From fc782db08b825036da5888f8450bc42207e5becf Mon Sep 17 00:00:00 2001 From: lili Date: Mon, 29 Jun 2026 01:04:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20A11=20=E5=88=87=E7=89=87?= =?UTF-8?q?=E4=B8=89=20M1=20=E5=89=8D=E7=BD=AE=E5=9F=BA=E5=BA=A7=20+=20M2?= =?UTF-8?q?=20worker=20=E5=88=A4=E6=84=8F=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cheap-worker/cheap_classify.py | 210 +++++++++++++ cheap-worker/tests/test_a11_m1.py | 114 +++++++ cheap-worker/tests/test_a11_m2_classify.py | 158 ++++++++++ cheap-worker/worker_service.py | 64 +++- ...003-feat-studio-A11-对话式调整回路-plan.md | 293 ++++++++++++++++++ .../config/AigcExecutorConfiguration.java | 11 +- .../callback/DifyCallbackServiceImpl.java | 6 +- .../executor/AigcGenerateExecutor.java | 105 ++++++- .../callback/DifyCallbackServiceImplTest.java | 38 +++ .../executor/AigcGenerateExecutorTest.java | 130 ++++++++ .../studio/SourceProjectServiceImplTest.java | 19 ++ 11 files changed, 1124 insertions(+), 24 deletions(-) create mode 100644 cheap-worker/cheap_classify.py create mode 100644 cheap-worker/tests/test_a11_m1.py create mode 100644 cheap-worker/tests/test_a11_m2_classify.py create mode 100644 docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md diff --git a/cheap-worker/cheap_classify.py b/cheap-worker/cheap_classify.py new file mode 100644 index 00000000..0b513de8 --- /dev/null +++ b/cheap-worker/cheap_classify.py @@ -0,0 +1,210 @@ +"""cheap_classify.py — A11 切片三 M2 判意图(NL → 结构化建议改动 + 风险)。 + +两段式回路第一段:用户试玩后用自然语言提改 → 本模块据 base 源工程 + 原话,判出建议的 +{category, mode, target, payload, riskLevel, needsConfirm, clarify},返前端供确认;确认后前端再调 +已结构化的 /modify 执行(第二段)。本模块不执行、不建版本、不计费——只判意图。 + +落点(对齐生成侧工程规范性,创始人 2026-06-28): + · 换资产 → assets.js 资产清单的一处(键→地址); + · 调数值 → core.js 集中常量的一处; + · 改玩法 → game-logic.js 模块重生成(intent 喂 LLM); + · 大改/换品类/危险 → 高风险、回问用户确认;意图不明 → 回问澄清。 + +纯函数(build_classify_prompt / parse_classify_response)无网络无 LLM、可单测;classify 为集成路 +(调便宜档 M3),complete_fn 可注入(单测桩/换模型)。 +""" + +import json +import re + +# mode×category 锁定映射(可信边界:LLM 给的 mode 不可信,一律按 category 落 —— 对齐契约 StudioModifyReqVO)。 +_DETERMINISTIC_CATEGORIES = ("asset", "config", "level") +_VALID_CATEGORIES = ("asset", "config", "level", "behavior", "big-change", "unclear") +_VALID_RISK = ("low", "medium", "high") + +# 判意图给 LLM 看的可改面文件(资产清单 / 集中数值 / 玩法);其余 plumbing 文件不喂(判意图不需要)。 +_SURFACE_FILES = ("src/assets.js", "src/core.js", "src/game-logic.js") + + +def _as_dict(source_project) -> dict: + """source_project 容错成 dict(后端经 HTTP 传的是 JSON 串;也容忍已是 dict)。坏输入返 {}。""" + if isinstance(source_project, dict): + return source_project + if isinstance(source_project, str) and source_project.strip(): + try: + obj = json.loads(source_project) + return obj if isinstance(obj, dict) else {} + except Exception: # noqa: BLE001 — 坏 JSON 容错返空,build_prompt 仍可只带原话 + return {} + return {} + + +def build_classify_prompt(raw_text: str, source_project) -> str: + """把 base 源的可改面(资产清单 / 集中数值 / 玩法 + 文件列表)与用户原话组成判意图 prompt。 + + 可改面对齐工程规范性:assets.js = 资产统一管理的一处、core.js = 数值集中的一处,喂给 LLM 以便它把 + 改动定位到"那一处"。其余 L1 plumbing 文件不喂。 + """ + sp = _as_dict(source_project) + files = sp.get("files") if isinstance(sp.get("files"), dict) else {} + file_list = sorted(files.keys()) + + surface_blocks = [] + for path in _SURFACE_FILES: + content = files.get(path) + if content: + # 截断防超长(判意图只需看可改面结构,不需全文)。 + snippet = content if len(content) <= 1600 else content[:1600] + "\n…(截断)" + surface_blocks.append(f"--- {path} ---\n{snippet}") + surface_text = "\n\n".join(surface_blocks) if surface_blocks else "(无可改面文件)" + + return f"""你是游戏调整意图判断器。用户试玩了一款小游戏后,用自然语言提出修改。你的任务:判断这次修改属于哪一类、应落到哪一处,并评估风险。**只判断,不执行。** + +【用户原话】 +{raw_text} + +【游戏文件列表】 +{", ".join(file_list) if file_list else "(空)"} + +【可改面(改动应落到这里的一处)】 +{surface_text} + +【分类规则】 +- 换美术/贴图/配色/图标/角色外观 → category="asset"(改 assets.js 资产清单的一处键→地址,或配色常量); +- 调数值/难度/速度/时长/分数/节奏/数量 → category="config"(改 core.js 集中常量的一处); +- 改关卡/布局/场景排布 → category="level"; +- 改玩法/规则/机制/胜负条件/逻辑 → category="behavior"(模块重生成 game-logic.js,intent 写改写意图); +- 换品类/整体重做/推倒重写/危险不可逆(清档/重置) → category="big-change"(超出局部调整); +- 意图不明/无法定位到具体一处 → category="unclear"(回问澄清)。 + +【输出】严格输出一个 JSON 对象(不要多余文字),字段: +{{"category": "asset|config|level|behavior|big-change|unclear", + "target": {{"kind": "asset|config|level|behavior", "path": "落点(如 assets.js 的键名 / core.js 的常量名 / 文件路径)"}} 或 null, + "payload": {{"value": {{"键": "新值"}}}}(确定性类) 或 {{"intent": "改写意图"}}(behavior) 或 null, + "riskLevel": "low|medium|high", + "rationale": "一句话判断依据(中文)", + "clarify": "若 unclear/big-change,回问用户的话;否则 null"}} +""" + + +def _safe_fallback(reason: str) -> dict: + """坏 JSON / 解析失败的安全兜底:判为 unclear + 需确认 + 不落任何可执行 mode(绝不静默判成可执行改动)。""" + return { + "category": "unclear", "mode": None, "target": None, "payload": None, + "riskLevel": "medium", "needsConfirm": True, + "rationale": f"未能解析判意图结果({reason})", "clarify": "没太理解你想改什么,能换种说法、或说明要改哪一处吗?", + } + + +def _extract_json(text: str): + """从 LLM 文本剥出 JSON 对象(剥 ```json 围栏 / 取首个 {…} 块);失败返 None。""" + if not isinstance(text, str) or not text.strip(): + return None + s = text.strip() + fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", s, re.DOTALL) + if fence: + s = fence.group(1) + else: + brace = re.search(r"\{.*\}", s, re.DOTALL) + if brace: + s = brace.group(0) + try: + obj = json.loads(s) + return obj if isinstance(obj, dict) else None + except Exception: # noqa: BLE001 + return None + + +def parse_classify_response(text: str) -> dict: + """解析 LLM 判意图输出 → 规范化建议改动 + 落 mode×category 锁定 + 风险/确认规则 + 坏 JSON 兜底。 + + 可信边界:LLM 给的 mode 不采信,一律按 category 落 mode(asset/config/level→deterministic、 + behavior→regenerate-module、big-change/unclear→None);needsConfirm 由本函数定(只有低风险确定性改 + 才免确认),不交给 LLM——危险/大改/改玩法/意图不明一律回问(创始人 2026-06-28)。 + """ + obj = _extract_json(text) + if obj is None: + return _safe_fallback("非 JSON") + + category = obj.get("category") + if category not in _VALID_CATEGORIES: + return _safe_fallback(f"未知 category={category}") + + # mode 按 category 锁定(无视 LLM 给的 mode)。 + if category in _DETERMINISTIC_CATEGORIES: + mode = "deterministic" + elif category == "behavior": + mode = "regenerate-module" + else: # big-change / unclear + mode = None + + risk = obj.get("riskLevel") + if risk not in _VALID_RISK: + risk = "medium" + if category == "big-change": + risk = "high" # 大改强制高风险 + + # needsConfirm:只有"低风险 + 确定性类"才免确认;其余(改玩法/大改/意图不明/中高风险)一律回问。 + needs_confirm = not (mode == "deterministic" and risk == "low") + + clarify = obj.get("clarify") + if not isinstance(clarify, str) or not clarify.strip(): + clarify = None + if category in ("unclear", "big-change") and not clarify: + clarify = "这处改动较大或意图不够明确,想确认一下你具体想怎么改?" + + return { + "category": category, + "mode": mode, + "target": obj.get("target") if isinstance(obj.get("target"), dict) else None, + "payload": obj.get("payload") if isinstance(obj.get("payload"), dict) else None, + "riskLevel": risk, + "needsConfirm": needs_confirm, + "rationale": obj.get("rationale") if isinstance(obj.get("rationale"), str) else "", + "clarify": clarify, + } + + +def _default_complete(prompt: str, *, max_tokens: int = 1500) -> str: + """默认 LLM 调用:openai 协议直调 new-api 网关(便宜档 M3,温度 0 求稳定分类)。集成路、非单测。 + + 复用框架代理旁路:load_framework() 触发 config import 把 new-api host 并入 NO_PROXY,故 openai + 客户端(httpx 读 NO_PROXY)对内网网关直连、不被系统代理 fake-ip 拦 502。base_url 取 host 根补 /v1 + (内网凭据文档 baseUrl 坑:Python openai 侧须带 /v1)。 + """ + import os + + import openai + + import _bootstrap + + fw = _bootstrap.load_framework() # 装代理旁路 + ensure key(在 import openai 前已由 config 链装好) + base = fw["client"].resolve_base_url().rstrip("/") + if not base.endswith("/v1"): + base = base + "/v1" + oc = openai.OpenAI(base_url=base, api_key=os.environ["NEWAPI_KEY"]) + resp = oc.chat.completions.create( + model=_bootstrap.SPIKE_MODEL, temperature=0, max_tokens=max_tokens, + messages=[{"role": "user", "content": prompt}], + ) + return resp.choices[0].message.content or "" + + +def classify(raw_text: str, source_project, *, complete_fn=None) -> dict: + """判意图集成入口:组 prompt → 调 LLM(complete_fn 可注入)→ 解析规范化。 + + Args: + raw_text: 用户自然语言改话。 + source_project: base 源工程(dict 或 2.0 JSON 串)。 + complete_fn: (prompt:str)->str 的 LLM 调用桩(默认 _default_complete);单测注入免真网络。 + + Returns: + 规范化建议改动 dict(见 parse_classify_response);LLM 调用异常 → 安全兜底 unclear+需确认。 + """ + fn = complete_fn or _default_complete + prompt = build_classify_prompt(raw_text, source_project) + try: + text = fn(prompt) + except Exception as e: # noqa: BLE001 — LLM 异常不外抛,兜底为需确认的 unclear(绝不静默判成可执行) + return _safe_fallback(f"LLM 调用异常:{type(e).__name__}") + return parse_classify_response(text) diff --git a/cheap-worker/tests/test_a11_m1.py b/cheap-worker/tests/test_a11_m1.py new file mode 100644 index 00000000..8ff63e3b --- /dev/null +++ b/cheap-worker/tests/test_a11_m1.py @@ -0,0 +1,114 @@ +"""test_a11_m1.py — A11 切片三 M1 前置基座(Python 侧)单测。 + +守两个不变量: + · derive_profile 兑现:便宜档 tap-targets 返基线 profile{realtime,discrete-choice,metric}(三枚举齐), + 解锁 build_source_project → sourceProject 随 result-out 回传 → game_source_project 落库 → base 版本可反查。 + · parse_modify 提取 §6.1 job 的 modify 区(modifyMode/baseVersionId/modifyPatch/sourceProject); + 无 modifyMode = create 路、返 None(现行 create 路零副作用)。 + +跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_a11_m1.py +""" + +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ +import result_out as R # noqa: E402 +import worker_service as W # noqa: E402 + + +def _tmp() -> Path: + return Path(tempfile.mkdtemp(prefix="a11-m1-")) + + +def _make_game_dir(tmp: Path) -> Path: + """造 amgen-/ 形态产物目录(src/ 多文件 + bundle)。""" + gd = tmp / "amgen-m1" + (gd / "src").mkdir(parents=True) + (gd / "bundle.iife.js").write_text("var __GameBundle=(function(){return{}})();", encoding="utf-8") + (gd / "src" / "game-logic.js").write_text("// logic\nconst ROUND_MS=30000;\n", encoding="utf-8") + (gd / "src" / "core.js").write_text("// core\nexport const COLOR_PALETTE=['red'];\n", encoding="utf-8") + return gd + + +# ── M1① derive_profile 兑现 ── + +def test_derive_profile_returns_valid_baseline(): + """derive_profile 返三枚举齐的合法 profile(非 None),且过 result_out._valid_profile。""" + job = {"gameId": "amgen-m1", "templateId": "generic", "brief": "点点乐"} + profile = W.derive_profile(job, _make_game_dir(_tmp())) + assert profile is not None, "derive_profile 不应再恒返 None" + assert R._valid_profile(profile), f"profile 三枚举不全/非法: {profile}" + assert profile["tickModel"] in ("realtime", "turn-based", "event") + assert profile["inputModel"] in ("continuous", "discrete-choice", "text-command") + assert profile["progressModel"] in ("metric", "narrative") + + +def test_derive_profile_unblocks_source_project(): + """有了合法 profile,build_source_project 能组出带 files 的 2.0 源工程(非 None)。""" + import json + gd = _make_game_dir(_tmp()) + job = {"gameId": "amgen-m1", "brief": "点点乐"} + sp = R.build_source_project(gd, W.derive_profile(job, gd)) + assert sp is not None, "profile 兑现后 sourceProject 不应再被省略" + obj = json.loads(sp) + assert obj["schemaVersion"] == "2.0" + assert "src/game-logic.js" in obj["files"] and "src/core.js" in obj["files"] + assert R._valid_profile(obj["profile"]) + + +def test_process_job_carries_source_project(): + """端到端接线:derive_profile 兑现后,process_job 的回调 payload 带 sourceProject(源回传通)。""" + gd = _make_game_dir(_tmp()) + summary = {"verdict": {"pass": True}, "wallSec": 1.0, "attempts": 1, "gameId": "amgen-m1", "stage": "done"} + captured = {} + + def fake_send(url, payload, secret, **kw): + captured["payload"] = payload + return 200, "ok" + + state = W.WorkerState(run_fn=lambda job: (summary, gd), send_fn=fake_send) + job = {"job_id": "t-m1", "traceId": "t-m1", "templateId": "generic", "brief": "点点乐", + "callback": {"target": "http://localhost:48080/cb"}} + W.process_job(state, job) + assert "sourceProject" in captured["payload"], "源回传应把 sourceProject 带进 result-out" + + +# ── M1④ parse_modify 提取 modify 区 ── + +def test_parse_modify_extracts_fields(): + """有 modifyMode 的 job → 提取 {mode,baseVersionId,modifyPatch,sourceProject} 四件。""" + job = { + "gameId": "amgen-m1", + "modifyMode": "deterministic", + "baseVersionId": 4096, + "modifyPatch": '{"target":{"kind":"config","path":"/config/speed"},"payload":{"value":2}}', + "sourceProject": '{"schemaVersion":"2.0","files":{}}', + } + m = W.parse_modify(job) + assert m is not None + assert m["mode"] == "deterministic" + assert m["baseVersionId"] == 4096 + assert m["modifyPatch"] and "config" in m["modifyPatch"] + assert m["sourceProject"] and "2.0" in m["sourceProject"] + + +def test_parse_modify_create_path_none(): + """无 modifyMode = create 路 → 返 None(现行 create 路零副作用)。""" + assert W.parse_modify({"gameId": "amgen-m1", "brief": "点点乐"}) is None + assert W.parse_modify({"gameId": "amgen-m1", "modifyMode": ""}) is None + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + passed = 0 + for fn in fns: + fn() + print(f" ✓ {fn.__name__}") + passed += 1 + print(f"\n[test_a11_m1] {passed}/{len(fns)} passed") + + +if __name__ == "__main__": + _run_all() diff --git a/cheap-worker/tests/test_a11_m2_classify.py b/cheap-worker/tests/test_a11_m2_classify.py new file mode 100644 index 00000000..d45aebc8 --- /dev/null +++ b/cheap-worker/tests/test_a11_m2_classify.py @@ -0,0 +1,158 @@ +"""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() diff --git a/cheap-worker/worker_service.py b/cheap-worker/worker_service.py index 2297d860..a18e2eb1 100644 --- a/cheap-worker/worker_service.py +++ b/cheap-worker/worker_service.py @@ -24,6 +24,7 @@ import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import cheap_classify import dedup import result_out @@ -56,6 +57,24 @@ def parse_job(raw: bytes) -> dict | None: return None +def parse_modify(job: dict) -> dict | None: + """从 §6.1 job 提取 modify 区(A11 调整路);无 modifyMode = create 路、返 None(现行 create 零副作用)。 + + 后端 putModifyFieldsIntoJob 放的键:modifyMode / baseVersionId / modifyPatch / sourceProject(base 源工程 + JSON 串,据 baseVersionId 反查注入)。A11 执行段(M3 确定性类 / M4 模块重生成)据此取 base 源 + 改动意图; + 判意图段(M2)产出的结构化改动经 modifyPatch 带入。本函数只提取不执行——执行落 M3/M4。 + """ + mode = job.get("modifyMode") + if not mode: + return None + return { + "mode": mode, + "baseVersionId": job.get("baseVersionId"), + "modifyPatch": job.get("modifyPatch"), + "sourceProject": job.get("sourceProject"), # base 源工程 JSON 串(后端反查注入;worker 只消费不反查) + } + + # ---------- HMAC 回调签名(与 Java CallbackSignatureVerifier 对账)---------- def compute_signature(secret: str, data: bytes) -> str | None: @@ -96,12 +115,14 @@ def post_callback(url: str, payload: dict, secret: str, *, opener=None) -> tuple # ---------- best-effort profile 派生(sourceProject 用;派生不出返 None=省略)---------- def derive_profile(job: dict, game_dir) -> dict | None: - """从产物/play-spec best-effort 派生源工程 2.0 profile{tickModel,inputModel,progressModel}。 + """派生源工程 2.0 profile{tickModel,inputModel,progressModel}(便宜档基线)。 - M3a 起步:sourceProject 非承重(additive,缺则后端源落库旁路)。当前不强行派生,返 None=省略 - (绝不伪造 profile 凑 schema)。play-spec→profile 的真派生待 modify 路(U6/future)需要时再接。 + A11(切片三)起:sourceProject 由非承重转为 modify 路的硬前置——源不回传落库则 base 版本反查为空、 + 无源可改。便宜档当前只产 tap-targets 玩法(点击得分/打地鼠/经营点客等),都是实时循环 + 离散点击 + + 指标进度,故返基线 profile{realtime, discrete-choice, metric}(非伪造:tap-targets 本就这三维)。 + 未来便宜档扩到回合制(如 2048)/叙事类时,从 play-spec/产物派生而非固定基线。 """ - return None + return {"tickModel": "realtime", "inputModel": "discrete-choice", "progressModel": "metric"} # ---------- 默认生成核心(lazy import,避免单测触发 tier2/key 装配)---------- @@ -128,12 +149,13 @@ class WorkerState: """进程级状态:配置 + 有界队列 + 可注入依赖 + job_id 去重。""" def __init__(self, *, callback_secret: str = "", queue_maxsize: int = DEFAULT_QUEUE_MAXSIZE, - run_fn=None, send_fn=None, profile_fn=None): + run_fn=None, send_fn=None, profile_fn=None, classify_fn=None): self.callback_secret = callback_secret self.queue: queue.Queue = queue.Queue(maxsize=queue_maxsize) self.run_fn = run_fn or _default_run_fn # job -> (summary, game_dir) self.send_fn = send_fn or post_callback # (url, payload, secret) -> (status, body) self.profile_fn = profile_fn or derive_profile # (job, game_dir) -> profile | None + self.classify_fn = classify_fn or cheap_classify.classify # (rawText, sourceProject) -> 建议改动(A11 M2 判意图) self._seen: set = set() # job_id 去重(防同 job 重投重跑) self._lock = threading.Lock() @@ -251,12 +273,38 @@ def make_handler(state: WorkerState): else: self._send_json(404, {"error": "not found"}) - def do_POST(self): - if self.path.rstrip("/") != "/generate": - self._send_json(404, {"error": "not found", "path": self.path}) + def _handle_classify(self, raw: bytes): + """A11 M2 判意图(同步):读 {rawText, sourceProject} → classify_fn → 返建议改动。 + + 不入生成队列、不起 Chrome——判意图是一次轻 LLM 调用(ThreadingHTTPServer 天然并发安全)。 + 异常一律兜底为"需确认的 unclear"(绝不静默判成可执行改动),HTTP 仍 200 带兜底建议。 + """ + body = parse_job(raw) + if body is None: + self._send_json(400, {"error": "bad classify json"}) return + raw_text = (body.get("rawText") or "").strip() + if not raw_text: + self._send_json(400, {"error": "rawText required"}) + return + try: + proposal = state.classify_fn(raw_text, body.get("sourceProject")) + log(f"判意图完成 category={proposal.get('category')}, needsConfirm={proposal.get('needsConfirm')}") + self._send_json(200, proposal) + except Exception as e: # noqa: BLE001 — 判意图异常不外抛,返需确认兜底 + log(f"判意图异常 → 兜底 unclear: {type(e).__name__}: {e}") + self._send_json(200, cheap_classify._safe_fallback(f"endpoint 异常:{type(e).__name__}")) + + def do_POST(self): length = int(self.headers.get("Content-Length", "0") or "0") raw = self.rfile.read(length) if length > 0 else b"" + route = self.path.rstrip("/") + if route == "/classify": + self._handle_classify(raw) + return + if route != "/generate": + self._send_json(404, {"error": "not found", "path": self.path}) + return job = parse_job(raw) if job is None: self._send_json(400, {"accepted": False, "error": "bad job json"}) diff --git a/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md b/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md new file mode 100644 index 00000000..2d80676f --- /dev/null +++ b/docs/plans/2026-06-28-003-feat-studio-A11-对话式调整回路-plan.md @@ -0,0 +1,293 @@ +--- +title: A11 对话式调整回路(试玩后纠错)细化执行 plan +status: 已批(创始人 2026-06-29)· 执行中(M1✅ → M2) +date: 2026-06-28 +topic: studio-A11-调整回路 +canonical: false # 切片三统领在 plan①;本档是「切片三·A11」的可执行细化,不另立 SoT;评审后把 A11 设计面蒸馏回 docs/architecture 生成引擎域(兑现 plan① 收口 TODO⑥) +承接: + - docs/plans/2026-06-25-生成引擎统一执行计划-AgentScope三档-plan.md # plan① · 切片三统领(§切片三 142-160) + - docs/architecture/架构/生成引擎/agentic运行时架构图说.md # 运行时 SoT · §三 A3/A3.5/A4/A5/A11 + - contracts/api-schemas/studio.yaml # StudioModify 契约(执行入口·已落,反映在 Java VO) + - contracts/agent-loop/source-project.schema.json # 源工程工件契约(config 字段) +关联代码: + - game-cloud/game-module-studio/.../service/studio/StudioServiceImpl.java # modify()/extend() 已落地(方法体 308-333) + - game-cloud/.../db/migration/V18.0.0__create_game_source_project.sql # 源工程落库 + base_version_id 血缘列 + - game-cloud/.../aigc/saa/SaaStudioGraph.java # SAA 路 modify 节点(deterministic 因 build 未闭合 fail-loud) + - game-cloud/.../aigc/service/callback/DifyCallbackServiceImpl.java # landSourceQuietly 通用落源(缺 base_version_id 回填) + - cheap-worker/{worker_service,cheap_studio,cheap_run,result_out}.py # 便宜档线 · A11 主要落点 +图清单: [图1 问题框定(已建vs缺口), 图2 两段式回路时序, 图3 执行任务态机, 图4 里程碑依赖] +--- + +# A11 对话式调整回路(试玩后纠错)细化执行 plan + +> 本档覆盖 plan① 切片三 A11 的完整交付定义,过 §6.8 双评审 + 创始人批后展开 TDD 实施。真跑(chrome 九门、真后端 e2e)只在 mini-desktop。 + +## Summary(结论先行) + +用户试玩生成的游戏后,会用自然语言回头提调整("主角颜色太暗""加快下落""把它做成赛车游戏")。这条回路缺位,产品阶段三承诺的对话式创作出口就是个无入口的孤儿——首次生成成了终点。A11 给它工程承接。 + +逐文件核完代码,A11 的图景与"切片三要从零建调整机制"的预期相反:契约、后端 `/modify` 执行编排、源工程落库表、修改血缘列、配额门都已落地,可直接复用。缺口集中在切片三所坐的便宜档 Python 线(cheap-worker)——它完全不认 modify:后端 job 里带了 modify 字段,worker 读都不读,会把一次调整静默做成整局重新生成。 + +调整回路的形态由创始人(2026-06-28)定死:A11 是 LLM 驱动的纠错回路、不是零-LLM 静态覆写。流程是"用户提改 → LLM 判意图 → 确认修改范围与用户输入 → 分类执行(替换资产 / 调整数值 / 大改动反问用户)",改动过大或危险操作要回问用户确认、不擅自重写。 + +可改性的根基是生成出来的工程本身规范——资产统一管理、数值集中,使一次修改落到一处(一个变量 / 一个文件 / 一个 CDN 地址)。便宜档现状已大体具备:资产由 `assets.js` 统一管理、核心数值集中在 `core.js`,二者都是可写文件,A11 判意图后把改落到这一处即可。这是生成质量、不是 A11 的静态门(判意图仍 LLM 驱动)。复杂档同此要求——设计跨档,实现随 tier2 go 跟进(创始人 2026-06-28 定 A)。 + +这条流程怎么落进系统,是本档评审收敛出的关键架构(创始人定 A 两段式):判意图与执行分离成两次请求。第一段是新增的判意图入口——收用户原话 + baseVersionId,由便宜档 worker 取 base 源、LLM 判出建议的结构化改动 + 风险等级,返回前端。前端展示建议供用户确认或编辑(危险/大改必须确认),确认后才调用已落地的结构化 `/modify` 执行。HITL 落在两次调用之间的前端确认,不动 aigc 内部状态机。这绕开了"NL 进不了已结构化的 /modify 入口"和"input-required 暂停态无承载"两处阻断;A2A 原生 input-required 暂停态留后期。 + +五个里程碑:M1 打前置基座(补 `derive_profile` 让源工程带回传 + 后端注入 base 源到 job + 回调落源回填 base_version_id,无源可改则一切免谈);M2 立判意图入口与前端确认(新端点 + 风险分级 + 危险回问);M3、M4 落两类执行(确定性类:换资产/调数值改规范工程那一处;模块重生成:有界单文件重写改玩法);M5 合验收闭环(三结构断言 + 接配额 + 跑通一次受计费的真实调整任务)。整体重设计、便宜档 extend 落点均留后期,不计入本切片。 + +## Problem Frame(问题框定) + +```mermaid +flowchart TB + user["用户试玩后提改
自然语言原话"] --> plan_ep["判意图入口
【本切片新建】"] + plan_ep --> confirm["前端展示建议 + 风险
用户确认/编辑【本切片】"] + confirm --> modify["studio /modify 执行入口
已结构化【已落地·复用】"] + modify --> orch["取 base 源 → submitGenerate
带 modify 字段 → 过 D12 配额门
【已落地·复用】"] + orch --> route{"D3 dispatcher
便宜档路由"} + route -->|"SAA Java 路"| saa["SAA modify 节点:regenerate 可达
deterministic 因 build 未闭合 fail-loud
【非完整复用证据】"] + route -->|"便宜档 Python 路
(切片三所坐)"| cheap["cheap-worker
❌ 不认 modify
❌ derive_profile 恒 None→源不带回传
❌ 无判意图/分类执行"] + cheap -.->|"本切片补"| fix["认 modify job + 读 job.base 源
分类执行 + 改动清单回传
三结构断言"] + + classDef built fill:#dcfce7,stroke:#16a34a,color:#0f172a; + classDef gap fill:#fee2e2,stroke:#dc2626,color:#0f172a; + classDef todo fill:#ede9fe,stroke:#7c3aed,color:#0f172a; + class modify,orch built; + class saa,cheap gap; + class plan_ep,confirm,fix todo; +``` + +绿块(结构化 /modify 编排 + 配额)已建、复用;红块(便宜档不认 modify、SAA deterministic 未闭合)是缺口;紫块(判意图入口 + 前端确认 + 便宜档分类执行)本切片建。 + +## 起点与边界 + +**起点(已落地、复用、不重建)** —— 数字代码事实核验(双评审逐文件确认): + +- **执行契约全有**:`studio.yaml` 的 `StudioModifyReqVO{baseVersionId, mode, target, payload}`(required + 枚举),`mode×target.kind` 锁定映射(deterministic ⇔ {asset,config,level} + value / regenerate-module ⇔ behavior + intent),违例 1-112-003-*** 拒;反映在 Java VO。这是**执行**入口,入参已结构化。 +- **后端执行编排全有**:`AppStudioController` POST `/modify`;`StudioServiceImpl.modify()`(方法体 308-333:校验锁定映射 → 取 base 源归属校验 → 建会话 + 落任务链 → `aigcApi.submitGenerate` 带 modify 字段)。 +- **生成路落库与透传**:`AigcTaskDO` 持久化 `modify_mode/base_version_id/modify_patch`(V19);`AigcGenerateExecutor` tick 认领时把这三个字段放进 job——但**当前不放 base 源**(执行器明确不带 sourceProject)。 +- **源工程落库表与血缘列**:`game_source_project`(V18 + V20 uk)存 `sourceJson/sourceHash/status/base_version_id`;回调落源 `landSourceQuietly` 是**通用**写入路径(入口只看 sourceProject 非空、与来源无关),便宜档 HTTP 回调只要带 sourceProject 就会落库——但**当前不回填 base_version_id**。 +- **源回传管道大体已通**:`result_out.build_source_project` 已实现、`build_result_out` 已支持 sourceProject 字段、`process_job` 已接线;唯一断点是 `derive_profile` 恒返 None,导致 sourceProject 永不被带上。 +- **产物工程规范性大体具备**:便宜档产物的资产已统一管理(`assets.js` 的 `IMAGE_FILES` 一处声明、按键间接引用、`baseUrl` 可指 CDN),核心数值集中在 `core.js`(`ROUND_MS`/`COLOR_PALETTE` 等);二者都是可写 L3(不在 `_L1_FIXED`)。换资产/调数值落到这一处即可,差最后的数值集中纪律(M3 轻量收紧)。 +- **配额门现成**:D12 `enqueueWithControlPlane`(per-creator×level 日配额 + 并发 + 背压 + fail-closed),modify 走 submitGenerate 自动过门——"一次调整 = 一次受计费受配额任务"是现成语义。 +- **九门 + 三层校验链现成**:`cheap_run` 的 scaffold/stage/smoke/ensure_play_spec/play(九门)+ check(静态门 + 形状门)+ build(esbuild),可复用。 + +**SAA Java 路的准确状态(评审纠正)**:SAA 有 modify 节点、regenerate-module 路径可达;但 **deterministic modify 因 sourceProject→build 未闭合而 fail-loud(显式失败)**。所以 SAA 路不能当作"A11 便宜档 deterministic 执行已有实现"的复用证据——deterministic 执行在两条线上都需本切片建。 + +**做(in scope)**: + +- 判意图入口(新端点):收用户 NL 原话 + baseVersionId,worker 取 base 源 + LLM 判出建议 {mode, target, payload} + 风险等级,返回前端。 +- 前端范围确认:展示建议供用户确认或编辑;危险或改动过大必须用户确认,可取消。 +- 便宜档 worker 认 modify job:读 job 里的 modify 字段 + base 源(后端注入),分类执行。 +- 源回传落库补全:补 `derive_profile` 让 sourceProject 带上回传;后端注入 base 源到便宜档 job;回调落源回填 base_version_id。 +- 分类执行两档:① 确定性类纠错(换资产 = 改 `assets.js` 资产清单那一处 / `baseUrl` 指 CDN;调数值 = 改 `core.js` 集中常量那一处;改关卡 = 改对应布局处);② 模块重生成(改玩法 = 有界单文件 LLM 重写,复用 resume + 三层校验)。 +- 轻量收紧便宜档生成纪律:可调数值都进 core.js(让"改一处"可靠),落 _template + 生成 prompt + soft check。 +- 三结构断言验收(含改动清单语义校验)+ 跑通一次受计费的真实调整任务 e2e。 + +**不做(out of scope)**: + +- 不重建已落地的 `/modify` 执行编排 / 契约 / 任务链 / 血缘表 / 配额门(复用)。 +- 不交付 A2A 原生 input-required 暂停态(创始人定 A 两段式;HITL 走前端两次调用之间的确认,aigc 内部状态机不动;A2A 原生暂停留后期想要 agent 多轮交互时再补)。 +- 不固定强结构化 config seam 当 A11 的静态门(创始人定:规范性靠生成产出规范工程、不靠工具层加门;详见 KTD2)。 +- 不做整体重设计档(plan① 明定留后期)。 +- 不接便宜档 extend 路(契约/StudioServiceImpl/SAA 已落 extend,但便宜档同样不认;A11 只接 deterministic + regenerate-module 两档,extend 便宜档落点留后期/另议,避免又一个悬空)。 +- 复杂档 tier2 A11 的**实现**不在本切片(随切片二 tier2 go 后用同一回路跟进);但**设计跨档**——工程规范性(资产统一 + 数值集中)对 tier2 产物(`tier2-source-project.schema.json`)同样要求,已写进本档(创始人 2026-06-28 定 A)。 +- 不在工具层做经济 / 数值平衡静态判断(切片二教训:数值平衡是 agent 玩法职责)。 +- 不真存二进制资产;资产替换 = 改资产清单(`assets.js`)的一处地址(后期 CDN 挂载游戏资产即替换它,创始人 2026-06-28)。 + +## 执行决策(创始人 2026-06-28) + +- **A11 = LLM 驱动纠错、并非零-LLM 静态覆写**:流程"用户提改 → 判意图 → 确认修改范围与用户输入 → 分类执行",不需要完全静态的编码替换。 +- **分类执行三类**:替换游戏资产(改资产清单的一处地址,后期 CDN 挂载即替换它)/ 调整数值(改集中数值的一处常量)/ 需要大改动(反问用户:改动过大需重写、危险操作需确认)。 +- **工程规范性是关键、跨档**:A11 的可改性建立在生成出来的工程本身规范上——游戏资产统一管理、数值集中,使一次修改落到一处(一个变量 / 一个文件 / 一个 CDN 地址)。这是生成质量、不是 A11 的静态门(判意图仍 LLM 驱动);便宜档与复杂档同此要求。 +- **不固定 config seam**:不在工具层把覆写靶点硬约束成静态校验门;规范性靠生成产出规范工程,不靠工具层加门。 +- **链路架构 = A 两段式**(评审收敛、创始人 2026-06-28 定):判意图与执行分离成两次请求,HITL 走前端确认,不上 A2A 原生暂停态。 +- **范围 = 便宜档优先、设计跨档、复杂档随 tier2**(创始人 2026-06-28 定):本切片实现便宜档 A11;设计文档把工程规范性写成跨档要求;复杂档 A11 用同一回路、等切片二 tier2 go 后跟进。 + +## Key Technical Decisions + +- **KTD1(判意图独立于执行 · 在 /modify 之前)**:契约 `/modify` 入参已结构化,校验在前;故"NL → {mode,target,payload}"必发生在调 `/modify` 之前。判意图做成独立的前置入口(收 NL 原话 → worker 取 base 源 + LLM 判建议结构化改动 + 风险),与执行解耦;前端确认后才走现成的结构化 `/modify`。取舍:相较把判意图塞进 worker 执行内部(与已结构化契约倒置、且要改契约放宽必填),两段式弥合了 NL 与结构化执行入口的缝。 +- **KTD2(执行落到规范工程的一处 · 资产统一管理 + 数值集中 · 跨档 · 创始人 2026-06-28)**:A11 的可改性建立在生成侧工程规范性上——资产统一管理、数值集中,使一次修改落到一处,而非 LLM 在散落代码里定位。便宜档现状已大体具备:资产由 `assets.js` 统一管理(`IMAGE_FILES` 键→文件名一处声明、按键间接引用、`baseUrl` 可指 CDN,且 assets.js 是可写 L3),换资产 = 改那一处;核心数值在 `core.js`(`ROUND_MS`/`COLOR_PALETTE` 等,可写 L3),调数值 = 改那个常量,差最后的纪律(个别数值散在别处)。本切片轻量收紧"可调数值都进 core.js"的生成纪律。这不是给 A11 加静态覆写门(那个不做、判意图仍 LLM 驱动),而是要求生成产出规范工程;规范性在生成侧,A11 判意图后把改落到规范工程的那一处。跨档:复杂档 tier2 产物(`tier2-source-project.schema.json`)同样要求资产统一 + 数值集中,A11 复杂档用同一回路、随 tier2 go 跟进。残余误伤由 M5 改动清单语义校验兜底(见 KTD6)。 +- **KTD3(危险/大改走前端确认 · 创始人定 · A 两段式)**:判意图评出风险高或改动过大时,前端必须让用户确认或编辑后才提交执行,不擅自重写;用户可取消。HITL 落在判意图与执行两次请求之间,不需 aigc 暂停态。取舍:相较 A2A 原生 input-required 任务内暂停(要补等待态 + 超时 + 回问落库 + confirm/resume API + worker→后端暂停通道,两评审都点名 P0 级工作量),两段式以前端持有建议态换掉整套暂停机制。 +- **KTD4(模块重生成起步 = 文件级)**:便宜档产物只有文件级写边界(game-logic.js 等 L3 文件),无语义 behavior 拆分。模块重生成把契约 `target.kind=behavior` 在便宜档映射到玩法逻辑文件整文件有界重写,不动 core/render/assets/host-config。取舍:先复用现有边界落地,behavior 细粒度留后期。 +- **KTD5(源回传落库是硬前置 · 钉死注入路径)**:modify 一切建立在"能按 base 版本取回源工程"上。便宜档 worker 是 Python、无 DB 访问,不主动反查;路径钉死为**后端派发前经 `SourceProjectApi.fetchByVersionId` 注入 base 源到 job,worker 只消费 `job.sourceProject`**。改 `result_out` 回传契约时按切片一 M3b 同款 parity 护栏防回退。 +- **KTD6(三断言①升级为改动清单语义校验)**:纯文件 diff 验不出"LLM 改对了那个值/那个位置"——目标文件本就允许变、文件内顺手改坏抓不到。故要求 worker 回传结构化"改动清单"(改了哪些位置/常量、改成什么),断言①校验清单真实命中目标且不溢出非目标;调数值类再叠一次改后行为采样(如改下落速度 → smoke 抓到的速度量真变)。取舍:在不固定 config seam 的前提下,用改动清单 + 行为采样替代结构锚点来兜误伤。 + +## High-Level Design(里程碑与依赖) + +```mermaid +flowchart LR + M1["M1 前置基座
derive_profile + 后端注入base源 + 血缘回填"] --> M2["M2 判意图入口 + 前端确认
NL→建议结构化+风险 · 危险回问"] + M2 --> M3["M3 确定性类纠错
换资产/调数值改规范工程一处 · 改动清单"] + M2 --> M4["M4 模块重生成
有界单文件重写改玩法"] + M3 --> M5["M5 验收闭环
三结构断言 + 受计费 e2e"] + M4 --> M5 + classDef m fill:#eff6ff,stroke:#2563eb,color:#0f172a; + class M1,M2,M3,M4,M5 m; +``` + +两段式回路时序(一次调整请求的端到端走向): + +```mermaid +sequenceDiagram + participant U as 用户(试玩后) + participant F as 前端 + participant PE as 判意图入口【本切片新建】 + participant W as cheap-worker + participant S as studio /modify【已落·复用】 + participant A as aigc submitGenerate + D12门【已落】 + participant P as game_source_project + + U->>F: 自然语言改话 + F->>PE: 第一段:判意图(rawText + baseVersionId) + PE->>W: 取 base 源 + LLM 判意图 + W->>P: 读 base 源工程 + W-->>PE: 建议{mode,target,payload} + 风险等级 + PE-->>F: 返回建议 + 风险 + F-->>U: 展示建议(危险/大改 高亮需确认) + U->>F: 确认 / 编辑 / 取消 + F->>S: 第二段:/modify(已结构化, 用户确认后, 受计费) + S->>A: 取 base 源 + 提交 modify 任务(过配额门) + A->>W: dispatch job(modify 字段 + 后端注入的 base 源) + W->>W: 分类执行(换资产/调数值改规范工程一处 / 模块重生成) + 出改动清单 + W->>W: esbuild 重建 → 九门 + 三层校验 + 三结构断言 + W->>P: 回传新源工程(新 sourceHash, base_version_id=被改版) + W-->>S: 新预览版(走发布审核, 不动 currentVersion) +``` + +执行任务态机(判意图是独立请求-响应、不入任务态机;只有第二段 /modify 执行是任务): + +```mermaid +stateDiagram-v2 + [*] --> 初始: 用户确认后提交 /modify + 初始 --> 生成中: worker 认领(读 job.base源 + modify 字段) + 生成中 --> 完成: 九门+三层校验+三断言全过(回填新 version_id + base_version_id 血缘) + 生成中 --> 失败: 任一门挂(不建新版·base 不动) + 完成 --> [*] + 失败 --> [*] +``` + +## M1 · 前置基座:源回传补全 + worker 认 modify job + +**交付物**:① 补 `derive_profile`(派生 profile 三枚举),让便宜档每次生成把源工程带上回传、落 `game_source_project`(回传/落库链已通,只接这一个断点);② 后端派发到便宜档 job 时注入 base 源(经 `SourceProjectApi.fetchByVersionId`),worker 只消费 `job.sourceProject`、不主动反查;③ 回调落源 `landSourceQuietly` 从 `AigcTaskDO.baseVersionId` 回填 `SourceProjectLandReqDTO.baseVersionId`,让血缘列写上;④ worker `parse_job` 读 job 里的 modify 字段(modify_mode / modify_patch / base 源)。 + +**接入点**:`cheap-worker/worker_service.py`(derive_profile / parse_job)、`game-cloud` 侧 `AigcGenerateExecutor`(注入 base 源到 job)、`DifyCallbackServiceImpl.landSourceQuietly`(回填 base_version_id)。 + +**验证**:单测——便宜档生成产物经 derive_profile 后带 sourceProject;构造带 modify 字段 + base 源的 job,断言 worker 取全。真后端 e2e(mini-desktop)——便宜档生成一款后 `game_source_project` 有行、sourceHash 非空;一次 modify 回调后该版本 `base_version_id` 非空。 + +**依赖**:切片一便宜档线。 + +**风险**:① 改 `result_out` 回传契约牵动下游消费(参照切片一 M3b 翻车)——加 parity 护栏(回传前后过同一组形状断言)。② base 源是多文件全文 JSON,经 HTTP body 传便宜档 worker,体积/超时未评估——M1 先量体积上限,超标再议(worker 无 DB seam,凭 id 回拉更重,非首选)。 + +## M2 · 判意图入口 + 前端范围确认(A11 主干) + +**交付物**:① 新增判意图入口(收 `rawText` + `baseVersionId`),由便宜档 worker 取 base 源 + LLM 判出建议 `{mode, target, payload}` + 风险等级 + 是否需强制确认,返回前端——不执行、不落新版本;② 前端展示建议供用户确认或编辑,危险或改动过大强制确认,可取消;③ 确认后前端调用已落地的结构化 `/modify` 执行(受计费)。判意图入口是 contract-first 新增(additive,不动 `/modify` 既有契约)。 + +**接入点**:新判意图端点(studio 侧)+ worker 判意图职责(cheap-worker,复用其模型接入 + base 源消费);前端确认交互。 + +**验证**:单测——"把主角颜色调暗"判为 deterministic·asset·低风险·可直接确认;"把它做成 3D 赛车"判为大改·高风险·强制确认;分类与风险判据可单测(喂固定改话 + base 上下文,断言判出的 mode×target×risk)。e2e——判意图入口返回建议、前端确认后 /modify 收到结构化入参执行;判意图本身不产新版本、不计费(计费在执行段)。 + +**依赖**:M1(要有 base 源 + worker 认 job 才能判意图、才能执行)。 + +**风险**:判意图准确率(LLM 判错类型/风险)——缓解:风险存疑一律升级为强制确认(宁可多问);判据留可观测日志校准。 + +## M3 · 确定性类纠错执行(换资产 / 调数值 / 改关卡) + +**交付物**:执行段判为 deterministic(target.kind∈{asset,config,level})时,改动落到规范工程的一处——换资产 = 改 `assets.js` 的 `IMAGE_FILES` 那一项(或 `baseUrl` 指 CDN,后期 CDN 挂载即替换它);调数值 = 改 `core.js` 里那个常量;改关卡 = 改对应布局处;三者落点(assets.js / core.js / render 等)都是可写 L3,不撞 L1_FIXED 写禁。取 base 源 → 改那一处 → 产出**改动清单**(改了哪个文件的哪个键/常量、改成什么)→ esbuild 重建 → 过九门。不重生成玩法逻辑。配套轻量收紧生成纪律:可调数值都进 core.js(生成 prompt / _template + soft check),让"改一处"可靠。 + +**接入点**:worker 确定性类执行分支(取 base 源 → 改规范工程那一处 + 记清单 → cheap_run 的 build + stage + smoke + play 轻链);需一个"在既有产物上只重建 + 重过九门"的轻入口(现有 cheap_run 入口都假定整局 scaffold 起步);生成纪律收紧(数值归 core.js)落 _template + 生成 prompt。 + +**验证**:单测 + e2e——换 sprite 占位 → 产物引用确实变、改动清单命中该占位、九门过、非目标文件 hash 稳;调下落速度 → 对应数值变、清单命中、改后 smoke 抓到速度真变、九门过、非目标稳。 + +**依赖**:M1、M2。 + +**风险**:LLM 定位常量改错位置(改串/改溢出)——缓解:改动清单 + 行为采样(KTD6)抓"改对没";定位失败回退到强制确认或转模块重生成。 + +## M4 · 模块重生成执行(改玩法) + +**交付物**:执行段判为 regenerate-module(target.kind=behavior)时——把 behavior 在便宜档映射到玩法逻辑文件(game-logic.js),取 base 源 → 据用户意图有界重写该单文件 → 复用既有外层 resume 与三层校验 → 不动 core/render/assets/host-config → 过九门。 + +**接入点**:worker 模块重生成分支(复用 cheap_studio 的 ReAct 写单文件 + cheap_run resume + 三层校验);写边界收窄到玩法逻辑文件。 + +**验证**:e2e——"陨石下落改成会左右摇摆" → game-logic 文件变、core/render/assets hash 稳、九门过、改后仍能玩。 + +**依赖**:M1、M2。 + +**风险**:玩法文件与其他文件耦合导致单文件重写不自洽——缓解:复用切片一 resume 熔断 + 三层校验抓不自洽;牵连过深时由判意图升级为大改 → 强制确认。 + +## M5 · 验收闭环 + 受计费 e2e + +**交付物**:三结构断言机器门 + 接 D12 配额 + 跑通一次受计费的真实调整任务。三结构断言(plan① 线 154,结合 KTD6)——① 请求的结构化改动可断言真实生效:worker 改动清单命中目标且不溢出非目标(非纯文件 hash),调数值类叠改后行为采样;② 非目标模块的 hash / 白名单 diff 稳定(没顺手改坏别的模块);③ 新版本与 baseVersionId 血缘可查(复用 M1 回填的 `game_source_project.base_version_id` 反查链)。三断言 + 九门 + 三层校验全过才算"改成"。 + +**接入点**:三断言机器门(纯逻辑、可单测:清单命中校验 + 非目标 diff + 血缘字段链);血缘可查复用 M1 写上的 `base_version_id`(`game_version` 产物表无 parent 列,用源工程表 base_version_id 反查链;给版本产物表加 parent 列列为可选、非本切片必做,避免动 project 模块版本权威);受计费走现成 D12 `enqueueWithControlPlane`。 + +**验证**:真后端 e2e(mini-desktop)——判意图 → 前端确认 → `/modify` 执行 → 九门 + 三层校验 + 三断言全过 → 落新预览版(不动 currentVersion、走发布审核)→ 计费落账 → 血缘可查。 + +**依赖**:M1–M4。 + +**风险**:三断言只验"改了对的东西、没误伤",不直接证"改得好不好"(玩法质量归 agent / WU-B,非本门职责)——已知边界、不在工具层加码玩法质量门。 + +## Verification(验证计划) + +- 单测优先:判意图分类与风险判据、资产/数值一处改写、改动清单与三断言判据、parse_job modify 字段、derive_profile、parity 护栏、base_version_id 回填。 +- 真后端 e2e(mini-desktop,绕系统代理直连内网):两段式全链路 + 前端确认 + 三断言 + 受计费落账 + 血缘可查。 +- 九门 + 三层校验复用 cheap_run,不另造。 +- 完成判据 = plan① 交付定义:两类执行落地、改后过九门 + 三结构断言、接 D12 配额、跑通一次受计费真实调整任务(整体重设计、便宜档 extend 不计入)。 + +## 风险与回滚 + +- **判意图错判**:风险存疑升级强制确认兜底;判据留可观测日志校准。 +- **模块重生成不收敛**:复用切片一 resume 熔断 + 三层校验;牵连过深升级为大改强制确认。 +- **源回传契约改动回退**:parity 护栏(切片一 M3b 同款)。 +- **回滚**:里程碑独立可回退;modify 失败 = 不建新版、base 不动(契约已定,天然安全);M1 源回传若出问题可单独关闭、不影响首次生成线;判意图入口是 additive 新端点,不影响既有 /modify。 + +## 迁移对账(跨设计面归属 · 防越位/揽活) + +- **复用(已建、不重建)**:StudioServiceImpl.modify 执行编排、taskchain 任务链、game_source_project V18 落库与血缘列、D12 配额门、cheap_run 九门 + 三层校验、源回传/落库管道(除 derive_profile 断点)。 +- **自建(本切片 A11)**:判意图入口(新端点 + worker 判意图)、前端范围确认、便宜档 worker 的 modify 执行两档、derive_profile 补全、后端注入 base 源、base_version_id 血缘回填、改动清单 + 三结构断言机器门。 +- **跨设计面(plan① 线 158)**:WU-B 的 A11 反馈回路(生成/玩法质量)、WU-C 5.4(覆写/重生成后的三层校验与九门)、WU-F 配额数值。本档不揽生成质量职责、不替 WU-F 定配额数值(占位待创始人拍)。 +- **收口 TODO**: + - ① 回写 plan① 切片三 行 148——"确定性覆写"不再是零-LLM、改为 LLM 判意图后改规范工程一处(本档 KTD2),守 canonical 唯一性,交横切一致性主人对账。 + - ② 本 plan 兼充 A11 设计面(plan① 收口 TODO⑥:A11 在设计 SoT 仅协议表一行);评审通过后蒸馏回 `docs/architecture/架构/生成引擎/` 与 `.agents/knowledge/`。 + - ③ 便宜档 extend 落点、A2A 原生 input-required 暂停态:均留后期,需要时另立切片/plan,避免悬空。 + +## Sources(事实来源) + +- plan① 切片三 A11(`docs/plans/2026-06-25-…-plan.md` 行 142-160、298)。 +- 运行时 SoT §三 A3/A3.5/A4/A5/A11(`docs/architecture/架构/生成引擎/agentic运行时架构图说.md`)。 +- 契约 `studio.yaml`(StudioModifyReqVO + mode×kind 锁定映射)、`source-project.schema.json`(config 字段 / sourceHash 寻址)。 +- 数字代码事实 digest + Codex/Opus 双评审逐文件核验(后端执行编排已落 + 便宜档缺口 + SAA deterministic fail-loud + 源回传管道断点 = derive_profile + 血缘回填漏接)。 + +## 双评审发现与处置 + +并行双评审(Codex codex-rescue + Opus 对抗式文档评审,互不通气);两评审在两条主干上咬合。逐条处置: + +- **P0-A 入口顺序倒置(两评审一致)**:/modify 入参已结构化,NL→结构化必在其之前,原稿把判意图放 worker 内部、与契约倒置。**处置:改 A 两段式(创始人定)**——新增判意图入口(/modify 之前),执行复用结构化 /modify;KTD1 重写、M2 重构、时序图改。已修。 +- **P0-B input-required 无承载(两评审一致)**:aigc 六态无暂停态、回调只认 succeeded/failed、无 resume API。**处置:A 两段式绕开**——HITL 走前端两次调用间确认,aigc 状态机不动;A2A 原生暂停态列收口 TODO③ 留后期。已修。 +- **P1 血缘写链漏接(Codex)**:landSourceQuietly 未从 AigcTaskDO.baseVersionId 回填,断言③会查空。**处置:M1 交付物③ 补回填 + 单测断言。**已纳。 +- **P1 M1 缺口高估 + 路径矛盾(两评审)**:回传/落库链已通,真缺口仅 derive_profile;"后端注入"与"worker 反查"措辞冲突。**处置:M1 缩到真缺口、KTD5 钉死后端注入·worker 只消费。**已修。 +- **P1 SAA modify 全通不准(Codex)**:SAA deterministic 实为 fail-loud、build 未闭合。**处置:起点段改正、不作复用证据。**已修。 +- **P1 三断言对改错位置盲区(Opus)**:纯文件 diff 验不出改对。**处置:KTD6 + M5 断言① 升级为改动清单语义校验 + 行为采样。**已修。 +- **P1 plan① 定义漂移(Opus)**:确定性覆写从零-LLM 改为 LLM 判意图后改规范工程一处,未回写 plan①。**处置:收口 TODO① 回写 plan① 行 148。**已列。 +- **P2(两评审)**:extend 悬空(→ 边界显式 scope-out + TODO③)、占位撞写禁(→ 已核 `assets.js`/`core.js` 均可写 L3、不在 `_L1_FIXED`,换资产/调数值落点干净)、HTTP body 体积(→ M1 风险②)、散文元叙述/"房式"笔误/"639 行"措辞/"modify 四件套"术语(→ 全文清理:删执行者须知元叙述、改"方法体 308-333"、统一"modify 字段 + base 源单独注入")。已处置。 + +**评审后创始人追加指令(2026-06-28,已整合)**:① 链路架构定 **A 两段式**(解 P0-A/B:判意图入口在 /modify 之前、HITL 走前端确认、不上 A2A 暂停态);② **工程规范性是关键、跨档**——资产统一管理 + 数值集中使"改一处",便宜档现状已大体具备(`assets.js` 统一资产、`core.js` 集中数值、均可写 L3),复杂档同此要求、实现随 tier2(定 A)。已落入执行决策 + KTD2 + M3 + 起点 + 边界 + Summary。 + +## 执行发现 + +**M1 前置基座(执行中,2026-06-29)** + +- **Python 侧(M1① derive_profile + M1④ parse_modify)完成、绿**:`derive_profile` 兑现返便宜档基线 `{realtime, discrete-choice, metric}`(tap-targets 本就这三维、非伪造),解锁 `build_source_project` → sourceProject 随 result-out 回传;`parse_modify` 提取 modify 区(mode/baseVersionId/modifyPatch/sourceProject),无 modifyMode 返 None(create 路零副作用)。`tests/test_a11_m1.py` 5/5 + 回归 `test_worker_service` 15/15 + `test_result_out` 18/18,零回退。 +- **对账与偏差**:① 评审所言属实——源回传/落库管道已通,唯一断点是 `derive_profile` 恒返 None;兑现它即打开全便宜档生成的 sourceProject 回传(每 gen 多带 src/ 全文,实测约 6 文件几 KB 量级,HTTP body 体积无虞,消解 M1 风险②的体积顾虑)。② 便宜档 base 源注入 job 的键名钉死为 `sourceProject`(对齐 SAA `K_SOURCE_PROJECT` + worker `parse_modify` 读取侧)。 +- **Java 侧(M1② executor 注入 base 源 + M1③ 回调回填 base_version_id)完成、绿**:`AigcGenerateExecutor.injectBaseSourceProject`(据 baseVersionId 反查 `SourceProjectApi.fetchByVersionId` → `job.put("sourceProject", data.getSourceJson())`,镜像 `SaaGraphDispatcher.resolveBaseSourceProject`,best-effort 非阻断、create 路零变,经 `AigcExecutorConfiguration` 软注入 SourceProjectApi);`DifyCallbackServiceImpl.landSourceQuietly` 补 `landReq.setBaseVersionId(task.getBaseVersionId())`(落库服务 `SourceProjectServiceImpl.land` L105 已持久化该列,无需改)。子代理本地 maven 全绿(98 测试:执行器/回调/SAA 回归/源服务);**主会话独立复跑 `AigcGenerateExecutorTest`+`DifyCallbackServiceImplTest` = 46/46 绿、BUILD SUCCESS**(一手验证非口头)。 +- **M1 收口**:源回传落库链端到端通——便宜档生成 → derive_profile 兑现 → sourceProject 回传 → landSourceQuietly 落库(带 base_version_id 血缘);modify 路 → executor 注入 base 源 → worker parse_modify 读到。改动未提交(在工作树)。 + +**M2 判意图入口 + 前端确认(执行中,2026-06-29)** + +- **Worker 侧(判意图 classify + `/classify` 端点)完成、绿**:`cheap_classify.py` 的 `build_classify_prompt`(把 base 源可改面——assets.js 资产清单 / core.js 集中数值 / game-logic.js 玩法 + 用户原话喂进 prompt)+ `parse_classify_response`(落 mode×category 锁定 + 风险/确认规则 + 坏 JSON 安全兜底);worker `POST /classify` 同步端点(注入式 `classify_fn`,不入生成队列、不起 Chrome)。`tests/test_a11_m2_classify.py` 9/9 + 回归 `test_worker_service` 15/15、`test_a11_m1` 5/5。 +- **判意图契约**:`{category(asset|config|level|behavior|big-change|unclear), mode(deterministic|regenerate-module|null), target, payload, riskLevel, needsConfirm, rationale, clarify}`。可信边界 = LLM 给的 mode 不采信、一律按 category 落 mode;`needsConfirm` 由 worker 定(只低风险确定性改免确认,改玩法/大改/意图不明/中高风险一律回问,兑现创始人"危险回问")。真 LLM 调用(`_default_complete` openai 直调 M3 + 框架代理旁路)为集成路、M5 e2e 真验。 +- **Java 侧(studio `/modify/plan` → aigc `classifyModify` → worker `/classify` 同步对接)**:派 opus 子代理执行(契约先行加 studio.yaml;NO_PROXY 代理旁路命门;base 版本归属校验;best-effort 兜底 unclear+needsConfirm;本地 maven)。 + +## 状态 + +双评审已过(发现全处置)→ 待创始人批 → 执行(M1→M5 顺序,M1 为 plan① 所称"首个里程碑")。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java index 24f16e94..b77f2e62 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/framework/executor/config/AigcExecutorConfiguration.java @@ -144,6 +144,7 @@ public class AigcExecutorConfiguration { * @param schemaValidator schema 校验器 * @param llmClient LLM 通道 * @param workerDispatchClient 外置生成 worker 派发通道(P3 generic 路) + * @param sourceProjectApiProvider 源项目反查 seam(A11 M1②:modify 路据 baseVersionId 反查 base 源注入 job.sourceProject;软取) * @return 执行器(@Scheduled 注解由全局调度后处理器消费,fixedDelay 单飞) */ @Bean @@ -154,12 +155,16 @@ public class AigcExecutorConfiguration { GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, WorkerDispatchClient workerDispatchClient, - SaaGraphDispatcher saaGraphDispatcher) { - // SAA 迁移 #2:传入 http worker + saa 进程内派发器,由执行器 9 参构造按 aigc.executor.dispatcher 选用 + SaaGraphDispatcher saaGraphDispatcher, + ObjectProvider sourceProjectApiProvider) { + // A11 M1②:软取 SourceProjectApi(studio @Primary 就地解析;modify 路据 baseVersionId 反查 base 源注入 §6.1 job 的 sourceProject 键, + // 供便宜档 HTTP worker 无 DB 被动消费——与 SaaGraphDispatcher 同一 seam)。缺席(单模块装配/studio 未在 classpath)→ modify 取源旁路(不注入 sourceProject 键),不阻断装配。 + SourceProjectApi sourceProjectApi = sourceProjectApiProvider.getIfAvailable(); + // SAA 迁移 #2:传入 http worker + saa 进程内派发器,由执行器 10 参构造按 aigc.executor.dispatcher 选用 // (默认 http=现行行为不变;saa=opt-in 进程内形态①)。 return new AigcGenerateExecutor(properties, aigcTaskMapper, difyCallbackService, promptResourceLoader, schemaValidator, llmClient, workerDispatchClient, saaGraphDispatcher, - Clock.systemDefaultZone()); + sourceProjectApi, Clock.systemDefaultZone()); } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java index 60a6aa16..8b44a228 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImpl.java @@ -208,7 +208,11 @@ public class DifyCallbackServiceImpl implements DifyCallbackService { SourceProjectLandReqDTO landReq = new SourceProjectLandReqDTO(); landReq.setGameId(task.getGameId()); landReq.setSourceJson(reqVO.getSourceProject()); - // schemaVersion/buildProfile/baseVersionId 缺省由 studio 落库服务兜底(回调入参暂不携带,B6/B5 接线后可扩) + // A11 切片三 M1③:从任务(V19 列 base_version_id)回填血缘——modify 产的新源行据此可反查「派生自哪个 base 版本」 + // (A11 验收第三断言「血缘可查」的反查链根基;game_source_project.base_version_id 即此值)。 + // create 路 task.getBaseVersionId() 本就是 null,set null 无副作用(现行字节零变);落库服务 land() 已把该列持久化进 DO(V18 列)。 + landReq.setBaseVersionId(task.getBaseVersionId()); + // schemaVersion/buildProfile 缺省由 studio 落库服务兜底(回调入参暂不携带,B5 接线后可扩) CommonResult resp = sourceProjectApi.land(landReq); if (resp == null || resp.getData() == null) { log.warn("[handleCallback] 源落库返回空结果,跳过后续源态流转 traceId={}", reqVO.getTraceId()); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java index 6631d69d..2a468d5c 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutor.java @@ -5,8 +5,11 @@ import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO; import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper; import com.wanxiang.huijing.game.module.aigc.enums.FailureReasonEnum; import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService; +import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.security.core.LoginUser; import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils; import com.fasterxml.jackson.core.type.TypeReference; @@ -94,6 +97,14 @@ public class AigcGenerateExecutor { * 仅 generic 模板派发路使用(§6.5)。 */ private final GenerationDispatcher generationDispatcher; + /** + * 源项目工件反查 seam(A11 切片三 M1②/U6):modify 路据 {@code task.baseVersionId} 反查 base 源 source_json, + * 注入 §6.1 job 的 {@code sourceProject} 键,供便宜档 HTTP worker(Python,无 DB 访问,不主动反查)被动消费—— + * KTD5 钉死「后端注入·worker 只消费」。与 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher} + * 用同一 {@link SourceProjectApi} seam、同一取值/容错口径。 + *

可为 null(8/9 参旧构造 / studio-api 未装配态)→ modify 取 base 源整段旁路(不注入 sourceProject 键,create 路本就不触)。 + */ + private final SourceProjectApi sourceProjectApi; /** 墙钟(预算门用;单测注入假时钟验证 M5) */ private final Clock clock; @@ -128,9 +139,8 @@ public class AigcGenerateExecutor { } /** - * 9 参构造(SAA 迁移 #2 收口;经 AigcExecutorConfiguration @Bean 调用):按 {@code aigc.executor.dispatcher} - * 选派发通道——{@code saa} 且 saaDispatcher 非空 → 进程内 SaaGraphDispatcher(形态①);否则 → http - * {@link WorkerDispatchClient}(默认,现行)。 + * 9 参构造(SAA 迁移 #2 收口;现行单测沿用此签名):委托 10 参构造,{@code sourceProjectApi} 传 null → + * modify 取 base 源旁路(A11 M1② 前的行为,不注入 sourceProject 键;create 路本就不触,字节零变)。 * * @param saaDispatcher SAA 进程内派发器(可空:dispatcher=http 或 saa 未装配时为 null → 回落 http) */ @@ -139,6 +149,25 @@ public class AigcGenerateExecutor { GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher, Clock clock) { + // 委托 10 参构造,sourceProjectApi 传 null → modify 取源旁路(A11 M1② 前行为,非破坏性)。 + this(properties, aigcTaskMapper, difyCallbackService, promptResourceLoader, schemaValidator, + llmClient, workerDispatchClient, saaDispatcher, null, clock); + } + + /** + * 10 参构造(A11 切片三 M1② 收口;经 AigcExecutorConfiguration @Bean 调用):在 9 参基础上接 + * {@link SourceProjectApi} 反查 seam——modify 路据 baseVersionId 反查 base 源注入 §6.1 job 的 sourceProject 键 + * (供便宜档 HTTP worker 无 DB 消费)。按 {@code aigc.executor.dispatcher} 选派发通道——{@code saa} 且 saaDispatcher + * 非空 → 进程内 SaaGraphDispatcher(形态①);否则 → http {@link WorkerDispatchClient}(默认,现行)。 + * + * @param saaDispatcher SAA 进程内派发器(可空:dispatcher=http 或 saa 未装配时为 null → 回落 http) + * @param sourceProjectApi 源项目反查 seam(A11 modify 取 base 源;可 null=取源旁路,不注入 sourceProject) + */ + public AigcGenerateExecutor(AigcExecutorProperties properties, AigcTaskMapper aigcTaskMapper, + DifyCallbackService difyCallbackService, PromptResourceLoader promptResourceLoader, + GameConfigSchemaValidator schemaValidator, ExecutorLlmClient llmClient, + WorkerDispatchClient workerDispatchClient, GenerationDispatcher saaDispatcher, + SourceProjectApi sourceProjectApi, Clock clock) { // §5.2 阈值关系铁律:stale×60 > budget + 273,装配期校验(误配置宁可启动失败也不带病收割在飞任务) properties.validateThresholds(); this.properties = properties; @@ -150,13 +179,16 @@ public class AigcGenerateExecutor { // 派发通道选择(SAA 迁移 #2,非破坏性):saa 且已装配 → SaaGraphDispatcher;否则 http worker(默认/回落)。 boolean useSaa = "saa".equalsIgnoreCase(properties.getDispatcher()) && saaDispatcher != null; this.generationDispatcher = useSaa ? saaDispatcher : workerDispatchClient; + // A11 M1②:源项目反查 seam(可 null=旧构造/未装配态,modify 取 base 源旁路、不注入 sourceProject 键)。 + this.sourceProjectApi = sourceProjectApi; log.info("[executor] 生成派发通道选定 dispatcher={}(saa装配={})= {}", properties.getDispatcher(), saaDispatcher != null, useSaa ? "进程内SaaGraphDispatcher(形态①)" : "http WorkerDispatchClient(现行)"); this.clock = clock; // 启动三行自检日志(§12-① 冒烟观察面:执行器已启用 / prompt 资源版本 / key 配置态;严禁打印密钥本身) - log.info("[executor-selfcheck] aigc 生成执行器已启用(poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={})", + log.info("[executor-selfcheck] aigc 生成执行器已启用(poll={}ms, batch={}, budget={}s, stale={}min, maxAge={}h, templates={}, sourceSeam={})", properties.getPollIntervalMs(), properties.getScanBatchSize(), properties.getTaskBudgetSeconds(), - properties.getStaleRunningMinutes(), properties.getMaxTaskAgeHours(), properties.getSupportedTemplates()); + properties.getStaleRunningMinutes(), properties.getMaxTaskAgeHours(), properties.getSupportedTemplates(), + sourceProjectApi != null ? "在席" : "缺席(modify取源旁路)"); // HJ-MC-TPL-EXEC-001 §6.4:多模板装载后无单一版本,按 templateId 逐模板列出版本(就绪时) log.info("[executor-selfcheck] prompt 资源版本 {}", promptResourceLoader.isReady() ? promptResourceLoader.describePromptVersions() : ("未就绪:" + promptResourceLoader.getNotReadyReason())); @@ -512,7 +544,8 @@ public class AigcGenerateExecutor { // 语义对齐(关键):buildInputs 读的 state 键名是 modifyMode/modifyPatch/baseVersionId/sourceProject;此处 job key 须与之一致。 // ⚠️ create 路 task.getMode() 为 ''/null(V19 列 modify_mode 默认空,create 路从不设)→ 这些键不放/放空串=走 SAA create 路(buildInputs 对空 modifyMode 不分流 modify),现行行为零变。 // modify 三件套经 V19 列持久化:task 由执行器从 DB 重建(跨异步 tick)时 modify 字段可取到 → 生产 modify 抵达 buildInputs 已闭合。 - // sourceProject(base 源项目工件 JSON)由 U2/U6 据 baseVersionId 反查后填充——本单元不接 sourceProject 反查(属 U2 GameSourceProjectMapper),故此处不放 sourceProject 键(buildInputs 缺省空串)。 + // sourceProject(base 源项目工件 JSON):A11 M1② 起,modify 路据 baseVersionId 经 SourceProjectApi 反查后注入此键—— + // 便宜档 HTTP worker(Python,无 DB)只被动消费 job["sourceProject"](键名同 SAA 路 K_SOURCE_PROJECT);反查失败/缺源则不放该键(best-effort 非阻断)。 putModifyFieldsIntoJob(job, task); boolean dispatched = generationDispatcher.dispatch(job); @@ -533,12 +566,13 @@ public class AigcGenerateExecutor { * 把 modify/extend 四件套(契约 C3)接进 §6.1 job 的 modify 区——使 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher} * 的 {@code buildInputs} 能读到 modifyMode/modifyPatch/baseVersionId 并据此分流到 SAA modify 路。 * - *

键名契约(必须与 dispatcher buildInputs / SaaStudioNodes K_* 一致): - * {@code modifyMode}(K_MODIFY_MODE)/ {@code modifyPatch}(K_MODIFY_PATCH)/ {@code baseVersionId}(K_BASE_VERSION_ID)。 - * sourceProject(K_SOURCE_PROJECT,base 源项目工件 JSON)由 U2/U6 据 baseVersionId 反查填充,本单元不接(属 GameSourceProjectMapper)。 + *

键名契约(必须与 dispatcher buildInputs / SaaStudioNodes K_* / Python worker parse_modify 一致): + * {@code modifyMode}(K_MODIFY_MODE)/ {@code modifyPatch}(K_MODIFY_PATCH)/ {@code baseVersionId}(K_BASE_VERSION_ID)/ + * {@code sourceProject}(K_SOURCE_PROJECT,base 源项目工件 JSON)。前三件透传 DO,第四件 {@code sourceProject} 由 + * A11 M1② 经 {@link #injectBaseSourceProject} 据 baseVersionId 反查注入(best-effort)。 * *

非破坏性(create 路零变):{@code task.getMode()} 为 ''/null(V19 列 modify_mode 默认空,create/generate 路从不设)→ - * 本方法不放任何 modify 键 → buildInputs 对缺省 modifyMode 不分流 modify(走 create 路),现行字节零变。 + * 本方法不放任何 modify 键(含 sourceProject)→ buildInputs / worker 对缺省 modifyMode 不分流 modify(走 create 路),现行字节零变。 * *

持久化(V19):{@code AigcTaskDO} 的 modify 三件套(mode/baseVersionId/modifyPatch)经 V19 列持久化, * 执行器跨异步 tick 从 DB 重建 DO 时可取到 → 生产 modify 抵达 buildInputs 已闭合;assetContext 仍 transient(引擎线后用)。 @@ -555,12 +589,59 @@ public class AigcGenerateExecutor { job.put("modifyMode", task.getMode()); // K_MODIFY_MODE:deterministic|regenerate-module|extend if (task.getBaseVersionId() != null) { job.put("baseVersionId", task.getBaseVersionId()); // K_BASE_VERSION_ID:modify/extend 血缘 + // A11 M1②:据 baseVersionId 反查 base 源工程 JSON 注入 job.sourceProject(便宜档 HTTP worker 无 DB、不主动反查;后端派发前注入·worker 只消费,KTD5)。 + injectBaseSourceProject(job, task); } if (StringUtils.hasText(task.getModifyPatch())) { job.put("modifyPatch", task.getModifyPatch()); // K_MODIFY_PATCH:寻址+载荷 JSON 串(§5.6) } - log.info("[executor-dispatch] modify 路 job 接线 taskId={}, traceId={}, mode={}, baseVersionId={}", - task.getId(), task.getTraceId(), task.getMode(), task.getBaseVersionId()); + log.info("[executor-dispatch] modify 路 job 接线 taskId={}, traceId={}, mode={}, baseVersionId={}, 含sourceProject={}", + task.getId(), task.getTraceId(), task.getMode(), task.getBaseVersionId(), job.containsKey("sourceProject")); + } + + /** + * A11 切片三 M1②(KTD5「源回传落库是硬前置·钉死注入路径」):modify 路据 {@code task.baseVersionId} 反查 base 源工程 JSON, + * 注入 §6.1 job 的 {@code sourceProject} 键——便宜档 HTTP worker 是 Python、无 DB 访问、不能主动反查,故由后端派发前注入、 + * worker 只被动消费 {@code job["sourceProject"]}(Python {@code parse_modify} 读此键;键名与 SAA 路 + * {@link com.wanxiang.huijing.game.module.aigc.saa.SaaStudioNodes#K_SOURCE_PROJECT} 同名,跨两路一致)。 + * + *

取值/容错口径镜像 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher} 的 {@code resolveBaseSourceProject} + * (同一 {@link SourceProjectApi} seam):seam 缺席 / 反查未命中({@code found=false})/ 源空 / 反查异常 → 不放 sourceProject 键。 + * + *

best-effort 非阻断(命门):全程 try-catch 吞异常——反查失败绝不外抛、绝不让 {@link #dispatchGeneric} 派发链崩。 + * base 源缺则 worker 走不了 modify(由 worker 侧据缺源决断),但派发本身不因源反查失败而中断。 + * create 路零副作用:本方法只在 {@link #putModifyFieldsIntoJob} 的 modify 分支(baseVersionId 非空)内被调,create 路根本不触。 + * + * @param job 已组好 modify 区的 §6.1 job map(命中则 additive 追加 sourceProject 键) + * @param task 已认领的 modify 任务(取 baseVersionId 反查;调用前已保证 baseVersionId 非空) + */ + private void injectBaseSourceProject(Map job, AigcTaskDO task) { + // seam 缺席(config 未注入 SourceProjectApi / studio-api 未在 classpath,与 SaaGraphDispatcher 4 参构造态同口径)→ 取源旁路。 + if (sourceProjectApi == null) { + log.warn("[executor-dispatch] modify 取 base 源跳过:SourceProjectApi seam 缺席(未注入)taskId={}, traceId={}, baseVersionId={}", + task.getId(), task.getTraceId(), task.getBaseVersionId()); + return; + } + try { + // aigc→studio-api seam 反查 base 版本对应源工件(与 SaaGraphDispatcher / DifyCallbackServiceImpl 同一 seam;baseVersionId 已是 Long,无需解析)。 + CommonResult resp = sourceProjectApi.fetchByVersionId(task.getBaseVersionId()); + SourceProjectFetchRespDTO data = (resp == null) ? null : resp.getData(); + if (data == null || !Boolean.TRUE.equals(data.getFound()) + || data.getSourceJson() == null || data.getSourceJson().isBlank()) { + // base 无源(未落源 / 早期态 / 源空):不注入 sourceProject 键(worker 据缺源决断,不抛、不阻断派发)。 + log.warn("[executor-dispatch] modify 取 base 源未命中(base 无源行/源空)→ 不注入 sourceProject taskId={}, traceId={}, baseVersionId={}", + task.getId(), task.getTraceId(), task.getBaseVersionId()); + return; + } + // 键名契约:sourceProject(与 SAA 路 K_SOURCE_PROJECT / Python worker parse_modify 读 job["sourceProject"] 同名)。 + job.put("sourceProject", data.getSourceJson()); + log.info("[executor-dispatch] modify 取 base 源命中并注入 job.sourceProject taskId={}, traceId={}, baseVersionId={}, sourceId={}, len={}", + task.getId(), task.getTraceId(), task.getBaseVersionId(), data.getId(), data.getSourceJson().length()); + } catch (Exception e) { + // 命门:反查异常(seam/Feign/DB 抖动)一律吞 + warn,不注入 sourceProject 键,绝不阻断 dispatchGeneric 派发链。 + log.warn("[executor-dispatch] modify 取 base 源反查异常 → 不注入 sourceProject(不阻断派发)taskId={}, traceId={}, baseVersionId={}", + task.getId(), task.getTraceId(), task.getBaseVersionId(), e); + } } /** diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java index 5d4dd590..3425e91a 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/callback/DifyCallbackServiceImplTest.java @@ -456,6 +456,8 @@ class DifyCallbackServiceImplTest extends BaseMockitoUnitTest { verify(sourceProjectApi).land(landCaptor.capture()); assertEquals(5L, landCaptor.getValue().getGameId()); assertEquals(SOURCE_JSON, landCaptor.getValue().getSourceJson()); + // A11 M1③ 零副作用:create 路(queuedTask 无 baseVersionId)→ 血缘回填 null(set null 不影响现行落源行为) + assertNull(landCaptor.getValue().getBaseVersionId()); // ② 建包成功后回填:markBuilt(landedId=3001, versionId=2048, gameId=5)(P0-2 带 gameId 校归属;versionId/gameId 取自回查任务) verify(sourceProjectApi).markBuilt(3001L, 2048L, 5L); @@ -635,6 +637,42 @@ class DifyCallbackServiceImplTest extends BaseMockitoUnitTest { verify(aigcTaskService).completeWithVersion(77L, 2048L); } + // ====================== 用例19(A11 切片三 M1③):modify 回调落源回填 base_version_id 血缘 ====================== + + /** + * A11 M1③(血缘可查):modify 回调——任务带 {@code baseVersionId}(V19 列)→ 落源时 {@code landReq.baseVersionId} + * 回填该值,使 modify 产的新源行可反查「派生自哪个 base 版本」(验收第三断言「血缘可查」的反查链根基)。 + * + * 断言:land 入参 baseVersionId = 任务 baseVersionId(gameId/sourceJson 仍透传)。 + */ + @Test + void testHandleCallback_landFillsBaseVersionIdLineage() { + wireSourceLandingDeps(); + // 三元序列同用例13:①landSourceQuietly 取 gameId+baseVersionId(modify task)→ land; + // ②内层步骤①(queued)走完整三表链;③提交后回查(SUCCEEDED+version)。 + AigcTaskDO modifyTask = queuedTask(); + modifyTask.setBaseVersionId(4096L); // V19 列:本次 modify 派生自的 base 版本 + when(aigcTaskMapper.selectByTraceId("aigc-trace-1")) + .thenReturn(modifyTask, queuedTask(), succeededTaskWithVersion()); + when(projectVersionApi.createForPackage(any(ProjectVersionCreateForPackageReqDTO.class))) + .thenReturn(CommonResult.success(2048L)); + when(runtimePackageApi.storeForVersion(any(RuntimePackageStoreReqDTO.class))) + .thenReturn(CommonResult.success(99L)); + when(sourceProjectApi.land(any(SourceProjectLandReqDTO.class))) + .thenReturn(CommonResult.success(landResp(3010L, "f".repeat(64), false))); + when(sourceProjectApi.markBuilt(anyLong(), anyLong(), anyLong())).thenReturn(CommonResult.success(true)); + + Boolean result = callbackService.handleCallback(succeededReqWithSource()); + assertTrue(result); + + // 核心断言:land 入参 baseVersionId = 任务 baseVersionId(M1③ 血缘回填);gameId/sourceJson 仍透传 + ArgumentCaptor landCaptor = ArgumentCaptor.forClass(SourceProjectLandReqDTO.class); + verify(sourceProjectApi).land(landCaptor.capture()); + assertEquals(4096L, landCaptor.getValue().getBaseVersionId()); + assertEquals(5L, landCaptor.getValue().getGameId()); + assertEquals(SOURCE_JSON, landCaptor.getValue().getSourceJson()); + } + // ============================== 测试夹具 ============================== /** 源工件最小骨架 JSON(asset 节点口径:schemaVersion + 空 gameDefinition + assets[])。 */ diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutorTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutorTest.java index 5a5ed561..9adfe3b5 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutorTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcGenerateExecutorTest.java @@ -5,8 +5,11 @@ import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO; import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper; import com.wanxiang.huijing.game.module.aigc.framework.executor.config.AigcExecutorConfiguration; import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackService; +import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi; +import com.wanxiang.huijing.game.module.studio.dto.SourceProjectFetchRespDTO; import com.wanxiang.huijing.framework.common.enums.UserTypeEnum; import com.wanxiang.huijing.framework.common.exception.ServiceException; +import com.wanxiang.huijing.framework.common.pojo.CommonResult; import com.wanxiang.huijing.framework.security.core.LoginUser; import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils; import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest; @@ -81,6 +84,10 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest { @Mock private ExecutorLlmClient.HttpJsonSender dispatchSender; + /** 源项目反查 seam(A11 M1②:modify 路据 baseVersionId 反查 base 源注入 job.sourceProject;仅 modify 取源用例经 10 参构造显式注入,存量用例不触) */ + @Mock + private SourceProjectApi sourceProjectApi; + // ============================== 组装与夹具 ============================== /** @@ -702,4 +709,127 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest { verifyNoInteractions(dispatchSender); } + // ============================== 用例20-23:A11 切片三 M1② base 源注入 job.sourceProject ============================== + + /** + * 组装带 SourceProjectApi 反查 seam 的执行器(A11 M1②,10 参构造):saaDispatcher=null(走 http worker), + * 注入 sourceApi 用于验证 modify 路 base 源注入 job.sourceProject。 + */ + private AigcGenerateExecutor newExecutorWithSourceApi(AigcExecutorProperties props, Clock clock, + SourceProjectApi sourceApi) { + ExecutorLlmClient llmClient = new ExecutorLlmClient(props, sender, millis -> { + // 单测空 sleeper(与 newExecutor 同款) + }); + WorkerDispatchClient workerDispatchClient = new WorkerDispatchClient(props, dispatchSender); + // 10 参构造:saaDispatcher=null → 恒选 http worker(与便宜档 HTTP worker 路一致);末位注入 sourceApi 反查 seam。 + return new AigcGenerateExecutor(props, aigcTaskMapper, difyCallbackService, LOADER, VALIDATOR, + llmClient, workerDispatchClient, null, sourceApi, clock); + } + + /** 反查命中:found=true + source_json(A11 base 源可反查态)。 */ + private static CommonResult fetchHit(String sourceJson) { + SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO(); + data.setFound(Boolean.TRUE); + data.setId(3001L); + data.setSourceJson(sourceJson); + data.setStatus(1); + return CommonResult.success(data); + } + + /** 反查未命中:found=false(base 无源行/早期态)。 */ + private static CommonResult fetchMiss() { + SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO(); + data.setFound(Boolean.FALSE); + return CommonResult.success(data); + } + + @Test + void testDispatchGeneric_modifyInjectsBaseSourceProject() throws Exception { + // A11 M1②:modify 路 + baseVersionId 可反查 → job 含 sourceProject 键且值=反查到的源 JSON(便宜档 worker 据此在 base 源上改)。 + String baseSource = "{\"schemaVersion\":\"1.0\",\"gameDefinition\":{\"speed\":3},\"assets\":[]}"; + AigcTaskDO task = genericQueuedTask(801L, "aigc-mod-src-1"); + task.setMode("deterministic"); + task.setBaseVersionId(4096L); + when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task)); + when(aigcTaskMapper.claimQueuedTask(801L)).thenReturn(true); + when(sourceProjectApi.fetchByVersionId(4096L)).thenReturn(fetchHit(baseSource)); + when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted()); + + newExecutorWithSourceApi(genericProps(), Clock.systemDefaultZone(), sourceProjectApi).runOneTick(); + + // 断言①:据 baseVersionId 反查(Long 直传,无需解析) + verify(sourceProjectApi).fetchByVersionId(4096L); + // 断言②:job body 含 sourceProject 键且值=反查源 JSON(键名 = SAA 路 K_SOURCE_PROJECT / Python worker parse_modify 读取键) + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(dispatchSender).send(anyString(), anyString(), bodyCaptor.capture(), anyInt()); + com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue()); + assertEquals(baseSource, job.path("sourceProject").asText()); + // 断言③:仍带 modify 三件套 + 走派发不走 in-process LLM + assertEquals("deterministic", job.path("modifyMode").asText()); + assertEquals(4096, job.path("baseVersionId").asLong()); + verifyNoInteractions(sender); + } + + @Test + void testDispatchGeneric_modifyBaseSourceMiss_noSourceProjectKeyDispatchOk() throws Exception { + // A11 M1② best-effort:反查未命中(found=false)→ job 不含 sourceProject 键,但派发仍成功(不崩、任务留 RUNNING 等回调)。 + AigcTaskDO task = genericQueuedTask(802L, "aigc-mod-src-2"); + task.setMode("deterministic"); + task.setBaseVersionId(4097L); + when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task)); + when(aigcTaskMapper.claimQueuedTask(802L)).thenReturn(true); + when(sourceProjectApi.fetchByVersionId(4097L)).thenReturn(fetchMiss()); + when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted()); + + newExecutorWithSourceApi(genericProps(), Clock.systemDefaultZone(), sourceProjectApi).runOneTick(); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(dispatchSender).send(anyString(), anyString(), bodyCaptor.capture(), anyInt()); + com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue()); + assertTrue(job.path("sourceProject").isMissingNode(), "反查未命中时 job 不应含 sourceProject 键"); + // 派发未崩:任务留 RUNNING 等回调(不落终态 → 零 handleCallback);modify 三件套照常带上 + verifyNoInteractions(difyCallbackService); + assertEquals("deterministic", job.path("modifyMode").asText()); + assertEquals(4097, job.path("baseVersionId").asLong()); + } + + @Test + void testDispatchGeneric_modifyBaseSourceFetchThrows_noSourceProjectKeyDispatchOk() throws Exception { + // A11 M1② 命门:反查抛异常(seam/Feign/DB 抖动)→ 吞 + 不注入 sourceProject,派发链不崩(仍投递成功、不外抛)。 + AigcTaskDO task = genericQueuedTask(803L, "aigc-mod-src-3"); + task.setMode("deterministic"); + task.setBaseVersionId(4098L); + when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task)); + when(aigcTaskMapper.claimQueuedTask(803L)).thenReturn(true); + when(sourceProjectApi.fetchByVersionId(4098L)).thenThrow(new RuntimeException("seam 抖动(单测桩)")); + when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted()); + + AigcGenerateExecutor executor = newExecutorWithSourceApi(genericProps(), Clock.systemDefaultZone(), sourceProjectApi); + assertDoesNotThrow(executor::runOneTick); // 反查异常不外抛、不崩派发链 + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(dispatchSender).send(anyString(), anyString(), bodyCaptor.capture(), anyInt()); + com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue()); + assertTrue(job.path("sourceProject").isMissingNode(), "反查异常时 job 不应含 sourceProject 键"); + verifyNoInteractions(difyCallbackService); // 派发成功,未落终态 + } + + @Test + void testDispatchGeneric_createPathNoSourceProjectNoFetch() throws Exception { + // 零回归:create 路(mode 空)→ 不反查、job 不含 sourceProject 键(与 modify 三件套同旁路,现行字节零变)。 + AigcTaskDO task = genericQueuedTask(804L, "aigc-gen-src-4"); // mode 未设=null(create 路) + when(aigcTaskMapper.selectClaimableQueued(anyInt(), any(LocalDateTime.class))).thenReturn(List.of(task)); + when(aigcTaskMapper.claimQueuedTask(804L)).thenReturn(true); + when(dispatchSender.send(anyString(), anyString(), anyString(), anyInt())).thenReturn(dispatchAccepted()); + + newExecutorWithSourceApi(genericProps(), Clock.systemDefaultZone(), sourceProjectApi).runOneTick(); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(dispatchSender).send(anyString(), anyString(), bodyCaptor.capture(), anyInt()); + com.fasterxml.jackson.databind.JsonNode job = JSON.readTree(bodyCaptor.getValue()); + assertTrue(job.path("sourceProject").isMissingNode(), "create 路 job 不应含 sourceProject 键"); + // create 路绝不反查 base 源(零副作用) + verifyNoInteractions(sourceProjectApi); + } + } diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java index 584e8f1a..f4476b63 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/SourceProjectServiceImplTest.java @@ -86,6 +86,25 @@ class SourceProjectServiceImplTest extends BaseMockitoUnitTest { assertEquals("default", inserted.getBuildProfile()); assertEquals(SOURCE_JSON, inserted.getSourceJson()); assertEquals(resp.getSourceHash(), inserted.getSourceHash()); + assertNull(inserted.getBaseVersionId()); // A11 M1③ 零副作用:create 路 landReq 无 baseVersionId → DO 血缘列 null + } + + // ============================== 用例1b(A11 M1③):modify 落源 base_version_id 血缘真写进 DO ============================== + + @Test + void testLand_persistsBaseVersionIdLineage() { + // A11 M1③:modify 路 landReq 带 baseVersionId → land 把它写进 DO 的 base_version_id 列(V18),血缘真落库。 + when(gameSourceProjectMapper.selectLatestByGameAndSourceHash(anyLong(), anyString())).thenReturn(null); + doAnswer(inv -> { ((GameSourceProjectDO) inv.getArgument(0)).setId(2099L); return 1; }) + .when(gameSourceProjectMapper).insert(any(GameSourceProjectDO.class)); + + SourceProjectLandReqDTO req = landReq(); + req.setBaseVersionId(4096L); // modify 血缘:本源派生自的 base 版本 + sourceProjectService.land(req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(GameSourceProjectDO.class); + verify(gameSourceProjectMapper).insert(captor.capture()); + assertEquals(4096L, captor.getValue().getBaseVersionId()); // base_version_id 真写进 DO(V18 列) } // ============================== 用例2:edge 同 hash 幂等去重 ==============================