Files
mini-openclaw-v2/backend/services/prescreen.py
T

304 lines
14 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 [])
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")
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 与修订")
# 证据成熟度刻度。**只表达证据成熟度,不看结论好坏**:发现问题不会降阶段,
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 的执行链还产不出,
def stage_from_check(item) -> int:
"""核查项 → 证据成熟度阶段(BR-46)。"""
if item.get("result") in (None, "not_applicable", "error"):
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,
}
hard_codes = [c['code'] for rule in rules if rule.get('effect') == 'hard' and rule.get('on_match', 'block') == 'block' for c in rule.get('conditions', []) if c.get('code')]
critical = components.get('plan', {}).get('critical_check_ids', [])
decision = policy.decide(checks, _missing_inputs(steps, run), _degradations(steps, run),
critical_check_ids=critical, hard_rule_codes=hard_codes,
rule_versions=[{'id': r['id'], 'version': r['version']} for r in rules])
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"))
view["dimension_results"] = dimension_results(frozen_plan, dimension_inputs)
return ScreeningRun(**view).model_dump(mode="json")