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:
@@ -0,0 +1,147 @@
|
||||
"""T07-c 预审运行装配:把冻结方案、结论校验与专项配置收成一条可审计的运行记录。
|
||||
|
||||
依据:docs/v2/03-detailed-design.md §9.2/§9.3、02-architecture.md §4;
|
||||
BR-13/14/20/31、AC-13/14/45。
|
||||
|
||||
**这是既有 run 对象的受约束投影,不是第二份运行存储。** 既有
|
||||
`ProjectAssistantService.enqueue()` 已经提供输入快照冻结、幂等键、revision 冲突与
|
||||
队列恢复(AC-13/AC-14 的实现),本模块**不重做**这些,只负责:
|
||||
1. 把冻结方案与专项配置投影进 ScreeningRun 并校验
|
||||
2. 把政策结论、降级与维度状态落成契约合法的运行视图
|
||||
3. 保证任何路径都写不出未校准概率
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contracts.prescreen import (
|
||||
DataDomain,
|
||||
DimensionResult,
|
||||
ProbabilityView,
|
||||
ScreeningRun,
|
||||
)
|
||||
from services import assistant_store as store
|
||||
from services import prescreen_policy as policy
|
||||
|
||||
|
||||
def versions_block(frozen_plan: dict, extra: dict | None = None) -> dict:
|
||||
"""冻结的运行版本块。缺哪一项就明确留空,不编造版本号。"""
|
||||
plan = frozen_plan.get("plan") or {}
|
||||
components = frozen_plan.get("component_versions") or [
|
||||
{"id": item["id"], "version": item.get("version")}
|
||||
for item in [plan] + list(frozen_plan.get("skills") or ())
|
||||
+ list(frozen_plan.get("rules") or ())
|
||||
+ ([frozen_plan["template"]] if frozen_plan.get("template") else [])
|
||||
]
|
||||
block = {
|
||||
"schema": 1,
|
||||
"policy": "prescreen_policy@1",
|
||||
"skills": [item for item in components if item.get("id")],
|
||||
"experiences": list(frozen_plan.get("experience_versions") or ()),
|
||||
"model": None, # 主审模型配置由运行冻结,未配置即留空
|
||||
"questions": None, # 题库版本在 T08 统一适配后填入,P0 留空
|
||||
"plan": {"id": plan.get("id"), "version": plan.get("version")},
|
||||
"plan_hash": frozen_plan.get("content_hash"),
|
||||
}
|
||||
block.update(extra or {})
|
||||
return block
|
||||
|
||||
|
||||
def dimension_results(frozen_plan: dict, findings) -> list[dict]:
|
||||
"""把维度与本次核查结果对上:命中即 checked,缺证即 unknown,永不默认成立。"""
|
||||
result_by_code = {}
|
||||
for item in findings or ():
|
||||
result_by_code.setdefault(str(item.get("code")), []).append(item)
|
||||
|
||||
rows = []
|
||||
for spec in frozen_plan.get("dimensions") or ():
|
||||
refs = spec.get("component_refs") or ()
|
||||
bases, states, reported = [], set(), 0
|
||||
for ref in refs:
|
||||
items = result_by_code.get(str(ref.get("id")), ())
|
||||
if not items:
|
||||
continue # 该组件本次没有任何结果 = 未报告
|
||||
reported += 1
|
||||
for item in items:
|
||||
states.add(item.get("result"))
|
||||
bases.extend(item.get("evidence_ids") or [])
|
||||
# 静默不等于通过(BR-07):任一被引用的组件没有结果,维度就不能算已核查。
|
||||
# issue 优先于 unknown —— 具体的阻断比"信息缺失"更该被看到。
|
||||
unreported = len(refs) - reported
|
||||
if states & {"issue"}:
|
||||
state = "blocked"
|
||||
elif unreported or not states or (states & {"unknown", "error", "not_applicable"}):
|
||||
state = "unknown"
|
||||
else:
|
||||
state = "checked"
|
||||
stages = [item.get("stage") for ref in refs
|
||||
for item in result_by_code.get(str(ref.get("id")), ())
|
||||
if isinstance(item.get("stage"), int)]
|
||||
rows.append({"id": spec["id"], "state": state,
|
||||
"stage": max(stages) if stages else None,
|
||||
"basis_ids": sorted(set(bases))})
|
||||
return rows
|
||||
|
||||
|
||||
def probability_view(snapshot_id: str, as_of: str | None = None,
|
||||
demo_estimate: dict | None = None) -> dict:
|
||||
"""概率视图。真实估计恒为空;演示值只能走 demo_estimate 且必须带说明。"""
|
||||
return ProbabilityView(snapshot_id=snapshot_id, as_of=as_of or store.now(),
|
||||
demo_estimate=demo_estimate).model_dump(mode="json")
|
||||
|
||||
|
||||
def assemble(run_body: dict, frozen_plan: dict, decision: dict, judgment_config,
|
||||
data_domain: str = "demo", status: str = "completed",
|
||||
trajectory_id: str | None = None) -> dict:
|
||||
"""装配 ScreeningRun 视图并校验。
|
||||
|
||||
任何一条路径写不出未校准概率:`probability` 与 `probability_view.real_estimate`
|
||||
都是契约里的 `None` 类型,传别的值会直接被 pydantic 拒绝。
|
||||
"""
|
||||
plan = frozen_plan.get("plan") or {}
|
||||
snapshot_id = run_body.get("input_snapshot_id") or run_body.get("snapshot_id")
|
||||
if not snapshot_id:
|
||||
raise ValueError("运行缺少输入快照,无法装配预审视图")
|
||||
|
||||
body = {
|
||||
"id": run_body["id"],
|
||||
"project_id": run_body["project_id"],
|
||||
"data_domain": data_domain,
|
||||
"revision": run_body.get("revision", 1),
|
||||
"snapshot_id": snapshot_id,
|
||||
"plan_ref": {"id": plan.get("id", ""), "version": str(plan.get("version", ""))},
|
||||
"status": status,
|
||||
"recommendation": decision.get("recommendation"),
|
||||
"findings": decision.get("findings") or [],
|
||||
"missing_inputs": decision.get("missing_inputs") or [],
|
||||
"applied_experiences": run_body.get("experience_applications") or [],
|
||||
"degradations": decision.get("degradations") or [],
|
||||
"judgment_config": judgment_config.model_dump(mode="json")
|
||||
if hasattr(judgment_config, "model_dump") else dict(judgment_config),
|
||||
"versions": versions_block(frozen_plan, {"trajectory_id": trajectory_id}
|
||||
if trajectory_id else None),
|
||||
"dimension_results": dimension_results(frozen_plan, decision.get("findings")),
|
||||
"run_instruction": run_body.get("requirement") or "",
|
||||
"created_at": run_body.get("created_at") or store.now(),
|
||||
"finished_at": run_body.get("ended_at"),
|
||||
}
|
||||
if status in ("completed", "partial"):
|
||||
body["probability_view"] = probability_view(snapshot_id)
|
||||
result = ScreeningRun(**body).model_dump(mode="json")
|
||||
# 最后一道闸:契约已把这两个字段的类型限死为 None,这里再显式确认一次,
|
||||
# 使得"未校准就没有概率"在装配出口也是可读的断言而不是隐含约束。
|
||||
if result["probability"] is not None:
|
||||
raise ValueError("未校准时不得产出审批概率")
|
||||
if (result.get("probability_view") or {}).get("real_estimate") is not None:
|
||||
raise ValueError("P0 的真实概率估计必须为空")
|
||||
return result
|
||||
|
||||
|
||||
def assert_versions_frozen(screening: dict) -> None:
|
||||
"""运行必须冻结方案与专项配置,否则不得进入执行(BR-13 / BR-20)。"""
|
||||
reference = screening.get("plan_ref") or {}
|
||||
if not reference.get("id") or not reference.get("version"):
|
||||
raise ValueError("运行未冻结评审方案版本,不得执行")
|
||||
config = screening.get("judgment_config") or {}
|
||||
if config.get("enabled") and (not config.get("profile_id")
|
||||
or config.get("profile_revision") is None):
|
||||
raise ValueError("启用专项的运行必须冻结配置 ID 与修订")
|
||||
Reference in New Issue
Block a user