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
+67 -1
View File
@@ -34,6 +34,7 @@ from api.project_assistant import enabled
from api.risk_agent_access import authorize, check_project
from contracts.prescreen import (
EVIDENCE_MAX,
ID_MAX,
MAX_BATCH_FILES,
NOTE_MAX,
ErrorCode,
@@ -41,7 +42,7 @@ from contracts.prescreen import (
S1_ERROR_RETRYABLE,
)
from services import assistant_store as store
from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store
from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store, trajectory
from services.assistant_settings import flags
from services.fact_conflict import FactConflictUnresolved
from services.project_assistant import ProjectAssistantService
@@ -91,6 +92,20 @@ class SimulationInput(Input):
expected_revision: int | None = Field(default=None, ge=1)
class OutcomeInput(Input):
"""T10 会议结果。原始结果码必填;未提供时不得推断成通过或否决。"""
raw_code: str = Field(min_length=1, max_length=100)
meeting_id: str = Field(min_length=1, max_length=ID_MAX)
normalized: str | None = Field(default=None, max_length=40)
occurred_at: str | None = None
reviewed_snapshot_id: str | None = Field(default=None, max_length=ID_MAX)
supersedes_id: str | None = Field(default=None, max_length=ID_MAX)
mapping_ref: dict | None = None
conditions: list[str] = Field(default_factory=list, max_length=50)
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
# ---------------------------------------------------------------- 错误信封
def _envelope(request: Request, code: ErrorCode, message: str) -> JSONResponse:
request_id = request.headers.get("x-request-id") or store.uid("req")
@@ -452,3 +467,54 @@ def create_simulation(project_id: str, run_id: str, payload: SimulationInput,
return result
return run(request, action)
# ---------------------------------------------------------------- 轨迹与会议结果(T10)
@router.get("/projects/{project_id}/runs/{run_id}/trajectory")
def get_trajectory(project_id: str, run_id: str, request: Request,
cursor: str | None = None, limit: int = 50):
"""只读轨迹回放。失败的步骤照常返回,不做美化过滤。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return trajectory.replay(db, project_id, run_id, cursor, _limit(limit))
return run(request, action)
@router.get("/projects/{project_id}/review-outcomes")
def list_review_outcomes(project_id: str, request: Request, status: str | None = None):
"""会议结果列表。unmatched 单列,不混进已观察通过率的分母。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
if status not in (None, "matched", "unmatched"):
return _envelope(request, ErrorCode.INVALID_REQUEST,
"状态取值须为 matched 或 unmatched")
def action():
with _db() as db:
return trajectory.listing(db, project_id, status)
return run(request, action)
@router.post("/projects/{project_id}/review-outcomes", status_code=201)
def record_review_outcome(project_id: str, payload: OutcomeInput, request: Request):
"""追加会议结果。找不到材料版本的一律记 unmatched;更正只能新增。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
runs = store.listing(db, "run", project_id)
snapshots = [item.get("input_snapshot_id") for item in runs
if item.get("input_snapshot_id")]
return trajectory.record(db, project_id, payload.model_dump(), _actor(request),
known_snapshots=snapshots)
return run(request, action)
+112
View File
@@ -0,0 +1,112 @@
"""T10 轨迹与会议结果契约。
Owner:本文件(docs/v2/03-detailed-design.md §9.1 的 TrajectoryStep / ReviewOutcome)。
`contracts/prescreen.py` 头部已明确把这两个对象排除在该文件之外,这里补上。
两条不可让渡的规则:
1. **只记录可观察动作**:步骤只保存计划、组件版本、输入输出引用、理由摘要与
错误码;**不存在**隐藏思维链字段,也不采集模型内部推理(BR-31 / AC-32)。
2. **结果追加,不覆盖**:会议结果只能新增并指向 `supersedes_id`,绝不改写既有
记录或历史预测(BR-13 / AC-33)。
"""
from __future__ import annotations
from typing import Literal
from pydantic import Field, model_validator
from contracts.prescreen import (
DESC_MAX,
EVIDENCE_MAX,
ID_MAX,
AwareDatetime,
Contract,
EventOutcome,
Ref,
)
STEP_KINDS = ("plan", "skill", "tool", "rule", "judgment", "synthesis")
STEP_STATUSES = ("queued", "running", "completed", "skipped", "failed", "cancelled")
class TrajectoryStep(Contract):
"""一次运行里的一个可观察步骤。`failed` 步骤保留而不是被过滤掉。"""
id: str = Field(min_length=1, max_length=ID_MAX)
run_id: str = Field(min_length=1, max_length=ID_MAX)
parent_step_id: str | None = Field(default=None, max_length=ID_MAX)
sequence: int = Field(ge=0)
kind: Literal["plan", "skill", "tool", "rule", "judgment", "synthesis"]
component_ref: Ref | None = None
input_refs: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
output_refs: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
status: Literal["queued", "running", "completed", "skipped", "failed", "cancelled"]
started_at: AwareDatetime | None = None
finished_at: AwareDatetime | None = None
reason_summary: str | None = Field(default=None, max_length=DESC_MAX)
error_code: str | None = Field(default=None, max_length=ID_MAX)
latency_ms: int | None = Field(default=None, ge=0)
cost: float | None = Field(default=None, ge=0)
cost_currency: str | None = Field(default=None, max_length=16)
@model_validator(mode="after")
def _check_step(self) -> TrajectoryStep:
if self.status == "failed" and not self.error_code:
raise ValueError("失败的步骤必须带错误码,不能只留一条看不出原因的记录")
if self.cost is None and self.cost_currency is not None:
raise ValueError("成本未知时币种也必须留空,不能只写币种")
if self.parent_step_id == self.id:
raise ValueError("步骤不能以自己为父步骤")
return self
def observable(self) -> dict:
"""对外可读字段白名单:排除任何推理过程字段(AC-32 的机器可读断言)。"""
body = self.model_dump(mode="json")
return {key: body[key] for key in (
"id", "run_id", "parent_step_id", "sequence", "kind", "component_ref",
"input_refs", "output_refs", "evidence_ids", "status", "started_at",
"finished_at", "reason_summary", "error_code", "latency_ms", "cost",
"cost_currency")}
class ReviewOutcome(Contract):
"""评审会结果记录。`label` 只在口径明确时才有值,未确认的一律为空。"""
id: str = Field(min_length=1, max_length=ID_MAX)
project_id: str = Field(min_length=1, max_length=ID_MAX)
meeting_id: str = Field(min_length=1, max_length=ID_MAX)
revision: int = Field(ge=1)
occurred_at: AwareDatetime
recorded_at: AwareDatetime
reviewed_snapshot_id: str | None = Field(default=None, max_length=ID_MAX)
raw_code: str = Field(min_length=1, max_length=100)
normalized: EventOutcome
mapping_ref: Ref | None = None
label: bool | None = None
conditions: list[str] = Field(default_factory=list, max_length=50)
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
status: Literal["matched", "unmatched"]
supersedes_id: str | None = Field(default=None, max_length=ID_MAX)
recorded_by: str = Field(min_length=1, max_length=ID_MAX)
@model_validator(mode="after")
def _check_outcome(self) -> ReviewOutcome:
if self.status == "unmatched" and self.reviewed_snapshot_id is not None:
raise ValueError("找不到对应材料版本时应记为 unmatched,不得挂上任何快照")
if self.status == "matched" and self.reviewed_snapshot_id is None:
raise ValueError("matched 必须指向被评审的材料版本")
if self.label is not None:
if self.mapping_ref is None or self.status != "matched":
raise ValueError("没有明确映射口径或材料版本未匹配时不得给出二元标签")
if self.normalized not in (EventOutcome.approved, EventOutcome.rejected):
raise ValueError("只有通过/否决才有二元标签;附条件、撤回、未送审一律留空")
if self.supersedes_id == self.id:
raise ValueError("更正不能指向自己")
return self
def counts_toward_denominator(self) -> bool:
"""未送审、撤回、附条件、未知都不进入已观察通过率的分母(AC-22/37)。"""
return self.status == "matched" and self.normalized in (
EventOutcome.approved, EventOutcome.rejected)
+174
View File
@@ -0,0 +1,174 @@
"""T10 轨迹回放与会议结果复盘。
依据:docs/v2/03-detailed-design.md §9.1/§9.4、14-experience-loop.md §7/§8;
BR-13/31/32、E-05/23/26、AC-22/32/33。
三条不可让渡的规则:
1. **只读回放**:轨迹从既有 run 的步骤投影出来,本模块不写 run,也不改历史结果。
2. **失败保留**:失败的步骤照样出现并带错误码,不因为"看起来不好"被过滤。
3. **结果追加**:会议结果只能新增;更正生成新记录并指向 `supersedes_id`,
找不到材料版本的一律记 unmatched 并单列,不当成否决、也不进通过率分母。
"""
from __future__ import annotations
from contracts.prescreen import EventOutcome
from contracts.trajectory import ReviewOutcome, TrajectoryStep
from services import assistant_store as store
KIND = "review_outcome"
DEFAULT_PAGE = 50
# 既有步骤状态 → 轨迹状态。partial 不是完成,保留原样说明,避免看起来"全过"。
_STATUS_MAP = {"running": "running", "completed": "completed", "partial": "completed",
"failed": "failed", "skipped": "skipped", "cancelled": "cancelled"}
_PARTIAL_NOTE = "部分完成(原始状态 partial);未完成的部分不计入已核查"
_FAILURE_CODE_FALLBACK = "RUN_STEP_FAILED"
RAW_ALIASES = {
"通过": EventOutcome.approved, "同意": EventOutcome.approved,
"否决": EventOutcome.rejected, "未通过": EventOutcome.rejected,
"附条件": EventOutcome.conditional, "附条件通过": EventOutcome.conditional,
"暂缓": EventOutcome.deferred, "撤回": EventOutcome.withdrawn,
"未送审": EventOutcome.not_submitted,
}
def _step(run_body: dict, raw: dict, index: int) -> TrajectoryStep:
status = _STATUS_MAP.get(str(raw.get("status")), "skipped")
errors = raw.get("errors") or ()
notes = []
if raw.get("status") == "partial":
notes.append(_PARTIAL_NOTE)
if raw.get("missing_inputs"):
notes.append("缺证:" + "、".join(str(item) for item in raw["missing_inputs"]))
skill = raw.get("skill") or {}
component = {"id": skill["id"], "version": str(skill.get("version", ""))} if skill.get("id") else None
return TrajectoryStep(
id=str(raw.get("step_id")), run_id=str(raw.get("run_id") or run_body.get("id")),
parent_step_id=None, sequence=index,
kind="skill" if raw.get("skill") else "tool" if raw.get("tool") else "plan",
component_ref=component,
input_refs=[str(raw.get("input_snapshot_id"))] if raw.get("input_snapshot_id") else [],
output_refs=sorted(str(key) for key in (raw.get("outputs") or {})),
evidence_ids=sorted({str(item) for finding in raw.get("findings") or ()
for item in (finding.get("evidence_ids") or ())}),
status=status,
started_at=raw.get("started_at"), finished_at=raw.get("finished_at"),
reason_summary=";".join(notes) or None,
error_code=(str(errors[0].get("code")) if errors and errors[0].get("code")
else _FAILURE_CODE_FALLBACK) if status == "failed" else None,
latency_ms=raw.get("latency_ms"), cost=None, cost_currency=None)
# 成本缺省一律留空:既有运行没有成本口径时不得填 0 或估计值(06 §5)。
def project_steps(run_body: dict) -> list[dict]:
"""把既有 run 的步骤投影成轨迹步骤,顺序即 sequence,不改写原 run。"""
return [_step(run_body, raw, index).model_dump(mode="json")
for index, raw in enumerate(run_body.get("steps") or ())]
def replay(db, project_id: str, run_id: str, cursor: str | None = None,
limit: int = DEFAULT_PAGE) -> dict:
"""只读回放:按 sequence 稳定分页。不存在或跨项目 → KeyError(接口层转 404)。"""
run = store.get(db, "run", run_id)
if run.get("project_id") != project_id:
raise KeyError("运行不存在或不属于该项目")
steps = project_steps(run)
start = max(0, int(cursor or 0))
window = steps[start:start + max(1, min(limit, 200))]
end = start + len(window)
return {
"run_id": run_id, "project_id": project_id, "status": run.get("status"),
"snapshot_id": run.get("input_snapshot_id"),
"steps": window,
"next_cursor": str(end) if end < len(steps) else None,
"read_only": True,
"note": "轨迹只记录可观察动作与版本;不包含模型内部推理过程",
}
def _outcomes(db, project_id: str) -> list[dict]:
return list(store.listing(db, KIND, project_id))
def _chain_has_cycle(rows: list[dict], start: str) -> bool:
"""防御性检查:追加写入本身造不出环,但迁移/导入的旧行可能有(03 §9.1)。"""
seen, current = set(), start
while current:
if current in seen:
return True
seen.add(current)
current = next((item.get("supersedes_id") for item in rows
if item["id"] == current), None)
return False
def record(db, project_id: str, body: dict, actor: str,
known_snapshots=()) -> dict:
"""追加一条会议结果。更正只能新增,且必须指向同项目的旧记录。"""
rows = _outcomes(db, project_id)
raw_code = str(body.get("raw_code") or "").strip()
if not raw_code:
raise ValueError("会议结果必须给出原始结果码,未提供时不得推断")
normalized = body.get("normalized") or RAW_ALIASES.get(raw_code, EventOutcome.unknown)
if isinstance(normalized, str):
normalized = EventOutcome(normalized)
snapshot = body.get("reviewed_snapshot_id")
matched = bool(snapshot) and (not known_snapshots or snapshot in set(known_snapshots))
supersedes = body.get("supersedes_id")
if supersedes:
previous = next((item for item in rows if item["id"] == supersedes), None)
if previous is None:
raise ValueError("更正要指向本项目既有的会议结果记录")
if _chain_has_cycle(rows, supersedes):
raise ValueError("更正链出现循环,已拒绝")
mapping_ref = body.get("mapping_ref")
label = None
if matched and mapping_ref and normalized in (EventOutcome.approved, EventOutcome.rejected):
label = normalized == EventOutcome.approved
revision = 1 + max([item.get("revision", 0) for item in rows
if item.get("meeting_id") == body.get("meeting_id")] or [0])
outcome = ReviewOutcome(
id=store.uid("outcome"), project_id=project_id,
meeting_id=str(body.get("meeting_id") or ""), revision=revision,
occurred_at=body.get("occurred_at") or store.now(), recorded_at=store.now(),
reviewed_snapshot_id=snapshot if matched else None,
raw_code=raw_code, normalized=normalized, mapping_ref=mapping_ref, label=label,
conditions=list(body.get("conditions") or ()),
evidence_ids=list(body.get("evidence_ids") or ()),
status="matched" if matched else "unmatched",
supersedes_id=supersedes, recorded_by=actor)
saved = outcome.model_dump(mode="json")
store.put(db, KIND, saved)
store.event(db, project_id, "review_outcome.recorded", actor,
{"outcome_id": saved["id"], "status": saved["status"],
"raw_code": saved["raw_code"]}, None)
return saved
def listing(db, project_id: str, status: str | None = None) -> dict:
"""按匹配状态分组返回。unmatched 单列,不混进通过率分母。"""
rows = sorted(_outcomes(db, project_id),
key=lambda item: item.get("recorded_at") or "")
observed = [item for item in rows if item["status"] == "matched"]
unmatched = [item for item in rows if item["status"] == "unmatched"]
if status == "matched":
return {"items": observed}
if status == "unmatched":
return {"items": unmatched}
return {"items": rows, "matched": observed, "unmatched": unmatched,
"counts_toward_denominator": denominator(rows)}
def denominator(rows) -> dict:
"""已观察分母只数"匹配到材料版本且有通过/否决口径"的记录(AC-22/37)。"""
labelled = [item for item in rows if item.get("status") == "matched"
and item.get("normalized") in ("approved", "rejected")]
excluded = [(item["id"], item.get("raw_code"), item.get("normalized"))
for item in rows if item not in labelled]
return {"observed": len(labelled), "excluded": excluded,
"note": "未送审、撤回、附条件与未知结果不计入已观察通过率;不把未知当否决"}
+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()