- run_batch:--template/--prompt-designer CLI 参数(缺省 clicker 零变化);play 调用改传 (templateId, config) 取参分流(:772 clicker 专属 target 取参解套) - player_cdp:_dispatch_drag(mousePressed→6帧插值 mouseMoved→mouseReleased);_play_merge_once 确定性贪心(本地棋盘模型推演,禁读游戏状态,预算≤90s 超时记 infra);§5.3.1 布局契约与 runtime 字面同一公式(canvas W/H 只读优先,rect 等分退化);共享前置 _open_navigate_preflight 抽取(clicker 步骤5-6 字节不变);demo 信号(c)仅 clicker 启用(merge score≠点击目标语义);五条 AND 信封零改动 - judge:array 分支(minItems/maxItems+items 逐元素 string 口径),与后端 networknt 同源同判 - 新增 test_judge_merge_schema 15 用例(itemLabel 三红样本/const 串台/minmax 越界);83 单测全绿(主 agent 独立复跑实证) HJ-MC-TPL-EXEC-001 §7/§9级1② Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
511 lines
28 KiB
Python
511 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
裁决引擎(D3 核心件)—— spec:HJ-AGENT-LOOP-EXEC-001 §10(评审版 §3.3 Z5 硬要求)
|
||
|
||
定位与铁律:
|
||
- 纯代码规则,无任何 LLM 参与;规则变更 = 改本文件走 PR;
|
||
- §10.2 决策表 D1-D10 逐行落码,按求值序 E1→E6 短路;
|
||
- 末尾 raise 防御:任何未匹配输入 = 编排器/本引擎代码缺陷,立即暴露,禁止默认通过;
|
||
- 「agent 自述一律不作证据」:runnableOk 五条 AND(§9.4)由本模块对 CDP 捕获的
|
||
原始 PlayReport 数据逐条核对,不信任玩家 agent 的任何主观结论字段;
|
||
- 发布闸(§7.1):Verdict 仅能由本模块 build_verdict() 铸章产生,
|
||
编排器发布函数只接受带本模块私有铸章且 decision=accept 的 Verdict(代码层强制)。
|
||
|
||
回炉计数总则(§10.1,评审版 §3.3 原文):
|
||
- round = 该 designId 的回炉计数(0=首轮,1=回炉轮);
|
||
- 全流程回炉额度合计 1 轮,schema 重出(D1)与 P1-fix(D5)共享同一额度;
|
||
- round=1 后任何不满足 accept 的分支一律不再回炉(D2/D6 直接 kill);
|
||
- 实玩重试(D8,≤1 次)不消耗回炉额度、不改 round。
|
||
"""
|
||
|
||
import re
|
||
import unicodedata
|
||
|
||
# ============================== 决策动作常量(§10.2「→ 输出」列的机器编码) ==============================
|
||
|
||
# D1:本地重出——携 schema 错误回灌策划重出 1 次(不经对抗、不落包,消耗唯一回炉额度)
|
||
ACTION_SCHEMA_RETRY = "schema_retry"
|
||
# D2/D3/D4/D6/D9:终判否决(kill / kill-runnable)
|
||
ACTION_KILL = "kill"
|
||
# D5:fix——findings 回灌策划重出,round→1,全流程重走 E1
|
||
ACTION_FIX = "fix"
|
||
# D7:infra_fail——基础设施类失败,不计分母、designId 可重放、计入熔断分子
|
||
ACTION_INFRA_FAIL = "infra_fail"
|
||
# D8:重试实玩 1 次(不消耗回炉额度),回到 E5
|
||
ACTION_PLAY_RETRY = "play_retry"
|
||
# D10:accept——四条 AND 全真,进发布段(金丝雀≤10 内)
|
||
ACTION_ACCEPT = "accept"
|
||
|
||
# Verdict.decision 受控枚举(契约 contracts/agent-loop/verdict.schema.json,§6.2)
|
||
DECISION_ACCEPT = "accept"
|
||
DECISION_FIX = "fix"
|
||
DECISION_KILL = "kill"
|
||
|
||
# 回调动作(§10.2「回调/任务态」列):None=不回调;否则 (status, failureReason)。
|
||
# failureReason 取 FailureReasonEnum 既有 7 值(冻结,不扩枚举——§1.2);
|
||
# 撞重/P1 残留归桶 config_invalid 为 §16-1 主 agent 已确认决断,真因记 Verdict.reasons。
|
||
CALLBACK_FAILED_CONFIG_INVALID = ("failed", "config_invalid")
|
||
CALLBACK_FAILED_UNSAFE_PROMPT = ("failed", "unsafe_prompt")
|
||
|
||
# findings 严重级受控枚举(对抗 prompt 输出契约,§5.3-2)
|
||
_VALID_SEVERITIES = ("P0", "P1", "P2")
|
||
|
||
|
||
class JudgeError(Exception):
|
||
"""裁决引擎防御性异常:输入越出决策表定义域 = 调用方或本引擎代码缺陷,必须立即暴露。"""
|
||
|
||
|
||
# ============================== 裁决结果对象 ==============================
|
||
|
||
class JudgeOutcome(object):
|
||
"""
|
||
单次裁决的结构化结果(对应 §10.2 决策表一行)。
|
||
|
||
字段:
|
||
- row:命中的决策表行号 "D1".."D10"(可追溯);
|
||
- action:编排器下一步动作(ACTION_* 常量);
|
||
- decision:Verdict.decision 值(accept/fix/kill);非终判行(D1/D7/D8)为 None;
|
||
- reasons:受控词表记账值列表(§10.2「reasons 记账值」列,批报告据此聚合);
|
||
- callback:回调后端动作,None 或 (status, failureReason) 二元组;
|
||
- structure_ok / runnable_ok:写入 Verdict 的两布尔(D9 前未实玩的行 runnable_ok=False);
|
||
- note:行语义中文说明(入账本,便于人审计)。
|
||
"""
|
||
|
||
def __init__(self, row, action, decision, reasons, callback, structure_ok, runnable_ok, note):
|
||
self.row = row
|
||
self.action = action
|
||
self.decision = decision
|
||
self.reasons = list(reasons)
|
||
self.callback = callback
|
||
self.structure_ok = structure_ok
|
||
self.runnable_ok = runnable_ok
|
||
self.note = note
|
||
|
||
def as_dict(self):
|
||
"""账本 stage=judge 行的 data 载荷(全字段中文可溯)。"""
|
||
return {
|
||
"row": self.row,
|
||
"action": self.action,
|
||
"decision": self.decision,
|
||
"reasons": self.reasons,
|
||
"callback": list(self.callback) if self.callback else None,
|
||
"structureOk": self.structure_ok,
|
||
"runnableOk": self.runnable_ok,
|
||
"note": self.note,
|
||
}
|
||
|
||
|
||
# ============================== runnableOk 五条 AND(§9.4) ==============================
|
||
|
||
def compute_runnable_ok(play_report):
|
||
"""
|
||
对玩家 agent(D5)产出的 PlayReport(§9.6 形状)逐条核对 §9.4 五条 AND。
|
||
|
||
数据源铁律:全部为 CDP 捕获原始值的归一化形态;本函数只读核对,不做任何容错放水。
|
||
返回 (runnable_ok: bool, failed_clauses: [中文条款说明]),失败条款入账本便于归因。
|
||
|
||
PlayReport 预期键(§9.6):
|
||
loaded(bool) / clicks(int) / gameEnd({completed,durationMs}|None) /
|
||
consoleErrors([str]) / assetLoadErrors(int) / packageChecksumVerified(bool) /
|
||
emitsCaptured([str]) / demoFallback(bool,内部) / score(内部)
|
||
"""
|
||
if not isinstance(play_report, dict):
|
||
# 防御:非字典即编排器传参缺陷
|
||
raise JudgeError("PlayReport 必须为 dict(§9.6),实际类型=%s" % type(play_report).__name__)
|
||
|
||
failed = []
|
||
|
||
# ① 捕获 game_loaded:以归一化 loaded 布尔为准(D5 由 __qaEmits 信封派生)
|
||
if play_report.get("loaded") is not True:
|
||
failed.append("①未捕获 game_loaded(loaded!=true)")
|
||
|
||
# ② 捕获 game_end{completed:true}:布尔严格等值(B2′ 坑④ completed 漏发的回归面)
|
||
game_end = play_report.get("gameEnd")
|
||
if not isinstance(game_end, dict) or game_end.get("completed") is not True:
|
||
failed.append("②未捕获 game_end{completed:true}")
|
||
|
||
# ③ durationMs>0:D5 必须已做线缆蛇形 duration_ms → 驼峰 durationMs 的显式映射(§9.4-③/§2.4-2);
|
||
# 严格数值>0,防 startMs 缺省=0 假象
|
||
duration_ms = game_end.get("durationMs") if isinstance(game_end, dict) else None
|
||
if not (isinstance(duration_ms, (int, float)) and not isinstance(duration_ms, bool) and duration_ms > 0):
|
||
failed.append("③durationMs 非正(=%r)" % (duration_ms,))
|
||
|
||
# ④ 零 game_error 且 console 无致命错误(白名单噪声由 D5 在采集层过滤后产出 consoleErrors);
|
||
# assetLoadErrors>0 仅记录不否决(v1 assets=[] 理论为 0)
|
||
emits = play_report.get("emitsCaptured") or []
|
||
console_errors = play_report.get("consoleErrors")
|
||
if "game_error" in emits:
|
||
failed.append("④捕获到 game_error 信封")
|
||
if not isinstance(console_errors, list):
|
||
raise JudgeError("PlayReport.consoleErrors 必须为列表(§9.6),实际=%r" % (console_errors,))
|
||
if console_errors:
|
||
failed.append("④console 存在致命错误 %d 条" % len(console_errors))
|
||
|
||
# ⑤ 所玩包 checksum/config 与落包一致(§9.5 浏览器侧核验结论位)
|
||
# + §16 D5-a 三角合成裁决:核验方法必须可信——checksumVerifyMethod ∈ {browser_body, synthesized};
|
||
# none(或字段缺失)不得通过;packageChecksumVerified=true 却 method 不可信 = 报告自相矛盾,同判不过
|
||
if play_report.get("packageChecksumVerified") is not True:
|
||
failed.append("⑤包完整性校验未通过(packageChecksumVerified!=true)")
|
||
elif play_report.get("checksumVerifyMethod") not in ("browser_body", "synthesized"):
|
||
failed.append("⑤checksumVerifyMethod=%r 不可信(none/缺失不得通过,§16 D5-a)"
|
||
% (play_report.get("checksumVerifyMethod"),))
|
||
|
||
return (len(failed) == 0), failed
|
||
|
||
|
||
# ============================== 模板 GameConfig 深度校验(结构门权威,§8.4-3 职责分界) ==============================
|
||
|
||
def validate_config_against_schema(config, template_schema):
|
||
"""
|
||
用 D2 落盘的模板 schema(contracts/templates/<templateId>.schema.json)深度校验 GameConfig。
|
||
|
||
深度 schema 校验权威在裁决引擎侧(§8.4-3:后端回调只做非空+模板一致薄校验)。
|
||
标准库无 jsonschema,这里实现覆盖模板契约所需的 JSON Schema 子集:
|
||
type(object/string/integer/array)/required/properties/additionalProperties/const/enum/
|
||
minimum/maximum/minLength/maxLength/pattern/items/minItems/maxItems。
|
||
另按真源 gen_spike.py(C1 52/52 已验口径)补强:字符串非空 = strip 后非空(防纯空白绕过 minLength)。
|
||
array 支持(HJ-MC-TPL-EXEC-001 §7.3,merge itemLabel 引入必补):校验 minItems/maxItems
|
||
并逐元素递归套用 items 子 schema(items 复用 string 分支 minLength/pattern/strip 非空口径),
|
||
与后端 networknt SchemaValidator(支持 array/items/minItems/maxItems)同源同判,守同源铁律。
|
||
|
||
返回 (ok: bool, errors: [中文错误说明])。
|
||
"""
|
||
errors = []
|
||
if not isinstance(config, dict):
|
||
return False, ["config 必须为 JSON 对象,实际类型=%s" % type(config).__name__]
|
||
if not isinstance(template_schema, dict):
|
||
raise JudgeError("模板 schema 必须为 dict(D2 契约文件加载产物)")
|
||
|
||
props = template_schema.get("properties") or {}
|
||
required = template_schema.get("required") or []
|
||
|
||
# 必填字段缺失
|
||
for key in required:
|
||
if key not in config:
|
||
errors.append("缺少必填字段 %s" % key)
|
||
|
||
# additionalProperties:false → 多余字段拒绝(模板契约全字段封闭)
|
||
if template_schema.get("additionalProperties") is False:
|
||
for key in config:
|
||
if key not in props:
|
||
errors.append("存在契约外多余字段 %s" % key)
|
||
|
||
# 逐字段子 schema 校验
|
||
for key, sub in props.items():
|
||
if key not in config:
|
||
continue # 缺失已在 required 报过
|
||
value = config[key]
|
||
expect_type = sub.get("type")
|
||
if expect_type == "string":
|
||
if not isinstance(value, str):
|
||
errors.append("字段 %s 应为字符串,实际=%r" % (key, value))
|
||
continue
|
||
# 真源 gen_spike 口径:strip 后非空
|
||
min_len = sub.get("minLength", 0)
|
||
if min_len >= 1 and len(value.strip()) == 0:
|
||
errors.append("字段 %s 不得为空白字符串" % key)
|
||
if len(value) < min_len:
|
||
errors.append("字段 %s 长度 %d 低于下限 %d" % (key, len(value), min_len))
|
||
if "maxLength" in sub and len(value) > sub["maxLength"]:
|
||
errors.append("字段 %s 长度 %d 超上限 %d" % (key, len(value), sub["maxLength"]))
|
||
if "pattern" in sub and re.search(sub["pattern"], value) is None:
|
||
errors.append("字段 %s 不匹配模式 %s" % (key, sub["pattern"]))
|
||
elif expect_type == "integer":
|
||
# bool 是 int 子类,显式排除(真源 gen_spike._int_in 同口径)
|
||
if not isinstance(value, int) or isinstance(value, bool):
|
||
errors.append("字段 %s 应为整数,实际=%r" % (key, value))
|
||
continue
|
||
if "minimum" in sub and value < sub["minimum"]:
|
||
errors.append("字段 %s=%d 低于下限 %d" % (key, value, sub["minimum"]))
|
||
if "maximum" in sub and value > sub["maximum"]:
|
||
errors.append("字段 %s=%d 超上限 %d" % (key, value, sub["maximum"]))
|
||
elif expect_type == "array":
|
||
# 数组分支(§7.3 merge itemLabel 引入必补,与后端 networknt 同源同判)
|
||
if not isinstance(value, list):
|
||
errors.append("字段 %s 应为数组,实际=%r" % (key, value))
|
||
continue
|
||
# minItems/maxItems 元素数量边界
|
||
if "minItems" in sub and len(value) < sub["minItems"]:
|
||
errors.append("字段 %s 元素数 %d 低于下限 %d" % (key, len(value), sub["minItems"]))
|
||
if "maxItems" in sub and len(value) > sub["maxItems"]:
|
||
errors.append("字段 %s 元素数 %d 超上限 %d" % (key, len(value), sub["maxItems"]))
|
||
# items 子 schema:逐元素递归套用(复用 string 分支 minLength/pattern/strip 非空口径)
|
||
item_sub = sub.get("items") or {}
|
||
for i, elem in enumerate(value):
|
||
if item_sub.get("type") == "string":
|
||
if not isinstance(elem, str):
|
||
errors.append("字段 %s[%d] 应为字符串,实际=%r" % (key, i, elem))
|
||
continue
|
||
imin = item_sub.get("minLength", 0)
|
||
# 真源 gen_spike 口径:strip 后非空(防纯空白绕过 minLength)
|
||
if imin >= 1 and len(elem.strip()) == 0:
|
||
errors.append("字段 %s[%d] 不得为空白字符串" % (key, i))
|
||
if len(elem) < imin:
|
||
errors.append("字段 %s[%d] 长度 %d 低于下限 %d" % (key, i, len(elem), imin))
|
||
if "pattern" in item_sub and re.search(item_sub["pattern"], elem) is None:
|
||
errors.append("字段 %s[%d] 不匹配模式 %s" % (key, i, item_sub["pattern"]))
|
||
# const / enum(templateId 精确等值等)
|
||
if "const" in sub and value != sub["const"]:
|
||
errors.append("字段 %s 必须恒等于 %r,实际=%r" % (key, sub["const"], value))
|
||
if "enum" in sub and value not in sub["enum"]:
|
||
errors.append("字段 %s=%r 不在枚举 %r 内" % (key, value, sub["enum"]))
|
||
|
||
return (len(errors) == 0), errors
|
||
|
||
|
||
# ============================== findings 归一化与派生 ==============================
|
||
|
||
def _check_findings(findings):
|
||
"""
|
||
防御性校验 findings 形状:列表 + 每项 dict 且 severity ∈ {P0,P1,P2}。
|
||
对抗 agent 输出经编排器解析校验后才进裁决;此处再设防是「未匹配输入立即暴露」总则的一部分。
|
||
"""
|
||
if not isinstance(findings, list):
|
||
raise JudgeError("findings 必须为列表,实际类型=%s" % type(findings).__name__)
|
||
for idx, item in enumerate(findings):
|
||
if not isinstance(item, dict):
|
||
raise JudgeError("findings[%d] 必须为 dict,实际=%r" % (idx, item))
|
||
if item.get("severity") not in _VALID_SEVERITIES:
|
||
raise JudgeError("findings[%d].severity=%r 越出受控枚举 %s" % (idx, item.get("severity"), list(_VALID_SEVERITIES)))
|
||
|
||
|
||
def _slug(text, fallback):
|
||
"""把类别文本压成 [a-z0-9_] 受控记账后缀;压空则取兜底值(reasons 受控词表纪律,§6.2 补充)。"""
|
||
if not isinstance(text, str):
|
||
return fallback
|
||
s = unicodedata.normalize("NFKC", text).strip().lower()
|
||
s = re.sub(r"[^a-z0-9_]+", "_", s).strip("_")
|
||
return (s[:24] or fallback)
|
||
|
||
|
||
def _p0_category(findings):
|
||
"""
|
||
派生 D4 的 reasons 记账后缀 p0_<类别>。
|
||
优先取 finding 自带 category 字段;否则按对抗 prompt 写死的 P0 口径(违规/敏感/年龄不适,§5.3-2)
|
||
做确定性关键词归桶;兜底 uncategorized。纯确定性映射,无内容主观判断。
|
||
"""
|
||
first_p0 = next(f for f in findings if f.get("severity") == "P0")
|
||
if first_p0.get("category"):
|
||
return _slug(first_p0["category"], "uncategorized")
|
||
issue = str(first_p0.get("issue", ""))
|
||
# 确定性关键词归桶(与对抗 prompt 的 P0 三类口径一一对应)
|
||
if any(k in issue for k in ("违法", "违规", "侵权")):
|
||
return "violation"
|
||
if any(k in issue for k in ("敏感", "涉政", "涉黄", "色情", "涉暴")):
|
||
return "sensitive"
|
||
if any(k in issue for k in ("年龄", "未成年", "暴力", "血腥", "恐怖")):
|
||
return "age_inappropriate"
|
||
return "uncategorized"
|
||
|
||
|
||
# ============================== 裁决主函数(§10.2 决策表逐行落码) ==============================
|
||
|
||
def judge(schema_ok, dup_hit, findings, play_report, play_attempt, infra_signal, round_, infra_category=None):
|
||
"""
|
||
裁决决策表入口(§10.1 签名原样):
|
||
judge(schemaOk: bool, dupHit: bool, findings: list, playReport: PlayReport|None,
|
||
playAttempt: int, infraSignal: bool, round: 0|1)
|
||
可选 infra_category:D7 行 reasons 记账后缀 infra_<类别>(§11 失败路径表 F1-F11 的类别词,
|
||
由编排器按失败点传入,如 "llm"/"backend"/"callback"/"callback_tx"/"task_poll"/
|
||
"package_verify"/"demo_fallback"/"batch_timeout"),不改变 §10.1 位置参数契约。
|
||
|
||
按 E1→E6 顺序短路求值,每个输入组合唯一命中一行;不存在落空组合,
|
||
末尾仍 raise 防御(任何未匹配输入=代码缺陷立即暴露,禁止默认通过)。
|
||
"""
|
||
# ---------- 入参定义域防御(越界即调用方缺陷) ----------
|
||
if round_ not in (0, 1):
|
||
raise JudgeError("round 只能取 0/1(回炉额度合计 1 轮,§10.1),实际=%r" % (round_,))
|
||
_check_findings(findings)
|
||
has_p0 = any(f.get("severity") == "P0" for f in findings)
|
||
has_p1 = any(f.get("severity") == "P1" for f in findings)
|
||
# P2 不参与判定,仅留档(§10.1)
|
||
|
||
# ---------- E1 schema 门 ----------
|
||
if not schema_ok:
|
||
if round_ == 0:
|
||
# D1:本地重出——携 schema 错误回灌策划重出 1 次,round→1,回到 E1
|
||
#(不经对抗、不落包、不回调,消耗唯一回炉额度)
|
||
return JudgeOutcome(
|
||
row="D1", action=ACTION_SCHEMA_RETRY, decision=None,
|
||
reasons=["schema_retry"], callback=None,
|
||
structure_ok=False, runnable_ok=False,
|
||
note="E1 schema 门未过@round=0:本地重出一次(消耗唯一回炉额度)")
|
||
# D2:round=1 仍不合法 → kill;回调 failed + config_invalid
|
||
return JudgeOutcome(
|
||
row="D2", action=ACTION_KILL, decision=DECISION_KILL,
|
||
reasons=["schema_invalid_after_retry"], callback=CALLBACK_FAILED_CONFIG_INVALID,
|
||
structure_ok=False, runnable_ok=False,
|
||
note="E1 schema 门未过@round=1:额度用尽即杀")
|
||
|
||
# ---------- E2 查重门 ----------
|
||
if dup_hit:
|
||
# D3:批内撞重 → kill 留档负例,不回炉(撞重非 P1 可修类);
|
||
# 回调 failed + config_invalid 为 §16-1 归桶决断(真因 duplicate_in_batch 记 reasons)
|
||
return JudgeOutcome(
|
||
row="D3", action=ACTION_KILL, decision=DECISION_KILL,
|
||
reasons=["duplicate_in_batch"], callback=CALLBACK_FAILED_CONFIG_INVALID,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E2 查重门命中:批内同质化撞重即杀(round 无关)")
|
||
|
||
# ---------- E3 对抗 P0 ----------
|
||
if has_p0:
|
||
# D4:P0(违规/敏感/年龄不适)即杀,不给 fix(round 无关);回调 failed + unsafe_prompt
|
||
return JudgeOutcome(
|
||
row="D4", action=ACTION_KILL, decision=DECISION_KILL,
|
||
reasons=["p0_%s" % _p0_category(findings)], callback=CALLBACK_FAILED_UNSAFE_PROMPT,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E3 对抗评审存在 P0:即杀不回炉")
|
||
|
||
# ---------- E4 对抗 P1 ----------
|
||
if has_p1:
|
||
if round_ == 0:
|
||
# D5:fix——findings 回灌策划重出,round→1,全流程重走 E1
|
||
#(target 可能变,落包/实玩全重做);本轮不落包、不回调
|
||
return JudgeOutcome(
|
||
row="D5", action=ACTION_FIX, decision=DECISION_FIX,
|
||
reasons=["p1_fix_round"], callback=None,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E4 存在 P1@round=0:回炉 fix(与 schema 重出共享唯一额度)")
|
||
# D6:round=1 仍存在 P1 → kill(额度用尽);归桶同 D3
|
||
return JudgeOutcome(
|
||
row="D6", action=ACTION_KILL, decision=DECISION_KILL,
|
||
reasons=["p1_residual_after_fix"], callback=CALLBACK_FAILED_CONFIG_INVALID,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E4 存在 P1@round=1:额度用尽即杀")
|
||
|
||
# ---------- E5 落包+实玩 ----------
|
||
if infra_signal:
|
||
# D7:infra_fail——demo 兜底/浏览器挂/new-api 超时/落包核验不等/回调写链失败等;
|
||
# 编排器已按 §11 完成退避重试仍败才到此;不计分母、designId 可重放、计入熔断分子
|
||
category = _slug(infra_category or "unknown", "unknown")
|
||
return JudgeOutcome(
|
||
row="D7", action=ACTION_INFRA_FAIL, decision=None,
|
||
reasons=["infra_%s" % category], callback=None,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E5 基础设施失败(重试仍败):不计分母可重放,计熔断分子")
|
||
|
||
if play_report is None:
|
||
# 防御:文本面全过、无 infra 信号、又没有实玩报告——决策表无此组合 = 编排器调用缺陷
|
||
raise JudgeError("E5 缺实玩报告且无 infra 信号:决策表无此组合(编排器调用缺陷,禁止默认通过)")
|
||
|
||
if play_report.get("demoFallback") is True:
|
||
# 防御:demo 兜底必须由编排器归为 infraSignal=true(§9.5:不算通过也不算 kill),
|
||
# 流到 runnable 判定 = 编排器分类缺陷,立即暴露
|
||
raise JudgeError("PlayReport.demoFallback=true 却未置 infraSignal:demo 兜底必须按 D7 infra 处理(§9.5)")
|
||
|
||
runnable_ok, failed_clauses = compute_runnable_ok(play_report)
|
||
|
||
if not runnable_ok:
|
||
if play_attempt == 1:
|
||
# D8:重试实玩 1 次(不消耗回炉额度、不改 round),回到 E5
|
||
return JudgeOutcome(
|
||
row="D8", action=ACTION_PLAY_RETRY, decision=None,
|
||
reasons=["runnable_retry"], callback=None,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E5 runnable 五条 AND 未过@首玩:重试实玩一次;未过条款=%s" % ";".join(failed_clauses))
|
||
if play_attempt == 2:
|
||
# D9:kill-runnable——重试后仍非 runnable:不发布、留档告警;
|
||
# 任务保持 succeeded(区分「运行环境劣/设计劣」,不污染状态机语义,评审版原文),不再回调
|
||
return JudgeOutcome(
|
||
row="D9", action=ACTION_KILL, decision=DECISION_KILL,
|
||
reasons=["runnable_fail_after_retry"], callback=None,
|
||
structure_ok=True, runnable_ok=False,
|
||
note="E5 runnable 重试后仍未过:kill-runnable(任务保持 succeeded);未过条款=%s" % ";".join(failed_clauses))
|
||
raise JudgeError("playAttempt=%r 越出决策表定义(仅 1/2;实玩预算闸应已拦截)" % (play_attempt,))
|
||
|
||
# ---------- E6 终判 ----------
|
||
if runnable_ok:
|
||
# D10:accept——structureOk ∧ runnableOk ∧ 无 P0/P1 残留 ∧ 查重通过 四条 AND 至此全真
|
||
#(P2-only 组合归本行:P2 不挡 accept,仅留档)
|
||
return JudgeOutcome(
|
||
row="D10", action=ACTION_ACCEPT, decision=DECISION_ACCEPT,
|
||
reasons=["accept"], callback=None,
|
||
structure_ok=True, runnable_ok=True,
|
||
note="E6 终判:四条 AND 全真 → accept(发布段金丝雀≤10 内)")
|
||
|
||
# ---------- 防御 raise(§10.2 全组合覆盖论证的代码兜底) ----------
|
||
raise JudgeError("决策表全组合理论已覆盖仍落空 = 代码缺陷,禁止默认通过")
|
||
|
||
|
||
# ============================== Verdict 铸章(发布闸的代码层强制,§7.1) ==============================
|
||
|
||
# 模块私有铸章对象:只有本模块能把它挂到 Verdict 上;
|
||
# 编排器发布函数用 is_judge_accept() 验章,确保「发布函数仅接受 judge 返回的 accept Verdict」。
|
||
_VERDICT_SEAL = object()
|
||
|
||
|
||
class SealedVerdict(dict):
|
||
"""带裁决铸章的 Verdict(§6.2 契约全字段 dict + 私有完整性标记)。"""
|
||
|
||
def __init__(self, payload, seal):
|
||
super(SealedVerdict, self).__init__(payload)
|
||
# 铸章以实例属性持有,不进 dict 序列化面(json.dumps 只见契约字段)
|
||
self._seal = seal
|
||
|
||
|
||
def build_verdict(outcome, design_id, task_id, version_id, play_report, findings,
|
||
round_, trace_id, prompt_versions):
|
||
"""
|
||
据 JudgeOutcome 铸造 Verdict(§6.2 契约全字段;仅本模块可铸章)。
|
||
|
||
约束:
|
||
- outcome.decision 必须非空(D1/D7/D8 非终判行不产 Verdict——它们走账本 stage 行);
|
||
- playReport=null 仅允许出现在未到实玩即终判的分支(schema-kill/P0-kill/撞重-kill/fix,§6.2 补充);
|
||
- PlayReport 内部字段 demoFallback/score 不入 Verdict 契约(§9.6),此处剥除。
|
||
"""
|
||
if not isinstance(outcome, JudgeOutcome) or outcome.decision is None:
|
||
raise JudgeError("仅终判/回炉裁决(decision 非空)可铸 Verdict,实际 outcome=%r" % (outcome,))
|
||
public_report = None
|
||
if play_report is not None:
|
||
# 白名单只保留 §6.2 playReport 契约七字段闭集(verdict.schema additionalProperties:false):
|
||
# demoFallback/score/fiveAnd/checksumVerifyMethod 等 D5 超集内部键属账本 stage 行,
|
||
# 不入 Verdict 契约——§9.6 / §16 D5-a(method 仅作裁决输入与账本披露)
|
||
contract_keys = ("loaded", "clicks", "gameEnd", "consoleErrors",
|
||
"assetLoadErrors", "packageChecksumVerified", "emitsCaptured")
|
||
public_report = {k: play_report.get(k) for k in contract_keys}
|
||
# gameEnd 子投影防御(契约:gameEnd 为 null 或 {completed,durationMs} 两键闭集;
|
||
# durationMs 为 integer ≥0 不可 null——D5 线缆侧 duration_ms 缺失/非数值时此处归一化为 0,
|
||
# 原始值已由 runnableOk 条款③判定并留账本证据,契约字段只保「形状合法」)
|
||
game_end = public_report.get("gameEnd")
|
||
if isinstance(game_end, dict):
|
||
raw_ms = game_end.get("durationMs")
|
||
if isinstance(raw_ms, bool) or not isinstance(raw_ms, (int, float)) or raw_ms < 0:
|
||
norm_ms = 0 # 缺失/None/负值/布尔 → 0(防违反契约 integer/minimum:0)
|
||
else:
|
||
norm_ms = int(raw_ms) # float 取整(线缆为毫秒整数,防御性处理)
|
||
public_report["gameEnd"] = {
|
||
"completed": game_end.get("completed") is True, # 布尔严格等值(§9.4-②同口径)
|
||
"durationMs": norm_ms,
|
||
}
|
||
else:
|
||
public_report["gameEnd"] = None # 非 dict 一律归 null(未捕获 game_end 的契约形态)
|
||
payload = {
|
||
"designId": design_id,
|
||
"taskId": task_id,
|
||
"decision": outcome.decision,
|
||
"structureOk": outcome.structure_ok,
|
||
"runnableOk": outcome.runnable_ok,
|
||
"playReport": public_report,
|
||
"findings": findings,
|
||
"reasons": outcome.reasons,
|
||
"round": round_,
|
||
"evidence": {"traceId": trace_id, "promptVersions": dict(prompt_versions or {})},
|
||
}
|
||
if version_id is not None:
|
||
# versionId 为契约可选字段(§6.2:未到落包即终判的文本面分支无此字段)——
|
||
# 缺位语义=键不存在,而非 null(verdict.schema 的 type 不含 null)
|
||
payload["versionId"] = version_id
|
||
return SealedVerdict(payload, _VERDICT_SEAL)
|
||
|
||
|
||
def is_judge_accept(verdict):
|
||
"""
|
||
发布闸验章(§7.1 代码层强制):
|
||
仅当对象是本模块铸章的 SealedVerdict 且 decision=accept 才放行发布。
|
||
手工拼的 dict、伪造对象、非 accept 决定一律 False。
|
||
"""
|
||
return isinstance(verdict, SealedVerdict) \
|
||
and getattr(verdict, "_seal", None) is _VERDICT_SEAL \
|
||
and verdict.get("decision") == DECISION_ACCEPT
|