feat(#86): 端到端联调用例编写(数据流 + 问答流 + 跨链路闭环)
新增 tests/e2e/ 端到端联调测试套件,覆盖四个 iAOP-Core 内核模块的全链路协作: 数据流(PRD 5.1→5.2): - edge-gateway 只读采集(模拟驱动)→ spool 断点续传 → data-bus 批量写入 - 验证不丢不重(幂等去重)、样本字段完整、健康度满足 SLA 问答流(PRD 5.4): - rag-kb 模板化知识库检索(命中片段+来源)→ llm-gateway 混合网关 - 验证敏感度路由、DLP 拦截、幻觉溯源校验、审计可追溯 跨链路:采集→落库→知识沉淀→安全问答业务闭环 共 14 个用例,全部基于可注入接口运行,零外部依赖(CI 可直接执行)。 运行:python -m unittest discover -s tests/e2e -v
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""端到端联调用例(Issue #86)—— 问答流链路。
|
||||
|
||||
验证 PRD 5.4(LLM 网关 + RAG)的端到端协作:
|
||||
rag-kb 模板化知识库检索(命中文档片段 + 来源)→ llm-gateway 混合网关
|
||||
(路由 → 生成 → 溯源校验 → DLP 出站防线)。
|
||||
|
||||
不依赖真实 LLM / 向量库:全部走内存桩,可在 CI 直接执行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# 引导加载四个内核模块(必须在测试导入前执行)
|
||||
import tests.e2e._bootstrap # noqa: F401
|
||||
|
||||
import unittest
|
||||
|
||||
from rag_kb import RagKnowledgeBase, build_document
|
||||
from llm_gateway import (
|
||||
DLP_DEFAULT_RULES,
|
||||
CloudBackend,
|
||||
DlpEngine,
|
||||
GatewayResult,
|
||||
HallucinationGuard,
|
||||
LLMGateway,
|
||||
LocalBackend,
|
||||
PromptRegistry,
|
||||
RouteTarget,
|
||||
SensitivityRouter,
|
||||
)
|
||||
|
||||
|
||||
def _build_kb() -> RagKnowledgeBase:
|
||||
"""构造一个模板化知识库:工艺规范 / SOP / 国标 三类知识源。"""
|
||||
kb = RagKnowledgeBase()
|
||||
docs = [
|
||||
build_document(
|
||||
title="氯化车间操作规程",
|
||||
text="氯化车间 1#炉正常运行温度区间 850~920℃,超过 950℃ 属于超温,"
|
||||
"应立即减少通氯量并检查冷却系统。停机检修须挂牌上锁。",
|
||||
category="process",
|
||||
),
|
||||
build_document(
|
||||
title="海绵钛氯化工序 SOP",
|
||||
text="氯化工序标准作业指导书:开机前确认氯气流量计归零,"
|
||||
"升温阶段按 50℃/h 速率升温至 850℃。异常停机时按紧急停机程序处置。",
|
||||
category="sop",
|
||||
),
|
||||
build_document(
|
||||
title="工业氯化工艺国家标准",
|
||||
text="GB/T XXXX 工业氯化工艺安全规范:氯化炉设计压力不低于 0.6MPa,"
|
||||
"操作人员持证上岗,关键参数实时记录留存不少于 3 年。",
|
||||
category="standard",
|
||||
),
|
||||
]
|
||||
kb.add_documents(docs)
|
||||
return kb
|
||||
|
||||
|
||||
def _build_gateway() -> LLMGateway:
|
||||
"""构造一个可运行的混合网关:注册 qa 提示词 + 默认 DLP/路由/校验。"""
|
||||
prompts = PromptRegistry()
|
||||
prompts.update(
|
||||
name="qa",
|
||||
version="1.0.0",
|
||||
text="你是工业 AI 优化助手。基于知识库回答:{query}",
|
||||
description="问答主提示词 v1.0.0",
|
||||
)
|
||||
return LLMGateway(
|
||||
dlp=DlpEngine(),
|
||||
router=SensitivityRouter(),
|
||||
prompts=prompts,
|
||||
guard=HallucinationGuard(),
|
||||
local=LocalBackend(echo_context=True),
|
||||
cloud=CloudBackend(echo_context=True),
|
||||
prompt_name="qa",
|
||||
)
|
||||
|
||||
|
||||
class QueryPipelineE2ETest(unittest.TestCase):
|
||||
"""RAG 检索 → LLM 网关编排 全链路联调。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.kb = _build_kb()
|
||||
cls.gateway = _build_gateway()
|
||||
|
||||
# -- RAG 检索 ----------------------------------------------------------
|
||||
def test_kb_retrieval_returns_relevant_chunks_with_source(self) -> None:
|
||||
"""检索命中文档片段并带来源(引用溯源基础)。"""
|
||||
hits = self.kb.search("氯化炉温度", top_k=3)
|
||||
self.assertGreater(len(hits), 0)
|
||||
# 命中片段必须携带来源信息(文档标题 / 类别)
|
||||
for hit in hits:
|
||||
self.assertIsNotNone(hit.chunk.title)
|
||||
self.assertIn(hit.chunk.category, ("process", "sop", "standard"))
|
||||
# 相关片段应命中"温度"相关内容
|
||||
joined = " ".join(h.chunk.text for h in hits)
|
||||
self.assertIn("温度", joined)
|
||||
|
||||
def test_kb_category_filter(self) -> None:
|
||||
"""类别过滤:仅检索 SOP 知识源。"""
|
||||
from rag_kb import KnowledgeSourceKind
|
||||
hits = self.kb.search("升温", top_k=5, categories=[KnowledgeSourceKind.SOP])
|
||||
self.assertGreater(len(hits), 0)
|
||||
for hit in hits:
|
||||
self.assertEqual(hit.chunk.category, "sop")
|
||||
|
||||
# -- 问答闭环(普通问题 → 本地后端)------------------------------------
|
||||
def test_normal_question_routes_local_with_rag_context(self) -> None:
|
||||
"""普通工艺问题:路由到本地后端,回答含 RAG 来源引用。"""
|
||||
query = "氯化车间 1#炉的正常运行温度是多少?"
|
||||
hits = self.kb.search(query, top_k=3)
|
||||
sources = [h.chunk.title for h in hits]
|
||||
|
||||
result = self.gateway.ask(query, rag_context=sources, confidence=0.95)
|
||||
|
||||
self.assertIsInstance(result, GatewayResult)
|
||||
self.assertEqual(result.route.target, RouteTarget.LOCAL)
|
||||
# 本地后端 echo_context 时输出含来源标记
|
||||
self.assertGreater(len(result.answer), 0)
|
||||
if sources:
|
||||
self.assertIn(sources[0], result.answer)
|
||||
# 溯源校验通过(有来源支撑)
|
||||
self.assertTrue(result.verdict.supported)
|
||||
self.assertFalse(result.needs_human)
|
||||
|
||||
# -- 敏感数据 DLP 拦截(fail-closed)-----------------------------------
|
||||
def test_sensitive_query_blocked_by_dlp(self) -> None:
|
||||
"""含敏感数据的问题被 DLP 拦截 → 路由 block → 转人工。"""
|
||||
# 身份证号(DLP 默认规则命中)
|
||||
query = "请查询员工 110101199003078834 的工资"
|
||||
result = self.gateway.ask(query, confidence=1.0)
|
||||
|
||||
self.assertEqual(result.route.target, RouteTarget.BLOCK)
|
||||
self.assertTrue(result.needs_human)
|
||||
# block 时不调用后端,给出人工确认占位
|
||||
self.assertIn("人工", result.answer)
|
||||
|
||||
# -- 脱敏/通用问题路由到云端 ------------------------------------------
|
||||
def test_generic_question_routes_cloud(self) -> None:
|
||||
"""通用(非敏感)问题经 DLP 放行后可路由云端(这里默认路由 local,
|
||||
需显式配置 cloud 规则才走云端)。验证默认 local 闭环正常。"""
|
||||
query = "今天的天气如何?"
|
||||
result = self.gateway.ask(query, confidence=0.9)
|
||||
# 默认无规则命中 → local
|
||||
self.assertEqual(result.route.target, RouteTarget.LOCAL)
|
||||
self.assertFalse(result.needs_human)
|
||||
|
||||
# -- 高利害低信度转人工 ------------------------------------------------
|
||||
def test_high_stakes_low_confidence_to_human(self) -> None:
|
||||
"""高利害提示词 + 低信度 → 转人工复核(幻觉防线)。"""
|
||||
prompts = PromptRegistry()
|
||||
prompts.update(name="alarm_explain", version="1.0.0",
|
||||
text="解释报警:{query}", description="报警解释(高利害)")
|
||||
gateway = LLMGateway(
|
||||
prompts=prompts,
|
||||
prompt_name="alarm_explain",
|
||||
high_stakes_names=["alarm_explain"],
|
||||
)
|
||||
result = gateway.ask("1#炉超温报警", rag_context=["氯化车间操作规程"],
|
||||
confidence=0.3) # 低信度
|
||||
self.assertTrue(result.needs_human)
|
||||
self.assertEqual(result.verdict.action, "human_review")
|
||||
|
||||
# -- 审计可追溯 --------------------------------------------------------
|
||||
def test_audit_drain_after_query(self) -> None:
|
||||
"""每次 ask() 后各组件审计记录可统一导出(DLP/路由/Prompt/幻觉)。"""
|
||||
self.gateway.ask("氯化炉温度区间", confidence=0.9)
|
||||
audits = self.gateway.drain_audits()
|
||||
self.assertIn("dlp", audits)
|
||||
self.assertIn("router", audits)
|
||||
self.assertIn("prompts", audits)
|
||||
self.assertIn("guard", audits)
|
||||
# 路由审计应有记录
|
||||
self.assertGreater(len(audits["router"]), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user