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>
This commit is contained in:
lili 2026-06-29 01:04:55 -07:00
parent bc1efb26d8
commit fc782db08b
11 changed files with 1124 additions and 24 deletions

View File

@ -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/leveldeterministic
behaviorregenerate-modulebig-change/unclearNone);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 502base_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)

View File

@ -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-<id>/ 形态产物目录(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()

View File

@ -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()

View File

@ -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-specprofile 的真派生待 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"})

View File

@ -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["用户试玩后提改<br/>自然语言原话"] --> plan_ep["判意图入口<br/>【本切片新建】"]
plan_ep --> confirm["前端展示建议 + 风险<br/>用户确认/编辑【本切片】"]
confirm --> modify["studio /modify 执行入口<br/>已结构化【已落地·复用】"]
modify --> orch["取 base 源 → submitGenerate<br/>带 modify 字段 → 过 D12 配额门<br/>【已落地·复用】"]
orch --> route{"D3 dispatcher<br/>便宜档路由"}
route -->|"SAA Java 路"| saa["SAA modify 节点regenerate 可达<br/>deterministic 因 build 未闭合 fail-loud<br/>【非完整复用证据】"]
route -->|"便宜档 Python 路<br/>(切片三所坐)"| cheap["cheap-worker<br/>❌ 不认 modify<br/>❌ derive_profile 恒 None→源不带回传<br/>❌ 无判意图/分类执行"]
cheap -.->|"本切片补"| fix["认 modify job + 读 job.base 源<br/>分类执行 + 改动清单回传<br/>三结构断言"]
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-closedmodify 走 submitGenerate 自动过门——"一次调整 = 一次受计费受配额任务"是现成语义。
- **九门 + 三层校验链现成**`cheap_run` 的 scaffold/stage/smoke/ensure_play_spec/play九门+ check静态门 + 形状门)+ buildesbuild可复用。
**SAA Java 路的准确状态(评审纠正)**SAA 有 modify 节点、regenerate-module 路径可达;但 **deterministic modify 因 sourceProject→build 未闭合而 fail-loud显式失败**。所以 SAA 路不能当作"A11 便宜档 deterministic 执行已有实现"的复用证据——deterministic 执行在两条线上都需本切片建。
**做in scope**
- 判意图入口(新端点):收用户 NL 原话 + baseVersionIdworker 取 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 源到 jobworker 只消费 `job.sourceProject`**。改 `result_out` 回传契约时按切片一 M3b 同款 parity 护栏防回退。
- **KTD6三断言①升级为改动清单语义校验**:纯文件 diff 验不出"LLM 改对了那个值/那个位置"——目标文件本就允许变、文件内顺手改坏抓不到。故要求 worker 回传结构化"改动清单"(改了哪些位置/常量、改成什么),断言①校验清单真实命中目标且不溢出非目标;调数值类再叠一次改后行为采样(如改下落速度 → smoke 抓到的速度量真变)。取舍:在不固定 config seam 的前提下,用改动清单 + 行为采样替代结构锚点来兜误伤。
## High-Level Design里程碑与依赖
```mermaid
flowchart LR
M1["M1 前置基座<br/>derive_profile + 后端注入base源 + 血缘回填"] --> M2["M2 判意图入口 + 前端确认<br/>NL→建议结构化+风险 · 危险回问"]
M2 --> M3["M3 确定性类纠错<br/>换资产/调数值改规范工程一处 · 改动清单"]
M2 --> M4["M4 模块重生成<br/>有界单文件重写改玩法"]
M3 --> M5["M5 验收闭环<br/>三结构断言 + 受计费 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 取全。真后端 e2emini-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 · 确定性类纠错执行(换资产 / 调数值 / 改关卡)
**交付物**:执行段判为 deterministictarget.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-moduletarget.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`
**验证**:真后端 e2emini-desktop——判意图 → 前端确认 → `/modify` 执行 → 九门 + 三层校验 + 三断言全过 → 落新预览版(不动 currentVersion、走发布审核→ 计费落账 → 血缘可查。
**依赖**M1M4。
**风险**:三断言只验"改了对的东西、没误伤",不直接证"改得好不好"(玩法质量归 agent / WU-B非本门职责——已知边界、不在工具层加码玩法质量门。
## Verification验证计划
- 单测优先:判意图分类与风险判据、资产/数值一处改写、改动清单与三断言判据、parse_job modify 字段、derive_profile、parity 护栏、base_version_id 回填。
- 真后端 e2emini-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 之前),执行复用结构化 /modifyKTD1 重写、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 返 Nonecreate 路零副作用)。`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.yamlNO_PROXY 代理旁路命门base 版本归属校验best-effort 兜底 unclear+needsConfirm本地 maven
## 状态
双评审已过(发现全处置)→ 待创始人批 → 执行M1→M5 顺序M1 为 plan① 所称"首个里程碑")。

View File

@ -144,6 +144,7 @@ public class AigcExecutorConfiguration {
* @param schemaValidator schema 校验器
* @param llmClient LLM 通道
* @param workerDispatchClient 外置生成 worker 派发通道P3 generic
* @param sourceProjectApiProvider 源项目反查 seamA11 M1modify 路据 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<SourceProjectApi> sourceProjectApiProvider) {
// A11 M1软取 SourceProjectApistudio @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());
}
}

View File

@ -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() 本就是 nullset null 无副作用现行字节零变落库服务 land() 已把该列持久化进 DOV18
landReq.setBaseVersionId(task.getBaseVersionId());
// schemaVersion/buildProfile 缺省由 studio 落库服务兜底回调入参暂不携带B5 接线后可扩
CommonResult<SourceProjectLandRespDTO> resp = sourceProjectApi.land(landReq);
if (resp == null || resp.getData() == null) {
log.warn("[handleCallback] 源落库返回空结果,跳过后续源态流转 traceId={}", reqVO.getTraceId());

View File

@ -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;
/**
* 源项目工件反查 seamA11 切片三 M1/U6modify 路据 {@code task.baseVersionId} 反查 base source_json
* 注入 §6.1 job {@code sourceProject} 供便宜档 HTTP workerPython<b> DB 访问</b>不主动反查被动消费
* KTD5 钉死后端注入·worker 只消费 {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher}
* 用同一 {@link SourceProjectApi} seam同一取值/容错口径
* <p><b>可为 null</b>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} 反查 seammodify 路据 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 源项目反查 seamA11 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() ''/nullV19 modify_mode 默认空create 路从不设 这些键不放/放空串= SAA create buildInputs 对空 modifyMode 不分流 modify现行行为零变
// modify 三件套经 V19 列持久化task 由执行器从 DB 重建跨异步 tick modify 字段可取到 生产 modify 抵达 buildInputs 已闭合
// sourceProjectbase 源项目工件 JSON U2/U6 baseVersionId 反查后填充本单元不接 sourceProject 反查 U2 GameSourceProjectMapper故此处不放 sourceProject buildInputs 缺省空串
// sourceProjectbase 源项目工件 JSONA11 M1 modify 路据 baseVersionId SourceProjectApi 反查后注入此键
// 便宜档 HTTP workerPython 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
*
* <p><b>键名契约必须与 dispatcher buildInputs / SaaStudioNodes K_* 一致</b>
* {@code modifyMode}K_MODIFY_MODE/ {@code modifyPatch}K_MODIFY_PATCH/ {@code baseVersionId}K_BASE_VERSION_ID
* sourceProjectK_SOURCE_PROJECTbase 源项目工件 JSON U2/U6 baseVersionId 反查填充<b>本单元不接</b> GameSourceProjectMapper
* <p><b>键名契约必须与 dispatcher buildInputs / SaaStudioNodes K_* / Python worker parse_modify 一致</b>
* {@code modifyMode}K_MODIFY_MODE/ {@code modifyPatch}K_MODIFY_PATCH/ {@code baseVersionId}K_BASE_VERSION_ID/
* {@code sourceProject}K_SOURCE_PROJECTbase 源项目工件 JSON前三件透传 DO第四件 {@code sourceProject}
* A11 M1 {@link #injectBaseSourceProject} baseVersionId 反查注入best-effort
*
* <p><b>非破坏性create 路零变</b>{@code task.getMode()} ''/nullV19 modify_mode 默认空create/generate 路从不设
* 本方法<b>不放任何 modify </b> buildInputs 对缺省 modifyMode 不分流 modify create 现行字节零变
* 本方法<b>不放任何 modify </b> sourceProject buildInputs / worker 对缺省 modifyMode 不分流 modify create 现行字节零变
*
* <p><b>持久化V19</b>{@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_MODEdeterministic|regenerate-module|extend
if (task.getBaseVersionId() != null) {
job.put("baseVersionId", task.getBaseVersionId()); // K_BASE_VERSION_IDmodify/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 切片三 M1KTD5源回传落库是硬前置·钉死注入路径modify 路据 {@code task.baseVersionId} 反查 base 源工程 JSON
* 注入 §6.1 job {@code sourceProject} 便宜档 HTTP worker Python<b> DB 访问</b>不能主动反查故由后端派发前注入
* worker 只被动消费 {@code job["sourceProject"]}Python {@code parse_modify} 读此键键名与 SAA
* {@link com.wanxiang.huijing.game.module.aigc.saa.SaaStudioNodes#K_SOURCE_PROJECT} 同名跨两路一致
*
* <p><b>取值/容错口径镜像</b> {@link com.wanxiang.huijing.game.module.aigc.saa.SaaGraphDispatcher} {@code resolveBaseSourceProject}
* 同一 {@link SourceProjectApi} seamseam 缺席 / 反查未命中{@code found=false}/ 源空 / 反查异常 <b>不放 sourceProject </b>
*
* <p><b>best-effort 非阻断命门</b>全程 try-catch 吞异常反查失败绝不外抛绝不让 {@link #dispatchGeneric} 派发链崩
* base 源缺则 worker 走不了 modify worker 侧据缺源决断但派发本身不因源反查失败而中断
* <b>create 路零副作用</b>本方法只在 {@link #putModifyFieldsIntoJob} modify 分支baseVersionId 非空内被调create 路根本不触
*
* @param job 已组好 modify 区的 §6.1 job map命中则 additive 追加 sourceProject
* @param task 已认领的 modify 任务 baseVersionId 反查调用前已保证 baseVersionId 非空
*/
private void injectBaseSourceProject(Map<String, Object> 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 {
// aigcstudio-api seam 反查 base 版本对应源工件 SaaGraphDispatcher / DifyCallbackServiceImpl 同一 seambaseVersionId 已是 Long无需解析
CommonResult<SourceProjectFetchRespDTO> 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);
}
}
/**

View File

@ -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 血缘回填 nullset 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);
}
// ====================== 用例19A11 切片三 M1modify 回调落源回填 base_version_id 血缘 ======================
/**
* A11 M1血缘可查modify 回调任务带 {@code baseVersionId}V19 落源时 {@code landReq.baseVersionId}
* 回填该值使 modify 产的新源行可反查派生自哪个 base 版本验收第三断言血缘可查的反查链根基
*
* 断言land 入参 baseVersionId = 任务 baseVersionIdgameId/sourceJson 仍透传
*/
@Test
void testHandleCallback_landFillsBaseVersionIdLineage() {
wireSourceLandingDeps();
// 三元序列同用例13landSourceQuietly gameId+baseVersionIdmodify 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 = 任务 baseVersionIdM1 血缘回填gameId/sourceJson 仍透传
ArgumentCaptor<SourceProjectLandReqDTO> 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());
}
// ============================== 测试夹具 ==============================
/** 源工件最小骨架 JSONasset 节点口径schemaVersion + 空 gameDefinition + assets[])。 */

View File

@ -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;
/** 源项目反查 seamA11 M1②modify 路据 baseVersionId 反查 base 源注入 job.sourceProject仅 modify 取源用例经 10 参构造显式注入,存量用例不触) */
@Mock
private SourceProjectApi sourceProjectApi;
// ============================== 组装与夹具 ==============================
/**
@ -702,4 +709,127 @@ class AigcGenerateExecutorTest extends BaseMockitoUnitTest {
verifyNoInteractions(dispatchSender);
}
// ============================== 用例20-23A11 切片三 M1 base 源注入 job.sourceProject ==============================
/**
* 组装带 SourceProjectApi 反查 seam 的执行器A11 M110 参构造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_jsonA11 base 源可反查态)。 */
private static CommonResult<SourceProjectFetchRespDTO> 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=falsebase 无源行/早期态)。 */
private static CommonResult<SourceProjectFetchRespDTO> fetchMiss() {
SourceProjectFetchRespDTO data = new SourceProjectFetchRespDTO();
data.setFound(Boolean.FALSE);
return CommonResult.success(data);
}
@Test
void testDispatchGeneric_modifyInjectsBaseSourceProject() throws Exception {
// A11 M1modify + 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<String> 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<String> 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 等回调不落终态 handleCallbackmodify 三件套照常带上
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<String> 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 未设=nullcreate
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<String> 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);
}
}

View File

@ -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
}
// ============================== 用例1bA11 M1modify 落源 base_version_id 血缘真写进 DO ==============================
@Test
void testLand_persistsBaseVersionIdLineage() {
// A11 M1modify 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<GameSourceProjectDO> captor = ArgumentCaptor.forClass(GameSourceProjectDO.class);
verify(gameSourceProjectMapper).insert(captor.capture());
assertEquals(4096L, captor.getValue().getBaseVersionId()); // base_version_id 真写进 DOV18
}
// ============================== 用例2edge hash 幂等去重 ==============================