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>
159 lines
6.9 KiB
Python
159 lines
6.9 KiB
Python
"""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()
|