覆盖 AC-46/47/48/49(BR-45/46/47、E-27)。审计确认的 P1 缺口"正常发起评审不产生 新版预审记录"由此关闭:视图由执行链出口产生,不再依赖测试或脚本注入。 后端: - services/prescreen.py:新增 screening_from_run(),把一次真实运行的执行结果装配成 预审视图;维度与阶段按**本次实际执行的组件与核查项**投影 (dimensions_and_evidence:冻结方案声明过、且本次真的执行过的技能/规则才算维度; 经验核查等方案外伪步骤不算;阶段只表达证据成熟度,不看结论好坏)。 - services/assistant_worker.py:运行出口(含失败路径)统一调用 save_prescreen_view(), 运行结束即落库视图;装配失败单独记事件,不让运行一起丢。 - contracts/prescreen.py:视图带 evidence(界面"查看依据"的来源实体); 新增运行级错误码 RUN_STEP_FAILED(E-27)。 - 维度状态用本次执行的步骤与核查项算(含跑通但无问题的项), 只看待办 finding 会把通过的维度误判成未核查。 前端:面板新增"核查结果"区(每条可打开依据抽屉,显示来源、定位、原文片段与组件版本) 与降级记录展示;没有证据时明确写"按缺证处理",不编造。 测试:backend/test_prescreen_from_run.py 12 项,用真实 enqueue + DurableWorker.process_next (临时库)断言视图由执行链产生、失败与模型不可用如实留痕、重跑新增记录。 含反例:执行前不得存在任何记录。 实际验收(浏览器渲染 + 真实运行):状态 partial、10 维(5 已核查/2 待核实/3 阻断)、 4 条核查结果、14 条证据、1 条 RUN_STEP_FAILED 未完成项;记录见 docs/v2/17-batch-delivery-records.md(含剩余缺口:界面点击未自动化、材料确认未在浏览器走完、 演示角色需 XINGBANG_DEMO_ROLE_LOGIN=1、模型未配置)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
310 lines
15 KiB
Python
310 lines
15 KiB
Python
"""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,
|
||
DimensionSpec,
|
||
ErrorCode,
|
||
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")),
|
||
# 证据是界面"查看依据"的实体来源;超长只截断列表,不改内容
|
||
"evidence": [dict(item) for item in (run_body.get("evidence") or ())][:200],
|
||
"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 与修订")
|
||
|
||
|
||
# ---------------------------------------------------------------- 执行链装配(纠偏第一批)
|
||
# 证据成熟度刻度。**只表达证据成熟度,不看结论好坏**:发现问题不会降阶段,
|
||
# 通过也不会升到人工复核(见 docs/v2/16-delivery-corrections.md BR-46)。
|
||
STAGE_SCALE_ID = "evidence-stage"
|
||
STAGE_SCALE_VERSION = "1.0.0"
|
||
STAGE_LEVELS = [
|
||
{"value": 0, "label": "未提供", "criterion": "本次没有该核查项的结果"},
|
||
{"value": 1, "label": "仅有说明", "criterion": "有结论但未引用任何证据"},
|
||
{"value": 2, "label": "资料待核", "criterion": "引用了证据;尚无交叉核验记录"},
|
||
{"value": 3, "label": "交叉核验", "criterion": "有核验记录证明证据之间互相印证"},
|
||
{"value": 4, "label": "人工复核", "criterion": "有人工复核记录"},
|
||
]
|
||
DISABLED_JUDGMENT_CONFIG = {
|
||
"enabled": False, "provider": "disabled",
|
||
"disabled_reason": "本次运行未启用专项判断;主审仍按规则、工具与经验形成意见",
|
||
}
|
||
# 阶段 3/4 需要"核验记录"或"人工复核记录"这类依据,P0 的执行链还产不出,
|
||
# 因此自动映射只会落在 0–2;3/4 保留给后续判别路径,不在这里臆造。
|
||
|
||
|
||
def stage_from_check(item) -> int:
|
||
"""核查项 → 证据成熟度阶段(BR-46)。"""
|
||
if item.get("result") in (None, "not_applicable"):
|
||
return 0
|
||
return 2 if item.get("evidence_ids") else 1
|
||
|
||
|
||
def _component_of(step, skills, rules):
|
||
"""步骤归属的组件:**必须是冻结方案里声明过的技能或规则**。
|
||
|
||
既有执行器把技能、规则、经验核查都落成 step,其中经验核查这类不在方案里的
|
||
步骤不是核查维度;反过来方案声明的组件若本次没执行,也不会出现在结果里
|
||
(维度的定义是"跑了什么",不是"方案里写了什么")。
|
||
"""
|
||
step_id = str(step.get("step_id") or "")
|
||
skill_id = str((step.get("skill") or {}).get("id") or "")
|
||
lookup = skill_id or step_id
|
||
if not lookup or lookup != step_id:
|
||
return None
|
||
item = next((s for s in skills or () if s.get("id") == lookup), None)
|
||
if item is None:
|
||
item = next((r for r in rules or () if r.get("id") == lookup), None)
|
||
if item is None:
|
||
return None
|
||
return lookup, str(item.get("name") or lookup), str(item.get("version") or "1.0.0")
|
||
|
||
|
||
def dimensions_and_evidence(steps, skills, rules):
|
||
"""本次实际执行的组件 → 维度定义 + 用于判定状态与阶段的核查输入(BR-46)。
|
||
|
||
只投影真实执行记录:步骤状态决定该维度是否算已核查,核查项决定是否阻断;
|
||
技能/规则跑通但没有产出核查项时,维度按"已核查"成立——这不等于通过审批。
|
||
方案没有执行记录就不产生维度,绝不补占位轴。
|
||
"""
|
||
dimensions, items, order = [], [], 0
|
||
for step in steps or ():
|
||
component = _component_of(step, skills, rules)
|
||
if not component:
|
||
continue
|
||
component_id, label, version = component
|
||
findings = [dict(item) for item in step.get("findings") or ()]
|
||
codes = [str(item.get("code")) for item in findings if item.get("code")]
|
||
status = str(step.get("status") or "")
|
||
if status == "failed":
|
||
step_result = "error"
|
||
elif status in ("partial", "running", "queued"):
|
||
step_result = "unknown"
|
||
elif any(item.get("result") not in ("pass", "not_applicable") for item in findings):
|
||
step_result = "issue"
|
||
else:
|
||
step_result = "pass"
|
||
evidence_ids = sorted({str(e) for item in findings for e in (item.get("evidence_ids") or ())})
|
||
refs = [{"id": component_id, "version": version}] + [
|
||
{"id": code, "version": version} for code in codes]
|
||
dimensions.append(DimensionSpec(
|
||
id=component_id, label=label, order=order, scale_id=STAGE_SCALE_ID,
|
||
scale_version=STAGE_SCALE_VERSION, levels=STAGE_LEVELS,
|
||
component_refs=refs).model_dump(mode="json"))
|
||
order += 1
|
||
step_item = {"code": component_id, "result": step_result,
|
||
"evidence_ids": evidence_ids, "stage": None}
|
||
step_item["stage"] = stage_from_check(step_item)
|
||
items.append(step_item)
|
||
for finding, code in zip(findings, codes):
|
||
row = {"code": code, "result": finding.get("result"),
|
||
"evidence_ids": list(finding.get("evidence_ids") or ())}
|
||
row["stage"] = stage_from_check(row)
|
||
items.append(row)
|
||
return dimensions, items
|
||
|
||
|
||
def _degradations(steps, run) -> list[dict]:
|
||
"""失败步骤与运行级失败都留痕,并给出可定位的原因(AC-48)。"""
|
||
rows = []
|
||
for step in steps or ():
|
||
if step.get("status") != "failed":
|
||
continue
|
||
error = (step.get("errors") or [{}])[0]
|
||
kind = str(error.get("type") or "")
|
||
code = (ErrorCode.RUN_BUDGET_EXCEEDED if kind == "TimeoutError"
|
||
else ErrorCode.RUN_STEP_FAILED)
|
||
label = str(step.get("name") or step.get("step_id") or "未命名步骤")
|
||
detail = str(error.get("message") or "步骤未完成")
|
||
rows.append({"code": code, "message": f"{label}:{detail[:400]}"})
|
||
if run.get("status") == "failed" and not rows:
|
||
rows.append({"code": ErrorCode.RUN_STEP_FAILED,
|
||
"message": str(run.get("error") or "运行未完成")[:400]})
|
||
return rows
|
||
|
||
|
||
def _missing_inputs(steps, run) -> list[str]:
|
||
"""缺证项汇总:来自各步骤与数据错误,去重后保持顺序。"""
|
||
raw = [str(item) for step in steps or () for item in (step.get("missing_inputs") or ())]
|
||
for item in run.get("data_errors") or ():
|
||
raw.append(str(item.get("message") if isinstance(item, dict) else item))
|
||
seen, rows = set(), []
|
||
for item in raw:
|
||
if item and item not in seen:
|
||
seen.add(item)
|
||
rows.append(item[:200])
|
||
return rows
|
||
|
||
|
||
def screening_from_run(run: dict, data_domain: str = "demo",
|
||
judgment_config=None) -> dict:
|
||
"""把**一次真实运行的执行结果**装配成新版预审视图(AC-46/47/48/49)。
|
||
|
||
这是执行链的出口:由 worker 在运行结束时调用,因此正常发起评审就会产生记录,
|
||
不需要测试或演示脚本注入(BR-45)。方案取自运行创建时冻结的 components,
|
||
不在装配时重读目录,避免"跑的是旧版本、显示的是新版本"。
|
||
"""
|
||
components = run.get("components") or {}
|
||
checks = [dict(item) for item in run.get("checks") or ()]
|
||
steps = list(run.get("steps") or ())
|
||
skills = list(components.get("skills") or ())
|
||
rules = list(components.get("rules") or ())
|
||
dimensions, dimension_inputs = dimensions_and_evidence(steps, skills, rules)
|
||
frozen_plan = {
|
||
"plan": components.get("plan") or {},
|
||
"skills": skills,
|
||
"rules": rules,
|
||
"template": components.get("template") or {},
|
||
"content_hash": components.get("content_hash"),
|
||
"dimensions": dimensions,
|
||
}
|
||
decision = policy.decide(checks, _missing_inputs(steps, run), _degradations(steps, run))
|
||
decision["findings"] = [dict(item) for item in run.get("findings") or ()]
|
||
view = assemble(run, frozen_plan, decision,
|
||
judgment_config or DISABLED_JUDGMENT_CONFIG,
|
||
data_domain=data_domain,
|
||
status=str(run.get("status") or "completed"),
|
||
trajectory_id=run.get("id"))
|
||
# 维度状态用**本次执行的步骤与核查项**算(含跑通但无问题的项):
|
||
# 只看待办 finding 会把通过的维度误判成未核查。
|
||
view["dimension_results"] = dimension_results(frozen_plan, dimension_inputs)
|
||
return ScreeningRun(**view).model_dump(mode="json")
|