lili e6525aaa3d feat(cheap-worker): 便宜档生成丰富度 + M1 达标门富化重验(切片一 A/B)
接 sim-business 设计指导到生成 agent(cheap_roles+prompt.mjs 双源,先设计后写码+MVP-first);新建纯 LLM 丰富度验证 agent cheap_verify(非阻塞·不进 verdict·零 code-presence 断言,红线落地);bake_off 加 richness 列;cheap_roles 三级回落配置热取(C2a 加载器);cheap_studio trace 接线(C1b)。富游戏 M1 三品类各 5/5=100% 过九门达标。测试 test_roles 12 / test_cheap_verify 11 / test_bake_off 17 / test_cheap_trace 4 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 01:39:06 -07:00

242 lines
13 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 兜底)。
本模块**不含任何丰富度的代码断言**——8 条好玩清单只是【喂给 LLM 的评分尺】,命中与否全由 judge 读码后裁。
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 cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap故纯函数可在无 agentscope 环境单测。
# ── sim-business §10 反「无趣」8 条好玩清单judge 的评分 rubric──
# 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。
# 与 .agents/skills/sim-business-game-design.md §10 同源(那边是 design 自检,这里是产物 judge 评分)。
# 每项 = (标准名, 含义)judge 按标准名逐条回命中与否 + 理由。
RICHNESS_CHECKLIST = [
("即时反馈", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"),
("可见成长", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"),
("下一个解锁", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"),
("30秒爽点", "开局 30 秒内有第一次升级/解锁"),
("数值滚雪球", "成长有「越来越快」的暴富段,而非平淡线性"),
("情感锚", "有萌角色/拥有物让玩家「想养大它」"),
("放置回归", "离线回来有惊喜(离线收益弹窗等)"),
("音反馈", "收益/升级/解锁有声音(哪怕程序化)"),
]
_MAX = len(RICHNESS_CHECKLIST) # = 8
# 只读 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 = 3000 # judge 输出小8 条短理由 + 一句点评3000 足够、M3 无 thinking。
# judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。
_JUDGE_SYSTEM = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
"只评判它「作为一款游戏够不够丰富、好玩」,逐条给出 8 条好玩清单的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈与音效,别被空壳或注释骗。"
)
def _degraded(reason: str) -> dict:
"""降级结果非阻塞铁律score=None + degraded=Truescore 为 None 表示「这次没评出来」,绝不参与达标。"""
return {"score": None, "max": _MAX, "hits": [], "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) -> dict:
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
成功 → {score:int(命中数 0..8), max:8, hits:[{name,hit,why}*8], notes:str, degraded:False}
解析失败(空/非 JSON 对象/缺 checks 数组)→ _degraded(...)score=None, degraded=True
对齐策略:优先按 name 对齐 judge 的 checks 到 8 条标准项name 对不上的按位置兜底(容忍模型改名/英文名)。
本函数**不含任何丰富度的代码判断**——只搬运 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
hits = []
score = 0
for i, (name, _meaning) in enumerate(checklist):
c = by_name.get(name)
if c is None and i < len(checks_raw) and isinstance(checks_raw[i], dict):
c = checks_raw[i] # 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 ""
hits.append({"name": name, "hit": hit, "why": why})
if hit:
score += 1
notes = str(data.get("notes", "")).strip()[:500]
return {"score": score, "max": len(checklist), "hits": hits, "notes": notes, "degraded": False}
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 = "") -> str:
"""拼 judge 的 user 消息brief可选+ 8 条清单 + 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。"""
checklist_lines = "\n".join(
f"{i + 1}. {name}{meaning}" for i, (name, meaning) in enumerate(RICHNESS_CHECKLIST)
)
brief_block = f"这款游戏的 brief玩家想要的{brief}\n\n" if brief else ""
return (
f"{brief_block}"
"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 8 条好玩清单,"
"据**源码里真实实现了的玩法/数值/音效/解锁**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"但收益处没真调 playSfx则「音反馈」不算命中\n\n"
f"{checklist_lines}\n\n"
"=== 源码开始 ===\n"
f"{src_text}\n"
"=== 源码结束 ===\n\n"
"严格只输出以下 JSON不要任何额外文字、不要 markdown 围栏):\n"
'{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 8 条,'
'name 用上面 8 条的中文名、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) -> 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 的源码上限。
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)
sys_msg = SystemMsg(name="system", content=_JUDGE_SYSTEM)
user_msg = UserMsg(name="user", content=_build_judge_user(src_text, brief))
# 硬超时包裹真模型调用judge 卡住绝不能拖死收口(非阻塞铁律)。
resp = await asyncio.wait_for(m([sys_msg, user_msg]), timeout=timeout)
result = parse_judge_output(_extract_text(resp))
# 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))