feat(v2): T07-c 预审运行装配与运行级集成,T07 收尾

T07 第三块。ScreeningRun 是**既有 run 对象的受约束投影**,不是第二份运行存储;
AC-13/14 由既有 enqueue 实现,本批只验证其成立、不重做。

新增契约:Degradation、DimensionResult、ProbabilityView、ScreeningRun。
契约层的硬约束:`probability` 与 `probability_view.real_estimate` 的类型就是
`None`,传任何数值都被 pydantic 拒绝——"未校准就没有概率"不靠自觉;
演示估计必须标 synthetic_demo 且带"人工设定"说明、取值 0–1。

新增 services/prescreen.py:
- assemble():把冻结方案、政策结论、降级与维度状态收成契约合法的运行视图
- versions_block():冻结 plan_hash 与各组件版本;题库与主审模型未接入时**留空**,
  不编造版本号
- dimension_results():维度状态由本次核查结果推出
- assert_versions_frozen():未冻结方案版本或专项修订的运行不得执行

**实现中被测试抓到一个真实错误**:初版把"被引用组件未报告结果"当作已核查
(状态 checked)。这是把静默当成通过,违背 BR-07。现改为:任一被引用组件没有
结果即 unknown;issue 优先于 unknown(具体阻断比信息缺失更该被看到)。

测试:新增 test_prescreen_run.py 20 项,其中 4 项直接验证既有 enqueue 的
AC-13(新证据→新快照+新运行、旧运行留存、旧 revision 冲突)与 AC-14(同键同载荷
返回同一运行、异载荷 409)。全量 410 项 OK(390 → 410);verify.py 10/10 PASS。

T07 三块完成,卡片验收对照:AC-05/06/07/12/45(T07-a)· AC-09/10/21(T07-b)·
AC-13/14 验证(T07-c)。AC-08 的跨项目引用拒绝已在 T03 覆盖。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:38:02 +08:00
co-authored by Claude Code
parent 27d57c9227
commit bd389ebd31
3 changed files with 447 additions and 0 deletions
+83
View File
@@ -755,3 +755,86 @@ class JudgmentConfig(Contract):
if not self.disabled_reason:
raise ValueError("关闭专项必须记录关闭原因,不能静默")
return self
# ---------------------------------------------------------------- 预审运行(T07-c)
class Degradation(Contract):
"""降级记录:未完成的检查必须留痕,不能静默(BR-09)。"""
code: ErrorCode
message: str = Field(min_length=1, max_length=DESC_MAX)
class DimensionResult(Contract):
"""维度在本次运行中的状态。`unknown` 不等于不成立(03 §9.1)。"""
id: Identifier
stage: int | None = None
state: Literal["unknown", "checked", "blocked"]
basis_ids: list[Identifier] = Field(default_factory=list, max_length=EVIDENCE_MAX)
class ProbabilityView(Contract):
"""概率呈现。P0 的 real_estimate **必须为空**,演示值只能走 demo_estimate(BR-29)。"""
target_event: Literal["company_review_meeting_pass"] = "company_review_meeting_pass"
snapshot_id: Identifier
as_of: AwareDatetime
real_estimate: None = None
reason: Literal["not_calibrated"] = "not_calibrated"
demo_estimate: dict | None = None
@model_validator(mode="after")
def _check_demo_isolated(self) -> ProbabilityView:
demo = self.demo_estimate
if demo is None:
return self
if demo.get("source") != "synthetic_demo":
raise ValueError("演示估计必须标明来源 synthetic_demo")
if not demo.get("label"):
raise ValueError("演示估计必须带固定的『人工设定』说明,避免被当作真实概率")
value = demo.get("value")
if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1:
raise ValueError("演示估计须为 0–1 的数值")
return self
class ScreeningRun(Contract):
"""预审运行视图(docs/v2/03-detailed-design.md §9.2 + §9.3 增量)。
这是既有 run 对象的**受约束投影**,不是第二份运行存储:plan_ref、snapshot、
judgment_config 与 versions 在创建时冻结(BR-13 / BR-20)。
"""
id: Identifier
project_id: Identifier
data_domain: DataDomain
revision: int = Field(ge=1)
snapshot_id: Identifier
plan_ref: Ref
status: Literal["queued", "running", "awaiting_input", "completed", "partial", "failed", "cancelled"]
recommendation: Literal["advance", "supplement", "stop", "insufficient_information"] | None = None
findings: list[dict] = Field(default_factory=list, max_length=500)
missing_inputs: list[str] = Field(default_factory=list, max_length=200)
applied_experiences: list[dict] = Field(default_factory=list, max_length=200)
# 未校准时恒为空——契约层直接禁止填入数值
probability: None = None
probability_reason: Literal["not_calibrated"] = "not_calibrated"
probability_view: ProbabilityView | None = None
degradations: list[Degradation] = Field(default_factory=list, max_length=100)
judgment_config: JudgmentConfig
versions: dict = Field(default_factory=dict)
dimension_results: list[DimensionResult] = Field(default_factory=list, max_length=32)
run_instruction: str = Field(default="", max_length=2000)
created_at: AwareDatetime
finished_at: AwareDatetime | None = None
@model_validator(mode="after")
def _check_run(self) -> ScreeningRun:
if self.status in ("queued", "running") and self.recommendation is not None:
raise ValueError("运行未结束时不得给出建议")
if self.status == "failed" and not self.degradations:
raise ValueError("failed 运行必须有降级记录,不能只留空结果")
if self.probability_view and self.status in ("queued", "running"):
raise ValueError("运行未结束时不得产出概率视图")
return self