aps-agent/tests/golden/test_session_ref.py

180 lines
7.4 KiB
Python
Raw Permalink 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.

# ============================================================
# 黄金测试:会话引用(plan.md §4.7 / 矩阵 63 行)
# 解析稳定性 / 按时点快照取值 / 来源删除显式失败 / 进入证据链
# ============================================================
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from server.agent_core.audit import write_audit
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 _store_data() -> dict:
"""工作区快照(与 ProjectStore.snapshot 结构一致 + 方案/版本/报告表)。"""
return {
"projects": [
{"id": "p1", "name": "青岛Q3排产", "scopeLabel": "工厂/车间", "archived": False},
],
"sessions": [
{"id": "sess_a", "projectId": "p1", "title": "比亚迪插单评估", "status": "done"},
{"id": "sess_personal", "projectId": "__personal__", "title": "草稿", "status": "running"},
],
"messages": {
"sess_a": [
{"role": "user", "text": "评估比亚迪插单"},
{"role": "agent", "text": "结论:可插单,交期优先"},
],
},
"scenarios": [
{"scenarioId": "scen_1", "strategy": "交期优先", "kpi": {"avgUtilization": 0.92}},
],
"scheduleVersions": [
{"id": "V20260716-003", "name": "V20260716-003", "summary": "基准版本", "planCount": 12},
],
"reports": [
{"reportId": "rep_1", "title": "上周复盘", "markdown": "插单流程需优化"},
],
}
def test_parse_refs_stable_kinds_and_dedup():
text = (
"按 @会话:比亚迪插单评估 的结论重排本周计划,"
"参考 @方案:交期优先-v2 与 @版本:V20260716-003、@报告:上周复盘;"
"再提一次 @会话:比亚迪插单评估。"
)
refs = parse_refs(text)
assert [(r.kind, r.target_id) for r in refs] == [
("session", "比亚迪插单评估"),
("scenario", "交期优先-v2"),
("version", "V20260716-003"),
("report", "上周复盘"),
]
# 稳定:相同输入 -> 相同输出(顺序、去重、ref_id 均确定)
again = parse_refs(text)
assert [(r.ref_id, r.raw) for r in refs] == [(r.ref_id, r.raw) for r in again]
assert refs[0].ref_id == "session:比亚迪插单评估"
# 无引用/非引用 @ 不产生误解析
assert parse_refs("直接重排,没有引用") == []
assert parse_refs("联系邮箱 a@b.com 和 @xxx:空") == []
def test_resolve_ref_takes_time_point_snapshot():
sd = _store_data()
resolved = resolve_ref(sd, "session", "sess_a", now=FIXED_NOW)
assert resolved.kind == "session"
assert resolved.snapshot_at == "2026-08-02 10:30"
assert resolved.value["session"]["status"] == "done"
assert len(resolved.value["messages"]) == 2
# 被引会话后续变化不影响已取快照(按时点取值硬规则)
sd["sessions"][0]["status"] = "archived"
sd["messages"]["sess_a"].append({"role": "agent", "text": "后续新结论"})
assert resolved.value["session"]["status"] == "done"
assert len(resolved.value["messages"]) == 2
# 版本引用同样按时点快照
version = resolve_ref(sd, "version", "V20260716-003", now=FIXED_NOW)
assert version.value["planCount"] == 12
sd["scheduleVersions"][0]["planCount"] = 999
assert version.value["planCount"] == 12
def test_resolve_kind_value_shapes():
sd = _store_data()
scenario = resolve_ref(sd, "scenario", "scen_1", now=FIXED_NOW)
assert scenario.value["strategy"] == "交期优先"
assert scenario.value["kpi"]["avgUtilization"] == 0.92
report = resolve_ref(sd, "report", "rep_1", now=FIXED_NOW)
assert report.value["title"] == "上周复盘"
# 来源信息随解析结果携带(出处可查)
assert resolve_ref(sd, "session", "sess_a", now=FIXED_NOW).source["messageCount"] == 2
def test_resolve_ref_explicit_failure_when_source_deleted_or_inaccessible():
sd = _store_data()
# 来源删除:目标不在可见会话列表
with pytest.raises(RefResolutionError) as exc:
resolve_ref(sd, "session", "sess_deleted", now=FIXED_NOW)
assert exc.value.kind == "session"
assert "deleted" in exc.value.reason
# 无权访问:会话归属项目已归档
sd["projects"][0]["archived"] = True
with pytest.raises(RefResolutionError) as exc:
resolve_ref(sd, "session", "sess_a", now=FIXED_NOW)
assert "no-access" in exc.value.reason
sd["projects"][0]["archived"] = False
# 各 kind 目标缺失均显式失败,不泛化掩盖
for kind, target in (("scenario", "scen_missing"), ("version", "V0000"), ("report", "rep_missing")):
with pytest.raises(RefResolutionError) as exc:
resolve_ref(sd, kind, target, now=FIXED_NOW)
assert exc.value.kind == kind
assert exc.value.target_id == target
# @知识 已是合法 kind(矩阵 63 行):未注入知识查找回调必须显式报错(不静默、不编造)
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
# 真正的未知 kind 仍是参数级拒绝
with pytest.raises(ValueError):
resolve_ref(sd, "bogus", "x", now=FIXED_NOW)
def test_resolved_refs_enter_evidence_chain_via_write_audit():
sd = _store_data()
refs = parse_refs("按 @会话:sess_a 结论重排,以 @版本:V20260716-003 为基准")
resolved, evidence_refs = resolve_all(sd, refs, now=FIXED_NOW)
assert len(resolved) == 2
assert evidence_refs == ["session:sess_a", "version:V20260716-003"]
# 证据链:evidenceRefs 进入审计事件(write_audit 原样落库)
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": "session", "id": "sess_a"},
power="P0",
rationale={"intent": "引用方案重排", "refCount": len(evidence_refs)},
evidence_refs=evidence_refs,
)
assert event["evidenceRefs"] == ["session:sess_a", "version:V20260716-003"]
assert world["auditEvents"][-1]["hash"] == event["hash"]
# 引用可追溯:kind:target 可直接回查解析结果
assert [r.ref for r in resolved] == event["evidenceRefs"]
def test_four_kinds_remain_compatible_with_knowledge_lookup_param():
"""新增 knowledge_lookup 参数不影响 4 类既有引用(向后兼容)。"""
sd = _store_data()
refs = parse_refs(
"按 @会话:sess_a 结论,以 @方案:scen_1、@版本:V20260716-003、@报告:rep_1 为输入")
resolved, evidence_refs = resolve_all(sd, refs, now=FIXED_NOW,
knowledge_lookup=lambda q: None)
assert [r.kind for r in resolved] == ["session", "scenario", "version", "report"]
assert evidence_refs == [
"session:sess_a", "scenario:scen_1", "version:V20260716-003", "report:rep_1",
]
# 不含 @知识 时,不传 knowledge_lookup 与传入等价(无副作用)
resolved2, evidence2 = resolve_all(sd, refs, now=FIXED_NOW)
assert evidence2 == evidence_refs