Files
mini-openclaw-v2/backend/services/prescreen_policy.py
T
qiaoqichaoandClaude Code b5feee6982 feat(v2): T07-a 预审结果约束与结论校验
T07 是"预审执行"大卡,按纪律拆子卡。本批为第一块:纯确定性的结论校验,
不调用任何模型、不计算概率。覆盖 AC-05/06/07/09/12/45 与 BR-05/06/07/12。

新增 services/prescreen_policy.py:
- decide():四级优先(hard_block → critical_incomplete → open_findings → clean),
  优先级顺序即业务口径,改动等于改结论,已显式标注
- **硬性阻断不可覆盖**:命中 hard 规则即 stop,并指出规则版本、保留模型原始
  结构化提案供审计;专项模型的正面单题判断不能翻盘(有专门反例)
- **未知不等于风险也不等于通过**:关键核查不可完成给 insufficient_information
  并列出未完成项,findings 的 result 原样保留、**不得被改写成 issue**
- **没有 second path 给概率**:probability() 不接受任何入参,模型分数无法顺手传入;
  native_metrics 里名为 probability 的指标会被 assert_no_probability_leak 拦下,
  self_reported 置信度可入档但不得变概率
- stop 明确不触碰正式审批(AC-45),预算耗尽按 E-08 列未完成项而非伪造完成

实现中修正了两处自己的测试写法:断言"不含高风险三字"会被免责说明本身命中;
断言 open_findings 为空与"unknown 属待处理"的定义矛盾。两处都改成检验真正的
不变量(结论非风险判定 + findings 不被改写)。

测试:新增 test_prescreen_policy.py 19 项。全量 368 项 OK(349 → 368);
verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 20:28:45 +08:00

127 lines
6.0 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-a 预审结果约束:把核查结果收敛成受约束的建议。
依据:docs/v2/01-requirements.md §5、03-detailed-design.md §5;
BR-05/06/07/12、E-02/06/07/08、AC-05/06/07/09/12/45。
本模块只做**确定性结论校验**,不调用任何模型、不算概率。四条不可让渡的规则:
1. **硬性阻断不可被覆盖**(BR-06):已确认的 hard 规则命中即 `stop`,模型的高分
或"建议推进"都不能翻盘。同时**保留模型原始结构化提案**供审计,并指出规则版本。
2. **未知不等于风险,也不等于通过**(BR-07):关键核查不可完成时给
`insufficient_information` 并列补件,**不得**据此评判企业高风险。
3. **单题不能代表综合意见**(BR-05):专项判断只进 findings 与被引用,不能直接
写入 recommendation。
4. **未校准就没有概率**(BR-12):`probability` 恒为 None + `not_calibrated`,
任何模型自报置信度都不改写它。
"""
from __future__ import annotations
RECOMMENDATIONS = ("advance", "supplement", "stop", "insufficient_information")
NOT_CALIBRATED = "not_calibrated"
NO_PROBABILITY_REASON = "尚未完成真实历史校准;不展示审批通过概率"
# 判定优先级(从上到下,先命中先返回)。顺序本身是业务口径,改动等于改结论。
PRECEDENCE = ("hard_block", "critical_incomplete", "open_findings", "clean")
INCOMPLETE_RESULTS = ("unknown", "error", "not_applicable")
def _codes(findings) -> set[str]:
return {str(item.get("code")) for item in findings or () if item.get("code")}
def hard_blocks(findings, hard_rule_codes) -> list[dict]:
"""命中的硬性阻断项。`hard_rule_codes` 来自冻结方案里 effect=hard 且 on_match=block 的规则。"""
wanted = set(hard_rule_codes or ())
return [dict(item) for item in findings or () if item.get("code") in wanted]
def critical_incomplete(findings, critical_check_ids) -> list[str]:
"""关键核查项中未完成的那些编码(未知/执行失败/不适用)。"""
critical = set(critical_check_ids or ())
return sorted({
str(item.get("code")) for item in findings or ()
if item.get("code") in critical and item.get("result") in INCOMPLETE_RESULTS
})
def open_findings(findings) -> list[dict]:
"""需要业务处理但未阻断的问题(issue / unknown)。"""
return [dict(item) for item in findings or ()
if item.get("result") in ("issue", "unknown")]
def probability() -> dict:
"""P0 恒为空。**不接受任何入参**——不给"顺手传个模型分数"留口子。"""
return {"value": None, "reason": NOT_CALIBRATED, "message": NO_PROBABILITY_REASON}
def decide(findings, missing_inputs=(), degradations=(), judgments=(),
critical_check_ids=(), hard_rule_codes=(), rule_versions=()) -> dict:
"""收敛结论。返回 recommendation 与支撑它的理由、降级与审计信息。"""
blocks = hard_blocks(findings, hard_rule_codes)
incomplete = critical_incomplete(findings, critical_check_ids)
open_items = open_findings(findings)
degradations = [dict(item) for item in degradations or ()]
missing = sorted({str(item) for item in missing_inputs or ()})
if blocks:
# 命中硬阻断:即便专项模型给出正面单题判断,也不改变结论(BR-05 / BR-06)
stage = "hard_block"
recommendation = "stop"
reason = "存在已确认的硬性阻断,暂不建议推进;" + "、".join(
f'{item.get("code")}@{item.get("basis_version", "未标注版本")}' for item in blocks)
elif incomplete:
stage = "critical_incomplete"
recommendation = "insufficient_information"
reason = ("关键核查未能完成,无法形成结论,请补充材料后重新发起;"
"不得据此判定企业高风险。未完成项:" + "、".join(incomplete))
missing = sorted(set(missing) | set(incomplete))
elif open_items:
stage = "open_findings"
recommendation = "supplement"
reason = f"存在 {len(open_items)} 项待处理事项,建议补充材料或说明后重新核查"
else:
stage = "clean"
recommendation = "advance"
reason = "已完成方案内的确定性核查,未发现阻断项;正式审批仍由业务流程决定"
return {
"recommendation": recommendation,
"recommendation_reason": reason,
"stage": stage,
"findings": [dict(item) for item in findings or ()],
"open_findings": open_items,
"hard_blocks": blocks,
"critical_incomplete": incomplete,
"missing_inputs": missing,
"degradations": degradations,
# 专项判断只作为依据入档,**不参与** recommendation 的判定
"judgments": [dict(item) for item in judgments or ()],
"judgment_can_override": False,
"rule_versions": [dict(item) for item in rule_versions or ()],
"probability": probability(),
# AC-45:stop 只是"暂不建议推进",系统不得自动关闭项目或执行正式审批
"formal_approval_touched": False,
}
def assert_no_probability_leak(result: dict) -> None:
"""任何路径都不得产出未校准概率(BR-12 / AC-12)。"""
value = (result.get("probability") or {}).get("value")
if value is not None:
raise ValueError("未校准的数据集不得产出审批通过概率")
for item in result.get("judgments", ()):
for metric in item.get("native_metrics", ()) or ():
if str(metric.get("name", "")).lower() in ("probability", "approval_probability"):
raise ValueError("专项模型的原生指标不得被当作审批概率")
def budget_exhausted(reason: str = "RUN_BUDGET_EXCEEDED") -> dict:
"""预算耗尽(E-08):停止并列出未完成项,不伪造完成。"""
return {"code": reason, "message": "本次核查已达到执行上限,请复核未完成项",
"retryable": False}