feat(v2): T10 轨迹回放与会议结果复盘——只读轨迹、unmatched 单列、更正追加

覆盖 AC-22/32/33(BR-13/21/31/32、E-05/23/26)。

后端:
- contracts/trajectory.py:TrajectoryStep 与 ReviewOutcome。
  TrajectoryStep 失败必有错误码、成本未知时币种留空、observable() 给出
  可读字段白名单(机器可读地排除任何推理过程字段);
  ReviewOutcome 只在 matched 且有明确映射口径时才有二元标签,附条件/撤回/
  未送审一律留空,unmatched 不得挂快照。
- services/trajectory.py:
  · project_steps 把既有 run 的步骤投影成轨迹(顺序即 sequence,部分完成
    如实标注,失败保留,成本缺省留空不填 0);
  · replay 只读回放并按 sequence 稳定分页,不存在或跨项目抛 KeyError;
  · record 追加会议结果:未知快照记 unmatched,更正要指向本项目既有记录,
    链必须能终止;denominator 只数 matched 且有通过/否决口径的记录。
- api/prescreen.py:新增轨迹回放、会议结果列表与追加三个端点。

未做:轨迹写入仍由既有 DurableWorker.save_step 负责(本批不重做运行执行),
因此目前没有 judgment/rule 类步骤的产出方;会议标签映射(E-26)仍属 T11。

验证:backend 全量 unittest 494 项 OK(T09 时 469 项,无回归)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:19:33 +08:00
co-authored by Claude Code
parent 22f8a37799
commit f2bbac03e7
4 changed files with 641 additions and 1 deletions
+288
View File
@@ -0,0 +1,288 @@
"""T10 轨迹回放与会议结果复盘测试。
覆盖 AC-32(一个 run 多 step、失败保留、可追溯、不伪造隐藏推理)
与 AC-33(unmatched 单列、更正追加并引用旧版、不改原预测),
以及 06 §5 要求的"成本未知不得伪造"。
同时验证只读性:回放与追加会议结果都不改动既有 run。
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.testclient import TestClient
from api import prescreen as api
from contracts.trajectory import ReviewOutcome, TrajectoryStep
from services import assistant_store as store
from services import trajectory
from services.project_assistant import ProjectAssistantService
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
PROJECT = "DEMO-B"
def step(step_id, status="completed", **over):
body = {"run_id": "run-1", "step_id": step_id, "name": f"步骤 {step_id}",
"skill": {"id": step_id, "version": "1.0.0"}, "status": status,
"input_snapshot_id": "snap-1", "started_at": "2026-09-30T01:00:00+00:00",
"finished_at": "2026-09-30T01:00:03+00:00", "attempt": 1,
"outputs": {"requirement_response": "已核查"},
"findings": [{"code": f"{step_id}.check", "result": "issue",
"evidence_ids": ["EV-1"]}],
"errors": [], "missing_inputs": [], "output_sources": {}}
body.update(over)
return body
def run_body(steps=None, **over):
body = {"id": "run-1", "project_id": PROJECT, "status": "completed",
"input_snapshot_id": "snap-1", "revision": 1,
"created_at": "2026-09-30T01:00:00+00:00",
"steps": steps if steps is not None else [step("corporate.cashflow")]}
body.update(over)
return body
class TrajectoryProjectionTests(unittest.TestCase):
def test_one_run_becomes_one_trajectory_with_many_steps(self):
rows = trajectory.project_steps(run_body(steps=[
step("corporate.cashflow"), step("corporate.title"),
step("corporate.deal", status="failed",
errors=[{"code": "SKILL_TIMEOUT", "message": "模型超时"}])]))
self.assertEqual([item["sequence"] for item in rows], [0, 1, 2])
self.assertEqual({item["run_id"] for item in rows}, {"run-1"})
self.assertEqual(len({item["id"] for item in rows}), 3)
def test_failed_step_is_preserved_with_a_code(self):
rows = trajectory.project_steps(run_body(steps=[
step("a"), step("b", status="failed",
errors=[{"code": "SKILL_TIMEOUT", "message": "超时"}])]))
failed = [item for item in rows if item["status"] == "failed"]
self.assertEqual(len(failed), 1, "失败的步骤不得被过滤掉")
self.assertEqual(failed[0]["error_code"], "SKILL_TIMEOUT")
def test_failed_step_without_error_detail_still_carries_a_code(self):
rows = trajectory.project_steps(run_body(steps=[step("b", status="failed")]))
self.assertTrue(rows[0]["error_code"], "失败必须留痕,不能只有一条看不出原因的记录")
def test_partial_is_not_reported_as_fully_checked(self):
rows = trajectory.project_steps(run_body(steps=[step("a", status="partial",
missing_inputs=["银行流水"])]))
self.assertIn("部分完成", rows[0]["reason_summary"])
self.assertIn("银行流水", rows[0]["reason_summary"])
def test_steps_expose_only_observable_fields(self):
rows = trajectory.project_steps(run_body())
allowed = set(TrajectoryStep.model_fields)
for row in rows:
self.assertTrue(set(row) <= allowed)
for banned in ("thought", "reasoning", "chain_of_thought", "scratchpad",
"hidden_state", "prompt"):
self.assertNotIn(banned, rows[0], "轨迹不得包含模型内部推理字段")
def test_cost_is_unknown_not_zero(self):
rows = trajectory.project_steps(run_body())
self.assertIsNone(rows[0]["cost"], "没有成本口径时应留空,不得填 0 或估计值")
self.assertIsNone(rows[0]["cost_currency"])
def test_evidence_and_versions_are_traceable(self):
rows = trajectory.project_steps(run_body())
self.assertEqual(rows[0]["evidence_ids"], ["EV-1"])
self.assertEqual(rows[0]["component_ref"], {"id": "corporate.cashflow",
"version": "1.0.0"})
self.assertEqual(rows[0]["input_refs"], ["snap-1"])
class TrajectoryStoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
with store.transaction(self.service.db_path) as db:
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed")]))
def tearDown(self):
self.temp.cleanup()
def test_replay_is_read_only_and_paginated(self):
with store.transaction(self.service.db_path) as db:
before = store.get(db, "run", "run-1")
page = trajectory.replay(db, PROJECT, "run-1", limit=1)
after = store.get(db, "run", "run-1")
self.assertEqual(len(page["steps"]), 1)
self.assertEqual(page["next_cursor"], "1")
self.assertTrue(page["read_only"])
self.assertEqual(before, after, "回放不得改动运行")
def test_replay_rejects_foreign_project(self):
with store.transaction(self.service.db_path) as db:
with self.assertRaises(KeyError):
trajectory.replay(db, "OTHER-9", "run-1")
class ReviewOutcomeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
def tearDown(self):
self.temp.cleanup()
def record(self, **over):
body = {"raw_code": "通过", "meeting_id": "M-1",
"occurred_at": "2026-09-29T02:00:00+00:00",
"reviewed_snapshot_id": "snap-1", "evidence_ids": ["EV-1"]}
body.update(over)
with store.transaction(self.service.db_path) as db:
return trajectory.record(db, PROJECT, body, "reviewer-1",
known_snapshots=["snap-1"])
def test_unknown_snapshot_is_unmatched_and_has_no_label(self):
saved = self.record(reviewed_snapshot_id=None)
self.assertEqual(saved["status"], "unmatched")
self.assertIsNone(saved["label"], "未匹配材料版本时不得给出二元标签")
self.assertIsNone(saved["reviewed_snapshot_id"])
def test_conditional_is_never_labeled_as_approved(self):
saved = self.record(raw_code="附条件通过")
self.assertEqual(saved["normalized"], "conditional")
self.assertIsNone(saved["label"], "附条件不等于通过(BR-21)")
def test_withdrawn_is_recorded_but_excluded_from_denominator(self):
self.record(raw_code="撤回")
self.record(raw_code="通过")
with store.transaction(self.service.db_path) as db:
listed = trajectory.listing(db, PROJECT)
self.assertEqual(listed["counts_toward_denominator"]["observed"], 1)
excluded = listed["counts_toward_denominator"]["excluded"]
self.assertEqual([item[1] for item in excluded], ["撤回"])
def test_correction_appends_and_references_the_old_record(self):
first = self.record(raw_code="通过")
corrected = self.record(raw_code="否决", supersedes_id=first["id"])
self.assertEqual(corrected["supersedes_id"], first["id"])
self.assertGreater(corrected["revision"], first["revision"])
with store.transaction(self.service.db_path) as db:
rows = trajectory.listing(db, PROJECT)["items"]
self.assertEqual(len(rows), 2, "更正必须追加,不得覆盖旧记录")
self.assertEqual(rows[0]["raw_code"], "通过")
def test_correction_must_point_to_an_existing_record_of_the_project(self):
with self.assertRaises(ValueError):
self.record(supersedes_id="outcome-missing")
def test_supersede_chains_always_terminate(self):
first = self.record(raw_code="通过")
second = self.record(raw_code="否决", supersedes_id=first["id"])
self.record(raw_code="附条件", supersedes_id=second["id"])
with store.transaction(self.service.db_path) as db:
rows = trajectory.listing(db, PROJECT)["items"]
by_id = {item["id"]: item for item in rows}
for item in rows:
seen, current = set(), item["id"]
while current:
self.assertNotIn(current, seen, "更正链不得出现循环")
seen.add(current)
current = (by_id.get(current) or {}).get("supersedes_id")
self.assertEqual(len(rows), 3)
def test_missing_raw_code_is_rejected(self):
with self.assertRaises(ValueError):
self.record(raw_code="")
def test_recording_never_touches_runs(self):
with store.transaction(self.service.db_path) as db:
store.put(db, "run", run_body())
before = None
with store.transaction(self.service.db_path) as db:
before = store.get(db, "run", "run-1")
self.record(raw_code="否决")
with store.transaction(self.service.db_path) as db:
after = store.get(db, "run", "run-1")
self.assertEqual(before, after, "会议结果不得改写历史预测")
def test_contract_refuses_label_without_mapping(self):
with self.assertRaises(ValueError):
ReviewOutcome(id="o-1", project_id=PROJECT, meeting_id="M-1", revision=1,
occurred_at="2026-09-29T02:00:00+00:00",
recorded_at="2026-09-29T02:00:00+00:00",
reviewed_snapshot_id="snap-1", raw_code="通过",
normalized="approved", label=True, status="matched",
recorded_by="reviewer-1")
class TrajectoryApiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
self.patches = [
patch.object(api, "_service", lambda: self.service),
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
"XINGBANG_LOCAL_MODEL_URL": ""}),
]
for item in self.patches:
item.start()
app = FastAPI()
app.add_exception_handler(RequestValidationError, api.validation_handler)
app.include_router(api.router)
self.client = TestClient(app)
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
with store.transaction(self.service.db_path) as db:
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed",
errors=[{"code": "SKILL_TIMEOUT"}])]))
def tearDown(self):
self.client.close()
for item in reversed(self.patches):
item.stop()
self.temp.cleanup()
def test_trajectory_replay_over_http(self):
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-1/trajectory",
headers=HEADERS)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual([item["status"] for item in body["steps"]],
["completed", "failed"])
self.assertTrue(body["read_only"])
def test_trajectory_of_missing_run_is_404(self):
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-x/trajectory",
headers=HEADERS)
self.assertEqual(response.status_code, 404)
def test_outcome_round_trip(self):
created = self.client.post(
f"{self.base}/projects/{PROJECT}/review-outcomes",
json={"raw_code": "附条件通过", "meeting_id": "M-1"},
headers=HEADERS)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.json()["status"], "unmatched")
self.assertIsNone(created.json()["label"])
listed = self.client.get(f"{self.base}/projects/{PROJECT}/review-outcomes",
headers=HEADERS)
self.assertEqual(len(listed.json()["unmatched"]), 1)
self.assertEqual(listed.json()["counts_toward_denominator"]["observed"], 0)
def test_outcome_without_raw_code_is_rejected(self):
response = self.client.post(
f"{self.base}/projects/{PROJECT}/review-outcomes",
json={"meeting_id": "M-1"}, headers=HEADERS)
self.assertEqual(response.status_code, 422)
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
def test_unknown_outcome_status_filter_is_rejected(self):
response = self.client.get(
f"{self.base}/projects/{PROJECT}/review-outcomes?status=weird",
headers=HEADERS)
self.assertEqual(response.status_code, 422)
if __name__ == "__main__":
unittest.main()