From d2b916f1122f388d5dccd990a48b302ef0744ca3 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 3 Jul 2026 14:17:54 -0700 Subject: [PATCH] =?UTF-8?q?feat(cheap):=20T4=20=E7=94=9F=E4=BA=A7=20create?= =?UTF-8?q?=20=E8=B7=AF=20genre=20=E6=9C=80=E5=B0=8F=E8=B7=AF=E7=94=B1(bri?= =?UTF-8?q?ef=E2=86=92=E4=BA=94=E7=B1=BB=E7=A1=AE=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E8=AF=8D=E8=A1=A8=C2=B7=E5=85=88=E9=AA=8C=E5=BA=8F=3D=E7=89=B9?= =?UTF-8?q?=E5=BC=82=E6=80=A7=E9=99=8D=E5=BA=8F=C2=B7=E6=97=A0=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E5=9B=9E=E9=80=80=E9=80=9A=E7=94=A8)=20(M3=20cutover?= =?UTF-8?q?=20=E6=94=BE=E8=A1=8C=E6=A3=80=E6=9F=A5=E5=8D=95=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cheap_genre_route:纯词表路由(零 LLM/零 IO/确定性),规则表数据化单源(_GENRE_RULES 顺序即先验序: 非遗>TRPG>经营>解谜>剧情,特异词前泛词后);映射对齐 GENRE_BY_TEMPLATE 五类模板 - cheap_service_driver:取代硬编码 scaffold_template=None——命中选 per-genre 黄金骨架+genre 透传丰富度评分 (生产 create 路与 S5 bake_off 复验同轨);无命中 (None,None) 走通用 _template(路由只加不减) - tests/test_genre_route:五类命中/无命中回退/多类共现先验序/GENRE_BY_TEMPLATE 一致性/确定性/脏输入 14 用例 Co-Authored-By: Claude Fable 5 --- cheap-worker/cheap_genre_route.py | 65 ++++++++++++++ cheap-worker/cheap_service_driver.py | 14 ++- cheap-worker/tests/test_genre_route.py | 113 +++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 cheap-worker/cheap_genre_route.py create mode 100644 cheap-worker/tests/test_genre_route.py diff --git a/cheap-worker/cheap_genre_route.py b/cheap-worker/cheap_genre_route.py new file mode 100644 index 00000000..56c02b96 --- /dev/null +++ b/cheap-worker/cheap_genre_route.py @@ -0,0 +1,65 @@ +"""cheap_genre_route.py — brief → genre 确定性关键词路由(生产 create 路最小品类接线)。 + +背景(M3 cutover plan「放行检查单增补 2026-07-04」):live Service 创建路 cheap_service_driver 此前硬编码 +scaffold_template=None,不选品类骨架、不透传 genre——W-GENRE 四件套(品类 skill/骨架/few-shot/评分尺)在 +生产 create 路不通电,而 S5 复验(bake_off)是显式传品类的,复验口径与放量后生产口径不同轨。本模块补最小 +路由:纯词表匹配、零 LLM、确定性(同 brief 恒同结果),词表映射到既有 GENRE_BY_TEMPLATE 的五类模板; +无命中回退 None(走通用 _template,与旧行为一致——路由只加不减,失配面=原行为)。 + +规则表数据化:_GENRE_RULES 是唯一规则源(顺序即先验序),改词表/调先验只动这一张表,不动逻辑。 +""" + +from typing import Optional, Tuple + +# 品类路由规则表(唯一规则源;列表顺序 = 先验序)。 +# 先验序依据:按【词面特异性】降序——特异词品类在前(非遗/TRPG 的词几乎不出现在别的品类 brief 里, +# 误伤率最低),泛词品类垫后(「剧情/故事」在各品类 brief 里都常见,最易过泛匹配)。多类词共现时取 +# 序前者:如「经营一家店铺,顾客的故事各不相同」同时命中经营与剧情 → 判经营(特异性高者定品类, +# 泛词只是修饰)。每条:(genre 键, scaffold 模板名, 关键词元组)——genre/模板对齐 cheap_verify.GENRE_BY_TEMPLATE。 +_GENRE_RULES: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = ( + # ① 非遗(heritage):非遗工艺词特异性最高,与其他品类零重叠。 + ("heritage", "_template-feiyi", ( + "非遗", "剪纸", "皮影", "刺绣", "年画", "扎染", "泥塑", "糖画", "风筝制作", + "传统工艺", "传统手艺", "民俗", "戏曲", "京剧", "昆曲", "陶艺", "木雕", "漆器", + )), + # ② TRPG:跑团/骰子/地下城词特异性高。 + ("trpg", "_template-trpg", ( + "trpg", "跑团", "骰子", "地下城", "龙与地下城", "dnd", "角色扮演冒险", "冒险者小队", + "桌面角色扮演", "属性检定", "掷骰", + )), + # ③ 经营(sim-business):开店/客人/生意词较特异(「服务」单字面泛,组合词收窄)。 + ("sim-business", "_template-shop", ( + "经营", "开店", "店铺", "商店", "餐厅", "饭店", "奶茶店", "咖啡店", "咖啡馆", "面馆", + "小卖部", "杂货店", "顾客", "客人", "接客", "点单", "上菜", "生意", "收银", "摆摊", + )), + # ④ 解谜(puzzle):谜题/推理词。 + ("puzzle", "_template-puzzle", ( + "解谜", "谜题", "谜语", "推理", "密室", "华容道", "数独", "拼图", "解密", "机关", + "找不同", "找茬", + )), + # ⑤ 剧情(narrative):故事/分支词最泛,垫底(仅当没有更特异品类命中时才路由到剧情)。 + ("narrative", "_template-story", ( + "剧情", "故事", "文字冒险", "互动小说", "视觉小说", "对话选择", "分支", "多结局", + "章节", "叙事", + )), +) + + +def route_genre(brief: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """把用户 brief 确定性路由到 (scaffold_template, genre);无命中 → (None, None) 走通用模板(原行为)。 + + 匹配口径:大小写不敏感的子串包含(brief 是自然语言短句,词表词都是低歧义中文词/固定拼写,子串够用、 + 不引分词依赖);按 _GENRE_RULES 先验序扫,首个命中即定(多类词共现取先验序前者)。空/非字符串 brief + → 无命中回退。纯函数、零 IO、零 LLM——同 brief 恒同结果(生产路由必须可复验)。 + + Returns: + (scaffold_template, genre):如 ("_template-shop", "sim-business");无命中 (None, None)。 + """ + if not brief or not isinstance(brief, str): + return None, None + text = brief.lower() + for genre, template, keywords in _GENRE_RULES: + for kw in keywords: + if kw.lower() in text: + return template, genre + return None, None diff --git a/cheap-worker/cheap_service_driver.py b/cheap-worker/cheap_service_driver.py index 8f5c75ab..e5977b1f 100644 --- a/cheap-worker/cheap_service_driver.py +++ b/cheap-worker/cheap_service_driver.py @@ -219,8 +219,15 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user brief = job.get("brief") or "" t0 = time.time() - # create 路默认参(对齐现 _default_run_fn):通用 scaffold(无 per-genre 模板)、通用系统提示、write_whitelist=None。 - scaffold_template = None + # create 路参数:brief→genre 确定性关键词路由(T4 最小品类接线,取代硬编码 scaffold_template=None)。 + # 命中五类(经营/剧情/TRPG/非遗/解谜)→ 选 per-genre 黄金骨架 + genre 透传丰富度评分(生产 create 路 + # 与 S5 bake_off 复验口径同轨);无命中 → (None, None) 走通用 _template(与旧行为一致,路由只加不减)。 + # write_whitelist=None(create 不收窄,对齐现 _default_run_fn)。 + from cheap_genre_route import route_genre # noqa: PLC0415 + + scaffold_template, genre = route_genre(brief) + if scaffold_template: + print(f"[cheap-driver] game={game_id} 品类路由命中:genre={genre} template={scaffold_template}", flush=True) scaffold_desc = None write_whitelist = None # 失控兜底轮数(与三闸 150 对齐;成本上界=max_repairs=6 优雅终止,而非轮数闸;放开防单 POST 多续修被 EXCEED_MAX_ITERS 提前切断)。 @@ -318,8 +325,9 @@ async def drive_cheap_generation(job: dict, *, base_url: str | None = None, user summary = _build_summary(game_id, brief, verdict, svc, turn, t0) # ⑩ reply 外收口:非阻塞丰富度 LLM 评分(additive;不进 verdict、不改 ok;token 不污染生成成本台账)。 + # genre 透传(T4):品类路由命中时同一次评分 additive 追加品类扩展条目(与 run_studio 的 genre 参数同义)。 try: - summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief) + summary["richness"] = await cheap_verify.verify_richness(game_id, brief=brief, genre=genre) rv = summary["richness"] print(f"[cheap-driver] game={game_id} 丰富度 score={rv.get('score')}/{rv.get('max')} " f"groups={rv.get('groups')} degraded={rv.get('degraded')}", flush=True) diff --git a/cheap-worker/tests/test_genre_route.py b/cheap-worker/tests/test_genre_route.py new file mode 100644 index 00000000..eb5ad5c5 --- /dev/null +++ b/cheap-worker/tests/test_genre_route.py @@ -0,0 +1,113 @@ +"""test_genre_route.py — brief→genre 确定性关键词路由单测(T4 生产 create 路品类接线)。 + +守的不变量:五类各自命中且映射到 GENRE_BY_TEMPLATE 登记的模板;无命中回退 (None, None)(=旧通用行为); +多类词共现取先验序(特异性高者胜);空/脏输入不炸;确定性(同 brief 恒同结果)。 + +跑:cheap-worker/.venv/bin/python -m pytest cheap-worker/tests/test_genre_route.py -v +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +from cheap_genre_route import route_genre # noqa: E402 +from cheap_verify import GENRE_BY_TEMPLATE # noqa: E402 + + +# ───────────────────────── 五类命中 ───────────────────────── + +def test_sim_business_hits(): + for brief in ("经营一家奶茶店", "开店卖包子招待顾客", "餐厅上菜小游戏", "收银台接客人"): + t, g = route_genre(brief) + assert (t, g) == ("_template-shop", "sim-business"), brief + + +def test_narrative_hits(): + for brief in ("一个多结局的文字冒险", "互动小说,对话选择推动剧情", "分支叙事小游戏"): + t, g = route_genre(brief) + assert (t, g) == ("_template-story", "narrative"), brief + + +def test_trpg_hits(): + for brief in ("跑团风格的冒险", "掷骰过检定的 TRPG", "地下城探险,骰子决定命运"): + t, g = route_genre(brief) + assert (t, g) == ("_template-trpg", "trpg"), brief + + +def test_heritage_hits(): + for brief in ("剪纸主题小游戏", "体验非遗皮影戏", "做一款刺绣传统工艺游戏"): + t, g = route_genre(brief) + assert (t, g) == ("_template-feiyi", "heritage"), brief + + +def test_puzzle_hits(): + for brief in ("华容道解谜", "一个密室逃脱谜题", "数独小游戏"): + t, g = route_genre(brief) + assert (t, g) == ("_template-puzzle", "puzzle"), brief + + +# ───────────────────────── 无命中回退(=旧通用行为)───────────────────────── + +def test_no_hit_falls_back_to_none(): + for brief in ("一个简单的点击得分小游戏", "接住掉落的水果", "打砖块"): + assert route_genre(brief) == (None, None), brief + + +def test_empty_and_dirty_input(): + for brief in (None, "", 123, [], {}): + assert route_genre(brief) == (None, None) + + +# ───────────────────────── 多类共现取先验序 ───────────────────────── + +def test_cooccurrence_specific_beats_generic(): + """经营词 + 剧情泛词共现 → 判经营(特异性高者定品类,泛词只是修饰;先验序=规则表序)。""" + t, g = route_genre("经营一家小店,每位顾客都有自己的故事") + assert (t, g) == ("_template-shop", "sim-business") + + +def test_cooccurrence_heritage_beats_business(): + """非遗词 + 经营词共现 → 判非遗(非遗特异性序最前:『经营剪纸铺』的灵魂是剪纸)。""" + t, g = route_genre("经营一家剪纸铺,传承传统手艺") + assert (t, g) == ("_template-feiyi", "heritage") + + +def test_cooccurrence_trpg_beats_narrative(): + t, g = route_genre("跑团故事:骰子决定每章剧情走向") + assert (t, g) == ("_template-trpg", "trpg") + + +# ───────────────────────── 一致性 ───────────────────────── + +def test_templates_registered_in_genre_by_template(): + """路由产出的每个模板都必须在 GENRE_BY_TEMPLATE 登记,且 genre 键一致(不造孤儿模板/键漂移)。""" + from cheap_genre_route import _GENRE_RULES + + for genre, template, _ in _GENRE_RULES: + assert GENRE_BY_TEMPLATE.get(template) == genre, \ + f"规则表 {template}→{genre} 与 GENRE_BY_TEMPLATE({GENRE_BY_TEMPLATE.get(template)})漂移" + + +def test_deterministic(): + """确定性:同 brief 重复路由恒同结果(生产路由必须可复验)。""" + brief = "经营奶茶店的故事" + assert all(route_genre(brief) == route_genre(brief) for _ in range(5)) + + +def test_case_insensitive(): + assert route_genre("一场 DND 冒险")[1] == "trpg" + assert route_genre("一场 dnd 冒险")[1] == "trpg" + + +if __name__ == "__main__": + _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + _failed = 0 + for _fn in _fns: + try: + _fn() + print(f" PASS {_fn.__name__}") + except Exception as e: # noqa: BLE001 + _failed += 1 + print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") + print(f"\n{len(_fns) - _failed}/{len(_fns)} passed") + sys.exit(1 if _failed else 0)