Files
mini-openclaw-v2/backend/services/prescreen.py
T
qiaoqichaoandClaude Code 02289661a8 feat(v2): 纠偏第一批——执行链产出新版预审记录(真实运行验收)
覆盖 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>
2026-09-30 12:34:45 +08:00

310 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")