opus 执行位交付、主会话四步验收过(测试亲跑/红线 diff 亲读/硬证亲眼): - playtest.cdp.cjs 生产版(§3 八件:DPR=1 playtest/1 坐标协议/坐标尺/落点回显/ 同点硬提示/反早退/预算 14→24 随进展扩/fail 二掷显式 seed+清存档/图像自检 fail-closed exit=3)+ serve-and-playtest.sh;证据落 evidence/playtest/roll-N/ - cheap_verify:project_floor 四门投影(预筛权威唯一产地,绝不复用 harness 原生 verdict.pass)+ run_playtest(子进程/超时/记账/playtest.json 真相层/端口派生避 Chrome unsafe 5060/5061)+ run_acceptance 编排器(v2=floor∧测试员阻断/ shadow=旧口径+shadowV2Accepted 对照/v1=旧判定器;修复反馈只引现象段) - 三路接线:cheap_studio/cheap_service_driver/cheap_modify 两档全改调编排器, create 与 modify 单一验收标准 - §4 字段迁移:run-summary acceptanceVersion+playtest+floor 段、trace.playtest additive(firstPlay 三字段镜像 ReadinessScorer 口径)、批账 floorPass+accepted+ acceptanceVersion(verdictPass 仅对照);result_out 三级取值(floor→verdictFull 投影→verdict.pass 回落)、gate_judge 续修触发器切四门、xtheme 同批;判定语义单轨 - §5 Java 读侧:ReadinessScorer 可玩性/firstPlay 优先读 trace.playtest, GenMetrics 归因加 playtest/tester_degraded(向后兼容,未编译验证——本机无后端 构建环境,待 mini-desktop 构建窗口) - genconfig/generation.yaml:acceptance.mode 三态(波1 灰度默认 shadow)+ playtest 旋钮(steps/二掷/超时/成本上限);judge 段未动 - 单测 32 项新增(二掷/图像 fail-closed/mode 三态/投影/现象分离/退出码/unsafe 端口) 验收:pytest 512 绿 + node 44/44(主会话亲跑);10 局考卷生产件重跑判对 9/9 (真坏 3/3 零放行、假阴 0/6,heritage-r4/puzzle-r1 二掷翻案、sb2 假超时根因= Chrome unsafe 端口已修);Service E2E(w1v2-e2e1 shadow→v2 一发收敛)与 modify 回归(w1v2-mod1 ROUND_MS 60000→45000,src 与局内截图双证)真浏览器硬证; 总成本 ¥2.3。已知余项:真续修回喂环仅单测覆盖(波2 n=3 冒烟自然覆盖)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1346 lines
81 KiB
Python
1346 lines
81 KiB
Python
"""
|
||
cheap_verify.py — 便宜档「丰富度」LLM 验证 agent(非阻塞·只报告)。
|
||
|
||
架构红线(创始人多轮强调,绝不可破):**项目代码只做机械确定的事**;玩法/美术/音乐的「丰富」——
|
||
无论生成还是校验——都需大模型能力,**绝不写成代码校验**(code-presence/静态扫描/正则/断言一律禁)。
|
||
故本模块只做两件 100% 机械确定的事,「丰富不丰富」的判断本身完全交 LLM judge:
|
||
① 读产物 L3 源码(确定性文件 I/O)+ 拼 judge prompt(确定性字符串);
|
||
② 解析 LLM 返回的 JSON 成结构化评分(确定性解析 + degraded 兜底)。
|
||
本模块**不含任何丰富度的代码断言**——11 条丰富度清单只是【喂给 LLM 的评分尺】,命中与否全由 judge 读码后裁;
|
||
评分按层标 L2/L3/L4 聚合成三分组小计(L2 内容丰富 / L3 留存结构 / L4 传播钩子,不设单一总分),规范见质量模型 SoT §3.3 计分制。
|
||
(注:sim-business-game-design.md §10 写了「4 条可机检 code-presence」子集,那与本红线冲突——本模块刻意不照它写代码校验。)
|
||
|
||
非阻塞铁律:LLM 调失败/超时/解析失败 → 返回 {score:None, degraded:True, reason:...},
|
||
**绝不抛、绝不阻断生成、绝不进 verdict / 不改达标判定**。接线方(cheap_studio)再包一层 try/except 双保险。
|
||
|
||
被 cheap_studio.run_studio 收口段调用(九门 play 之后、写 run-summary 之前),结果 additive 写 summary["richness"]。
|
||
"""
|
||
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import subprocess
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap,故纯函数可在无 agentscope 环境单测。
|
||
|
||
# ── 通用底座 v2 = 11 条丰富度清单(judge 的评分 rubric)──
|
||
# 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。
|
||
# 规范来源 = 质量模型 SoT《游戏质量与爆火能力·设计》§3.3 计分制(每条 0/1 + 层标 L2/L3/L4,产三分组小计、不设单一总分)。
|
||
# v2 = 现行 8 条(与 .agents/skills/sim-business-game-design.md §10 同源)+ 新增 3 条(首 3 分钟脚本 / 炫耀时刻 / 同款钩子)。
|
||
# 每项 = (标准名, 层标, 含义);judge 按标准名逐条回命中与否 + 理由,命中数按层聚合成三分组小计。
|
||
# ★锚定纪律(§3.3 第 3 条):原 8 条判据文本与顺序一字不改(评分尺变更须金标复验、原 8 条判定漂移 ≤±1),
|
||
# 新增 3 条追加于末尾;层标只是聚合归属,不改原条目的判据本身。
|
||
# 分层含义:L2 内容丰富(有料耐玩)/ L3 留存结构(想再玩的结构前提)/ L4 传播钩子(想传出去的结构前提)。
|
||
RICHNESS_CHECKLIST = [
|
||
# 原 8 条(文本/顺序锁定,仅补层标)
|
||
("即时反馈", "L2", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
|
||
("可见成长", "L2", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
|
||
("下一个解锁", "L3", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
|
||
("30秒爽点", "L2", "开局 30 秒内有第一次升级/解锁"),
|
||
("数值滚雪球", "L2", "成长有「越来越快」的暴富段,而非平淡线性"),
|
||
("情感锚", "L2", "有萌角色/拥有物让玩家「想养大它」"),
|
||
("放置回归", "L3", "离线回来有惊喜(离线收益弹窗等)"),
|
||
("音反馈", "L2", "收益/升级/解锁有声音(哪怕程序化)"),
|
||
# 新增 3 条(v2 草案,随首个品类件落地验证;判据文本源自 §3.3 第 1 条)
|
||
("首3分钟脚本", "L3", "首 3 分钟脚本成立:0–10s 零阅读上手 / 10–60s 首次升级 / 1–3min 露出 2–3 个后续锁"),
|
||
("炫耀时刻", "L4", "有可截图分享的结算/成就画面(打完有结算展示场景,不是直接黑屏)"),
|
||
("同款钩子", "L4", "有清晰的品类原型 + 主题标签元数据,可供 remix 预填做同款"),
|
||
# 第 12 条(2026-07-03 增补,质量 SoT §4 规范一「通用底座 12 条口径」;创始人授权默认项拍板采纳):
|
||
# 源自便宜档主 prompt 自检第 ⑨ 条(策划知识包 v2 否决项判定句式),升为评分尺条目。
|
||
# 锚定纪律:追加于末尾、前 11 条一字不动;L2 分母随之 6→7,档位观测线百分比不变、分母随升;
|
||
# 落地与金标复验同批(复验漂移超线按规范三复采样定性再判回退)。
|
||
("核心操作非无脑", "L2", "每次主操作含真实决策或技巧含量、判错有真代价(如补错货压库存),不是点哪都加分/点了就自动结算"),
|
||
]
|
||
|
||
_MAX = len(RICHNESS_CHECKLIST) # = 12(2026-07-03 ⑨升第 12 条;此前 v2 = 11)
|
||
_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表)
|
||
|
||
# ── 品类扩展 rubric(W-GENRE 品类件④;质量模型 SoT §4 规范二)──
|
||
# 数据源唯一 = fixtures/genre-rubrics/<genre>.json(items 键,条目 {name,layer,meaning,positive,negative}),
|
||
# 经 load_genre_checklist 加载。2026-07-02 rubric 挂点归一:原内联 GENRE_CHECKLISTS 字典退役,
|
||
# TRPG 条目一字不动迁入 trpg.json——品类条目自此只有 fixture 一路,不再留内联第二源。
|
||
# 每品类另加 ≥4 条品类特有维度:同一字段形状 (标准名, 层标, 含义)、同样标层,
|
||
# **分母独立小计**(与通用底座分开、不混合平均——评分输出落 richness.genre.groups,通用 groups 不变)。
|
||
# 这仍是【喂给 LLM judge 的评分尺】数据、不是代码校验(红线:不进九门/不进 verdict/不进脚手架);
|
||
# 设计侧同源对齐 = .agents/skills/<genre>-game-design.md §10 品类自检。
|
||
# 锚定纪律(规范三):品类条目文本变更须金标复验(金标正例 + 薄反例,分组小计漂移 ≤±1 即回退)。
|
||
_GENRE_RUBRICS_DIR = Path(__file__).resolve().parent / "fixtures" / "genre-rubrics"
|
||
|
||
# 品类脚手架目录名 → 品类 key(run_studio 据 scaffold_template 透传 genre;None/未登记 → 不评品类扩展)。
|
||
# 非遗品类键收敛 = "heritage"(与 rubric 文件名/散文 skill heritage-game-design.md 同名):
|
||
# 模板目录 _template-feiyi 是资产目录名、金标 spec 的 _genre="heritage-craft" 是金标名(driver 承重不动),
|
||
# 三者同一品类——映射依据详见 fixtures/genre-rubrics/heritage.json 的 _note。
|
||
GENRE_BY_TEMPLATE = {
|
||
"_template-trpg": "trpg",
|
||
"_template-puzzle": "puzzle",
|
||
"_template-feiyi": "heritage",
|
||
# 2026-07-04 数据面补全(策划知识包 v2):剧情/经营两品类 fixture 落地补线,
|
||
# 条目源见各 fixture _note(narrative=skill §10 表逐字迁入;sim-business=质量 SoT §4 规范二名目展开)。
|
||
"_template-story": "narrative",
|
||
"_template-shop": "sim-business",
|
||
}
|
||
|
||
|
||
def load_genre_checklist(genre) -> list:
|
||
"""读品类 rubric fixture(品类条目唯一数据源)→ [(标准名, 层标, 判据)*];genre 空/文件缺/坏条目 → [](绝不抛)。
|
||
|
||
条目形状随 fixture(name/layer/meaning,正反例 positive/negative 只作金标锚材料、不进 judge prompt
|
||
——prompt 里逐条只给一句可判定判据,与通用底座条目同形)。
|
||
"""
|
||
if not genre or not isinstance(genre, str):
|
||
return []
|
||
try:
|
||
p = _GENRE_RUBRICS_DIR / (genre + ".json")
|
||
if not p.is_file():
|
||
return []
|
||
data = json.loads(p.read_text(encoding="utf-8"))
|
||
out = []
|
||
for it in (data.get("items") or []):
|
||
if not isinstance(it, dict):
|
||
continue
|
||
name, layer, meaning = it.get("name"), it.get("layer"), it.get("meaning")
|
||
if isinstance(name, str) and name and layer in _LAYERS and isinstance(meaning, str) and meaning:
|
||
out.append((name, layer, meaning))
|
||
return out
|
||
except Exception: # noqa: BLE001 fixture 坏 → 扩展为空,评分退化为纯通用底座,绝不阻断
|
||
return []
|
||
|
||
|
||
def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict:
|
||
"""各层条目数(分母)。通用底座 12 条口径 = L2:7 / L3:3 / L4:2(2026-07-03 第 12 条入 L2)。"""
|
||
m = {ly: 0 for ly in _LAYERS}
|
||
for _name, layer, _meaning in checklist:
|
||
m[layer] = m.get(layer, 0) + 1
|
||
return m
|
||
|
||
|
||
def _group_subtotals(hits: list, checklist=RICHNESS_CHECKLIST) -> dict:
|
||
"""把逐条命中按层标聚合成三分组小计 {L2:{score,max}, L3:..., L4:...}。
|
||
|
||
这是 §3.3 计分制的落库主形状——三层各自对得上自己的消费点(品类件验收/批次观测/回流分析),
|
||
**不做单一总分平均**(单一总分诱导 Goodhart,见 §3.3 第 1 条)。
|
||
"""
|
||
maxes = _layer_maxes(checklist)
|
||
scores = {ly: 0 for ly in _LAYERS}
|
||
for h in hits:
|
||
ly = h.get("layer")
|
||
if h.get("hit") and ly in scores:
|
||
scores[ly] += 1
|
||
return {ly: {"score": scores[ly], "max": maxes.get(ly, 0)} for ly in _LAYERS}
|
||
|
||
# 只读 L3 游戏本体(玩法/画面/数值/资产);跳过 L1 plumbing(host-config/game.js/index.html——
|
||
# 固定脚手架、与「游戏丰富不丰富」无关,喂给 judge 只会稀释信号)。
|
||
_L3_SOURCE_FILES = ("game-logic.js", "core.js", "render.js", "balance.js", "assets.js")
|
||
_MAX_SRC_BYTES = 24000 # 喂 judge 的源码总量上限(控 token / 成本;超限按字节截断)。
|
||
_JUDGE_MAX_TOKENS = 4000 # judge 输出小(11 条短理由 + 一句点评),4000 足够、M3 无 thinking。
|
||
|
||
# judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。
|
||
# 计数用 len(RICHNESS_CHECKLIST) 动态拼(2026-07-03 ⑨升第 12 条时改:此前「11 条」写死在文案里,
|
||
# 清单变更即漂;动态取数后条数永远与评分尺同源。评分尺变更本身仍受锚定纪律约束=金标复验)。
|
||
_JUDGE_SYSTEM = (
|
||
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
|
||
f"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出 {len(RICHNESS_CHECKLIST)} 条丰富度清单的命中与否 + 一句中文理由,"
|
||
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
|
||
f"这 {len(RICHNESS_CHECKLIST)} 条分三层:L2 内容丰富(有料耐玩)、L3 留存结构(想再玩的结构前提)、L4 传播钩子(想让别人看/做同款的前提);"
|
||
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
|
||
)
|
||
|
||
# 品类扩展评分时的 system 变体(W-GENRE 件④):仅把量词放宽到「通用 N 条 + 品类扩展若干条」,
|
||
# 其余判据措辞一字不动(锚定纪律:无品类路走上面原版 _JUDGE_SYSTEM)。
|
||
_JUDGE_SYSTEM_GENRE = (
|
||
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
|
||
f"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出丰富度清单(通用 {len(RICHNESS_CHECKLIST)} 条 + 品类扩展若干条)的命中与否 + 一句中文理由,"
|
||
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
|
||
"条目分三层:L2 内容丰富(有料耐玩)、L3 留存结构(想再玩的结构前提)、L4 传播钩子(想让别人看/做同款的前提);"
|
||
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
|
||
)
|
||
|
||
|
||
def _degraded(reason: str) -> dict:
|
||
"""降级结果(非阻塞铁律):score=None + degraded=True;score 为 None 表示「这次没评出来」,绝不参与达标。
|
||
|
||
groups 同置 None:三分组小计的消费面(品类件验收/回流分析)遇 None 即「本次未评出」,与 score=None 同义。
|
||
"""
|
||
return {"score": None, "max": _MAX, "hits": [], "groups": None, "notes": None, "degraded": True, "reason": reason}
|
||
|
||
|
||
def _coerce_bool(v) -> bool:
|
||
"""把 judge 可能给的多形态命中值归一为 bool(true/1/"是"/"命中" 等 → True;其余 → False)。"""
|
||
if isinstance(v, bool):
|
||
return v
|
||
if isinstance(v, (int, float)):
|
||
return v > 0
|
||
if isinstance(v, str):
|
||
return v.strip().lower() in ("true", "1", "yes", "y", "是", "命中", "hit", "对", "✓", "√")
|
||
return False
|
||
|
||
|
||
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST, genre_checklist=None, genre_key=None) -> dict:
|
||
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
|
||
|
||
成功 → {
|
||
score:int(11 条命中总数 0..11), # ★向后兼容字段:仅供 bake_off.richness_dist 读;非「质量总分」。
|
||
max:11, # 设计不设单一总分(诱导 Goodhart),主消费形状 = groups 三分组小计。
|
||
hits:[{name,layer,hit,why}*11], # 逐条:加 layer 层标(L2/L3/L4)
|
||
groups:{L2:{score,max}, L3:..., L4:...}, # ★三分组小计(§3.3 计分制主形状)
|
||
notes:str, degraded:False}
|
||
传 genre_checklist(品类扩展条目,W-GENRE 件④)时 additive 加:
|
||
genre:{key, hits:[…*N], score, max:N, groups:{…}} # ★品类分母独立小计(§4 规范二:不与通用混合平均)
|
||
解析失败(空/非 JSON 对象/缺 checks 数组)→ _degraded(...)(score=None, groups=None, degraded=True)。
|
||
|
||
对齐策略:优先按 name 对齐 judge 的 checks;name 对不上的按位置兜底(容忍模型改名/英文名)——
|
||
checks 数组约定 = 通用条目在前、品类条目紧随其后,位置兜底按段内偏移算。
|
||
layer 层标取自 checklist(不依赖 LLM 回传),故分组小计对模型改名鲁棒。
|
||
本函数**不含任何丰富度的代码判断**——只搬运 judge 的逐条裁决并按层聚合命中数。
|
||
"""
|
||
if not isinstance(text, str) or not text.strip():
|
||
return _degraded("judge 输出为空")
|
||
try:
|
||
# json_repair 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出。
|
||
import json_repair
|
||
data = json_repair.loads(text)
|
||
except Exception as e: # noqa: BLE001 解析层任何异常都降级,绝不抛
|
||
return _degraded(f"judge 输出解析失败:{type(e).__name__}")
|
||
if not isinstance(data, dict):
|
||
return _degraded("judge 输出不是 JSON 对象")
|
||
checks_raw = data.get("checks")
|
||
if not isinstance(checks_raw, list) or not checks_raw:
|
||
return _degraded("judge 输出缺 checks 数组")
|
||
|
||
by_name = {}
|
||
for c in checks_raw:
|
||
if isinstance(c, dict):
|
||
nm = str(c.get("name", "")).strip()
|
||
if nm:
|
||
by_name[nm] = c
|
||
|
||
def _align(items, offset):
|
||
"""按 name 对齐(对不上按位置兜底,位置 = offset + 段内下标)→ [{name,layer,hit,why}]。"""
|
||
out = []
|
||
for i, (name, layer, _meaning) in enumerate(items):
|
||
c = by_name.get(name)
|
||
pos = offset + i
|
||
if c is None and pos < len(checks_raw) and isinstance(checks_raw[pos], dict):
|
||
c = checks_raw[pos] # name 对不上 → 位置兜底
|
||
hit = _coerce_bool(c.get("hit")) if isinstance(c, dict) else False
|
||
why = (str(c.get("why", "")).strip()[:200]) if isinstance(c, dict) else ""
|
||
out.append({"name": name, "layer": layer, "hit": hit, "why": why}) # layer 取自 checklist(权威)
|
||
return out
|
||
|
||
hits = _align(checklist, 0)
|
||
score = sum(1 for h in hits if h["hit"])
|
||
notes = str(data.get("notes", "")).strip()[:500]
|
||
result = {"score": score, "max": len(checklist), "hits": hits,
|
||
"groups": _group_subtotals(hits, checklist), "notes": notes, "degraded": False}
|
||
|
||
# 品类扩展段(W-GENRE 件④):checks 数组的通用段之后是品类条目;**分母独立小计**(不并进通用 groups/score)。
|
||
if genre_checklist:
|
||
ghits = _align(genre_checklist, len(checklist))
|
||
result["genre"] = {
|
||
"key": genre_key,
|
||
"hits": ghits,
|
||
"score": sum(1 for h in ghits if h["hit"]),
|
||
"max": len(genre_checklist),
|
||
"groups": _group_subtotals(ghits, genre_checklist),
|
||
}
|
||
return result
|
||
|
||
|
||
def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
|
||
"""读产物 L3 源码(game-logic/core/render/balance/assets)拼成带文件头的一段文本,总量截断到 max_bytes。
|
||
|
||
只读 L3 游戏本体;读不到的文件跳过;全空 → 返回空串(上层据此降级、跳过 LLM、不白烧钱)。
|
||
"""
|
||
src_dir = cheap_run.game_dir(game_id) / "src"
|
||
parts = []
|
||
total = 0
|
||
for name in _L3_SOURCE_FILES:
|
||
p = src_dir / name
|
||
try:
|
||
if not p.is_file():
|
||
continue
|
||
txt = p.read_text(encoding="utf-8", errors="ignore")
|
||
except OSError:
|
||
continue # 单文件读失败不致命,跳过即可
|
||
chunk = f"\n// ===== {name} =====\n{txt}\n"
|
||
parts.append(chunk)
|
||
total += len(chunk.encode("utf-8"))
|
||
if total >= max_bytes:
|
||
break
|
||
joined = "".join(parts)
|
||
enc = joined.encode("utf-8")
|
||
if len(enc) > max_bytes: # 字节级截断兜底(防单个大文件超限撑爆 token)
|
||
joined = enc[:max_bytes].decode("utf-8", "ignore") + "\n// [源码已截断]"
|
||
return joined
|
||
|
||
|
||
def _build_judge_user(src_text: str, brief: str = "", genre_key: str = None, genre_checklist=None) -> str:
|
||
"""拼 judge 的 user 消息:brief(可选)+ 11 条清单(带层标)[+ 品类扩展清单] + 产物源码 + 严格 JSON 输出契约。
|
||
|
||
纯确定性字符串拼接。**不传品类时输出与既有版本逐字节一致**(金标复验基线稳,规范三锚定纪律);
|
||
传品类时通用清单后 additive 追加品类段、输出契约条数 = 11+N(checks 通用在前、品类紧随)。
|
||
"""
|
||
checklist_lines = "\n".join(
|
||
f"{i + 1}. [{layer}] {name}:{meaning}" for i, (name, layer, meaning) in enumerate(RICHNESS_CHECKLIST)
|
||
)
|
||
brief_block = f"这款游戏的 brief(玩家想要的):{brief}\n\n" if brief else ""
|
||
n_genre = len(genre_checklist) if genre_checklist else 0
|
||
if n_genre:
|
||
genre_lines = "\n".join(
|
||
f"{len(RICHNESS_CHECKLIST) + i + 1}. [{layer}] {name}:{meaning}"
|
||
for i, (name, layer, meaning) in enumerate(genre_checklist)
|
||
)
|
||
genre_block = (
|
||
f"\n另有 {n_genre} 条【{genre_key} 品类扩展清单】(分母独立、不与上面 {len(RICHNESS_CHECKLIST)} 条混合,同样逐条独立判命中):\n"
|
||
f"{genre_lines}\n"
|
||
)
|
||
total_note = f"共 {len(RICHNESS_CHECKLIST) + n_genre} 条(通用 {len(RICHNESS_CHECKLIST)} 条在前、品类 {n_genre} 条紧随其后)"
|
||
name_note = "name 用上面各条的中文名"
|
||
else:
|
||
genre_block = ""
|
||
total_note = f"共 {len(RICHNESS_CHECKLIST)} 条"
|
||
name_note = f"name 用上面 {len(RICHNESS_CHECKLIST)} 条的中文名"
|
||
return (
|
||
f"{brief_block}"
|
||
f"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 {len(RICHNESS_CHECKLIST)} 条丰富度清单(每条前的 [L2]/[L3]/[L4] 是分层标注——"
|
||
"L2 内容丰富、L3 留存结构、L4 传播钩子——仅用于分组,不改变你对每条的独立判命中),"
|
||
"据**源码里真实实现了的玩法/数值/音效/解锁/结算画面/品类元数据**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
|
||
"但收益处没真调 playSfx,则「音反馈」不算命中):\n\n"
|
||
f"{checklist_lines}\n"
|
||
f"{genre_block}\n"
|
||
"=== 源码开始 ===\n"
|
||
f"{src_text}\n"
|
||
"=== 源码结束 ===\n\n"
|
||
"严格只输出以下 JSON(不要任何额外文字、不要 markdown 围栏):\n"
|
||
f'{{"checks":[{{"name":"即时反馈","hit":true,"why":"一句中文理由"}}, … {total_note},'
|
||
f'{name_note}、hit 为 true/false], "notes":"整体一句话点评"}}'
|
||
)
|
||
|
||
|
||
def _extract_text(resp) -> str:
|
||
"""从 ChatResponse 抽纯文本:content 是 TextBlock/ToolCall... 序列,只取 text 块(兼容 pydantic 块与 dict 块)。"""
|
||
content = getattr(resp, "content", None)
|
||
if content is None and isinstance(resp, dict):
|
||
content = resp.get("content")
|
||
if isinstance(content, str):
|
||
return content
|
||
parts = []
|
||
for b in (content or []):
|
||
if isinstance(b, dict):
|
||
if b.get("type") == "text" and isinstance(b.get("text"), str):
|
||
parts.append(b["text"])
|
||
elif getattr(b, "type", None) == "text":
|
||
t = getattr(b, "text", None)
|
||
if isinstance(t, str):
|
||
parts.append(t)
|
||
return "".join(parts)
|
||
|
||
|
||
async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=None,
|
||
timeout: float = 90.0, max_src_bytes: int = _MAX_SRC_BYTES,
|
||
genre: str = None) -> dict:
|
||
"""生成完成后跑一次 LLM 丰富度评分(非阻塞·只报告)。**绝不抛、绝不阻断、绝不进 verdict / 不改达标**。
|
||
|
||
Args:
|
||
game_id: 本 run 游戏 id(读 games/amgen-<id>/src/ 下 L3 源码)。
|
||
brief: 本局 brief(喂 judge 做上下文,提升「情感锚/解锁」等判断质量);缺省空串。
|
||
model: 可选注入的 LLM 客户端(单测用 fake model 注入、零网络);None → 用 _bootstrap.build_cheap_model
|
||
新建**独立** M3 实例(judge token 不污染生成成本台账 costRmb——richness 是非阻塞 add-on)。
|
||
sources: 可选直接注入的源码文本(单测用,绕过文件 I/O);None → 从 game_id 收集。
|
||
timeout: judge LLM 调用超时秒数;超时 → degraded(绝不卡死收口)。
|
||
max_src_bytes: 喂 judge 的源码上限。
|
||
genre: 品类 key(W-GENRE 件④);命中 fixtures/genre-rubrics/<genre>.json(唯一数据源)→
|
||
同一次评分 additive 追加品类扩展条目(分母独立小计落 result["genre"]);
|
||
None/fixture 缺 → 行为与既有版本完全一致(prompt 逐字节不变)。
|
||
|
||
Returns:
|
||
成功 → parse_judge_output 的结构化评分(含 judgeTokens 观测,best-effort);
|
||
任何失败(src 空/LLM 异常/超时/解析失败)→ _degraded(...)(score=None, degraded=True, reason)。
|
||
"""
|
||
try:
|
||
src_text = sources if sources is not None else _collect_sources(game_id, max_bytes=max_src_bytes)
|
||
if not src_text or not src_text.strip():
|
||
return _degraded("产物 src 为空/读不到,跳过 LLM 评分")
|
||
|
||
# 惰性 import:agentscope / _bootstrap 较重,且让上面的 parse_judge_output 等纯函数能在无 agentscope 环境单测。
|
||
from agentscope.message import SystemMsg, UserMsg
|
||
m = model
|
||
if m is None:
|
||
import _bootstrap # 代理旁路 + key 注入由 _bootstrap.build_cheap_model 内部处理
|
||
m = _bootstrap.build_cheap_model(max_tokens=_JUDGE_MAX_TOKENS)
|
||
|
||
# 品类扩展(唯一数据源 = fixtures/genre-rubrics/<genre>.json,经 load_genre_checklist;
|
||
# 没有 fixture → 静默按 None——评分尺没有它的条目,别让评分半路造尺)。
|
||
genre_checklist = (load_genre_checklist(genre) or None) if genre else None
|
||
genre_key = genre if genre_checklist else None
|
||
|
||
sys_msg = SystemMsg(name="system", content=_JUDGE_SYSTEM_GENRE if genre_checklist else _JUDGE_SYSTEM)
|
||
user_msg = UserMsg(name="user", content=_build_judge_user(src_text, brief,
|
||
genre_key=genre_key, genre_checklist=genre_checklist))
|
||
# 硬超时包裹真模型调用:judge 卡住绝不能拖死收口(非阻塞铁律)。
|
||
resp = await asyncio.wait_for(m([sys_msg, user_msg]), timeout=timeout)
|
||
result = parse_judge_output(_extract_text(resp), genre_checklist=genre_checklist, genre_key=genre_key)
|
||
|
||
# best-effort 附 judge token(外部 LLM 调用可观测;与生成成本台账隔离——judge 用独立 model 实例)。
|
||
try:
|
||
ti, to = m.usage_sum()
|
||
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
|
||
except Exception: # noqa: BLE001 观测字段失败不影响评分主体
|
||
pass
|
||
return result
|
||
except (asyncio.TimeoutError, TimeoutError):
|
||
return _degraded(f"judge LLM 超时(>{timeout}s)")
|
||
except Exception as e: # noqa: BLE001 非阻塞铁律:任何异常(网络/import/解析)都降级,绝不抛断生成主链
|
||
return _degraded(f"judge 异常:{type(e).__name__}: {e}")
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 玩法地板判定(W-AXIS 波2 · 质量模型 SoT 裁定三的代码兑现)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# 与上面的丰富度评分是两物,别混:
|
||
# · 丰富度(verify_richness):评「有多丰富/多耐玩」的【程度】——非阻塞软信号,只观测、永不拒发(L2)。
|
||
# · 玩法地板(judge_gameplay_floor):裁「作为游戏成不成立」的【地板布尔】——阻断放行(L1 的判定半)。
|
||
# 裁定三钉死的分界:判定只裁三类【拒绝】的有无,不判程度高低:
|
||
# · broken —— 真人玩不通 / 到不了终局 / 起不来 / 卡死一屏 / 核心操作无响应;
|
||
# · hollow —— 空壳:核心循环没有决策层(点哪都一样、无输赢取舍、纯自动结算的挂机点击),判「有没有」不判「多好」;
|
||
# · off_brief —— 不切题:画面/玩法与 brief 要的游戏是两回事。
|
||
# 三条铁律(裁定三):
|
||
# ① 出题≠被考(2026-07-10 创始人修正边界):判定只吃运行证据不吃自报;判定模型允许 M3(生成同源)——
|
||
# 地板只裁「有无」不裁品味、同源盲区风险最小,且 07-09 spike 实证 M3 当场抓出 M3 自己写的渲染 bug;
|
||
# 同源残余风险由金标正反例+创始人抽玩定标兜底。修正动因=glm-5.2 唯一通道(闲鱼二手中转)图像支持
|
||
# 按池轮换、07-09 整日全盲,判定层可用性不能押注二手供给。SoT 修订随验收 v2 双评审落档。
|
||
# ② fail-closed:判定失败/评不出/无证据 → 不放行、标 degraded(绝不静默降级成自动通过)。
|
||
# ③ 校准与阻断开关:金标正反例复验 + 创始人抽玩定标;judge.blocking 开关默认 true,可一键整体回退到修订前口径。
|
||
# 判定模型走 new-api:生产档由 generation.yaml judge.model 定(现 MiniMax-M3,一手直连通道);
|
||
# 单局判定成本目标 ≤¥0.3(实测 4-6 帧约 ¥0.10,记账进 judge.costRmb)。
|
||
|
||
# 判定默认档(genconfig 不可达时的编译回落,生产以 YAML 为准):2026-07-10 随创始人决策切 M3。
|
||
# 07-09 探针实证 glm-5.2 与 MiniMax-M3 均能读图;glm-5.2 因二手供给整日全盲弃用。
|
||
_JUDGE_DEFAULT_MODEL = "MiniMax-M3"
|
||
# 三类拒绝的机读键 → 对外类名(off_brief 用下划线,与 LLM 输出的 camelCase offBrief 解耦)。
|
||
_FLOOR_CLASSES = (("broken", "broken"), ("hollow", "hollow"), ("offBrief", "off_brief"))
|
||
|
||
_FLOOR_SYSTEM = (
|
||
"你是轻量小游戏的【玩法地板判定 agent】。你不是打分器——只判「这款游戏作为一个游戏,地板成不成立」,"
|
||
"只裁【有没有】、绝不裁【做得多好】(丰富/耐玩/好玩的程度问题不归你,归另一套软评分)。"
|
||
"你只看运行证据(真玩截图 / 运行日志 / 取证状态时间线),绝不采信任何自报、承诺或源码里写了什么。"
|
||
"逐条判这三类【拒绝】是否成立:\n"
|
||
"· broken(坏死):真人根本玩不通 —— 起不来 / 一直卡在同一屏 / 核心操作点了没反应 / 到不了任何终局或结算。\n"
|
||
"· hollow(空壳):核心循环没有决策层 —— 点哪都一样、没有输赢、没有取舍、判错没有代价,纯自动结算的挂机点击。"
|
||
"只判「有没有决策层」,不判「决策做得多有层次」。\n"
|
||
"· off_brief(不切题):画面与玩法跟 brief 要的游戏是两回事(主题漂移 / 品类不符)。\n"
|
||
"任一类成立 → 整体判 reject;三类都不成立 → 判 accept。"
|
||
"证据不足以断定某一类是否成立时,该类按【成立(有问题)】算——地板判定 fail-closed,宁可错拦、不可放过坏游戏"
|
||
"(后面有人工复核与金标校准兜)。严格只输出 JSON,不要任何额外文字、不要 markdown 围栏。"
|
||
)
|
||
|
||
_FLOOR_OUTPUT_CONTRACT = (
|
||
"严格只输出以下 JSON(problem=true 表示「这一类有问题、应拒绝」,why 给一句中文依据;"
|
||
"imagesSeen=你在本条消息里【真实看到】的截图张数,整数——一张都看不到就如实填 0,"
|
||
"这个字段用于检测图像传输故障,绝不据它奖惩你):\n"
|
||
'{"imagesSeen":0,"broken":{"problem":false,"why":"…"},"hollow":{"problem":false,"why":"…"},'
|
||
'"offBrief":{"problem":false,"why":"…"},"verdict":"accept","notes":"整体一句话"}'
|
||
)
|
||
|
||
|
||
def _degraded_floor(reason: str) -> dict:
|
||
"""fail-closed 降级结果(裁定三②):评不出/无证据/调用失败 → 不放行、标 degraded。
|
||
|
||
accepted=False 是【地板判定的 fail-closed 语义】,与丰富度的 score=None(非阻塞观测)完全不同:
|
||
这里 degraded 直接构成「拒绝」(rejectClasses=['degraded']),阻断放行,由人工复核兜。
|
||
"""
|
||
return {"accepted": False, "verdict": "reject", "rejectClasses": ["degraded"],
|
||
"checks": [], "notes": None, "degraded": True, "reason": reason}
|
||
|
||
|
||
def parse_floor_judgment(text) -> dict:
|
||
"""纯函数:把判定 LLM 的 JSON 输出解析成结构化地板裁决(含 fail-closed 兜底)。可单测、零网络、零 agentscope。
|
||
|
||
成功 → {
|
||
accepted: bool, # = 三类均无问题 且 LLM verdict 未判 reject(两者取交,任一拒即拒)
|
||
verdict: "accept"|"reject",
|
||
rejectClasses: [str], # 命中的拒绝类子集(broken/hollow/off_brief)
|
||
checks: [{class, problem, why}*3],
|
||
notes: str, degraded: False}
|
||
解析失败 / 非对象 / 缺任一类判定字段 → _degraded_floor(...)(accepted=False、fail-closed)。
|
||
|
||
每一类容忍两种形状:{problem,why} 对象,或裸 bool(模型偷懒直接给 true/false);缺字段一律 fail-closed
|
||
(无法确认地板 → 保守拒绝),绝不把「没判出来」静默当成通过。
|
||
"""
|
||
if not isinstance(text, str) or not text.strip():
|
||
return _degraded_floor("判定输出为空")
|
||
try:
|
||
import json_repair # 容忍 markdown 围栏 / 前后赘语 / 尾逗号等 LLM 常见脏输出
|
||
data = json_repair.loads(text)
|
||
except Exception as e: # noqa: BLE001 解析层任何异常都 fail-closed,绝不抛
|
||
return _degraded_floor(f"判定输出解析失败:{type(e).__name__}")
|
||
if not isinstance(data, dict):
|
||
return _degraded_floor("判定输出不是 JSON 对象")
|
||
checks = []
|
||
for raw_key, cname in _FLOOR_CLASSES:
|
||
node = data.get(raw_key)
|
||
if isinstance(node, dict):
|
||
problem = _coerce_bool(node.get("problem"))
|
||
why = str(node.get("why", "")).strip()[:200]
|
||
elif node is not None: # 裸 bool/字符串:模型没按对象给,也接住
|
||
problem = _coerce_bool(node)
|
||
why = ""
|
||
else: # 缺这一类判定 → 无法确认地板 → fail-closed(不静默判过)
|
||
return _degraded_floor(f"判定输出缺 {raw_key} 字段(无法确认地板,fail-closed)")
|
||
checks.append({"class": cname, "problem": problem, "why": why})
|
||
reject_classes = [c["class"] for c in checks if c["problem"]]
|
||
llm_verdict = str(data.get("verdict", "")).strip().lower()
|
||
# accepted 需两者一致取 accept:三类均无问题 且 LLM 自评 verdict 不是 reject(任一给拒绝信号 → 拒,fail-closed 偏严)。
|
||
accepted = (not reject_classes) and (llm_verdict != "reject")
|
||
# imagesSeen:模型自报真实看到的截图张数(图像盲检测的结构化信号;缺失/非数字 → None=未知,不触发盲重掷)。
|
||
# 背景(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体确定性路由,~15% 载荷永远落在丢图通道——
|
||
# 同载荷重试必盲、任何字节微扰即换通道。靠措辞 grep 判盲太脆,改为契约字段模型自报。
|
||
images_seen = None
|
||
try:
|
||
raw_seen = data.get("imagesSeen")
|
||
if raw_seen is not None and not isinstance(raw_seen, bool):
|
||
images_seen = max(0, int(raw_seen))
|
||
except (TypeError, ValueError):
|
||
images_seen = None
|
||
return {"accepted": accepted, "verdict": "accept" if accepted else "reject",
|
||
"rejectClasses": reject_classes, "checks": checks, "imagesSeen": images_seen,
|
||
"notes": str(data.get("notes", "")).strip()[:300], "degraded": False}
|
||
|
||
|
||
class _JudgeResp:
|
||
"""判定多模态调用的极简响应载体(content=纯文本);与丰富度 _extract_text 的 str 分支兼容。"""
|
||
|
||
def __init__(self, text: str):
|
||
self.content = text if isinstance(text, str) else ""
|
||
|
||
|
||
class _NewapiVisionModel:
|
||
"""判定用的薄多模态客户端:直连 new-api /v1/chat/completions(OpenAI 兼容,支持 image_url 图像块)。
|
||
|
||
为什么不复用 build_cheap_model(agentscope M3):判定层要的是薄而可控的多模态 HTTP 客户端——
|
||
直连网关对图像块形状可控(image_url data-URI 已 probe 坐实)、finish_reason/reasoning 元信息可留痕,
|
||
与生成栈(agentscope 全家桶)解耦;仍走 new-api 统一出口(base+/v1 + NEWAPI_KEY),不违统一网关铁律。
|
||
(判定模型 2026-07-10 起=MiniMax-M3,由 YAML judge.model 定;出题≠被考边界修正见 §判定地板头注。)
|
||
trust_env=False:判定目标在内网 Tailscale(new-api 100.64.0.8),彻底绕开本机 clash 代理(fake-ip 拦本地/内网,
|
||
§7 内网直连必绕代理)。records 记 (in,out,cached) 供 judge 段成本记账(与生成成本台账隔离——判定用独立档)。
|
||
"""
|
||
|
||
def __init__(self, model_name: str, base_url: str, api_key: str, *,
|
||
max_tokens: int = 1500, timeout: float = 120.0):
|
||
self.model_name = model_name
|
||
self.base_url = base_url.rstrip("/")
|
||
self.api_key = api_key
|
||
self.max_tokens = max_tokens
|
||
self.timeout = timeout
|
||
self.records = [] # [(input_tokens, output_tokens, cached_tokens)]
|
||
self.last_meta = {} # 最近一次响应的诊断元信息(finishReason/reasoningChars)——content 空时据此归因
|
||
|
||
async def __call__(self, messages):
|
||
import httpx # noqa: PLC0415 惰性 import(顶层零重依赖;与 cheap_verify 纯函数可无 httpx 环境单测同源)
|
||
body = {"model": self.model_name, "max_tokens": self.max_tokens, "messages": messages}
|
||
async with httpx.AsyncClient(trust_env=False, timeout=self.timeout) as h:
|
||
r = await h.post(
|
||
f"{self.base_url}/chat/completions",
|
||
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||
json=body,
|
||
)
|
||
r.raise_for_status()
|
||
d = r.json()
|
||
u = d.get("usage") or {}
|
||
pi = int(u.get("prompt_tokens") or u.get("input_tokens") or 0)
|
||
po = int(u.get("completion_tokens") or u.get("output_tokens") or 0)
|
||
cached = 0
|
||
det = u.get("prompt_tokens_details") or {}
|
||
if isinstance(det, dict):
|
||
cached = int(det.get("cached_tokens") or 0)
|
||
self.records.append((pi, po, cached))
|
||
ch = (d.get("choices") or [{}])[0]
|
||
msg = ch.get("message") or {}
|
||
text = msg.get("content", "")
|
||
# 判定器自身的真相层:留 finish_reason 与 reasoning 长度。glm-5.2 带思考(reasoning_content),思考长度是
|
||
# 随机变量——吃光 max_tokens 时 content 为空、finish_reason=length(w2b-verify 2026-07-09 实测案),据此可诊断。
|
||
self.last_meta = {"finishReason": ch.get("finish_reason"),
|
||
"reasoningChars": len(msg.get("reasoning_content") or "")}
|
||
return _JudgeResp(text if isinstance(text, str) else "")
|
||
|
||
def usage_sum(self):
|
||
return (sum(r[0] for r in self.records), sum(r[1] for r in self.records))
|
||
|
||
|
||
def _wg1_evidence_dir(game_id: str) -> Path:
|
||
"""判卷证据目录 = _wg1-gen/<id>/evidence/(九门 play 落 first-paint/after-play/midplay/game-log/verdict 处)。"""
|
||
return cheap_run.wg1_game_dir(game_id) / "evidence"
|
||
|
||
|
||
def _natural_key(name: str):
|
||
"""midplay-2 排在 midplay-10 前:按名字里的数字段自然排序(纯字母段保原序)。"""
|
||
import re # noqa: PLC0415
|
||
return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", name)]
|
||
|
||
|
||
def _collect_frames(evidence_dir, *, max_frames: int = 6) -> list:
|
||
"""收 evidence 目录的真玩截图 → [data:image/png;base64,...],顺序 first-paint → 局中连拍 midplay-* → after-play。
|
||
|
||
超 max_frames 时保头尾(首帧+局末)、中间连拍均匀采样(判定需要「局前→局中→局末」的时间跨度,不能只留一头)。
|
||
读不到目录 / 无截图 → [](上层据此 fail-closed:真玩过的游戏必有 first-paint,缺失即证据异常)。
|
||
"""
|
||
try:
|
||
ev = Path(evidence_dir)
|
||
if not ev.is_dir():
|
||
return []
|
||
ordered = []
|
||
fp = ev / "first-paint.png"
|
||
if fp.is_file():
|
||
ordered.append(fp)
|
||
ordered.extend(sorted(ev.glob("midplay-*.png"), key=lambda p: _natural_key(p.name)))
|
||
ap = ev / "after-play.png"
|
||
if ap.is_file():
|
||
ordered.append(ap)
|
||
if len(ordered) > max_frames:
|
||
head, tail, mid = ordered[0], ordered[-1], ordered[1:-1]
|
||
keep = max_frames - 2
|
||
if keep <= 0:
|
||
ordered = [head, tail]
|
||
else:
|
||
step = len(mid) / keep
|
||
ordered = [head] + [mid[int(i * step)] for i in range(keep)] + [tail]
|
||
out = []
|
||
for p in ordered:
|
||
try:
|
||
out.append("data:image/png;base64," + base64.b64encode(p.read_bytes()).decode())
|
||
except OSError:
|
||
continue
|
||
return out
|
||
except Exception: # noqa: BLE001 证据装配失败 → 返回空(上层 fail-closed),绝不抛
|
||
return []
|
||
|
||
|
||
def _read_state_timeline(evidence_dir) -> str:
|
||
"""从九门 verdict.json 抽一段取证状态时间线(判定的文字证据,补截图看不清的数值/终态信号)。
|
||
|
||
只搬运机械门已测出的客观信号(不加解读):H_progress 的 score 玩前/玩后、E_live 的 played/phase/活性、
|
||
latch 的终局 phase 与是否驻留、D_render 的亮度。读不到/坏 → 空串(判定仍靠截图,不伪造)。
|
||
"""
|
||
try:
|
||
ev = Path(evidence_dir)
|
||
p = ev / "verdict.json"
|
||
if not p.is_file():
|
||
return ""
|
||
v = json.loads(p.read_text(encoding="utf-8"))
|
||
guards = (v or {}).get("guards") or {}
|
||
lines = []
|
||
hg = guards.get("H_progress") or {}
|
||
for c in (hg.get("checks") or []):
|
||
if isinstance(c, dict):
|
||
lines.append(f"进展[{c.get('path')}] {c.get('op')}: 玩前={c.get('before')} 玩后={c.get('after')}"
|
||
f"(机械门判 {'达成' if c.get('pass') else '未达成'})")
|
||
latch = hg.get("latch") or {}
|
||
if isinstance(latch, dict) and latch:
|
||
lines.append(f"终局 latch: 到达终态={bool(latch.get('pass'))} 此刻 phase={latch.get('phaseNow') or latch.get('after')}"
|
||
f" {(latch.get('reason') or '')[:60]}")
|
||
el = guards.get("E_live") or {}
|
||
if isinstance(el, dict) and el:
|
||
lines.append(f"活性 E_live: 真进过游玩={el.get('played')} 玩后 phase={el.get('phase1')}"
|
||
f" 去重画面态数={el.get('distinctStates')} 状态真变={el.get('liveByState')}")
|
||
dr = guards.get("D_render") or {}
|
||
if isinstance(dr, dict) and dr:
|
||
lines.append(f"渲染 D_render: 亮度={dr.get('bright')} 最大通道={dr.get('maxCh')}")
|
||
return "\n".join(lines)
|
||
except Exception: # noqa: BLE001 取证时间线 best-effort,读不出降级空串
|
||
return ""
|
||
|
||
|
||
def _read_game_log_text(evidence_dir, *, max_entries: int = 12, max_chars: int = 800) -> str:
|
||
"""从 evidence/game-log.json 摘运行时日志(游戏语义 + 插件告警),给判定补运行期信号。读不到/坏 → 空串。"""
|
||
try:
|
||
ev = Path(evidence_dir)
|
||
p = ev / "game-log.json"
|
||
if not p.is_file():
|
||
return ""
|
||
arr = json.loads(p.read_text(encoding="utf-8"))
|
||
if not isinstance(arr, list) or not arr:
|
||
return ""
|
||
|
||
def _fmt(e):
|
||
if not isinstance(e, dict):
|
||
return str(e)[:100]
|
||
return f"[{e.get('scope')}:{e.get('tag')}] {e.get('msg') or ''}"[:100]
|
||
|
||
return "\n".join(_fmt(e) for e in arr[-max_entries:])[:max_chars]
|
||
except Exception: # noqa: BLE001 日志摘要 best-effort
|
||
return ""
|
||
|
||
|
||
def _build_floor_messages(brief: str, state_text: str, log_text: str, data_uris: list) -> list:
|
||
"""拼判定的 OpenAI 多模态消息:brief + 取证时间线 + 日志 + 「首帧/局中/局末」截图序列 + 输出契约。"""
|
||
head = (
|
||
f"这款游戏的 brief(玩家想要的):{brief or '(未提供)'}\n\n"
|
||
"下面给你这一局【真玩】留下的运行证据。请只据这些运行证据判三类拒绝是否成立。\n\n"
|
||
f"【取证状态时间线(机械门客观测得,未加解读)】\n{state_text or '(无)'}\n\n"
|
||
f"【运行时日志(节选)】\n{log_text or '(无)'}\n\n"
|
||
f"【真玩截图】接下来 {len(data_uris)} 张图按时间顺序给出:第 1 张=首帧(刚进入),"
|
||
"中间=局中连拍(真玩过程),最后 1 张=局末(真玩结束时)。请对照 brief 看这些画面是不是一款玩得通、"
|
||
"有决策层、且切题的游戏:\n"
|
||
)
|
||
parts = [{"type": "text", "text": head}]
|
||
for uri in data_uris:
|
||
parts.append({"type": "image_url", "image_url": {"url": uri}})
|
||
parts.append({"type": "text", "text": "\n" + _FLOOR_OUTPUT_CONTRACT})
|
||
return [{"role": "system", "content": _FLOOR_SYSTEM},
|
||
{"role": "user", "content": parts}]
|
||
|
||
|
||
def _build_judge_model(model_name: str, max_tokens: int, timeout: float):
|
||
"""装判定多模态客户端(直连 new-api /v1)。key 从 env/凭据档解析(内网阶段单一事实源)。"""
|
||
import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入由 _bootstrap/client 处理
|
||
_bootstrap.ensure_api_key_env()
|
||
from worker import client # noqa: PLC0415
|
||
base = client.resolve_base_url().rstrip("/")
|
||
if not base.endswith("/v1"):
|
||
base = base + "/v1"
|
||
return _NewapiVisionModel(model_name, base, client.get_api_key(),
|
||
max_tokens=max_tokens, timeout=timeout)
|
||
|
||
|
||
def _estimate_judge_cost(model_name: str, ti: int, to: int, cached: int):
|
||
"""据 new-api /api/pricing 权威倍率把判定 token 折成 ¥(与生成成本台账同口径 observability.cost.compute)。
|
||
|
||
best-effort:取价失败(网关不可达 / 无 httpx)→ None(judge 段记 tokens、costRmb 留空,不伪造成本)。
|
||
"""
|
||
try:
|
||
from observability import cost as _cost, newapi_pricing as _np # noqa: PLC0415
|
||
params = _np.fetch_pricing_params()
|
||
if not params:
|
||
return None
|
||
d = _cost.compute(model_name, ti, to, params["pricing"], params["qpu"],
|
||
params["usd_rate"], group_ratio=1.0, cached_tokens=cached)
|
||
return round(d["rmb"], 5)
|
||
except Exception: # noqa: BLE001 成本记账 best-effort,绝不连累判定主体
|
||
return None
|
||
|
||
|
||
def _persist_judge_json(evidence_dir, result: dict, raw_text: str) -> None:
|
||
"""判定真相层落盘(best-effort,绝不抛):evidence/judge.json = 完整地板裁决 + LLM 原始输出头(截断 2000 字)。
|
||
|
||
动机(W-AXIS 波2 验收发现,2026-07-09):判定结果此前只进 run-summary → 后端 trace,本地证据目录零痕迹——
|
||
verdict.json 孤零零 pass=true,盘上看不出这局判定发生过什么;degraded(如「判定输出为空」)更无从诊断。
|
||
判定器自己也要有真相层:每次判定(含 degraded)都在证据目录留完整裁决与原始输出。目录不存在则静默跳过(单测 fake gid)。
|
||
"""
|
||
try:
|
||
ev = Path(evidence_dir) if evidence_dir else None
|
||
if ev is None or not ev.is_dir():
|
||
return
|
||
payload = dict(result)
|
||
payload["rawTextHead"] = (raw_text or "")[:2000]
|
||
payload["ts"] = int(time.time() * 1000)
|
||
(ev / "judge.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception: # noqa: BLE001 落盘失败绝不连累判定主体
|
||
pass
|
||
|
||
|
||
async def judge_gameplay_floor(game_id: str, *, brief: str = "", model=None, frames=None,
|
||
state_text=None, log_text=None, evidence_dir=None,
|
||
model_name: str = None, max_tokens: int = 1500,
|
||
timeout: float = 120.0, max_frames: int = 6) -> dict:
|
||
"""对一局真玩证据跑独立多模态玩法地板判定(阻断权威;裁定三)。**fail-closed**:任何失败/无证据 → degraded(=拒绝)。
|
||
|
||
与 verify_richness 的非阻塞铁律相反——这里失败不是「静默降级放行」,而是「fail-closed 拒绝」:
|
||
评不出的游戏不许自动过,标 degraded 交人工复核。
|
||
|
||
Args:
|
||
game_id: 本局 id(读 _wg1-gen/<id>/evidence/ 下截图/日志/verdict)。
|
||
brief: 本局 brief(off_brief 判定必需)。
|
||
model: 可注入的判定 LLM 客户端(单测用 fake,零网络);None → 建 glm-5.2 多模态客户端。
|
||
frames/state_text/log_text: 可直接注入的证据(单测用,绕文件 I/O);None → 从 evidence_dir 收集。
|
||
evidence_dir: 证据目录;None → _wg1-gen/<id>/evidence/。
|
||
model_name/max_tokens/timeout/max_frames: 判定档旋钮(缺省走 glm-5.2 / 1500 / 120s / 6 帧)。
|
||
|
||
Returns:
|
||
parse_floor_judgment 的结构(附 judgeTokens/costRmb/model/frames 观测),或 _degraded_floor(...)(fail-closed)。
|
||
"""
|
||
mn = model_name or _JUDGE_DEFAULT_MODEL
|
||
ev = None
|
||
try:
|
||
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
|
||
imgs = frames if frames is not None else _collect_frames(ev, max_frames=max_frames)
|
||
if not imgs:
|
||
# 无真玩截图:真玩过的游戏必落 first-paint.png,缺失即证据异常 → fail-closed(绝不无证据放行)。
|
||
r = _degraded_floor("真玩截图证据缺失(无 first-paint/midplay/after-play),fail-closed 不放行")
|
||
r["model"] = mn
|
||
_persist_judge_json(ev, r, "")
|
||
return r
|
||
st = state_text if state_text is not None else _read_state_timeline(ev)
|
||
lg = log_text if log_text is not None else _read_game_log_text(ev)
|
||
m = model if model is not None else _build_judge_model(mn, max_tokens, timeout)
|
||
|
||
async def _roll(msgs):
|
||
"""一掷判定 = 一次调用 + 空输出有界重试一次(2026-07-09 w2b-verify 实测案):glm-5.2 带思考,
|
||
思考长度是随机变量——吃光 max_tokens 时 content 为空;同证据复跑即可得答案(主会话复现坐实)。
|
||
重试前放大 max_tokens 对症;绝不无限重试(判定成本有账)。放大取 max(原值, ×2封顶6000):
|
||
base 已调大(YAML 202752)时绝不反向缩小——旧式 min(×2,6000) 在大 base 下会把上限缩回 6000。"""
|
||
t = ""
|
||
used = 0
|
||
for attempt in range(2):
|
||
resp = await asyncio.wait_for(m(msgs), timeout=timeout)
|
||
used = attempt + 1
|
||
t = _extract_text(resp)
|
||
if isinstance(t, str) and t.strip():
|
||
break
|
||
if attempt == 0 and hasattr(m, "max_tokens"):
|
||
cur = int(getattr(m, "max_tokens", 1500))
|
||
m.max_tokens = max(cur, min(cur * 2, 6000))
|
||
return t, used
|
||
|
||
text, attempts_used = await _roll(_build_floor_messages(brief, st, lg, imgs))
|
||
result = parse_floor_judgment(text)
|
||
# 图像盲微扰重掷(2026-07-09 基线终审实锤):网关对 glm-5.2 按请求体【确定性】路由,部分载荷永远落在
|
||
# 丢图通道——同载荷重试必盲(3 例假阴复判 3/3 仍盲),而 brief 尾加一个空格微扰载荷即换路由、当场看见。
|
||
# 故:送了帧而模型自报 imagesSeen=0 → 微扰重掷一次;重掷仍自报 0 → 这是判定仪器故障不是游戏证据缺失,
|
||
# fail-closed degraded 归因「图像通道盲」交人工,不再把好游戏错杀成 hollow/off_brief(基线 3/20 假阴案)。
|
||
# imagesSeen=None(模型没报/老 fake)不触发,向后兼容。
|
||
if imgs and not result.get("degraded") and result.get("imagesSeen") == 0:
|
||
text2, used2 = await _roll(_build_floor_messages(brief + " ", st, lg, imgs))
|
||
attempts_used += used2
|
||
second = parse_floor_judgment(text2)
|
||
if not second.get("degraded") and second.get("imagesSeen") == 0:
|
||
second = _degraded_floor("判定模型两掷均自报看不见截图(网关同载荷确定性丢图)——判定仪器故障 fail-closed,待人工复核")
|
||
second["imageBlindRetried"] = True
|
||
result, text = second, text2
|
||
result["model"] = mn
|
||
result["frames"] = len(imgs)
|
||
if attempts_used > 1:
|
||
result["retries"] = attempts_used - 1
|
||
# 判定器真相层:附最近响应的 finish_reason(空输出归因的第一手证据;fake 模型无此属性则省略)。
|
||
meta = getattr(m, "last_meta", None)
|
||
if isinstance(meta, dict) and meta.get("finishReason") is not None:
|
||
result["finishReason"] = meta.get("finishReason")
|
||
# 判定成本记账(独立档,不污染生成 costRmb)——best-effort。
|
||
try:
|
||
ti, to = m.usage_sum()
|
||
cached = sum(r[2] for r in getattr(m, "records", []) if len(r) > 2)
|
||
result["judgeTokens"] = {"in": ti, "out": to, "total": ti + to}
|
||
result["costRmb"] = _estimate_judge_cost(mn, ti, to, cached)
|
||
except Exception: # noqa: BLE001 成本/观测字段失败不影响判定主体
|
||
pass
|
||
_persist_judge_json(ev, result, text)
|
||
return result
|
||
except (asyncio.TimeoutError, TimeoutError):
|
||
r = _degraded_floor(f"判定 LLM 超时(>{timeout}s),fail-closed 不放行")
|
||
r["model"] = mn
|
||
_persist_judge_json(ev, r, "")
|
||
return r
|
||
except Exception as e: # noqa: BLE001 fail-closed:任何异常(网络/import/解析)都判 degraded=拒绝,绝不放行
|
||
r = _degraded_floor(f"判定异常:{type(e).__name__}: {e}")
|
||
r["model"] = mn
|
||
_persist_judge_json(ev, r, "")
|
||
return r
|
||
|
||
|
||
def _judge_cfg() -> dict:
|
||
"""读判定档配置(genconfig judge.*;worker 不可达时回落编译默认,保证无 agentscope 环境也能取值/单测)。"""
|
||
try:
|
||
from worker import genconfig # noqa: PLC0415
|
||
g = genconfig.get
|
||
except Exception: # noqa: BLE001 无 worker(纯单测环境)→ 用编译默认
|
||
def g(_area, _key, default):
|
||
return default
|
||
return {
|
||
"blocking": bool(g("judge", "blocking", True)),
|
||
"model": g("judge", "model", _JUDGE_DEFAULT_MODEL),
|
||
"max_tokens": int(g("judge", "max_tokens", 1500)),
|
||
"timeout_s": float(g("judge", "timeout_s", 120.0)),
|
||
"max_frames": int(g("judge", "max_frames", 6)),
|
||
"cost_target_rmb": float(g("judge", "cost_target_rmb", 0.3)),
|
||
}
|
||
|
||
|
||
def _writeback_verdict_json(evidence_dir, accepted: bool, judge_summary: dict) -> None:
|
||
"""把最终验收权威增量写回盘上 evidence/verdict.json(best-effort,绝不抛)。
|
||
|
||
字段只加不减:`pass` 物理保留(语义=预筛/机械九门),新增 `accepted`(=预筛∧玩法判定,最终权威)与
|
||
`judge` 摘要段。动机(W-AXIS 波2 验收发现):此前判定只进 run-summary→后端 trace,盘上 verdict.json
|
||
孤零零 pass=true——证据链与真实验收结论脱节(§6.13 亲眼验收看的就是盘上证据)。verdict.json 不存在则跳过。
|
||
"""
|
||
try:
|
||
p = (Path(evidence_dir) / "verdict.json") if evidence_dir else None
|
||
if p is None or not p.is_file():
|
||
return
|
||
v = json.loads(p.read_text(encoding="utf-8"))
|
||
if not isinstance(v, dict):
|
||
return
|
||
v["accepted"] = bool(accepted)
|
||
v["judge"] = judge_summary
|
||
p.write_text(json.dumps(v, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception: # noqa: BLE001 写回失败绝不连累判定主链
|
||
pass
|
||
|
||
|
||
async def apply_gameplay_judge(summary: dict, *, game_id: str, brief: str = "",
|
||
model=None, blocking=None, evidence_dir=None,
|
||
prefilter=None) -> dict:
|
||
"""把玩法地板判定接到 run-summary,落 ok 语义切换(W-AXIS 波2 · SoT 裁定三代码兑现)。
|
||
|
||
ok 语义(裁定三):`ok = 预筛 ∧ 判定`。verdict 的 `pass` 字段物理保留、语义收窄为「预筛(机械九门)通过」,
|
||
新增 `accepted`(= 预筛 ∧ 玩法地板判定)与 `judge` 段(裁决/三类理由/degraded/成本)——字段只加不减。
|
||
|
||
· 预筛 = 显式 `prefilter` 入参;缺省(None)回落 summary 现有的 ok。**预筛必须是机械九门口径**(裁定一:
|
||
driven 时九门全量 AND)——Service 路 summary.ok=九门 judge.passed 天然合规可走缺省;CLI(cheap_studio)路
|
||
summary.ok=finished(模型自称收敛),**不是**九门信号,必须显式传 prefilter=finished∧verdict.pass
|
||
(2026-07-09 W3 真跑暴露:三门挂的局按 finished 预筛被判定放行成 accepted=True,违裁定三,已修)。
|
||
· 判定只对【过筛者】跑(省 ¥:预筛没过的游戏已被机械门拒,不再花判定钱;对齐诊断档 §5「预筛先挡明显死」)。
|
||
· blocking=True(默认)→ ok=accepted(判定阻断放行);blocking=False → ok=预筛(判定观测不阻断,整体回退位)。
|
||
· fail-closed:证据缺/调用失败/解析失败 → judge.degraded=True → floor 不成立 → (blocking) accepted=False。
|
||
|
||
绝不抛(顶层兜底):additive 写 summary['accepted'] 与 summary['judge'],并按上式更新 summary['ok']。
|
||
blocking/evidence_dir 缺省 None:blocking→读 genconfig;evidence_dir→按 game_id 推 _wg1-gen/<id>/evidence/。
|
||
"""
|
||
try:
|
||
cfg = _judge_cfg()
|
||
blk = cfg["blocking"] if blocking is None else bool(blocking)
|
||
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
|
||
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
|
||
if not prefilter:
|
||
# 预筛未过:机械门已拒,不花判定钱。accepted=预筛(False);ok 维持 False。
|
||
summary["judge"] = {"ran": False, "blocking": blk,
|
||
"reason": "预筛(机械九门)未过,不跑玩法判定"}
|
||
summary["accepted"] = False
|
||
summary["ok"] = False
|
||
_writeback_verdict_json(ev, False, summary["judge"])
|
||
return summary
|
||
floor = await judge_gameplay_floor(
|
||
game_id, brief=brief, model=model, evidence_dir=ev,
|
||
model_name=cfg["model"], max_tokens=cfg["max_tokens"],
|
||
timeout=cfg["timeout_s"], max_frames=cfg["max_frames"])
|
||
floor_ok = bool(floor.get("accepted")) and not bool(floor.get("degraded"))
|
||
accepted = prefilter and floor_ok
|
||
summary["judge"] = {
|
||
"ran": True, "blocking": blk,
|
||
"verdict": floor.get("verdict"),
|
||
"accepted": bool(floor.get("accepted")),
|
||
"rejectClasses": floor.get("rejectClasses"),
|
||
"checks": floor.get("checks"),
|
||
"degraded": bool(floor.get("degraded")),
|
||
"reason": floor.get("reason"),
|
||
"notes": floor.get("notes"),
|
||
"model": floor.get("model"),
|
||
"frames": floor.get("frames"),
|
||
"costRmb": floor.get("costRmb"),
|
||
"judgeTokens": floor.get("judgeTokens"),
|
||
}
|
||
summary["accepted"] = accepted
|
||
summary["ok"] = accepted if blk else prefilter
|
||
# 阻断路且未过:degraded 走可重试类失败因(result_out 缺省 llm_error 即可重试;不覆盖已有 quota_exhausted)。
|
||
if blk and not accepted and floor.get("degraded"):
|
||
summary.setdefault("failureReason", "llm_error")
|
||
# 盘上证据链与最终验收结论对齐(judge.json 由 judge_gameplay_floor 已落;这里补 verdict.json 增量段)。
|
||
_writeback_verdict_json(ev, accepted, summary["judge"])
|
||
return summary
|
||
except Exception as e: # noqa: BLE001 判定接线级异常:fail-closed(blocking→不放行),observe 模式保预筛。
|
||
blk = True if blocking is None else bool(blocking)
|
||
try:
|
||
blk = _judge_cfg()["blocking"] if blocking is None else bool(blocking)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
prefilter = bool(summary.get("ok")) if prefilter is None else bool(prefilter)
|
||
summary["judge"] = {"ran": False, "blocking": blk, "degraded": True,
|
||
"reason": f"判定接线异常(fail-closed):{type(e).__name__}: {e}"}
|
||
summary["accepted"] = False if blk else prefilter
|
||
summary["ok"] = False if blk else prefilter
|
||
try: # 接线异常路也尽力对齐盘上证据(ev 推导自身可能失败,再兜一层)
|
||
_writeback_verdict_json(
|
||
evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id),
|
||
summary["accepted"], summary["judge"])
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return summary
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 验收 v2:四门投影 + 测试 agent 真玩 + 统一编排器(W-AXIS-V2 波1 · 「拆着杀」)
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# W-AXIS 三波把验收权威交给独立模型判定,但证据仍由「一份固定代码玩几十种游戏」的取证契约(play-spec /
|
||
# tap-targets 驱动器 / _forensicsView)生产——n=5 基线逐局破案坐实这条证据生产线本身就是最大失败源。
|
||
# 本段兑现创始人 2026-07-09「拆着杀」:契约无关的四门(A/B/C/D)降为地板保留,依赖契约的 E/G/H/I/F 与
|
||
# 驱动器退役,验收证据的生产与裁决交测试 agent(playtest.cdp.cjs:视觉引导真玩 + 玩法地板判定合一)。
|
||
# 三路(CLI/Service/modify)共用一个编排器 run_acceptance;acceptance.mode 三态 v2/shadow/v1 控阻断与灰度。
|
||
|
||
# 四门 = 契约无关:读装载标志 / 未捕获异常 / 引擎帧号 / canvas 像素,不需游戏配合任何自报接口,
|
||
# 零成本、确定性、抓真死(基线里 boot 空指针正是 A 门拦的)。F_wiring 的期望前缀集源自 play-spec、
|
||
# 随契约退役降观测,不入地板(裁定三 2026-07-10 修订)。
|
||
_FLOOR_GATES = ("A_boot", "B_uncaught", "C_frame", "D_render")
|
||
|
||
|
||
def project_floor(verdict) -> dict:
|
||
"""四门投影(floor 段的**唯一产地**):从 verdict.guards 显式抽取 A_boot/B_uncaught/C_frame/D_render 求 AND。
|
||
|
||
这是消费层函数,**绝不复用 harness 原生 verdict.pass**——那是九门(driven)/七门(undriven)AND、随驱动器语义,
|
||
拆契约后会静默坍缩;四门投影独立于它,是便宜档预筛的权威取值(裁定一 2026-07-10 修订)。
|
||
任一门缺失(guards 无该门 dict)或 pass 非 True → floor.pass=False(fail-closed:无四门证据不放行)。
|
||
返回 {pass: bool, gates: {A,B,C,D: bool}}(短键 A/B/C/D = plan §4 字段协议)。
|
||
"""
|
||
guards = (verdict or {}).get("guards") or {}
|
||
gates = {}
|
||
all_ok = True
|
||
for full in _FLOOR_GATES:
|
||
short = full.split("_")[0] # A_boot → A
|
||
node = guards.get(full)
|
||
ok = isinstance(node, dict) and node.get("pass") is True
|
||
gates[short] = ok
|
||
all_ok = all_ok and ok
|
||
return {"pass": all_ok, "gates": gates}
|
||
|
||
|
||
def _acceptance_cfg() -> dict:
|
||
"""读验收 v2 配置(genconfig acceptance.* + playtest.* + judge.*;worker 不可达回落编译默认,保单测)。"""
|
||
try:
|
||
from worker import genconfig # noqa: PLC0415
|
||
g = genconfig.get
|
||
except Exception: # noqa: BLE001 无 worker(纯单测)→ 编译默认
|
||
def g(_area, _key, default):
|
||
return default
|
||
return {
|
||
"mode": str(g("acceptance", "mode", "shadow")), # v2|shadow|v1(波1 灰度窗口默认 shadow)
|
||
"model": g("judge", "model", _JUDGE_DEFAULT_MODEL), # 测试员 = 判定档同一配置(§2;现 MiniMax-M3)
|
||
"steps_base": int(g("playtest", "steps_base", 14)), # 基础步数预算(§3 第6件)
|
||
"steps_max": int(g("playtest", "steps_max", 24)), # 有进展扩展上限
|
||
"second_roll": bool(g("playtest", "second_roll", True)),# fail 二掷确认(§3 第7件)
|
||
"timeout_s": float(g("playtest", "timeout_s", 600.0)), # 单掷子进程墙钟超时
|
||
"cost_cap_rmb": float(g("playtest", "cost_cap_rmb", 1.5)), # 单局验收成本上限(含二掷)
|
||
"blocking": bool(g("judge", "blocking", True)), # 测试员裁决是否阻断放行
|
||
}
|
||
|
||
|
||
def _playtest_script() -> Path:
|
||
"""serve-and-playtest.sh 路径(与 serve-and-play.sh 同套起服编排,末步换调 playtest.cdp.cjs)。"""
|
||
return cheap_run._WG1_DIR / "_shared" / "serve-and-playtest.sh"
|
||
|
||
|
||
def _playtest_env() -> dict:
|
||
"""subprocess env:复用 cheap_run 起服 env(CHROME_BIN + NO_PROXY)+ 注入 NEWAPI_KEY/BASE(测试员连 new-api)。
|
||
|
||
playtest.cdp.cjs 用 Node http 模块直连 new-api、天然绕系统代理;key/base 由本函数从凭据档解析后注入 env。
|
||
"""
|
||
env = dict(cheap_run._shell_env())
|
||
try:
|
||
import _bootstrap # noqa: PLC0415 代理旁路时序与 key 注入
|
||
_bootstrap.ensure_api_key_env()
|
||
from worker import client # noqa: PLC0415
|
||
env["NEWAPI_KEY"] = client.get_api_key()
|
||
env["NEWAPI_BASE_URL"] = client.resolve_base_url()
|
||
except Exception: # noqa: BLE001 凭据解析失败 → 交 cjs 自身的 env 兜底(仍可能从进程环境拿到)
|
||
pass
|
||
return env
|
||
|
||
|
||
# Chrome unsafe ports(net::ERR_UNSAFE_PORT):派生范围 4998..5097 内被 Chrome 封锁的页面加载端口。
|
||
# 2026-07-10 考卷真跑实锤:hard-sim-business-r2 派生到 5060(SIP),curl 就绪检查 200 而 Chrome navigate
|
||
# 直接拒(NAT slipstreaming 防护),__genBooted 永不置位 → 假 boot-timeout。派生必须跳过。
|
||
_CHROME_UNSAFE_PORTS = {5060, 5061}
|
||
|
||
|
||
def _derive_playtest_ports(game_id) -> tuple:
|
||
"""按 game_id 确定性派生测试员起服端口(避并发多局撞:Service 异步队列 / modify / 批跑可能同时收口)。
|
||
|
||
基址 4998/9331(spike 同段,与九门 play 的 4320/9222 分离)+ game_id 稳定 hash % 100(md5,禁 Python
|
||
随机化 hash)。同一 game_id 恒得同端口(可复现);不同 game_id 大概率不撞(并发 ≤15 远低于 100 槽)。
|
||
静态服务端口撞 Chrome unsafe 清单(5060/5061=SIP,页面加载被 ERR_UNSAFE_PORT 拒)→ +2 避开
|
||
(CDP 端口无此限制——unsafe 清单只管页面加载,不管 Chrome 自身监听)。
|
||
"""
|
||
import hashlib # noqa: PLC0415
|
||
h = int(hashlib.md5(str(game_id).encode("utf-8")).hexdigest()[:4], 16) % 100
|
||
port = 4998 + h
|
||
if port in _CHROME_UNSAFE_PORTS:
|
||
port += 2 # 5060→5062 / 5061→5063(5062/5063 不在清单;仍确定性可复现)
|
||
return port, 9331 + h
|
||
|
||
|
||
def _run_playtest_roll_sync(game_id, brief_file, *, model_name, roll, seed, steps_base, steps_max,
|
||
evidence_dir, port, cdp_port, timeout) -> dict:
|
||
"""同步跑一掷(subprocess serve-and-playtest.sh → playtest.cdp.cjs);解析 stdout 末行 JSON 裁决。
|
||
|
||
绝不抛——超时/异常/未产裁决一律归 degraded 裁决(fail-closed)。退出码语义(playtest/1 协议):
|
||
0=正常裁决、2=运行错误/装载失败(degraded)、3=图像通道故障(degraded,归因测试员图像通道非游戏缺陷)。
|
||
"""
|
||
argv = ["bash", str(_playtest_script()), str(game_id), str(port), str(cdp_port), "--",
|
||
f"--model={model_name}", f"--roll={roll}", f"--seed={seed}",
|
||
f"--steps-base={steps_base}", f"--steps-max={steps_max}",
|
||
f"--brief-file={brief_file}", f"--evidence-dir={evidence_dir}"]
|
||
try:
|
||
r = subprocess.run(argv, cwd=str(cheap_run._GAME_RUNTIME), capture_output=True, text=True,
|
||
timeout=timeout, env=_playtest_env(), check=False)
|
||
except subprocess.TimeoutExpired:
|
||
return {"degraded": True, "reason": f"测试员子进程超时(>{timeout}s),fail-closed", "exitCode": -1, "roll": roll}
|
||
except Exception as e: # noqa: BLE001 subprocess 层异常一律 fail-closed
|
||
return {"degraded": True, "reason": f"测试员子进程异常:{type(e).__name__}: {e}", "exitCode": -1, "roll": roll}
|
||
# 解析 stdout 末行单行 JSON(playtest.cdp.cjs 末行 = 裁决)。
|
||
out = None
|
||
for line in reversed((r.stdout or "").splitlines()):
|
||
line = line.strip()
|
||
if line.startswith("{") and line.endswith("}"):
|
||
try:
|
||
out = json.loads(line)
|
||
break
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if out is None:
|
||
return {"degraded": True, "reason": "测试员未产出裁决 JSON(见 raw)", "exitCode": r.returncode,
|
||
"raw": (r.stdout + r.stderr)[-1000:], "roll": roll}
|
||
out["exitCode"] = r.returncode
|
||
# 退出码/自报标志 → degraded(fail-closed):运行错误、装载失败、图像通道故障都不放行。
|
||
if r.returncode == 2 or out.get("runnerError") or out.get("bootDead"):
|
||
out["degraded"] = True
|
||
out.setdefault("reason", "测试员运行错误/装载失败,fail-closed")
|
||
if r.returncode == 3 or out.get("imageBlind"):
|
||
out["degraded"] = True
|
||
out["imageBlind"] = True
|
||
out.setdefault("reason", "测试员图像通道故障(自报看不到截图),fail-closed 归因测试员非游戏")
|
||
return out
|
||
|
||
|
||
def _roll_brief(roll) -> dict:
|
||
"""把单掷裁决压成轻量摘要(落 playtest.json 的 rolls 列表 / 批次账,不塞完整转写)。"""
|
||
return {"roll": roll.get("roll"), "seed": roll.get("seed"), "pass": roll.get("pass"),
|
||
"degraded": bool(roll.get("degraded")), "canSee": roll.get("canSee"),
|
||
"steps": roll.get("steps"), "tapPoints": roll.get("tapPoints"),
|
||
"summary": roll.get("summary"), "reason": roll.get("reason"),
|
||
"tokens": roll.get("tokens")}
|
||
|
||
|
||
def _playtest_roll_cost(model_name, roll) -> float:
|
||
"""据 new-api 权威倍率把一掷 token 折成 ¥(与判定成本同口径);取价失败 → 0.0(不伪造成本、不误停)。"""
|
||
tok = roll.get("tokens") or {}
|
||
ti, to = int(tok.get("in") or 0), int(tok.get("out") or 0)
|
||
c = _estimate_judge_cost(model_name, ti, to, 0)
|
||
return float(c) if isinstance(c, (int, float)) else 0.0
|
||
|
||
|
||
def _persist_playtest_json(evidence_dir, result: dict) -> None:
|
||
"""测试员验收真相层落盘(best-effort,绝不抛):evidence/playtest/playtest.json = 汇总裁决 + 二掷账。
|
||
|
||
与 judge.json 同一动机(W-AXIS 波2):验收结论必须在盘上留痕,§6.13 亲眼验收看的就是它 + roll-N 截图序列。
|
||
"""
|
||
try:
|
||
ev = Path(evidence_dir) if evidence_dir else None
|
||
if ev is None:
|
||
return
|
||
pdir = ev / "playtest"
|
||
pdir.mkdir(parents=True, exist_ok=True)
|
||
payload = dict(result)
|
||
payload["ts"] = int(time.time() * 1000)
|
||
(pdir / "playtest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception: # noqa: BLE001 落盘失败绝不连累验收主链
|
||
pass
|
||
|
||
|
||
async def run_playtest(game_id, *, brief="", model_name=None, evidence_dir=None,
|
||
port=None, cdp_port=None, steps_base=None, steps_max=None,
|
||
second_roll=None, timeout=None, cost_cap_rmb=None, seed=None) -> dict:
|
||
"""测试 agent 真玩验收(阻断权威;裁定三 v2)。**fail-closed**:超时/图像故障/未裁决 → degraded=拒绝。
|
||
|
||
二掷(§3 第7件):roll-1 判 fail 且非 degraded、second_roll 开、成本未撞上限 → roll-2(不同 seed、清
|
||
localStorage 独立);两掷同 fail 才落 fail,roll-2 pass 即翻案(治 puzzle 类坐标失手假阴)。pass 一掷即过。
|
||
|
||
Returns:
|
||
playtest 段 dict:{accepted, verdict, pass, degraded, reason, problems, feedback, summary,
|
||
firstPlay, rolls, rollCount, costRmb, model, imageBlind}。落 evidence/playtest/playtest.json。
|
||
"""
|
||
cfg = _acceptance_cfg()
|
||
mn = model_name or cfg["model"]
|
||
sb = steps_base if steps_base is not None else cfg["steps_base"]
|
||
smax = steps_max if steps_max is not None else cfg["steps_max"]
|
||
sr = cfg["second_roll"] if second_roll is None else bool(second_roll)
|
||
to = timeout if timeout is not None else cfg["timeout_s"]
|
||
cap = cost_cap_rmb if cost_cap_rmb is not None else cfg["cost_cap_rmb"]
|
||
ev = Path(evidence_dir) if evidence_dir is not None else _wg1_evidence_dir(game_id)
|
||
base_seed = seed if seed is not None else int(time.time() * 1000) % 1_000_000
|
||
if port is None or cdp_port is None: # 缺省 → 按 game_id 派生 per-game 端口(并发避撞)
|
||
dp, dc = _derive_playtest_ports(game_id)
|
||
port = dp if port is None else port
|
||
cdp_port = dc if cdp_port is None else cdp_port
|
||
_NULL_FIRSTPLAY = {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}
|
||
|
||
result = {"model": mn, "rolls": [], "degraded": False}
|
||
brief_file = None
|
||
try:
|
||
import tempfile # noqa: PLC0415 brief 写临时文件(诚实来源,避免命令行转义/长度)
|
||
with tempfile.NamedTemporaryFile("w", suffix=".brieftxt", delete=False, encoding="utf-8") as bf:
|
||
bf.write(brief or "")
|
||
brief_file = bf.name
|
||
|
||
async def _one(roll, s):
|
||
# subprocess 阻塞 → to_thread 让出事件循环(超时由 subprocess timeout 兜)。
|
||
return await asyncio.to_thread(
|
||
_run_playtest_roll_sync, game_id, brief_file,
|
||
model_name=mn, roll=roll, seed=s, steps_base=sb, steps_max=smax,
|
||
evidence_dir=str(ev), port=port, cdp_port=cdp_port, timeout=to)
|
||
|
||
# roll-1
|
||
r1 = await _one(1, base_seed)
|
||
result["rolls"].append(_roll_brief(r1))
|
||
cost = _playtest_roll_cost(mn, r1)
|
||
final = r1
|
||
# 二掷:仅当 roll-1 是【确定的 fail】(非 degraded)、开关开、成本未撞线时;degraded 不二掷(图像/运行故障重掷无益)。
|
||
if (not r1.get("degraded")) and (r1.get("pass") is False) and sr and cost < cap:
|
||
r2 = await _one(2, base_seed + 7919) # 素数偏移换 seed(禁隐式时钟种子)
|
||
result["rolls"].append(_roll_brief(r2))
|
||
cost += _playtest_roll_cost(mn, r2)
|
||
if r2.get("pass") is True:
|
||
final = r2 # roll-2 翻案 → pass
|
||
elif r2.get("degraded"):
|
||
final = r2 # roll-2 degraded → fail-closed
|
||
else:
|
||
final = r1 # 两掷同 fail → 维持 fail
|
||
except Exception as e: # noqa: BLE001 编排层任何异常 → fail-closed degraded
|
||
result.update({"accepted": False, "verdict": "reject", "pass": False, "degraded": True,
|
||
"reason": f"测试员编排异常:{type(e).__name__}: {e}", "problems": [], "feedback": "",
|
||
"summary": "orchestration-error", "firstPlay": dict(_NULL_FIRSTPLAY),
|
||
"rollCount": len(result["rolls"]), "costRmb": 0.0, "imageBlind": False})
|
||
_persist_playtest_json(ev, result)
|
||
return result
|
||
finally:
|
||
if brief_file:
|
||
try:
|
||
import os as _os # noqa: PLC0415
|
||
_os.unlink(brief_file)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
final_pass = final.get("pass") is True
|
||
final_degraded = bool(final.get("degraded"))
|
||
result.update({
|
||
"accepted": final_pass and not final_degraded,
|
||
"verdict": "accept" if (final_pass and not final_degraded) else "reject",
|
||
"pass": final_pass,
|
||
"degraded": final_degraded,
|
||
"reason": final.get("reason"),
|
||
"problems": final.get("problems") or [], # 现象+推测(修复反馈拼装只引现象,见 _build_repair_feedback)
|
||
"feedback": final.get("feedback") or "",
|
||
"summary": final.get("summary") or "",
|
||
"firstPlay": final.get("firstPlay") or dict(_NULL_FIRSTPLAY),
|
||
"rollCount": len(result["rolls"]),
|
||
"costRmb": round(cost, 5),
|
||
"imageBlind": bool(final.get("imageBlind")),
|
||
})
|
||
_persist_playtest_json(ev, result)
|
||
return result
|
||
|
||
|
||
def _build_repair_feedback(floor: dict, playtest: dict) -> str:
|
||
"""修复反馈拼装(现象/推测强制分离,续修**只引现象段**)——给 writer resume 的续修来源。
|
||
|
||
治 spike 三次「测试员失手后错误归因」失效模式:测试员 problems 里凡含「推测」的分句剔除,只留带步号+落点的
|
||
客观现象进反馈;degraded 则不派续修(fail-closed 待人工)。附四门地板逐门结果作接线上下文。
|
||
"""
|
||
import re # noqa: PLC0415
|
||
if playtest.get("degraded"):
|
||
return f"测试员未能给出有效裁决(degraded:{playtest.get('reason')}),fail-closed 待人工复核,不派续修。"
|
||
g = (floor or {}).get("gates") or {}
|
||
gate_str = " ".join(f"{k}={'✓' if g.get(k) else '✗'}" for k in ("A", "B", "C", "D"))
|
||
lines = []
|
||
for p in (playtest.get("problems") or []):
|
||
# 现象/推测分离:按分句剔除含「推测」的段,只留客观现象。
|
||
phenom = ";".join(seg.strip() for seg in re.split(r"[;;]", str(p)) if seg.strip() and "推测" not in seg)
|
||
if phenom:
|
||
lines.append(phenom)
|
||
head = (f"[测试员真玩现象·续修依据] 四门地板 {gate_str};测试员判不通过。以下仅列客观现象(带步号与落点坐标,"
|
||
f"推测已剔除),据此定位修复:\n")
|
||
if not lines:
|
||
lines = [playtest.get("summary") or "测试员判不通过但未列具体现象"]
|
||
return head + "\n".join(f"- {ln}" for ln in lines)
|
||
|
||
|
||
async def run_acceptance(summary: dict, *, game_id: str, brief: str = "", verdict=None,
|
||
evidence_dir=None, mode=None, model=None,
|
||
port=None, cdp_port=None) -> dict:
|
||
"""统一验收编排器(CLI / Service / modify 三路共用)。accepted = floor ∧ 测试员(mode=v2)/旧口径(shadow/v1)。
|
||
|
||
· floor = project_floor(verdict):四门投影(预筛权威,唯一产地),写 summary['floor']。
|
||
· mode=v2(目标态):测试员真玩阻断,accepted = floor.pass ∧ playtest.accepted。
|
||
· mode=shadow(波1 灰度):测试员照跑照落证据(对照),accepted 仍取旧口径(判定器 apply_gameplay_judge);
|
||
并写 summary['shadowV2Accepted'] 供批次账新旧口径对照表。
|
||
· mode=v1(回退态):W-AXIS 旧口径(apply_gameplay_judge:driven 九门预筛→四门投影 + 判定器读驱动器证据)。
|
||
|
||
additive 写 summary:floor 段、acceptanceVersion、playtest 段、judge 段(§4 双写窗口)、ok/accepted。
|
||
绝不抛(顶层兜底 fail-closed:异常 → accepted=False)。verdict 缺 → 取 summary.verdict/verdictFull。
|
||
"""
|
||
try:
|
||
cfg = _acceptance_cfg()
|
||
mode = mode or cfg["mode"]
|
||
ev = evidence_dir if evidence_dir is not None else _wg1_evidence_dir(game_id)
|
||
v = verdict if verdict is not None else (summary.get("verdict") or summary.get("verdictFull") or {})
|
||
floor = project_floor(v)
|
||
summary["floor"] = floor
|
||
summary["acceptanceVersion"] = mode
|
||
|
||
if mode == "v1":
|
||
# 回退态:预筛权威统一为四门投影(floor.pass),判定仍走驱动器证据(apply_gameplay_judge→judge_gameplay_floor)。
|
||
# 时序约束:v1 仅在 play-spec/驱动器仍挂载的波1~波2 前有效(波2 拆除后不可达)。
|
||
return await apply_gameplay_judge(summary, game_id=game_id, brief=brief,
|
||
evidence_dir=ev, prefilter=floor["pass"])
|
||
|
||
# v2 / shadow:跑测试员——仅预筛(四门)过时跑,省 ¥(预筛没过已被机械门拒,不花测试员钱)。
|
||
if floor["pass"]:
|
||
playtest = await run_playtest(game_id, brief=brief, evidence_dir=ev,
|
||
model_name=model or cfg["model"], port=port, cdp_port=cdp_port)
|
||
else:
|
||
playtest = {"accepted": False, "verdict": "reject", "pass": False, "degraded": False,
|
||
"reason": "四门地板未过,不跑测试员(省 ¥)", "problems": [], "feedback": "",
|
||
"summary": "floor-fail", "rolls": [], "rollCount": 0, "costRmb": 0.0, "model": cfg["model"],
|
||
"firstPlay": {"playableAtMs": None, "firstFeedbackMs": None, "loopClosed": False}}
|
||
summary["playtest"] = playtest
|
||
v2_accepted = bool(floor["pass"]) and bool(playtest.get("accepted")) and not bool(playtest.get("degraded"))
|
||
|
||
if mode == "shadow":
|
||
# 灰度:accepted 取旧口径(判定器 apply_gameplay_judge 写 judge 段);测试员只观测、并列落对照。
|
||
summary = await apply_gameplay_judge(summary, game_id=game_id, brief=brief,
|
||
evidence_dir=ev, prefilter=floor["pass"])
|
||
summary["shadowV2Accepted"] = v2_accepted # 对照:v2 生效时会是什么(批次账新旧口径对照表)
|
||
return summary
|
||
|
||
# mode == v2:测试员阻断放行。
|
||
blk = cfg["blocking"]
|
||
# rejectClasses:v2 测试员不产 broken/hollow/off_brief 细类,归一为 playtest_reject / tester_degraded(供 gate 桶归因)。
|
||
if playtest.get("degraded"):
|
||
reject_classes = ["tester_degraded"]
|
||
elif not v2_accepted:
|
||
reject_classes = ["playtest_reject"]
|
||
else:
|
||
reject_classes = []
|
||
summary["judge"] = { # §4 双写窗口:v2 下 judge 段镜像 playtest 裁决,喂 trace.gameplayJudge 与旧消费面
|
||
"ran": True, "blocking": blk, "verdict": playtest.get("verdict"),
|
||
"accepted": bool(playtest.get("accepted")), "rejectClasses": reject_classes,
|
||
"degraded": bool(playtest.get("degraded")), "reason": playtest.get("reason"),
|
||
"notes": playtest.get("summary"), "model": playtest.get("model"),
|
||
"costRmb": playtest.get("costRmb"), "acceptanceVersion": "v2",
|
||
}
|
||
summary["accepted"] = v2_accepted
|
||
summary["ok"] = v2_accepted if blk else floor["pass"]
|
||
if not v2_accepted: # 续修回喂来源(只引现象段);accepted 时无失败现象、不拼装(E2E 实测曾误拼自相矛盾文案)
|
||
summary["repairFeedback"] = _build_repair_feedback(floor, playtest)
|
||
if blk and not v2_accepted and playtest.get("degraded"):
|
||
summary.setdefault("failureReason", "tester_degraded") # §4 新增归因层
|
||
_writeback_verdict_json(ev, v2_accepted, summary["judge"])
|
||
return summary
|
||
except Exception as e: # noqa: BLE001 编排接线级异常:fail-closed(v2/shadow 不放行)。
|
||
summary["acceptanceVersion"] = summary.get("acceptanceVersion") or (mode or "v2")
|
||
summary["judge"] = {"ran": False, "degraded": True,
|
||
"reason": f"验收编排异常(fail-closed):{type(e).__name__}: {e}"}
|
||
summary["accepted"] = False
|
||
summary["ok"] = False
|
||
return summary
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 便捷自跑:python cheap-worker/cheap_verify.py <gameId> ["brief"] —— 对已有产物真跑一次评分。
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
gid = sys.argv[1] if len(sys.argv) > 1 else "cheap-smoke1"
|
||
bf = sys.argv[2] if len(sys.argv) > 2 else ""
|
||
out = asyncio.run(verify_richness(gid, brief=bf))
|
||
print(json.dumps(out, ensure_ascii=False, indent=2))
|