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>
This commit is contained in:
2026-09-30 12:34:45 +08:00
co-authored by Claude Code
parent f38959b483
commit 02289661a8
8 changed files with 559 additions and 0 deletions
+26
View File
@@ -8,6 +8,7 @@ from datetime import datetime, timezone, timedelta
from copy import deepcopy
from services.assistant_store import get, put, listing, event, uid, now, digest, finish_time_event
from services import prescreen, prescreen_store
from services.assistant_data import read_cards
from services.skill_executor import execute_skill, execute_rules
from services.assistant_experiences import apply_experiences
@@ -122,6 +123,27 @@ class DurableWorker:
with self.service.connect() as db:
db.execute("UPDATE assistant_jobs SET lease_until=? WHERE id=? AND owner=? AND state='running'", (time.time() + LEASE_SECONDS, run["id"], self.owner))
def save_prescreen_view(self, db, run):
"""运行出口:把本次执行结果装配成新版预审视图(纠偏第一批 / BR-45)。
装配失败不吞掉:运行结果已经落库,视图失败单独记事件便于定位,
而不是让整个运行一起丢。失败运行也走这里,保证"未完成"能被页面看到。
"""
try:
project = get(db, "project", run["project_id"])
view = prescreen.screening_from_run(
run, data_domain=str(project.get("data_domain") or "demo"))
prescreen_store.save(db, view)
event(db, run["project_id"], "prescreen_view.saved", self.owner,
{"run_id": run["id"], "view_id": view["id"], "status": view["status"],
"dimensions": len(view["dimension_results"]),
"missing_inputs": len(view["missing_inputs"]),
"degradations": len(view["degradations"])}, run["id"])
except Exception as exc:
event(db, run["project_id"], "prescreen_view.failed", self.owner,
{"run_id": run["id"], "error": f"{type(exc).__name__}: {exc}"[:400]},
run["id"])
def save_step(self, run, step):
if step["status"] != "running":
step = SkillResult.model_validate(step).model_dump()
@@ -181,6 +203,8 @@ class DurableWorker:
db.execute("UPDATE assistant_jobs SET state='failed' WHERE id=? AND owner=?", (run["id"], self.owner))
finish_time_event(db, "run", run["id"])
event(db, run["project_id"], "run.failed", self.owner, {"error_type": type(exc).__name__}, run["id"])
# AC-48:失败运行同样要能被页面读到,并明确显示未完成原因
self.save_prescreen_view(db, current)
finally:
done.set()
heartbeat_thread.join(timeout=2)
@@ -299,3 +323,5 @@ class DurableWorker:
for finding in findings:
put(db, "finding", finding)
event(db, run["project_id"], "run." + current["status"], self.owner, {"summary": current["summary"], "superseded": current["superseded"]}, run["id"])
# 纠偏第一批(BR-45):运行结束即产出新版预审视图,页面直接读它。
self.save_prescreen_view(db, current)
+162
View File
@@ -16,6 +16,8 @@ from __future__ import annotations
from contracts.prescreen import (
DataDomain,
DimensionResult,
DimensionSpec,
ErrorCode,
ProbabilityView,
ScreeningRun,
)
@@ -120,6 +122,8 @@ def assemble(run_body: dict, frozen_plan: dict, decision: 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"),
@@ -145,3 +149,161 @@ def assert_versions_frozen(screening: dict) -> None:
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")