407 lines
25 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.

"""
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 json
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 分钟脚本成立010s 零阅读上手 / 1060s 首次升级 / 13min 露出 23 个后续锁"),
("炫耀时刻", "L4", "有可截图分享的结算/成就画面(打完有结算展示场景,不是直接黑屏)"),
("同款钩子", "L4", "有清晰的品类原型 + 主题标签元数据,可供 remix 预填做同款"),
# 第 12 条2026-07-03 增补,质量 SoT §4 规范一「通用底座 12 条口径」;创始人授权默认项拍板采纳):
# 源自便宜档主 prompt 自检第 ⑨ 条(策划知识包 v2 否决项判定句式),升为评分尺条目。
# 锚定纪律:追加于末尾、前 11 条一字不动L2 分母随之 6→7档位观测线百分比不变、分母随升
# 落地与金标复验同批(复验漂移超线按规范三复采样定性再判回退)。
("核心操作非无脑", "L2", "每次主操作含真实决策或技巧含量、判错有真代价(如补错货压库存),不是点哪都加分/点了就自动结算"),
]
_MAX = len(RICHNESS_CHECKLIST) # = 122026-07-03 ⑨升第 12 条;此前 v2 = 11
_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表)
# ── 品类扩展 rubricW-GENRE 品类件④;质量模型 SoT §4 规范二)──
# 数据源唯一 = fixtures/genre-rubrics/<genre>.jsonitems 键,条目 {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"
# 品类脚手架目录名 → 品类 keyrun_studio 据 scaffold_template 透传 genreNone/未登记 → 不评品类扩展)。
# 非遗品类键收敛 = "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 空/文件缺/坏条目 → [](绝不抛)。
条目形状随 fixturename/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:22026-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 plumbinghost-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=Truescore 为 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 可能给的多形态命中值归一为 booltrue/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 的 checksname 对不上的按位置兜底(容忍模型改名/英文名)——
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+Nchecks 通用在前、品类紧随)。
"""
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/ONone → 从 game_id 收集。
timeout: judge LLM 调用超时秒数;超时 → degraded绝不卡死收口
max_src_bytes: 喂 judge 的源码上限。
genre: 品类 keyW-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 评分")
# 惰性 importagentscope / _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}")
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))