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
+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)