diff --git a/cheap-worker/cheap_service_driver.py b/cheap-worker/cheap_service_driver.py index 475d51a0..dabaf9fc 100644 --- a/cheap-worker/cheap_service_driver.py +++ b/cheap-worker/cheap_service_driver.py @@ -314,7 +314,7 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief) rv = summary["richness"] print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} " - f"degraded={rv.get('degraded')}", flush=True) + f"groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True) except Exception as e: # noqa: BLE001 —— richness 非阻塞铁律:绝不影响回调 summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"} diff --git a/cheap-worker/cheap_studio.py b/cheap-worker/cheap_studio.py index 83ecc054..971a0c48 100644 --- a/cheap-worker/cheap_studio.py +++ b/cheap-worker/cheap_studio.py @@ -287,7 +287,7 @@ async def run_studio(game_id, brief, *, max_iters=40, max_resumes=6, max_tokens= try: summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief) rv = summary["richness"] - _rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} degraded={rv.get('degraded')}") + _rec(f"丰富度 LLM 评分 score={rv.get('score')}/{rv.get('max')} groups={rv.get('groups')} degraded={rv.get('degraded')}") except Exception as e: # noqa: BLE001 非阻塞铁律:richness 绝不影响生成主链 summary["richness"] = {"score": None, "degraded": True, "reason": f"richness 接线异常:{type(e).__name__}: {e}"} diff --git a/cheap-worker/cheap_verify.py b/cheap-worker/cheap_verify.py index 33488fbc..f7ac037a 100644 --- a/cheap-worker/cheap_verify.py +++ b/cheap-worker/cheap_verify.py @@ -6,7 +6,8 @@ cheap_verify.py — 便宜档「丰富度」LLM 验证 agent(非阻塞·只报 故本模块只做两件 100% 机械确定的事,「丰富不丰富」的判断本身完全交 LLM judge: ① 读产物 L3 源码(确定性文件 I/O)+ 拼 judge prompt(确定性字符串); ② 解析 LLM 返回的 JSON 成结构化评分(确定性解析 + degraded 兜底)。 -本模块**不含任何丰富度的代码断言**——8 条好玩清单只是【喂给 LLM 的评分尺】,命中与否全由 judge 读码后裁。 +本模块**不含任何丰富度的代码断言**——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:...}, @@ -19,41 +20,78 @@ import asyncio import cheap_run # 仅 stdlib 依赖(读产物 src 文件);不 import agentscope/_bootstrap,故纯函数可在无 agentscope 环境单测。 -# ── sim-business §10 反「无趣」8 条好玩清单(judge 的评分 rubric)── +# ── 通用底座 v2 = 11 条丰富度清单(judge 的评分 rubric)── # 这是【喂给 LLM judge 的打分尺】,不是代码校验:丰富不丰富全由 judge 读源码后逐条裁。 -# 与 .agents/skills/sim-business-game-design.md §10 同源(那边是 design 自检,这里是产物 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 = [ - ("即时反馈", "每个主要操作有「飘字 +N + 音效 + 粒子」即时回报"), - ("可见成长", "有数字/规模肉眼可见地变大(分数飙升/店铺扩张/等级上涨)"), - ("下一个解锁", "任意时刻玩家眼前都有「再攒一点就解锁 X」的钩子"), - ("30秒爽点", "开局 30 秒内有第一次升级/解锁"), - ("数值滚雪球", "成长有「越来越快」的暴富段,而非平淡线性"), - ("情感锚", "有萌角色/拥有物让玩家「想养大它」"), - ("放置回归", "离线回来有惊喜(离线收益弹窗等)"), - ("音反馈", "收益/升级/解锁有声音(哪怕程序化)"), + # 原 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 预填做同款"), ] -_MAX = len(RICHNESS_CHECKLIST) # = 8 +_MAX = len(RICHNESS_CHECKLIST) # = 11 +_LAYERS = ("L2", "L3", "L4") # 三分组固定顺序(消费面按分组小计各自取阈,见 §3.6 档位表) + + +def _layer_maxes(checklist=RICHNESS_CHECKLIST) -> dict: + """各层条目数(分母)。通用底座 v2 = L2:6 / L3:3 / L4:2。""" + 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 = 3000 # judge 输出小(8 条短理由 + 一句点评),3000 足够、M3 无 thinking。 +_JUDGE_MAX_TOKENS = 4000 # judge 输出小(11 条短理由 + 一句点评),4000 足够、M3 无 thinking。 # judge 的 system / user 框定——只评分、不改码、要看源码真实现、输出严格 JSON。 _JUDGE_SYSTEM = ( "你是轻量小游戏的【丰富度评审 agent】。任务 = 读一款便宜档 LittleJS 小游戏的源码," - "只评判它「作为一款游戏够不够丰富、好玩」,逐条给出 8 条好玩清单的命中与否 + 一句中文理由," + "只评判它「作为一款游戏够不够丰富、好玩、耐玩、想传出去」,逐条给出 11 条丰富度清单的命中与否 + 一句中文理由," "最后输出严格 JSON。你只评分、不修改代码、不阻断发布——这是非阻塞的质量信号。" - "评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈与音效,别被空壳或注释骗。" + "这 11 条分三层:L2 内容丰富(有料耐玩)、L3 留存结构(想再玩的结构前提)、L4 传播钩子(想让别人看/做同款的前提);" + "评判要看源码里**真实实现**的玩法、数值成长、解锁阶梯、即时反馈、音效、结算画面与品类元数据,别被空壳或注释骗。" ) def _degraded(reason: str) -> dict: - """降级结果(非阻塞铁律):score=None + degraded=True;score 为 None 表示「这次没评出来」,绝不参与达标。""" - return {"score": None, "max": _MAX, "hits": [], "notes": None, "degraded": True, "reason": reason} + """降级结果(非阻塞铁律):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: @@ -70,11 +108,17 @@ def _coerce_bool(v) -> bool: 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)。 + 成功 → { + 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} + 解析失败(空/非 JSON 对象/缺 checks 数组)→ _degraded(...)(score=None, groups=None, degraded=True)。 - 对齐策略:优先按 name 对齐 judge 的 checks 到 8 条标准项;name 对不上的按位置兜底(容忍模型改名/英文名)。 - 本函数**不含任何丰富度的代码判断**——只搬运 judge 的逐条裁决并计命中数。 + 对齐策略:优先按 name 对齐 judge 的 checks 到 11 条标准项;name 对不上的按位置兜底(容忍模型改名/英文名)。 + layer 层标取自 checklist(不依赖 LLM 回传),故分组小计对模型改名鲁棒。 + 本函数**不含任何丰富度的代码判断**——只搬运 judge 的逐条裁决并按层聚合命中数。 """ if not isinstance(text, str) or not text.strip(): return _degraded("judge 输出为空") @@ -99,17 +143,18 @@ def parse_judge_output(text, *, checklist=RICHNESS_CHECKLIST) -> dict: hits = [] score = 0 - for i, (name, _meaning) in enumerate(checklist): + 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, "hit": hit, "why": why}) + hits.append({"name": name, "layer": layer, "hit": hit, "why": why}) # layer 取自 checklist(权威) if hit: score += 1 notes = str(data.get("notes", "")).strip()[:500] - return {"score": score, "max": len(checklist), "hits": hits, "notes": notes, "degraded": False} + return {"score": score, "max": len(checklist), "hits": hits, + "groups": _group_subtotals(hits, checklist), "notes": notes, "degraded": False} def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str: @@ -141,23 +186,24 @@ def _collect_sources(game_id: str, *, max_bytes: int = _MAX_SRC_BYTES) -> str: def _build_judge_user(src_text: str, brief: str = "") -> str: - """拼 judge 的 user 消息:brief(可选)+ 8 条清单 + 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。""" + """拼 judge 的 user 消息:brief(可选)+ 11 条清单(带层标)+ 产物源码 + 严格 JSON 输出契约(纯确定性字符串拼接)。""" checklist_lines = "\n".join( - f"{i + 1}. {name}:{meaning}" for i, (name, meaning) in enumerate(RICHNESS_CHECKLIST) + 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 "" return ( f"{brief_block}" - "下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 8 条好玩清单," - "据**源码里真实实现了的玩法/数值/音效/解锁**判断(别被注释或空壳骗:比如只 import 了 audioMusic " + "下面是这款便宜档小游戏的 L3 源码。请逐条评判它是否命中这 11 条丰富度清单(每条前的 [L2]/[L3]/[L4] 是分层标注——" + "L2 内容丰富、L3 留存结构、L4 传播钩子——仅用于分组,不改变你对每条的独立判命中)," + "据**源码里真实实现了的玩法/数值/音效/解锁/结算画面/品类元数据**判断(别被注释或空壳骗:比如只 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":"整体一句话点评"}' + '{"checks":[{"name":"即时反馈","hit":true,"why":"一句中文理由"}, … 共 11 条,' + 'name 用上面 11 条的中文名、hit 为 true/false], "notes":"整体一句话点评"}' ) diff --git a/cheap-worker/tests/test_cheap_verify.py b/cheap-worker/tests/test_cheap_verify.py index 0112ee70..054b3b88 100644 --- a/cheap-worker/tests/test_cheap_verify.py +++ b/cheap-worker/tests/test_cheap_verify.py @@ -1,12 +1,15 @@ """ -test_cheap_verify.py — U-A2 便宜档「丰富度」LLM 验证 agent 单测。 +test_cheap_verify.py — U-A2 便宜档「丰富度」LLM 验证 agent 单测(rubric v2:11 条 + 三分组小计)。 两层: - ① parse_judge_output 纯函数(解析 LLM 输出 → 结构化评分 + degraded 兜底)——无 agentscope、零网络, + ① parse_judge_output 纯函数(解析 LLM 输出 → 结构化评分 + 三分组小计 + degraded 兜底)——无 agentscope、零网络, plain python 即可跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_cheap_verify.py ② verify_richness 非阻塞契约(fake model 注入,零真网络)——验证「LLM 失败/超时/解析失败/空 src → degraded、 绝不抛、绝不阻断」。需 agentscope.message(venv 有),缺则自动跳过。 +rubric v2(质量模型 SoT §3.3):通用底座 11 条,分层 L2×6 / L3×3 / L4×2; + 逐条 0/1 标层 + L2/L3/L4 三分组小计;score 保留为向后兼容字段(bake_off 读),非单一总分。 + 跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_cheap_verify.py -q """ @@ -26,35 +29,66 @@ except Exception: # noqa: BLE001 无 agentscope 时 ② 类测试跳过(纯 def _full_checks(hits): - """据布尔列表造 8 条 checks(name 用 cheap_verify 的标准名)。""" - return [{"name": n, "hit": h, "why": "理由"} for (n, _m), h in zip(cheap_verify.RICHNESS_CHECKLIST, hits)] + """据布尔列表造 checks(name 用 cheap_verify 的标准名);hits 短于 11 条则 zip 取最短(余条判 False)。""" + return [{"name": n, "hit": h, "why": "理由"} for (n, _l, _m), h in zip(cheap_verify.RICHNESS_CHECKLIST, hits)] + + +# 通用底座 v2 层索引(据 RICHNESS_CHECKLIST 顺序):L2={0,1,3,4,5,7} L3={2,6,8} L4={9,10} +def test_checklist_shape_v2(): + """底座 = 11 条,分层 L2×6 / L3×3 / L4×2(分组归属对齐 §3.3 第 1 条)。""" + cl = cheap_verify.RICHNESS_CHECKLIST + assert len(cl) == 11 and cheap_verify._MAX == 11 + layers = [layer for _n, layer, _m in cl] + assert layers.count("L2") == 6 and layers.count("L3") == 3 and layers.count("L4") == 2 + # 原 8 条 name/顺序锁定(锚定纪律:评分尺变更不得动原条目) + assert [n for n, _l, _m in cl][:8] == [ + "即时反馈", "可见成长", "下一个解锁", "30秒爽点", "数值滚雪球", "情感锚", "放置回归", "音反馈"] # ───────────────────────── ① parse_judge_output 纯函数(无 agentscope/无网络)───────────────────────── def test_parse_valid_full(): - """合法 8 条 JSON → 命中数正确、非 degraded、hits/notes 落位。""" + """合法 JSON(喂前 8 条命中态)→ 命中数正确、非 degraded、hits 带 layer、groups 三分组小计落位。""" text = json.dumps({"checks": _full_checks([True, True, False, True, False, False, False, True]), "notes": "还行"}, ensure_ascii=False) r = cheap_verify.parse_judge_output(text) assert r["degraded"] is False - assert r["score"] == 4 and r["max"] == 8 - assert len(r["hits"]) == 8 and r["hits"][0]["name"] == "即时反馈" + assert r["score"] == 4 and r["max"] == 11 + assert len(r["hits"]) == 11 and r["hits"][0]["name"] == "即时反馈" and r["hits"][0]["layer"] == "L2" + # 前 8 条命中态 [T,T,F,T,F,F,F,T]:L2(0,1,3,4,5,7)=4;L3(2,6,8=F,F,未喂F)=0;L4(9,10 未喂)=0 + assert r["groups"] == {"L2": {"score": 4, "max": 6}, "L3": {"score": 0, "max": 3}, "L4": {"score": 0, "max": 2}} assert r["notes"] == "还行" +def test_parse_11_full_and_groups(): + """11 条全命中 → score=11、三分组小计满分(L2 6/6 · L3 3/3 · L4 2/2)。""" + r = cheap_verify.parse_judge_output(json.dumps({"checks": _full_checks([True] * 11), "notes": "满"})) + assert r["degraded"] is False and r["score"] == 11 and r["max"] == 11 + assert r["groups"] == {"L2": {"score": 6, "max": 6}, "L3": {"score": 3, "max": 3}, "L4": {"score": 2, "max": 2}} + + +def test_parse_groups_by_layer(): + """按层聚合正确:命中态 [T,T,F,T,F,F,F,T,F,T,T] → L2=4 L3=0 L4=2、score=6。""" + pat = [True, True, False, True, False, False, False, True, False, True, True] + r = cheap_verify.parse_judge_output(json.dumps({"checks": _full_checks(pat)})) + assert r["score"] == 6 + assert r["groups"]["L2"] == {"score": 4, "max": 6} + assert r["groups"]["L3"] == {"score": 0, "max": 3} + assert r["groups"]["L4"] == {"score": 2, "max": 2} + + def test_parse_fenced_json(): """markdown 围栏 + 前后赘语 → json_repair 兜得住、正确解析。""" - body = json.dumps({"checks": _full_checks([True] * 8), "notes": "满"}, ensure_ascii=False) + body = json.dumps({"checks": _full_checks([True] * 11), "notes": "满"}, ensure_ascii=False) text = "这是我的评分:\n```json\n" + body + "\n```\n以上。" r = cheap_verify.parse_judge_output(text) - assert r["degraded"] is False and r["score"] == 8 + assert r["degraded"] is False and r["score"] == 11 def test_parse_garbage_degraded(): - """完全非 JSON → degraded(score=None),不抛。""" + """完全非 JSON → degraded(score=None, groups=None),不抛。""" r = cheap_verify.parse_judge_output("完全不是 JSON 的一段话") - assert r["degraded"] is True and r["score"] is None and r["max"] == 8 + assert r["degraded"] is True and r["score"] is None and r["max"] == 11 and r["groups"] is None def test_parse_none_and_empty_degraded(): @@ -81,6 +115,7 @@ def test_parse_coerce_and_positional(): r = cheap_verify.parse_judge_output(json.dumps({"checks": checks})) assert r["degraded"] is False assert r["score"] == 2 # 前两条 "true"/1 命中,第三条「否」不命中,其余 False + assert r["max"] == 11 and r["hits"][0]["layer"] == "L2" # 位置兜底也带 checklist 权威 layer # ───────────────────────── ② verify_richness 非阻塞契约(fake model,零真网络)───────────────────────── @@ -107,7 +142,7 @@ class _FakeModel: return (11, 22) -_SRC = "// game-logic.js\nfunction createGame(){ /* 进货 库存 解锁 playSfx 飘字 */ }" +_SRC = "// game-logic.js\nfunction createGame(){ /* 进货 库存 解锁 playSfx 飘字 结算 remix */ }" def _skip_no_as(name): @@ -115,12 +150,14 @@ def _skip_no_as(name): def test_verify_happy_path(): - """fake model 返回合法 JSON → 结构化评分 + judgeTokens;非 degraded。""" + """fake model 返回合法 JSON → 结构化评分 + 三分组小计 + judgeTokens;非 degraded。""" if not _HAS_AS: return _skip_no_as("test_verify_happy_path") - body = json.dumps({"checks": _full_checks([True] * 5 + [False] * 3), "notes": "不错"}, ensure_ascii=False) + body = json.dumps({"checks": _full_checks([True] * 5 + [False] * 6), "notes": "不错"}, ensure_ascii=False) r = asyncio.run(cheap_verify.verify_richness("x", sources=_SRC, model=_FakeModel(text=body))) assert r["degraded"] is False and r["score"] == 5 + # 前 5 条命中 [T,T,T,T,T] 均属 L2 索引{0,1,3,4}? idx0,1L2 idx2L3 idx3,4L2 → L2=4 L3=1 + assert r["groups"]["L2"]["score"] == 4 and r["groups"]["L3"]["score"] == 1 and r["groups"]["L4"]["score"] == 0 assert r["judgeTokens"]["total"] == 33