aps-agent/tests/golden/test_ref_knowledge.py

275 lines
12 KiB
Python
Raw Normal View History

# -*- 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={"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={"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"