276 lines
12 KiB
Python
276 lines
12 KiB
Python
# -*- 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:<assetId>
|
||
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"
|