Merge origin/dev/2.0.0(SAA 生成域 20 提交)—— 设计文档域化重构 8 提交并入
This commit is contained in:
commit
77f67fdb13
@ -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`**(`<think>…`)→ 污染 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%/天花板存疑」的悲观面)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
contracts/agent-loop/skeletons/breakout.json
Normal file
1
contracts/agent-loop/skeletons/breakout.json
Normal file
@ -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<rows;r++){ for(let c=0;c<cols;c++){ const x = startX + c*(bw+gap) + bw/2; const y = startY + r*(bh+gap) + bh/2; const hard = (r*cols+c+c)%5===0 || (r*cols+c+c)%5===1; const id = 'b'+r+'_'+c; if(hard){ rt.spawn({id:id,x:x,y:y,hp:2,tags:['brick'],idx:r*cols+c,components:[{kind:'render',shape:'rect',color:'#FFD54F',w:56,h:22}]}); } else { rt.spawn({id:id,x:x,y:y,hp:1,tags:['brick'],idx:r*cols+c,components:[{kind:'render',shape:'rect',color:'#FF5252',w:56,h:22}]}); } } }"}, {"id": "control", "trigger": "update", "code": "const p=rt.getEntity('paddle'); if(!p) return; if(rt.input.pointer.down){ self.tx = rt.input.pointer.x; } if(self.tx===undefined) self.tx = p.x; p.x = p.x + (self.tx - p.x)*0.28; p.x = rt.clamp(p.x, 45, 345);"}, {"id": "hudtext", "trigger": "init", "code": "rt.spawn({id:'hudbg',x:195,y:25,components:[{kind:'render',shape:'rect',color:'#222831',w:390,h:50}]});"}, {"id": "ballupdate", "trigger": "update", "code": "const b=rt.getEntity('ball'); const p=rt.getEntity('paddle'); if(!b||!p) return; b.x += b.vx*dt; b.y += b.vy*dt; if(b.x < 9){ b.x = 9; b.vx = Math.abs(b.vx); rt.fx.beep('wall'); } if(b.x > 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"}]}
|
||||
1
contracts/agent-loop/skeletons/simon.json
Normal file
1
contracts/agent-loop/skeletons/simon.json
Normal file
File diff suppressed because one or more lines are too long
1
contracts/agent-loop/skeletons/tictactoe.json
Normal file
1
contracts/agent-loop/skeletons/tictactoe.json
Normal file
@ -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"}]}
|
||||
367
docs/plans/2026-06-19-002-feat-gendone-用对M3生成生死门-plan.md
Normal file
367
docs/plans/2026-06-19-002-feat-gendone-用对M3生成生死门-plan.md
Normal file
@ -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<AssistantMessage>`(thinking 先序)。(见 KTD 2 / U1 / U2)
|
||||
- **P0-5(checkpoint 序列化潜伏坑,feasibility)**:e2e harness `saaCheckpointEnabled=false`+dataSource=null → **MysqlSaver 序列化路从不被量测触发**;staging 默认 `saaCheckpointEnabled=true` → `List<AssistantMessage>` 若 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<maxTokens` fail-fast(U1);确认无 `spring-ai-autoconfigure-model-anthropic` 传递(U1);spike 实证 1.1.2 多 Generation 形(U1);`llm-base` 仅需覆盖才加 yaml(去噪,U7)。
|
||||
|
||||
---
|
||||
|
||||
## 摘要
|
||||
|
||||
把 SAA **真结构化(gameDefinition)生成路**从「用错 M3 + 历史不留 + 救场从头重生成 + 整图串行不可量」推到「**用对 agentic 的 M3(Anthropic 协议 + thinking 全保留 + 连续对话救场)+ 可量产线门 ≥80%(机制门口径)+ 默认产线 flip(含回退验过)**」。
|
||||
|
||||
**第一性纠偏(代码核证)**:现状跨 Java/Python 三层**完全一致**——每轮全新 `[system,user]`、`getText()` 丢弃 thinking、九门走外部 `bash` 子进程当末端反馈文本、救场命令"重出完整模块不要 diff"。四项目标能力(Anthropic 协议 / 历史保留 / 门作工具 / staging dispatcher env)**全为净新建,无半成品可改**。可行性已实证:Anthropic+thinking+工具循环在现 `spring-ai-bom:1.1.2` 上 **FEASIBLE**(加一 bare 依赖、零 bean 冲突、阻塞调用),唯响应是**多 Generation 形**(block0=thinking,须遍历 `getResults()`,见 KTD 2)。
|
||||
|
||||
**执行姿势 = 先量真基线、数据驱动收敛**:用对 M3(U-A/U-B)+ 8/8 driver 上下文落地后,跑**有界并发**(K=正确性参数、非提速旋钮)n≥30 真基线(mini-desktop 权威 / lili-mac 快走查),用 **Workflow-Opus 全量根因分析**产出排序优化项,改→重测,循环到 ≥80%;**模型驱动工具循环(U-C)= measure-first 闸控 + 并行廉价 spike 去险**。达标后两步 flip(隔离验→切默认→回退验,含 saa-model-protocol 同切)。
|
||||
|
||||
> **≥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→内联 `<think>` 污染);代理 `Proxy.NO_PROXY` 经 `AnthropicApi.restClientBuilder`(**1.1.2 无 `customHeaders` 方法**);`thinking(ThinkingType.ENABLED, budget<maxTokens).maxTokens(≤128K)`;**阻塞 `.call()`**(bug #4407)。
|
||||
- **⚠️ 响应形(P0-4)**:每 content block 一 `AssistantMessage`(多 Generation);thinking 开时 **block0=thinking**。**必遍历 `resp.getResults()`**、答案取 text-role Generation、历史存有序 `List<AssistantMessage>`(thinking 先序)。`getMetadata()` 的 `thinking`/`signature`(皆 String)回读。
|
||||
- **序列化(P0-5)**:`AssistantMessage` 经 `SpringAIJacksonStateSerializer` 可往返,但 metadata 写为无类型 JSON、读回为 Map(thinking/signature 皆 String 可活);**`List<AssistantMessage>` 容器须经 `GenericListDeserializer` 保元素类型**(否则 `List<LinkedHashMap>` → 回放给 `AnthropicChatModel` 炸)。Anthropic 产 base `AssistantMessage`(非子类,通用 handler 适配)——须 checkpoint-ON 测试坐实。
|
||||
- **工具循环**:`DefaultToolCallingManager` + `while(hasToolCalls()) executeToolCalls(...)` 进单 `NodeAction`(无 agent-framework)。
|
||||
- **必修三险**:① ~~认证头方言~~ **【spike 已解 2026-06-19】鉴权锁 x-api-key(默认);Bearer 反致 thinking 内联 `<think>`(非 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<AssistantMessage>`(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<AssistantMessage>` 类型 + 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)<br/>flag 默认 openai · 多 Generation 重组"]
|
||||
U2["U2 thinking 历史保留(有界+可序列化) + 连续救场(U-B)"]
|
||||
U1 --> U2
|
||||
end
|
||||
subgraph HARNESS["量测地基"]
|
||||
U3["U3 gamedef 量测 + 有界并发池 harness<br/>sourceMode=gamedef · pool(K) · 双校准 · 共享面审计"]
|
||||
U4["U4 8/8 driver 决策树 + 上下文 + gatespec 约定"]
|
||||
end
|
||||
subgraph LOOP["收敛环(measure-first · 运行时钉)"]
|
||||
U5["U5 量测→Workflow-Opus 根因→优化→重测 至 ≥80%<br/>+ U-C 廉价 spike 并行去险 + 留出集 + 基线定标复核"]
|
||||
end
|
||||
U6["U6(闸控)模型驱动工具循环(U-C)<br/>仅 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<br/>收敛窗口冻结(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<maxTokens`**);阻塞 `.call()`。**取答案 = 遍历 `getResults()` 取「metadata 无 `signature` 键的末个 Generation」`getText()`**(thinking gen 含 signature;**绝不用 `getResult()`** 否则取到推理)。**鉴权锁 x-api-key、绝不 Bearer**(Bearer 内联 `<think>`)。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 → 内联 `<think>`(弃用)。
|
||||
- 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<AssistantMessage>`、答案取 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<AssistantMessage>`、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→内联 `<think>` 污染(弃)。非 401 问题、是响应形——U1 锁定 x-api-key。险已消。
|
||||
- **R-2 流式 thinking bug #4407 + thinking/tool_use 顺序**:只 `.call()`;多轮原样回放有序 `List<AssistantMessage>`;深度 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 同步。
|
||||
61
docs/plans/2026-06-20-关闭九门判定-全面参考OpenGame-决策与Phase0-4.md
Normal file
61
docs/plans/2026-06-20-关闭九门判定-全面参考OpenGame-决策与Phase0-4.md
Normal file
@ -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 门)仍可叠加作进一步增益。
|
||||
@ -47,6 +47,13 @@
|
||||
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-graph-core</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-graph-observation</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency>
|
||||
<!-- Anthropic 协议客户端(Plan A · U1 M3 client;版本由上方 spring-ai-bom:1.1.2 托管,勿写 version)。
|
||||
刻意用 bare `spring-ai-anthropic`(裸库,非 `-starter`):仅要 AnthropicApi/AnthropicChatModel/AnthropicChatOptions
|
||||
这套低层 API,避免 `-starter` 的 AnthropicAutoConfiguration 在 Spring 上下文里抢注 ChatModel bean(与现有
|
||||
OpenAI 路并存时会冲突)。U1 client 经 new-api **host 根 /v1/messages**(baseUrl=host 根、x-api-key 鉴权;
|
||||
lili-mac spike 1.1.2 实测纠偏:**绝不加 /anthropic 前缀**,否则命中 SPA catch-all 返 HTML 假挂起)驱动
|
||||
MiniMax-M3 走 Anthropic Messages 协议(thinking 原生分离),与 OpenAI-兼容路 flag 旁挂。 -->
|
||||
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-anthropic</artifactId></dependency>
|
||||
<!-- Spring Cloud 基础环境 -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
@ -192,6 +199,8 @@
|
||||
<include>prompts/01-safety/*.md</include>
|
||||
<include>prompts/04-config/*.md</include>
|
||||
<include>templates/*.schema.json</include>
|
||||
<!-- Plan A·Phase 2:Template-First gamedef 骨架进 classpath(SaaSkeletons 据此装载注入生成 prompt) -->
|
||||
<include>agent-loop/skeletons/*.json</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</resources>
|
||||
|
||||
@ -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<String> 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)不参与生成硬门。
|
||||
* <p>遍历 verdict.guards 中出现的门,只对客观门要求 pass;driver 门跳过。无 guards 明细或未见任何客观门→
|
||||
* 保守退回聚合 verdict.pass(避免误放空壳)。<b>不改九门判定本身</b>,只改生成环对其的消费口径。
|
||||
*/
|
||||
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<Map.Entry<String, JsonNode>> it = guards.fields();
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<String, JsonNode> 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));
|
||||
|
||||
@ -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) 落终态。
|
||||
*
|
||||
* <p><b>串行真玩门(Semaphore(1))</b>:play 节点用固定端口(4320/9222)+ headless Chrome,多 job 并行真玩必撞端口;
|
||||
* 故图执行经一把全局 {@link Semaphore}(1) 串行化(acquire→跑图→release)。与 Python worker {@code service.py:80}
|
||||
* 串行锁同语义(serve 4320/CDP 9222 不可并发)。后台线程池亦设单线程,双保险。
|
||||
* <p><b>有界并发真玩门(003-U1:信号量(K) + 端口槽池)</b>:play 节点用真玩端口 + headless Chrome,多 job
|
||||
* <b>共用同一对</b>端口必撞;故每 job 整生命期独占一对错开端口(第 i 槽 serve={@code base+2i}/CDP={@code base+i}),
|
||||
* 并发上限 K={@link AigcExecutorProperties#getSaaConcurrency()}(线程池 size=K + {@link Semaphore}(K) + K 个端口槽三者同界)。
|
||||
* <b>默认 K=1 = 生产字节零变</b>:单线程池 + 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} 注释)。
|
||||
*
|
||||
* <p><b>事务边界</b>:图执行<b>不包大 {@code @Transactional}</b>(数十秒级跑图占长事务=连接池杀手);落库由
|
||||
* {@code handleCallback → DifyCallbackTxService} 内部独立短事务完成(既有三表同事务写链不变)。
|
||||
@ -95,22 +101,33 @@ public class SaaGraphDispatcher implements GenerationDispatcher {
|
||||
private final SourceProjectApi sourceProjectApi;
|
||||
|
||||
/**
|
||||
* 后台单线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。
|
||||
* 单线程 = 串行跑图(与 Semaphore 双保险——单线程已天然串行,Semaphore 兜「未来若改多线程」语义不破)。
|
||||
* 后台跑图线程池(跑图阻塞活,禁占 tick/Tomcat 线程)。daemon 线程,命名便于排障。
|
||||
* <b>有界 size=K</b>({@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 并行真玩须各占独立端口对,故并发受限)。
|
||||
* <b>容量=K</b>({@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<int[2]>},恰 K 个错开端口对 {@code [playPort, cdpPort]}
|
||||
* (第 i 槽 playPort={@code saaPlayPortBase+2i} / cdpPort={@code saaCdpPortBase+i},{@link #allocPortSlots} 算)。
|
||||
* <p><b>整 job 生命期持槽语义</b>:{@code runGraphAndCallback} 起手 {@code take()} 拿一空闲槽(无则阻塞,与信号量同节流),
|
||||
* 用该槽端口覆盖 {@code inputs} 的 port/cdpPort 后跑图,<b>finally 随 release 把同一槽 {@code put} 回池</b>——
|
||||
* 即便跑图超时/异常也归还(防端口泄漏致后续 job 无槽永挂)。<b>K=1 时只有槽 0 = {@code [4320,9222]}</b>,
|
||||
* 覆盖值与改造前 buildInputs 常量逐字一致 → 字节零变。
|
||||
*/
|
||||
private final BlockingQueue<int[]> 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<String, Object> inputs,
|
||||
Map<String, Object> 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 鉴权;budget<maxTokens client 侧 fail-fast。
|
||||
String anthropicBase = properties.getSaaAnthropicBase();
|
||||
mf = SaaStudioGraph.anthropicFactory(anthropicBase, properties.getApiKey(),
|
||||
properties.getSaaThinkingBudget());
|
||||
upstreamDesc = "anthropic(" + anthropicBase + ", thinkingBudget=" + properties.getSaaThinkingBudget() + ")";
|
||||
} else {
|
||||
// OpenAI 兼容路(默认):baseUrl 取 host 根(剥末尾 /v1,坑见 §7-1);apiKey 经配置注入。openAiFactory 逐字调 model(api,...),字节等价改造前。
|
||||
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
|
||||
// Plan A U5:openai 路同 anthropic 旁路系统代理(macOS clash 把 Tailscale IP 走代理→502;无代理环境 no-op)+ 可配读超时(M3+thinking 慢)。
|
||||
OpenAiApi api = SaaStudioGraph.openAiApiNoProxy(baseUrl, properties.getApiKey(), properties.getSaaOpenAiReadTimeoutMs());
|
||||
mf = SaaStudioGraph.openAiFactory(api);
|
||||
upstreamDesc = "openai(" + baseUrl + ")";
|
||||
}
|
||||
// Plan A U5「maxtoken 大余量」:saaForceMaxTokens>0 → 包装 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:<cat> ref 为几何占位资产引用,原样透传不伪造 type/hash/bytes/mime。
|
||||
// 空/缺/非数组源 assets → 回退空集(不抛):严格非破坏,存量无源回调字节零变。
|
||||
reqVO.setAssets(extractSourceAssets(s, traceId));
|
||||
// gameConfig 最小合法占位(engine 路游戏逻辑全在 engineBundle,gameConfig 仍不可空:GamePackage required)。
|
||||
Map<String, Object> 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。
|
||||
*
|
||||
* <p><b>兑现 TODO</b>:源 asset 节点已把六类源资产写入 {@code sourceProject} 顶层 {@code assets}
|
||||
* ({@code source-project.schema.json #/$defs/assetSpec},形态 {@code {id,category,ref,provider}}),历史上组包侧
|
||||
* 硬置空集导致产消断链;本方法把<b>同一批</b>源 assets 忠实搬进 {@link DifyCallbackReqVO#getAssets()},
|
||||
* 由 {@code DifyCallbackTxService.buildGamePackage} 落进 {@code GamePackage.assets}。运行时 {@code gd-runtime}
|
||||
* 经 {@code gdef.assets}(同 source assetSpec 形态)消费,故<b>原样透传不重塑</b>:{@code geom:<cat>} 为几何占位
|
||||
* 资产引用,不伪造包契约 strict AssetSpec 的 {@code type/url/hash/bytes/mime}(无真图、无真 sha256,伪造即脏数据)。
|
||||
*
|
||||
* <p><b>best-effort 非阻断(严格 additive)</b>:源缺失/空白/非 JSON/无 assets 键/assets 非数组/任意异常 →
|
||||
* 一律返回<b>空集</b>(绝不外抛、绝不打断 SAA 生成),与历史「恒空集」对存量无源回调字节零变化。
|
||||
*
|
||||
* @param s 图终态(可能 null)
|
||||
* @param traceId 贯穿键(仅日志)
|
||||
* @return 源 assets 映射出的 {@code List<Map<String,Object>>}(忠实透传字段);无/异常时空集(非 null)
|
||||
*/
|
||||
private static java.util.List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>>() {}));
|
||||
}
|
||||
}
|
||||
// 可追溯日志:接通了几个源 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 路并发各算一对<b>错开</b>端口 {@code [playPort, cdpPort]}。
|
||||
* <p>第 i 槽({@code 0..k-1}):{@code playPort = httpBase + 2*i}(步长 2 留头寸)、{@code cdpPort = cdpBase + i}(步长 1)。
|
||||
* <b>槽 0 = {@code [httpBase, cdpBase]}</b>(默认 {@code [4320, 9222]})→ K=1 时与改造前 buildInputs 常量逐字一致,字节零变。
|
||||
*
|
||||
* <p><b>不撞端口的依据</b>({@code SaaFullGraphE2eGateLogicTest} 单测断言):playPort 族 {@code {httpBase, httpBase+2, ...}}
|
||||
* 与 cdpPort 族 {@code {cdpBase, cdpBase+1, ...}} 默认区间天然不交叠(4320 区 vs 9222 区,相距数千);族内步长保互异 →
|
||||
* K 槽 2K 个端口两两不同(默认值下成立;若误配 base 致两族交叠属部署配置错,超本单元,由 U5 真跑校准兜)。
|
||||
*
|
||||
* <p><b>K>1 共享面安全审计(只读核 {@code _shared/serve-and-play.sh} + {@code static-serve.cjs} + {@code play.cdp.cjs},
|
||||
* 脚本禁改铁律下仅审计)</b>——结论:除「同一对端口」外,K 路并发的其余共享资源均隔离或读共享安全:
|
||||
* <ol>
|
||||
* <li><b>端口(唯一真冲突点)</b>:每 job 持独立端口对、整生命期独占({@link #portSlots}),serve-and-play.sh 净场
|
||||
* {@code lsof -ti tcp:$PORT|kill} <b>只杀传入的本槽两端口</b>(非全局 kill),故跨 job 不误杀 → 端口隔离即足。</li>
|
||||
* <li><b>服务根 cwd(共享、只读)</b>:N 个 {@code static-serve.cjs} 各 {@code listen} 自己端口,serve 同一 game-runtime
|
||||
* 根但<b>纯只读</b>(仅 {@code fs.readFile}、拒 {@code ..} 穿越,无写)→ 并发只读同一根安全。</li>
|
||||
* <li><b>{@code _shared/} 脚本(共享、只读)</b>:serve-and-play.sh / play.cdp.cjs / static-serve.cjs 是共读脚本文件,
|
||||
* 运行期不自改 → 安全。</li>
|
||||
* <li><b>{@code /tmp/wg1-*-$PORT.log}(按端口键隔离)</b>:serve/chrome 日志名含 {@code $PORT},端口对既隔离则日志路径天然不撞 → 安全。</li>
|
||||
* <li><b>Chrome user-data-dir(按调用隔离)</b>:serve-and-play.sh 用 {@code mktemp -d -t wg1-chrome-XXXX} 每次唯一临时目录 → 安全。</li>
|
||||
* <li><b>evidence 产物(按 gameId 隔离)</b>:play.cdp.cjs 只写 {@code games/_wg1-gen/<gameId>/evidence/},gameId={@code saa-<traceId>} 每 job 唯一 → 写面不交叠。</li>
|
||||
* </ol>
|
||||
* <b>遗留(U5 真跑校准项)</b>:上述为静态审计结论;K 路<b>真并发</b>下「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<int[]> 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<int[]> slots = new java.util.ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
slots.add(new int[]{hb + 2 * i, cb + i});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,8 @@ import java.util.regex.Pattern;
|
||||
* <ul>
|
||||
* <li>{@link #SYSTEM} ≡ wg1/gen-worker/worker/prompt.py:24-107(模块形状/能力面/12 禁止/硬规则/P0 latch/few-shot 挂点);</li>
|
||||
* <li>{@link #buildMessages} ≡ prompt.py:110-119(system 注入 few-shot;user 末尾回喂上轮失败原因,全量重出非 diff);</li>
|
||||
* <li>{@link #DESIGN_SYSTEM} ≡ roles.py:8-32(设计 agent + 末尾自产 ```gatespec;规则③已同步 tap-targets 家族二分:离散点击目标类自产 tap-targets〔避负族再带 safeOnly+safe 位〕);</li>
|
||||
* <li>{@link #DESIGN_SYSTEM} 源自 roles.py:8-32(设计 agent + 末尾自产 ```gatespec);<b>Phase 1b(2026-06-20,关闭九门 driver 判定·全面参考 OpenGame)已剥离 driver 决策树</b>:
|
||||
* gatespec 收窄为 expectLatch + 一条 progress 断言(score/remaining),设计 agent 只声明胜负与进展、不再预测 driver 类型(GAMEDEF_SYSTEM 同步去 driver 命名耦合);</li>
|
||||
* <li>{@link #playerSystem} ≡ roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必判 fix);</li>
|
||||
* <li>{@link #extractGatespec} ≡ studio.py:72-102(正则抠 ```gatespec → 容错解析 → 缺省补 expectLatch / ballPath .y→.x 归一)。</li>
|
||||
* </ul>
|
||||
@ -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":"<JS 布尔表达式,rt 在作用域>","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/>/>=/</<=/==/in),path 用你让代码导出的字段名(须与 exportState 一致)。""";
|
||||
规则:① `expectLatch` 恒 true(游戏必须能真玩到 gameover 且驻留终态)。② `assertAfterPlay` 给至少一条"真有进展"的客观断言:op ∈ increased/decreased/changed,path 只能是运行时真暴露的进展字段——`score`(得分上升用 increased)或 `remaining`(剩余目标/敌人/砖块下降用 decreased)。计数类进展(连击/收集/消除/落子)一律计入 score → 用 {"path":"score","op":"increased"};清目标类(打砖/消敌)→ 用 {"path":"remaining","op":"decreased"}。绝不要用 score.player / snakeLength / hits / piecesLocked 等自定义标量(运行时不暴露→验收读不到→判无进展)。③ 你只声明"什么算赢、什么算有进展",不必描述游戏怎样被自动测试驱动——怎么玩通由下游验收自适应处理。""";
|
||||
|
||||
// ============================================================================
|
||||
// ②.1 CLASSIFY_SYSTEM —— 固定架构新增(B2,execution §6.4/§5.4):品类分类 + profile 三维。
|
||||
@ -370,13 +374,91 @@ final class SaaPrompts {
|
||||
if (archetype != null && !archetype.isEmpty() && !"generic".equals(archetype)) {
|
||||
user.append("\n\n(品类提示:").append(archetype).append(")");
|
||||
}
|
||||
if (retryFeedback != null && !retryFeedback.isEmpty()) {
|
||||
boolean firstAttempt = retryFeedback == null || retryFeedback.isEmpty();
|
||||
if (firstAttempt) {
|
||||
appendSkeletonIfAny(user, archetype); // Phase 2:仅首生成注骨架;救场轮不注(已有失败反馈、避免冲淡)
|
||||
} else {
|
||||
user.append("\n\n———\n上一次生成【未通过】,失败原因如下,请针对性修正后重新产出完整 gameDefinition JSON(不要只给片段):\n")
|
||||
.append(retryFeedback);
|
||||
}
|
||||
return new String[]{GAMEDEF_SYSTEM, user.toString()};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan A·Phase 2:若该 archetype 有已验证骨架且 {@code -Dsaa.gen.skeletonFirst} 开,追加骨架段(结构参照、非照抄)。
|
||||
* flag 关 / 无映射 / 资源缺失时 {@link SaaSkeletons#forArchetype} 返 null → 不注入(沿用现 hint,生成字节零变)。
|
||||
*/
|
||||
private static void appendSkeletonIfAny(StringBuilder user, String archetype) {
|
||||
String skeleton = SaaSkeletons.forArchetype(archetype);
|
||||
if (skeleton != null) {
|
||||
user.append("\n\n【已验证骨架(同品类、已过门的 gameDefinition 结构参照)——请在此结构上改成上面题面要求的那款游戏:")
|
||||
.append("沿用其 rt 面用法与结构约定,替换实体/组件/数值/玩法逻辑使之契合题面;若题面与此结构不符,以题面为准、勿照抄】:\n")
|
||||
.append(skeleton);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ④.1 连续对话救场(Plan A/U2,仅 anthropic 路)—— system 不变;首轮 user=题面;救场 user=失败反馈增量修。
|
||||
// 与 ④ buildMessages/buildGameDefMessages(openai 路·每轮全新 [system,user])<b>物理隔离·并存</b>:
|
||||
// openai 路 prompt 字节零变(回归红线),本组仅 anthropic 路连续对话用。公平性铁律:system 逐字同上,不改语义。
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 连续对话路 system(与 openai 路<b>同一 SYSTEM/GAMEDEF_SYSTEM</b>,公平性铁律不改 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续对话路<b>首轮 user</b>(仅题面,无失败回喂——首轮无上一版)。与 openai 路首轮 user <b>正文一致</b>
|
||||
* (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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续对话路<b>救场 user</b>(plan U2:去「重新产出完整模块(不要只给 diff)」、改「针对失败反馈<b>增量修正</b>」)。
|
||||
*
|
||||
* <p>语义差异(与 openai 路 {@link #buildMessages}/{@link #buildGameDefMessages} 的回喂段刻意不同):连续对话路上一版
|
||||
* 答案文本已在历史里(模型看得到自己上一版源;U2 创始人定·历史只留答案文本不留 thinking),故救场<b>不必</b>命令
|
||||
* 「重出完整模块」——改为「在上一版基础上针对反馈<b>增量修正</b>」,省 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;② 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格;
|
||||
|
||||
@ -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 面接线 / 运动逻辑」从零写错的概率(哑火静态游戏=跨款最大失败簇)。
|
||||
*
|
||||
* <p><b>只用 3 个金标准过门纯净源</b>(gd-breakout/tictactoe/simon:均 verdict.pass=true + rt 面纯净 + 过 Phase3 坑门),
|
||||
* 按 (tick/input) profile 相近映射到 archetype;无近似骨架的 archetype(merge/idle/tycoon 经济类)走【无骨架】(沿用现 hint),
|
||||
* <b>绝不为凑覆盖喂低质/违禁骨架</b>(Phase2 研究铁律:13 个 gd-* 仅 3 个纯净,其余含违禁 token 或未过门)。
|
||||
*
|
||||
* <p><b>flag 旁挂、默认关</b>:{@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<String, String> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
@ -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 位 → 单模型路<b>无跨家回退</b>,符合"只用 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 预算全耗在 <think> 上、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.<String, Object>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<java.net.Proxy> 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 协议」解耦。
|
||||
*
|
||||
* <p><b>回归命门</b>:openai 工厂({@link #openAiFactory(OpenAiApi)})逐字调现行 {@link #model(OpenAiApi, String, Double, int)},
|
||||
* 产出与改造前完全相同的 {@link OpenAiChatModel} 对象 → 默认 openai 路<b>运行时字节等价</b>;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<ChatResponse> 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<ChatResponse> stream(Prompt prompt) {
|
||||
return delegate.stream(prompt);
|
||||
}
|
||||
@Override
|
||||
public org.springframework.ai.chat.prompt.ChatOptions getDefaultOptions() {
|
||||
return delegate.getDefaultOptions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建一个经 <b>Anthropic Messages 协议</b>指向 new-api 的 {@link AnthropicChatModel}(Plan A/U1「用对 M3」;thinking 原生分离,
|
||||
* 治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因)。<b>lili-mac spike 1.1.2 实测定死配方</b>:
|
||||
* <ul>
|
||||
* <li><b>baseUrl = new-api host 根</b>(如 {@code http://100.64.0.8:3000},completionsPath 保持默认 {@code /v1/messages}
|
||||
* → 真实打 {@code .../v1/messages})。<b>绝不加 {@code /anthropic} 前缀</b>(命中 SPA catch-all 返 HTML→假挂起);
|
||||
* <b>亦不复用 {@link #stripV1Suffix}</b>(那是 OpenAI 口径的坑,与 Anthropic 协议无关);</li>
|
||||
* <li><b>鉴权 = {@code apiKey(key)}(发 {@code x-api-key} 头)</b>。实测:x-api-key → 多 Generation 原生 thinking block
|
||||
* (含 signature=推理 / 无 signature=答案);<b>勿用 Authorization Bearer</b>(实测退化为字面 {@code <think>} 内联,正是要躲的坑);</li>
|
||||
* <li><b>代理旁路</b>:经 {@link SimpleClientHttpRequestFactory} 设 {@link Proxy#NO_PROXY} + 连接/读超时,经
|
||||
* {@code AnthropicApi.builder().restClientBuilder(...)} 注入(1.1.2 的 {@code AnthropicChatModel$Builder} <b>无 customHeaders</b>,
|
||||
* 自定义只能经 restClientBuilder);</li>
|
||||
* <li><b>thinking</b>:{@code thinking(ENABLED, budget)},<b>{@code budget < maxTokens} 硬约束</b>(client 侧 fail-fast 断言,
|
||||
* 勿信网关宽松、否则重演原截断 bug);阻塞 {@code .call()}(保留默认 RetryTemplate,流式 thinking 有 bug #4407 不用)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<java.net.Proxy> 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 {
|
||||
* <b>P0-4</b>: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 重载共用)。
|
||||
*
|
||||
* <p><b>本重载 = OpenAI 兼容路</b>(默认,行为字节不变):内部 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)。
|
||||
* <b>布线唯一源仍是 {@link #assemble}</b>(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})。
|
||||
* <b>布线唯一源仍是 {@link #assemble}</b>(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));
|
||||
|
||||
@ -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<Message>(system 不入历史·每轮现拼,仅 user 轮 + assistant 答案块入历史)。
|
||||
// 存储策略:ReplaceStrategy + 节点内 read-append-write(对齐 appendAttempt,勿进全量 ReplaceStrategy 误判,P1-4)。
|
||||
// 可序列化:经 SpringAIJacksonStateSerializer 往返保 List<AssistantMessage> 类型(P0-5,SaaHistorySerdeTest 守)。
|
||||
// gamedef 路与 iife(factory) 路各自历史键(隔离,勿串味;sourceMode 决定写哪个)。
|
||||
static final String K_MSG_HISTORY_GAMEDEF = "msgHistoryGamedef"; // gamedef 路连续对话历史(List<Message>,纯答案文本)
|
||||
static final String K_MSG_HISTORY_FACTORY = "msgHistoryFactory"; // factory(iife) 路连续对话历史(List<Message>,纯答案文本)
|
||||
|
||||
/** 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」;<b>纯函数、无网络、可单测</b>)。
|
||||
*
|
||||
* <p><b>背景(lili-mac spike 1.1.2 实测)</b>:经 Anthropic Messages 协议 + thinking ENABLED 调 M3,单次阻塞
|
||||
* {@code .call()} 回来的 {@link ChatResponse#getResults()} 是<b>多 Generation</b>(size≥2):一个是 thinking 推理块
|
||||
* ({@code AssistantMessage.getMetadata()} 含 {@code signature} 键、{@code getText()}=推理文本),一个是真答案
|
||||
* (metadata <b>不含</b> {@code signature} 键、{@code getText()}=结构化答案)。若沿用旧 {@code getResult().getText()}
|
||||
* 只取首个 Generation,会取到 thinking 推理而非答案(污染 JSON/```js 抽取)。
|
||||
*
|
||||
* <p><b>识别答案靠 {@code signature} 键的<u>存在性</u>,不靠 {@code "thinking"} 键</b>(实测 metadata 只见
|
||||
* {@code signature}、无 {@code thinking} 键)。从尾向前找第一个 metadata <b>不含 signature</b> 的 Generation 取其文本
|
||||
* (末个答案优先);全是 thinking(异常)则兜底取末个 Generation。
|
||||
*
|
||||
* <p><b>回归保证(命门)</b>:OpenAI 兼容路 {@code getResults().size()==1} → 直接取 {@code gens.get(0).getText()},
|
||||
* <b>与旧 {@code getResult().getOutput().getText()} 字节等价</b>({@code getResult()} 即返首个 Generation)。
|
||||
* 故 design/classify/nreview/player 等任一走单 Generation 的路<b>行为完全不变</b>。
|
||||
*
|
||||
* @param resp 模型阻塞调用响应(OpenAI 单 Generation / Anthropic+thinking 多 Generation)。
|
||||
* @return 答案文本(空响应返 "",绝不抛——交由调用方按节点语义兜底)。
|
||||
*/
|
||||
static String pickAnswerText(ChatResponse resp) {
|
||||
if (resp == null) {
|
||||
return "";
|
||||
}
|
||||
List<Generation> 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<String, Object> meta = out.getMetadata();
|
||||
return meta != null && meta.containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调模型并把 usage 累计回 state(对齐 RecordingChatModel),含单次 180s 超时 + ≤3 重试。
|
||||
* 返回 {@code [文本, inDelta(Long), outDelta(Long), orderedTurn(List<AssistantMessage>)]}。
|
||||
*
|
||||
* <p>U2:第 4 元素 {@code orderedTurn} = 本轮 {@code resp.getResults()} 的全部 {@link AssistantMessage}(<b>有序</b>,
|
||||
* 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:用<b>完整 message 列表</b>(连续对话:system + 历史轮 + 新 user)调模型,套同款 180s 超时 + ≤3 重试。
|
||||
* 返回 {@code [文本, inDelta, outDelta, orderedTurn(List<AssistantMessage>)]}(同 {@link #callAndRecord})。
|
||||
*
|
||||
* <p>anthropic 连续对话救场用此(messages 含历史 thinking/answer 块);openai 路仍走 {@link #callAndRecord}
|
||||
* (两元 [system,user],字节零变)。
|
||||
*/
|
||||
private static Object[] callAndRecordMessages(ChatModel model, List<Message> 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<AssistantMessage> orderedTurn = orderedAssistantTurn(resp);
|
||||
return new Object[]{text, in, out, orderedTurn};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* U2:把 {@link ChatResponse} 的多 Generation 重组为<b>有序 {@link AssistantMessage} 序列</b>(thinking 块在答案块前,
|
||||
* 符 Anthropic「assistant 轮以 thinking 起」铁律)。openai 单 Generation → 单元素 list;空响应 → 空 list(不抛)。
|
||||
*/
|
||||
private static List<AssistantMessage> orderedAssistantTurn(ChatResponse resp) {
|
||||
List<AssistantMessage> 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}
|
||||
* 同口径)。<b>仅用于 {@link #appendHistory} 入历史时丢弃 thinking 块</b>——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<Message>});缺/类型不符 → 空 list(不抛)。
|
||||
* <p>注:历史经 checkpoint 序列化往返后仍是类型化 {@code List<Message>}(P0-5,SaaHistorySerdeTest 守);
|
||||
* 极端非法值(理论不可达)防御性返空。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Message> readHistory(OverAllState s, boolean gamedef) {
|
||||
Object v = s.value(historyKey(gamedef)).orElse(null);
|
||||
if (v instanceof List<?> list) {
|
||||
List<Message> 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 组<b>连续对话 Prompt 的 message 列表</b>(有界截断后),供 anthropic 路下发。
|
||||
* 结构 = {@code [system] + boundedHistory + newUser}(system 不入历史、每轮现拼;历史 = 既往 user/答案轮序列)。
|
||||
*
|
||||
* <p><b>P1-1 命门(user/assistant 严格交替)</b>:截断后若历史<b>末条为 user</b>(理论上 {@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<Message> assembleConversation(String system, List<Message> priorHistory, String newUser) {
|
||||
List<Message> 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<Message> 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} 入历史时即丢),故只需<b>按最近 K 轮留</b>
|
||||
* (轮 = 一个 user + 其后答案块;用 user 边界切轮)。保最近 {@link #HISTORY_MAX_TURNS} 轮,更早轮整轮丢弃;
|
||||
* 每条答案 text 超 {@link #HISTORY_ANSWER_MAX_CHARS} 截断标注(守 token)。
|
||||
*/
|
||||
private static List<Message> boundHistory(List<Message> 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<Message> 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)。<b>只保留答案块、丢 thinking 块</b>(P1-2:stock
|
||||
* spring-ai-anthropic 1.1.2 出站丢弃 thinking,留之无益且回灌推理白话浪费 token)。<b>不重置</b>既往历史(连续对话救场命门)。
|
||||
*
|
||||
* <p><b>P1-1 命门(user/assistant 严格交替)</b>:本轮 generate <b>全档失败</b>(orderedTurn==null·无任何答案)时,
|
||||
* 本轮 user <b>不入历史</b>(不产生悬空 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<String, Object> out, boolean gamedef,
|
||||
String newUser, List<AssistantMessage> orderedTurn) {
|
||||
// P1-2:滤出本轮答案块(丢 thinking 块——metadata 含 signature 的)。
|
||||
List<AssistantMessage> 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<Message> 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]}(<b>字节零变</b>,回归红线)。
|
||||
*
|
||||
* <p><b>历史路边界</b>:仅<b>普通生成路</b>(create/repair 整源重生成)走连续对话历史;factory 的
|
||||
* <b>regenerate-module 消费 base 源</b>路(P0-2,一次性把 base 源 JSON 注入 user)<b>不走历史</b>(保其 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 语义,<b>不走连续对话历史</b>(防 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<Message> 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<String, Object> 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:历史路全档失败<b>不入历史</b>(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<AssistantMessage> orderedTurn = (List<AssistantMessage>) 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) {
|
||||
|
||||
@ -197,6 +197,78 @@ public class AigcExecutorProperties {
|
||||
*/
|
||||
private String saaSourceMode = "factory";
|
||||
|
||||
/**
|
||||
* SAA 图后台执行并发度 K(plan 2026-06-19 003-U1 有界并发池,P0-3/P1-5):进程内后台跑图的<b>并行 job 上限</b>。
|
||||
* <b>默认 1 = 生产字节零变</b>(单线程池 + Semaphore(1) + 端口槽 0 → 端口恒为 saaPlayPortBase/saaCdpPortBase,
|
||||
* 与改造前 {@code newSingleThreadExecutor + Semaphore(1) + 常量 4320/9222} 行为逐字节等价,回归红线)。
|
||||
* <p>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} 共享面审计注释)。
|
||||
* <p><b>仅 dispatcher=saa 生效</b>;<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 字节零变)。
|
||||
* <p>两族端口区间天然不交叠(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 位 → 单模型路<b>无跨家回退</b>);
|
||||
* <b>空(默认)= stage1 默认路由(deepseek 主力 + M3 视觉/分类/回退),字节零变</b>。per-role maxTokens/temperature 不变。
|
||||
* <p>仅 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 为此值;<b>0(默认)= 各角色默认(字节零变)</b>。仅 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},<b>默认·行为字节不变</b>);
|
||||
* {@code anthropic}=经 new-api Anthropic Messages 协议({@code AnthropicChatModel},{@code /v1/messages},thinking 原生分离)
|
||||
* 调 M3——治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因(根因 v3「用对 M3」)。
|
||||
* <b>仅 dispatcher=saa 生效</b>;openai 时 dispatcher 不构造 Anthropic 客户端(U1 旁挂、零回归)。
|
||||
*/
|
||||
private String saaModelProtocol = "openai";
|
||||
|
||||
/**
|
||||
* SAA Anthropic 协议 baseUrl(saaModelProtocol=anthropic 专用):取 <b>new-api host 根</b>
|
||||
* {@code http://100.64.0.8:3000}({@code AnthropicApi} 自拼 completionsPath 默认 {@code /v1/messages} → 真实打
|
||||
* {@code .../v1/messages})。<b>绝不加 {@code /anthropic} 前缀</b>(lili-mac spike 1.1.2 实测:{@code /anthropic/v1/messages}
|
||||
* 命中 new-api SPA catch-all 返 HTML→Spring AI 解析失败假挂起);<b>亦不复用 OpenAI 路的 stripV1Suffix</b>
|
||||
* (那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。内网地址按项目规则可入库。
|
||||
*/
|
||||
private String saaAnthropicBase = "http://100.64.0.8:3000";
|
||||
|
||||
/**
|
||||
* SAA Anthropic 协议 thinking 预算 tokens(saaModelProtocol=anthropic 专用):开 thinking 时的 budget_tokens。
|
||||
* <b>Anthropic 协议硬约束 {@code budget_tokens < max_tokens}</b>(client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug);
|
||||
* 默认 8192(< code/fix 角色 maxTokens=16000)。openai 协议下本字段不读。
|
||||
*/
|
||||
private Integer saaThinkingBudget = 8192;
|
||||
|
||||
/**
|
||||
* 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast)
|
||||
*
|
||||
|
||||
@ -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 <b>P0-2 专项命门</b>的<b>模型无关单测</b>(纯逻辑、<b>无网络/无 key</b>,普通 {@code mvn test} 即跑)。
|
||||
*
|
||||
* <p><b>验证对象</b>:{@link SaaStudioGraph#anthropicModel} 的 <b>per-role thinking budget 自适应</b>(替原 fail-fast 抛)。
|
||||
* 对抗验证抓到的 P0-2:assemble 给判定类小角色硬编码小 {@code maxTokens}({@code playerVision=900 / classify=1000 /
|
||||
* narrative=2000 / playerText=4000}),而单一全局 {@code thinkingBudget}(默认 8192,{@code AigcExecutorProperties.saaThinkingBudget})
|
||||
* 撞之——<b>修前</b> {@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。<b>修后</b>:{@code effectiveBudget = min(全局, maxTokens-1024)},恒 {@code < maxTokens}(守协议约束);
|
||||
* 容不下 Anthropic 最小 thinking(1024) 的小判定角色不开 thinking——<b>全程不抛</b>。
|
||||
*
|
||||
* <p><b>安全性</b>:{@link SaaStudioGraph#anthropicModel} / {@link SaaStudioGraph#anthropicFactory} 只构造
|
||||
* {@code AnthropicChatModel} client 对象(建 {@code AnthropicApi} + 设 {@code defaultOptions}),<b>绝不调网关</b>(无 {@code .call()});
|
||||
* 节点工厂({@code designNode/generateNode/...})只<b>存</b> {@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} 构造<b>全图</b>,触发 {@code assemble} 内全 11 角色
|
||||
* {@code mf.create(...)}(含 maxTokens 极小的 playerVision=900 / classify=1000 / narrative=2000 / playerText=4000)——
|
||||
* <b>断不抛任何异常</b>。修前小角色必抛 {@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<maxTokens 或关 thinking,全程不抛。
|
||||
// factory 形态(现行默认 sourceMode)。
|
||||
CompiledGraph factoryGraph = assertDoesNotThrow(() -> SaaStudioGraph.build(
|
||||
anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime,
|
||||
/*maxRepairs*/ 5, /*maxPlayerRounds*/ 1, /*stage2ExtraRepairs*/ 3,
|
||||
/*sourceMode*/ "factory", /*saverConfig*/ null, /*observationRegistry*/ null),
|
||||
"P0-2 回归:anthropic 工厂构造全图(factory 形态)不得抛——小判定角色 maxTokens<budget 时应 clamp/关 thinking,而非 fail-fast 抛");
|
||||
assertNotNull(factoryGraph, "factory 形态全图应编译成功");
|
||||
|
||||
// gamedef 形态(真结构化源路;同走 assemble 全 11 角色构造)一并验,证 P0-2 修复对两形态都成立。
|
||||
CompiledGraph gamedefGraph = assertDoesNotThrow(() -> SaaStudioGraph.build(
|
||||
anthropicFactory, SaaStudioGraph.Models.stage1(), dummyRuntime,
|
||||
5, 1, 3, "gamedef", null, null),
|
||||
"P0-2 回归:anthropic 工厂构造全图(gamedef 形态)同样不得抛");
|
||||
assertNotNull(gamedefGraph, "gamedef 形态全图应编译成功");
|
||||
}
|
||||
|
||||
/**
|
||||
* P0-2 直证(最精准):直接对 assemble 里硬编码小 maxTokens 的<b>各判定角色</b>逐一调
|
||||
* {@link SaaStudioGraph#anthropicModel}(全局 budget=8192)——<b>断每个都不抛</b>且产出非空 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");
|
||||
}
|
||||
}
|
||||
@ -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 的<b>正式门控集成烟测</b>(治「用对 M3」根因,HJ-AGI / plan003)。
|
||||
*
|
||||
* <p><b>验证对象</b>:{@link SaaStudioGraph#anthropicModel} 构造的 client(经 new-api Anthropic Messages 协议调 MiniMax-M3)+
|
||||
* {@link SaaStudioNodes#pickAnswerText(ChatResponse)} 多 Generation 重组取答案。<b>真打网关</b>(gated,默认跳过;需 NEWAPI_KEY + 可达 new-api)。
|
||||
*
|
||||
* <p><b>实测定死配方(lili-mac 真跑 spring-ai-anthropic 1.1.2 实证,本测试照此构造,务必勿改)</b>:
|
||||
* <ol>
|
||||
* <li><b>baseUrl = new-api host 根</b> {@code http://100.64.0.8:3000}(completionsPath 保持默认 {@code /v1/messages}
|
||||
* → 真实打 {@code .../v1/messages})。<b>绝不加 {@code /anthropic} 前缀</b>:{@code /anthropic/v1/messages} 命中
|
||||
* new-api SPA catch-all 返 <b>HTTP 200 + index.html</b>,Spring AI 解析 JSON 失败后落默认 RetryTemplate 指数退避
|
||||
* → 表象「call() 永久挂起」。<b>勿复用 OpenAI 路的 stripV1Suffix</b>(那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。</li>
|
||||
* <li><b>鉴权 = {@code apiKey(NEWAPI_KEY)}(发 {@code x-api-key} 头)</b>。实测:<b>x-api-key → {@code getResults().size()==2}
|
||||
* 原生 thinking block</b>(gen#0 的 {@code AssistantMessage.getMetadata()} 含 {@code signature} 键=推理 / gen#1 无 signature=答案);
|
||||
* <b>Authorization Bearer → {@code size==1} 字面 {@code <think>} 内联</b>(污染结构化输出,正是要躲的坑)。故 client 用 x-api-key、本测试只验这一路。</li>
|
||||
* <li><b>1.1.2 的 {@code AnthropicChatModel$Builder} 没有 customHeaders</b>——自定义头唯一干净入口 = {@code AnthropicApi.builder()
|
||||
* .restClientBuilder(...)}({@link SaaStudioGraph#anthropicModel} 经此注入 NO_PROXY+超时的 RestClient)。</li>
|
||||
* <li><b>{@code budget < maxTokens} 硬约束</b>(Anthropic 协议;client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug)。</li>
|
||||
* <li><b>{@code ChatResponse.getResults() -> List<Generation>};必须遍历 getResults(),不能只 getResult()</b>——thinking ENABLED
|
||||
* 下取真答案 = {@link SaaStudioNodes#pickAnswerText}(从尾向前找第一个 metadata 不含 signature 的 Generation)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>纪律</b>:① 门控 {@code -Danthropic.smoke=1}(无开关/无 key 时跳过,不算 fail);② 密钥经环境变量 {@code NEWAPI_KEY}
|
||||
* (绝不入库/打印/提交);③ 断言 client 配方真生效 + pickAnswerText 取到答案。
|
||||
*
|
||||
* <p><b>运行(lili-mac,在 game-cloud reactor 下;NEWAPI_KEY 经 env,绝不打印)</b>:
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
* <b>实测坑(必带 -DargLine 那行)</b>:macOS 系统代理 = clash 127.0.0.1:7897,surefire fork 的 JVM 自动继承 → 对 Tailscale IP
|
||||
* 100.64.0.8 走 clash 代理 → <b>502 TransientAiException</b>。{@link SaaStudioGraph#anthropicModel} 内已设
|
||||
* {@code SimpleClientHttpRequestFactory.setProxy(NO_PROXY)} 旁路;{@code -DargLine} 的 nonProxyHosts 是双保险。
|
||||
*
|
||||
* @author 造梦AI(Plan A · U1 Anthropic client 门控烟测)
|
||||
*/
|
||||
class SaaAnthropicSmokeTest {
|
||||
|
||||
/** new-api Anthropic 协议 baseUrl = <b>host 根</b>(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<maxTokens fail-fast)──
|
||||
AnthropicChatModel model = SaaStudioGraph.anthropicModel(
|
||||
ANTHROPIC_BASE_URL, key, MODEL, /*temperature*/ 0.3, MAX_TOKENS, THINKING_BUDGET);
|
||||
|
||||
// 简单 prompt:要点推理(诱发 thinking),但答案短(省钱)。
|
||||
Prompt prompt = new Prompt("用一句话回答:9.11 和 9.8 哪个大?先简要想一下再给结论。");
|
||||
|
||||
System.out.println("================ Plan A U1 Anthropic client 烟测证据 ================");
|
||||
System.out.println("[base] " + ANTHROPIC_BASE_URL + " (completionsPath 默认 /v1/messages → 真实 .../v1/messages,无 /anthropic)");
|
||||
System.out.println("[model] " + MODEL + " thinking=ENABLED budget=" + THINKING_BUDGET + " maxTokens=" + MAX_TOKENS + " 鉴权=x-api-key");
|
||||
|
||||
// ── 阻塞 .call()(保留默认 RetryTemplate;流式 thinking bug #4407 → 不用 stream)──
|
||||
ChatResponse resp = model.call(prompt);
|
||||
|
||||
// ── 响应形证据:必须遍历 getResults()(不止 getResult())──
|
||||
List<Generation> 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<String, Object> 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 + "\"");
|
||||
// 内联 <think> 探测(x-api-key 路不应出现;若出现则网关方言变了,需复核配方)。
|
||||
if (text.contains("<think>")) {
|
||||
System.out.println(" >> [警示] text 含字面 <think>(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 分离,非内联 <think>)。
|
||||
assertTrue(sawThinkingSignature,
|
||||
"应存在含 signature 键的 thinking Generation(原生 thinking block 分离);缺则 thinking 未原生分离(配方失效)");
|
||||
// ③ pickAnswerText 取到「无 signature 的答案 Generation」且非空(生产重组语义真生效)。
|
||||
assertFalse(answer.isBlank(), "pickAnswerText 应取到非空答案文本(无 signature 的答案 Generation)");
|
||||
}
|
||||
}
|
||||
@ -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<int[]> 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<int[]> 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<int[]> slots = SaaGraphDispatcher.allocPortSlots(4320, 9222, k);
|
||||
Set<Integer> 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<int[]> 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<int[]> clamp = SaaGraphDispatcher.allocPortSlots(4320, 9222, 0);
|
||||
assertEquals(1, clamp.size(), "k<1 → 至少 1 槽(防御,不返空)");
|
||||
assertEquals(4320, clamp.get(0)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,17 +57,30 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* <b>注</b>:SAA 路只有 token 计量,折¥是 follow-up F3({@code SaaGraphDispatcher} cost 整段省略,§6.3)→ 此处<b>不造假¥</b>。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>003-U1 量测 flags</b>:
|
||||
* <ul>
|
||||
* <li>{@code -Dsaa.e2e.sourceMode}(默认 {@code factory}):量哪条生成路——{@code factory}=iife 工厂旧路(现行字节零变基线);
|
||||
* {@code gamedef}=真结构化 gameDefinition 路(cutover ≥60% 量测对象)。<b>修发现②</b>:旧版未 setSaaSourceMode→量的是 iife 旧路非 gamedef,
|
||||
* 故量 gamedef 真基线<b>必须</b> {@code -Dsaa.e2e.sourceMode=gamedef};</li>
|
||||
* <li>{@code -Dsaa.e2e.concurrency}(默认 1):后台并发度 K——1=串行(生产字节零变基线);>1 多 job 并行真玩(各占错开端口对,
|
||||
* K=1 vs K=N verdict 对等校准延 003-U5 真跑期);</li>
|
||||
* <li>{@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)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>运行(mini-desktop,game-runtime 有 node+esbuild+Chrome、端口 4320/9222 空)</b>:
|
||||
* <pre>
|
||||
* // 基线量测(仅打印成功率 + 存在性断言;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
|
||||
* </pre>
|
||||
*
|
||||
* @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();
|
||||
|
||||
@ -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 连续对话历史保真<b>纯单元</b>(无网络/无 harness,确定性):用假 {@link ChatModel}
|
||||
* (记录收到的每个 {@link Prompt})驱动 {@link SaaStudioNodes#generateNode}(<b>anthropic 路 history 开</b>)+
|
||||
* {@link SaaStudioNodes#repairNode} 连续多轮,坐实 U2(创始人定·简化为 text 历史):
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>历史 append 非重置</b>:第二轮起 prompt 含上一轮答案文本 = 连续对话救场(非每轮全新 [system,user]);</li>
|
||||
* <li><b>历史只留答案文本、丢 thinking 块(P1-2)</b>:历史里<b>绝无</b>带 signature 的 thinking 块(stock anthropic 出站本就丢之);</li>
|
||||
* <li><b>有界截断</b>:深度 8 下历史按最近 K 轮留(不无界累积撑爆);</li>
|
||||
* <li><b>救场连续对话</b>:repair 轮的新 user = 失败 verdict/反馈(而非从头重出题面);</li>
|
||||
* <li><b>失败路守严格交替(P1-1)</b>:generate 全档失败→repair→下一轮,历史/wire <b>无连续 user</b>(Anthropic API 红线)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>openai 路对照</b>: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<Prompt> 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<Prompt> 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<String, Object> init) {
|
||||
OverAllState s = new OverAllState(init == null ? new HashMap<>() : new HashMap<>(init));
|
||||
Map<String, KeyStrategy> 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<String, Object> partial = node.apply(s);
|
||||
s.updateState(partial);
|
||||
}
|
||||
|
||||
/** 取 anthropic 路 gamedef 历史(List<Message>);缺则空 list。 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Message> gamedefHistory(OverAllState s) {
|
||||
return (List<Message>) s.value(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF).orElse(new ArrayList<Message>());
|
||||
}
|
||||
|
||||
/** 建一个 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 非重置 + <b>历史只留答案文本(无 thinking 块)</b> + 救场连续对话。
|
||||
*/
|
||||
@Test
|
||||
void multi_round_appends_text_only_history_at_depth_8() throws Exception {
|
||||
RecordingChatModel m = new RecordingChatModel();
|
||||
NodeAction gen = gamedefGenerateNodeHistoryOn(m);
|
||||
|
||||
Map<String, Object> 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<Message> 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<String, Object> 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 失败路回归门,<b>本次新增</b>):generate <b>全档失败</b>(r==null)→ repair → 下一轮 generate 成功——
|
||||
* 历史<b>无连续 user</b>、且下一轮成功 generate 下发的 wire 消息<b>严格交替</b>(user/assistant),不触发 Anthropic 400。
|
||||
*
|
||||
* <p><b>这是 P1-1 的回归门</b>:修前失败路 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<String, Object> 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<Message> 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<Message> 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<String, Object> 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<Message> 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<Message> 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<Message> 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<Message> 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);
|
||||
}
|
||||
}
|
||||
@ -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 <b>P0-5 命门</b>:连续对话历史键经 SAA 生产 checkpoint 序列化器<b>往返保真</b>的
|
||||
* <b>模型无关单测</b>(纯逻辑、<b>无网络/无 key/无 DB</b>,普通 {@code mvn test} 即跑)。
|
||||
*
|
||||
* <p><b>U2 创始人决策(简化为 text 历史)</b>:历史只留<b>答案文本块</b>(丢 thinking 块)——stock spring-ai-anthropic
|
||||
* 1.1.2 出站丢弃 thinking 块,留之无益。故本测试验「纯 text 答案历史({@code List<Message>},无 thinking 块)」往返保真,
|
||||
* <b>不再</b>验 thinking/signature 往返(历史里本就不存 thinking 块)。
|
||||
*
|
||||
* <p><b>为何这是命门</b>:e2e harness 用 {@code saaCheckpointEnabled=false}+dataSource=null(量测从不触发序列化路),
|
||||
* 但 staging 默认 {@code saaCheckpointEnabled=true} → 首次 checkpoint 写/续跑才序列化 {@link SaaStudioNodes#K_MSG_HISTORY_GAMEDEF}
|
||||
* 这个历史键。若 {@code List<Message>} 经 {@link SpringAIJacksonStateSerializer} 往返<b>退化成 {@code List<LinkedHashMap>}</b>
|
||||
* (丢元素类型),绿门后会<b>在生产炸</b>(回放给 AnthropicChatModel)。本测试在<b>生产同款序列化器</b>上强制
|
||||
* {@code dataToBytes→dataFromBytes} 往返,坐实:历史仍是<b>类型化 {@code List<Message>}</b>、文本完整、可再下发。
|
||||
*
|
||||
* @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<Message>},无 thinking 块)经生产序列化器 {@code dataToBytes→dataFromBytes}
|
||||
* 往返后——<b>仍是类型化 {@code List<Message>}</b>(非退化 {@code List<LinkedHashMap>}),user/答案文本完整,且能再构
|
||||
* Prompt 下发(assertNotNull)。修前(无此键登记/无往返保真)= 在 staging checkpoint 开时炸。
|
||||
*/
|
||||
@Test
|
||||
void msg_history_list_of_messages_roundtrips_as_typed_text_history() throws Exception {
|
||||
// 构一轮 U2 历史形:user 轮 + 答案块(纯文本,无 thinking 块)。
|
||||
List<Message> history = new ArrayList<>();
|
||||
history.add(new UserMessage("请实现一个 Flappy 游戏"));
|
||||
history.add(answerMsg("```js\nexport default function createGame(){}\n```"));
|
||||
|
||||
// 整 state map(历史键在内)经生产序列化器往返(与 MysqlSaver 序列化整 OverAllState 同路)。
|
||||
Map<String, Object> 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<String, Object> restored = ser.dataFromBytes(bytes);
|
||||
|
||||
// ① 容器仍是 List。
|
||||
Object back = restored.get(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF);
|
||||
assertInstanceOf(List.class, back, "历史键往返后必须仍是 List");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> backList = (List<Object>) 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<Message> 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 历史往返后<b>长度与顺序逐位一致</b>——续跑 checkpoint 后历史不丢轮、不错位。
|
||||
*/
|
||||
@Test
|
||||
void multi_turn_text_history_order_preserved_across_roundtrip() throws Exception {
|
||||
List<Message> 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<String, Object> state = new java.util.HashMap<>();
|
||||
state.put(SaaStudioNodes.K_MSG_HISTORY_GAMEDEF, history);
|
||||
|
||||
SpringAIJacksonStateSerializer ser = serializer();
|
||||
Map<String, Object> restored = ser.dataFromBytes(ser.dataToBytes(state));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object> back = (List<Object>) 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<Message> castMessages(List<Object> list) {
|
||||
return (List<Message>) (List<?>) list;
|
||||
}
|
||||
}
|
||||
@ -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)} 的<b>模型无关单测</b>(Plan A/U1)。
|
||||
*
|
||||
* <p>纯逻辑、<b>无网络</b>(构造假 {@link ChatResponse}),普通 {@code mvn test} 即跑。覆盖两形:
|
||||
* <ol>
|
||||
* <li><b>OpenAI 兼容路</b>(单 Generation):直接取 gen0,断<b>字节等价旧 {@code getResult().getText()} 行为</b>(回归保证);</li>
|
||||
* <li><b>Anthropic+thinking 路</b>(多 Generation):取「末个 metadata <b>不含 signature</b> 的 Generation」=答案,
|
||||
* 跳过含 signature 的 thinking 推理块(实测识别靠 {@code signature} 键存在性,不靠 {@code "thinking"} 键)。</li>
|
||||
* </ol>
|
||||
* 这把「取答案」逻辑与底层协议(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 列表返空串");
|
||||
}
|
||||
}
|
||||
@ -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 不应被误改");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)。
|
||||
#
|
||||
打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。
|
||||
贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。
|
||||
小恐龙跑酷:按键让恐龙跳过仙人掌和飞鸟,撞到障碍即结束,跑得越远分越高。
|
||||
点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。
|
||||
太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。
|
||||
打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。
|
||||
钢琴块:只点黑色方块别碰白块,节奏越来越快,点错或漏点即结束。
|
||||
像素鸟穿管:点击让小鸟扇翅上升,穿过一根根上下水管的缝隙,撞管或落地即结束。
|
||||
愤怒投石:拖拽瞄准弹射石块,砸倒所有目标木箱即通关,弹药用尽未清场则失败。
|
||||
太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。
|
||||
@ -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 + 瞄准发射)──
|
||||
泡泡射手:瞄准并发射泡泡,三个同色相连则消除,清空全部泡泡即胜。
|
||||
祖玛吐珠:旋转中心炮台向滚动的珠链射入同色珠子凑三消,珠链到达终点则失败。
|
||||
@ -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)。
|
||||
#
|
||||
# ── 挡板/接球类 ──
|
||||
保龄反弹:底部挡板把弹珠弹向顶部砖墙,敲碎全部砖块即胜,球落底则败。
|
||||
# ── 蛇/网格类 ──
|
||||
吃星变长:操控小蛇在网格内吃星星变长,撞墙或咬到自己则结束。
|
||||
# ── 接物类 ──
|
||||
接落叶:左右移动竹篮接住飘落的树叶,连漏五片则游戏结束。
|
||||
# ── 跳跃/躲避类 ──
|
||||
忍者翻墙:连续点击让忍者起跳越过迎面飞来的飞镖,被击中即结束。
|
||||
# ── 消除类 ──
|
||||
同色爆破:点击成片相连的同色气球一次性引爆,达到目标分数过关。
|
||||
# ── 射击类 ──
|
||||
炮台轰机:左右移动炮台射击编队俯冲的战机,被撞或漏过太多则失败。
|
||||
# ── 打地鼠/点击反应类 ──
|
||||
拍蚊子:屏幕四处乱飞的蚊子,快速点中拍死得分,限时结束统计战果。
|
||||
# ── 节奏类 ──
|
||||
鼓点连击:圆圈缩到判定环瞬间点击得分,连续错失三次则结束。
|
||||
# ── 平台类 ──
|
||||
攀岩登顶:交替左右蹬跳攀上一块块岩点抵达山顶,踩空坠落则失败。
|
||||
# ── 飞行/穿缝类 ──
|
||||
潜艇穿礁:点击上浮松手下沉,驾潜艇穿过上下错落的暗礁缝隙,触礁即结束。
|
||||
# ── 躲避/生存类 ──
|
||||
雷区穿行:上下左右移动小车躲开不断落下的地雷,坚持到倒计时归零即通关。
|
||||
# ── 弹力/打靶类 ──
|
||||
台球清台:拖动球杆瞄准击打母球,把台面所有彩球撞进袋即清台过关。
|
||||
@ -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/>/>=/</<=/==/in。返回逐条明细。 */
|
||||
function checkAssertion(a, s0, s1) {
|
||||
const p = a.path;
|
||||
@ -207,6 +218,7 @@ async function runDriver(cdp, driver, hashes) {
|
||||
if (driver.type === 'tap-targets') return runTapTargets(cdp, driver, hashes);
|
||||
if (driver.type === 'flap-to-gap') return runFlapToGap(cdp, driver, hashes);
|
||||
if (driver.type === 'seek-x') return runSeekX(cdp, driver, hashes);
|
||||
if (driver.type === 'seek-food') return runSeekFood(cdp, driver, hashes);
|
||||
if (driver.type === 'tap-pairs') return runTapPairs(cdp, driver, hashes);
|
||||
if (driver.type === 'key-cycle') return runKeyCycle(cdp, driver, hashes);
|
||||
if (driver.type === 'drag-aiming') return runDragAiming(cdp, driver, hashes);
|
||||
@ -308,6 +320,57 @@ async function runSeekX(cdp, driver, hashes) {
|
||||
return { drove, steps };
|
||||
}
|
||||
|
||||
/** type='seek-food':读蛇头 head.x/head.y 与食物 food.x/food.y,贪心选向(先消较大轴差)发方向键朝食物走;
|
||||
* 防 180° 反向(snake 反向键被运行时忽略)→记上一步方向、其反向时改走另一轴。把贪吃蛇的盲循环换成朝食物 steer
|
||||
* (类比 flap-to-gap 读 nextGap);零放水——只把"盲走"换"朝食物走",score/latch 仍照判。 */
|
||||
async function runSeekFood(cdp, driver, hashes) {
|
||||
const hxPath = driver.headXPath || 'head.x';
|
||||
const hyPath = driver.headYPath || 'head.y';
|
||||
const fxPath = driver.foodXPath || 'food.x';
|
||||
const fyPath = driver.foodYPath || 'food.y';
|
||||
const steps = driver.steps || 70;
|
||||
const stepMs = driver.stepMs != null ? driver.stepMs : 110;
|
||||
const scoreTarget = driver.scoreTarget != null ? driver.scoreTarget : 30; // 得分够即转终态阶段
|
||||
const OPP = { ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft', ArrowUp: 'ArrowDown', ArrowDown: 'ArrowUp' };
|
||||
let drove = 0, phx = null, phy = null, actualDir = null;
|
||||
// 阶段1:朝食物 steer 得分(防反向基于蛇头位移推断的实际方向)。
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const s = await readGameState(cdp);
|
||||
if (s && s.phase === 'gameover') return { drove, steps };
|
||||
if (s && typeof s.score === 'number' && s.score >= 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); })()');
|
||||
|
||||
@ -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 = [
|
||||
[/(?<![\w.])getEntity\s*\(/, '裸调 getEntity()(漏 rt. 前缀)→每帧 ReferenceError 夭折;须 rt.getEntity(...)'],
|
||||
[/(?<![\w.])(getEngine|getInput|restart)\s*\(/, '裸调引擎/宿主方法(漏 rt. 且 rt 面无此法)→ReferenceError;getEngine/getInput 不存在、重开由平台处理(游戏内勿写 restart)'],
|
||||
[/(?<![\w.])(addScore|setScore|randRange|randInt)\s*\(/, '裸调 rt 方法(漏 rt. 前缀)→ReferenceError;须 rt.addScore/setScore/randRange/randInt'],
|
||||
];
|
||||
|
||||
/** 扫一段 behavior.code 的玩法哑火坑;返回违规消息数组(空=过)。仅两族零误伤硬规则(self 泄漏见上方注释,暂不作硬门)。 */
|
||||
function scanGameplayPitfalls(code, label) {
|
||||
const out = [];
|
||||
if (typeof code !== 'string') return out;
|
||||
for (const [re, msg] of TAGS_SET_MISUSE) { if (re.test(code)) out.push(label + ' ' + msg); }
|
||||
for (const [re, msg] of BARE_RT_CALLS) { if (re.test(code)) out.push(label + ' ' + msg); }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量校验 + strip(产线容错)。
|
||||
* @param {object} sourceProject 源项目(可为完整 SourceProject,或直接是 gameDefinition)。
|
||||
@ -145,6 +174,7 @@ export function validateSourceProject(sourceProject) {
|
||||
continue;
|
||||
}
|
||||
errors.push(...scanLogic(code, 'behavior[' + id + ']', false));
|
||||
errors.push(...scanGameplayPitfalls(code, 'behavior[' + id + ']')); // Plan A·Phase 3:玩法哑火坑静态门
|
||||
}
|
||||
// rules:condition 须无副作用布尔表达式 + 静态扫描。
|
||||
for (const r of (Array.isArray(cleaned.rules) ? cleaned.rules : [])) {
|
||||
|
||||
53
game-runtime/src/host/gameplay-pitfalls.test.mjs
Normal file
53
game-runtime/src/host/gameplay-pitfalls.test.mjs
Normal file
@ -0,0 +1,53 @@
|
||||
// Plan A·Phase 3 坑门测试:验证 build-from-source.mjs 新增的「玩法哑火坑」静态门
|
||||
// ① 正例:三族坑(.tags Set 误用 / 漏 rt. 前缀裸调 / self 跨 behavior 泄漏)被拦
|
||||
// ② 零误伤铁律:对 3 个金标准过门纯净源(gd-breakout/tictactoe/simon)不得触任何坑门(否则会拦掉本可玩的游戏)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { validateSourceProject } from './build-from-source.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const WG1 = join(HERE, '../../games/_wg1-gen');
|
||||
|
||||
// 最小合法 gamedef + 注入一条待测 behavior code(隔离坑门,避免被无关结构错误干扰)。
|
||||
function gdWith(code) {
|
||||
return {
|
||||
components: [{ id: 'bg', kind: 'render', shape: 'fill', color: '#101028' }],
|
||||
entities: [{ id: 'p', transform: { position: { x: 0, y: 0 } }, components: ['bg'] }],
|
||||
behaviors: [{ id: 'b', trigger: 'update', code }],
|
||||
scenes: [{ id: 's', entityRefs: ['p'] }],
|
||||
rules: [],
|
||||
};
|
||||
}
|
||||
const errs = (code) => 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(' || '));
|
||||
});
|
||||
}
|
||||
@ -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 可读信号)。 */
|
||||
|
||||
@ -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()
|
||||
"""从模型输出抠最外层 {...}(先剥 <think> 推理块、再去 ```json 围栏/前后说明)。"""
|
||||
# 推理模型(M3)即便关了 thinking 偶仍内联 <think>…</think>;先整块剥除,避免抽到思维链里的花括号。
|
||||
s = re.sub(r"<think>.*?</think>", "", 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)
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user