# -*- coding: utf-8 -*- # ============================================================ # 黄金测试:@知识 结构化引用(plan.md §4.7 / 矩阵 63 行剩余项) # RAG 强制命中(正文+出处)/ 未命中显式 RefResolutionError(中文,不编造)/ # 未接线显式报错 / 证据链注入(write_audit evidenceRefs)/ # 接线到对话链路自动触发(handle_intent,fail closed) # ============================================================ from __future__ import annotations import asyncio from datetime import UTC, datetime import pytest from server.agent_core.session_ref import ( RefResolutionError, parse_refs, resolve_all, resolve_ref, ) FIXED_NOW = datetime(2026, 8, 2, 10, 30, tzinfo=UTC) def _asset() -> dict: """一张独立的知识资产(每测试新建,避免跨测试污染)。""" return { "assetId": "kn_001", "kind": "sop", "title": "换线标准SOP", "content": "产线换型必须遵守:① 同产品族连续生产优先;② 换线作业标准 30 分钟。", "tags": ["换线", "SOP"], "version": "v1", "approved": True, } def _knowledge_lookup(asset: dict): """注入型知识查找回调:assetId 精确命中或标题包含即返回,否则 None。""" def lookup(target: str): t = target.strip() if t == asset.get("assetId"): return asset if t and (t in (asset.get("title") or "")): return asset return None return lookup def _store_data() -> dict: """工作区快照(知识不走 store_data,只走注入回调;会话用于混排验证)。""" return { "sessions": [{"id": "sess_a", "projectId": "__personal__", "title": "会话A"}], "messages": {"sess_a": [{"role": "user", "text": "hi"}]}, } def _mem_store(world: dict): """最小内存 store:满足 handle_intent 对 data/next_id/save 的依赖。""" class _MemStore: def __init__(self, data): self.data = data def next_id(self, kind: str) -> int: key = f"_c_{kind}" self.data[key] = self.data.get(key, 0) + 1 return self.data[key] def save(self): pass return _MemStore(world) # ---------------- 解析:@知识 与既有引用同语法、稳定去重 ---------------- def test_parse_refs_knowledge_kind_stable_and_dedup(): refs = parse_refs("按 @知识:换线标准SOP 执行,再查 @知识:kn_002 与 @知识:换线标准SOP") assert [(r.kind, r.target_id) for r in refs] == [ ("knowledge", "换线标准SOP"), ("knowledge", "kn_002"), ] assert refs[0].ref_id == "knowledge:换线标准SOP" # 与 4 类既有引用混排、顺序稳定 mixed = parse_refs("参考 @会话:sess_a 与 @知识:换线标准SOP、@方案:交期优先-v2") assert [(r.kind, r.target_id) for r in mixed] == [ ("session", "sess_a"), ("knowledge", "换线标准SOP"), ("scenario", "交期优先-v2"), ] again = parse_refs("参考 @会话:sess_a 与 @知识:换线标准SOP、@方案:交期优先-v2") assert [(r.ref_id, r.raw) for r in mixed] == [(r.ref_id, r.raw) for r in again] # ---------------- 解析命中:正文 + 出处 + 时点快照 ---------------- def test_resolve_knowledge_hit_returns_content_provenance_and_snapshot(): sd = _store_data() resolved = resolve_ref(sd, "knowledge", "换线标准SOP", now=FIXED_NOW, knowledge_lookup=_knowledge_lookup(_asset())) assert resolved.kind == "knowledge" assert resolved.snapshot_at == "2026-08-02 10:30" assert resolved.value["content"] == _asset()["content"] # 资产正文 assert resolved.source == { "table": "knowledge", "assetId": "kn_001", "title": "换线标准SOP", "kind": "sop", "version": "v1", "query": "换线标准SOP", } # 出处 + 原始请求 assert resolved.ref == "knowledge:kn_001" # 稳定引用 = 资产 ID # 按时点快照:知识库后续变化不影响已得结论 asset = _asset() hit = resolve_ref(sd, "knowledge", "kn_001", now=FIXED_NOW, knowledge_lookup=_knowledge_lookup(asset)) asset["content"] = "被后续导入覆盖的新内容" assert hit.value["content"] != asset["content"] # ---------------- 解析未命中:显式 RefResolutionError(不编造) ---------------- def test_resolve_knowledge_miss_is_explicit_chinese_failure(): sd = _store_data() with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, "knowledge", "不存在的资产XYZ", now=FIXED_NOW, knowledge_lookup=_knowledge_lookup(_asset())) assert exc.value.kind == "knowledge" assert exc.value.target_id == "不存在的资产XYZ" assert "知识库未命中" in exc.value.reason assert "未编造" in exc.value.reason # ---------------- 未接线:默认 knowledge_lookup=None 显式报错 ---------------- def test_resolve_knowledge_without_lookup_fails_loudly(): sd = _store_data() with pytest.raises(RefResolutionError) as exc: resolve_ref(sd, "knowledge", "sop-1", now=FIXED_NOW) assert exc.value.kind == "knowledge" assert "not-wired" in exc.value.reason assert "未接线" in exc.value.reason # ---------------- 证据链:knowledge ref 进入 evidenceRefs ---------------- def test_resolve_all_evidence_chain_includes_knowledge(): from server.agent_core.audit import write_audit sd = _store_data() refs = parse_refs("按 @会话:sess_a 结论,并 @知识:换线标准SOP") resolved, evidence_refs = resolve_all(sd, refs, now=FIXED_NOW, knowledge_lookup=_knowledge_lookup(_asset())) assert evidence_refs == ["session:sess_a", "knowledge:kn_001"] world: dict = {"auditEvents": []} counter = iter(range(1, 100)) def next_id(_kind: str) -> int: return next(counter) event = write_audit( world, next_id, actor="planner", category="SESSION", action="session.ref.resolve", target={"type": "REF", "id": "|".join(evidence_refs)}, power="P0", rationale={"refCount": len(evidence_refs)}, evidence_refs=evidence_refs, ) assert event["evidenceRefs"] == ["session:sess_a", "knowledge:kn_001"] assert [r.ref for r in resolved] == event["evidenceRefs"] # 可回查解析结果 # ---------------- 对话链路接线:命中注入回复 + 证据链 + 上下文 ---------------- def test_handle_intent_wires_knowledge_ref_into_reply_and_evidence(monkeypatch, tmp_path): import server.aps_domain.workflow as wf from server.contracts import IntentResult from server.knowledge.assets import KnowledgeStore kb = KnowledgeStore(path=str(tmp_path / "kn.json")) kb.assets = [_asset()] kb._write() monkeypatch.setattr(wf, "get_knowledge", lambda: kb) store = _mem_store({"sessions": [{"id": "sess_a", "projectId": "__personal__", "title": "会话A"}]}) intent = IntentResult( intent="knowledge.query", params={"mode": "search", "query": "换线标准SOP", "assetTitle": "换线标准SOP", "_rawUserText": "查 @知识:换线标准SOP 的换线规定"}, confidence=1.0, source="RULE_FAST", ) reply = asyncio.run(wf.handle_intent(store, "s", intent)) # 命中:知识正文与出处进入本轮回复(不编造,带 assetId/title/version) assert "换线标准SOP" in reply.text assert "v1" in reply.text # 证据链:SESSION 审计 evidenceRefs 带 knowledge: audits = [a for a in store.data.get("auditEvents", []) if a["action"] == "session.ref.resolve"] assert audits and audits[-1]["evidenceRefs"] == ["knowledge:kn_001"] assert audits[-1]["result"] == "SUCCESS" # 本轮上下文:解析结果注入 intent(协议证据可展开) assert intent.params["_refs"]["evidenceRefs"] == ["knowledge:kn_001"] assert intent.params["_refs"]["sources"][0]["assetId"] == "kn_001" def test_handle_intent_mixed_refs_inject_evidence_chain(monkeypatch, tmp_path): """@会话 + @知识 混排:一次解析全部进入证据链,回复走原意图分支。""" import server.aps_domain.workflow as wf from server.contracts import IntentResult from server.knowledge.assets import KnowledgeStore kb = KnowledgeStore(path=str(tmp_path / "kn.json")) kb.assets = [_asset()] kb._write() monkeypatch.setattr(wf, "get_knowledge", lambda: kb) store = _mem_store({"sessions": [{"id": "sess_a", "projectId": "__personal__", "title": "会话A"}]}) intent = IntentResult( intent="help", params={"_rawUserText": "参考 @会话:sess_a 与 @知识:换线标准SOP 后下一步怎么办"}, confidence=1.0, source="RULE_FAST", ) reply = asyncio.run(wf.handle_intent(store, "s", intent)) assert "可执行的排产能力" in reply.text # 原意图分支正常 audits = [a for a in store.data.get("auditEvents", []) if a["action"] == "session.ref.resolve"] assert audits and audits[-1]["evidenceRefs"] == [ "session:sess_a", "knowledge:kn_001", ] assert intent.params["_refs"]["refs"] == [ "session:sess_a", "knowledge:kn_001", ] # ---------------- 对话链路接线:未命中 fail closed(明确中文说明) ---------------- def test_handle_intent_knowledge_miss_fails_closed(monkeypatch, tmp_path): import server.aps_domain.workflow as wf from server.contracts import IntentResult from server.knowledge.assets import KnowledgeStore kb = KnowledgeStore(path=str(tmp_path / "kn.json")) kb.assets = [_asset()] kb._write() monkeypatch.setattr(wf, "get_knowledge", lambda: kb) store = _mem_store({}) intent = IntentResult( intent="knowledge.query", params={"mode": "search", "query": "不存在的资产", "_rawUserText": "@知识:不存在的资产XYZ 请问规定"}, confidence=1.0, source="RULE_FAST", ) reply = asyncio.run(wf.handle_intent(store, "s", intent)) # 回复必须明确说明「知识库未命中」,不得假装命中 assert "知识库未命中" in reply.text assert "未编造" in reply.text audits = [a for a in store.data.get("auditEvents", []) if a["action"] == "session.ref.resolve"] assert audits and audits[-1]["result"] == "FAILED" assert audits[-1]["target"]["id"] == "knowledge:不存在的资产XYZ" # 未命中引用不进入证据链(不能假装命中) assert audits[-1].get("evidenceRefs") == [] def test_handle_intent_non_knowledge_ref_miss_fails_closed(): """4 类引用同样 fail closed:目标不存在时显式中文说明并停止本轮。""" import server.aps_domain.workflow as wf from server.contracts import IntentResult store = _mem_store({}) intent = IntentResult( intent="help", params={"_rawUserText": "@会话:不存在的会话X 帮我排产"}, confidence=1.0, source="RULE_FAST", ) reply = asyncio.run(wf.handle_intent(store, "s", intent)) assert "引用解析失败" in reply.text assert "不存在的会话X" in reply.text audits = [a for a in store.data.get("auditEvents", []) if a["action"] == "session.ref.resolve"] assert audits and audits[-1]["result"] == "FAILED" assert audits[-1]["target"]["id"] == "session:不存在的会话X"