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:
2026-08-04 18:26:00 +08:00
parent 691a812fcf
commit 33e64fe18b
6 changed files with 602 additions and 0 deletions
+179
View File
@@ -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()