aps-agent/tests/golden/test_ref_knowledge.py

276 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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