diff --git a/.agents/skills/saa-graph-orchestration.md b/.agents/skills/saa-graph-orchestration.md index 25469511..0ba2148e 100644 --- a/.agents/skills/saa-graph-orchestration.md +++ b/.agents/skills/saa-graph-orchestration.md @@ -48,6 +48,19 @@ OpenAiChatModel m = OpenAiChatModel.builder().openAiApi(api).defaultOptions(opts - ⚠️ **baseUrl 坑**:Spring AI 自拼 `completionsPath=/v1/chat/completions`,故 `baseUrl` 必须是 **host 根、不带 `/v1`**(`http://100.64.0.8:3000`);同一 `.env`(`NEWAPI_BASE_URL=.../v1`)两侧通用须 Java 侧 `stripV1Suffix` 剥之,否则 404 `/v1/v1/...`。 - 视觉位(M3)走多模态 `UserMessage.builder().media(Media(IMAGE_PNG, ...))`。成本:全走 new-api 单一 key→自动入 `newapi_cost`(logs.quota) 计费平面,不内联折账。 +## 3.5 M3 thinking 治理 + 协议/流式(gen 质量决定性杠杆,2026-06-20 实证 dddec3c8) + +便宜模型生成"哑火静态游戏"(能起、有渲染,但 **E_live 不动**=没接对运动逻辑)是 gamedef 路最大失败簇——**开 thinking 是修它的关键杠杆**:实测 conc=12 gamedef thinking-off 8/12(67%,E_live 哑火×4) → thinking-on 11/12(91.7%,哑火修 3/4)。但 M3 thinking 配错会从"截断假 parse 失败"恶化到"大面积 SocketTimeout": + +- **M3 thinking 仅二元 `disabled|adaptive`,无深度/budget 控制**(官方 Messages spec + llms.txt 双证),**提示词也压不住**("别过度思考"实测反更长,T3)。`thinking(ENABLED,budget)` 的 budget 对 M3 被忽略(按 adaptive 跑)。→ 想要"中等思考"=做不到,只能开/关。 +- **openai 兼容路**:adaptive **内联进 `content`**(`…`)→ 污染 JSON + 在 JSON 前吃满 max_tokens 截断(`finish_reason=length`;节点无 length 检测→伪装成 parse 失败/llm_error)。**故 openai 路只能 `thinking:{type:disabled}`**(`SaaStudioGraph.model()` 对 MiniMax 经 `extraBody` 注入)——快/省但留 67% 哑火。 +- **anthropic 兼容路(要质量走这条)**:`/v1/messages` + `x-api-key` + 原生 thinking 分离 block(content=[thinking(带 signature), text],不污染 JSON)。**两个必配否则灾难**: + - ① **必须流式**:`anthropicFactory` 包 `StreamingCallChatModel`(`stream()`+`MessageAggregator`)。否则阻塞 `.call()` 整取,adaptive(无界)+大 max_tokens 下单次阻塞读必撞读超时 → **`SocketTimeoutException` 大面积**(实测 conc=12 几乎全挂);流式块持续到达不饿死读计时。 + - ② **webClient 必 NO_PROXY**:`AnthropicApi.builder().webClientBuilder(JDK HttpClient + NO_PROXY ProxySelector + JdkClientHttpConnector)`(同 `openAiApiNoProxy`)。原代码只设 restClientBuilder(阻塞),**加 webClientBuilder 才支持流式**、且避 macOS clash 拦 Tailscale IP→502。 +- **#4407 是过时顾虑**:spring-ai "流式 thinking 缺陷"报在 **1.0.2** 且伴 **tool_use**、已 Closed;我们 **1.1.2 + 生成无工具调用**→不触发(n=1 实测流式聚合产真 verdict、JSON 抽得出)。区别于"出站 buildMessages 丢 thinking"(多轮回放限制,仍真)——本条是入站流式收取。 +- **max_tokens**:M3 接受 256000(探针连 300000 通)。**代价**:thinking-on ~10x 慢(adaptive 无界,单局 20–73min)+ 1.6x token——质量 vs 速度按场景权衡(异步产线可接受,要快则 openai+disabled 兜底)。 +- 🔴 **rc 门耦合 bug(客观门解耦的隐形杀手)**:`SaaGenNodes.playNode` 的 pass **别前置 `rc==0`**——`play.cdp.cjs` 退出码=九门聚合 `verdict.pass(全9门AND)?0:1`(任一门含 driver 门 G/H/I 挂即 exit 1),driver 门失败 rc=1 会**反向否决**"只认客观门 A–E"的解耦 → 空跑 8 轮救场烧 token。修:客观门模式 `pass=(rc==0||rc==1)&&objectiveGatesPass(verdict)`。 + ## 4. checkpoint(崩溃续跑 · 迁移 step5) ```java @@ -84,6 +97,7 @@ SaverConfig sc = SaverConfig.builder().register(saver).build(); - **异步派发测试坑(实翻)**:`dispatch` 是**异步+单线程执行器+Semaphore(1) 串行真玩门**,且单款墙钟 **3.5-16min**(九门真玩循环+最多 8 救场轮固有,非 SAA 特有=同 Python W-G1)。**别**"每条 dispatch 后 arm 新 latch 等 240s"(串行+回调错配=归因全错);正解=**一次性投递全 N 条→共享 `CountDownLatch(N)`→stub 按 `reqVO.getTraceId()` 精确归位并发 map**,每条超时 900s。 - **首证基线**:真全图(render→…→真九门 play→…→emit/giveup)真 LLM+真九门跑通、**零 prod bug**;成功率 **3/5=60% < 80%**。成功路 engineBundle≈211KB(含引擎,经 `__GameBundle` 全局名校验才置 succeeded)。**难门 I_control/F_wiring/H_progress 卡 60%——根因非"模型天花板"**(2026-06-18 根因分析 + Codex 复核证伪:无样本确认真硬天花板;scale-20 无"人工介入后仍不过"的款)。**主因 = 无人值守产线的"喂不全 + 救场机制不足 + 门判据偏"**:命门 = 注入的 few-shot 探针 `_goldenpath-probe/factory.js` 缺 `_forensicsView`/latch、而 prompt 硬要求它(模型抄没见过的实现);I_control 平滑跟手 harness 查、prompt 零提及;failCount 跨档不重置 + repair 不回喂源码。详见 `docs/brainstorms/2026-06-18-生成失败根因分析`。便宜模型不稳→救场耗尽 giveup(真落盘,非崩溃)。**SAA 机制 ≠ 生成质量**:转默认产线(U7)的 ≥80% 门由 W-G1 生成质量域把守,非 SAA 编排 bug。 - **003-U1 验收门工具化(plan 2026-06-18-003,可作 flip 前置硬门)**:把本测从「内置 5 条 / 只断 `succeeded>=1`(留人判 80%=不可作硬门)」升成**客观可运行产线门**——① brief 内置扩 12 条多品类 + `-Dsaa.e2e.briefFile` 外部行式文件(去空白/跳空行/跳 `#`,空则回退内置);② `-Dsaa.e2e.minSuccessRate` 可配**硬断言**,**默认不设=仅存在性**(保 003-U1「先实测真基线再收敛」、不破普通构建),显式 `0.8` 即 flip(003-U2)前置硬门;③ **成本会计 = token 代理,非¥**——从回调 `trace.tokens.{prompt,completion}` + `modelTier`(升 stage2 计数)汇总,`-Dsaa.e2e.maxTokensPerGame` 仅**软警告不硬阻断**(创始人裁「质量优先、允许临时破价、记账对账」)。**🔴 坑:SAA 路无 ¥ 数据**——`SaaGraphDispatcher` cost v0 整段省略(token≠¥,挂 cost 语义错)、折¥=follow-up F3 → **别接 ¥ 门、别造假¥,只记 token**。纯逻辑抽静态方法 + 模型无关单测 `SaaFullGraphE2eGateLogicTest`(普通 `mvn test` 即跑,不需模型/key,12/12 过)。 +- **🔴 2026-06-20 重大跃迁(dddec3c8,已提交 `4e5a7bbf`)**:rc 门耦合修 + 改 anthropic 原生分离 + **流式 thinking**(详见 §3.5)→ conc=12 gamedef **91.7%(11/12)过 80 门**(thinking-off openai 仅 67%、E_live 哑火×4;thinking 修哑火 3/4,证"M3 需开思考才接对运动逻辑";残留 #7 打地鼠=天生无运动点击类)。**SAA 机制本就 ok,质量杠杆=thinking+协议**。按创始人以 **n=12/91.7% 宣告达标**(放宽原 plan003 n≥30 门);代价 ~10x 慢+1.6x token。**先证伪了"模型天花板"——便宜模型经"对的协议+开思考"可达九成**(推翻 L85「真基线 60%/天花板存疑」的悲观面)。 --- diff --git a/contracts/agent-loop/skeletons/breakout.json b/contracts/agent-loop/skeletons/breakout.json new file mode 100644 index 00000000..524ee847 --- /dev/null +++ b/contracts/agent-loop/skeletons/breakout.json @@ -0,0 +1 @@ +{"entities": [{"id": "world", "transform": {"position": {"x": 0, "y": 0}}, "components": ["bg"]}, {"id": "paddle", "transform": {"position": {"x": 195, "y": 800}}, "components": ["rpaddle"]}, {"id": "ball", "transform": {"position": {"x": 195, "y": 760}}, "vx": 180, "vy": -310, "components": ["rball"]}], "components": [{"id": "bg", "kind": "render", "shape": "fill", "color": "#0D1117"}, {"id": "rpaddle", "kind": "render", "shape": "rect", "color": "#4FC3F7", "w": 90, "h": 16}, {"id": "rball", "kind": "render", "shape": "circle", "color": "#FFEB3B", "r": 9}, {"id": "rbrick1", "kind": "render", "shape": "rect", "color": "#FF5252", "w": 56, "h": 22}, {"id": "rbrick2", "kind": "render", "shape": "rect", "color": "#FFD54F", "w": 56, "h": 22}, {"id": "rbrick3", "kind": "render", "shape": "rect", "color": "#FF7043", "w": 56, "h": 22}, {"id": "rhud", "kind": "render", "shape": "rect", "color": "#222831", "w": 390, "h": 50}], "behaviors": [{"id": "spawnbricks", "trigger": "init", "code": "const cols=6, rows=5; const bw=56, bh=22, gap=4; const totalW = cols*bw + (cols-1)*gap; const startX = (390 - totalW)/2; const startY = 80; for(let r=0;r 381){ b.x = 381; b.vx = -Math.abs(b.vx); rt.fx.beep('wall'); } if(b.y < 9){ b.y = 9; b.vy = Math.abs(b.vy); rt.fx.beep('wall'); }"}, {"id": "ballcollide", "trigger": "update", "code": "const b=rt.getEntity('ball'); const p=rt.getEntity('paddle'); if(!b||!p) return; const bw=18, bh=18; for(const br of rt.query('brick')){ if(rt.overlap({x:b.x-bw/2,y:b.y-bh/2,w:bw,h:bh},{x:br.x-28,y:br.y-11,w:56,h:22})){ br.hp = (br.hp||1) - 1; if(br.hp <= 0){ rt.fx.burst(br.x, br.y, '#FFEB3B'); rt.fx.beep('brick'); rt.addScore(1); rt.destroy(br); } else { br.components = [{kind:'render',shape:'rect',color:'#FF7043',w:56,h:22}]; rt.fx.beep('wall'); } b.vy = -Math.abs(b.vy); b.y = br.y - 11 - 1; break; } } const ph = {x:p.x-45,y:p.y-8,w:90,h:16}; if(rt.overlap({x:b.x-bw/2,y:b.y-bh/2,w:bw,h:bh},ph)){ const rel = (b.x - p.x) / 45; rel = rt.clamp(rel, -1, 1); const speed = Math.sqrt(b.vx*b.vx + b.vy*b.vy); const angle = rel * (Math.PI/3); b.vx = Math.sin(angle) * speed; b.vy = -Math.abs(Math.cos(angle) * speed); b.y = p.y - 8 - bh/2 - 1; rt.fx.beep('paddle'); }"}, {"id": "diffscale", "trigger": "update", "code": "const b=rt.getEntity('ball'); if(!b) return; const target = Math.floor(rt.score/8) * 0.08; if(self.scale===undefined) self.scale = 1; const want = 1 + target; if(Math.abs(self.scale - want) > 0.001){ self.scale = want; const sp = Math.sqrt(b.vx*b.vx + b.vy*b.vy); const cur = sp; const newSp = 360 * want; const k = newSp / cur; b.vx *= k; b.vy *= k; }"}, {"id": "lifelose", "trigger": "update", "code": "const b=rt.getEntity('ball'); if(!b) return; if(self.lives===undefined){ self.lives = 3; } if(b.y > 860){ self.lives = self.lives - 1; rt.fx.burst(195, 800, '#ff4444'); rt.fx.beep('lose'); if(self.lives <= 0){ rt.lose(); } else { b.x = 195; b.y = 760; b.vx = 180; b.vy = -310; } }"}], "scenes": [{"id": "main", "entityRefs": ["world", "paddle", "ball"]}], "rules": [{"id": "win", "condition": "rt.query('brick').length === 0", "outcome": "win"}, {"id": "lose", "condition": "false", "outcome": "lose"}]} \ No newline at end of file diff --git a/contracts/agent-loop/skeletons/simon.json b/contracts/agent-loop/skeletons/simon.json new file mode 100644 index 00000000..f10f9904 --- /dev/null +++ b/contracts/agent-loop/skeletons/simon.json @@ -0,0 +1 @@ +{"components": [{"id": "bg", "kind": "render", "shape": "fill", "color": "#0e1116"}, {"id": "rhud", "kind": "render", "shape": "rect", "color": "#ffffff", "w": 1, "h": 1}, {"id": "rb0", "kind": "render", "shape": "rect", "color": "#801a1a", "w": 130, "h": 130}, {"id": "rb1", "kind": "render", "shape": "rect", "color": "#1a8033", "w": 130, "h": 130}, {"id": "rb2", "kind": "render", "shape": "rect", "color": "#1a3d80", "w": 130, "h": 130}, {"id": "rb3", "kind": "render", "shape": "rect", "color": "#80701a", "w": 130, "h": 130}, {"id": "mb0", "kind": "clickable", "score": 1, "mark": {"shape": "rect", "color": "#ff3344", "w": 130, "h": 130}, "hit": {"x": 65, "y": 65, "w": 130, "h": 130}}, {"id": "mb1", "kind": "clickable", "score": 1, "mark": {"shape": "rect", "color": "#33ff66", "w": 130, "h": 130}, "hit": {"x": 65, "y": 65, "w": 130, "h": 130}}, {"id": "mb2", "kind": "clickable", "score": 1, "mark": {"shape": "rect", "color": "#3366ff", "w": 130, "h": 130}, "hit": {"x": 65, "y": 65, "w": 130, "h": 130}}, {"id": "mb3", "kind": "clickable", "score": 1, "mark": {"shape": "rect", "color": "#ffdd33", "w": 130, "h": 130}, "hit": {"x": 65, "y": 65, "w": 130, "h": 130}}], "entities": [{"id": "world", "transform": {"position": {"x": 0, "y": 0}}, "components": ["bg"]}, {"id": "hud0", "transform": {"position": {"x": 0, "y": 0}}, "components": ["rhud"]}, {"id": "b0", "transform": {"position": {"x": 65, "y": 280}}, "tags": ["target"], "idx": 0, "components": ["rb0", "mb0"], "occupied": false}, {"id": "b1", "transform": {"position": {"x": 215, "y": 280}}, "tags": ["target"], "idx": 1, "components": ["rb1", "mb1"], "occupied": false}, {"id": "b2", "transform": {"position": {"x": 65, "y": 430}}, "tags": ["target"], "idx": 2, "components": ["rb2", "mb2"], "occupied": false}, {"id": "b3", "transform": {"position": {"x": 215, "y": 430}}, "tags": ["target"], "idx": 3, "components": ["rb3", "mb3"], "occupied": false}], "behaviors": [{"id": "init", "trigger": "init", "code": "self.phase='show'; self.round=1; self.progress=0; self.seq=[rt.randInt(0,3)]; self.idx=0; self.lit=-1; self.litT=0; self.gapT=0; self.failT=0; self.score=0; rt.setScore(0); for(const b of rt.query('target')){ b.occupied=false; b._lit=0; } rt.spawn({x:195,y:60,tags:['hud'],components:[{kind:'render',shape:'rect',color:'#ffffff',w:1,h:1}]});"}, {"id": "engine", "trigger": "update", "code": "self.gapT=(self.gapT||0)-dt; self.litT=(self.litT||0)-dt; self.failT=(self.failT||0)-dt; if(self.phase==='show'){ if(self.lit>=0){ const b=rt.getEntity('b'+self.lit); if(b){ b._lit=Math.max(0,self.litT/0.42); const s=1+0.08*Math.sin(Math.min(1,self.litT/0.42)*Math.PI); b._sx=s; b._sy=s; } if(self.litT<=0){ if(b){ b._lit=0; b._sx=1; b._sy=1; } self.lit=-1; self.gapT=0.16; } } else if(self.gapT<=0){ if(self.idx=b.x && p.x<=b.x+130 && p.y>=b.y && p.y<=b.y+130){ hit=b; break; } } if(!hit) return; const expected=self.seq[self.progress]; rt.fx.burst(hit.x+65", "hit.idx===0?'#ff3344'": "hit.idx===1?'#33ff66':hit.idx===2?'#3366ff':'#ffdd33'); rt.fx.beep(hit.idx===0?'red':hit.idx===1?'green':hit.idx===2?'blue':'yellow'); hit._lit=1; hit._sx=1.08; hit._sy=1.08; if(hit.idx===expected){ self.progress++; hit.occupied=true; if(self.progress>=self.seq.length){ self.score++; rt.addScore(1); rt.fx.burst(195,420,'#ffdd33'); self.round++; if(self.round>8){ self.phase='win'; self.failT=1.6; rt.fx.burst(195,422,'#ffdd33'); rt.win(); } else { self.phase='show'; self.idx=0; self.lit=-1; self.gapT=0.8; self.seq.push(rt.randInt(0,3)); for(const b of rt.query('target')){ b.occupied=true; b._lit=0; } } } } else { self.phase='fail'; self.failT=1.6; self.progress=0; rt.fx.burst(195,422,'#ff3344'); rt.fx.burst(195,422,'#ff3344'); for(const b of rt.query('target')){ b.occupied=true; } self.idx=0; }"}], "scenes": [{"id": "main", "entityRefs": ["world", "hud0", "b0", "b1", "b2", "b3"]}], "rules": [{"id": "winrule", "condition": "rt.score >= 1", "outcome": "win"}]} \ No newline at end of file diff --git a/contracts/agent-loop/skeletons/tictactoe.json b/contracts/agent-loop/skeletons/tictactoe.json new file mode 100644 index 00000000..1f3a57a2 --- /dev/null +++ b/contracts/agent-loop/skeletons/tictactoe.json @@ -0,0 +1 @@ +{"entities": [{"id": "world", "transform": {"position": {"x": 0, "y": 0}}, "components": ["bg"]}, {"id": "t0", "transform": {"position": {"x": 85, "y": 310}}, "tags": ["target"], "idx": 0, "components": ["tile", "clk"]}, {"id": "t1", "transform": {"position": {"x": 195, "y": 310}}, "tags": ["target"], "idx": 1, "components": ["tile", "clk"]}, {"id": "t2", "transform": {"position": {"x": 305, "y": 310}}, "tags": ["target"], "idx": 2, "components": ["tile", "clk"]}, {"id": "t3", "transform": {"position": {"x": 85, "y": 420}}, "tags": ["target"], "idx": 3, "components": ["tile", "clk"]}, {"id": "t4", "transform": {"position": {"x": 195, "y": 420}}, "tags": ["target"], "idx": 4, "components": ["tile", "clk"]}, {"id": "t5", "transform": {"position": {"x": 305, "y": 420}}, "tags": ["target"], "idx": 5, "components": ["tile", "clk"]}, {"id": "t6", "transform": {"position": {"x": 85, "y": 530}}, "tags": ["target"], "idx": 6, "components": ["tile", "clk"]}, {"id": "t7", "transform": {"position": {"x": 195, "y": 530}}, "tags": ["target"], "idx": 7, "components": ["tile", "clk"]}, {"id": "t8", "transform": {"position": {"x": 305, "y": 530}}, "tags": ["target"], "idx": 8, "components": ["tile", "clk"]}], "components": [{"id": "bg", "kind": "render", "shape": "fill", "color": "#10101a"}, {"id": "tile", "kind": "render", "shape": "rect", "color": "#33384a", "w": 110, "h": 110}, {"id": "clk", "kind": "clickable", "score": 1, "mark": {"shape": "circle", "color": "#ffcc00", "r": 30}}], "behaviors": [{"id": "init", "trigger": "init", "code": "self.board=[null,null,null,null,null,null,null,null,null]; self.turn='X'; self.result=null; self.moves=0; self.phase='playing'; self.aiPending=0; self.aiIdx=-1;"}, {"id": "tick", "trigger": "update", "code": "if(self.phase==='gameover'){ return; } if(self.turn==='O' && self.aiIdx===-1 && self.moves<9){ self.aiPending += dt; if(self.aiPending >= 0.42){ const empties=[]; for(let i=0;i<9;i++){ if(self.board[i]===null) empties.push(i); } if(empties.length>0){ self.aiIdx = empties[Math.floor(rt.random()*empties.length)]; } else { self.aiIdx=-1; } } } if(self.turn==='O' && self.aiIdx>=0){ const i=self.aiIdx; self.board[i]='O'; self.moves+=1; rt.fx.burst([85,195,305][i%3],[310,420,530][Math.floor(i/3)],'#ff8844'); rt.fx.beep('click'); self.aiIdx=-1; self.aiPending=0; const w=checkWin(self.board); if(w){ self.result=w; self.phase='gameover'; rt.fx.burst(195,420,'#ffaa44'); rt.fx.beep('win'); } else if(self.moves>=9){ self.result='draw'; self.phase='gameover'; rt.fx.beep('lose'); } else { self.turn='X'; } } function checkWin(b){ const L=[[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]]; for(const l of L){ if(b[l[0]] && b[l[0]]===b[l[1]] && b[l[0]]===b[l[2]]) return b[l[0]]; } return null; }"}], "scenes": [{"id": "main", "entityRefs": ["world", "t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"]}], "rules": [{"id": "win", "condition": "rt.score >= 9", "outcome": "win"}]} \ No newline at end of file diff --git a/docs/plans/2026-06-19-002-feat-gendone-用对M3生成生死门-plan.md b/docs/plans/2026-06-19-002-feat-gendone-用对M3生成生死门-plan.md new file mode 100644 index 00000000..122c5ea0 --- /dev/null +++ b/docs/plans/2026-06-19-002-feat-gendone-用对M3生成生死门-plan.md @@ -0,0 +1,367 @@ +--- +title: "feat: plan003 Plan A — gen-done 生成生死门(用对 agentic 的 M3 → ≥80% → 两步 flip)" +status: active +date: 2026-06-19 +type: feat +origin: docs/plans/2026-06-18-003-feat-gen-quality-converge-and-m0-integration-plan.md(003-U1/U2 骨架)+ docs/brainstorms/2026-06-18-生成失败根因分析-requirements.md(v3「用对 M3」方向 SoT)+ docs/brainstorms/2026-06-19-plan003-phase1验收闭环-requirements.md(R1/R2/T1/T2 done 判据) +review: "已过双评审(AGENTS.md §6 条款 8,2026-06-19):Opus 对抗式 + Opus 可行性双评(代码核证);Codex(codex-rescue) 11min 未出可用评审 → §6.8 Opus 单评回退。整改 5 P0 + 7 P1 + 多 P2(见「双评审整改注记」)。ce-work 前可补跑 Codex 作 belt-and-suspenders。" +--- + +# feat: plan003 Plan A — gen-done 生成生死门 + +> **双线拆分**:本档 = **Plan A(gen-done)**;Plan B(core-done)= `docs/plans/2026-06-19-001-feat-coredone-验收闭环执行-plan.md`,另一 session(worktree `feat/mac-planB-coredone`)并行。 +> +> **⚠️ 边界的真相(评审 P0-1 纠偏)**:Plan A 与 Plan B **代码作者面不相交**(Plan A **不编辑** `gd-runtime.js`/`build-from-source.mjs`,Plan B **不编辑** SAA java / worker python)。**但"作者面不相交 ≠ 量测面不相交"**:gen-done 的 ≥80% 量测**执行期实跑** Plan B 的两文件——validate 节点 `SaaGenNodes.java:283` 以子进程跑 `node src/host/build-from-source.mjs`,装配产物 `build-from-source.mjs:34` 硬 import `../../../src/host/gd-runtime.js`,九门真玩(F_wiring/H_progress/I_control)每跑必执行 gd-runtime。**故 gen-done 的指标是 gd-runtime/build-from-source 行为的函数**。→ 见 KTD 8(运行时版本钉 + Plan B 冻结协调)。 +> +> **铁律边界(执行期不可越)**:① **绝不编辑** `gd-runtime.js`/`build-from-source.mjs`(Plan B 域);② `remaining` 语义修正只走 `SaaPrompts` 的 `DESIGN_SYSTEM` java 范例;③ mini-desktop e2e 跑前确认无 Plan B 占用(端口 4320/9222 单实例 flock);④ **收敛窗口期协调 Plan B 冻结这两文件**(每轮量测前钉其 content-hash,变更=重置基线,见 KTD 8)。 +> +> **单向耦合**:Plan B 的 Phase 2/3 game-quality 门**消费** gen-done 产出(末段、单向)。 +> +> **编号命名空间**:本档 unit `U1…U7` = Plan A 局部命名空间;对应 plan003 `003-U1`(用对 M3)+ `003-U2`(两步 flip)。喂 ce-work / 跨档引用带 `PlanA-Ux`。 + +--- + +## 双评审整改注记(2026-06-19 · Opus 对抗式 + 可行性,代码核证) + +> 整改 5 P0 + 7 P1(全 IN-DOCUMENT 落本档)+ P2/确认。逐条可回溯到下文章节。 + +- **P0-1(独立性主张过宽)**:~~"gen-done 不依赖 Plan B"~~ → 改"**作者面不相交、量测面执行 Plan B 两文件**";增 KTD 8 运行时 content-hash 钉 + 收敛窗口 Plan B 冻结。(见 双线拆分 note / KTD 8 / U5 / R-10) +- **P0-2(≥80% 是机制门、对资产质量盲)**:~~`GAMEDEF_KEYS` strip 掉 assets、`drawEntity` 仅图元~~ **【2026-06-19 更新:Plan B U1(`db14845d`,feat/mac-planB-coredone)已闭合 asset-orphan——`gd-runtime` 现支持 `shape:'sprite'`→`drawSprite`→`drawImage`(受控面 host 预载),assets 走 `sourceProject` 顶层抽取(非 `GAMEDEF_KEYS` strip)。"运行时不能渲染资产"已过期】**。但九门仍只 `D_render` 验**非空画面**、**不验资产对位正确性**,故"**≥80% 仅证机制、不验资产/好玩**"仍成立(资产正确性归 core-done R9/R11);U7 的 asset-orphan 烟测**现已可满足**(运行时有 sprite 路)。(见 R1 / KTD 4 / 范围边界 / U7) +- **P0-3(并发共享面 > 端口)**:`serve-and-play.sh:44` 服整 runtime 根、`_shared/` 模板共读、`/tmp/wg1-*-$PORT.log` 可撞、`lsof|kill` 净场 port-keyed → 结构性竞态(非时序)。U3 加全共享面审计 + 同款×N 结构隔离断言;K=1 为记录基线权威。(见 KTD 3 / U3) +- **P0-4(Anthropic 多 Generation 响应形,feasibility)**:`AnthropicChatModel` 每 content block 一 `AssistantMessage`,thinking 开时 **block0=thinking** → `getResult().getText()` 取到推理文、抽取必败。U1/U2 改**遍历 `resp.getResults()`** 重组有序块、答案取 text-role Generation;历史项 = 每轮 `List`(thinking 先序)。(见 KTD 2 / U1 / U2) +- **P0-5(checkpoint 序列化潜伏坑,feasibility)**:e2e harness `saaCheckpointEnabled=false`+dataSource=null → **MysqlSaver 序列化路从不被量测触发**;staging 默认 `saaCheckpointEnabled=true` → `List` 若 Jackson 往返丢元素类型,**绿门后在生产炸**。U2 加 checkpoint-ON 序列化往返测试;U7 隔离验须 checkpoint 开。(见 KTD 2 / U2 / U7 / R-9) +- **P1-1(measure-first 隐藏晚长杆)**:U-C 是最难/最未证单元,若闸控到晚期才建=时间压力下建最不确定单元。增 U5 并行 U-C 廉价可行性 spike + 具体触发线。(见 KTD 5 / U5 / U6 / 待确认) +- **P1-2(≥80% 标的未验 + brief Goodhart)**:80% 是 iife 路继承数、gamedef 真基线 n=0;brief 集自控且偏易(`SaaFullGraphE2eTest.java:90` 注释自陈"偏向 harness 支持品类")。增 基线定标复核 + brief 集冻结/钉 hash + **留出集**(final R1 用未调优集)。(见 R1 / U3 / U5 / 待确认) +- **P1-3(thinking 历史无界增长 vs 保真张力)**:append 全 thinking × 8 救场轮 → token 爆 + 每轮复发计费;窗口截断又毁 thinking/顺序。增 thinking-aware 有界截断策略 + max-repair 深度(8)测试 + 并发隔离测试。(见 KTD 2 / U2) +- **P1-4(KeyStrategy 全量 ReplaceStrategy,feasibility)**:`SaaStudioGraph` keyFactory 对 `ALL_KEYS` 全赋 `ReplaceStrategy`;"append 策略"须特例化或**节点内 read-append-write**(对齐 `appendAttempt`)。U2 选后者。(见 U2) +- **P1-5(并发改动 > Semaphore(K),feasibility)**:`newSingleThreadExecutor` 须同改有界池;端口注入须 per-job 替 `buildInputs` 常量、`finally` 释放防泄漏。(见 KTD 3 / U3) +- **P1-6(flip 须同暴露 saa-model-protocol,feasibility P2-4)**:U7 只暴露 dispatcher+sourceMode → staging 仍跑 openai 默认协议 ≠ U5 量测的 anthropic。U7 加暴露 `saa-model-protocol` + R1 证据记在用协议(量==发)。(见 U7 / R2) +- **P1-7(T1 回退是 flip 硬前置,非可延 TODO)**:回退路效力依赖 Plan B 域代码;T1 失败 → step-2 flip **阻塞**(跨 Plan 关键依赖)。(见 U7 / R-fallback) +- **P2**:yaml 合并 config-presence 断言(U7);client 侧 `budget **≥80% 口径警示(评审 P0-2)**:本门是**机制门**(九门验引擎调用/进度/控制),**对资产渲染与"好玩"盲**(gamedef 装配 strip 掉 assets、运行时仅画图元)。≥80% 仅证"机制可玩",**不证产品级质量**——资产/玩法完整性由 core-done(Plan B R9/R11,003-U5)把守。flip 不可解读为"gamedef 已达产品质量"。 + +> 上游权威:plan003 `003-U1/U2` + 根因分析 v3 + phase1 验收闭环 R1/R2/T1/T2。本档 = 其 **HOW 执行层**,质量域续做、不另起平行 prompt。 + +--- + +## 问题背景(代码核证 · 含发现) + +> 行号锚点为 2026-06-19 dev/2.0.0 rebase 后快照;执行期以类/方法名为准。 + +- **三层用法一致地"用错 M3"**:Java `SaaStudioNodes.callAndRecord`(~235-248)`getText()` 丢 thinking、每轮全新 `[SystemMessage,UserMessage]` 零历史;`SaaPrompts.buildMessages`(~348-356)两元 + "重出完整模块不要 diff";`repairNode`(~764-783)不回传上一版源(a3 未做);Python `_client.chat` 显式 `thinking:disabled`(用错的反面)。 +- **🔴 发现①:M3 双协议**。new-api `/anthropic` `/v1/messages` 返原生 `['thinking','text']` 块 + tool_use(探针证);OpenAI 默认路 thinking 内联撑爆截断。 +- **🔴 发现②:e2e 门当前量错路**。`SaaFullGraphE2eTest`(~203-211)构造 props **未 set `saaSourceMode`** → 默认 `factory`(iife),故现 60%(n=5)**是 iife 旧路、非 gameDefinition**。dispatcher=saa 单切仍走 iife,须同时 `saaSourceMode=gamedef`。 +- **🔴 发现③:整图串行 + 并发会污染门 + 共享面 > 端口**。`SaaGraphDispatcher`:`newSingleThreadExecutor`(~101)+ `Semaphore(1)`(~113)包整条 `graph.invoke`(~268 acquire→~275 invoke→~296 release)。串行因 = 九门 `serve-and-play.sh` 绑固定端口 4320/9222。play 节点(`SaaGenNodes` ~454-456)**已从 state 读 port/cdpPort**、`buildInputs`(~399-400)注入(现硬编码 4320/9222)。**但并发共享面 > 端口**(评审 P0-3):`serve-and-play.sh:44` 服整 runtime 根、`_shared/` 模板共读、`/tmp/wg1-*-$PORT.log` 可撞、`lsof|kill` 净场 port-keyed(同端口误杀兄弟)。 +- **🔴 发现④:gen-done 量测执行 Plan B 两文件**(评审 P0-1):`SaaGenNodes.java:283` 子进程跑 `build-from-source.mjs`;产物 `build-from-source.mjs:34` 硬 import `gd-runtime.js`;`build-from-source.mjs:32` `GAMEDEF_KEYS=['entities','components','behaviors','scenes','rules']`、`gd-runtime` `drawEntity` 原仅 circle/fill/rect、**无 sprite**。**【2026-06-19:Plan B U1(`db14845d`)已修——assets 改走 `sourceProject` 顶层抽取(非 strip)、`drawEntity` 加 `shape:'sprite'`→`drawImage` 受控面渲染;故运行时已能渲资产,唯九门仍不验资产对位。】** +- **🔴 发现⑤:checkpoint 序列化潜伏**(评审 P0-5):图用 `SpringAIJacksonStateSerializer`(2 参 `StateGraph` ctor);`MysqlSaver` 序列化整 OverAllState 含历史键;e2e `saaCheckpointEnabled=false`+dataSource=null(~211/229)→ **量测从不触发序列化**;staging 默认 `saaCheckpointEnabled=true`(~190)→ 首 checkpoint 写/续跑才序列化新历史键。 +- **flip 当前无 env 钮**:`application-staging.yaml`(~173-185)只暴露 `enabled/api-key/worker-url/callback-secret`,**无 dispatcher/saa-source-mode/saa-model-protocol/llm-base**;`dispatcher` 默认 `http`(~160)、`saaSourceMode` 默认 `factory`(~198)、`llmBase` 默认 `http://100.64.0.8:3000`(~68);`AigcGenerateExecutor`(~151-152)`useSaa = "saa".equals(dispatcher) && saaDispatcher!=null`。`AigcExecutorProperties` 为 `@ConfigurationProperties("aigc.executor")`+`@Data`(props 已绑定,flip 是验证非新代码)。 + +--- + +## 可行性结论(外部 + 代码核证 · U-A/B/C 去险) + +**FEASIBLE**: + +- **依赖**:加 **bare `org.springframework.ai:spring-ai-anthropic`(无 version,`spring-ai-bom:1.1.2` 托管 @1.1.2)**,**非** `-starter`(避 `AnthropicAutoConfiguration` 抢注 bean)。**零 bean 冲突**(模块内无 `@Bean ChatModel`,模型全手装)。`mvn dependency:tree` 须证**无** `spring-ai-autoconfigure-model-anthropic` 传递入 + 无 `agentscope-core` + 无 spring-ai 漂离 1.1.2。 +- **协议(2026-06-19 spike 在 1.1.2 jar + lili-mac 实测定死)**:`AnthropicChatModel` → `baseUrl=http://100.64.0.8:3000`(**host 根、不带 `/anthropic`**;带前缀命中 new-api SPA catch-all 返 HTML→假挂起。`completionsPath` 默认 `/v1/messages`,**不复用 `stripV1Suffix`**);鉴权 **默认 `.apiKey()`(x-api-key),绝不 Bearer**(实测 x-api-key→`getResults().size()==2` 原生 thinking〔signature 键标识〕;Bearer→内联 `` 污染);代理 `Proxy.NO_PROXY` 经 `AnthropicApi.restClientBuilder`(**1.1.2 无 `customHeaders` 方法**);`thinking(ThinkingType.ENABLED, budget`(thinking 先序)。`getMetadata()` 的 `thinking`/`signature`(皆 String)回读。 +- **序列化(P0-5)**:`AssistantMessage` 经 `SpringAIJacksonStateSerializer` 可往返,但 metadata 写为无类型 JSON、读回为 Map(thinking/signature 皆 String 可活);**`List` 容器须经 `GenericListDeserializer` 保元素类型**(否则 `List` → 回放给 `AnthropicChatModel` 炸)。Anthropic 产 base `AssistantMessage`(非子类,通用 handler 适配)——须 checkpoint-ON 测试坐实。 +- **工具循环**:`DefaultToolCallingManager` + `while(hasToolCalls()) executeToolCalls(...)` 进单 `NodeAction`(无 agent-framework)。 +- **必修三险**:① ~~认证头方言~~ **【spike 已解 2026-06-19】鉴权锁 x-api-key(默认);Bearer 反致 thinking 内联 ``(非 401 问题,是响应形)**;② 流式 thinking bug #4407 → 只 `.call()`;③ 历史保真 → 新 client、**禁复用 `ExecutorLlmClient.normalizeJsonContent` 串扁平**。 +- **版本注**:本地 m2 仅有 anthropic 1.1.5(多 Generation 形从 1.1.5 反编译);1.1.2 首构网络拉取,**spike 须在实解 1.1.2 jar 上证多 Generation 形**(`resp.getResults().size()≥2`)。 + +--- + +## 关键技术决策(KTD) + +1. **新 client + flag 旁挂,旧 OpenAI 路零动**:Anthropic 路新建,经 `aigc.executor.saa-model-protocol`(默认 `openai`)切;默认值字节零变、可瞬时回退。不改非 thinking 角色旧路。 +2. **历史保真是地基(多 Generation + 有界 + 可序列化)**: + - **多 Generation 重组**:遍历 `resp.getResults()` 取有序块;答案取 text-role;历史项 = 每轮有序 `List`(thinking 先序,符 Anthropic"assistant 轮以 thinking 起再 tool_use"铁律)。 + - **存储策略**:`OverAllState` 加历史键,**用 `ReplaceStrategy` + 节点内 read-append-write**(对齐 `appendAttempt`,不破 `ALL_KEYS`→ReplaceStrategy 不变量;P1-4)。 + - **有界 thinking-aware 截断(P1-3)**:保最近 K 轮完整 thinking、更早轮**降级保留 text+verdict 摘要、丢其 thinking**(守 token/成本,且保"thinking 先序"不变量);解 preserve-vs-bound 张力,非无界 append。 + - **可序列化(P0-5)**:历史键须经 `SpringAIJacksonStateSerializer` 往返保 `List` 类型 + thinking/signature;checkpoint-ON 测试守。 +3. **并发 = 有界资源池、K 为正确性参数(非提速旋钮)**: + - **池非锁单改(P1-5)**:`newSingleThreadExecutor`→有界池(size K) **且** `Semaphore(1)`→`Semaphore(K)` 守端口池;二者须同改(单线程下 Semaphore(K) 无效)。 + - **per-job 端口(P1-5)**:acquire 后 → `inputs.put("port",base+2i)`/`("cdpPort",base+i)` 替 `buildInputs` 常量;**`finally` 随 `playLock.release()` 释放**防超时/异常泄漏(180s 超时频发)。 + - **共享面审计 > 端口(P0-3)**:开 K>1 前审 `serve-and-play.sh` 的服根/`_shared/` 共读/`/tmp/*-$PORT.log`/`lsof|kill` 净场;端口池**整 job 生命期持槽**(净场不跨 job)。`serve-and-play.sh` 复用不改(harness 禁重写铁律);其每调 `mktemp -d` Chrome profile = 安全(已证)。 + - **K=正确性 + 双校准门**:保每条真玩实时(否则 `C_frame`/`E_live` 掉帧假阴);① 同 brief 集 K=1 vs K=N verdict 对等(时序);② **同款重复 ×N 全过一致**(结构竞态,P0-3)。**K=1 为记录 R1 基线权威,直至结构隔离证毕**。lili-mac(M1 Pro 8 核/32G、负载~6.6)K≈3-4。 +4. **量测路口径锁死 gameDefinition + 机制门警示**:e2e 暴露 `-Dsaa.e2e.sourceMode=gamedef`,R1 一律 gamedef。**但 ≥80% 仅证机制(九门对资产盲,P0-2)**,非产品质量;资产/好玩归 core-done R9/R11。 +5. **measure-first 闸控 U-C + 并行去险(P1-1)**:先 U-A/U-B + 8/8 driver → 跑有界并发基线 + Workflow-Opus 分析 → 收敛;**U-C(模型驱动工具循环)仅基线优化后仍 <80% 才全建**(守"自治在门内、裁决在门外")。**但与 U5 并行先跑 U-C 廉价 spike**(throwaway 探"M3 能否在本任务 写源→build→跑门→读 verdict→改 一轮工具循环",解 R-6 未证),把晚长杆从"未知可行"降为"已知可行待接线"。触发线预定:**≥2 轮单变量 A/B(同组 n≥10)仍 <80% 且根因指向"需模型自纠"**才全建 U-C。 +6. **Workflow-Opus = 收敛引擎、跑 6c6g**:全量 evidence(九门 verdict + 保留 thinking + 源 + 截图 + giveup-dump)经 Workflow fan-out(每失败款一 Opus agent 根因 → 综合排序优化)。Workflow 须 opt-in(创始人本轮指令即 opt-in),执行期触发。 +7. **flip 两步 + 协议同切 + 回退硬前置(P1-6/P1-7)**:① staging yaml 暴露 `dispatcher`/`saa-source-mode`/**`saa-model-protocol`**(`-D` 注入、默认不变);② `.env` 设 `saa`+`gamedef`+`anthropic` 切默认。**R1 证据记在用协议(量测==发布)**。**T1 回退验过 = step-2 硬前置**:失败(须改 gd-runtime/build-from-source)→ step-2 阻塞、列跨 Plan 关键依赖(非可延 TODO)。回退 = flag 切回 `http`(iife 主链 M2 80% 已证)。 +8. **运行时版本钉 + Plan B 冻结协调(P0-1 最高价值修)**:gen-done 量测执行 `gd-runtime.js`+`build-from-source.mjs`。**每轮基线/A-B 前钉二者 content-hash(git SHA),收敛窗口内 Plan B 对其变更 = 量测失效/重置基线事件**;与 Plan B 约定收敛窗口"不合 gd-runtime/build-from-source"冻结。一钉同时中和 P0-1 混淆 / P0-3 幻象基线 / P1-2 基底漂移。 + - **当前基底(2026-06-19)**:Plan B **U1 已推**(`db14845d` on `feat/mac-planB-coredone`,含引擎资产渲染 + asset-orphan 闭合 + 已过 ce-code-review 整改),**尚未合 `dev/2.0.0`**(仍 `0518d245`)。U1 = 我收敛的运行时基底。 + - **协调动作(推荐,由 6c6g/Plan B owner 驱动,我不擅合 mainline)**:U1 = 已完成的运行时单元 + 双线共享基底 → **先合 `dev/2.0.0` 并在我收敛窗口冻结**,我再 rebase 钉之 = 干净基底;优于我 rebase 到 Plan B 在飞分支(耦合其分支漂移)。**规划期我不需运行时在树**(量测在 ce-work/U5),故现暂不 rebase,待 U1 落 `dev/2.0.0`。 + +--- + +## 高层技术设计(HTD) + +### 单元依赖 DAG + 收敛环 + +```mermaid +flowchart TB + subgraph FOUND["地基(用对 M3)"] + U1["U1 Anthropic 协议 M3 client(U-A)
flag 默认 openai · 多 Generation 重组"] + U2["U2 thinking 历史保留(有界+可序列化) + 连续救场(U-B)"] + U1 --> U2 + end + subgraph HARNESS["量测地基"] + U3["U3 gamedef 量测 + 有界并发池 harness
sourceMode=gamedef · pool(K) · 双校准 · 共享面审计"] + U4["U4 8/8 driver 决策树 + 上下文 + gatespec 约定"] + end + subgraph LOOP["收敛环(measure-first · 运行时钉)"] + U5["U5 量测→Workflow-Opus 根因→优化→重测 至 ≥80%
+ U-C 廉价 spike 并行去险 + 留出集 + 基线定标复核"] + end + U6["U6(闸控)模型驱动工具循环(U-C)
仅 U5 ≥2 轮 A/B 仍<80% 才全建"] + U7["U7 两步 flip + 协议同切 + 回退硬前置(003-U2)"] + U2 --> U5 + U3 --> U5 + U4 --> U5 + U5 -. spike .-> U6 + U5 -- "<80%(满足触发线)" --> U6 + U6 --> U5 + U5 -- "≥80%(留出集)" --> U7 + U7 -. 产出消费 .-> PB["Plan B core-done 门"] + PBfreeze["Plan B: gd-runtime/build-from-source
收敛窗口冻结(KTD8)"] -. content-hash 钉 .-> U5 +``` + +### 收敛环时序(U5) + +```mermaid +sequenceDiagram + participant Pin as 运行时钉(KTD8) + participant Run as 有界并发跑(K 池) + participant Ev as evidence 语料 + participant WF as Workflow(Opus) 6c6g + participant Dev as 优化落地(U2/U4 + 调参) + Pin->>Run: 钉 gd-runtime+build-from-source content-hash(变更则重置基线) + Run->>Ev: n≥30 sourceMode=gamedef(mini-desktop 权威 K=1 记数 / Mac 快走查 K≈4 筛) + Ev->>WF: 每失败款一 Opus agent 根因(门/thinking/源/截图) + WF->>Dev: 综合聚类 → 排序优化项 + Dev->>Run: 改单变量 → 重测(同组 n≥10 A/B) + Note over Run,Dev: 循环至 ≥80%(留出集);≥2 轮仍<80%→U6 工具循环 / 退路 +``` + +### flip + 回退状态(U7) + +```mermaid +stateDiagram-v2 + [*] --> http默认: 现状(dispatcher=http,iife,openai) + http默认 --> 隔离验: 步① yaml 暴露 env + -D 注入 :48090(saa+gamedef+anthropic, checkpoint 开) + 隔离验 --> T1回退验: 隔离全过(health/端点/真玩/asset 烟测)+ smoke 绿 + T1回退验 --> 切默认: T1 过(存量 engineBundle 切 http 仍可玩) + T1回退验 --> 阻塞: T1 失败(须改 Plan B 域)→ 跨 Plan 关键依赖 + 隔离验 --> http默认: 隔离 fail(零改 live) + 切默认 --> [*]: 步② .env=saa+gamedef+anthropic 重部署 + smoke + 切默认 --> http默认: smoke 红/失败率超阈 → flag 切回(回退预案) +``` + +--- + +## 实现单元 + +> 骨架级:定 目标/范围/文件/验收。质量域 file 级逐字由 owner 在 U2/U4/U5 续做。测试机:lili-mac(快走查)+ mini-desktop(权威门)。 + +### U1. Anthropic 协议 M3 client(003-U1 U-A) + +- **Goal**:接入"经 Anthropic `/v1/messages` 调 M3、thinking 开+干净分离、多 Generation 正确重组"的新 ChatModel,flag 旁挂(默认旧 OpenAI 路、字节零变)。 +- **Requirements**:R1 地基;003-U1 U-A。 +- **Dependencies**:无。 +- **Files**:`pom.xml`(加 bare `spring-ai-anthropic`);`.../saa/SaaStudioGraph.java`(新 `anthropicModel(...)` 工厂 + Anthropic base-url 推导,不复用 `stripV1Suffix`);`.../saa/SaaGraphDispatcher.java`(`ensureGraph` api 构造 ~316-317 旁挂 Anthropic 分支按 flag 选);`.../service/executor/AigcExecutorProperties.java`(加 `saaModelProtocol`(默认 `openai`)/`saaAnthropicBase`(默认 `http://100.64.0.8:3000` **host 根、不带 /anthropic**)/thinking budget)。 +- **Approach(spike 实测定死)**:`AnthropicApi.builder().baseUrl("http://100.64.0.8:3000")`【host 根、不带 /anthropic】`.apiKey(NEWAPI_KEY)`【默认 x-api-key】`.restClientBuilder(…requestFactory(SimpleClientHttpRequestFactory.setProxy(Proxy.NO_PROXY)+超时))`;`AnthropicChatOptions.thinking(ThinkingType.ENABLED, budget).maxTokens(16000).model("MiniMax-M3")`(**client 侧 fail-fast 断言 `budget`)。flag=openai 时不构造、单 Gen 行为字节不变。 +- **Patterns**:现 `SaaStudioGraph.model()` 工厂 + dispatcher api-build 缝;NEWAPI_KEY 取 `docs/内网凭据与端点.md`。 +- **Test scenarios**: + - happy(多 Generation):flag=anthropic 单条 `.call()` 返 `getResults().size()≥2`;text-role Generation 含答案、thinking Generation 含 `getMetadata().thinking` 非空。 + - 认证头(spike 实测):默认 x-api-key → `getResults().size()==2` 原生 thinking block(含 signature gen);Bearer → 内联 ``(弃用)。 + - fail-fast:`budget≥maxTokens` 时 client 构造即抛(不下发网关)。 + - 回归:flag=openai(默认)`mvn -pl game-module-aigc-server -am -DskipTests compile` 绿 + OpenAI 路字节等价;`mvn dependency:tree` 无 `spring-ai-autoconfigure-model-anthropic`/无 `agentscope-core`/无 spring-ai 漂离 1.1.2。 +- **Verification**:依赖树无漂移;默认 flag 零回归;anthropic flag 单调返 ≥2 Generation 含 thinking(日志 + gated 集成测试)。 + +### U2. thinking 历史保留(多 Gen + 有界 + 可序列化)+ 连续救场(003-U1 U-B) + +- **Goal**:跨轮保留**完整有序模型响应**入历史(有界、可 checkpoint 序列化),救场改连续对话增量修。 +- **Requirements**:R1;003-U1 U-B;根因 v3 §3①。 +- **Dependencies**:U1。 +- **Files**:`.../saa/SaaStudioNodes.java`(`callAndRecord` ~235-248 / `judgeVision` ~673-707:**遍历 `getResults()` 重组**、存有序 `List`、答案取 text-role;`generateNode`/`repairNode` 读写历史);`.../saa/SaaPrompts.java`(messages 改连续对话形;救场 prompt 去"重出完整模块"、改"针对反馈增量修正");`.../saa/SaaStudioGraph.java`(历史键登记 + **节点内 read-append-write**,不进 `ALL_KEYS` 全量 ReplaceStrategy 误判)。 +- **Approach**:`OverAllState` 加 `K_MSG_HISTORY`(List of 每轮有序消息组,thinking 先序);每轮 = 截断后历史 + 新 user(门 verdict/反馈);救场不重置历史、续上一轮 assistant 完整响应 + 失败 verdict。**有界**:保最近 K 轮完整 thinking、更早降级(text+verdict 摘要、丢 thinking),守 token/成本 + thinking 先序不变量。**gamedef 路与 iife 路各自历史键**。禁复用 `ExecutorLlmClient` 串扁平。 +- **Patterns**:`appendAttempt`(~298 节点内 append 范式);`SpringAIJacksonStateSerializer` 的 `AssistantMessageHandler`。 +- **Test scenarios**: + - happy:连续两轮救场,第二轮含第一轮 assistant 完整响应(thinking 块未改/未丢)。 + - **深度(P1-3)**:max-repair 深度(5+3=8 轮)至少一次,thinking 先序不变量保持、API 不拒。 + - **序列化(P0-5)**:构 `MysqlSaver`(或 `MemorySaver` 经 `dataToBytes`/`dataFromBytes`)跑一 thinking 轮 → 强制序列化→反序列化 → 断历史仍 `List`、thinking+signature 完整、可再下发。 + - **并发隔离(P0-3 联)**:两并发 run(一 gamedef 一 iife)历史键互不串。 + - 增量修:仅 H_progress 未过的源 + verdict,救场不打翻已过部分(diff 局部)。 + - token 追踪:U5 evidence 记**累计** token(含复发 thinking)。 + - 回归:iife 路(flag=openai)历史行为不变。 +- **Verification**:checkpoint-ON 序列化往返测试绿 + ≥2 轮且深度 8 thinking 保真 + 并发隔离 + 增量性人工抽检。 +- **Execution note**:先写"序列化往返保真"+"历史保真 ≥2 轮"失败测试再实现(最易静默回归 + 最易在生产才炸)。 + +### U3. gameDefinition 量测 + 有界并发池 harness + +- **Goal**:让 e2e 门量对路(gamedef)且**有界并发可量**(K 为正确性参数),把 n≥30 从串行 ~4-8h 压到 ~1-1.5h,且不污染门、无结构竞态。 +- **Requirements**:R1(可客观运行硬门);发现②③。 +- **Dependencies**:无(与 U1/U2 并行可起)。 +- **Files**:`.../saa/SaaGraphDispatcher.java`(`graphExecutor` 改有界池(K) **且** `Semaphore(1)→Semaphore(K)` + 端口池;per-job `port`/`cdpPort` 注入替 `buildInputs` ~399-400 常量;`finally` 释放槽);`.../service/executor/AigcExecutorProperties.java`(加 `saaConcurrency` 默认 1 / `saaSourceMode` 暴露);`.../src/test/java/.../saa/SaaFullGraphE2eTest.java`(加 `-Dsaa.e2e.sourceMode`(默认 factory,量基线设 gamedef)/`-Dsaa.e2e.concurrency`(默认 1)/briefFile≥30);测试资源 `.../resources/saa-e2e-briefs-gamedef.txt`(≥30 条多品类 + **冻结钉 content-hash**)+ `saa-e2e-briefs-holdout.txt`(留出集,不进 A/B)。 +- **Approach**:端口池 = 基址 + 槽位(`4320+2i`/`9222+i`),`Semaphore(K)` 守池、acquire 拿空闲槽 → play 用该槽端口、**整 job 生命期持槽**(净场不跨 job)。`saaConcurrency` 默认 1 = 生产字节等价。**共享面审计**:开 K>1 前确认 `serve-and-play.sh` 服根/`_shared/` 共读只读安全、`/tmp/*-$PORT.log` 不撞、`lsof|kill` 不跨 job(`serve-and-play.sh` 复用不改)。**双校准门**:① 同 brief 集 K=1 vs K=N verdict 对等;② 同款重复 ×N 全过一致(结构竞态)。 +- **Patterns**:`SaaGenNodes` ~454-456 已读 state 端口;lili-mac `gamedef_quickcheck` per-game 端口错开(`QC_CONCURRENCY` 默认 4)实证。 +- **Test scenarios**: + - happy:`-Dsaa.e2e.concurrency=4 -Dsaa.e2e.sourceMode=gamedef` 4 并发各异端口各产 verdict、无撞。 + - 校准①:同 6 款 K=1 vs K=4 verdict 对等(尤 `C_frame`/`E_live`)。 + - 校准②(结构隔离):同款重复 ×4 全过一致(无间歇竞态)。 + - 量对路:`sourceMode=gamedef` 真走 build-from-source(gamedef)非 iife。 + - 槽释放:play 超时(180s)/异常路端口槽 `finally` 释放、不泄漏。 + - 回归:默认(concurrency=1/sourceMode 未设)字节等价;`SaaFullGraphE2eGateLogicTest` 12/12。 +- **Verification**:端口分配/校准纯逻辑抽静态方法 + 模型无关单测;lili-mac 快走查证无撞 + 双校准过;**K=1 为记录基线权威直至结构隔离证毕**。 +- **Execution note**:default=1 先证字节零回归再开 K>1。 + +### U4. 8/8 driver 决策树 + 上下文 + gatespec 约定(003-U1 ③+②) + +- **Goal**:补齐 driver 词汇下半 + 失败品类范例 + 门约定修对,512K 窗喂全。 +- **Requirements**:R1;根因 v3 §3②③。 +- **Dependencies**:无。 +- **Files**:`.../saa/SaaPrompts.java`(`DESIGN_SYSTEM` ~240-265 + `GAMEDEF_SYSTEM` ~147-235)。 +- **Approach**:现 5 driver(后二由在飞 `804e7617` 补)→ **补 `tap-pairs`/`key-cycle`/`drag-aiming`/`aim-fire` 至 8/8**;每 driver worked example(内联金样 `gd-archetypes.test.mjs:34-85`)+ 反例;**命门**:障碍跟踪类须显式产 `nextGap`/`nextPlatform` 等命名实体逐帧更新。**门约定(不改门本体)**:H/F 消除类用 `tap-pairs`+`score`;I_control 按 control-scheme 分型;`remaining` 语义经 `DESIGN_SYSTEM` java 范例(**不碰 gd-runtime**)。 +- **Patterns**:在飞 `804e7617`;`play.cdp.cjs` 8 driver(~207-214);金样 `match3` tap-pairs+score 过 H。 +- **Test scenarios**:编译绿(长串无破坏);离线 `extractGatespec` 对 8 driver 各样例解析正确;真选对率随 U5 量。 +- **Verification**:编译绿 + gatespec 解析单测;真效随 U5 闭环。 + +### U5. 收敛环:量测 → Workflow-Opus 根因 → 优化 → 重测 至 ≥80% + +- **Goal**:有界并发量测 + 多 agent 根因,数据驱动把 gamedef 路过门率收敛到 ≥80%(留出集证)。**本里程碑收敛引擎。** +- **Requirements**:R1;验收闭环 §6;根因 v3 §6。 +- **Dependencies**:U1+U2+U3(+U4)。 +- **Files**:Workflow 脚本(执行期落 session 目录,达标蒸馏 `.agents/skills/`);复用 U3 brief 文件 + evidence harness;无新生产代码。 +- **Approach**: + 1. **运行时钉(KTD8)**:每跑前钉 `gd-runtime.js`+`build-from-source.mjs` content-hash;收敛窗口 Plan B 变更 = 重置基线。 + 2. **量基线(机器分工 2026-06-19 创始人放宽:Mac 能重载不休眠→主力批量验证;mini-desktop 只出 final evidence)**:`-Dsaa.e2e=1 -Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.n≥30 -Dsaa.e2e.concurrency=K`——**lili-mac 跑批量收敛量测 + A/B 多轮**(有界 K,能重载不休眠);**mini-desktop 只出最终 ≥80% 签核 final evidence**(x86 prod-同构,标 minSuccessRate 旗 + 在用协议 + 运行时 hash + 留出集)。 + 3. **基线定标复核(P1-2)**:首次干净 gamedef 基线后复核 80% 是否本路合适数(已 ≥85% → 门失信、转 rubric;~55% 有运行时天花板 → 早走退路)。 + 4. **U-C 廉价 spike(P1-1)**:与本环并行跑 throwaway U-C 探针(M3 单轮工具循环可行性),去 R-6 险。 + 5. **Workflow-Opus 分析**(6c6g,opt-in):每失败款一 Opus agent 根因 → 综合排序优化项。 + 6. **优化落地**:改单变量、同组 n≥10 A/B。 + 7. **重测 → 循环**至 ≥80%(**留出集 final 测,非调优集**);≥2 轮仍 <80% 且根因指向需自纠 → U6;天花板 → 退路。 +- **Patterns**:根因 v3「单变量 A/B 同组 n≥10」;效率策略 #8(Workflow 脚本);6c6g 角色。 +- **Test scenarios**:Test expectation: none —— 方法论/编排单元,其"验证"= R1 门(≥80% 实测,留出集)。 +- **Verification**:mini-desktop `SaaFullGraphE2eTest -Dsaa.e2e.minSuccessRate=0.8 -Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.n=30`(旗已设、协议记录、留出集)实测 ≥80%;运行时 hash 钉记录;各轮成功率/逐门/累计 token 留痕。 + +### U6.(闸控)模型驱动工具循环(003-U1 U-C) + +- **Goal**:仅 U5 ≥2 轮 A/B 仍 <80% 且根因指向需自纠才全建——给 M3 工具(写源/build/跑九门/读 verdict/改),交错思维迭代。 +- **Requirements**:R1;003-U1 U-C;measure-first 闸控触发。 +- **Dependencies**:U5(触发线满足)+ U5 的 U-C spike(已证基本可行)。 +- **Files**:`.../saa/SaaStudioNodes.java`(新 react 式节点);`.../saa/SaaPrompts.java`(工具定义);`.../saa/SaaStudioGraph.java`(按 flag 接)。 +- **Approach**:`DefaultToolCallingManager` + `while(hasToolCalls()) executeToolCalls(...)` 进单 `NodeAction`;**九门作工具但 verdict 恒确定性**(模型不得自评过门 = "自治在门内、裁决在门外");`recursionLimit`+计数器封顶(控墙钟,每 run-gates 3.5-16min)。flag 旁挂、默认不启。 +- **Patterns**:Spring AI Tools「user-controlled execution」;`saa-graph-orchestration.md`「自治在门内裁决在门外」。 +- **Test scenarios**:happy(模型一轮 build→run-gates→读 verdict→改、结果回灌历史 thinking 顺序不破);确定性铁律(同款两次 run-gates verdict 一致;模型自称过但 verdict=fail 仍判 fail);封顶(达 recursionLimit 优雅 giveup)。 +- **Verification**:gated 集成测试证工具循环 + verdict 确定性;并入 U5 量 ≥80% 增益。 + +### U7. 两步 flip + 协议同切 + 回退硬前置(003-U2) + +- **Goal**:质量达 ≥80% 后两步把默认从 `http`(iife,openai) 切 `saa`(gamedef,anthropic),含回退实证。 +- **Requirements**:R2;T1(回退硬前置);T2(两步分离)。 +- **Dependencies**:U5/U6 实测 ≥80%(留出集)。 +- **Files**:`game-cloud/huijing-server/src/main/resources/application-staging.yaml`(~173-185 加 `dispatcher: ${AIGC_EXECUTOR_DISPATCHER:http}` + `saa-source-mode: ${AIGC_SAA_SOURCE_MODE:factory}` + **`saa-model-protocol: ${AIGC_SAA_MODEL_PROTOCOL:openai}`**;**默认全不变**;`llm-base` **仅需 per-env 覆盖才加**否则去噪);`.../AigcExecutorProperties.java`(确认 env 绑定,@Data 已绑=验证非新代码)。 +- **Approach(两步,T2 分离)**:① **仅暴露 env** + `-D` 注入隔离实例 `:48090` 验 saa+gamedef+**anthropic**(**checkpoint 开**=首次真触序列化路 P0-5)+ **asset-orphan 最小烟测**(P0-2:至少一 sprite ref 可渲,即使全 R9 留 core-done),live `:48080` 零动(staging-ops §3);② **正式切默认**(staging `.env` 设三键,重部署 + `deploy/smoke-test.sh` 绿)。**R1 证据记在用协议(量==发)**。回退 = flag 切回 `http`。 +- **Patterns**:`staging-ops.md §3`(隔离 `-D` 注入、整机重部署安全变体);smoke 五链路。 +- **Test scenarios**: + - happy:隔离实例 saa+gamedef+anthropic(checkpoint 开)一句话生成→入 feed→真玩 ≥1 款 CDP。 + - asset 烟测(P0-2):step-2 前至少一 sprite ref 在 play 截图可见(非"画面非空");不过 → step-2 不切。 + - 协议一致(P1-6):切后 `saa-model-protocol=anthropic` 生效(非 openai 默认);R1 证据协议字段=anthropic。 + - yaml 合并(P2-1):合并后 `aigc.executor` 块含两 Plan 全部期望键(config-presence 断言)、缩进合法。 + - 零回归:现 http 主链 smoke 五链路绿。 + - **T1 回退(硬前置)**:flip 后存量 SAA 产 engineBundle 切回 `http` 仍经 runtime/package 取包 + feed 真玩 ≥1 款 CDP。**失败(须改 gd-runtime/build-from-source)→ step-2 flip 阻塞、列跨 Plan 关键依赖(非 TODO)**。 + - T2 分离:步①只暴露 env、live 默认仍 http。 +- **Verification**:隔离全过(含 checkpoint 开 + asset 烟测)→ T1 过 → 切默认 → smoke 绿;DB/feed 行级核。 + +--- + +## 验收门(done 判据 · R1/R2) + +- **R1(gen-done 核心 · 机制门口径)**:mini-desktop `SaaFullGraphE2eTest -Dsaa.e2e=1 -Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.n≥30 -Dsaa.e2e.minSuccessRate=0.8`(默认不带旗仅断 ≥1、不可作门;证据须打印 minSuccessRate 旗 + **在用协议=anthropic** + **运行时 content-hash** + **留出集标记**)实测 **≥80%**。**≥80% 仅证机制可玩(九门对资产/好玩盲,P0-2),非产品质量**——资产/玩法归 core-done R9/R11。与 smoke 互不替代。 +- **R2**:默认 `dispatcher=saa`+`saaSourceMode=gamedef`+**`saaModelProtocol=anthropic`**(量==发),一句话经 SAA 路生成→入 feed→真玩;worker 主链零回归;回退路验过(T1 为 step-2 硬前置)。 +- **gen-done = R1 ∧ R2**,独立里程碑、独立冻证。 + +--- + +## 范围边界 + +- **含**:003-U1(用对 M3 + 8/8 driver + 量测 harness + 收敛环)+ 003-U2(两步 flip + 协议同切 + 回退 + T1/T2)。 +- **不含(铁律)**:**编辑** `gd-runtime.js`/`build-from-source.mjs`/引擎资产渲染(= Plan B 003-U5);前端真验证/广度抽检/**资产渲染质量 rubric R9/R11**/M0 联合验收/最终 Phase-1 验收(= Plan B core-done)。**注**:gen-done 量测**执行**(非编辑)Plan B 两文件,故 ≥80% 是机制门、且需 KTD8 运行时钉。 +- **暂缓(次要 ④)**:best-of-N 生成、**生产侧多 job 并发**(生产 dispatcher 去串行是另一决策,带 split-brain/资源权重)——`saaConcurrency` 默认仍 1,本档只为**量测**开有界并发。 +- **退路(触发才走)**:升 `deepseek-v4-pro`/M3 强档 / Claude premium(质量优先记账);降九门判据/改判 Phase-1 门 = 末选创始人裁。 +- **worker `prompt.py` GAMEDEF_SYSTEM**:gen-done 不需(saa 路=量测/达标路、http=iife 回退路)→ 本档不补。 + +--- + +## 风险与依赖 + +- **R-1 认证头方言【spike 已解 2026-06-19】**:实测 **x-api-key(默认)→ 2-Gen 原生 thinking block**;Bearer→内联 `` 污染(弃)。非 401 问题、是响应形——U1 锁定 x-api-key。险已消。 +- **R-2 流式 thinking bug #4407 + thinking/tool_use 顺序**:只 `.call()`;多轮原样回放有序 `List`;深度 8 测试。 +- **R-3 历史保真回归**:Anthropic 新 client 全程类型化消息;U2 先写保真失败测试。 +- **R-4 并发污染门 + 结构竞态(P0-3)**:K=正确性参数 + 双校准(时序对等 + 同款×N 一致)+ 共享面审计;超阈调小。 +- **R-5 算钟瓶颈**:K=4 下 n≥30 ~1-1.5h,A/B 多轮累积;mini-desktop 权威单实例串行排队。缓解 = Mac 快走查先筛、Workflow-Opus 减盲改轮。 +- **R-6 M3 工具-use 可靠性未实证**:U5 并行 U-C 廉价 spike 先证可行(P1-1)。 +- **R-7 ≥80% 可达性 + 标的未验(P1-2)**:gamedef 真基线 n=0(n=5 量错路);brief 集偏易自控(Goodhart)。缓解 = 基线定标复核 + brief 冻结钉 + 留出集 final 测 + 退路预置。 +- **R-8 Anthropic 多 Generation 形(P0-4)**:block0=thinking、`getResult()` 取错 → 抽取必败。缓解 = 遍历 `getResults()`、答案取 text-role、spike 证 `size()≥2`。 +- **R-9 checkpoint 序列化潜伏(P0-5)**:e2e harness checkpoint-off 隐藏序列化路 → staging(checkpoint 默认开)才炸。缓解 = U2 checkpoint-ON 往返测试 + U7 隔离验 checkpoint 开。 +- **R-10 运行时基底漂移(P0-1)**:gen-done 量测执行 Plan B 两文件,Plan B 收敛窗口改之 → A/B 混淆。缓解 = KTD8 content-hash 钉 + 收敛窗口冻结协调。 +- **跨 Plan 协调(与 Plan B 单向耦合)**: + - **gd-runtime/build-from-source 冻结(P0-1/R-10)**:收敛窗口内 Plan B 不合并这两文件;每轮钉 hash。 + - `SaaFullGraphE2eTest`:本档**扩**(additive `-D` 旗/并发/sourceMode),Plan B **消费**(R4)→ 改动严格 additive。 + - `application-staging.yaml`:本档加 dispatcher/saa-source-mode/saa-model-protocol,Plan B U6 加 smoke 配置 → 同文件不同键,**合并须 config-presence 断言**(P2-1)、push 串行 `git ls-remote` 核。 + - **T1 回退依赖 Plan B 域(P1-7)**:T1 失败需改 gd-runtime/build-from-source → step-2 flip 阻塞、跨 Plan 关键依赖。 + - mini-desktop e2e **串行排队**:跑前确认无 Plan B 占用端口 4320/9222(flock 单实例)。 +- **依赖**:mini-desktop(权威九门 e2e,x86 prod-同构)+ lili-mac(快走查,M1 Pro 8 核/32G)+ 6c6g(Workflow 编排)+ `NEWAPI_KEY`(`docs/内网凭据与端点.md`)+ 已合 001 gamedef 运行时(`39c16630`)+ **收敛窗口 Plan B gd-runtime/build-from-source 冻结**。 + +--- + +## 需求追溯 + +| 目标 / origin | 单元 | +|---|---| +| 用对 M3 — Anthropic 协议 + thinking + 多 Generation 重组(U-A) | U1 | +| 用对 M3 — 完整有界可序列化历史 + 连续救场(U-B / a3) | U2 | +| gamedef 路量对 + 有界并发池可量(发现②③ / P0-3) | U3 | +| 8/8 driver 决策树 + 上下文 + 门约定(③+②) | U4 | +| 收敛到 ≥80%(R1,measure→Workflow-Opus→优化→重测 + 运行时钉 + 留出集) | U5 | +| 模型驱动工具循环(U-C,闸控 + 并行 spike) | U6 | +| 两步 flip + 协议同切 + 回退硬前置(R2 / T1/T2) | U7 | +| 达标主杠杆=用对 agentic M3(主路)· 升强档/Claude premium(退路) | U1+U2+U5 + 退路 | + +--- + +## 待确认(Open · 留待执行期/创始人) + +- **U-C 触发线(P1-1,已预定)**:≥2 轮单变量 A/B(同组 n≥10)仍 <80% 且根因指向"需模型自纠"才全建 U6——确认该线。 +- **≥80% 是否本路合适数(P1-2)**:首次干净 gamedef 基线后复核(已 ≥85% 转 rubric / ~55% 走退路)。 +- **brief 分布对production input mix**:冻结 brief 集的品类分布与真实一句话输入分布的对齐度(留出集设计)。 +- **K 实测值**:lili-mac K≈3-4 / mini-desktop K 待双校准门定。 +- **认证头方言 / thinking budget**:U1 spike 第一步实测。 +- **step-2 flip 前 asset 烟测门槛(P0-2)**:最小"一 sprite 可渲"够否,还是需更强 asset 门(与 Plan B R11 对齐)。 + +--- + +## 已知限制与未来 TODO(创始人 2026-06-19 指示:记录 + 未来解决) + +- **T-FUTURE-1 · thinking 历史出站丢失(U2 已绕过、真解留未来)**:**stock `spring-ai-anthropic` 1.1.2 的 `AnthropicChatModel.buildMessages` 出站只发 `text`+`tool_use` 块、丢弃 thinking 块**(U2 对抗验证反编译核证——不读 `getMetadata()` 的 signature/thinking)→ 内存保留的原生 thinking 块一经 `.call()` 即被拍平成 text。故 **「thinking interleaved 多轮保留」在 stock 库上不可达**。 + - **现状(U2)**:简化为 **text 历史连续对话救场**(保留上一版答案文本 + 失败 verdict、增量修;不回灌推理白话)——连续对话价值仍在,但 v3「thinking 全保留用于 interleaved」前提**降级**为「响应文本连续对话」。 + - **未来解决候选**:① 自拼 `AnthropicMessage` 绕过 stock `buildMessages` 发原生 thinking 块(成本高 + `signature` 跨本网关有效性存疑——U1 已证此网关行为特殊/曾非确定性);② 升级 spring-ai 库版本核其是否支持出站 thinking 回放;③ 评估 thinking interleaved 对**生成质量**的真实增益是否值得投入。 + - **触发**:U5 收敛阶段若数据显示「text 历史连续对话救场」不足、而 thinking interleaved 回放是达 ≥80% 的关键杠杆 → 立项真解(①/②);否则维持 text 历史。 + - **关联**:同属「未验证的库/网关行为前提」类风险(参 R-8/R-9/R-10 + auto-memory `minimax-m3-thinking-inline-gotcha` wire 层补充)。 + +--- + +## §6.8 双评审记录 + +本档已过 **Opus 对抗式 + Opus 可行性双评审**(代码核证,2026-06-19),整改 5 P0 + 7 P1 + 多 P2(见「双评审整改注记」,全 IN-DOCUMENT)。**Codex(codex-rescue) 11min 未出可用评审 → §6.8 Opus 单评回退**;ce-work 前可补跑 Codex 作 belt-and-suspenders。跨 Plan 协调项(gd-runtime/build-from-source 冻结、application-staging.yaml 合并、T1 跨 Plan 依赖、mini-desktop 串行排队)已列入「风险与依赖 · 跨 Plan 协调」,须与 Plan B owner 同步。 diff --git a/docs/plans/2026-06-20-关闭九门判定-全面参考OpenGame-决策与Phase0-4.md b/docs/plans/2026-06-20-关闭九门判定-全面参考OpenGame-决策与Phase0-4.md new file mode 100644 index 00000000..97e617d8 --- /dev/null +++ b/docs/plans/2026-06-20-关闭九门判定-全面参考OpenGame-决策与Phase0-4.md @@ -0,0 +1,61 @@ +# 关闭九门 driver 判定 · 全面参考 OpenGame · Phase 0–4 决策记录 + +> **状态**:✅ 主线达成(2026-06-20:rc 门修使 Phase 1 真生效 + 改 anthropic 流式 thinking → gamedef e2e **91.7%**、按 n=12 达标;详见末节「§7 结果与修正」)。execution-spec/Codex×Opus 双评审仍为 follow-up。 +> **决策人 / 日期**:创始人 / 2026-06-20。 +> **一句话**:把"逐品类手写 driver 自动试玩"从生成硬门**降级**,生成环改只认**客观健康门(build-health, A–F)**,验证全面对标 **OpenGame**(方法蓝图,非 Phaser 运行时)。 + +--- + +## 1. 决策内容 + +1. **关闭九门 driver 判定对生成的绑架**:生成环(SAA 管线)硬门 = 客观健康门 A–F;driver 依赖门 **G_input / H_progress / I_control 不再否决生成、不进救场回喂**。九门 harness **仍照常跑并写 verdict.json**(不动门判定本身),仅改"生成环如何消费 verdict",**双轨可回退**(`-Dsaa.gen.gateMode=all9` 退回旧聚合 pass)。 +2. **全面参考 OpenGame**:采纳其**方法蓝图**——agentic 编码循环 / Template-First+hook 覆写 / 活调试协议 / build-health+VLM 验证 / 薄 prompt+按需 archetype doc;落在我们 **LittleJS + gamedef + 便宜模型** 栈;**不照搬** Phaser/TS/GameCoder-27B 运行时(评审 `2026-06-14-OpenGame蓝图补缺-review` D1 已划线)。 +3. **反转前约束**:本会话早前的"never edit nine-gate judgment logic"安全约束由创始人 2026-06-20 显式解除。 + +## 2. 为什么(证据) + +- **n=1 铁证**:M3 openai 流式 n=1 实测,游戏**过尽全部客观门(A_boot/B_uncaught/C_frame/D_render/E_live/F_wiring),只挂 `I_control`+`H_progress`**(driver 门)→ 几乎肯定是可玩游戏,纯被 driver 玩不动误杀。 +- **Phase 0 去混杂基线**:跨日志门失败分布 `H_progress 24 / G_input 7 / F_wiring 5 / E_live 4 / I_control 1`,**~78% 失败是 driver 依赖门**;A_boot/B/C_frame/D_render **零失败**。all-9 历史率 ≈ 50%,推断**客观-only 率 ~80–90%**(精确值待 Phase 1 新口径重测)。 +- **OpenGame 源码+论文**(`leigest519/OpenGame`,arXiv 2604.18394):验证 = build-health + VLM(无 driver);消融**最大杠杆 = hook/模板 +10.1 BH、活调试协议 +6.9、模板族 +5.8**;prompt = 薄基座 + 按 archetype 动态装配 doc + 匹配式调试库。 +- **SOTA 校准**:OpenGame + Claude Sonnet 4.6 也只 **BH 72.4 / VU 67.2 / IA 65.1**(复合分,口径异)→ "一句话自由生成可玩游戏"前沿在七成上下,我们 ≥80% 自由生成目标偏激进,主失败源是 driver 测量偏差而非模型天花板。 + +## 3. Phase 0–4 + +| Phase | 内容 | 状态 | +|---|---|---| +| 0 | 去混杂基线(挖现有 verdict 样本:A–F vs all-9) | ✅ 完成(§2) | +| 1 | 关闭九门对生成的绑架 + prompt/driver 解耦 | 进行中:1a 代码门解耦 ✅ 编译验证;1b prompt 解耦起草中 | +| 2 | Template-First 骨架(+10.1):每 archetype 已验证 gamedef 骨架 + hook 覆写 | 待办 | +| 3 | 活调试协议/离线 linter(+6.9):坑入版本化匹配库,真玩前静态门回灌 | 待办 | +| 4 | build-health + VLM 验证替换 driver 判定;自修复封 T=3;达标 flip | 待办(注:`SaaStudioNodes.judgeVision` 已有 VLM 雏形) | + +## 4. 客观门 vs driver 门(Phase 1 口径) + +- **客观健康门(build-health·play-independent,生成硬门)**:`A_boot / B_uncaught / C_frame / D_render / E_live`。 +- **driver 依赖门(降级为参考,不否决生成)**:`G_input / H_progress / I_control`。 +- **F_wiring 已移出客观硬门**(2026-06-20 实测 n=2 发现):它靠游戏事件触发 `rt.fx`,无 driver 真玩→事件不触发→必挂=play-dependent(实测两局均挂 `[F_wiring,H_progress]`,但 A–E 全过=游戏本身可跑可动)。"真用引擎/有特效"语义改由 player 节点 VLM 看截图判(OpenGame VU 口径);彻底解法见 Phase 4(VLM 升打分门 / harness 通用 input-poker 触发事件)。 +- 实现:`SaaGenNodes.objectiveGatesPass()` + `OBJECTIVE_GATES` 常量 + `GATE_ALL9` 开关;`summarizeFailedGuards` 客观门模式下不回喂 driver 门失败。 + +## 5. Blast radius / 回退 + +- 改动面:`SaaGenNodes.java`(生成环 verdict 消费口径);后续 `SaaPrompts.java`(prompt 解耦)。 +- **不改**:`play.cdp.cjs` 九门判定逻辑、`gd-runtime.js`、verdict.json 产物结构。 +- 回退:`-Dsaa.gen.gateMode=all9` 即恢复旧"九门聚合 pass"硬门。 + +## 6. Follow-up(不阻塞执行) + +- 出 Phase 0–4 **execution-spec** 并过 **§6.8 Codex×Opus 双评审**(task #18)。 +- Phase 4 VLM 验证口径(对标 OpenGame BH/VU/IA)单独定义 + 契约无关基线 parity 门(≥ 现客观基线才 flip 默认)。 + +--- + +## 7. 2026-06-20 结果与修正(已提交 `4e5a7bbf` → feat/mac-gamedef-qc-concurrent) + +执行中发现 + 修正,最终把 gamedef 生成质量做到 **91.7%**(超 80 门): + +1. **rc 门耦合 bug(Phase 1 真根因)**:§4 的客观门解耦写对了,但 `SaaGenNodes.playNode` 的 pass 前置了 `rc==0`,而 `play.cdp.cjs` 退出码=九门聚合(任一门含 driver 门挂即 exit 1)→ driver 门失败 rc=1 反向否决客观 pass,**使 Phase 1 形同虚设、空跑 8 轮救场烧 302K token**。修:客观门模式 `pass=(rc==0||rc==1)&&objectiveGatesPass`。修后 Phase 1 真生效。 +2. **生成质量杠杆 = thinking + 协议(先于 Phase 2 模板即见效)**:便宜模型"哑火静态游戏"(E_live 不动=没接对运动逻辑)是最大失败簇;**开 M3 thinking 修了它**。M3 thinking 仅二元(无深度/budget、提示词压不住);openai 路 adaptive 内联污染 JSON+截断→只能关;**改走 anthropic 原生分离 + 流式(`StreamingCallChatModel` + NO_PROXY webClient,治阻塞 SocketTimeout;#4407 对 1.1.2 过时)+ max_tokens 256k**。详见 `.agents/skills/saa-graph-orchestration.md §3.5`。 +3. **实测对照(conc=12 gamedef)**:thinking-off 8/12(67%、哑火×4) → **thinking-on 11/12(91.7%、哑火修 3/4)**;残留 #7 打地鼠(天生无运动点击类,E_live 苛、思考也救不活→留 Phase 2 模板 / E_live 口径调整)。代价 ~10x 慢 + 1.6x token。 +4. **达标口径放宽(创始人拍板)**:以 **n=12/91.7% 宣告达标**(非原 plan003 n≥30——n≥30 只多给方差信息,不改"thinking-on 更好且过门"的结论)。 +5. **证伪"模型天花板"**:§2 曾忧"≥80% 偏激进 / 前沿七成"——便宜模型经"对的协议 + 开思考"实达九成,主瓶颈是工程配置非模型天花板。 +6. **未尽**:#7 无运动点击类;速度代价(adaptive 无界、单局 20–73min,上线要治);Phase 2/3/4(模板 / 活调试 / VLM 门)仍可叠加作进一步增益。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index 331724c5..4b185cb0 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -47,6 +47,13 @@ com.alibaba.cloud.aispring-ai-alibaba-graph-core com.alibaba.cloud.aispring-ai-alibaba-starter-graph-observation org.springframework.aispring-ai-starter-model-openai + + org.springframework.aispring-ai-anthropic com.wanxiang @@ -192,6 +199,8 @@ prompts/01-safety/*.md prompts/04-config/*.md templates/*.schema.json + + agent-loop/skeletons/*.json diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java index 768fdeab..e57be463 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGenNodes.java @@ -484,7 +484,19 @@ final class SaaGenNodes { out.put("feedback", "verdict.json 解析失败:" + e.getMessage()); } } - boolean pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false); + // Phase 1(关闭九门 driver 判定对生成的绑架;founder 2026-06-20「关闭九门判定·全面参考 OpenGame」): + // 生成硬门只认【客观健康门】(A-F build-health,对齐 OpenGame BH);driver 依赖门(G_input/H_progress/I_control) + // 降级为参考、不否决生成。九门 harness 仍照常跑并写 verdict.json(不动门判定本身)。双轨:-Dsaa.gen.gateMode=all9 退回旧聚合 pass。 + // play.cdp.cjs 退出码 = 九门聚合:verdict.pass(全 9 门 AND) ? 0 : 1——任一门(含 driver 门 G/H/I)挂即 rc=1。 + // 故客观门模式【绝不能】用 rc==0 当前置:否则 driver 门失败→rc=1→反向否决客观 pass,Phase 1 形同虚设 + //(n=3 实证:游戏过 A-E 仅挂 H_progress,却因 rc=1 判 false→空跑 8 轮救场→max_repairs/302K token)。 + // 修正:客观门模式只要 harness 完成判定(rc 0/1,排除 ≥2 用法/崩溃)且有可解析 verdict,即按客观门裁决;all9 保留 rc==0 旧口径。 + boolean pass; + if (GATE_ALL9) { + pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false); + } else { + pass = (rc == 0 || rc == 1) && verdict != null && objectiveGatesPass(verdict); + } out.put("playPass", pass); if (verdict != null) { // 以字符串形态塞 state(避免 KeyStrategy 序列化 JsonNode 的额外约束;诊断读回即可)。 @@ -523,6 +535,46 @@ final class SaaGenNodes { } } + // ============================================================================ + // Phase 1(关闭九门 driver 判定对生成的绑架;founder 2026-06-20 + 全面参考 OpenGame build-health 门) + // 生成环硬门 = 客观健康门(build-health,对齐 OpenGame BH);driver 依赖门(G/H/I)不否决生成、不进救场回喂。 + // 九门 harness 仍照常跑并记录(verdict.json 不变),仅改「生成环如何消费 verdict」——可回退、不动门判定本身。 + // ============================================================================ + /** 客观健康门(build-health):能起动/无异常/有帧/有渲染/有动画/真接引擎;与 driver 能否玩动无关。 */ + private static final java.util.Set OBJECTIVE_GATES = java.util.Set.of( + "A_boot", "B_uncaught", "C_frame", "D_render", "E_live"); + // F_wiring(真接引擎) 已移出客观硬门:它靠游戏事件触发 rt.fx,无 driver 真玩→事件不触发→必挂(实测 n=2 即挂此门), + // 属 play-dependent。其"是否真用引擎/有特效"的语义改由 player 节点 VLM 看截图判(对齐 OpenGame VU 口径); + // 彻底解法见 Phase 4(VLM 升打分门 / 或 harness 通用 input-poker 触发事件)。 + + /** 双轨开关:all9=退回旧「九门聚合 pass」硬门;默认 objective=只认客观健康门(关闭 driver 门对生成的绑架)。 */ + private static final boolean GATE_ALL9 = + "all9".equalsIgnoreCase(System.getProperty("saa.gen.gateMode", "objective")); + + /** + * Phase 1:仅判【客观健康门】(A-F)是否全过;driver 依赖门(G_input/H_progress/I_control)不参与生成硬门。 + *

遍历 verdict.guards 中出现的门,只对客观门要求 pass;driver 门跳过。无 guards 明细或未见任何客观门→ + * 保守退回聚合 verdict.pass(避免误放空壳)。不改九门判定本身,只改生成环对其的消费口径。 + */ + private static boolean objectiveGatesPass(JsonNode verdict) { + JsonNode guards = verdict.path("guards"); + if (!guards.isObject() || guards.size() == 0) { + return verdict.path("pass").asBoolean(false); + } + // C2 修(CodeRabbit/Codex 双报,2026-06-20):客观门按【必达集】校验——5 门必须【全部存在且 pass】,缺任一即判失败。 + // 旧"遍历出现的门、出现才查"会被 harness 中途崩钻空子误判 PASS:play.cdp.cjs 的 runDriver(含 seek-food)在 + // D_render/E_live 之前执行且未 try/catch,conc=12 下 CDP 抖动抛错→主 catch 只补 A_boot→verdict 只剩 + // A_boot/C_frame/I_control→旧逻辑 sawObjective=true 放行=【崩掉、根本没测到渲染/活性的游戏被判过】。 + // 缺门=harness 未跑完=失败,才安全(旧 rc==0 口径反不会误判;本修补回该安全性、又不重新绑 driver 门)。 + for (String gate : OBJECTIVE_GATES) { + JsonNode g = guards.path(gate); + if (g.isMissingNode() || !g.path("pass").asBoolean(false)) { + return false; // 客观门缺失或未过 → 生成硬门判失败。 + } + } + return true; + } + /** * 把失败的守卫摘成回喂文字(对齐 run.py:_verdict_feedback)。 */ @@ -536,6 +588,10 @@ final class SaaGenNodes { Iterator> it = guards.fields(); while (it.hasNext()) { Map.Entry e = it.next(); + // Phase 1:客观门模式下,只回喂客观健康门失败,不让 driver 门(G/H/I)驱动救场重生成。 + if (!GATE_ALL9 && !OBJECTIVE_GATES.contains(e.getKey())) { + continue; + } if (!e.getValue().path("pass").asBoolean(false)) { String g = e.getValue().toString(); lines.add("- 守卫 " + e.getKey() + " 未过:" + (g.length() > 200 ? g.substring(0, 200) : g)); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java index ae01244b..71888931 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java @@ -28,8 +28,10 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; @@ -51,9 +53,13 @@ import java.util.concurrent.atomic.AtomicInteger; * (生成是数十秒级多步活,不在 tick 线程内长挂);任务留 RUNNING 等回调命中、超 deadline 由 watchdog 收尸。 * 仅「投递前置失败」(图未就绪/线程池拒绝/game-runtime 不存在)返回 false,调用方按 failed(llm_error) 落终态。 * - *

串行真玩门(Semaphore(1)):play 节点用固定端口(4320/9222)+ headless Chrome,多 job 并行真玩必撞端口; - * 故图执行经一把全局 {@link Semaphore}(1) 串行化(acquire→跑图→release)。与 Python worker {@code service.py:80} - * 串行锁同语义(serve 4320/CDP 9222 不可并发)。后台线程池亦设单线程,双保险。 + *

有界并发真玩门(003-U1:信号量(K) + 端口槽池):play 节点用真玩端口 + headless Chrome,多 job + * 共用同一对端口必撞;故每 job 整生命期独占一对错开端口(第 i 槽 serve={@code base+2i}/CDP={@code base+i}), + * 并发上限 K={@link AigcExecutorProperties#getSaaConcurrency()}(线程池 size=K + {@link Semaphore}(K) + K 个端口槽三者同界)。 + * 默认 K=1 = 生产字节零变:单线程池 + Semaphore(1) + 仅槽 0=[4320,9222],与改造前 {@code newSingleThreadExecutor + + * Semaphore(1) + buildInputs 常量 4320/9222} 逐字节等价(与 Python worker {@code service.py:80} 串行锁同语义)。 + * K>1 时各 job 端口对两两不撞(serve-and-play.sh 净场只杀本槽两端口、static-serve 只读共享根、evidence 按 gameId + * 隔离 → 跨 job 安全;共享面审计见 {@link #allocPortSlots} 注释)。 * *

事务边界:图执行不包大 {@code @Transactional}(数十秒级跑图占长事务=连接池杀手);落库由 * {@code handleCallback → DifyCallbackTxService} 内部独立短事务完成(既有三表同事务写链不变)。 @@ -95,22 +101,33 @@ public class SaaGraphDispatcher implements GenerationDispatcher { private final SourceProjectApi sourceProjectApi; /** - * 后台单线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。 - * 单线程 = 串行跑图(与 Semaphore 双保险——单线程已天然串行,Semaphore 兜「未来若改多线程」语义不破)。 + * 后台跑图线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。 + * 有界 size=K({@link #concurrency},003-U1):K=1 时是单线程池,与改造前 {@code newSingleThreadExecutor} + * 逐字节等价(天然串行);K>1 时 K 路并行跑图,并发上限由本池 + {@link #playLock}(K) + {@link #portSlots} 三者共同界定。 + * 构造期据 {@code properties.saaConcurrency} 定 size(见构造函数),故非 inline 初始化。 */ - private final ExecutorService graphExecutor = Executors.newSingleThreadExecutor(new ThreadFactory() { - private final AtomicInteger seq = new AtomicInteger(); + private final ExecutorService graphExecutor; - @Override - public Thread newThread(Runnable r) { - Thread t = new Thread(r, "saa-graph-dispatcher-" + seq.incrementAndGet()); - t.setDaemon(true); // 守护线程:进程退出不被它阻塞(在飞 job 由 watchdog 兜超时) - return t; - } - }); + /** + * 真玩门信号量(play 用真玩端口,多 job 并行真玩须各占独立端口对,故并发受限)。 + * 容量=K({@link #concurrency},003-U1):K=1 时等价改造前 {@code Semaphore(1)}(严格串行); + * K>1 时放行 K 路并发,与端口槽 {@link #portSlots}(恰 K 个错开端口对)一一配对——每路 job 整生命期持一槽, + * 杜绝撞端口。线程池已隐含同样上限,信号量为显式语义双保险(与改造前「单线程 + Semaphore 双保险」同范式)。 + */ + private final Semaphore playLock; - /** 真玩门串行锁(play 用固定端口 4320/9222,多 job 并行真玩必撞,故全局串行)。 */ - private final Semaphore playLock = new Semaphore(1); + /** + * 端口槽池(003-U1,有界并发的核心):{@code BlockingQueue},恰 K 个错开端口对 {@code [playPort, cdpPort]} + * (第 i 槽 playPort={@code saaPlayPortBase+2i} / cdpPort={@code saaCdpPortBase+i},{@link #allocPortSlots} 算)。 + *

整 job 生命期持槽语义:{@code runGraphAndCallback} 起手 {@code take()} 拿一空闲槽(无则阻塞,与信号量同节流), + * 用该槽端口覆盖 {@code inputs} 的 port/cdpPort 后跑图,finally 随 release 把同一槽 {@code put} 回池—— + * 即便跑图超时/异常也归还(防端口泄漏致后续 job 无槽永挂)。K=1 时只有槽 0 = {@code [4320,9222]}, + * 覆盖值与改造前 buildInputs 常量逐字一致 → 字节零变。 + */ + private final BlockingQueue portSlots; + + /** 后台并发度 K({@code max(1, saaConcurrency)};线程池 size / 信号量容量 / 端口槽数三者同此值)。 */ + private final int concurrency; /** * trace 抽取专用 mapper(W-G1 组B · 闭合 SAA split-brain):dispatcher 原无 ObjectMapper({@code SaaStudioNodes.MAPPER} @@ -156,6 +173,32 @@ public class SaaGraphDispatcher implements GenerationDispatcher { this.dataSource = dataSource; this.observationRegistry = observationRegistry; this.sourceProjectApi = sourceProjectApi; + + // ── 003-U1 有界并发池:据 saaConcurrency 定 K(默认 1=生产字节零变),三件套(线程池/信号量/端口槽)同此值 ── + // null-properties 容错:纯 trace 抽取单测(SaaGraphDispatcherTraceTest)以 4-null 依赖构造本类(抽取方法不碰 properties)。 + // 改造前字段为 inline 初始化、不触 properties;故此处亦须对 properties==null 兜底(K=1 + 默认端口基址),保该单测路不破。 + Integer rawK = (properties == null) ? null : properties.getSaaConcurrency(); + Integer playBase = (properties == null) ? null : properties.getSaaPlayPortBase(); + Integer cdpBase = (properties == null) ? null : properties.getSaaCdpPortBase(); + this.concurrency = normalizeConcurrency(rawK); + // 后台线程池:K=1 时 newFixedThreadPool(1) 与改造前 newSingleThreadExecutor 行为等价(单 worker 串行、无界队列);K>1 时 K 路并行。 + this.graphExecutor = Executors.newFixedThreadPool(concurrency, new ThreadFactory() { + private final AtomicInteger seq = new AtomicInteger(); + + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "saa-graph-dispatcher-" + seq.incrementAndGet()); + t.setDaemon(true); // 守护线程:进程退出不被它阻塞(在飞 job 由 watchdog 兜超时) + return t; + } + }); + // 真玩门信号量容量=K(K=1 等价改造前 Semaphore(1))。 + this.playLock = new Semaphore(concurrency); + // 端口槽池:K 个错开端口对入队(槽 0 恒 = [saaPlayPortBase, saaCdpPortBase] = 默认 [4320,9222],K=1 时字节零变;allocPortSlots 对 null 基址兜底 4320/9222)。 + this.portSlots = new LinkedBlockingQueue<>(); + for (int[] slot : allocPortSlots(playBase, cdpBase, concurrency)) { + this.portSlots.add(slot); + } } /** @@ -241,11 +284,12 @@ public class SaaGraphDispatcher implements GenerationDispatcher { return false; } - // ③ 提交后台单线程跑图(投递握手即返回;图执行+回调全在后台线程,tick 线程不长挂)。 + // ③ 提交后台跑图(投递握手即返回;图执行+回调全在后台线程,tick 线程不长挂)。 + // 后台池 size=K(K=1 等价改造前单线程);并发上限由池 + Semaphore(K) + 端口槽共同界定。 try { graphExecutor.submit(() -> runGraphAndCallback(graph, inputs, job, traceId)); } catch (Exception e) { - // 线程池拒绝(理论不可达:单线程无界队列):投递失败留痕。 + // 线程池拒绝(理论不可达:固定池无界队列):投递失败留痕。 log.error("[saa-dispatch] 图执行提交后台线程失败,投递失败 traceId={}", traceId, e); return false; } @@ -257,17 +301,30 @@ public class SaaGraphDispatcher implements GenerationDispatcher { // ============================== 私有:图执行 + 进程内回调 ============================== /** - * 后台线程体:串行跑图(Semaphore 守真玩端口)→ result state 组 ReqVO → 进程内调 handleCallback。 - * 任何异常都兜成 failed(llm_error) 回调(绝不静默吞——否则任务卡 RUNNING 直到 watchdog 超时收尸,体验差)。 + * 后台线程体:取信号量许可 + 端口槽(K=1 严格串行/K>1 有界并发,各 job 独占端口对)→ 跑图 → result state 组 ReqVO + * → 进程内调 handleCallback。任何异常都兜成 failed(llm_error) 回调(绝不静默吞——否则任务卡 RUNNING 直到 watchdog 超时收尸,体验差); + * finally 必归还端口槽 + 释放许可(即便超时/异常,防端口泄漏致后续 job 无槽永挂)。 */ private void runGraphAndCallback(CompiledGraph graph, Map inputs, Map job, String traceId) { boolean acquired = false; + int[] slot = null; // 本 job 占用的端口槽 [playPort, cdpPort];finally 必归还(防泄漏)。 try { - // 串行真玩门:阻塞获取(单线程池下恒能拿到;双保险防未来多线程改动)。 + // 真玩门信号量:阻塞获取(K=1 下恒能拿到=严格串行;K>1 放行 K 路并发,与端口槽数一一对应)。 playLock.acquire(); acquired = true; + // ── 003-U1:取一空闲端口槽(无则阻塞,与信号量同节流),用该槽端口覆盖 inputs 的 port/cdpPort ── + // per-job 端口隔离:每 job 整生命期独占一对端口(serve 4320+2i / CDP 9222+i),跑完 finally 归还。 + // K=1 时只有槽 0=[saaPlayPortBase, saaCdpPortBase]=默认[4320,9222],覆盖值与 buildInputs 常量逐字一致 → 字节零变。 + slot = portSlots.take(); + inputs.put("port", slot[0]); + inputs.put("cdpPort", slot[1]); + if (concurrency > 1) { + // 仅 K>1 并发态打点(K=1 不打、保日志字节零变):便于排障「哪 job 用哪端口」。 + log.info("[saa-dispatch] 取端口槽 playPort={}, cdpPort={}(K={} 并发)traceId={}", slot[0], slot[1], concurrency, traceId); + } + // 同 threadId(traceId) 跑图:checkpoint 开启时(图已挂 MysqlSaver+releaseThread(false)),崩溃后同 // traceId 二次 invoke 即从最后已落 checkpoint 的节点续跑(step5 已接;watchdog 兜超时、checkpoint 兜续跑)。 RunnableConfig runConfig = RunnableConfig.builder().threadId(traceId).build(); @@ -292,6 +349,11 @@ public class SaaGraphDispatcher implements GenerationDispatcher { log.error("[saa-dispatch] 图执行异常,兜 failed(llm_error) 回调 traceId={}", traceId, e); safeFailedCallback(job, traceId, "图执行异常:" + trunc(String.valueOf(e.getMessage()), 200)); } finally { + // 先归还端口槽(即便超时/异常也归还,防端口泄漏致后续 job 无槽永挂),再释放信号量——保「槽与permit成对」不变量。 + // put 回的是 take 出的同一 int[](同一槽),K=1 时即把槽 0 放回,下个 job 再取仍是 [4320,9222](字节零变)。 + if (slot != null) { + portSlots.add(slot); // 有界队列容量足够(恰 K 个槽),add 不会阻塞/拒绝 + } if (acquired) { playLock.release(); } @@ -312,22 +374,57 @@ public class SaaGraphDispatcher implements GenerationDispatcher { } synchronized (this) { if (compiledGraph == null) { - // new-api 上游:baseUrl 取 host 根(剥末尾 /v1,坑见 §7-1);apiKey 取执行器配置(密钥经环境变量注入)。 - String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase()); - OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build(); // checkpoint(step5):开关开 ∧ DataSource 在席 → 懒建权威单 MysqlSaver(首个 dispatch 才触 DDL, // dispatcher=http 永不到此);否则降级内存态(saverConfig=null)。一图一 saver(只 register 一个)。 SaverConfig saverConfig = buildSaverConfigIfEnabled(); - // 固定架构 B2:透传救场阶梯 stage2 额外救场轮(saaStage2ExtraRepairs),走 8 参 build 重载(recursionLimit 按 5+3 重算)。 - compiledGraph = SaaStudioGraph.build(api, SaaStudioGraph.Models.stage1(), gameRuntimeRoot, + // Plan A/U1「用对 M3」:据 saaModelProtocol flag 选 per-role 模型工厂—— + // openai(默认)= OpenAI 兼容口径(OpenAiApi+OpenAiChatModel,字节零变); + // anthropic = Anthropic Messages 协议(thinking 原生分离,治 M3 thinking 内联污染)。布线唯一源仍是 assemble(两路共用)。 + boolean anthropic = "anthropic".equalsIgnoreCase(properties.getSaaModelProtocol()); + String upstreamDesc; + SaaStudioGraph.RoleModelFactory mf; + if (anthropic) { + // Anthropic 路:baseUrl=host 根(绝不加 /anthropic、勿用 stripV1Suffix,spike 实测);x-api-key 鉴权;budget0 → 包装 mf 覆盖各角色 maxTokens(M3+thinking 思考+答案需大余量防截断,doc 荐 M3 128K);0=各角色默认(字节零变)。 + int forceMaxTokens = properties.getSaaForceMaxTokens(); + if (forceMaxTokens > 0) { + SaaStudioGraph.RoleModelFactory baseMf = mf; + mf = (name, temperature, mt) -> baseMf.create(name, temperature, forceMaxTokens); + } + // Plan A U5「只用 M3」(创始人 2026-06-19):saaForceModel 非空 → 全角色统一该模型(含 stage2/fallback 位,单模型无跨家回退);空=stage1 默认(deepseek 主力,字节零变)。 + String forceModel = properties.getSaaForceModel(); + boolean singleModel = forceModel != null && !forceModel.isBlank(); + SaaStudioGraph.Models models = singleModel + ? SaaStudioGraph.Models.allSame(forceModel.trim()) + : SaaStudioGraph.Models.stage1(); + if (singleModel) { + log.info("[saa-dispatch] 单模型路(U5 只用 M3):forceModel={}(全 11 角色统一、无跨家回退)", forceModel.trim()); + } + // 固定架构 B2:透传救场阶梯 stage2 额外救场轮(saaStage2ExtraRepairs),走 factory-based build 重载(recursionLimit 按 5+3 重算)。 + // Plan A/U2:historyEnabled=anthropic——anthropic 路 generate 跨轮保留 thinking 历史 + 连续对话救场;openai 路关(字节零变)。 + compiledGraph = SaaStudioGraph.build(mf, models, gameRuntimeRoot, properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(), properties.getSaaStage2ExtraRepairs(), properties.getSaaSourceMode(), // plan U3:factory(默认,iife) | gamedef(真结构化源) + anthropic, // plan U2:historyEnabled(仅 anthropic 路开连续对话历史+thinking 保留) saverConfig, observationRegistry); - log.info("[saa-dispatch] SAA 图构造完成(缓存复用):maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, " - + "newApiBase={}, checkpoint={}, observation={}", + log.info("[saa-dispatch] SAA 图构造完成(缓存复用):protocol={}, maxRepairs={}, stage2Extra={}, maxPlayerRounds={}, " + + "upstream={}, checkpoint={}, observation={}", + anthropic ? "anthropic" : "openai", properties.getSaaMaxRepairs(), properties.getSaaStage2ExtraRepairs(), - properties.getSaaMaxPlayerRounds(), baseUrl, + properties.getSaaMaxPlayerRounds(), upstreamDesc, saverConfig != null ? "MysqlSaver(GRAPH_CHECKPOINT/GRAPH_THREAD)" : "内存态(off/无DataSource)", (observationRegistry != null && observationRegistry != ObservationRegistry.NOOP) ? "on" : "off"); } @@ -512,7 +609,14 @@ public class SaaGraphDispatcher implements GenerationDispatcher { DifyCallbackReqVO reqVO = new DifyCallbackReqVO(); reqVO.setTraceId(traceId); reqVO.setTemplateId(templateId); - reqVO.setAssets(java.util.Collections.emptyList()); + // ── U2/B8 消费侧(§5.2 产消接通):忠实取已落库源 assets[](六类源资产)填充包级 assets,替代历史硬置空。 ── + // 兑现 SaaStudioNodes 处自标的「engine-E2/E3 消费侧本期不做」TODO:源 asset 节点已产 assets[6] + // (schemaVersion+gameDefinition+顶层 assets,落 game_source_project.source_json),此处把同批源 assets + // 忠实带进 GamePackage.assets(DifyCallbackTxService.buildGamePackage 读 reqVO.getAssets()),闭合产消断链。 + // 形态注记:源 assetSpec={id,category,ref,provider}(source-project.schema.json #/$defs/assetSpec,运行时 + // gd-runtime 经 gdef.assets 同形消费);geom: ref 为几何占位资产引用,原样透传不伪造 type/hash/bytes/mime。 + // 空/缺/非数组源 assets → 回退空集(不抛):严格非破坏,存量无源回调字节零变。 + reqVO.setAssets(extractSourceAssets(s, traceId)); // gameConfig 最小合法占位(engine 路游戏逻辑全在 engineBundle,gameConfig 仍不可空:GamePackage required)。 Map gameConfig = new LinkedHashMap<>(); gameConfig.put("templateId", templateId); // 须与包级 templateId 一致(一致性校验) @@ -666,6 +770,55 @@ public class SaaGraphDispatcher implements GenerationDispatcher { } } + /** + * U2/B8 消费侧(§5.2 产消接通):从图终态 {@code K_SOURCE_PROJECT} 抽顶层 {@code assets[]} 映射为包级 assets。 + * + *

兑现 TODO:源 asset 节点已把六类源资产写入 {@code sourceProject} 顶层 {@code assets} + * ({@code source-project.schema.json #/$defs/assetSpec},形态 {@code {id,category,ref,provider}}),历史上组包侧 + * 硬置空集导致产消断链;本方法把同一批源 assets 忠实搬进 {@link DifyCallbackReqVO#getAssets()}, + * 由 {@code DifyCallbackTxService.buildGamePackage} 落进 {@code GamePackage.assets}。运行时 {@code gd-runtime} + * 经 {@code gdef.assets}(同 source assetSpec 形态)消费,故原样透传不重塑:{@code geom:} 为几何占位 + * 资产引用,不伪造包契约 strict AssetSpec 的 {@code type/url/hash/bytes/mime}(无真图、无真 sha256,伪造即脏数据)。 + * + *

best-effort 非阻断(严格 additive):源缺失/空白/非 JSON/无 assets 键/assets 非数组/任意异常 → + * 一律返回空集(绝不外抛、绝不打断 SAA 生成),与历史「恒空集」对存量无源回调字节零变化。 + * + * @param s 图终态(可能 null) + * @param traceId 贯穿键(仅日志) + * @return 源 assets 映射出的 {@code List>}(忠实透传字段);无/异常时空集(非 null) + */ + private static java.util.List> extractSourceAssets(OverAllState s, String traceId) { + if (s == null) { + return java.util.Collections.emptyList(); + } + try { + // 复用 best-effort 宽松解析(null/空白/非法 JSON → null);源串即 §5.2 落库的 source_json。 + JsonNode root = parseTrace(s.value(SaaStudioNodes.K_SOURCE_PROJECT, String.class).orElse("")); + if (root == null) { + return java.util.Collections.emptyList(); + } + JsonNode assets = root.path("assets"); + if (!assets.isArray() || assets.isEmpty()) { + // 无 assets 键 / 非数组 / 空数组 → 回退空集(不抛,存量无源回调字节零变)。 + return java.util.Collections.emptyList(); + } + // 逐项忠实映射为 Map(保 {id,category,ref,provider} 等全部源字段;geom: ref 原样);非对象项跳过(防御脏数据)。 + java.util.List> out = new java.util.ArrayList<>(assets.size()); + for (JsonNode a : assets) { + if (a != null && a.isObject()) { + out.add(TRACE_MAPPER.convertValue(a, new com.fasterxml.jackson.core.type.TypeReference>() {})); + } + } + // 可追溯日志:接通了几个源 assets(0 表示源里确无对象项,等价历史空集,不告警)。 + log.info("[saa-dispatch] 包级 assets 接通源 assets[] 共 {} 个(产消接通,兑现 engine-E2/E3 消费侧 TODO)traceId={}", out.size(), traceId); + return out; + } catch (Exception e) { + // 命门:抽取/映射异常一律吞 + warn 返空集,绝不打断 SAA 生成(生成成败由 status/engineBundle 既有逻辑决定)。 + log.warn("[saa-dispatch] 源 assets 抽取失败,包级 assets 回退空集(不阻断生成)traceId={}", traceId, e); + return java.util.Collections.emptyList(); + } + } + /** * 派生 models 并集:{@code K_TOKENS_BY_MODEL} 的 model 名键集 ∪ {@code K_ATTEMPTS[].{role:model}},组同形 {role:model} map。 * @@ -893,4 +1046,60 @@ public class SaaGraphDispatcher implements GenerationDispatcher { } return s.length() > n ? s.substring(0, n) : s; } + + // ============================== 003-U1 有界并发:端口分配纯逻辑(模型无关,可单测) ============================== + + /** + * 规整并发度 K(纯函数):{@code null} 或 {@code <1} → 1(退回单飞,误配不崩、不破生产字节零变);否则原值。 + * + * @param raw {@link AigcExecutorProperties#getSaaConcurrency()}(可能 null/非法) + * @return K(恒 {@code >=1}) + */ + static int normalizeConcurrency(Integer raw) { + if (raw == null || raw < 1) { + return 1; + } + return raw; + } + + /** + * 端口槽分配(纯函数,003-U1 有界并发核心):为 K 路并发各算一对错开端口 {@code [playPort, cdpPort]}。 + *

第 i 槽({@code 0..k-1}):{@code playPort = httpBase + 2*i}(步长 2 留头寸)、{@code cdpPort = cdpBase + i}(步长 1)。 + * 槽 0 = {@code [httpBase, cdpBase]}(默认 {@code [4320, 9222]})→ K=1 时与改造前 buildInputs 常量逐字一致,字节零变。 + * + *

不撞端口的依据({@code SaaFullGraphE2eGateLogicTest} 单测断言):playPort 族 {@code {httpBase, httpBase+2, ...}} + * 与 cdpPort 族 {@code {cdpBase, cdpBase+1, ...}} 默认区间天然不交叠(4320 区 vs 9222 区,相距数千);族内步长保互异 → + * K 槽 2K 个端口两两不同(默认值下成立;若误配 base 致两族交叠属部署配置错,超本单元,由 U5 真跑校准兜)。 + * + *

K>1 共享面安全审计(只读核 {@code _shared/serve-and-play.sh} + {@code static-serve.cjs} + {@code play.cdp.cjs}, + * 脚本禁改铁律下仅审计)——结论:除「同一对端口」外,K 路并发的其余共享资源均隔离或读共享安全: + *

    + *
  1. 端口(唯一真冲突点):每 job 持独立端口对、整生命期独占({@link #portSlots}),serve-and-play.sh 净场 + * {@code lsof -ti tcp:$PORT|kill} 只杀传入的本槽两端口(非全局 kill),故跨 job 不误杀 → 端口隔离即足。
  2. + *
  3. 服务根 cwd(共享、只读):N 个 {@code static-serve.cjs} 各 {@code listen} 自己端口,serve 同一 game-runtime + * 根但纯只读(仅 {@code fs.readFile}、拒 {@code ..} 穿越,无写)→ 并发只读同一根安全。
  4. + *
  5. {@code _shared/} 脚本(共享、只读):serve-and-play.sh / play.cdp.cjs / static-serve.cjs 是共读脚本文件, + * 运行期不自改 → 安全。
  6. + *
  7. {@code /tmp/wg1-*-$PORT.log}(按端口键隔离):serve/chrome 日志名含 {@code $PORT},端口对既隔离则日志路径天然不撞 → 安全。
  8. + *
  9. Chrome user-data-dir(按调用隔离):serve-and-play.sh 用 {@code mktemp -d -t wg1-chrome-XXXX} 每次唯一临时目录 → 安全。
  10. + *
  11. evidence 产物(按 gameId 隔离):play.cdp.cjs 只写 {@code games/_wg1-gen//evidence/},gameId={@code saa-} 每 job 唯一 → 写面不交叠。
  12. + *
+ * 遗留(U5 真跑校准项):上述为静态审计结论;K 路真并发下「verdict 对等(K=1 vs K=N 同 brief 判定一致)」+「同款×N 结构隔离」 + * 须九门环境实跑验证,延 003-U5 执行期。 + * + * @param httpBase playPort 基址({@link AigcExecutorProperties#getSaaPlayPortBase()},默认 4320;null 兜底 4320) + * @param cdpBase cdpPort 基址({@link AigcExecutorProperties#getSaaCdpPortBase()},默认 9222;null 兜底 9222) + * @param k 并发度(须 {@code >=1},由 {@link #normalizeConcurrency} 保证) + * @return K 个端口对 {@code [playPort, cdpPort]}(按槽序,槽 0 在首) + */ + static java.util.List allocPortSlots(Integer httpBase, Integer cdpBase, int k) { + int hb = (httpBase == null) ? 4320 : httpBase; + int cb = (cdpBase == null) ? 9222 : cdpBase; + int n = Math.max(1, k); + java.util.List slots = new java.util.ArrayList<>(n); + for (int i = 0; i < n; i++) { + slots.add(new int[]{hb + 2 * i, cb + i}); + } + return slots; + } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java index a1f55a48..07f5a65b 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPrompts.java @@ -17,7 +17,8 @@ import java.util.regex.Pattern; *
    *
  • {@link #SYSTEM} ≡ wg1/gen-worker/worker/prompt.py:24-107(模块形状/能力面/12 禁止/硬规则/P0 latch/few-shot 挂点);
  • *
  • {@link #buildMessages} ≡ prompt.py:110-119(system 注入 few-shot;user 末尾回喂上轮失败原因,全量重出非 diff);
  • - *
  • {@link #DESIGN_SYSTEM} ≡ roles.py:8-32(设计 agent + 末尾自产 ```gatespec;规则③已同步 tap-targets 家族二分:离散点击目标类自产 tap-targets〔避负族再带 safeOnly+safe 位〕);
  • + *
  • {@link #DESIGN_SYSTEM} 源自 roles.py:8-32(设计 agent + 末尾自产 ```gatespec);Phase 1b(2026-06-20,关闭九门 driver 判定·全面参考 OpenGame)已剥离 driver 决策树: + * gatespec 收窄为 expectLatch + 一条 progress 断言(score/remaining),设计 agent 只声明胜负与进展、不再预测 driver 类型(GAMEDEF_SYSTEM 同步去 driver 命名耦合);
  • *
  • {@link #playerSystem} ≡ roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必判 fix);
  • *
  • {@link #extractGatespec} ≡ studio.py:72-102(正则抠 ```gatespec → 容错解析 → 缺省补 expectLatch / ballPath .y→.x 归一)。
  • *
@@ -152,17 +153,24 @@ final class SaaPrompts { {"entities":[...],"components":[...],"behaviors":[...],"scenes":[...],"rules":[...]} 【结构】 - - entities[]: {"id","transform":{"position":{"x","y"}},"components":[组件id...]}(可加 "vx","vy","tags":[...] 及任意数值字段如 idx) + - entities[]: {"id","transform":{"position":{"x","y"}},"components":[组件id...]}(可加 "vx","vy","tags":[...];⚠️自定义字段如 idx/gridX 写字面量里会被丢弃,须在 init behavior 里对实体引用赋值,见下 rt 面 ⚠️) - components[]: {"id","kind",...} kind ∈ render/physics/collision/custom · render: {"kind":"render","shape":"rect|circle|fill","color":"#rrggbb"}; rect 加 "w","h"; circle 加 "r"; fill 铺满作背景 · physics: {"kind":"physics","gravity":数}; 挂此组件的实体每帧自动 x+=vx*dt,y+=vy*dt(+重力) - behaviors[]: {"id","trigger":"init|update|input|collision|timer","code":"<一段 JS 逻辑串>"} - code 编译为 function(rt,self,dt){...}: init 跑一次,其余每帧跑; self=本 behavior 持久态(跨帧存计时器/累加器) + code 编译为 function(rt,self,dt){...}: init 跑一次,其余每帧跑; self=本 behavior 私有持久态(跨帧存本 behavior 的计时器/累加器) + ⚠️【self 每个 behavior 各自独立——绝不跨 behavior 共享!】init 里写的 self.phase,别的 behavior 读到 undefined→分支永进不去→画面静止(实测最常见死法)。 + 跨 behavior 共享态(游戏阶段/计时/回合)必须挂【状态实体】:entities[] 声明一个固定 id 的状态实体(如 "gamestate",可无 render), + 各 behavior 一律 `const G=rt.getEntity('gamestate');` 再读写 G.phase/G.timeLeft(实体属性跨帧跨 behavior 持久,如 e.hp=3); 分数只用 rt.score/rt.addScore(全局唯一)。 + ⚠️【数组/对象态同样禁 self 跨享】最常见死法:init 写 self.active=[...]/self.moles=[...],别的 behavior 读 self.active[i]→空对象→抛 "Cannot read properties of undefined"→每帧夭折冻屏。共享数组挂状态实体(G.active=[...]),各 behavior 经 G 读写;或更优:打地鼠/接物类直接 rt.spawn 目标+clickable 内置计分+到点 rt.destroy,用"实体在不在"代替自管数组,免跨 behavior 态。 - scenes[]: {"id","entityRefs":[实体id...]}(scenes[0] 决定初始实例化哪些实体) - rules[]: {"id","condition":"","outcome":"win|lose|score|advance"} 【behavior/condition 唯一能用的 rt 面】 - 实体: rt.getEntity(id) / rt.entities() / rt.query(tag) / rt.spawn({x,y,vx,vy,tags,components}) / rt.destroy(e); 实体含 .x.y.vx.vy.alive.tags + 可直接读写属性(e.hp=3) + ⚠️【spawn/entities 字面量只认 id/x/y/vx/vy/tags/components——写在 {...} 里的自定义字段(gridX/colorIdx/scored/w/h/idx 等)被静默丢弃,读到 undefined!】存自定义态须先拿引用再赋值:`const e=rt.spawn({x,y,tags:['t'],components:['c']}); e.gridX=col; e.colorIdx=ci;`(直接写实体对象才持久);声明实体则 init 里 `for(const e of rt.query('t')) e.idx=...`。 + ⚠️碰撞/命中尺寸 w/h/r 只能写进 render 组件,不能写实体顶层:rt.overlap 的框、内置 clickable 命中框都只读 render 组件的 w/h/r——实体顶层 e.w 永远 undefined→NaN→比较恒 false→永不命中/得分。 + ⚠️.tags 是 Set(有 .has(name),没有 .includes/数组方法):判 tag 用 rt.query('pipe') 或 e.tags.has('pipe');写 e.tags.includes(...) 每帧抛错冻死。 - 输入: rt.input.isDown(key) / justPressed(key) / justTapped() / pointer{x,y,down} - 时间随机(确定性): rt.time.now()(秒,从0) / rt.dt / rt.random() / rt.randRange(a,b) / rt.randInt(a,b) - 分数胜负: rt.score / rt.addScore(n=1) / rt.setScore(n) / rt.win() / rt.lose()(置不可逆终态) @@ -171,25 +179,24 @@ final class SaaPrompts { 【硬约束(违反=校验拒绝→打回重做,非靠自觉)】 1. 确定性: 随机一律 rt.random/randRange/randInt, 时间一律 rt.time.now()/dt。禁 Math.random、Date、performance(Math.sin/abs/floor/PI 等允许)。 2. 只用 rt: 禁 process/require/eval/.constructor/Function/document/window/fetch/addEventListener/import。要生成实体用 rt.spawn(别自造约定外 helper)。 - 3. 终态: 胜负用 rt.win()/rt.lose()(不可逆), 别用分数/flag 表达结束; 游戏必须"会输"(超时/碰撞/耗尽任一)。 + rt 是【唯一可调用面】——上面【behavior/condition 唯一能用的 rt 面】之外没有全局函数;裸调 getEngine()/getInput()/restart()/spawn()(漏 rt. 前缀)=每帧抛 ReferenceError→整个 behavior 当帧夭折→画面静止/不接线。特效只经 rt.fx;重开局由平台处理,游戏内别写 restart。 + 3. 终态: 胜负用 rt.win()/rt.lose()(不可逆), 别用分数/flag 表达结束; 游戏必须有能【真触发】的终态(九门要求真玩到 gameover 且驻留,光"分数能涨"不够)。 + 无天然失败态的(打地鼠/三消/接物)必须加【时间限或回合限】:init 在状态实体记 G.timeLeft=30,每帧 G.timeLeft-=dt; if(G.timeLeft<=0) rt.lose();(或回合耗尽→rt.win())。 4. 特效: 碰撞/得分等关键事件调 rt.fx.burst/beep(真接引擎=满真接线门)。 5. condition 是无副作用布尔表达式: 禁 ; / 赋值 / 调 win/lose/addScore/spawn/destroy。 6. 死循环: 禁 while(true)/for(;;)。 - 【可玩 + 被九门驱动(关键约定,务必对齐——否则机制门必挂)】 + 【可玩 + 好玩(做到这些,真玩判分才高)】 - 实体须有 render 组件才可见; 放一个 shape:"fill" 实体铺满 390x844 作背景(否则判白屏)。 - - **命名逐字对齐设计稿 gatespec**: 设计稿末尾 ```gatespec 块的 driver.ballPath / controlCheck.paddlePath / assertAfterPlay.path - 引用的名字,你的实体 id 与进展字段必须**逐字一致**: - · 挡板接球类: 球实体 id **就叫 "ball"**、挡板 **就叫 "paddle"**(别用 paddlePlayer/ball1 等变体——九门读 ball.x/paddle.x,变体=读到 undefined=挂)。 - · 离散点击类(井字棋/打地鼠/翻牌/扫雷): **声明式·免写 tap-handler**——每个可点目标实体打 tags:["target"]+idx, - 并加一个 **clickable 组件** `{kind:"clickable", score:1, mark:{shape:"circle", color:"#ffcc00", r:30}}`;运行时**内置**: - 点中该目标 → 自动翻 occupied + spawn mark(可见变化) + 计分 + fx(粒子/音效)。**绝不要自己写 justTapped/overlap 的 tap-handler** - (实测便宜/强模型都常把命中门控在分离 state 实体/计时器/turn 上→盲点驱动永远点不中→E_live/G_input/F/H 全挂)。 - 九门盲点 occupied!==true 的目标;打地鼠等计时类:让当前激活目标 occupied=false(driver 才点)、其余 occupied=true(或 spawn/destroy 控可点性)。 - - **可控体必须响应指针**(九门 driver 靠点击指针位驱动你的控制体): 挡板/玩家等可控实体务必随指针移动,如 - `const p=rt.getEntity('paddle'); if(p && rt.input.pointer.down){ p.x = rt.input.pointer.x; }`(只接键盘=driver 推不动=I_control 挂)。 - - **进展落在可取证字段**: 取证只暴露 score / remaining(=存活实体数,destroy 目标即降)/ 实体位置——进展务必体现在 rt.addScore 或 - rt.destroy(目标/砖)上; 确保设计稿 assertAfterPlay 的 path 落在 score 或 remaining(别用取证读不到的自定义字段)。 + - **可控体要跟手**: 挡板/玩家等可控实体随指针或方向键平滑移动,如 + `const p=rt.getEntity('player'); if(p && rt.input.pointer.down){ p.x = rt.input.pointer.x; }`——让玩家点哪动哪 / 按键有反应。 + - **离散点击玩法(井字棋/打地鼠/翻牌等)直接用 clickable 组件**: 每个可点目标实体挂 clickable 组件, + 运行时内置"点中→翻 occupied + spawn mark(可见变化) + 计分 + fx",**不必自己写 justTapped/overlap 点击逻辑** + (自己写命中常因把命中门控在分离 state 实体/计时器上而点不中)。 + - **进展落在运行时真暴露的字段**: 进展务必体现在 rt.addScore(分数涨)或 rt.destroy(目标/砖被清=剩余 remaining 降)上。 + ⚠️绝不把进展藏进自定义标量(score.player / snakeLength / hits / piecesLocked 这类)——运行时不暴露,验收读不到→判无进展;蛇变长/连击/收集一律计入 rt.addScore。 + ⚠️别把实体命名成 snakeLength/hits 等"像计数器"的名字——会被按 id 投成 {x,y} 对象、永不"增大"。 + - **会输 + 胜负演出**: 游戏要真能输(掉命/超时/资源耗尽任一),胜/负时画面有明确变化或文案(别只停住);无天然失败态的(打地鼠/三消/接物)加限时/限回合终态(见硬约束 3)。 - HUD/进展可见: 分数/剩余看得到; 前 30 秒能上手、有正反馈、看得到分数增长。 【参照(严格按此结构产 JSON, 但实现成题目要求的那款游戏; 这是个躲避类示例)】 @@ -251,18 +258,15 @@ final class SaaPrompts { 【约束】设计正文控制在 ~200 字内,聚焦"代码 agent 实现时需要的决策",不堆砌辞藻、不写引擎/契约细节(那是代码 agent 的事)。 - 【末尾必附 gate-spec(机器可读·harness 据此确定性验"能玩 + 手感"·这是 gate-H 推广到任意游戏的关键)】 - 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏怎样被确定性验证: + 【末尾必附 gate-spec(机器可读·供下游确定性验收用)】 + 设计正文之后另起一个 ```gatespec 代码块,块内为 JSON,声明本游戏的验收锚点: ```gatespec { - "exportState": ["phase","score","remaining","progress","<关键实体位置:如 ball:{x,y,vx,vy}、paddle:{x,y,w};纯点击类可省位置,但离散点击目标类必须导出 targets:[{x,y,idx,occupied[,safe]}]>"], - "driver": {"type":"paddle-intercept","ballPath":"ball.x","paddleY":800}, - "controlCheck": {"paddlePath":"paddle.x","tapXs":[50,340]}, - "assertAfterPlay": [{"path":"remaining","op":"decreased","why":"击碎砖块→剩余下降"}], - "expectLatch": true + "expectLatch": true, + "assertAfterPlay": [{"path":"score","op":"increased","why":"得分上升=有进展"}] } ``` - 规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留);② 有"连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`,纯点击/无连续控制给 null;③ 按【玩法形态】二分选 driver(这是判"机制是否真点亮"的命门,别一律给 none):(a)技巧类(挡板接球/接物,玩法=连续控制体随手指水平移动去拦截)`driver.type="paddle-intercept"`,`ballPath`=**球的水平 x 坐标字段**(挡板水平移动去对齐它,几乎总是 `ball.x`,**绝不要填 ball.y**),`paddleY`=挡板所在 y。(b)离散点击目标类(井字棋/五子棋/打地鼠/翻牌/Simon/扫雷/见缝插针:玩法=逐个点击网格或离散目标)**必须** `driver.type="tap-targets"`,并让代码 agent 在 `_forensicsView().state()` 导出 `targets:[{x,y,idx,occupied}]`(每个可点目标的逻辑坐标 x/y、序号 idx、是否已占用 occupied);**绝不可用 none**(否则 harness 不点目标→H_progress 必挂)。其中再按"有无致负目标"细分:——规避/推理族(存在"点了立即致负"的目标,如扫雷雷格):driver 加 `"safeOnly":true`,targets 每元素额外导出 `safe`(布尔=该目标非致负,如扫雷 `!mine`;**仅供测试·只现于 `_forensicsView`,渲染层与玩家界面绝不可显示、不得据此给避险提示**);`assertAfterPlay` 用 `{"path":"result","op":"==","value":"win"}`(确定性避负逐格揭至 win,把核心循环跑透)。——安全放置族(无致负目标,如井字棋/打地鼠:任一未占用目标都是合法推进招):不加 safeOnly,`assertAfterPlay` 给推进断言(如落子数/命中数 `increased`)。(c)其余既无连续控制体、又无可枚举离散目标 → `driver.type="none"`;④ `assertAfterPlay` 至少一条"真有进展"断言(op ∈ increased/decreased/changed/>/>=/物理隔离·并存: + // openai 路 prompt 字节零变(回归红线),本组仅 anthropic 路连续对话用。公平性铁律:system 逐字同上,不改语义。 + // ============================================================================ + + /** + * 连续对话路 system(与 openai 路同一 SYSTEM/GAMEDEF_SYSTEM,公平性铁律不改 prompt 语义)。 + * + * @param gamedef true=gamedef 路(GAMEDEF_SYSTEM,无 few-shot 占位);false=iife 路(SYSTEM 注入 few-shot)。 + * @param gameRuntimeRoot game-runtime 根(iife 路读 few-shot 探针;gamedef 路忽略)。 + * @return system 文本。 + */ + static String historySystem(boolean gamedef, Path gameRuntimeRoot) { + return gamedef ? GAMEDEF_SYSTEM : SYSTEM.replace("__FEWSHOT__", fewShot(gameRuntimeRoot)); + } + + /** + * 连续对话路首轮 user(仅题面,无失败回喂——首轮无上一版)。与 openai 路首轮 user 正文一致 + * (iife「请实现下面这款游戏…」/ gamedef「请为下面这款游戏产出 gameDefinition…」+ 品类提示),仅去掉「失败回喂」段 + * (连续对话首轮恒无回喂)。 + * + * @param gamedef true=gamedef 路 user;false=iife 路 user。 + * @param briefText 题面(含设计稿 enriched)。 + * @param archetype 品类原型(仅 gamedef 路附提示;generic/空不附)。 + * @return 首轮 user 文本。 + */ + static String historyFirstUser(boolean gamedef, String briefText, String archetype) { + if (gamedef) { + StringBuilder user = new StringBuilder("请为下面这款游戏产出一份 gameDefinition(只输出一个 JSON 对象,不要 ```代码块、不要解释):\n\n"); + user.append(briefText == null ? "" : briefText); + if (archetype != null && !archetype.isEmpty() && !"generic".equals(archetype)) { + user.append("\n\n(品类提示:").append(archetype).append(")"); + } + appendSkeletonIfAny(user, archetype); // Phase 2:anthropic 路首轮注骨架(救场轮走 historyRepairUser、不注) + return user.toString(); + } + return "请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 ```js 代码块):\n\n" + + (briefText == null ? "" : briefText); + } + + /** + * 连续对话路救场 user(plan U2:去「重新产出完整模块(不要只给 diff)」、改「针对失败反馈增量修正」)。 + * + *

语义差异(与 openai 路 {@link #buildMessages}/{@link #buildGameDefMessages} 的回喂段刻意不同):连续对话路上一版 + * 答案文本已在历史里(模型看得到自己上一版源;U2 创始人定·历史只留答案文本不留 thinking),故救场不必命令 + * 「重出完整模块」——改为「在上一版基础上针对反馈增量修正」,省 token 且对齐 a3(救场=续上一版增量修,非从头重生成)。 + * + * @param gamedef true=gamedef 路(产物=JSON);false=iife 路(产物=```js 模块)。 + * @param feedback 失败 verdict/反馈(H_progress 未过等;调用方保证非空)。 + * @return 救场 user 文本。 + */ + static String historyRepairUser(boolean gamedef, String feedback) { + String fb = feedback == null ? "" : feedback; + if (gamedef) { + return "上一次生成【未通过】,失败原因如下。请在你上一版 gameDefinition 的基础上," + + "针对失败反馈**增量修正**(只改与反馈相关处,其余结构尽量保留):\n" + fb; + } + return "上一次生成【未通过】,失败原因如下。请在你上一版工厂模块的基础上," + + "针对失败反馈**增量修正**(只改与反馈相关处,仍产出一个完整可装载的 ```js 模块):\n" + fb; + } + /** base 源注入上限(防超长 prompt 撑爆上下文/计费;便宜模型上下文窗口有限)。超出则截断并标注。 */ private static final int SOURCE_PROJECT_MAX_CHARS = 12000; @@ -477,6 +559,31 @@ final class SaaPrompts { if (on.has("assertAfterPlay") && !on.get("assertAfterPlay").isArray()) { on.set("assertAfterPlay", MAPPER.createArrayNode()); } + // 净化 assertAfterPlay:终局态断言不应否决「进展门」H_progress。只要存在≥1 条进展断言(op∈ + // increased/decreased/changed/比较),就只保留进展断言、丢弃所有非进展(==/in/!=…)的终局态断言—— + // bot 限时真玩常到不了 gameover/win,但 score 已涨=确有进展(如井字棋 score 0→9 却被 phase=="gameover" + // 或误编码 score=="gameover" 误判红线)。无任何进展断言时(如规避类只给 result=="win")全保留——终局即其唯一 + // 进展信号。终局完成度另由 expectLatch 把关。lili-mac 快走查 extract_gatespec 同步镜像此过滤(drift-free)。 + if (on.has("assertAfterPlay") && on.get("assertAfterPlay").isArray()) { + com.fasterxml.jackson.databind.node.ArrayNode src = + (com.fasterxml.jackson.databind.node.ArrayNode) on.get("assertAfterPlay"); + boolean hasProgress = false; + for (JsonNode a : src) { + if (a != null && a.isObject() && isProgressOp(a.path("op").asText(""))) { + hasProgress = true; + break; + } + } + if (hasProgress) { + com.fasterxml.jackson.databind.node.ArrayNode kept = MAPPER.createArrayNode(); + for (JsonNode a : src) { + if (a != null && a.isObject() && isProgressOp(a.path("op").asText(""))) { + kept.add(a); + } + } + on.set("assertAfterPlay", kept); + } + } // expectLatch 缺省补 true(官方 latch 契约恒需,对齐 studio.py:95)。 if (!on.has("expectLatch")) { on.put("expectLatch", true); @@ -494,6 +601,22 @@ final class SaaPrompts { return on; } + /** 进展类 op(度量在动):用于「有进展断言则只留进展断言」过滤,把终局态(==/in/!=)断言挡在 H_progress 之外(详见 extractGatespec 注释)。 */ + private static boolean isProgressOp(String op) { + switch (op) { + case "increased": + case "decreased": + case "changed": + case ">": + case ">=": + case "<": + case "<=": + return true; + default: + return false; + } + } + /** * 宽松 JSON 解析(替代 Python json_repair,A-5 加强): * ① 严格 Jackson;② 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格; diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaSkeletons.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaSkeletons.java new file mode 100644 index 00000000..f92fbd19 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaSkeletons.java @@ -0,0 +1,78 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Plan A·Phase 2(Template-First gamedef 骨架,2026-06-20;OpenGame +10.1 杠杆): + * 给生成的 generate 角色按 archetype 注入一份【已过九门的纯净 gameDefinition 骨架】,让模型「在骨架上改成题面玩法」 + * 而非从零写——降低「rt 面接线 / 运动逻辑」从零写错的概率(哑火静态游戏=跨款最大失败簇)。 + * + *

只用 3 个金标准过门纯净源(gd-breakout/tictactoe/simon:均 verdict.pass=true + rt 面纯净 + 过 Phase3 坑门), + * 按 (tick/input) profile 相近映射到 archetype;无近似骨架的 archetype(merge/idle/tycoon 经济类)走【无骨架】(沿用现 hint), + * 绝不为凑覆盖喂低质/违禁骨架(Phase2 研究铁律:13 个 gd-* 仅 3 个纯净,其余含违禁 token 或未过门)。 + * + *

flag 旁挂、默认关:{@code -Dsaa.gen.skeletonFirst=1} 开启(A/B 用 thinking-off 衡量能否拉近 67%→91.7%、省 thinking 10x 成本); + * 默认关=生成行为字节不变(零回归)。骨架为静态文件,自身经 {@code validateSourceProject} 校验合法(gameplay-pitfalls.test 旁证)。 + */ +final class SaaSkeletons { + private static final Logger log = LoggerFactory.getLogger(SaaSkeletons.class); + + /** flag 旁挂:默认关(字节零变);{@code -Dsaa.gen.skeletonFirst=1/true} 开启骨架注入。 */ + private static final boolean ENABLED = "1".equals(System.getProperty("saa.gen.skeletonFirst")) + || "true".equalsIgnoreCase(System.getProperty("saa.gen.skeletonFirst")); + + /** archetype → 骨架源名(按 profile 相近映射;仅 3 金标准源,无近似骨架的 archetype 不登记=走无骨架)。 */ + private static final Map ARCHETYPE_SOURCE = new LinkedHashMap<>(); + static { + // 实时动作类 → breakout(realtime + vx/vy 物理 + pointer 控制 + 碰撞 + remaining 下降=进展) + ARCHETYPE_SOURCE.put("generic", "breakout"); + ARCHETYPE_SOURCE.put("dodge", "breakout"); + ARCHETYPE_SOURCE.put("runner", "breakout"); + ARCHETYPE_SOURCE.put("bubble", "breakout"); + // 离散点选类 → tictactoe(clickable 网格 + 离散选 + rt.addScore 进展) + ARCHETYPE_SOURCE.put("clicker", "tictactoe"); + ARCHETYPE_SOURCE.put("match3", "tictactoe"); + // 回合序列类 → simon(clickable + 回合 + 状态实体存 phase/seq=跨 behavior 态正解示范) + ARCHETYPE_SOURCE.put("line-clear", "simon"); + // merge / idle / tycoon(经济类)无近似纯净骨架 → 不登记,走无骨架(沿用现 hint),避免喂错结构。 + } + + /** 骨架内容缓存(源名 → gameDefinition JSON 文本);类加载期一次性装载,缺失只 warn 不抛(对齐 PromptResourceLoader「资源缺失只禁用」精神)。 */ + private static final Map CACHE = new LinkedHashMap<>(); + static { + for (String src : new String[]{"breakout", "tictactoe", "simon"}) { + String path = "wanxiang-contracts/agent-loop/skeletons/" + src + ".json"; + try (InputStream in = SaaSkeletons.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) { + log.warn("[saa-skeleton] 骨架资源缺失,该源禁用:{}", path); + } else { + CACHE.put(src, new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } + } catch (Exception e) { + log.warn("[saa-skeleton] 骨架装载失败,该源禁用:{}({})", path, e.getMessage()); + } + } + } + + private SaaSkeletons() { + } + + /** + * 取该 archetype 的骨架 gameDefinition JSON(flag 关 / 无映射 / 资源缺失 → 返 null,调用方据此决定是否注入)。 + * + * @param archetype classify 节点产出的 archetype(已 normalize 为合法枚举)。 + * @return 骨架 JSON 文本,或 null(不注入)。 + */ + static String forArchetype(String archetype) { + if (!ENABLED || archetype == null) { + return null; + } + String src = ARCHETYPE_SOURCE.get(archetype); + return src == null ? null : CACHE.get(src); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java index b3a44218..e1449a92 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioGraph.java @@ -10,10 +10,19 @@ import com.alibaba.cloud.ai.graph.exception.GraphStateException; import com.alibaba.cloud.ai.graph.observation.GraphObservationLifecycleListener; import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; import io.micrometer.observation.ObservationRegistry; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; +import java.net.Proxy; import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -122,6 +131,15 @@ public final class SaaStudioGraph { return new Models(M_DESIGN, M_CODE, M_FIX, M_PLAYER_TEXT, M_PLAYER_VISION, M_CLASSIFY, M_NARRATIVE, M_CODE_STAGE2, M_FIX_STAGE2, M_CODE_FALLBACK, M_FIX_FALLBACK); } + + /** + * 全角色统一同一模型(Plan A U5「只用 M3」创始人 2026-06-19;bake-off / 单模型基线用)。 + * 11 个角色名全置为 {@code model}(含 stage2 强档位与 code/fix fallback 位 → 单模型路无跨家回退,符合"只用 M3"语义); + * per-role maxTokens/temperature 不变(在 assemble 设),故小位(如 playerVision=900)仍按其预算、thinking 预算 clamp 后自动降级。 + */ + public static Models allSame(String model) { + return new Models(model, model, model, model, model, model, model, model, model, model, model); + } } /** @@ -142,12 +160,206 @@ public final class SaaStudioGraph { if (temperature != null) { opts.temperature(temperature); // null = 不下发,用服务端默认(对齐 Python design 角色) } + // 关闭思考(创始人 2026-06-20 拍板):M3 OpenAI 兼容路 thinking:adaptive 会把整段推理内联进 content、 + // 在写出 JSON 前就吃光 maxTokens → finish_reason=length 截断 → 节点侧无 length 检测、伪装成 parse 失败/llm_error。 + // 三子代理取证 + 活探针实证:默认 adaptive 4K 预算全耗在 上、JSON 一字未出(finish_reason=length); + // thinking:{type:disabled} 同题面当场吐干净紧凑 JSON(finish_reason=stop);worker 产线本就默认 disabled(13 种子可玩)。 + // 故对 MiniMax 系【全角色统一关思考】:生成角色保留 assemble 的 per-role maxTokens(16000,足装真 JSON ~8K),不再抬 60K。 + // 仅 MiniMax 注入;deepseek 等不动(无此坑、字节零变)。回退:要复开思考改回 {type:adaptive}+reasoning_split+抬上限即可。 + if (modelName != null && modelName.toLowerCase().contains("minimax")) { + opts.extraBody(java.util.Map.of("thinking", java.util.Map.of("type", "disabled"))); + } return OpenAiChatModel.builder() .openAiApi(api) .defaultOptions(opts.build()) .build(); } + /** + * 建一个走 new-api 的 {@link OpenAiApi},旁路系统代理(Plan A U5;macOS clash 等会把 Tailscale 内网 IP 走代理→502, + * 同 anthropic 路 {@link Proxy#NO_PROXY} 处理)。本机/内网量测 openai 兼容路必经此旁路;无代理环境为 no-op(安全)。 + */ + public static OpenAiApi openAiApiNoProxy(String baseUrl, String apiKey, int readTimeoutMs) { + SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); + rf.setProxy(Proxy.NO_PROXY); + rf.setConnectTimeout(10_000); + // Plan A U5:读超时可配(M3+thinking 慢、原 120s 在 K=10 下致超时;默认 120s,量测设 600s)。<=0 兜底 120s。 + rf.setReadTimeout(readTimeoutMs > 0 ? readTimeoutMs : 120_000); + // WebClient(流式 .stream() 用)也须旁路代理——否则 macOS clash 拦 Tailscale IP → 502(实测 WebClientResponseException 502 Bad Gateway)。 + // 用 JDK HttpClient + 显式 NO_PROXY ProxySelector(编译期可见、避 reactor.netty 非 main 编译依赖)+ JdkClientHttpConnector 注入 WebClient。 + java.net.http.HttpClient jdkHttp = java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(10)) + .proxy(new java.net.ProxySelector() { // 每个请求恒返 NO_PROXY = 直连,旁路 clash + @Override public java.util.List select(java.net.URI uri) { return java.util.List.of(java.net.Proxy.NO_PROXY); } + @Override public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, java.io.IOException ioe) { } + }) + .build(); + org.springframework.web.reactive.function.client.WebClient.Builder wcb = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.JdkClientHttpConnector(jdkHttp)); + return OpenAiApi.builder() + .baseUrl(baseUrl) + .apiKey(apiKey) + .restClientBuilder(RestClient.builder().requestFactory(rf)) + .webClientBuilder(wcb) + .build(); + } + + /** + * per-role 模型工厂(Plan A/U1「用对 M3」flag 旁挂的缝):把「角色名+采样面 → {@link ChatModel}」抽象成函数, + * 使 {@link #assemble} 的节点布线与「底层走 OpenAI 兼容还是 Anthropic 协议」解耦。 + * + *

回归命门:openai 工厂({@link #openAiFactory(OpenAiApi)})逐字调现行 {@link #model(OpenAiApi, String, Double, int)}, + * 产出与改造前完全相同的 {@link OpenAiChatModel} 对象 → 默认 openai 路运行时字节等价;anthropic 工厂 + * ({@link #anthropicFactory(String, String, int)})产 {@link AnthropicChatModel}(thinking 原生分离),仅 flag=anthropic 时注入。 + * + * @param modelName 角色模型名(per-role,如 deepseek-v4-flash / MiniMax-M3) + * @param temperature 采样温度(null=不下发,用服务端默认;anthropic 路同口径透传) + * @param maxTokens 最大补全 tokens(per-role,如 code/fix=16000、player_vision=900) + * @return 该角色的 {@link ChatModel}(OpenAI 或 Anthropic 实现) + */ + @FunctionalInterface + public interface RoleModelFactory { + ChatModel create(String modelName, Double temperature, int maxTokens); + } + + /** + * OpenAI 兼容口径的 per-role 工厂(默认路):逐字调 {@link #model(OpenAiApi, String, Double, int)},运行时字节等价改造前。 + * + * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) + * @return openai per-role 工厂 + */ + public static RoleModelFactory openAiFactory(OpenAiApi api) { + return (name, temperature, maxTokens) -> { + OpenAiChatModel m = model(api, name, temperature, maxTokens); + // Plan A U5「流式避读超时」(创始人 2026-06-19):MiniMax 系大输出(M3+thinking)非流式整体生成 >readTimeout 会读超时 + // → 包装成流式收取(块持续到达、不饿死读计时);deepseek 快、不变(返原 model 字节零变)。 + if (name != null && name.toLowerCase().contains("minimax")) { + return new StreamingCallChatModel(m); + } + return m; + }; + } + + /** + * 流式收取包装(Plan A U5「流式避读超时」创始人 2026-06-19):把底层 {@link ChatModel} 的阻塞 {@code call()} 改为 + * 「{@code stream()} 收块 → {@link org.springframework.ai.chat.model.MessageAggregator} 拼成整 {@link ChatResponse}」。 + * M3+thinking 大输出整体生成 >readTimeout 会读超时;流式下块持续到达、不饿死读计时 → 避超时。 + * 对调用方(节点)透明:仍 {@code call()} 返完整 ChatResponse(用量/finishReason 经 MessageAggregator 拼回)。 + */ + static final class StreamingCallChatModel implements ChatModel { + private final ChatModel delegate; + StreamingCallChatModel(ChatModel delegate) { this.delegate = delegate; } + @Override + public ChatResponse call(Prompt prompt) { + // 流式收块 + 官方 MessageAggregator 聚合为单 ChatResponse(含 content 拼接 + usage 末块);回调取聚合结果。 + java.util.concurrent.atomic.AtomicReference agg = new java.util.concurrent.atomic.AtomicReference<>(); + new org.springframework.ai.chat.model.MessageAggregator().aggregate(delegate.stream(prompt), agg::set).blockLast(); + return agg.get(); + } + @Override + public reactor.core.publisher.Flux stream(Prompt prompt) { + return delegate.stream(prompt); + } + @Override + public org.springframework.ai.chat.prompt.ChatOptions getDefaultOptions() { + return delegate.getDefaultOptions(); + } + } + + /** + * 建一个经 Anthropic Messages 协议指向 new-api 的 {@link AnthropicChatModel}(Plan A/U1「用对 M3」;thinking 原生分离, + * 治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因)。lili-mac spike 1.1.2 实测定死配方: + *

    + *
  • baseUrl = new-api host 根(如 {@code http://100.64.0.8:3000},completionsPath 保持默认 {@code /v1/messages} + * → 真实打 {@code .../v1/messages})。绝不加 {@code /anthropic} 前缀(命中 SPA catch-all 返 HTML→假挂起); + * 亦不复用 {@link #stripV1Suffix}(那是 OpenAI 口径的坑,与 Anthropic 协议无关);
  • + *
  • 鉴权 = {@code apiKey(key)}(发 {@code x-api-key} 头)。实测:x-api-key → 多 Generation 原生 thinking block + * (含 signature=推理 / 无 signature=答案);勿用 Authorization Bearer(实测退化为字面 {@code } 内联,正是要躲的坑);
  • + *
  • 代理旁路:经 {@link SimpleClientHttpRequestFactory} 设 {@link Proxy#NO_PROXY} + 连接/读超时,经 + * {@code AnthropicApi.builder().restClientBuilder(...)} 注入(1.1.2 的 {@code AnthropicChatModel$Builder} 无 customHeaders, + * 自定义只能经 restClientBuilder);
  • + *
  • thinking:{@code thinking(ENABLED, budget)},{@code budget < maxTokens} 硬约束(client 侧 fail-fast 断言, + * 勿信网关宽松、否则重演原截断 bug);阻塞 {@code .call()}(保留默认 RetryTemplate,流式 thinking 有 bug #4407 不用)。
  • + *
+ * + * @param anthropicBase Anthropic 协议 baseUrl(new-api host 根,无 /anthropic 前缀;= AigcExecutorProperties.saaAnthropicBase) + * @param apiKey new-api 密钥(发 x-api-key 头;密钥经配置注入,绝不入 repo) + * @param thinkingBudget thinking 预算 tokens(须 < 各角色 maxTokens;= AigcExecutorProperties.saaThinkingBudget) + * @return anthropic per-role 工厂 + */ + public static RoleModelFactory anthropicFactory(String anthropicBase, String apiKey, int thinkingBudget) { + return (name, temperature, maxTokens) -> { + AnthropicChatModel m = anthropicModel(anthropicBase, apiKey, name, temperature, maxTokens, thinkingBudget); + // 流式收取(同 openai 路 StreamingCallChatModel):把阻塞 .call() 改 stream()+MessageAggregator 聚合, + // 避 M3+thinking 大输出阻塞整取读超时(B 实测阻塞在 adaptive+256k 下大面积 SocketTimeout)。 + // #4407(流式 thinking 缺陷)报在 spring-ai 1.0.2 且伴 tool_use、已 Closed;我们 1.1.2 + 生成无工具调用,n=1 实测确认流式可用。 + return new StreamingCallChatModel(m); + }; + } + + /** + * 构造单个 Anthropic 协议角色模型(配方见 {@link #anthropicFactory})。 + * + * @param anthropicBase Anthropic 协议 baseUrl(host 根、无 /anthropic) + * @param apiKey new-api 密钥(x-api-key) + * @param modelName 角色模型名 + * @param temperature 采样温度(null=不下发) + * @param maxTokens 最大补全 tokens + * @param thinkingBudget thinking 预算(全局上限;per-role 自适应 clamp 到 {@code min(全局, maxTokens-余量)} 恒 {@code < maxTokens}; + * 容不下 Anthropic 最小(1024) 的小判定角色则关 thinking,P0-2) + * @return Anthropic 角色模型 + */ + public static AnthropicChatModel anthropicModel(String anthropicBase, String apiKey, String modelName, + Double temperature, int maxTokens, int thinkingBudget) { + // 代理旁路 + 连接/读超时:macOS/容器系统代理(clash 等)会把 Tailscale IP 走代理→502;NO_PROXY 直连 new-api。 + SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); + rf.setProxy(Proxy.NO_PROXY); + rf.setConnectTimeout(10_000); + // Plan A U5:M3+thinking 阻塞 .call() 单次(16K content+8K thinking≈24K token)易 >120s 读超时; + // anthropic 路恒阻塞(流式 thinking 有 bug #4407 不用),故读超时放宽至 600s(单局总超时 900s 兜底)。 + rf.setReadTimeout(600_000); + // 流式收取避读超时(创始人 2026-06-20:B 实测阻塞整取在 adaptive+256k 下大面积 SocketTimeout,慢局 20-31min): + // 流式走 WebClient,须同 openAiApiNoProxy 注入 NO_PROXY webClient(否则 macOS clash 拦 Tailscale IP→502)。 + // 用 JDK HttpClient + 显式 NO_PROXY ProxySelector + JdkClientHttpConnector(编译期可见、避 reactor.netty 非 main 依赖)。 + java.net.http.HttpClient jdkHttp = java.net.http.HttpClient.newBuilder() + .connectTimeout(java.time.Duration.ofSeconds(10)) + .proxy(new java.net.ProxySelector() { // 每请求恒返 NO_PROXY = 直连,旁路 clash + @Override public java.util.List select(java.net.URI uri) { return java.util.List.of(java.net.Proxy.NO_PROXY); } + @Override public void connectFailed(java.net.URI uri, java.net.SocketAddress sa, java.io.IOException ioe) { } + }) + .build(); + org.springframework.web.reactive.function.client.WebClient.Builder wcb = + org.springframework.web.reactive.function.client.WebClient.builder() + .clientConnector(new org.springframework.http.client.reactive.JdkClientHttpConnector(jdkHttp)); + AnthropicApi api = AnthropicApi.builder() + .baseUrl(anthropicBase) // host 根;completionsPath 默认 /v1/messages(绝不加 /anthropic,勿用 stripV1Suffix) + .apiKey(apiKey) // 发 x-api-key 头(勿用 Authorization Bearer) + .restClientBuilder(RestClient.builder().requestFactory(rf)) // 阻塞兜底(1.1.2 无 customHeaders,自定义只能经 restClientBuilder) + .webClientBuilder(wcb) // 流式路(StreamingCallChatModel 用):NO_PROXY 直连 new-api,避 clash 502 + .build(); + // P0-2 修:per-role thinking budget 自适应(替原 fail-fast 抛)。assemble 给判定类角色硬编码小 maxTokens + // (playerVision=900/classify=1000/narrative=2000/playerText=4000),单一全局 budget(默认8192) 撞之即抛、anthropic 路 assemble 崩。 + // 正解:budget = min(全局, maxTokens-答案余量),恒 < maxTokens(守协议约束);容不下 Anthropic 最小 thinking(1024) + // 的小判定角色不开 thinking(判定/审查非生成、无需推理);生成角色(maxTokens 16000)仍用全局 8192。 + final int MIN_THINKING = 1024; // Anthropic thinking budget_tokens 下限 + final int ANSWER_RESERVE = 1024; // 给答案/工具调用留的最小补全空间 + int effectiveBudget = Math.min(thinkingBudget, maxTokens - ANSWER_RESERVE); + AnthropicChatOptions.Builder opts = AnthropicChatOptions.builder() + .model(modelName) + .maxTokens(maxTokens); + if (effectiveBudget >= MIN_THINKING) { + opts.thinking(AnthropicApi.ThinkingType.ENABLED, effectiveBudget); // 生成角色:thinking 开(原生分离 block) + } // else:maxTokens 太小的判定类角色不开 thinking(避免 budget≥maxTokens 崩;判定/审查无需推理) + if (temperature != null) { + opts.temperature(temperature); // null = 不下发(与 openai 路同口径) + } + return AnthropicChatModel.builder() + .anthropicApi(api) + .defaultOptions(opts.build()) // 默认 RetryTemplate 兜瞬时;流式收取在 anthropicFactory 包 StreamingCallChatModel(#4407 已 Closed/1.1.2+无工具,n=1 实测确认) + .build(); + } + /** * 剥 new-api host 根末尾的 {@code /v1}(Spring AI OpenAiApi 自拼 completionsPath=/v1/chat/completions, * baseUrl 带 /v1 → 404 /v1/v1/...;坑见迁移设计 §7-1)。同一 .env(NEWAPI_BASE_URL=.../v1)两侧通用须 Java 侧剥之。 @@ -201,7 +413,7 @@ public final class SaaStudioGraph { * P0-4:deterministic 路本期 fail-loud 到 END(「从 sourceProject 真构建」管线待 B7+引擎线 E2;不静默走会忽略 * sourceProject 的旧 build);regenerate-module 路走 generate(本期可达)。modify 非孤儿、真做事(真改源/真置终态,无空壳)。 * - * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) + * @param mf per-role 模型工厂({@link #openAiFactory} 默认路·字节等价 / {@link #anthropicFactory} flag=anthropic) * @param models 角色模型名(含固定架构 classify/narrative) * @param gameRuntimeRoot game-runtime 根(harness/few-shot/证据所在;build/play 子进程 cwd) * @param maxRepairs stage1 修复轮上限(对齐 studio.py:max_repairs 默认 5) @@ -210,29 +422,29 @@ public final class SaaStudioGraph { * @return 未编译的全图(供 {@link #build} 挂 CompileConfig 后编译) * @throws GraphStateException 图结构非法(节点/边布线错误,编译期暴露) */ - private static StateGraph assemble(OpenAiApi api, Models models, Path gameRuntimeRoot, + private static StateGraph assemble(RoleModelFactory mf, Models models, Path gameRuntimeRoot, int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, - String sourceMode) + String sourceMode, boolean historyEnabled) throws GraphStateException { - // == per-role 采样面(temperature/maxTokens)逐字对齐 Python == + // == per-role 采样面(temperature/maxTokens)逐字对齐 Python(经 mf 工厂建 ChatModel:openai 默认路字节等价;anthropic flag 旁挂)== // design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发); // code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出); // player_text:studio.py:167 max_tokens=4000、temperature=0.3; // player_vision:studio.py:160 max_tokens=900、temperature=0.3。 - OpenAiChatModel designModel = model(api, models.design(), null, 16000); - OpenAiChatModel codeModel = model(api, models.code(), 0.0, 16000); - OpenAiChatModel fixModel = model(api, models.fix(), 0.0, 16000); + ChatModel designModel = mf.create(models.design(), null, 16000); + ChatModel codeModel = mf.create(models.code(), 0.0, 16000); + ChatModel fixModel = mf.create(models.fix(), 0.0, 16000); // P1-5:救场 stage2 强档 code/fix(同 16000/temperature=0,仅模型名换强档);generate 据 modelTier 真切,使升档生效。 - OpenAiChatModel codeStage2Model = model(api, models.codeStage2(), 0.0, 16000); - OpenAiChatModel fixStage2Model = model(api, models.fixStage2(), 0.0, 16000); + ChatModel codeStage2Model = mf.create(models.codeStage2(), 0.0, 16000); + ChatModel fixStage2Model = mf.create(models.fixStage2(), 0.0, 16000); // U4 回退档(同采样面 0.0/16000,仅模型名换跨家;generateNode 主耗尽重试后再试一档)。 - OpenAiChatModel codeFallbackModel = model(api, models.codeFallback(), 0.0, 16000); - OpenAiChatModel fixFallbackModel = model(api, models.fixFallback(), 0.0, 16000); - OpenAiChatModel playerTextModel = model(api, models.playerText(), 0.3, 4000); - OpenAiChatModel playerVisionModel = model(api, models.playerVision(), 0.3, 900); + ChatModel codeFallbackModel = mf.create(models.codeFallback(), 0.0, 16000); + ChatModel fixFallbackModel = mf.create(models.fixFallback(), 0.0, 16000); + ChatModel playerTextModel = mf.create(models.playerText(), 0.3, 4000); + ChatModel playerVisionModel = mf.create(models.playerVision(), 0.3, 900); // 固定架构新增两角色(B2):classify(物理优先分类,温度低求稳)、narrative-reviewer(叙事审查,温度低)。 - OpenAiChatModel classifyModel = model(api, models.classify(), 0.0, 1000); - OpenAiChatModel narrativeModel = model(api, models.narrative(), 0.3, 2000); + ChatModel classifyModel = mf.create(models.classify(), 0.0, 1000); + ChatModel narrativeModel = mf.create(models.narrative(), 0.3, 2000); // == state schema:全 key 用 ReplaceStrategy(节点 return 覆盖写,对齐 dossier §1-22)== KeyStrategyFactory keyFactory = () -> { @@ -253,7 +465,7 @@ public final class SaaStudioGraph { codeFallbackModel, fixFallbackModel, models.code(), models.fix(), models.codeStage2(), models.fixStage2(), models.codeFallback(), models.fixFallback()), - gameRuntimeRoot, sourceMode))) + gameRuntimeRoot, sourceMode, historyEnabled))) // U2:anthropic 路 history 开(连续对话救场);openai 路关(字节零变) .addNode("validate", node_async(SaaGenNodes.validateNode(gameRuntimeRoot, sourceMode))) .addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(gameRuntimeRoot))) .addNode("asset", node_async(SaaStudioNodes.assetNode())) @@ -408,6 +620,11 @@ public final class SaaStudioGraph { * 组装并编译全图(plan 2026-06-18-001 U3 全量重载:显式 {@code sourceMode}=factory|gamedef)。 * 布线唯一源 = {@link #assemble}(所有 build 重载共用)。 * + *

本重载 = OpenAI 兼容路(默认,行为字节不变):内部 delegate 到 {@link #build(RoleModelFactory, Models, Path, + * int, int, int, String, SaverConfig, ObservationRegistry)} 并传 {@link #openAiFactory(OpenAiApi)}(逐字调现行 + * {@link #model(OpenAiApi, String, Double, int)},运行时字节等价)。anthropic 路 = dispatcher 经 flag 改传 + * {@link #anthropicFactory(String, String, int)}(U1 旁挂)。 + * * @param sourceMode 生成产物形态(factory=iife 现行 / gamedef=真结构化源;dispatcher 据 AigcExecutorProperties.saaSourceMode 传)。 */ public static CompiledGraph build(OpenAiApi api, Models models, Path gameRuntimeRoot, @@ -415,8 +632,51 @@ public final class SaaStudioGraph { String sourceMode, SaverConfig saverConfig, ObservationRegistry observationRegistry) throws GraphStateException { - StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, - stage2ExtraRepairs, sourceMode); + // openai 工厂 delegate(字节等价改造前:openAiFactory 逐字调 model(api,...))。 + return build(openAiFactory(api), models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs, + sourceMode, saverConfig, observationRegistry); + } + + /** + * 组装并编译全图(Plan A/U1 协议旁挂全量重载:显式 {@link RoleModelFactory}=openai 默认 / anthropic flag)。 + * 布线唯一源仍是 {@link #assemble}(openai/anthropic 两路共用同一布线,仅 per-role 模型实现不同,杜绝 split-brain)。 + * + * @param mf per-role 模型工厂({@link #openAiFactory} / {@link #anthropicFactory}) + * @param models 角色模型名 + * @param gameRuntimeRoot game-runtime 根 + * @param maxRepairs stage1 修复轮上限 + * @param maxPlayerRounds player 顾问轮上限 + * @param stage2ExtraRepairs stage2 额外修复轮上限 + * @param sourceMode 生成产物形态(factory|gamedef) + * @param saverConfig checkpoint saver 配置(null=默认内存态) + * @param observationRegistry 观测注册表(null=不埋点) + * @return 已编译图 + * @throws GraphStateException 图结构非法 + */ + public static CompiledGraph build(RoleModelFactory mf, Models models, Path gameRuntimeRoot, + int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, + String sourceMode, + SaverConfig saverConfig, ObservationRegistry observationRegistry) + throws GraphStateException { + // historyEnabled 默认 false(openai 路 / 不关心历史的回归测试):现行行为字节不变。 + // anthropic 路经 10 参重载传 historyEnabled=true(dispatcher 据 saaModelProtocol 选)。 + return build(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs, + sourceMode, /*historyEnabled*/ false, saverConfig, observationRegistry); + } + + /** + * 组装并编译全图(Plan A/U2 历史旁挂全量重载:显式 {@code historyEnabled})。 + * 布线唯一源仍是 {@link #assemble}(openai/anthropic 两路共用同一布线,仅 per-role 模型实现 + generate 历史开关不同)。 + * + * @param historyEnabled true=anthropic 路(generate 连续对话历史 + thinking 跨轮保留);false=openai 路(每轮 [system,user],字节零变)。 + */ + public static CompiledGraph build(RoleModelFactory mf, Models models, Path gameRuntimeRoot, + int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, + String sourceMode, boolean historyEnabled, + SaverConfig saverConfig, ObservationRegistry observationRegistry) + throws GraphStateException { + StateGraph graph = assemble(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, + stage2ExtraRepairs, sourceMode, historyEnabled); CompileConfig.Builder cc = CompileConfig.builder() .recursionLimit(recursionLimitFor(maxRepairs, stage2ExtraRepairs)); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java index 519281cd..e6237983 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -6,11 +6,14 @@ import com.alibaba.cloud.ai.graph.action.NodeAction; import com.fasterxml.jackson.core.JsonPointer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; import org.springframework.core.io.ByteArrayResource; @@ -109,6 +112,17 @@ final class SaaStudioNodes { static final String K_GIVEUP_DUMP_PATH = "giveupDumpPath"; // 放弃前完整 dump 落盘路径(giveup 写;Opus 离线读,B4 落盘) static final String K_NARRATIVE_VERDICT = "narrativeReviewVerdict"; // narrative 质量门裁决 JSON 串(nreview 产,§5.9) + // ====================== Plan A/U2 新增:连续对话历史键(仅 anthropic 路启用;多轮连续对话救场) ====================== + // 历史项 = 既往 user 轮 + 上一版【答案文本块】(U2 创始人定:只留答案 text、丢 thinking 块——stock spring-ai-anthropic + // 1.1.2 出站只发 text + tool_use、丢弃 thinking 块,故内存留 thinking 一经下发即被拍平成 text,「thinking interleaved + // 保留」名不副实;改只保留上一版响应文本,连续对话救场价值不变=模型看上一版答案 + 失败 verdict 增量修,不回灌推理白话)。 + // 跨轮保留进 List(system 不入历史·每轮现拼,仅 user 轮 + assistant 答案块入历史)。 + // 存储策略:ReplaceStrategy + 节点内 read-append-write(对齐 appendAttempt,勿进全量 ReplaceStrategy 误判,P1-4)。 + // 可序列化:经 SpringAIJacksonStateSerializer 往返保 List 类型(P0-5,SaaHistorySerdeTest 守)。 + // gamedef 路与 iife(factory) 路各自历史键(隔离,勿串味;sourceMode 决定写哪个)。 + static final String K_MSG_HISTORY_GAMEDEF = "msgHistoryGamedef"; // gamedef 路连续对话历史(List,纯答案文本) + static final String K_MSG_HISTORY_FACTORY = "msgHistoryFactory"; // factory(iife) 路连续对话历史(List,纯答案文本) + /** state key 全集(KeyStrategyFactory 注册用,全 ReplaceStrategy 覆盖语义)。 */ static final String[] ALL_KEYS = { K_BRIEF, K_ENRICHED, K_DESIGN_TEXT, K_GATESPEC, K_GATESPEC_ERROR, K_PLAY_SPEC, K_FACTORY_SRC, K_GAME_ID, @@ -126,6 +140,8 @@ final class SaaStudioNodes { K_MODIFY_MODE, K_MODIFY_PATCH, K_BASE_VERSION_ID, // modify 路:确定性|重生成 + 寻址载荷 + base 血缘 K_FAIL_COUNT, K_MODEL_TIER, K_ESCALATION_EVENTS, K_GIVEUP_DUMP_PATH, // 救场阶梯:连续九门失败计数 + 模型档 + 升档事件 + 放弃 dump 路径 K_NARRATIVE_VERDICT, // narrative 质量门裁决(progressModel=narrative 路,替九门) + // Plan A/U2:连续对话历史(仅 anthropic 路写·纯答案文本;ReplaceStrategy + 节点内 read-append-write,勿误判全量覆盖语义): + K_MSG_HISTORY_GAMEDEF, K_MSG_HISTORY_FACTORY, // gamedef / factory 路各自历史(隔离,勿串味) }; // ====================== ```js 抽取(对齐 validate.extract_code) ====================== @@ -151,8 +167,10 @@ final class SaaStudioNodes { // ====================== LLM 调用:单次 180s 超时 + ≤3 次重试(仅 429/5xx/IO/timeout) ====================== - /** 单次 LLM 调用墙钟超时秒数(对齐 Python _client.py:57 timeout=180.0)。 */ - private static final long LLM_TIMEOUT_SECS = 180; + /** 单次 LLM 调用墙钟超时秒数。Python worker thinking:disabled 用 180s;但 SAA【M3+thinking 流式生成单次实测 ~300-400s】, + * 180s 会半途 Future.cancel → 假"挂死" + JDK HttpClient 非守护线程泄漏(Plan A U5 实证)。放宽至 600s: + * 慢生成首次即完成(不触发取消→不泄线程)、重试只对真错误(429/5xx/IO,快);600s < perBriefSec 900s 单局预算。 */ + private static final long LLM_TIMEOUT_SECS = 600; /** LLM 最大尝试次数(对齐 Python _client.py:60 tries=3)。 */ private static final int LLM_MAX_TRIES = 3; /** 重试间隔毫秒(对齐 Python _client.py:60 retry_delay=2.0)。 */ @@ -231,22 +249,276 @@ final class SaaStudioNodes { return false; } - /** 调模型并把 usage 累计回 state(对齐 RecordingChatModel),含单次 180s 超时 + ≤3 重试。返回 [文本, inDelta, outDelta]。 */ + /** Anthropic thinking 块的在场标志键(实测 1.1.2:thinking Generation 的 {@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */ + private static final String ANTHROPIC_THINKING_SIGNATURE_KEY = "signature"; + + /** + * 从 {@link ChatResponse} 多 Generation 中取「答案文本」(Plan A/U1「用对 M3」;纯函数、无网络、可单测)。 + * + *

背景(lili-mac spike 1.1.2 实测):经 Anthropic Messages 协议 + thinking ENABLED 调 M3,单次阻塞 + * {@code .call()} 回来的 {@link ChatResponse#getResults()} 是多 Generation(size≥2):一个是 thinking 推理块 + * ({@code AssistantMessage.getMetadata()} 含 {@code signature} 键、{@code getText()}=推理文本),一个是真答案 + * (metadata 不含 {@code signature} 键、{@code getText()}=结构化答案)。若沿用旧 {@code getResult().getText()} + * 只取首个 Generation,会取到 thinking 推理而非答案(污染 JSON/```js 抽取)。 + * + *

识别答案靠 {@code signature} 键的存在性,不靠 {@code "thinking"} 键(实测 metadata 只见 + * {@code signature}、无 {@code thinking} 键)。从尾向前找第一个 metadata 不含 signature 的 Generation 取其文本 + * (末个答案优先);全是 thinking(异常)则兜底取末个 Generation。 + * + *

回归保证(命门):OpenAI 兼容路 {@code getResults().size()==1} → 直接取 {@code gens.get(0).getText()}, + * 与旧 {@code getResult().getOutput().getText()} 字节等价({@code getResult()} 即返首个 Generation)。 + * 故 design/classify/nreview/player 等任一走单 Generation 的路行为完全不变。 + * + * @param resp 模型阻塞调用响应(OpenAI 单 Generation / Anthropic+thinking 多 Generation)。 + * @return 答案文本(空响应返 "",绝不抛——交由调用方按节点语义兜底)。 + */ + static String pickAnswerText(ChatResponse resp) { + if (resp == null) { + return ""; + } + List gens = resp.getResults(); + if (gens == null || gens.isEmpty()) { + return ""; + } + // 单 Generation(OpenAI 兼容路 / Anthropic thinking 关):等价旧 getResult().getText()(回归保证)。 + if (gens.size() == 1) { + return textOf(gens.get(0)); + } + // 多 Generation(Anthropic+thinking):从尾向前找第一个 metadata 不含 signature 的 Generation(=答案,非 thinking 推理)。 + for (int i = gens.size() - 1; i >= 0; i--) { + Generation g = gens.get(i); + if (!hasThinkingSignature(g)) { + return textOf(g); + } + } + // 兜底(全是 thinking 块,异常态):取末个 Generation 文本(best-effort,不抛)。 + return textOf(gens.get(gens.size() - 1)); + } + + /** 取 Generation 的输出文本(空安全,null 归 "")。 */ + private static String textOf(Generation g) { + if (g == null || g.getOutput() == null) { + return ""; + } + String t = g.getOutput().getText(); + return t == null ? "" : t; + } + + /** 判该 Generation 是否为 Anthropic thinking 推理块({@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */ + private static boolean hasThinkingSignature(Generation g) { + if (g == null || g.getOutput() == null) { + return false; + } + AssistantMessage out = g.getOutput(); + Map meta = out.getMetadata(); + return meta != null && meta.containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY); + } + + /** + * 调模型并把 usage 累计回 state(对齐 RecordingChatModel),含单次 180s 超时 + ≤3 重试。 + * 返回 {@code [文本, inDelta(Long), outDelta(Long), orderedTurn(List)]}。 + * + *

U2:第 4 元素 {@code orderedTurn} = 本轮 {@code resp.getResults()} 的全部 {@link AssistantMessage}(有序, + * thinking 块在答案块前;复用 U1 已加的多 Generation 遍历)——anthropic 路据此把完整模型响应跨轮保留进历史 + * (openai 路单 Generation 时 orderedTurn=[单条答案],调用方按 historyEnabled 决定是否用)。 + */ private static Object[] callAndRecord(ChatModel model, String system, String user) throws Exception { + return callAndRecordMessages(model, List.of(new SystemMessage(system), new UserMessage(user))); + } + + /** + * U2:用完整 message 列表(连续对话:system + 历史轮 + 新 user)调模型,套同款 180s 超时 + ≤3 重试。 + * 返回 {@code [文本, inDelta, outDelta, orderedTurn(List)]}(同 {@link #callAndRecord})。 + * + *

anthropic 连续对话救场用此(messages 含历史 thinking/answer 块);openai 路仍走 {@link #callAndRecord} + * (两元 [system,user],字节零变)。 + */ + private static Object[] callAndRecordMessages(ChatModel model, List messages) throws Exception { return callWithTimeoutAndRetry(() -> { - Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user))); + Prompt prompt = new Prompt(messages); ChatResponse resp = model.call(prompt); - String text = resp.getResult().getOutput().getText(); + // U1:多 Generation 重组取答案(Anthropic+thinking 路答案在「无 signature」的 Generation;OpenAI 单 Generation 字节等价旧行为)。 + String text = pickAnswerText(resp); long in = 0, out = 0; Usage u = resp.getMetadata().getUsage(); if (u != null) { in = u.getPromptTokens() == null ? 0 : u.getPromptTokens(); out = u.getCompletionTokens() == null ? 0 : u.getCompletionTokens(); } - return new Object[]{text, in, out}; + // U2:抽本轮有序 AssistantMessage 序列(thinking 先序,复用 U1 getResults 遍历)。 + List orderedTurn = orderedAssistantTurn(resp); + return new Object[]{text, in, out, orderedTurn}; }); } + /** + * U2:把 {@link ChatResponse} 的多 Generation 重组为有序 {@link AssistantMessage} 序列(thinking 块在答案块前, + * 符 Anthropic「assistant 轮以 thinking 起」铁律)。openai 单 Generation → 单元素 list;空响应 → 空 list(不抛)。 + */ + private static List orderedAssistantTurn(ChatResponse resp) { + List turn = new ArrayList<>(); + if (resp == null || resp.getResults() == null) { + return turn; + } + for (Generation g : resp.getResults()) { + if (g != null && g.getOutput() != null) { + turn.add(g.getOutput()); // getResults() 已是「thinking 先序」顺序(U1 实测),原序保留 + } + } + return turn; + } + + // ====================== U2:连续对话历史(仅 anthropic 路;read-append-write + 纯答案文本 + 有界截断) ====================== + + /** + * 历史保最近 K 轮(user+答案),更早轮丢弃,守 token 上界(plan P1-3 简化版)。K 取保守值——深度 8 救场下保最近 + * 3 轮(上一版答案 + 失败 verdict)足够「续上一版增量修」,更早轮对当前修复已无边际价值,丢之省 token + 防累积撑爆。 + */ + private static final int HISTORY_MAX_TURNS = 3; + /** 单条答案 text 上限(守 token;超出截断标注)。 */ + private static final int HISTORY_ANSWER_MAX_CHARS = 8000; + + /** + * U2(创始人定):判 AssistantMessage 是否为 thinking 推理块(metadata 含 signature 键;与 {@link #hasThinkingSignature} + * 同口径)。仅用于 {@link #appendHistory} 入历史时丢弃 thinking 块——stock spring-ai-anthropic 1.1.2 出站丢弃 + * thinking 块,内存留 thinking 一经下发即被拍平成 text,故历史只留答案块(无 signature)即可,不回灌推理白话。 + */ + private static boolean isThinkingMsg(Message m) { + if (!(m instanceof AssistantMessage am) || am.getMetadata() == null) { + return false; + } + return am.getMetadata().containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY); + } + + /** 取本 sourceMode 的历史键名(gamedef / factory 路各自隔离,勿串味)。 */ + private static String historyKey(boolean gamedef) { + return gamedef ? K_MSG_HISTORY_GAMEDEF : K_MSG_HISTORY_FACTORY; + } + + /** + * 读 state 内本路历史({@code List});缺/类型不符 → 空 list(不抛)。 + *

注:历史经 checkpoint 序列化往返后仍是类型化 {@code List}(P0-5,SaaHistorySerdeTest 守); + * 极端非法值(理论不可达)防御性返空。 + */ + @SuppressWarnings("unchecked") + private static List readHistory(OverAllState s, boolean gamedef) { + Object v = s.value(historyKey(gamedef)).orElse(null); + if (v instanceof List list) { + List out = new ArrayList<>(); + for (Object el : list) { + if (el instanceof Message msg) { + out.add(msg); + } + // 非 Message 元素(理论不可达:序列化保类型)跳过,不连坐(best-effort)。 + } + return out; + } + return new ArrayList<>(); + } + + /** + * 据历史 + system + 本轮新 user 组连续对话 Prompt 的 message 列表(有界截断后),供 anthropic 路下发。 + * 结构 = {@code [system] + boundedHistory + newUser}(system 不入历史、每轮现拼;历史 = 既往 user/答案轮序列)。 + * + *

P1-1 命门(user/assistant 严格交替):截断后若历史末条为 user(理论上 {@link #appendHistory} + * 失败路已不入悬空 user,此处再做防御性去尾——防任何残留悬空 user),则丢掉它,杜绝「拼上本轮 newUser → 两条 + * 连续 user」触发 Anthropic API HTTP 400(user/assistant 必须严格交替)。 + * + * @param system 本路 system(gamedef=GAMEDEF_SYSTEM / iife=SYSTEM+few-shot)。 + * @param priorHistory 既往历史(user/答案轮序列)。 + * @param newUser 本轮新 user(首轮=题面 / 救场=失败反馈增量修)。 + * @return 下发用 message 列表。 + */ + private static List assembleConversation(String system, List priorHistory, String newUser) { + List bounded = boundHistory(priorHistory); + // P1-1 防御:历史末条若为 user(悬空 user)→ 去尾,避免与本轮 newUser 连成两条 user(破交替→Anthropic 400)。 + while (!bounded.isEmpty() && bounded.get(bounded.size() - 1) instanceof UserMessage) { + bounded.remove(bounded.size() - 1); + } + List msgs = new ArrayList<>(bounded.size() + 2); + msgs.add(new SystemMessage(system)); + msgs.addAll(bounded); + msgs.add(new UserMessage(newUser)); + return msgs; + } + + /** + * 有界截断(P1-3 简化版):历史已无 thinking 块({@link #appendHistory} 入历史时即丢),故只需按最近 K 轮留 + * (轮 = 一个 user + 其后答案块;用 user 边界切轮)。保最近 {@link #HISTORY_MAX_TURNS} 轮,更早轮整轮丢弃; + * 每条答案 text 超 {@link #HISTORY_ANSWER_MAX_CHARS} 截断标注(守 token)。 + */ + private static List boundHistory(List history) { + if (history == null || history.isEmpty()) { + return new ArrayList<>(); + } + int n = history.size(); + // 倒序找「最近 K 个 user 边界」的起点 index——从该 index 起的尾段即最近 K 轮(含其后答案块)。 + int userSeen = 0; + int startIdx = 0; + for (int i = n - 1; i >= 0; i--) { + if (history.get(i) instanceof UserMessage) { + userSeen++; + if (userSeen == HISTORY_MAX_TURNS) { + startIdx = i; // 第 K 个 user 即保留窗口起点 + break; + } + } + } + // 逐位重建尾段:user 原样留;答案块 text 超限截断(历史已无 thinking 块,无需再判 signature)。 + List out = new ArrayList<>(n - startIdx); + for (int i = startIdx; i < n; i++) { + Message m = history.get(i); + if (m instanceof AssistantMessage am) { + String t = am.getText(); + if (t != null && t.length() > HISTORY_ANSWER_MAX_CHARS) { + out.add(new AssistantMessage(t.substring(0, HISTORY_ANSWER_MAX_CHARS) + "…(早轮已截断)")); + } else { + out.add(am); + } + } else { + out.add(m); // user/其它轮原样留 + } + } + return out; + } + + /** + * U2(创始人定·简化为 text 历史):把本轮「新 user + 上一版答案文本块」append 进历史(read-append-write,对齐 + * {@link #appendAttempt} 范式;ReplaceStrategy 整键覆盖写新 list)。只保留答案块、丢 thinking 块(P1-2:stock + * spring-ai-anthropic 1.1.2 出站丢弃 thinking,留之无益且回灌推理白话浪费 token)。不重置既往历史(连续对话救场命门)。 + * + *

P1-1 命门(user/assistant 严格交替):本轮 generate 全档失败(orderedTurn==null·无任何答案)时, + * 本轮 user 不入历史(不产生悬空 user)——下一轮 repair 的 generate 会 append 新 user 自然承接上一个答案块; + * 若强行入悬空 user,下一轮再 append 新 user 即两条连续 user → wire 破交替 → Anthropic API HTTP 400 整 job 硬崩 + * (本是可恢复的超时降级态)。故 {@code orderedTurn} 无有效答案块时整轮不入历史。 + * + * @param s 当前 state(读既往历史)。 + * @param out 本节点 partial out(写回新历史)。 + * @param gamedef true=写 gamedef 历史键;false=factory 历史键(隔离)。 + * @param newUser 本轮新 user 文本。 + * @param orderedTurn 本轮 assistant 有序响应(thinking+answer,本方法滤掉 thinking 只留答案;全档失败时为 null)。 + */ + private static void appendHistory(OverAllState s, Map out, boolean gamedef, + String newUser, List orderedTurn) { + // P1-2:滤出本轮答案块(丢 thinking 块——metadata 含 signature 的)。 + List answers = new ArrayList<>(); + if (orderedTurn != null) { + for (AssistantMessage am : orderedTurn) { + if (am != null && !isThinkingMsg(am)) { + answers.add(am); + } + } + } + // P1-1:本轮无任何答案块(全档失败 / 仅 thinking)→ 整轮不入历史(不产生悬空 user,守 user/assistant 严格交替)。 + if (answers.isEmpty()) { + return; + } + List hist = readHistory(s, gamedef); + hist.add(new UserMessage(newUser)); + hist.addAll(answers); // 仅答案块(纯文本)入历史;连续对话救场=模型看上一版答案 + 失败 verdict 增量修。 + out.put(historyKey(gamedef), hist); + } + /** * 把 usage delta 累加进 out map(基线优先取本节点 out 内已写值,否则取 state 旧值 + delta)。 * 注:同一节点多次累加(如 player 文本位+视觉位)时,第二次须叠加在第一次已写入 out 的值上, @@ -445,6 +717,22 @@ final class SaaStudioNodes { } static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode) { + // 默认 openai 路(history 关):现行行为字节不变。anthropic 路经 4 参重载传 historyEnabled=true。 + return generateNode(gm, gameRuntimeRoot, sourceMode, false); + } + + /** + * generate 节点(Plan A/U2 历史旁挂全量重载):{@code historyEnabled=true}(anthropic 路)时跨轮保留完整模型响应 + * (thinking+text 有序块)进历史 + 连续对话救场(续上一版增量修);{@code false}(openai 路)保现行每轮全新 + * {@code [system,user]}(字节零变,回归红线)。 + * + *

历史路边界:仅普通生成路(create/repair 整源重生成)走连续对话历史;factory 的 + * regenerate-module 消费 base 源路(P0-2,一次性把 base 源 JSON 注入 user)不走历史(保其 one-shot + * base-source 语义不被对话历史叠加污染)——该路仍走现行 {@link SaaPrompts#buildRegenerateMessages} 两元 prompt。 + * + * @param historyEnabled true=anthropic 路(连续对话历史+thinking 保留);false=openai 路(每轮 [system,user],字节零变)。 + */ + static NodeAction generateNode(GenModels gm, Path gameRuntimeRoot, String sourceMode, boolean historyEnabled) { return (OverAllState s) -> { String role = s.value(K_ROLE, "code"); boolean isFix = "fix".equals(role); @@ -469,23 +757,36 @@ final class SaaStudioNodes { // ── 组 messages:gamedef 路用 GAMEDEF_SYSTEM(真结构化源);factory 路保现行(regenerate 消费 base 源 / 普通)。 String modifyMode = s.value(K_MODIFY_MODE, String.class).orElse(""); String sourceProject = s.value(K_SOURCE_PROJECT, String.class).orElse(""); - String[] msgs; - if (gamedef) { - // gamedef:题面 + 品类提示 + 回喂;regenerate-module 在 gamedef 路退化为带反馈整源重生成(确定性点改走 modify 节点)。 - String archetype = s.value(K_ARCHETYPE, String.class).orElse("generic"); + // factory regenerate-module 消费 base 源路:保现行 one-shot base-source 语义,不走连续对话历史(防 base 源被对话历史叠加污染)。 + boolean regeneratePath = !gamedef && isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty(); + // 历史路启用条件:anthropic 路(historyEnabled)∧ 非 regenerate base-源路。其余走现行两元 prompt(含 openai 全路 + regenerate 路)。 + boolean useHistory = historyEnabled && !regeneratePath; + String archetype = s.value(K_ARCHETYPE, String.class).orElse("generic"); + + String[] msgs = null; // 现行两元 prompt(openai 路 / regenerate 路) + List convo = null; // 连续对话 message 列表(anthropic 历史路) + String newUser = null; // 本轮新 user(历史路 append 用) + if (useHistory) { + // anthropic 连续对话:system + 有界历史 + 本轮新 user(首轮=题面 / 救场=失败反馈增量修)。 + String system = SaaPrompts.historySystem(gamedef, gameRuntimeRoot); + boolean hasFeedback = feedback != null && !feedback.isEmpty(); + newUser = hasFeedback + ? SaaPrompts.historyRepairUser(gamedef, feedback) + : SaaPrompts.historyFirstUser(gamedef, enriched, archetype); + convo = assembleConversation(system, readHistory(s, gamedef), newUser); + } else if (gamedef) { + // gamedef(openai 路):题面 + 品类提示 + 回喂;regenerate-module 在 gamedef 路退化为带反馈整源重生成(确定性点改走 modify 节点)。 msgs = SaaPrompts.buildGameDefMessages(enriched, feedback, archetype); + } else if (regeneratePath) { + // P0-2(factory regenerate-module 路真消费 base 源·informed-by-base 重生成)。 + JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse("")); + String target = resolveBehaviorTarget(patch); + String intent = patch != null ? patch.path("payload").path("intent").asText("") : ""; + msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot, + sourceProject, target, intent); } else { - // P0-2(factory regenerate-module 路真消费 base 源·informed-by-base 重生成);否则普通 buildMessages,prompt 字节零变。 - boolean regeneratePath = isFix && "regenerate-module".equals(modifyMode) && !sourceProject.isEmpty(); - if (regeneratePath) { - JsonNode patch = SaaPrompts.looseParse(s.value(K_MODIFY_PATCH, String.class).orElse("")); - String target = resolveBehaviorTarget(patch); - String intent = patch != null ? patch.path("payload").path("intent").asText("") : ""; - msgs = SaaPrompts.buildRegenerateMessages(enriched, feedback, gameRuntimeRoot, - sourceProject, target, intent); - } else { - msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot); - } + // 普通 buildMessages(factory openai 路),prompt 字节零变。 + msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot); } Map out = new HashMap<>(); @@ -499,7 +800,8 @@ final class SaaStudioNodes { Exception lastErr = null; for (int i = 0; i < chain.length; i++) { try { - r = callAndRecord(chain[i], msgs[0], msgs[1]); + // 历史路用完整 message 列表(含历史 thinking/answer 块);现行路用两元 [system,user](字节零变)。 + r = useHistory ? callAndRecordMessages(chain[i], convo) : callAndRecord(chain[i], msgs[0], msgs[1]); usedModel = chainNames[i]; lastErr = null; break; @@ -515,6 +817,11 @@ final class SaaStudioNodes { .put("attempt", attemptNo).put("role", role).put("model", pname) .put("stage_fail", "generate") .set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0))); + // P1-1:历史路全档失败不入历史(orderedTurn=null → appendHistory 整轮跳过,不产生悬空 user)—— + // 下一轮 repair 的 generate 会 append 新 user 自然承接上一个答案块,守 user/assistant 严格交替(防 Anthropic 400)。 + if (useHistory) { + appendHistory(s, out, gamedef, newUser, null); + } return out; } @@ -543,6 +850,12 @@ final class SaaStudioNodes { .put("attempt", attemptNo).put("role", role).put("model", usedModel) .put("stage_fail", stageFail) .set("usage", MAPPER.createObjectNode().put("in", in).put("out", outTok))); + // U2 历史路:append 本轮「新 user + 上一版答案文本块」进历史(appendHistory 内滤掉 thinking 块;连续对话救场,非重置)。 + if (useHistory) { + @SuppressWarnings("unchecked") + List orderedTurn = (List) r[3]; + appendHistory(s, out, gamedef, newUser, orderedTurn); + } return out; }; } @@ -695,7 +1008,8 @@ final class SaaStudioNodes { Prompt prompt = new Prompt(List.of(new SystemMessage(SaaPrompts.playerSystem(persona)), um)); return callWithTimeoutAndRetry(() -> { ChatResponse resp = visionModel.call(prompt); - String text = resp.getResult().getOutput().getText(); + // U1:多 Generation 重组取答案(同 callAndRecord;视觉位走 Anthropic+thinking 时答案在「无 signature」的 Generation)。 + String text = pickAnswerText(resp); long in = 0, out = 0; Usage u = resp.getMetadata().getUsage(); if (u != null) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java index cb264184..92d3f458 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java @@ -197,6 +197,78 @@ public class AigcExecutorProperties { */ private String saaSourceMode = "factory"; + /** + * SAA 图后台执行并发度 K(plan 2026-06-19 003-U1 有界并发池,P0-3/P1-5):进程内后台跑图的并行 job 上限。 + * 默认 1 = 生产字节零变(单线程池 + Semaphore(1) + 端口槽 0 → 端口恒为 saaPlayPortBase/saaCdpPortBase, + * 与改造前 {@code newSingleThreadExecutor + Semaphore(1) + 常量 4320/9222} 行为逐字节等价,回归红线)。 + *

K>1 时:固定线程池 size=K + Semaphore(K) + K 个错开端口槽(每槽 {@code playPort=base+2i / cdpPort=base+i}), + * 多 job 并行真玩各占独立端口对(serve-and-play.sh 净场只杀本槽两端口、static-serve 只读共享 game-runtime 根、 + * evidence 按 gameId 隔离 → 跨 job 安全,详见 {@code SaaGraphDispatcher} 共享面审计注释)。 + *

仅 dispatcher=saa 生效;<1 的误配按 1 兜底(不崩、退回单飞)。真跑 K 路并发校准(verdict 对等 + 结构隔离) + * 需九门环境,延 003-U5 执行期;本字段先就位 + 纯端口分配逻辑可单测。 + */ + private Integer saaConcurrency = 1; + + /** + * SAA 真玩 HTTP 端口基址(端口池槽 0 的 playPort;plan 003-U1):第 i 槽 playPort = {@code base + 2*i}(步长 2 留头寸)。 + * 默认 4320(与改造前 buildInputs 常量、serve-and-play.sh 默认逐字一致 → K=1 字节零变)。内网地址按项目规则可入库。 + */ + private Integer saaPlayPortBase = 4320; + + /** + * SAA 真玩 CDP 端口基址(端口池槽 0 的 cdpPort;plan 003-U1):第 i 槽 cdpPort = {@code base + i}(步长 1)。 + * 默认 9222(与改造前 buildInputs 常量、serve-and-play.sh 默认逐字一致 → K=1 字节零变)。 + *

两族端口区间天然不交叠(4320 区 vs 9222 区),族内步长保互异 → K 槽端口对两两不撞({@code SaaGraphDispatcher.allocPortSlots} 单测守)。 + */ + private Integer saaCdpPortBase = 9222; + + /** + * SAA 全角色强制统一模型(Plan A U5「只用 M3」,创始人 2026-06-19;bake-off / 单模型基线用): + * 非空 → SAA 图 11 个角色名全置为此模型(含 stage2 强档位与 code/fix fallback 位 → 单模型路无跨家回退); + * 空(默认)= stage1 默认路由(deepseek 主力 + M3 视觉/分类/回退),字节零变。per-role maxTokens/temperature 不变。 + *

仅 dispatcher=saa 生效。量 M3-only 基线设 {@code MiniMax-M3};deepseek 对照留空(默认)即 deepseek 主力路。 + */ + private String saaForceModel; + + /** + * SAA 全角色强制 maxTokens(Plan A U5;M3+thinking 思考+答案需大余量防截断,doc 荐 M3 128K=131072): + * >0 → 包装 RoleModelFactory 覆盖各角色 maxTokens 为此值;0(默认)= 各角色默认(字节零变)。仅 dispatcher=saa 生效。 + */ + private Integer saaForceMaxTokens = 0; + + /** + * SAA openai 兼容路读超时 ms(Plan A U5;M3+thinking 慢、原 120s 在 K=10 下致 SocketTimeout): + * 默认 120000;量 M3+thinking 设 600000。仅 openai 路({@link com.wanxiang.huijing.game.module.aigc.saa.SaaStudioGraph#openAiApiNoProxy})生效。 + */ + private Integer saaOpenAiReadTimeoutMs = 120000; + + // ===== SAA 模型协议(Plan A · U1「用对 M3」;flag 旁挂,默认 openai 字节零变)===== + + /** + * SAA 图角色模型走的上游协议(Plan A/U1,plan 2026-06-19-002):{@code openai}=现行 OpenAI 兼容口径 + * ({@code OpenAiApi}+{@code OpenAiChatModel},经 new-api {@code /v1/chat/completions},默认·行为字节不变); + * {@code anthropic}=经 new-api Anthropic Messages 协议({@code AnthropicChatModel},{@code /v1/messages},thinking 原生分离) + * 调 M3——治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因(根因 v3「用对 M3」)。 + * 仅 dispatcher=saa 生效;openai 时 dispatcher 不构造 Anthropic 客户端(U1 旁挂、零回归)。 + */ + private String saaModelProtocol = "openai"; + + /** + * SAA Anthropic 协议 baseUrl(saaModelProtocol=anthropic 专用):取 new-api host 根 + * {@code http://100.64.0.8:3000}({@code AnthropicApi} 自拼 completionsPath 默认 {@code /v1/messages} → 真实打 + * {@code .../v1/messages})。绝不加 {@code /anthropic} 前缀(lili-mac spike 1.1.2 实测:{@code /anthropic/v1/messages} + * 命中 new-api SPA catch-all 返 HTML→Spring AI 解析失败假挂起);亦不复用 OpenAI 路的 stripV1Suffix + * (那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。内网地址按项目规则可入库。 + */ + private String saaAnthropicBase = "http://100.64.0.8:3000"; + + /** + * SAA Anthropic 协议 thinking 预算 tokens(saaModelProtocol=anthropic 专用):开 thinking 时的 budget_tokens。 + * Anthropic 协议硬约束 {@code budget_tokens < max_tokens}(client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug); + * 默认 8192(< code/fix 角色 maxTokens=16000)。openai 协议下本字段不读。 + */ + private Integer saaThinkingBudget = 8192; + /** * 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast) * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java new file mode 100644 index 00000000..42780326 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java @@ -0,0 +1,100 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatModel; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * SaaAnthropicAssembleTest —— Plan A/U1 P0-2 专项命门模型无关单测(纯逻辑、无网络/无 key,普通 {@code mvn test} 即跑)。 + * + *

验证对象:{@link SaaStudioGraph#anthropicModel} 的 per-role thinking budget 自适应(替原 fail-fast 抛)。 + * 对抗验证抓到的 P0-2:assemble 给判定类小角色硬编码小 {@code maxTokens}({@code playerVision=900 / classify=1000 / + * narrative=2000 / playerText=4000}),而单一全局 {@code thinkingBudget}(默认 8192,{@code AigcExecutorProperties.saaThinkingBudget}) + * 撞之——修前 {@code anthropicModel} 对 {@code budget >= maxTokens} 直接 {@code throw IllegalArgumentException} → + * anthropic 路 {@link SaaStudioGraph#build(SaaStudioGraph.RoleModelFactory, SaaStudioGraph.Models, Path, int, int, int, String, + * com.alibaba.cloud.ai.graph.checkpoint.config.SaverConfig, io.micrometer.observation.ObservationRegistry) assemble} 内对小角色抛、 + * 每 job failed。修后:{@code effectiveBudget = min(全局, maxTokens-1024)},恒 {@code < maxTokens}(守协议约束); + * 容不下 Anthropic 最小 thinking(1024) 的小判定角色不开 thinking——全程不抛。 + * + *

安全性:{@link SaaStudioGraph#anthropicModel} / {@link SaaStudioGraph#anthropicFactory} 只构造 + * {@code AnthropicChatModel} client 对象(建 {@code AnthropicApi} + 设 {@code defaultOptions}),绝不调网关(无 {@code .call()}); + * 节点工厂({@code designNode/generateNode/...})只 {@code gameRuntimeRoot} 句柄、路径在运行时才 resolve——故 + * dummy key + dummy 路径构造全图安全(不触网/不触盘)。这把「per-role budget clamp 不抛」与真模型/真网关解耦坐实。 + * + * @author 造梦AI(Plan A · U1 · P0-2 专项) + */ +class SaaAnthropicAssembleTest { + + /** dummy Anthropic 协议 baseUrl(host 根口径;只构造 client,绝不真打)。 */ + private static final String DUMMY_BASE = "http://100.64.0.8:3000"; + /** dummy key(绝不真鉴权;anthropicModel 只构造 client,不发请求)。 */ + private static final String DUMMY_KEY = "dummy-key"; + /** 默认全局 thinking budget(= AigcExecutorProperties.saaThinkingBudget 默认 8192),即撞小角色 maxTokens 的那个值。 */ + private static final int THINKING_BUDGET = 8192; + + /** + * P0-2 核心命门:用 {@link SaaStudioGraph#anthropicFactory} 构造全图,触发 {@code assemble} 内全 11 角色 + * {@code mf.create(...)}(含 maxTokens 极小的 playerVision=900 / classify=1000 / narrative=2000 / playerText=4000)—— + * 断不抛任何异常。修前小角色必抛 {@code IllegalArgumentException}(budget 8192 >= maxTokens);修后 clamp 不抛。 + */ + @Test + void anthropic_full_graph_assembles_without_throwing_for_small_maxtokens_roles() { + // 全局 8192 budget 的 anthropic per-role 工厂(撞小角色 maxTokens 的那把刀)。 + SaaStudioGraph.RoleModelFactory anthropicFactory = + SaaStudioGraph.anthropicFactory(DUMMY_BASE, DUMMY_KEY, THINKING_BUDGET); + // dummy game-runtime 路径:节点工厂只存句柄、不在构造期 resolve/读盘(运行时才用),故无需真实存在。 + Path dummyRuntime = Paths.get("/tmp/saa-p0-2-dummy-runtime"); + + // assemble 在 build 内同步构造全 11 角色 ChatModel(designNode/generateNode/.../playerNode/nreviewNode/classifyNode)。 + // 修前:playerVision(900)/classify(1000)/narrative(2000) 等小 maxTokens 角色 → anthropicModel 抛 IllegalArgumentException + // → assemble 崩 → 整图构造失败。修后 per-role clamp:恒 budget SaaStudioGraph.build( + anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime, + /*maxRepairs*/ 5, /*maxPlayerRounds*/ 1, /*stage2ExtraRepairs*/ 3, + /*sourceMode*/ "factory", /*saverConfig*/ null, /*observationRegistry*/ null), + "P0-2 回归:anthropic 工厂构造全图(factory 形态)不得抛——小判定角色 maxTokens SaaStudioGraph.build( + anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime, + 5, 1, 3, "gamedef", null, null), + "P0-2 回归:anthropic 工厂构造全图(gamedef 形态)同样不得抛"); + assertNotNull(gamedefGraph, "gamedef 形态全图应编译成功"); + } + + /** + * P0-2 直证(最精准):直接对 assemble 里硬编码小 maxTokens 的各判定角色逐一调 + * {@link SaaStudioGraph#anthropicModel}(全局 budget=8192)——断每个都不抛且产出非空 client。 + * 修前每一个都必抛 {@code IllegalArgumentException}(budget 8192 >= maxTokens of 900/1000/2000/4000)。 + */ + @Test + void anthropic_model_clamps_budget_for_each_small_role_without_throwing() { + // assemble 里各角色硬编码 maxTokens(逐字对照 SaaStudioGraph.assemble): + // playerVision=900、classify=1000、narrative=2000、playerText=4000(皆 < 全局 budget 8192,修前必抛)。 + int[] smallMaxTokens = {900, 1000, 2000, 4000}; + String[] roleHint = {"playerVision(900)", "classify(1000)", "narrative(2000)", "playerText(4000)"}; + for (int i = 0; i < smallMaxTokens.length; i++) { + final int maxTokens = smallMaxTokens[i]; + final String hint = roleHint[i]; + AnthropicChatModel m = assertDoesNotThrow(() -> SaaStudioGraph.anthropicModel( + DUMMY_BASE, DUMMY_KEY, "MiniMax-M3", /*temperature*/ 0.3, maxTokens, THINKING_BUDGET), + "P0-2:小角色 " + hint + " 全局 budget=" + THINKING_BUDGET + + " 撞 maxTokens 应 clamp/关 thinking 不抛(修前抛 IllegalArgumentException)"); + assertNotNull(m, "小角色 " + hint + " 应构造出非空 AnthropicChatModel client"); + } + + // 生成角色(maxTokens=16000 > budget 8192):本就合法,clamp 后 budget=min(8192,16000-1024)=8192 仍开 thinking,不抛。 + AnthropicChatModel gen = assertDoesNotThrow(() -> SaaStudioGraph.anthropicModel( + DUMMY_BASE, DUMMY_KEY, "deepseek-v4-flash", 0.0, 16000, THINKING_BUDGET), + "生成角色(maxTokens=16000)应正常构造(clamp 后 budget=8192<16000,thinking 开)"); + assertNotNull(gen, "生成角色应构造出非空 client"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicSmokeTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicSmokeTest.java new file mode 100644 index 00000000..f1135f84 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicSmokeTest.java @@ -0,0 +1,146 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaAnthropicSmokeTest —— Plan A / U1 Anthropic 协议 M3 client 的正式门控集成烟测(治「用对 M3」根因,HJ-AGI / plan003)。 + * + *

验证对象:{@link SaaStudioGraph#anthropicModel} 构造的 client(经 new-api Anthropic Messages 协议调 MiniMax-M3)+ + * {@link SaaStudioNodes#pickAnswerText(ChatResponse)} 多 Generation 重组取答案。真打网关(gated,默认跳过;需 NEWAPI_KEY + 可达 new-api)。 + * + *

实测定死配方(lili-mac 真跑 spring-ai-anthropic 1.1.2 实证,本测试照此构造,务必勿改): + *

    + *
  1. baseUrl = new-api host 根 {@code http://100.64.0.8:3000}(completionsPath 保持默认 {@code /v1/messages} + * → 真实打 {@code .../v1/messages})。绝不加 {@code /anthropic} 前缀:{@code /anthropic/v1/messages} 命中 + * new-api SPA catch-all 返 HTTP 200 + index.html,Spring AI 解析 JSON 失败后落默认 RetryTemplate 指数退避 + * → 表象「call() 永久挂起」。勿复用 OpenAI 路的 stripV1Suffix(那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。
  2. + *
  3. 鉴权 = {@code apiKey(NEWAPI_KEY)}(发 {@code x-api-key} 头)。实测:x-api-key → {@code getResults().size()==2} + * 原生 thinking block(gen#0 的 {@code AssistantMessage.getMetadata()} 含 {@code signature} 键=推理 / gen#1 无 signature=答案); + * Authorization Bearer → {@code size==1} 字面 {@code } 内联(污染结构化输出,正是要躲的坑)。故 client 用 x-api-key、本测试只验这一路。
  4. + *
  5. 1.1.2 的 {@code AnthropicChatModel$Builder} 没有 customHeaders——自定义头唯一干净入口 = {@code AnthropicApi.builder() + * .restClientBuilder(...)}({@link SaaStudioGraph#anthropicModel} 经此注入 NO_PROXY+超时的 RestClient)。
  6. + *
  7. {@code budget < maxTokens} 硬约束(Anthropic 协议;client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug)。
  8. + *
  9. {@code ChatResponse.getResults() -> List};必须遍历 getResults(),不能只 getResult()——thinking ENABLED + * 下取真答案 = {@link SaaStudioNodes#pickAnswerText}(从尾向前找第一个 metadata 不含 signature 的 Generation)。
  10. + *
+ * + *

纪律:① 门控 {@code -Danthropic.smoke=1}(无开关/无 key 时跳过,不算 fail);② 密钥经环境变量 {@code NEWAPI_KEY} + * (绝不入库/打印/提交);③ 断言 client 配方真生效 + pickAnswerText 取到答案。 + * + *

运行(lili-mac,在 game-cloud reactor 下;NEWAPI_KEY 经 env,绝不打印): + *

+ *   export NEWAPI_KEY=$(grep -oE 'sk-[A-Za-z0-9_-]{20,}' docs/内网凭据与端点.md | head -1)
+ *   mvn -pl game-module-aigc/game-module-aigc-server -am \
+ *       -Dtest=SaaAnthropicSmokeTest -Danthropic.smoke=1 -DfailIfNoTests=false \
+ *       -DargLine='-Dhttp.nonProxyHosts=100.64.0.8|localhost|127.0.0.1 -Dhttps.nonProxyHosts=100.64.0.8|localhost|127.0.0.1' \
+ *       test
+ * 
+ * 实测坑(必带 -DargLine 那行):macOS 系统代理 = clash 127.0.0.1:7897,surefire fork 的 JVM 自动继承 → 对 Tailscale IP + * 100.64.0.8 走 clash 代理 → 502 TransientAiException。{@link SaaStudioGraph#anthropicModel} 内已设 + * {@code SimpleClientHttpRequestFactory.setProxy(NO_PROXY)} 旁路;{@code -DargLine} 的 nonProxyHosts 是双保险。 + * + * @author 造梦AI(Plan A · U1 Anthropic client 门控烟测) + */ +class SaaAnthropicSmokeTest { + + /** new-api Anthropic 协议 baseUrl = host 根(completionsPath 默认 /v1/messages → 真实打 .../v1/messages;绝不加 /anthropic)。 */ + private static final String ANTHROPIC_BASE_URL = + System.getenv().getOrDefault("NEWAPI_BASE_URL_RAW", "http://100.64.0.8:3000"); + + /** 目标模型(经 new-api 转发到 MiniMax-M3,走 Anthropic Messages 协议)。 */ + private static final String MODEL = "MiniMax-M3"; + + /** 省钱预算:maxTokens 给上限,budget 必须 < maxTokens(Anthropic 协议约束,client 侧 fail-fast 断言)。 */ + private static final int MAX_TOKENS = 4096; + private static final int THINKING_BUDGET = 2048; + + /** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径)。 */ + private static final String SIGNATURE_KEY = "signature"; + + @Test + @EnabledIfSystemProperty(named = "anthropic.smoke", matches = "1", + disabledReason = "Plan A U1 Anthropic client 门控烟测:需 NEWAPI_KEY + 可达 new-api host 根 /v1/messages;开关 -Danthropic.smoke=1") + void anthropic_client_recipe_and_pick_answer_text() { + String key = System.getenv("NEWAPI_KEY"); + assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)"); + assertTrue(!key.isBlank(), "NEWAPI_KEY 为空"); + + // ── 用生产同款工厂构造 client(定死配方:host 根 baseUrl + x-api-key + thinking ENABLED + NO_PROXY;budget gens = resp.getResults(); + int genCount = gens == null ? 0 : gens.size(); + System.out.println("[generationCount] resp.getResults().size() = " + genCount); + + boolean sawThinkingSignature = false; + for (int i = 0; i < genCount; i++) { + Generation g = gens.get(i); + AssistantMessage out = g.getOutput(); + String text = (out == null || out.getText() == null) ? "" : out.getText(); + String head = text.length() > 120 ? text.substring(0, 120).replace("\n", "\\n") : text.replace("\n", "\\n"); + Map meta = (out == null) ? null : out.getMetadata(); + boolean hasSig = meta != null && meta.containsKey(SIGNATURE_KEY); + sawThinkingSignature |= hasSig; + System.out.println(" [gen#" + i + "] textLen=" + text.length() + + " hasSignature=" + hasSig + " metaKeys=" + (meta == null ? "(null)" : meta.keySet())); + System.out.println(" textHead120=\"" + head + "\""); + // 内联 探测(x-api-key 路不应出现;若出现则网关方言变了,需复核配方)。 + if (text.contains("")) { + System.out.println(" >> [警示] text 含字面 (thinking 内联进正文,非原生 block)——配方可能失效,请复核"); + } + } + + // usage(成本证据)。 + try { + var u = resp.getMetadata() == null ? null : resp.getMetadata().getUsage(); + if (u != null) { + System.out.println("[usage] prompt=" + u.getPromptTokens() + + " completion=" + u.getCompletionTokens() + " total=" + u.getTotalTokens()); + } + } catch (Exception ignore) { + // usage 取不到不连坐证据打印 + } + + // ── pickAnswerText 取答案(生产同款重组)── + String answer = SaaStudioNodes.pickAnswerText(resp); + System.out.println("[pickAnswerText] len=" + answer.length() + + " head120=\"" + (answer.length() > 120 ? answer.substring(0, 120).replace("\n", "\\n") : answer.replace("\n", "\\n")) + "\""); + System.out.println("=================================================================="); + + // ── 断言(坐实配方真生效)── + // ① 多 Generation 形(x-api-key + thinking ENABLED 实测 size≥2:thinking + 答案)。 + assertTrue(genCount >= 2, + "x-api-key + thinking ENABLED 应返 ≥2 Generation(thinking + 答案);实得 " + genCount + + "——可能:端点路径错(应 host 根 /v1/messages,非 /anthropic) / 网关未开 Claude 兼容路 / 鉴权方言变 / 代理坑"); + // ② 存在含 signature 的 thinking 推理块(原生 thinking 分离,非内联 )。 + assertTrue(sawThinkingSignature, + "应存在含 signature 键的 thinking Generation(原生 thinking block 分离);缺则 thinking 未原生分离(配方失效)"); + // ③ pickAnswerText 取到「无 signature 的答案 Generation」且非空(生产重组语义真生效)。 + assertFalse(answer.isBlank(), "pickAnswerText 应取到非空答案文本(无 signature 的答案 Generation)"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java index af1a2530..2f399bbe 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eGateLogicTest.java @@ -1,14 +1,17 @@ package com.wanxiang.huijing.game.module.aigc.saa; +import com.wanxiang.huijing.game.module.aigc.service.executor.AigcExecutorProperties; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -168,4 +171,79 @@ class SaaFullGraphE2eGateLogicTest { assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(new HashMap<>()), "缺 modelTier → stage1"); assertEquals("stage1", SaaFullGraphE2eTest.extractModelTier(null), "trace=null → stage1"); } + + // ═══════════════════════ 003-U1 有界并发池:端口分配 + 并发规整(纯逻辑,模型无关) ═══════════════════════ + + // ─────────────────────────── saaConcurrency 默认值(回归红线:默认 1 = 生产字节零变前提) ─────────────────────────── + + @Test + @DisplayName("saaConcurrency 默认 = 1(K=1 字节零变回归红线:默认即单飞,不改生产行为)") + void saaConcurrency_defaultsToOne() { + AigcExecutorProperties props = new AigcExecutorProperties(); + assertEquals(1, props.getSaaConcurrency(), "saaConcurrency 默认必须是 1(默认并发=1 才保 K=1 字节零变)"); + // 端口基址默认亦须与改造前 buildInputs 常量一致(4320/9222),否则槽 0 端口漂移破字节零变。 + assertEquals(4320, props.getSaaPlayPortBase(), "saaPlayPortBase 默认必须 4320(= 改造前 play 端口常量)"); + assertEquals(9222, props.getSaaCdpPortBase(), "saaCdpPortBase 默认必须 9222(= 改造前 cdp 端口常量)"); + } + + // ─────────────────────────── normalizeConcurrency:K 规整(null/<1 → 1) ─────────────────────────── + + @Test + @DisplayName("normalizeConcurrency:null/0/负 → 1(误配退回单飞,不崩、不破字节零变);正值原样") + void normalizeConcurrency_cases() { + assertEquals(1, SaaGraphDispatcher.normalizeConcurrency(null), "null → 1"); + assertEquals(1, SaaGraphDispatcher.normalizeConcurrency(0), "0 → 1"); + assertEquals(1, SaaGraphDispatcher.normalizeConcurrency(-3), "负 → 1"); + assertEquals(1, SaaGraphDispatcher.normalizeConcurrency(1), "1 → 1"); + assertEquals(4, SaaGraphDispatcher.normalizeConcurrency(4), "4 → 4(正值原样)"); + } + + // ─────────────────────────── allocPortSlots:端口槽分配(槽 0 字节零变 + 步长 + 不撞 + null 兜底) ─────────────────────────── + + @Test + @DisplayName("allocPortSlots:K=1 槽 0 = [4320,9222](字节零变铁证:与改造前 buildInputs 常量逐字一致)") + void allocPortSlots_k1_byteIdentical() { + List slots = SaaGraphDispatcher.allocPortSlots(4320, 9222, 1); + assertEquals(1, slots.size(), "K=1 只有一个槽"); + assertEquals(4320, slots.get(0)[0], "K=1 playPort 必须恒为 4320(字节零变)"); + assertEquals(9222, slots.get(0)[1], "K=1 cdpPort 必须恒为 9222(字节零变)"); + } + + @Test + @DisplayName("allocPortSlots:第 i 槽 playPort=base+2i / cdpPort=base+i(步长 2 / 1)") + void allocPortSlots_strideFormula() { + List slots = SaaGraphDispatcher.allocPortSlots(4320, 9222, 4); + assertEquals(4, slots.size()); + for (int i = 0; i < 4; i++) { + assertEquals(4320 + 2 * i, slots.get(i)[0], "槽 " + i + " playPort = 4320+2*" + i); + assertEquals(9222 + i, slots.get(i)[1], "槽 " + i + " cdpPort = 9222+" + i); + } + } + + @Test + @DisplayName("allocPortSlots:K 槽 2K 个端口两两不撞(play 族 vs cdp 族不交叠 + 族内互异)") + void allocPortSlots_noCollision() { + for (int k : new int[]{1, 2, 4, 8, 16}) { + List slots = SaaGraphDispatcher.allocPortSlots(4320, 9222, k); + Set seen = new HashSet<>(); + for (int[] slot : slots) { + assertTrue(seen.add(slot[0]), "K=" + k + " playPort " + slot[0] + " 与已分配端口相撞"); + assertTrue(seen.add(slot[1]), "K=" + k + " cdpPort " + slot[1] + " 与已分配端口相撞"); + } + assertEquals(2 * k, seen.size(), "K=" + k + " 应有 2K=" + (2 * k) + " 个互异端口"); + } + } + + @Test + @DisplayName("allocPortSlots:null 基址兜底 4320/9222;k<1 兜底 1 槽(防御误配,不抛)") + void allocPortSlots_nullBaseAndClamp() { + List nullBase = SaaGraphDispatcher.allocPortSlots(null, null, 2); + assertEquals(4320, nullBase.get(0)[0], "httpBase=null → 兜底 4320"); + assertEquals(9222, nullBase.get(0)[1], "cdpBase=null → 兜底 9222"); + assertEquals(4322, nullBase.get(1)[0], "槽 1 仍按步长"); + + List clamp = SaaGraphDispatcher.allocPortSlots(4320, 9222, 0); + assertEquals(1, clamp.size(), "k<1 → 至少 1 槽(防御,不返空)"); + assertEquals(4320, clamp.get(0)[0]); + } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java index 00a39469..fa6bae99 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaFullGraphE2eTest.java @@ -57,17 +57,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * :SAA 路只有 token 计量,折¥是 follow-up F3({@code SaaGraphDispatcher} cost 整段省略,§6.3)→ 此处不造假¥。 * * + *

003-U1 量测 flags: + *

    + *
  • {@code -Dsaa.e2e.sourceMode}(默认 {@code factory}):量哪条生成路——{@code factory}=iife 工厂旧路(现行字节零变基线); + * {@code gamedef}=真结构化 gameDefinition 路(cutover ≥60% 量测对象)。修发现②:旧版未 setSaaSourceMode→量的是 iife 旧路非 gamedef, + * 故量 gamedef 真基线必须 {@code -Dsaa.e2e.sourceMode=gamedef};
  • + *
  • {@code -Dsaa.e2e.concurrency}(默认 1):后台并发度 K——1=串行(生产字节零变基线);>1 多 job 并行真玩(各占错开端口对, + * K=1 vs K=N verdict 对等校准延 003-U5 真跑期);
  • + *
  • {@code -Dsaa.e2e.briefFile}(可选):外部 brief 文件(行式,去空白/跳空行与 {@code #} 注释); + * A/B 量测用 {@code src/test/resources/saa-e2e-briefs-gamedef.txt}(≥30 条多品类),出集留 {@code saa-e2e-briefs-holdout.txt}(供 final R1)。
  • + *
+ * *

运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空): *

- *   // 基线量测(仅打印成功率 + 存在性断言;003-U1 第一步「实测真基线」):
+ *   // gamedef 路基线量测(003-U1 第一步「实测真基线」;务必带 sourceMode=gamedef 才量对路):
  *   NEWAPI_KEY=sk-... timeout 6000 mvn -pl ...aigc-server test \
- *       -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 -Dsaa.e2e.n=10 \
+ *       -Dtest=SaaFullGraphE2eTest -Dsaa.e2e=1 -Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.n=10 \
+ *       -Dsaa.e2e.briefFile=src/test/resources/saa-e2e-briefs-gamedef.txt \
  *       -DGAME_RUNTIME_DIR=/root/game-staging/repo/game-runtime \
  *       -Dsurefire.failIfNoSpecifiedTests=false
- *   // flip 前置硬门(成功率须≥80%,否则测试 fail):追加 -Dsaa.e2e.minSuccessRate=0.8
+ *   // flip 前置硬门(gamedef 成功率须≥60% iife 基线,否则测试 fail):追加 -Dsaa.e2e.minSuccessRate=0.6
+ *   // 有界并发量测(K=4 并行真玩,端口对自动错开):追加 -Dsaa.e2e.concurrency=4
  * 
* - * @author 造梦AI(U7 集成顶石;003-U1 验收门工具化) + * @author 造梦AI(U7 集成顶石;003-U1 验收门工具化 + sourceMode/concurrency 量测 flags) */ class SaaFullGraphE2eTest { @@ -198,6 +211,25 @@ class SaaFullGraphE2eTest { int n = briefs.size(); System.out.println("[briefs] 本次投递 n=" + n + " 条(源 " + sourceBriefs.size() + " 条)"); + // 量测路别(003-U1 发现②修复):-Dsaa.e2e.sourceMode 决定量的是哪条生成路—— + // factory(默认)= iife 工厂旧路(现行字节零变基线);gamedef = 真结构化 gameDefinition 路(cutover ≥60% 量测对象)。 + // 不显式设 setSaaSourceMode 时 props 默认 "factory"(与现行一致),故缺省=量 iife 路;量 gamedef 基线须 -Dsaa.e2e.sourceMode=gamedef。 + String sourceMode = System.getProperty("saa.e2e.sourceMode", "factory").trim(); + // 后台并发度(003-U1):-Dsaa.e2e.concurrency 默认 1(串行=生产字节零变基线);>1 时多 job 并行真玩(各占错开端口对)。 + int concurrency = Integer.getInteger("saa.e2e.concurrency", 1); + // SAA 模型协议(Plan A U1「用对 M3」;本 flag = P1-6「flip 须同切协议」的量测面孪生):-Dsaa.e2e.protocol 默认 openai(字节零变基线); + // 量 anthropic 真基线(R1 硬要求 量==发、在用协议=anthropic)须显式 -Dsaa.e2e.protocol=anthropic;base/thinkingBudget 走 props 默认。 + String protocol = System.getProperty("saa.e2e.protocol", "openai").trim(); + // 强制单模型(Plan A U5「只用 M3」,创始人 2026-06-19):-Dsaa.e2e.model 非空 → 全角色统一该模型(如 MiniMax-M3);未设=stage1 默认(deepseek 主力)。 + String forceModel = System.getProperty("saa.e2e.model", "").trim(); + // Plan A U5:M3+thinking 量测旋钮——openai 读超时(慢)+ 全角色 maxTokens(防截断)。未设=props 默认(120s/各角色默认)。 + Integer readTimeoutMs = Integer.getInteger("saa.e2e.readTimeoutMs"); + Integer forceMaxTokens = Integer.getInteger("saa.e2e.maxTokens"); + // 端口基址(Plan A U3 暴露;与 superpowers-chrome 等本机进程共存防撞):未设=props 默认 4320/9222(字节零变)。 + // 本机 9222 常被 superpowers-chrome 占用 → 量测可 -Dsaa.e2e.cdpPortBase=9322 避撞(serve-and-play 净场只杀本槽端口,不误杀他者)。 + Integer playPortBase = Integer.getInteger("saa.e2e.playPortBase"); + Integer cdpPortBase = Integer.getInteger("saa.e2e.cdpPortBase"); + // ── 1) 构造真 AigcExecutorProperties(@Data 即 setter 全有)── // checkpoint 关 + DataSource 传 null → 零 DB 依赖;dispatcher=saa 走进程内全图派发。 AigcExecutorProperties props = new AigcExecutorProperties(); @@ -209,6 +241,15 @@ class SaaFullGraphE2eTest { props.setSaaMaxPlayerRounds(1); // player 顾问轮上限 props.setSaaStage2ExtraRepairs(3); // 救场阶梯 stage2 额外修复轮(5+3 阶梯) props.setSaaCheckpointEnabled(false); // 关 checkpoint → 不依赖 DataSource,零 DB + props.setSaaSourceMode(sourceMode); // 003-U1 发现②:量对路(gamedef 基线须显式 -Dsaa.e2e.sourceMode=gamedef) + props.setSaaConcurrency(concurrency); // 003-U1:后台并发度(默认 1=串行基线;>1 多 job 并行真玩各占错开端口) + props.setSaaModelProtocol(protocol); // Plan A U1:anthropic=用对 M3 真路(R1 量==发);默认 openai 字节零变。base/budget 走 props 默认(saaAnthropicBase / saaThinkingBudget) + if (!forceModel.isEmpty()) props.setSaaForceModel(forceModel); // Plan A U5:全角色统一模型(如 MiniMax-M3);未设=stage1 默认(deepseek 主力) + if (readTimeoutMs != null) props.setSaaOpenAiReadTimeoutMs(readTimeoutMs); // Plan A U5:openai 路读超时(M3+thinking 慢,量测 600000) + if (forceMaxTokens != null) props.setSaaForceMaxTokens(forceMaxTokens); // Plan A U5:全角色 maxTokens(M3+thinking 防截断,如 128000) + if (playPortBase != null) props.setSaaPlayPortBase(playPortBase); // 未设=props 默认 4320(字节零变) + if (cdpPortBase != null) props.setSaaCdpPortBase(cdpPortBase); // 未设=props 默认 9222;本机避 superpowers-chrome 可 -Dsaa.e2e.cdpPortBase=9322 + System.out.println("[config] sourceMode=" + sourceMode + "(factory=iife旧路/gamedef=真结构化), concurrency=" + concurrency + ", protocol=" + protocol + ", forceModel=" + (forceModel.isEmpty() ? "(stage1默认/deepseek主力)" : forceModel) + ", forceMaxTokens=" + props.getSaaForceMaxTokens() + ", openaiReadTimeoutMs=" + props.getSaaOpenAiReadTimeoutMs() + ", playPortBase=" + props.getSaaPlayPortBase() + ", cdpPortBase=" + props.getSaaCdpPortBase()); // ── 2) 结果容器 + stub 回调(共享 latch(N) + byTrace 精确归位)+ 派发器(5 参,sourceProjectApi=null=create 路)── long runStart = System.currentTimeMillis(); diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistoryFidelityTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistoryFidelityTest.java new file mode 100644 index 00000000..89701a29 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistoryFidelityTest.java @@ -0,0 +1,375 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.KeyStrategy; +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * SaaHistoryFidelityTest —— Plan A/U2 连续对话历史保真纯单元(无网络/无 harness,确定性):用假 {@link ChatModel} + * (记录收到的每个 {@link Prompt})驱动 {@link SaaStudioNodes#generateNode}(anthropic 路 history 开)+ + * {@link SaaStudioNodes#repairNode} 连续多轮,坐实 U2(创始人定·简化为 text 历史): + * + *
    + *
  1. 历史 append 非重置:第二轮起 prompt 含上一轮答案文本 = 连续对话救场(非每轮全新 [system,user]);
  2. + *
  3. 历史只留答案文本、丢 thinking 块(P1-2):历史里绝无带 signature 的 thinking 块(stock anthropic 出站本就丢之);
  4. + *
  5. 有界截断:深度 8 下历史按最近 K 轮留(不无界累积撑爆);
  6. + *
  7. 救场连续对话:repair 轮的新 user = 失败 verdict/反馈(而非从头重出题面);
  8. + *
  9. 失败路守严格交替(P1-1):generate 全档失败→repair→下一轮,历史/wire 无连续 user(Anthropic API 红线)。
  10. + *
+ * + *

openai 路对照:history 关(saaModelProtocol=openai)时每轮恒 {@code [system,user]} 两条、无历史——回归红线由 {@link + * #openai_path_keeps_two_message_prompt_no_history} 守(字节零变锚)。 + * + * @author 造梦AI(Plan A · U2 · 简化为 text 历史 + P1-1 失败路回归门) + */ +class SaaHistoryFidelityTest { + + private static final String SIGNATURE_KEY = "signature"; + private static final Path DUMMY_ROOT = Paths.get("/tmp/saa-history-fidelity-dummy"); + + /** 假 ChatModel:记录收到的每个 Prompt(供断言连续对话),恒回「thinking 块(signature)+ 答案块(合法 gameDefinition JSON)」。 */ + private static final class RecordingChatModel implements ChatModel { + final List prompts = new ArrayList<>(); + private int round = 0; + + @Override + public ChatResponse call(Prompt prompt) { + prompts.add(prompt); + round++; + return thinkingPlusAnswer(round); + } + } + + /** + * 假 ChatModel(失败路用):前 {@code failFirstN} 次 {@code call} 抛 IOException(触发 generateNode 主+回退档全失败→r==null), + * 之后恒回正常 thinking+answer。用于驱动「generate 全档失败→repair→下一轮 generate 成功」序列,验 P1-1 无连续 user。 + */ + private static final class FlakyChatModel implements ChatModel { + final List prompts = new ArrayList<>(); + private final int failFirstN; + private int calls = 0; + private int round = 0; + + FlakyChatModel(int failFirstN) { + this.failFirstN = failFirstN; + } + + @Override + public ChatResponse call(Prompt prompt) { + calls++; + if (calls <= failFirstN) { + // 抛 IO(isRetryable=true)→ 主档 180s×3 重试后耗尽、回退档同样耗尽 → generateNode r==null(全档失败)。 + throw new RuntimeException(new java.io.IOException("模拟网关连不上(失败路)")); + } + prompts.add(prompt); // 仅成功调用记 prompt(断言下一轮成功 generate 的 wire 交替)。 + round++; + return thinkingPlusAnswer(round); + } + } + + /** 构一轮「thinking 块(带 signature)+ 答案块(无 signature,合法最小 gameDefinition)」响应(thinking 先序)。 */ + private static ChatResponse thinkingPlusAnswer(int round) { + AssistantMessage thinking = AssistantMessage.builder() + .content("第" + round + "轮推理:先定实体与规则……") + .properties(Map.of("messageType", "ASSISTANT", SIGNATURE_KEY, "sig-r" + round)) + .build(); + AssistantMessage answer = new AssistantMessage( + "{\"entities\":[],\"components\":[],\"behaviors\":[],\"scenes\":[],\"rules\":[],\"round\":" + round + "}"); + ChatResponseMetadata meta = ChatResponseMetadata.builder() + .usage(new DefaultUsage(10, 20)) + .build(); + return new ChatResponse(List.of(new Generation(thinking), new Generation(answer)), meta); + } + + /** 用 ALL_KEYS 全 ReplaceStrategy 建可读写 state。 */ + private static OverAllState newFullState(Map init) { + OverAllState s = new OverAllState(init == null ? new HashMap<>() : new HashMap<>(init)); + Map ks = new HashMap<>(); + for (String k : SaaStudioNodes.ALL_KEYS) { + ks.put(k, new ReplaceStrategy()); + } + s.registerKeyAndStrategy(ks); + return s; + } + + private static void applyNode(OverAllState s, NodeAction node) throws Exception { + Map partial = node.apply(s); + s.updateState(partial); + } + + /** 取 anthropic 路 gamedef 历史(List);缺则空 list。 */ + @SuppressWarnings("unchecked") + private static List gamedefHistory(OverAllState s) { + return (List) s.value(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF).orElse(new ArrayList()); + } + + /** 建一个 anthropic 路(history 开)的 gamedef generate 节点(同一假模型贯穿 code/fix/stage2/fallback,便于记录全轮 prompt)。 */ + private static NodeAction gamedefGenerateNodeHistoryOn(ChatModel m) { + SaaStudioNodes.GenModels gm = new SaaStudioNodes.GenModels( + m, m, m, m, m, m, + "deepseek-v4-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-pro", + "MiniMax-M3", "MiniMax-M3"); + // Plan A/U2:anthropic 路(history 开)的 generate 节点(gamedef 形态)。 + return SaaStudioNodes.generateNode(gm, DUMMY_ROOT, "gamedef", /*historyEnabled*/ true); + } + + /** + * 命门①+②+④:连续多轮(首轮 + 7 救场轮 = 深度 8)下——历史 append 非重置 + 历史只留答案文本(无 thinking 块) + 救场连续对话。 + */ + @Test + void multi_round_appends_text_only_history_at_depth_8() throws Exception { + RecordingChatModel m = new RecordingChatModel(); + NodeAction gen = gamedefGenerateNodeHistoryOn(m); + + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_BRIEF, "做一个躲避小游戏"); + init.put(SaaStudioNodes.K_ENRICHED, "做一个躲避小游戏\n\n## 设计稿\n躲避下落物,碰到即输"); + init.put(SaaStudioNodes.K_ARCHETYPE, "dodge"); + init.put(SaaStudioNodes.K_ROLE, "code"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + OverAllState s = newFullState(init); + + // 首轮 generate(role=code)。 + applyNode(s, gen); + int historyAfterRound1 = gamedefHistory(s).size(); + assertTrue(historyAfterRound1 >= 2, "首轮后历史至少含 [user, 答案](连续对话起步)"); + + // 连续 7 轮救场:每轮 repair(写失败 feedback + role=fix) → generate。深度共 8。 + for (int round = 2; round <= 8; round++) { + // 模拟失败:写 feedback(H_progress 未过),让 repair 续上一轮答案 + 失败 verdict 作新 user。 + s.updateState(Map.of(SaaStudioNodes.K_FEEDBACK, + "第" + (round - 1) + "轮 H_progress 未过:剩余实体未下降,请增量修正")); + applyNode(s, SaaStudioNodes.repairNode()); + applyNode(s, gen); + } + + // ── 命门①:历史 append(非每轮重置):8 轮后历史显著长于单轮(含多轮救场,受 K 界后仍 > 单轮)。 + List hist = gamedefHistory(s); + assertTrue(hist.size() > historyAfterRound1, + "连续 8 轮后历史必须 append 增长(非每轮全新 [system,user] 重置);实得 size=" + hist.size()); + + // ── 命门②(P1-2):历史里【绝无】thinking 块(带 signature)——U2 只留答案文本。 + assertNoThinkingInHistory(hist); + + // ── 命门④:救场连续对话——最后一轮 prompt 的「最后一条 user」是失败 verdict/反馈(增量修),而非从头重出题面。 + Prompt lastPrompt = m.prompts.get(m.prompts.size() - 1); + Message lastUser = lastUserOf(lastPrompt); + assertTrue(lastUser != null && lastUser.getText().contains("H_progress 未过"), + "救场轮新 user 应是失败反馈(连续对话增量修),实得=" + (lastUser == null ? "null" : tail(lastUser.getText()))); + + // ── 命门①续证:最后一轮 prompt 含早期轮答案块(连续对话,非从头)。 + long assistantTurnsInLastPrompt = lastPrompt.getInstructions().stream() + .filter(x -> x.getMessageType() == MessageType.ASSISTANT).count(); + assertTrue(assistantTurnsInLastPrompt >= 1, + "末轮 prompt 应含历史答案轮(连续对话救场),实得 assistant 轮=" + assistantTurnsInLastPrompt); + + // ── wire 交替:末轮 prompt user/assistant 严格交替(无连续同 role)。 + assertStrictAlternation(lastPrompt.getInstructions()); + } + + /** + * 命门③:有界截断——深度 8 下历史按最近 K 轮留(不无界累积)。末轮 prompt 的 assistant(答案) 轮数应被界在 ~K, + * 而非 8(证有界);且历史里 user 轮数同样受界。 + */ + @Test + void older_turns_bounded_to_recent_k_turns() throws Exception { + RecordingChatModel m = new RecordingChatModel(); + NodeAction gen = gamedefGenerateNodeHistoryOn(m); + + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_BRIEF, "做一个躲避小游戏"); + init.put(SaaStudioNodes.K_ENRICHED, "做一个躲避小游戏(含设计稿)"); + init.put(SaaStudioNodes.K_ARCHETYPE, "dodge"); + init.put(SaaStudioNodes.K_ROLE, "code"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + OverAllState s = newFullState(init); + + applyNode(s, gen); + for (int round = 2; round <= 8; round++) { + s.updateState(Map.of(SaaStudioNodes.K_FEEDBACK, "第" + (round - 1) + "轮失败:增量修正")); + applyNode(s, SaaStudioNodes.repairNode()); + applyNode(s, gen); + } + + // 末轮 prompt:历史经 boundHistory 截到最近 K 轮 → 历史里 user 轮数应 < 总轮数 8(证有界,非全保留)。 + Prompt lastPrompt = m.prompts.get(m.prompts.size() - 1); + long userTurns = lastPrompt.getInstructions().stream() + .filter(x -> x.getMessageType() == MessageType.USER).count(); + long assistantTurns = lastPrompt.getInstructions().stream() + .filter(x -> x.getMessageType() == MessageType.ASSISTANT).count(); + // 末轮 prompt = system + boundedHistory(≤K轮) + 本轮新 user;故 user 轮数 ≤ K(含本轮新 user),远 < 8。 + assertTrue(userTurns <= 4 && userTurns >= 1, + "有界截断:末轮 prompt user 轮数应被界在最近 K(+本轮)(实得 user=" + userTurns + ",应 < 总 8 轮)"); + assertTrue(assistantTurns < 8, + "有界截断:历史答案轮应被界(实得 assistant=" + assistantTurns + ",应 < 8 证非无界全保留)"); + // 历史本身也受界(state 内历史 user 轮数 < 8)。 + long histUsers = gamedefHistory(s).stream().filter(x -> x.getMessageType() == MessageType.USER).count(); + assertTrue(histUsers >= 1, "历史至少含 user 轮"); + } + + /** + * 命门⑤(P1-1 失败路回归门,本次新增):generate 全档失败(r==null)→ repair → 下一轮 generate 成功—— + * 历史无连续 user、且下一轮成功 generate 下发的 wire 消息严格交替(user/assistant),不触发 Anthropic 400。 + * + *

这是 P1-1 的回归门:修前失败路 append 悬空 user(orderedTurn=null)→ 下一轮再 append 新 user → 两条连续 user。 + * 修后失败路整轮不入历史(无悬空 user)+ assembleConversation 去尾防御 → 严格交替。 + */ + @Test + void failure_then_repair_keeps_strict_user_assistant_alternation() throws Exception { + // 首轮 generate 全档失败:gamedefGenerateNodeHistoryOn 全角色共用同一模型实例 → generateNode 内回退档==主档 → + // 模型链只 1 档,每档内 LLM_MAX_TRIES=3 重试 → 首轮 3 次 call 全失败即 r==null;第 4 次起成功(下一轮 repair generate)。 + FlakyChatModel flaky = new FlakyChatModel(3); + NodeAction gen = gamedefGenerateNodeHistoryOn(flaky); + + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_BRIEF, "做一个躲避小游戏"); + init.put(SaaStudioNodes.K_ENRICHED, "做一个躲避小游戏(含设计稿)"); + init.put(SaaStudioNodes.K_ARCHETYPE, "dodge"); + init.put(SaaStudioNodes.K_ROLE, "code"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + OverAllState s = newFullState(init); + + // 首轮 generate:全档失败(r==null)。失败路应写 feedback、且【不入悬空 user】历史。 + applyNode(s, gen); + List histAfterFail = gamedefHistory(s); + assertTrue(histAfterFail.isEmpty(), + "P1-1:generate 全档失败后历史应仍为空(不入悬空 user),实得 size=" + histAfterFail.size()); + assertTrue(s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("").contains("模型调用失败"), + "全档失败应写 feedback(下游路由 repair)"); + + // repair → 下一轮 generate(此次成功)。 + applyNode(s, SaaStudioNodes.repairNode()); + applyNode(s, gen); + + // ── 历史无连续 user:成功轮 append [user, 答案] → 历史首条 user、次条 assistant,严格交替。 + List hist = gamedefHistory(s); + assertNoConsecutiveUser(hist); + assertStrictAlternation(hist); + + // ── wire 交替:成功 generate 下发的 prompt(flaky 仅成功调用记 prompt)user/assistant 严格交替、无连续 user。 + assertTrue(!flaky.prompts.isEmpty(), "应有一次成功 generate 下发 prompt"); + Prompt successPrompt = flaky.prompts.get(flaky.prompts.size() - 1); + assertStrictAlternation(successPrompt.getInstructions()); + assertNoConsecutiveUser(successPrompt.getInstructions()); + } + + /** + * 回归红线:openai 路(history 关)每轮恒 {@code [SystemMessage, UserMessage]} 两条、零历史——与改造前字节零变。 + */ + @Test + void openai_path_keeps_two_message_prompt_no_history() throws Exception { + RecordingChatModel m = new RecordingChatModel(); + // history 关(openai 路)。 + SaaStudioNodes.GenModels gm = new SaaStudioNodes.GenModels( + m, m, m, m, m, m, + "deepseek-v4-flash", "deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-pro", + "MiniMax-M3", "MiniMax-M3"); + NodeAction gen = SaaStudioNodes.generateNode(gm, DUMMY_ROOT, "gamedef", /*historyEnabled*/ false); + + Map init = new HashMap<>(); + init.put(SaaStudioNodes.K_BRIEF, "做一个躲避小游戏"); + init.put(SaaStudioNodes.K_ENRICHED, "做一个躲避小游戏(含设计稿)"); + init.put(SaaStudioNodes.K_ARCHETYPE, "dodge"); + init.put(SaaStudioNodes.K_ROLE, "code"); + init.put(SaaStudioNodes.K_REPAIR_COUNT, 0); + OverAllState s = newFullState(init); + + applyNode(s, gen); + s.updateState(Map.of(SaaStudioNodes.K_FEEDBACK, "第1轮失败")); + applyNode(s, SaaStudioNodes.repairNode()); + applyNode(s, gen); + + // openai 路每轮恒 2 条(system+user),无历史键。 + for (Prompt p : m.prompts) { + List msgs = p.getInstructions(); + assertEquals(2, msgs.size(), "openai 路每轮恒 [system,user] 两条(字节零变锚)"); + assertEquals(MessageType.SYSTEM, msgs.get(0).getMessageType(), "首条为 system"); + assertEquals(MessageType.USER, msgs.get(1).getMessageType(), "次条为 user"); + } + assertTrue(s.value(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF).isEmpty(), + "openai 路绝不写历史键(字节零变)"); + } + + // ====================== helpers ====================== + + /** 断言历史里【绝无】thinking 块(带 signature 的 AssistantMessage)——U2 历史只留答案文本(P1-2)。 */ + private static void assertNoThinkingInHistory(List history) { + for (Message msg : history) { + if (msg instanceof AssistantMessage am && am.getMetadata() != null + && am.getMetadata().containsKey(SIGNATURE_KEY)) { + fail("历史里出现 thinking 块(带 signature)——U2 应只留答案文本块"); + } + } + } + + /** 断言消息序列【无连续两条 user】(Anthropic user/assistant 严格交替红线,P1-1)。 */ + private static void assertNoConsecutiveUser(List msgs) { + MessageType prev = null; + for (Message m : msgs) { + MessageType t = m.getMessageType(); + if (t == MessageType.USER && prev == MessageType.USER) { + fail("出现两条连续 user(破 Anthropic 严格交替,P1-1)"); + } + prev = t; + } + } + + /** + * 断言 user/assistant 严格交替(忽略首个 system;其后 user 与 assistant 必交替出现,无连续同 role)。 + * 这是 Anthropic Messages API 的硬约束(违反→HTTP 400)。 + */ + private static void assertStrictAlternation(List msgs) { + MessageType prevConv = null; // 仅记 user/assistant 会话轮(跳过 system) + for (Message m : msgs) { + MessageType t = m.getMessageType(); + if (t == MessageType.SYSTEM) { + continue; + } + if (t == MessageType.USER || t == MessageType.ASSISTANT) { + if (prevConv != null) { + assertFalse(prevConv == t, + "user/assistant 未严格交替:出现连续 " + t + "(破 Anthropic 红线,P1-1)"); + } + prevConv = t; + } + } + } + + /** 取 prompt 里最后一条 user message(救场轮新 user = 失败反馈)。 */ + private static Message lastUserOf(Prompt p) { + Message found = null; + for (Message msg : p.getInstructions()) { + if (msg.getMessageType() == MessageType.USER) { + found = msg; + } + } + return found; + } + + private static String tail(String x) { + return x == null ? "null" : (x.length() > 120 ? "..." + x.substring(x.length() - 120) : x); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistorySerdeTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistorySerdeTest.java new file mode 100644 index 00000000..34de85b7 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaHistorySerdeTest.java @@ -0,0 +1,132 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.serializer.plain_text.jackson.SpringAIJacksonStateSerializer; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * SaaHistorySerdeTest —— Plan A/U2 P0-5 命门:连续对话历史键经 SAA 生产 checkpoint 序列化器往返保真的 + * 模型无关单测(纯逻辑、无网络/无 key/无 DB,普通 {@code mvn test} 即跑)。 + * + *

U2 创始人决策(简化为 text 历史):历史只留答案文本块(丢 thinking 块)——stock spring-ai-anthropic + * 1.1.2 出站丢弃 thinking 块,留之无益。故本测试验「纯 text 答案历史({@code List},无 thinking 块)」往返保真, + * 不再验 thinking/signature 往返(历史里本就不存 thinking 块)。 + * + *

为何这是命门:e2e harness 用 {@code saaCheckpointEnabled=false}+dataSource=null(量测从不触发序列化路), + * 但 staging 默认 {@code saaCheckpointEnabled=true} → 首次 checkpoint 写/续跑才序列化 {@link SaaStudioNodes#K_MSG_HISTORY_GAMEDEF} + * 这个历史键。若 {@code List} 经 {@link SpringAIJacksonStateSerializer} 往返退化成 {@code List} + * (丢元素类型),绿门后会在生产炸(回放给 AnthropicChatModel)。本测试在生产同款序列化器上强制 + * {@code dataToBytes→dataFromBytes} 往返,坐实:历史仍是类型化 {@code List}、文本完整、可再下发。 + * + * @author 造梦AI(Plan A · U2 · P0-5 命门 · 简化为 text 历史) + */ +class SaaHistorySerdeTest { + + /** 建一条答案块 AssistantMessage(U2 历史只存答案块,无 thinking/signature metadata)。 */ + private static AssistantMessage answerMsg(String text) { + return new AssistantMessage(text); + } + + /** 生产同款序列化器(AgentStateFactory=OverAllState::new,与 StateGraph 内部对 OverAllState 序列化同路)。 */ + private static SpringAIJacksonStateSerializer serializer() { + return new SpringAIJacksonStateSerializer(OverAllState::new); + } + + /** + * P0-5 命门①:纯 text 答案历史({@code List},无 thinking 块)经生产序列化器 {@code dataToBytes→dataFromBytes} + * 往返后——仍是类型化 {@code List}(非退化 {@code List}),user/答案文本完整,且能再构 + * Prompt 下发(assertNotNull)。修前(无此键登记/无往返保真)= 在 staging checkpoint 开时炸。 + */ + @Test + void msg_history_list_of_messages_roundtrips_as_typed_text_history() throws Exception { + // 构一轮 U2 历史形:user 轮 + 答案块(纯文本,无 thinking 块)。 + List history = new ArrayList<>(); + history.add(new UserMessage("请实现一个 Flappy 游戏")); + history.add(answerMsg("```js\nexport default function createGame(){}\n```")); + + // 整 state map(历史键在内)经生产序列化器往返(与 MysqlSaver 序列化整 OverAllState 同路)。 + Map state = new java.util.HashMap<>(); + state.put(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF, history); + state.put(SaaStudioNodes.K_ROLE, "fix"); // 混入普通标量键,证不连坐 + + SpringAIJacksonStateSerializer ser = serializer(); + byte[] bytes = ser.dataToBytes(state); + assertNotNull(bytes, "序列化字节非空"); + Map restored = ser.dataFromBytes(bytes); + + // ① 容器仍是 List。 + Object back = restored.get(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF); + assertInstanceOf(List.class, back, "历史键往返后必须仍是 List"); + @SuppressWarnings("unchecked") + List backList = (List) back; + assertEquals(2, backList.size(), "历史长度往返不变"); + + // ② 元素仍是类型化 Message(非退化 LinkedHashMap)——这是命门:退化即在生产回放炸。 + for (Object el : backList) { + assertInstanceOf(Message.class, el, "历史元素往返后必须仍是类型化 Message(非 LinkedHashMap)"); + } + assertInstanceOf(UserMessage.class, backList.get(0), "第 0 元素应是 UserMessage"); + assertInstanceOf(AssistantMessage.class, backList.get(1), "答案块往返后应是 AssistantMessage"); + + // ③ user / 答案文本完整(往返保真)。 + assertEquals("请实现一个 Flappy 游戏", ((Message) backList.get(0)).getText(), "user 文本往返保真"); + assertEquals("```js\nexport default function createGame(){}\n```", + ((Message) backList.get(1)).getText(), "答案文本往返保真"); + + // ④ 可再下发:用往返后的历史 + system + 新 user 构 Prompt 不抛(坐实「能回放给 AnthropicChatModel」)。 + List next = new ArrayList<>(); + next.add(new SystemMessage("你是结构化源生成器")); + next.addAll(castMessages(backList)); + next.add(new UserMessage("上一轮 H_progress 未过,请针对反馈增量修正")); + assertNotNull(new org.springframework.ai.chat.prompt.Prompt(next), "往返后的历史应能再构 Prompt 下发"); + } + + /** + * P0-5 命门②:多轮(连续 2 轮救场)纯 text 历史往返后长度与顺序逐位一致——续跑 checkpoint 后历史不丢轮、不错位。 + */ + @Test + void multi_turn_text_history_order_preserved_across_roundtrip() throws Exception { + List history = new ArrayList<>(); + // 轮1:user → 答案 + history.add(new UserMessage("第一轮:实现游戏")); + history.add(answerMsg("轮1答案")); + // 轮2(救场):失败 verdict 作 user → 答案 + history.add(new UserMessage("第二轮:H_progress 未过,增量修正")); + history.add(answerMsg("轮2答案")); + + Map state = new java.util.HashMap<>(); + state.put(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF, history); + + SpringAIJacksonStateSerializer ser = serializer(); + Map restored = ser.dataFromBytes(ser.dataToBytes(state)); + @SuppressWarnings("unchecked") + List back = (List) restored.get(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF); + + // 逐位类型 + 文本:user/答案严格交替,顺序与文本逐位一致。 + assertEquals(4, back.size(), "两轮历史共 4 条往返不变"); + assertInstanceOf(UserMessage.class, back.get(0)); + assertInstanceOf(AssistantMessage.class, back.get(1)); + assertInstanceOf(UserMessage.class, back.get(2)); + assertInstanceOf(AssistantMessage.class, back.get(3)); + assertEquals("轮1答案", ((Message) back.get(1)).getText(), "轮1答案文本往返保真"); + assertEquals("轮2答案", ((Message) back.get(3)).getText(), "轮2答案文本往返保真"); + } + + /** 把往返后的 Object 列表安全转 Message 列表(元素已断言为 Message)。 */ + @SuppressWarnings("unchecked") + private static List castMessages(List list) { + return (List) (List) list; + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java new file mode 100644 index 00000000..dc285745 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java @@ -0,0 +1,89 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaPickAnswerTextTest —— {@link SaaStudioNodes#pickAnswerText(ChatResponse)} 的模型无关单测(Plan A/U1)。 + * + *

纯逻辑、无网络(构造假 {@link ChatResponse}),普通 {@code mvn test} 即跑。覆盖两形: + *

    + *
  1. OpenAI 兼容路(单 Generation):直接取 gen0,断字节等价旧 {@code getResult().getText()} 行为(回归保证);
  2. + *
  3. Anthropic+thinking 路(多 Generation):取「末个 metadata 不含 signature 的 Generation」=答案, + * 跳过含 signature 的 thinking 推理块(实测识别靠 {@code signature} 键存在性,不靠 {@code "thinking"} 键)。
  4. + *
+ * 这把「取答案」逻辑与底层协议(OpenAI/Anthropic)解耦验证:无需真模型/真网关即可坐实 U1 重组语义正确 + 回归不破。 + * + * @author 造梦AI(Plan A · U1) + */ +class SaaPickAnswerTextTest { + + /** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径:Anthropic thinking Generation 的 metadata 含此键)。 */ + private static final String SIGNATURE_KEY = "signature"; + + /** 建一条「无 metadata signature」的 Generation(=答案 Generation / 或 OpenAI 单 Generation)。 */ + private static Generation answerGen(String text) { + return new Generation(new AssistantMessage(text)); + } + + /** 建一条「含 metadata signature」的 Generation(=Anthropic thinking 推理块)。 */ + private static Generation thinkingGen(String text) { + AssistantMessage msg = AssistantMessage.builder() + .content(text) + .properties(Map.of("messageType", "ASSISTANT", SIGNATURE_KEY, "sig-abc123")) + .build(); + return new Generation(msg); + } + + @Test + void openai_single_generation_is_byte_equivalent_to_old_getResult() { + // OpenAI 兼容路:单 Generation → 直接取 gen0(等价旧 getResult().getOutput().getText())。 + ChatResponse resp = new ChatResponse(List.of(answerGen("{\"entities\":[]}"))); + assertEquals("{\"entities\":[]}", SaaStudioNodes.pickAnswerText(resp), + "单 Generation 必须字节等价旧 getResult().getText()(回归命门)"); + } + + @Test + void anthropic_thinking_picks_the_no_signature_answer_generation() { + // Anthropic+thinking:gen#0=thinking(含 signature,是推理)、gen#1=答案(无 signature)。 + ChatResponse resp = new ChatResponse(List.of( + thinkingGen("让我想一想:先比较小数部分……"), + answerGen("```js\nexport default function(){}\n```"))); + String picked = SaaStudioNodes.pickAnswerText(resp); + assertEquals("```js\nexport default function(){}\n```", picked, + "多 Generation 须取无 signature 的答案 Generation,而非含 signature 的 thinking 推理块"); + assertTrue(!picked.contains("让我想一想"), "绝不能取到 thinking 推理文本(那会污染 ```js/JSON 抽取)"); + } + + @Test + void anthropic_picks_last_no_signature_when_multiple_answers() { + // 多个无 signature 块(极端):取末个(末答案优先,对齐「从尾向前找第一个无 signature」)。 + ChatResponse resp = new ChatResponse(List.of( + thinkingGen("推理…"), + answerGen("早期答案"), + answerGen("最终答案"))); + assertEquals("最终答案", SaaStudioNodes.pickAnswerText(resp), "应取末个无 signature 的答案 Generation"); + } + + @Test + void all_thinking_falls_back_to_last_generation_without_throwing() { + // 兜底(异常态:全是 thinking 块)→ 取末个 Generation,绝不抛。 + ChatResponse resp = new ChatResponse(List.of(thinkingGen("推理A"), thinkingGen("推理B"))); + assertEquals("推理B", SaaStudioNodes.pickAnswerText(resp), "全 thinking 时兜底取末个(best-effort 不抛)"); + } + + @Test + void empty_or_null_returns_empty_string() { + // 空响应/空 Generation 列表 → 返 ""(交调用方按节点语义兜底,绝不抛/NPE)。 + assertEquals("", SaaStudioNodes.pickAnswerText(null), "null 响应返空串"); + assertEquals("", SaaStudioNodes.pickAnswerText(new ChatResponse(List.of())), "空 Generation 列表返空串"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java index a9996455..5c879320 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodesRegressionTest.java @@ -165,4 +165,95 @@ class SaaStudioNodesRegressionTest { assertTrue(SaaPrompts.hasGatespecBlock("x\n```gatespec\n{bad json}\n```")); assertFalse(SaaPrompts.hasGatespecBlock("没有任何 gatespec 块的设计稿")); } + + // ====================== 003-U1:8/8 driver gatespec 解析保真(每 driver 一样例)====================== + // 校验 DESIGN_SYSTEM 决策树补齐至 8 driver 后,每类 driver 的 gatespec 经 extractGatespec 解析时 + // driver.type 不被误规整、关键字段保真——这是「设计 agent 产对 driver → harness 真驱动」的契约根。 + // driver.type 集合须与 play.cdp.cjs:206-214 的 8 分发口径逐字对齐。 + + /** 小工具:包一段 gatespec JSON 进 ```gatespec 块,喂 extractGatespec。 */ + private static JsonNode parseSpec(String specJson) { + JsonNode g = SaaPrompts.extractGatespec("设计正文……\n```gatespec\n" + specJson + "\n```"); + org.junit.jupiter.api.Assertions.assertNotNull(g, "gatespec 应解析成功"); + return g; + } + + @Test + void gatespec_preserves_all_eight_driver_types_verbatim() { + // 8 driver 各一最小 gatespec:driver.type 必须逐字保真(不被归一/丢弃),与 play.cdp.cjs dispatch 口径一致。 + String[][] cases = { + {"paddle-intercept", "{\"driver\":{\"type\":\"paddle-intercept\",\"ballPath\":\"ball.x\",\"paddleY\":800}}"}, + {"tap-targets", "{\"driver\":{\"type\":\"tap-targets\"}}"}, + {"flap-to-gap", "{\"driver\":{\"type\":\"flap-to-gap\",\"entityPath\":\"bird.y\",\"vyPath\":\"bird.vy\",\"gapPath\":\"nextGap.y\"}}"}, + {"seek-x", "{\"driver\":{\"type\":\"seek-x\",\"entityPath\":\"bird.x\",\"targetPath\":\"nextPlatform.x\"}}"}, + {"tap-pairs", "{\"driver\":{\"type\":\"tap-pairs\"}}"}, + {"key-cycle", "{\"driver\":{\"type\":\"key-cycle\",\"keys\":[\"ArrowLeft\",\"ArrowUp\",\"Space\"]}}"}, + {"drag-aiming", "{\"driver\":{\"type\":\"drag-aiming\",\"origin\":{\"x\":80,\"y\":600},\"targetsPath\":\"targets\",\"launchPath\":\"launch\"}}"}, + {"aim-fire", "{\"driver\":{\"type\":\"aim-fire\",\"shipPath\":\"ship\",\"targetsPath\":\"targets\",\"fireKey\":\"Space\"}}"}, + }; + for (String[] c : cases) { + JsonNode g = parseSpec(c[1]); + assertEquals(c[0], g.path("driver").path("type").asText(), + "driver.type 必须逐字保真(与 play.cdp.cjs dispatch 对齐):" + c[0]); + } + } + + @Test + void gatespec_tap_pairs_fields_intact() { + // tap-pairs:steps/stepMs 等数值字段保真 + assertAfterPlay 进展断言保留(score increased)。 + JsonNode g = parseSpec("{\"driver\":{\"type\":\"tap-pairs\",\"steps\":24,\"stepMs\":300}," + + "\"assertAfterPlay\":[{\"path\":\"score\",\"op\":\"increased\"}],\"expectLatch\":true}"); + assertEquals("tap-pairs", g.path("driver").path("type").asText()); + assertEquals(24, g.path("driver").path("steps").asInt(), "steps 数值保真"); + assertEquals(300, g.path("driver").path("stepMs").asInt(), "stepMs 数值保真"); + assertEquals(1, g.path("assertAfterPlay").size(), "进展断言保留"); + assertEquals("score", g.path("assertAfterPlay").get(0).path("path").asText()); + assertEquals("increased", g.path("assertAfterPlay").get(0).path("op").asText()); + } + + @Test + void gatespec_key_cycle_keys_array_intact() { + // key-cycle:keys 数组逐项保真(顺序/内容不被规整)。 + JsonNode g = parseSpec("{\"driver\":{\"type\":\"key-cycle\",\"keys\":[\"ArrowUp\",\"ArrowRight\"],\"steps\":120,\"stepMs\":200}," + + "\"assertAfterPlay\":[{\"path\":\"score\",\"op\":\"increased\"}]}"); + JsonNode keys = g.path("driver").path("keys"); + assertTrue(keys.isArray() && keys.size() == 2, "keys 应为 2 元素数组"); + assertEquals("ArrowUp", keys.get(0).asText()); + assertEquals("ArrowRight", keys.get(1).asText()); + } + + @Test + void gatespec_aim_fire_paths_intact() { + // aim-fire:shipPath/targetsPath/fireKey 保真(harness 据此读 ship.angle + targets 旋向对齐开火)。 + JsonNode g = parseSpec("{\"driver\":{\"type\":\"aim-fire\",\"shipPath\":\"ship\",\"targetsPath\":\"targets\",\"fireKey\":\"Space\"}," + + "\"exportState\":[\"phase\",\"score\",\"ship\",\"targets\"]," + + "\"assertAfterPlay\":[{\"path\":\"score\",\"op\":\"increased\"}]}"); + JsonNode d = g.path("driver"); + assertEquals("aim-fire", d.path("type").asText()); + assertEquals("ship", d.path("shipPath").asText(), "shipPath 保真"); + assertEquals("targets", d.path("targetsPath").asText(), "targetsPath 保真"); + assertEquals("Space", d.path("fireKey").asText(), "fireKey 保真"); + } + + @Test + void gatespec_drag_aiming_origin_and_launchpath_intact() { + // drag-aiming:origin 嵌套对象 + launchPath 保真(harness 据 launchPath 读物理常数做解析瞄准)。 + JsonNode g = parseSpec("{\"driver\":{\"type\":\"drag-aiming\",\"origin\":{\"x\":80,\"y\":600}," + + "\"targetsPath\":\"targets\",\"launchPath\":\"launch\",\"steps\":6}," + + "\"assertAfterPlay\":[{\"path\":\"score\",\"op\":\"increased\"}]}"); + JsonNode d = g.path("driver"); + assertEquals("drag-aiming", d.path("type").asText()); + assertEquals(80, d.path("origin").path("x").asInt(), "origin.x 嵌套保真"); + assertEquals(600, d.path("origin").path("y").asInt(), "origin.y 嵌套保真"); + assertEquals("launch", d.path("launchPath").asText(), "launchPath 保真"); + } + + @Test + void gatespec_new_drivers_not_clobbered_by_ballpath_normalization() { + // ballPath .y→.x 归一【只对 paddle-intercept 生效】;新 driver 即便误带 .y 路径也不应被改(隔离副作用)。 + JsonNode g = parseSpec("{\"driver\":{\"type\":\"flap-to-gap\",\"entityPath\":\"bird.y\",\"vyPath\":\"bird.vy\",\"gapPath\":\"nextGap.y\"}}"); + assertEquals("bird.y", g.path("driver").path("entityPath").asText(), + "flap-to-gap 的 entityPath=bird.y 不应被 paddle-intercept 的 .y→.x 归一误改"); + assertEquals("nextGap.y", g.path("driver").path("gapPath").asText(), "gapPath 的 .y 不应被误改"); + } } diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-diverse10.txt b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-diverse10.txt new file mode 100644 index 00000000..d3d58890 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-diverse10.txt @@ -0,0 +1,15 @@ +# saa-e2e-briefs-diverse10.txt —— 003-U1/U5 验证集:10 个不同玩法品类(创始人 2026-06-19 指令) +# 用法:-Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.briefFile=src/test/resources/saa-e2e-briefs-diverse10.txt -Dsaa.e2e.n=10 +# 选品原则:一品类一条、覆盖 U4 全部 8 个 driver 家族(a-h),避免单品类偶过/偶不过;优于按行序取前 10(前 10 仅 4 品类聚集)。 +# 行式:去首尾空白、跳空行与本行式 # 注释(SaaFullGraphE2eTest.parseBriefLines)。 +# +打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。 +贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。 +小恐龙跑酷:按键让恐龙跳过仙人掌和飞鸟,撞到障碍即结束,跑得越远分越高。 +点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。 +太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。 +打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。 +钢琴块:只点黑色方块别碰白块,节奏越来越快,点错或漏点即结束。 +像素鸟穿管:点击让小鸟扇翅上升,穿过一根根上下水管的缝隙,撞管或落地即结束。 +愤怒投石:拖拽瞄准弹射石块,砸倒所有目标木箱即通关,弹药用尽未清场则失败。 +太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-gamedef.txt b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-gamedef.txt new file mode 100644 index 00000000..1d7c2c3e --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-gamedef.txt @@ -0,0 +1,53 @@ +# saa-e2e-briefs-gamedef.txt —— 003-U1 gamedef 路 A/B 量测 brief 集(≥30 条多品类) +# 用法:-Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.briefFile=src/test/resources/saa-e2e-briefs-gamedef.txt +# 行式:去首尾空白、跳空行与本行式 # 注释(SaaFullGraphE2eTest.parseBriefLines)。 +# 选品偏向运行时/九门 harness 支持的品类(realtime/physics/tap-targets/clickable),避免单品类偶过/偶不过。 +# 留出集(不进本 A/B、供 final R1 量):saa-e2e-briefs-holdout.txt——两文件 brief 不重叠。 +# +# ── 挡板/接球类(realtime + 碰撞)── +打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。 +弹球台:用底部挡板把弹球接住反弹,击碎顶部所有彩色方块过关,球落底则失败。 +双挡板对打:上下各一块挡板来回击球,让球越过对方底线即得分,先得五分者胜。 +# ── 蛇/贪吃类(网格 + tick 移动)── +贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。 +吃豆人:在迷宫里移动吃光所有豆子,碰到巡逻的幽灵则失败。 +# ── 接物/收集类(realtime + 左右移动)── +接金币:左右移动篮子接住从天上掉落的金币,漏接太多就失败。 +接水果:晃动盘子接住掉落的水果,接到炸弹则扣命,命数耗尽结束。 +雨中接伞:左右移动小人撑伞接住落下的雨滴,三滴落空则游戏结束。 +# ── 跳跃/躲避类(physics + 点击跳)── +躲障碍:角色不断前进,点击跳跃躲开迎面而来的障碍物,撞到就结束。 +小恐龙跑酷:按键让恐龙跳过仙人掌和飞鸟,撞到障碍即结束,跑得越远分越高。 +火柴人跳坑:连续点击控制跳跃跨过地面深坑,掉进坑里则失败。 +直升机穿洞:长按上升松开下降,穿过上下起伏的山洞间隙,撞壁即结束。 +# ── 消除/三连类(clickable + 网格)── +点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。 +方块连连看:点击两个图案相同且可直连的方块成对消除,全部清空即胜。 +下落消除:交换相邻宝石凑成三连消除得分,步数用尽时达标过关。 +# ── 射击/弹幕类(realtime + 发射)── +太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。 +打飞碟:移动炮台瞄准并射击横向飞过的飞碟,限时内击落数量达标过关。 +保卫基地:旋转底部炮塔射击四面来袭的入侵者,基地被攻破则失败。 +# ── 点击反应/打地鼠类(tap-targets + 限时)── +打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。 +戳泡泡:屏幕不断冒出彩色泡泡,快速点击戳破得分,漏掉太多则结束。 +快速点靶:随机位置闪现靶心,限时内尽量多地点中,结束时统计命中数。 +点亮方块:方块随机亮起,趁熄灭前点中得分,连续三次没点中则结束。 +# ── 节奏/判定类(realtime + 时机)── +节奏敲击:方块下落到底部判定线的瞬间点击得分,连续错过三次则结束。 +钢琴块:只点黑色方块别碰白块,节奏越来越快,点错或漏点即结束。 +# ── 平台/抵达类(physics + 移动跳跃)── +平台跳跃:左右移动并跳跃,踩着悬空平台抵达终点旗帜,掉出屏幕底部则失败。 +跳台阶登顶:不断向上跳到下一块平台,踩空掉落则结束,登得越高分越高。 +# ── 飞行/穿缝类(physics + 点击上升)── +像素鸟穿管:点击让小鸟扇翅上升,穿过一根根上下水管的缝隙,撞管或落地即结束。 +气球升空:轻点给气球补气上升,避开横向飘来的尖刺,被扎破则游戏结束。 +# ── 躲陨石/生存类(realtime + 八向移动)── +太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。 +雪崩求生:左右移动滑雪者躲开滚落的石块与树木,被砸中即结束,撑得越久越好。 +# ── 弹力/打靶类(physics + 点击施力)── +弹力打靶:点击屏幕给小球施加方向,把场上所有靶子撞掉即过关。 +愤怒投石:拖拽瞄准弹射石块,砸倒所有目标木箱即通关,弹药用尽未清场则失败。 +# ── 泡泡射手类(physics + 瞄准发射)── +泡泡射手:瞄准并发射泡泡,三个同色相连则消除,清空全部泡泡即胜。 +祖玛吐珠:旋转中心炮台向滚动的珠链射入同色珠子凑三消,珠链到达终点则失败。 diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-holdout.txt b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-holdout.txt new file mode 100644 index 00000000..8d9e8ed3 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/resources/saa-e2e-briefs-holdout.txt @@ -0,0 +1,30 @@ +# saa-e2e-briefs-holdout.txt —— 003-U1 留出集(hold-out,不进 A/B 调参,供 final R1 量) +# 用法:-Dsaa.e2e.sourceMode=gamedef -Dsaa.e2e.briefFile=src/test/resources/saa-e2e-briefs-holdout.txt +# 纪律:本集 brief 与 saa-e2e-briefs-gamedef.txt 及 SaaFullGraphE2eTest.ALL_BRIEFS 均不重叠—— +# A/B 阶段只用 gamedef.txt 调参/收敛,避免对留出集过拟合;final R1 验收才用本集量真泛化成功率。 +# 选品同样偏向运行时/九门 harness 支持品类(realtime/physics/tap-targets/clickable)。 +# +# ── 挡板/接球类 ── +保龄反弹:底部挡板把弹珠弹向顶部砖墙,敲碎全部砖块即胜,球落底则败。 +# ── 蛇/网格类 ── +吃星变长:操控小蛇在网格内吃星星变长,撞墙或咬到自己则结束。 +# ── 接物类 ── +接落叶:左右移动竹篮接住飘落的树叶,连漏五片则游戏结束。 +# ── 跳跃/躲避类 ── +忍者翻墙:连续点击让忍者起跳越过迎面飞来的飞镖,被击中即结束。 +# ── 消除类 ── +同色爆破:点击成片相连的同色气球一次性引爆,达到目标分数过关。 +# ── 射击类 ── +炮台轰机:左右移动炮台射击编队俯冲的战机,被撞或漏过太多则失败。 +# ── 打地鼠/点击反应类 ── +拍蚊子:屏幕四处乱飞的蚊子,快速点中拍死得分,限时结束统计战果。 +# ── 节奏类 ── +鼓点连击:圆圈缩到判定环瞬间点击得分,连续错失三次则结束。 +# ── 平台类 ── +攀岩登顶:交替左右蹬跳攀上一块块岩点抵达山顶,踩空坠落则失败。 +# ── 飞行/穿缝类 ── +潜艇穿礁:点击上浮松手下沉,驾潜艇穿过上下错落的暗礁缝隙,触礁即结束。 +# ── 躲避/生存类 ── +雷区穿行:上下左右移动小车躲开不断落下的地雷,坚持到倒计时归零即通关。 +# ── 弹力/打靶类 ── +台球清台:拖动球杆瞄准击打母球,把台面所有彩球撞进袋即清台过关。 diff --git a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs index e5797772..e3bda547 100644 --- a/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs +++ b/game-runtime/games/_wg1-gen/_shared/play.cdp.cjs @@ -174,6 +174,17 @@ async function readGameState(cdp) { ); } +/** Phase 4 E_live 校准(2026-06-20):判玩前→玩后「游戏进展态」是否真变化(score/remaining 任一数值变=游戏在响应输入)。 + * 用于给 E_live 补「状态活性」证据:无运动点击类(打地鼠等)画面只在命中瞬间变、像素采样易错过→假阴; + * 但其 score/remaining 会真变。真冻屏 score/remaining 不变→返 false→E_live 仍判否,绝不放过真冻屏(只放宽不收紧)。 */ +function stateProgressed(s0, s1) { + if (!s0 || !s1) return false; + for (const k of ['score', 'remaining']) { + if (typeof s0[k] === 'number' && typeof s1[k] === 'number' && s0[k] !== s1[k]) return true; + } + return false; +} + /** 判一条机制断言(s0=玩前态 / s1=玩后态)。op:increased/decreased/changed/>/>=/= scoreTarget) break; // 得分够→转终态阶段 + const hx = getPath(s, hxPath), hy = getPath(s, hyPath); + const fx = getPath(s, fxPath), fy = getPath(s, fyPath); + if (typeof hx === 'number' && typeof hy === 'number' && typeof fx === 'number' && typeof fy === 'number') { + // 从蛇头位移推断【实际行进方向】(仅在真移动时更新)——防反向须基于实际方向, + // 而非已发的键(发的反向键会被蛇忽略→驱动误以为转了→继续直行撞墙)。 + if (phx != null && (hx !== phx || hy !== phy)) { + if (Math.abs(hx - phx) >= Math.abs(hy - phy)) actualDir = hx > phx ? 'ArrowRight' : 'ArrowLeft'; + else actualDir = hy > phy ? 'ArrowDown' : 'ArrowUp'; + } + phx = hx; phy = hy; + const dx = fx - hx, dy = fy - hy; + const horiz = Math.abs(dx) >= Math.abs(dy); + // 主选(消较大轴差) + 次选(另一轴);选第一个「非空且非实际方向反向」的(反向会被忽略→撞墙)。 + const cands = horiz + ? [dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null), dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null)] + : [dy > 0 ? 'ArrowDown' : (dy < 0 ? 'ArrowUp' : null), dx > 0 ? 'ArrowRight' : (dx < 0 ? 'ArrowLeft' : null)]; + const dir = cands.find((d) => d && d !== OPP[actualDir]) || cands.find(Boolean); + if (dir) { await key(cdp, dir, driver.downMs || 60); drove++; } + } else { await delay(stepMs); } + try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {} + await delay(stepMs); + } + // 阶段2:终态——已得分但蛇太稳不死 → 保持直行撞墙触发 lose→gameover(snake 真终态=死亡),满足 latch(不放水门:用游戏真有的失败态)。 + for (let i = 0; i < 30; i++) { + const s = await readGameState(cdp); + if (s && s.phase === 'gameover') break; + if (actualDir) { await key(cdp, actualDir, driver.downMs || 60); } + try { hashes.push(await sampleHash(cdp, '#game-engine')); } catch (_) {} + await delay(stepMs); + } + return { drove, steps }; +} + /** 触屏拖拽:touchStart(from) → 多帧 touchMove 插值到 to → touchEnd。受控面只给原始 pointer,swipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */ async function drag(cdp, from, to, ms) { const f = from || { x: 195, y: 600 }, t = to || { x: 195, y: 700 }; @@ -873,6 +936,7 @@ async function main() { // 首帧截图 + 渲染快照(守卫D 初值 + 守卫E t0)。 await saveScreenshot(cdp, path.join(evDir, 'first-paint.png')); const px0 = await brightPixels(cdp, '#game-engine'); + const s0 = await readGameState(cdp); // Phase 4 E_live 校准:玩前进展态(与玩后对比补「状态活性」) // 守卫H 玩前态:真玩输入前读一次可观测状态(基线,供机制断言对照)。 const state0 = await readGameState(cdp); @@ -909,12 +973,16 @@ async function main() { // 守卫D:真渲染(输入后再测,取较优)。 const px1 = await brightPixels(cdp, '#game-engine'); hashes.push(px1.hash); + const s1 = await readGameState(cdp); // Phase 4 E_live 校准:玩后进展态 const bright = Math.max(px0.bright, px1.bright), maxCh = Math.max(px0.maxCh, px1.maxCh); verdict.guards.D_render = { pass: bright > 50 && maxCh > 80, bright, maxCh }; // 守卫E:活性 —— 整段真玩出现 ≥2 个不同画面态(真在动/真在响应);全程恒同一帧=冻屏判否。 + // Phase 4 校准(2026-06-20):像素活性 OR 状态活性(score/remaining 真变)——治无运动点击类(打地鼠等)像素采样 + // 错过命中瞬间的假阴;真冻屏像素+状态皆不变→仍判否(只加 OR、只放宽不收紧,绝不放过真冻屏)。 const distinct = new Set(hashes); - verdict.guards.E_live = { pass: distinct.size >= 2, distinctStates: distinct.size, samples: hashes.length }; + const liveByState = stateProgressed(s0, s1); + verdict.guards.E_live = { pass: distinct.size >= 2 || liveByState, distinctStates: distinct.size, samples: hashes.length, liveByState }; // 守卫B:未捕获错误。 const unc = await cdp.evaluate('(function(){ var n=window.__genInternals; var u=(n&&n.uncaught)?n.uncaught:[]; return u.slice(0,10); })()'); diff --git a/game-runtime/src/host/build-from-source.mjs b/game-runtime/src/host/build-from-source.mjs index 808d4dc3..fa743b8b 100644 --- a/game-runtime/src/host/build-from-source.mjs +++ b/game-runtime/src/host/build-from-source.mjs @@ -86,6 +86,35 @@ function behaviorCode(b) { return null; } +/* ── 玩法哑火坑静态门(Plan A·Phase 3,2026-06-20;坑库码化:把 GAMEDEF_SYSTEM 的 ⚠️ 从「prompt 预防」升为「真玩前检测」)── + * 只码化【零误伤·高价值】两族:①.tags 是 Set 误用数组法/下标/length(必每帧抛)②漏 rt. 前缀的裸引擎调(必 ReferenceError; + * 补 LOGIC_BANS 不拦 getEngine/getInput/restart 的洞——含此坑的源装配能过却运行期夭折,gd-runner/whack/tetris 即此)。 + * 命中→进 validationErrors→repair,真玩前就修、省一轮 CDP。零误伤铁律:对 gd-breakout/tictactoe/simon 三过门纯净源零命中(gameplay-pitfalls.test.mjs)。 + * 【已评估但暂不作硬门】self 跨 behavior 泄漏(读 self.X 却本 behavior 从未赋值;self 确为 per-behavior,见 gd-runtime.js `self:{}`): + * 它是真 bug(§0 跨款最大失败簇),但 gd-tictactoe(self.board)/gd-simon(self.phase/seq) 等【带此 bug 却仍过九门】(AI/序列哑了但游戏可玩到过门), + * 硬门会误杀这些过门款(破零误伤)。正解=接「非阻塞 warning 通道」回喂 repair 改进、而非硬拒——留待 warning 通道落地后补(Phase 3 follow-up)。 */ +const TAGS_SET_MISUSE = [ + [/\.\s*tags\s*\.\s*(includes|indexOf|lastIndexOf|push|pop|shift|unshift|splice|concat|slice|filter|map|forEach|find|findIndex|some|every|reduce|join|sort|reverse)\s*\(/, + '.tags 是 Set 无数组方法(判 tag 用 e.tags.has(name) 或 rt.query(tag);调数组法每帧抛错冻屏)'], + [/\.\s*tags\s*\[\s*\d/, '.tags 是 Set 不可下标索引(用 e.tags.has(name))'], + [/\.\s*tags\s*\.\s*length\b/, '.tags 是 Set 无 .length(用 e.tags.size)'], +]; +/** 漏 rt. 前缀的裸引擎调(限 gd-runtime 专有名、几无重名风险;负向后瞻排除 rt./obj. 方法调用)。 */ +const BARE_RT_CALLS = [ + [/(? validateSourceProject(gdWith(code)).errors; +const hit = (code, marker) => errs(code).some((m) => m.includes(marker)); + +test('Phase3 坑门①: .tags 是 Set 误用数组法/length 被拦', () => { + assert.ok(hit("for(const e of rt.query('x')){ if(e.tags.includes('y')) rt.destroy(e); }", '.tags 是 Set')); + assert.ok(hit("const e=rt.getEntity('p'); if(e && e.tags.length>0){}", '.tags 是 Set')); + // 正例:e.tags.has(...) 是正确用法,不该被拦 + assert.ok(!hit("const e=rt.getEntity('p'); if(e && e.tags.has('foe')){}", '.tags 是 Set')); +}); + +test('Phase3 坑门②: 漏 rt. 前缀的裸引擎调被拦', () => { + assert.ok(hit("const e=getEntity('p'); if(e) e.x+=1;", 'ReferenceError')); + assert.ok(hit("const eng=getEngine(); if(eng) eng.foo();", 'ReferenceError')); + assert.ok(hit("addScore(1);", 'ReferenceError')); + // 正例:rt.getEntity / rt.addScore 不该被拦 + assert.ok(!hit("const e=rt.getEntity('p'); rt.addScore(1);", 'ReferenceError')); +}); + +// 坑门③ self 跨 behavior 泄漏:已评估但【暂不作硬门】——会误伤 gd-tictactoe(self.board)/gd-simon(self.phase/seq) 等 +// 「带此 bug 却仍过九门」的款(破零误伤)。留待非阻塞 warning 通道落地后回喂 repair。详见 build-from-source.mjs 注释。 + +// 零误伤铁律:3 个金标准过门纯净源不得触任何坑门(误伤=拦掉本可玩的游戏,比漏报更糟)。 +const PIT_MARKERS = ['.tags 是 Set', 'ReferenceError']; +for (const g of ['gd-breakout', 'gd-tictactoe', 'gd-simon']) { + test('Phase3 零误伤: ' + g + ' 不触任何坑门', () => { + const sp = JSON.parse(readFileSync(join(WG1, g, 'source.json'), 'utf8')); + const pit = validateSourceProject(sp).errors.filter((m) => PIT_MARKERS.some((k) => m.includes(k))); + assert.deepStrictEqual(pit, [], g + ' 被坑门误伤: ' + pit.join(' || ')); + }); +} diff --git a/game-runtime/src/host/gd-runtime.js b/game-runtime/src/host/gd-runtime.js index cd50b176..37516b41 100644 --- a/game-runtime/src/host/gd-runtime.js +++ b/game-runtime/src/host/gd-runtime.js @@ -133,6 +133,18 @@ export function createRuntime(boot, gameDefinition) { set(k, v) { this[k] = v; return v; }, destroy() { this.alive = false; }, }; + // 去脚枪(Plan A·S2):把 spec/entities 字面量上的自定义标量字段(gridX/colorIdx/scored/idx/hp 等) + // 拷到实体,使「spec 带自定义字段」的模型自然假设成真(原仅留 id/x/y/vx/vy/tags/components → §0 + // 跨款最大失败簇:自定义状态读到 undefined→逻辑空转/冻屏)。仅标量(num/str/bool)、不覆盖保留键/已设键; + // 取证 entityView 白名单投影,故自定义字段不外泄取证。对象/数组不拷(避免共享引用意外)。 + // M3 修(CodeRabbit 2026-06-20):w/h/r 列入保留键【不拷顶层】——它们是 rt.overlap 的碰撞尺寸(读实体顶层、缺省 16), + // 拷上去会静默改存量游戏命中框;且违反 prompt 契约"碰撞尺寸只写 render 组件、顶层 e.w 永 undefined"。 + const RESERVED = { id: 1, transform: 1, x: 1, y: 1, vx: 1, vy: 1, tags: 1, components: 1, alive: 1, get: 1, set: 1, destroy: 1, w: 1, h: 1, r: 1 }; + for (const k in spec) { + if (RESERVED[k] || e[k] !== undefined) continue; + const t = typeof spec[k]; + if (t === 'number' || t === 'string' || t === 'boolean') e[k] = spec[k]; + } return e; } /** 实体上限(防失控 spawn 静默膨胀;超限记错误信号并拒新增,转成 repair 可读信号)。 */ diff --git a/wg1/gen-worker/gamedef_quickcheck.py b/wg1/gen-worker/gamedef_quickcheck.py index 727b3eca..fd81d5e3 100644 --- a/wg1/gen-worker/gamedef_quickcheck.py +++ b/wg1/gen-worker/gamedef_quickcheck.py @@ -33,6 +33,10 @@ STAGE1_MODEL = os.environ.get("QC_STAGE1", "deepseek-v4-flash") STAGE2_MODEL = os.environ.get("QC_STAGE2", "deepseek-v4-pro") STAGE1_REPAIRS = int(os.environ.get("QC_STAGE1_REPAIRS", "5")) # 真图 maxRepairs 默认 5 STAGE2_EXTRA = int(os.environ.get("QC_STAGE2_EXTRA", "3")) # 真图 stage2ExtraRepairs 默认 3 +# 跨局并发数:仅跨不同游戏并行(局内 repair 链恒串行——第 N+1 次需第 N 次失败回喂); +# 每局独立 (port,cdpPort) 端口对(serve/Chrome/user-data-dir 全隔离,见 serve-and-play.sh)→互不撞; +# 每局过门判定不变→聚合过门率与串行等价。authoritative ≥60% cutover 仍在 mini-desktop。 +CONCURRENCY = int(os.environ.get("QC_CONCURRENCY", "4")) def extract_block(name): @@ -56,14 +60,24 @@ def extract_gatespec(design_text): if "assertAfterPlay" in gs and not isinstance(gs["assertAfterPlay"], list): gs["assertAfterPlay"] = [] gs.setdefault("expectLatch", True) - # gamedef 取证只暴露 score/remaining/progress/实体位置——把 assertAfterPlay 的顶层自定义标量字段(moves/totalRound 等)强制到 score - # (设计 agent 常造取证读不到的字段→H_progress 必挂;不动含 '.' 的实体位置断言如 ball.x)。 - EXPOSED = ("score", "remaining", "progress") - for a in (gs.get("assertAfterPlay") or []): - if isinstance(a, dict) and isinstance(a.get("path"), str): + # assertAfterPlay 处理两步:① 有进展断言则只留进展断言(挡终局态断言);② 取证读不到的自定义标量归一到 score。 + PROGRESS_OPS = ("increased", "decreased", "changed", ">", ">=", "<", "<=") + KNOWN = ("score", "remaining", "progress", "result", "phase") # 取证暴露的合法顶层字段:不强转 score(result/phase 是真字段) + + # ① 终局态断言(phase/result=="gameover"/"win"、result in[...]、误编码 score=="gameover" 等 op 非进展类)不应否决 + # 「进展门」H_progress:只要≥1 条进展断言(op∈PROGRESS_OPS)存在,就只留进展断言、丢弃所有非进展(==/in/!=…)断言—— + # bot 限时真玩常到不了终局,但 score 已涨=确有进展(井字棋 0→9 实证)。无任何进展断言(如规避类只给 result=="win") + # 则全留(终局即唯一进展信号)。镜像后端 SaaPrompts.isProgressOp 过滤(drift-free);终局完成度另由 expectLatch 把关。 + aap = [a for a in (gs.get("assertAfterPlay") or []) if isinstance(a, dict)] + if any(a.get("op") in PROGRESS_OPS for a in aap): + aap = [a for a in aap if a.get("op") in PROGRESS_OPS] + gs["assertAfterPlay"] = aap + # ② 顶层自定义标量字段(moves/totalRound 取证读不到)强制到 score;result/phase 等合法字段与含 '.' 的实体位置断言(ball.x)不动。 + for a in aap: + if isinstance(a.get("path"), str): p = a["path"].lstrip("/") top = p.split(".")[0].split("[")[0] - if "." not in p and top not in EXPOSED: + if "." not in p and top not in KNOWN: a["path"] = "score" a["op"] = a.get("op", "increased") drv = gs.get("driver") @@ -75,15 +89,25 @@ def extract_gatespec(design_text): def extract_json_obj(content): - """从模型输出抠最外层 {...}(去 ```json 围栏/前后说明)。""" - s = content.strip() + """从模型输出抠最外层 {...}(先剥 推理块、再去 ```json 围栏/前后说明)。""" + # 推理模型(M3)即便关了 thinking 偶仍内联 ;先整块剥除,避免抽到思维链里的花括号。 + s = re.sub(r".*?", "", content or "", flags=re.DOTALL).strip() s = re.sub(r"^```(?:json)?\s*", "", s) s = re.sub(r"\s*```$", "", s) lo, hi = s.find("{"), s.rfind("}") if lo < 0 or hi <= lo: return None + body = s[lo:hi + 1] try: - return json.loads(s[lo:hi + 1]) + return json.loads(body) # ① 严格优先 + except Exception: + pass + # ② 宽松回退:便宜模型偶产轻微非法 JSON(如多余引号 "h":130"),json_repair 修复——镜像后端 looseParse 宽松级, + # 跑的就是产线真解析口径(drift-free);避免快走查因严格解析假性 parse 失败、低估真过门率。 + try: + import json_repair + obj = json_repair.loads(body) + return obj if isinstance(obj, dict) else None except Exception: return None @@ -101,7 +125,7 @@ def assemble_via_cli(game_id, gamedef_obj): return r.returncode == 0, (r.stdout + r.stderr).strip() -def run_one(game_id, gamedef_system, design_system): +def run_one(game_id, gamedef_system, design_system, port=4320, cdp_port=9222): brief_text, play_spec = run._load_brief(game_id) gid = "gd-" + game_id out = {"game_id": gid, "stage": None, "pass": False, "guards": {}, "err": "", "driver": None, "model": STAGE1_MODEL} @@ -146,7 +170,7 @@ def run_one(game_id, gamedef_system, design_system): bok, blog = run.build(gid) if not bok: out["stage"] = "build"; feedback = "打包失败(esbuild):\n" + blog[:500]; continue - rc, _, verdict = run.play(gid) + rc, _, verdict = run.play(gid, port=port, cdp_port=cdp_port) out["stage"] = "play" out["guards"] = {k: g.get("pass") for k, g in ((verdict or {}).get("guards") or {}).items()} if rc == 0 and verdict and verdict.get("pass"): @@ -157,23 +181,40 @@ def run_one(game_id, gamedef_system, design_system): return out +def _fmt_result(r): + """单局结果行(含 game_id,并发完成序打印不串)。""" + mark = "✅ PASS" if r["pass"] else f"❌ {r['stage']}" + gpass = sum(1 for v in (r.get("guards") or {}).values() if v) + gtot = len(r.get("guards") or {}) + drv = r.get("driver") or "-" + att = r.get("attempts", 1) + return f" {r['game_id']:14s} {mark:16s} drv={str(drv):14s} att={att} gates={gpass}/{gtot} {('' if r['pass'] else r.get('err','')[:100])}" + + def main(): + import concurrent.futures games = sys.argv[1:] or ["runner", "flappy", "whack", "pong"] gds = extract_block("GAMEDEF_SYSTEM") dsys = extract_block("DESIGN_SYSTEM") - print(f"[gamedef-qc] stage1={STAGE1_MODEL}({STAGE1_REPAIRS})→stage2={STAGE2_MODEL}({STAGE2_EXTRA}) games={games} (全图镜像+救场升档)\n") + conc = max(1, CONCURRENCY) + # flush=True:stdout 重定向到文件时 Python 默认块缓冲,加 flush 让后台日志逐局可读(便于进度观察)。 + print(f"[gamedef-qc] stage1={STAGE1_MODEL}({STAGE1_REPAIRS})→stage2={STAGE2_MODEL}({STAGE2_EXTRA}) games={games} 并发={conc} (全图镜像+救场升档)\n", flush=True) + # 跨局并发:每局分到独立端口对(port=4400+i / cdpPort=9300+i,与串行默认 4320/9222 错开,逐局唯一→任意调度都不撞)。 results = [] - for g in games: - r = run_one(g, gds, dsys) - results.append(r) - mark = "✅ PASS" if r["pass"] else f"❌ {r['stage']}" - gpass = sum(1 for v in r["guards"].values() if v) - gtot = len(r["guards"]) - drv = r.get("driver") or "-" - att = r.get("attempts", 1) - print(f" {r['game_id']:14s} {mark:16s} drv={drv:14s} att={att} gates={gpass}/{gtot} {('' if r['pass'] else r['err'][:100])}") + with concurrent.futures.ThreadPoolExecutor(max_workers=conc) as ex: + futs = {ex.submit(run_one, g, gds, dsys, 4400 + i, 9300 + i): g for i, g in enumerate(games)} + for fut in concurrent.futures.as_completed(futs): + try: + r = fut.result() + except Exception as e: # harness 级异常兜底,不让一局炸掉整轮 + r = {"game_id": "gd-" + futs[fut], "stage": "harness", "pass": False, "guards": {}, "err": "harness 异常:" + str(e)[:160]} + results.append(r) + print(_fmt_result(r), flush=True) + # as_completed 是完成序,按输入顺序重排再落盘/汇总。 + order = {("gd-" + g): i for i, g in enumerate(games)} + results.sort(key=lambda r: order.get(r.get("game_id"), 999)) npass = sum(1 for r in results if r["pass"]) - print(f"\n[gamedef-qc] 过门 {npass}/{len(results)} (cutover 门基线=iife 60%; 本为 lili-mac 小样早读, authoritative=mini-desktop)") + print(f"\n[gamedef-qc] 过门 {npass}/{len(results)} (cutover 门基线=iife 60%; 本为 lili-mac 小样早读, authoritative=mini-desktop)", flush=True) Path(__file__).resolve().parent.joinpath("results", "gamedef-quickcheck.json").write_text( json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") sys.exit(0) diff --git a/wg1/gen-worker/worker/_client.py b/wg1/gen-worker/worker/_client.py index b67686ef..eb7fa54d 100644 --- a/wg1/gen-worker/worker/_client.py +++ b/wg1/gen-worker/worker/_client.py @@ -57,6 +57,13 @@ def get_client(): return openai.OpenAI(api_key=get_api_key(), base_url=BASE_URL, max_retries=2, timeout=180.0) +def _extra_body(model, enable_thinking): + """按模型构造 extra_body:MiniMax 系走官方 thinking 控制(默认 disabled,关思维链防截断);其他模型不带厂商专有参。""" + if "minimax" in (model or "").lower(): + return {"thinking": {"type": "adaptive" if enable_thinking else "disabled"}} + return {} + + def _cache_hit_from_usage(usage): """从 openai 原始 usage 抽命中缓存的 prompt token(兼容两套字段取 max,U4/B9)。 @@ -80,7 +87,7 @@ def _cache_hit_from_usage(usage): return 0 -def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0): +def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_delay=2.0, enable_thinking=False): """单次裸调用 chat.completions。确定性产出 temperature=0;瞬时网关错误(502 burst)重试。 返回 dict:{content, prompt_tokens, completion_tokens, prompt_cache_hit_tokens, total_tokens, model, wall_s, id, finish_reason}。 @@ -101,6 +108,11 @@ def chat(model, system, user, max_tokens=16000, temperature=0.0, tries=3, retry_ temperature=temperature, max_tokens=max_tokens, stream=False, + # 关推理(默认):MiniMax-M3 在 OpenAI 端点默认 thinking=adaptive(开),思维链内联进 content,复杂局 + # 飙 token → max_tokens 处截断、永远到不了 JSON(finish_reason=length,表现为"parse 失败",实测 + # match3/simon)。官方关法(MiniMax docs)= 顶层 thinking={type:disabled};本 worker 只要结构化 JSON、 + # 不需 CoT,故对 MiniMax 系默认关(enable_thinking=True 可覆盖)。非 MiniMax 模型不带此厂商专有参。 + extra_body=_extra_body(model, enable_thinking), ) wall = time.perf_counter() - t0 choice = resp.choices[0]