games-development-ai/cheap-worker/tests/test_a11_m2_classify.py
lili fc782db08b feat(studio): A11 切片三 M1 前置基座 + M2 worker 判意图
M1 前置基座(便宜档源回传落库链 + base 源注入 + 血缘):
- derive_profile 兑现(便宜档基线 profile{realtime,discrete-choice,metric})解锁 sourceProject 回传
- worker parse_modify 提取 modify 区(mode/baseVersionId/modifyPatch/sourceProject)
- executor 据 baseVersionId 反查注入 base 源到 HTTP job(镜像 SaaGraphDispatcher.resolveBaseSourceProject)
- 回调落源回填 base_version_id 血缘(landSourceQuietly)

M2 worker 判意图(两段式第一段,NL→建议改动+风险):
- cheap_classify 把用户原话 + base 源可改面(assets.js 资产清单/core.js 集中数值/game-logic 玩法)
  判成 {category,mode,target,payload,riskLevel,needsConfirm,clarify}
- mode×category 锁定(LLM 给的 mode 不采信)+ 危险回问硬编进 needsConfirm(只低风险确定性改免确认)
- worker /classify 同步端点(不入生成队列、不起 Chrome)

测试:Python test_a11_m1 5/5 + test_a11_m2_classify 9/9 + 回归全绿;
Java 执行器/回调/源服务 + SAA 回归(本地 maven 独立复跑 46 + 子代理 98)全绿。

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

159 lines
6.9 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.

"""test_a11_m2_classify.py — A11 切片三 M2 判意图(classify)纯函数单测。
判意图 = 用户原话 + base 源工程 → LLM 判 {category, mode, target, payload, riskLevel, needsConfirm, clarify}。
LLM 调用是集成(M5 e2e 真验),本测只守两个纯函数:
· build_classify_prompt:把 base 源的可改面(资产清单 / 集中数值 / 文件列表)+ 用户原话喂进 prompt;
· parse_classify_response:解析 LLM JSON + 落 mode×category 锁定映射 + 风险/确认规则 + 坏 JSON 安全兜底。
跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m2_classify.py
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/
import cheap_classify as C # noqa: E402
def _src_project() -> dict:
"""造一个便宜档 base 源工程(2.0):assets.js 资产清单 + core.js 集中数值 + game-logic.js 玩法。"""
return {
"schemaVersion": "2.0",
"sourceHash": "deadbeef",
"profile": {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"},
"files": {
"src/assets.js": "export const IMAGE_FILES = { heroSprite: 'hero.png', bg: 'bg.jpg' };\n",
"src/core.js": "export const ROUND_MS = 30000;\nexport const COLOR_PALETTE = ['red','blue'];\n",
"src/game-logic.js": "// 彩点点击得分玩法\nimport { ROUND_MS } from './core.js';\n",
},
"entry": "entry.js",
"globalName": "__GameBundle",
}
# ── build_classify_prompt:可改面 + 原话进 prompt ──
def test_prompt_includes_instruction_and_changeable_surface():
p = C.build_classify_prompt("把主角调成绿色", _src_project())
assert "把主角调成绿色" in p, "用户原话必须进 prompt"
assert "assets.js" in p and "heroSprite" in p, "资产清单(可改面)要进 prompt 供定位 asset 目标"
assert "core.js" in p and "ROUND_MS" in p, "集中数值(可改面)要进 prompt 供定位 config 目标"
assert "game-logic.js" in p, "玩法文件要进 prompt 供 behavior 判断"
def test_prompt_handles_string_source_project():
"""source_project 传 JSON 串也行(后端经 HTTP 传的是串)。"""
p = C.build_classify_prompt("加快下落", json.dumps(_src_project()))
assert "加快下落" in p and "core.js" in p
# ── parse_classify_response:映射 + 风险/确认 + 兜底 ──
def _llm(obj: dict) -> str:
"""模拟 LLM 返回(可能裹 ```json 围栏,parse 要能剥)。"""
return "```json\n" + json.dumps(obj, ensure_ascii=False) + "\n```"
def test_parse_asset_change_low_risk_no_confirm():
out = C.parse_classify_response(_llm({
"category": "asset", "target": {"kind": "asset", "path": "/files/src/assets.js#heroSprite"},
"payload": {"value": {"heroSprite": "hero-green.png"}}, "riskLevel": "low",
"rationale": "换主角贴图", "clarify": None,
}))
assert out["category"] == "asset"
assert out["mode"] == "deterministic", "asset/config/level → deterministic(锁定映射)"
assert out["needsConfirm"] is False, "低风险确定性改 → 不强制确认"
def test_parse_behavior_change_is_regenerate_and_confirm():
out = C.parse_classify_response(_llm({
"category": "behavior", "target": {"kind": "behavior", "path": "/files/src/game-logic.js"},
"payload": {"intent": "陨石改成左右摇摆"}, "riskLevel": "medium", "rationale": "改玩法逻辑",
}))
assert out["mode"] == "regenerate-module", "behavior → regenerate-module"
assert out["needsConfirm"] is True, "改玩法(模块重生成)默认要确认"
def test_parse_big_change_high_risk_confirm_no_mode():
out = C.parse_classify_response(_llm({
"category": "big-change", "riskLevel": "high",
"rationale": "把点击游戏改成 3D 赛车 = 换品类重写", "clarify": "这是大改动,需重新生成整个游戏,确认?",
}))
assert out["category"] == "big-change"
assert out["mode"] is None, "大改不落 deterministic/regenerate-module(超局部调整)"
assert out["riskLevel"] == "high" and out["needsConfirm"] is True
assert out["clarify"]
def test_parse_unclear_asks_clarify():
out = C.parse_classify_response(_llm({
"category": "unclear", "rationale": "意图不明", "clarify": "你想改哪一处?颜色、速度还是玩法?",
}))
assert out["category"] == "unclear"
assert out["needsConfirm"] is True and out["clarify"]
def test_parse_enforces_mode_category_lock():
"""LLM 给了矛盾的 category=asset + mode=regenerate-module → 按 category 纠正 mode(锁定映射可信边界)。"""
out = C.parse_classify_response(_llm({
"category": "asset", "mode": "regenerate-module",
"target": {"kind": "asset", "path": "/x"}, "payload": {"value": {}}, "riskLevel": "low",
}))
assert out["mode"] == "deterministic", "category=asset 必落 deterministic,无视 LLM 给的矛盾 mode"
def test_parse_bad_json_safe_fallback():
"""坏 JSON / 非 JSON → 安全兜底为 unclear + 需确认(绝不抛、绝不静默判成可执行改动)。"""
out = C.parse_classify_response("这不是 JSON,模型胡说一通")
assert out["category"] == "unclear"
assert out["needsConfirm"] is True
assert out["mode"] is None
# ── worker /classify 端点路由(注入 classify_fn 桩,免真网络)──
def test_classify_endpoint_routes_and_returns_proposal():
import json as _json
import urllib.request
import worker_service as WS
stub = {"category": "config", "mode": "deterministic", "needsConfirm": False, "riskLevel": "low"}
state = WS.WorkerState(classify_fn=lambda raw, sp: stub)
server, port = WS.start_server(state, host="127.0.0.1", port=0)
try:
req = urllib.request.Request(
f"http://127.0.0.1:{port}/classify",
data=_json.dumps({"rawText": "加快下落", "sourceProject": "{}"}).encode("utf-8"),
method="POST", headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 200
out = _json.loads(resp.read().decode("utf-8"))
assert out["category"] == "config" and out["needsConfirm"] is False
# 缺 rawText → 400
bad = urllib.request.Request(
f"http://127.0.0.1:{port}/classify",
data=_json.dumps({"sourceProject": "{}"}).encode("utf-8"),
method="POST", headers={"Content-Type": "application/json"})
try:
urllib.request.urlopen(bad, timeout=5)
assert False, "缺 rawText 应 400"
except urllib.error.HTTPError as e:
assert e.code == 400
finally:
server.shutdown()
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
fn()
print(f" ✓ {fn.__name__}")
print(f"\n[test_a11_m2_classify] {len(fns)}/{len(fns)} passed")
if __name__ == "__main__":
_run_all()