Files

113 lines
5.4 KiB
Python
Raw Permalink Normal View History

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