feat(cheap): TRPG 品类 rubric(5条·分母独立)+ 金标 play-spec(W-GENRE 品类件④)

品类扩展 rubric(质量模型 SoT §4 规范二·五六):cheap_verify.GENRE_CHECKLISTS['trpg'] 5 条
(掷骰过程可见 L2 / 风险回报取舍 L2 / 成长进判定公式 L2 / 难度爬坡登顶 L3 / 冒险战报炫耀 L4),
每条 0/1 标层 + 一对正反例;仍是喂 LLM judge 的评分尺数据、纯 LLM 非阻塞(红线:不写代码校验
/不进九门/不进 verdict/不进脚手架)。同一次评分 additive 追加品类条目,**分母独立小计**落
richness.genre{key,hits,score,max,groups},通用 11 条 groups/score 分母不混(规范二);
接线 = run_studio 据 scaffold_template 查 GENRE_BY_TEMPLATE 透传 genre,未登记/None 行为
与既有完全一致(无品类路 judge prompt 逐字节不变,锚定纪律)。

金标 play-spec:fixtures/golden-specs/trpg.play-spec.json(tap-targets occupied 反应族
+ score increased + expectLatch,formalize 件② p11c-tpl 已验的 occupied spec;不用货币
递减门——SoT §3 裁定二门级判据不跨档);test_golden_specs 期望集同步纳 trpg(schema/
契约依赖/无坐标绑定 20/20 绿)。

金标复验(规范三,真跑 M3):正例 _template-trpg 骨架 品类 5/5、通用 8/11;薄反例(黑箱
点卡+无骰无取舍无成长无终点)品类 0/5、通用 0/11——正反例拉满、评分尺有鉴别力;同一正例
品类路 vs 基线路通用 11 条漂移 = 0(≤±1,加品类段不扰动通用判定)。单测 +5(条目形状/
独立分母/位置兜底偏移/无品类向后兼容/judge prompt additive)= test_cheap_verify 19/19 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-02 22:46:45 -07:00
parent 89f1c60534
commit caadfe4c06
5 changed files with 185 additions and 29 deletions

View File

@ -285,9 +285,13 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens=
# 只在 run_gates(完整 spike 收口)且 finished(有真产物)时跑;对照路(run_gates=False)零改动、不触发 LLM。
if verify_richness_enabled and run_gates and finished:
try:
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief)
# 品类扩展 rubric(W-GENRE 件④):scaffold_template 登记在 GENRE_BY_TEMPLATE 时同一次评分
# additive 追加品类条目(分母独立小计落 richness.genre);未登记/None → 行为与既有完全一致。
_genre = cheap_verify.GENRE_BY_TEMPLATE.get(scaffold_template) if scaffold_template else None
summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=_genre)
rv = summary["richness"]
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} degraded={rv.get('degraded')}")
_rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} "
f"genre={(rv.get('genre') or {}).get('key')}:{(rv.get('genre') or {}).get('score')} degraded={rv.get('degraded')}")
except Exception as e: # noqa: BLE001 非阻塞铁律:richness 绝不影响生成主链
summary["richness"] = {"score": None, "degraded": True,
"reason": f"richness 接线异常:{type(e).__name__}: {e}"}

View File

@ -47,6 +47,27 @@ RICHNESS_CHECKLIST = [
_MAX = len(RICHNESS_CHECKLIST) # = 11
_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表)
# ── 品类扩展 rubric(W-GENRE 品类件④;质量模型 SoT §4 规范二)──
# 每品类另加 ≥4 条品类特有维度:同一字段形状 (标准名, 层标, 含义含一对正反例)、同样标层,
# **分母独立小计**(与通用底座分开、不混合平均——评分输出落 richness.genre.groups,通用 groups 不变)。
# 这仍是【喂给 LLM judge 的评分尺】数据、不是代码校验(红线:不进九门/不进 verdict/不进脚手架);
# 设计侧同源对齐 = .agents/skills/<genre>-game-design.md §10 品类自检。
# 锚定纪律(规范三):品类条目文本变更须金标复验(金标正例 + 薄反例,分组小计漂移 ≤±1 即回退)。
GENRE_CHECKLISTS = {
"trpg": [
("掷骰过程可见", "L2", "掷骰判定(骰值+加成 vs 难度)以飘字/动画亮给玩家,不是黑箱出结果(正:飘「d20:15+2 vs 12 ✓」;反:点卡直接加钱、不见骰)"),
("风险回报取舍", "L2", "同屏遭遇/选项的难度与奖励差异化亮牌,挑哪个的决定影响期望收益(正:骷髅卫 ≥9 +7金 vs 宝箱 ≥4 +3金;反:所有卡同难同赏点哪张都一样)"),
("成长进判定公式", "L2", "等级/伙伴等成长真实改变掷骰判定数值且 UI 可见,不是摆设数字(正:升级→骰+1 写面板、进公式;反:等级只是显示数字、判定恒裸 d20)"),
("难度爬坡登顶", "L3", "难度随层/进度递升,有明确通关终点与进度可见(正:第 N/6 层 HUD + 每层难度 +1 + 登顶 win;反:无限平难度刷分无终点)"),
("冒险战报炫耀", "L4", "结算屏讲这局冒险的故事(层数/击败/大成功/胜负因由),可截图分享(正:「爬到第 5 层·击败 7·大成功 2·勇者倒下」;反:只有一个总分数字)"),
],
}
# 品类脚手架目录名 → 品类 key(run_studio 据 scaffold_template 透传 genre;None/未登记 → 不评品类扩展)。
GENRE_BY_TEMPLATE = {
"_template-trpg": "trpg",
}
def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict:
"""各层条目数(分母)。通用底座 v2 = L2:6 / L3:3 / L4:2。"""
@ -85,6 +106,16 @@ _JUDGE_SYSTEM = (
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
)
# 品类扩展评分时的 system 变体(W-GENRE 件④):仅把「11 条」的量词放宽到「通用 11 条 + 品类扩展若干条」,
# 其余判据措辞一字不动(锚定纪律:无品类路走上面原版 _JUDGE_SYSTEM,逐字节不变)。
_JUDGE_SYSTEM_GENRE = (
"你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码,"
"只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出丰富度清单(通用 11 条 + 品类扩展若干条)的命中与否 + 一句中文理由,"
"最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。"
"条目分三层:L2 内容丰富(有料耐玩)、L3 留存结构(想再玩的结构前提)、L4 传播钩子(想让别人看/做同款的前提);"
"评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。"
)
def _degraded(reason: str) -> dict:
"""降级结果(非阻塞铁律):score=None + degraded=True;score 为 None 表示「这次没评出来」,绝不参与达标。
@ -105,7 +136,7 @@ def _coerce_bool(v) -> bool:
return False
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST, genre_checklist=None, genre_key=None) -> dict:
"""纯函数:把 judge 的 LLM 文本输出解析成结构化丰富度评分(含 degraded 兜底)。可单测、零网络、零 agentscope。
成功 → {
@ -114,9 +145,12 @@ def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
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 到 11 条标准项;name 对不上的按位置兜底(容忍模型改名/英文名)。
对齐策略:优先按 name 对齐 judge 的 checks;name 对不上的按位置兜底(容忍模型改名/英文名)——
checks 数组约定 = 通用条目在前、品类条目紧随其后,位置兜底按段内偏移算。
layer 层标取自 checklist(不依赖 LLM 回传),故分组小计对模型改名鲁棒。
本函数**不含任何丰富度的代码判断**——只搬运 judge 的逐条裁决并按层聚合命中数。
"""
@ -141,20 +175,36 @@ def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict:
if nm:
by_name[nm] = c
hits = []
score = 0
for i, (name, layer, _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, "layer": layer, "hit": hit, "why": why}) # layer 取自 checklist(权威)
if hit:
score += 1
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]
return {"score": score, "max": len(checklist), "hits": hits,
"groups": _group_subtotals(hits, checklist), "notes": notes, "degraded": False}
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:
@ -185,25 +235,46 @@ def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str:
return joined
def _build_judge_user(src_text: str, brief: str = "") -> str:
"""拼 judge 的 user 消息:brief(可选)+ 11 条清单(带层标)+ 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。"""
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} 品类扩展清单】(分母独立、不与上面 11 条混合,同样逐条独立判命中):\n"
f"{genre_lines}\n"
)
total_note = f"共 {len(RICHNESS_CHECKLIST) + n_genre} 条(通用 11 条在前、品类 {n_genre} 条紧随其后)"
name_note = "name 用上面各条的中文名"
else:
genre_block = ""
total_note = "共 11 条"
name_note = "name 用上面 11 条的中文名"
return (
f"{brief_block}"
"下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 11 条丰富度清单(每条前的 [L2]/[L3]/[L4] 是分层标注——"
"L2 内容丰富、L3 留存结构、L4 传播钩子——仅用于分组,不改变你对每条的独立判命中),"
"据**源码里真实实现了的玩法/数值/音效/解锁/结算画面/品类元数据**判断(别被注释或空壳骗:比如只 import 了 audioMusic "
"但收益处没真调 playSfx,则「音反馈」不算命中):\n\n"
f"{checklist_lines}\n\n"
f"{checklist_lines}\n"
f"{genre_block}\n"
"=== 源码开始 ===\n"
f"{src_text}\n"
"=== 源码结束 ===\n\n"
"严格只输出以下 JSON(不要任何额外文字、不要 markdown 围栏):\n"
'{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 11 条,'
'name 用上面 11 条的中文名、hit 为 true/false], "notes":"整体一句话点评"}'
f'{{"checks":[{{"name":"即时反馈","hit":true,"why":"一句中文理由"}}, … {total_note},'
f'{name_note}、hit 为 true/false], "notes":"整体一句话点评"}}'
)
@ -227,7 +298,8 @@ def _extract_text(resp) -> str:
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:
timeout: float = 90.0, max_src_bytes: int = _MAX_SRC_BYTES,
genre: str = None) -> dict:
"""生成完成后跑一次 LLM 丰富度评分(非阻塞·只报告)。**绝不抛、绝不阻断、绝不进 verdict / 不改达标**。
Args:
@ -238,6 +310,8 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
sources: 可选直接注入的源码文本(单测用,绕过文件 I/O);None → 从 game_id 收集。
timeout: judge LLM 调用超时秒数;超时 → degraded(绝不卡死收口)。
max_src_bytes: 喂 judge 的源码上限。
genre: 品类 key(W-GENRE 件④);命中 GENRE_CHECKLISTS → 同一次评分 additive 追加品类扩展条目
(分母独立小计落 result["genre"]);None/未登记品类 → 行为与既有版本完全一致(prompt 逐字节不变)。
Returns:
成功 → parse_judge_output 的结构化评分(含 judgeTokens 观测,best-effort);
@ -255,11 +329,16 @@ async def verify_richness(game_id: str, *, brief: str = "", model=None, sources=
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))
# 品类扩展(未登记的 genre 静默按 None——评分尺没有它的条目,别让评分半路造尺)。
genre_checklist = GENRE_CHECKLISTS.get(genre) 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))
result = parse_judge_output(_extract_text(resp), genre_checklist=genre_checklist, genre_key=genre_key)
# best-effort 附 judge token(外部 LLM 调用可观测;与生成成本台账隔离——judge 用独立 model 实例)。
try:

View File

@ -0,0 +1,17 @@
{
"_genre": "trpg",
"_baseSample": "_template-trpg(p11c-tpl 独立过九门 9/9)",
"_note": "金标 play-spec(对照 fixtures)。TRPG 掷骰爬塔:本层 ready 遭遇卡 occupied=true,点卡=掷 d20 对抗难度三档结算,金币战利品(score)随结算单调上升(失败也有安慰 1 金=进展契约)。formalize _template-trpg 骨架已验证过的 occupied spec。TRPG 在范围内是 occupied 反应族 + score 递增,不用货币递减门(单循环无经济死局,质量模型 SoT §3 裁定二:门级判据不跨档)。只依赖 forensics 契约(targets occupied / state.score),不绑定具体游戏坐标。",
"driver": {
"type": "tap-targets",
"targetMode": "occupied",
"targetsPath": "targets",
"steps": 50,
"stepMs": 240
},
"expectedEngineCallPrefixes": ["sessionScore.", "audioMusic.", "juice."],
"assertAfterPlay": [
{ "path": "score", "op": "increased", "why": "掷骰结算金币入账(战利品=score)应上升;点不到 ready 遭遇卡/结算坏则不升" }
],
"expectLatch": true
}

View File

@ -188,6 +188,61 @@ def test_verify_empty_sources_skips_llm():
assert "跳过" in r.get("reason", "") # 走的是「空 src 跳过」路径,而非 LLM 抛异常路径
# ── W-GENRE 品类件④:品类扩展 rubric(分母独立小计)────────────────────────────
def test_genre_checklist_trpg_shape():
"""trpg 品类扩展 ≥4 条、形状同通用底座 (名, 层标, 含义含正反例)、层标合法(§4 规范二)。"""
gc = cheap_verify.GENRE_CHECKLISTS["trpg"]
assert len(gc) >= 4, f"品类扩展应 ≥4 条,得 {len(gc)}"
for name, layer, meaning in gc:
assert isinstance(name, str) and name, "标准名非空"
assert layer in ("L2", "L3", "L4"), f"层标非法:{layer}"
assert "正:" in meaning and "反:" in meaning, f"「{name}」含义应带一对正反例(规范三锚定)"
assert cheap_verify.GENRE_BY_TEMPLATE["_template-trpg"] == "trpg"
def test_parse_with_genre_checklist_independent_denominator():
"""品类段独立分母:通用 score/groups 不被品类条目污染;richness.genre 形状齐全。"""
gc = cheap_verify.GENRE_CHECKLISTS["trpg"]
checks = [{"name": n, "hit": True, "why": "w"} for (n, _l, _m) in cheap_verify.RICHNESS_CHECKLIST]
checks += [{"name": n, "hit": (i != len(gc) - 1), "why": "w"} for i, (n, _l, _m) in enumerate(gc)]
r = cheap_verify.parse_judge_output(json.dumps({"checks": checks, "notes": "n"}),
genre_checklist=gc, genre_key="trpg")
assert r["score"] == 11 and r["max"] == 11, "通用分母被品类条目污染"
g = r["genre"]
assert g["key"] == "trpg" and g["max"] == len(gc) and g["score"] == len(gc) - 1
total = sum(v["max"] for v in g["groups"].values())
assert total == len(gc), "品类分组小计分母应 = 品类条目数"
def test_parse_genre_positional_fallback_offset():
"""品类段位置兜底:name 全对不上时按「通用在前、品类紧随」的偏移对位。"""
gc = cheap_verify.GENRE_CHECKLISTS["trpg"]
n = 11 + len(gc)
checks = [{"name": f"未知{i}", "hit": True, "why": ""} for i in range(n)]
r = cheap_verify.parse_judge_output(json.dumps({"checks": checks}),
genre_checklist=gc, genre_key="trpg")
assert r["score"] == 11 and r["genre"]["score"] == len(gc)
def test_parse_no_genre_backcompat_no_genre_key():
"""不传品类:输出形状与既有完全一致(无 genre 键——老消费面零感知)。"""
checks = [{"name": n, "hit": True, "why": "w"} for (n, _l, _m) in cheap_verify.RICHNESS_CHECKLIST]
r = cheap_verify.parse_judge_output(json.dumps({"checks": checks}))
assert "genre" not in r and r["score"] == 11
def test_build_judge_user_genre_block_additive():
"""user prompt:无品类段落基线不变;有品类只 additive 加品类清单段 + 条数契约(11+N)。"""
base = cheap_verify._build_judge_user("SRC", "B")
gc = cheap_verify.GENRE_CHECKLISTS["trpg"]
withg = cheap_verify._build_judge_user("SRC", "B", genre_key="trpg", genre_checklist=gc)
assert "品类扩展清单" not in base and "共 11 条" in base
assert "trpg 品类扩展清单" in withg and f"共 {11 + len(gc)} 条" in withg
for name, _l, _m in gc:
assert name in withg, f"品类条目「{name}」应出现在 judge prompt"
if __name__ == "__main__":
_fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
_failed = 0

View File

@ -13,8 +13,9 @@ from pathlib import Path
_FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "golden-specs"
# 三个 tap-targets 代表品类(plan KTD2):点击得分/打地鼠/经营点客,各 formalize base1/base2/base4 的 occupied spec。
_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve"}
# tap-targets 代表品类:三个基线品类(plan KTD2,各 formalize base1/base2/base4 的 occupied spec)
# + W-GENRE 品类件④ 新增(trpg=_template-trpg 骨架 formalize;同属 occupied 反应族)。
_EXPECTED_GENRES = {"click-score", "whack-mole", "shop-serve", "trpg"}
# driver 只许这些键 —— 全是与具体游戏无关的契约级参数;出现坐标类键(x/y/coords/points)即"绑定具体游戏"违规。
_ALLOWED_DRIVER_KEYS = {"type", "targetMode", "targetsPath", "steps", "stepMs"}
_FORBIDDEN_COORD_KEYS = {"x", "y", "coords", "points", "positions", "targets"}
@ -30,7 +31,7 @@ def _load_specs() -> dict:
def test_three_genres_present():
"""恰好三个 tap-targets 代表品类(无遗漏、无多余)。"""
"""tap-targets 代表品类集合恰好对位(无遗漏、无多余;新品类进金标须同步本期望集)。"""
specs = _load_specs()
assert set(specs) == _EXPECTED_GENRES, f"品类集不符:{set(specs)} != {_EXPECTED_GENRES}"