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