Merge origin/dev/2.0.0(SAA 生成域 20 提交)—— 设计文档域化重构 8 提交并入

This commit is contained in:
lili 2026-06-20 21:16:28 -07:00
commit 77f67fdb13
31 changed files with 3058 additions and 126 deletions

View File

@ -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 分离 blockcontent=[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 无界,单局 2073min+ 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 1driver 门失败 rc=1 会**反向否决**"只认客观门 AE"的解耦 → 空跑 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 哑火×4thinking 修哑火 3/4证"M3 需开思考才接对运动逻辑";残留 #7 打地鼠=天生无运动点击类)。**SAA 机制本就 ok质量杠杆=thinking+协议**。按创始人以 **n=12/91.7% 宣告达标**(放宽原 plan003 n≥30 门);代价 ~10x 慢+1.6x token。**先证伪了"模型天花板"——便宜模型经"对的协议+开思考"可达九成**(推翻 L85「真基线 60%/天花板存疑」的悲观面)。
---

View 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"}]}

File diff suppressed because one or more lines are too long

View 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"}]}

View 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.md003-U1/U2 骨架)+ docs/brainstorms/2026-06-18-生成失败根因分析-requirements.mdv3「用对 M3」方向 SoT+ docs/brainstorms/2026-06-19-plan003-phase1验收闭环-requirements.mdR1/R2/T1/T2 done 判据)
review: "已过双评审AGENTS.md §6 条款 82026-06-19Opus 对抗式 + 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 Agen-done**Plan Bcore-done= `docs/plans/2026-06-19-001-feat-coredone-验收闭环执行-plan.md`,另一 sessionworktree `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/R11U7 的 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-4Anthropic 多 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-5checkpoint 序列化潜伏坑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-1measure-first 隐藏晚长杆)**U-C 是最难/最未证单元,若闸控到晚期才建=时间压力下建最不确定单元。增 U5 并行 U-C 廉价可行性 spike + 具体触发线。(见 KTD 5 / U5 / U6 / 待确认)
- **P1-2≥80% 标的未验 + brief Goodhart**80% 是 iife 路继承数、gamedef 真基线 n=0brief 集自控且偏易(`SaaFullGraphE2eTest.java:90` 注释自陈"偏向 harness 支持品类")。增 基线定标复核 + brief 集冻结/钉 hash + **留出集**final R1 用未调优集)。(见 R1 / U3 / U5 / 待确认)
- **P1-3thinking 历史无界增长 vs 保真张力)**append 全 thinking × 8 救场轮 → token 爆 + 每轮复发计费;窗口截断又毁 thinking/顺序。增 thinking-aware 有界截断策略 + max-repair 深度8测试 + 并发隔离测试。(见 KTD 2 / U2
- **P1-4KeyStrategy 全量 ReplaceStrategyfeasibility**`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-6flip 须同暴露 saa-model-protocolfeasibility P2-4**U7 只暴露 dispatcher+sourceMode → staging 仍跑 openai 默认协议 ≠ U5 量测的 anthropic。U7 加暴露 `saa-model-protocol` + R1 证据记在用协议(量==发)。(见 U7 / R2
- **P1-7T1 回退是 flip 硬前置,非可延 TODO**:回退路效力依赖 Plan B 域代码T1 失败 → step-2 flip **阻塞**(跨 Plan 关键依赖)。(见 U7 / R-fallback
- **P2**yaml 合并 config-presence 断言U7client 侧 `budget<maxTokens` fail-fastU1确认无 `spring-ai-autoconfigure-model-anthropic` 传递U1spike 实证 1.1.2 多 Generation 形U1`llm-base` 仅需覆盖才加 yaml去噪U7
---
## 摘要
把 SAA **真结构化gameDefinition生成路**从「用错 M3 + 历史不留 + 救场从头重生成 + 整图串行不可量」推到「**用对 agentic 的 M3Anthropic 协议 + 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
**执行姿势 = 先量真基线、数据驱动收敛**:用对 M3U-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-donePlan B R9/R11003-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-19Plan 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` 原生 thinkingsignature 键标识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`(多 Generationthinking 开时 **block0=thinking**。**必遍历 `resp.getResults()`**、答案取 text-role Generation、历史存有序 `List<AssistantMessage>`thinking 先序)。`getMetadata()``thinking`/`signature`(皆 String回读。
- **序列化P0-5**`AssistantMessage``SpringAIJacksonStateSerializer` 可往返,但 metadata 写为无类型 JSON、读回为 Mapthinking/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/signaturecheckpoint-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-macM1 Pro 8 核/32G、负载~6.6K≈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同组 n10 <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-hashgit 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 clientU-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=gamedefmini-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 client003-U1 U-A
- **Goal**:接入"经 Anthropic `/v1/messages` 调 M3、thinking 开+干净分离、多 Generation 正确重组"的新 ChatModelflag 旁挂(默认旧 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
- **Approachspike 实测定死)**`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多 Generationflag=anthropic 单条 `.call()``getResults().size()≥2`text-role Generation 含答案、thinking Generation 含 `getMetadata().thinking` 非空。
- 认证头spike 实测):默认 x-api-key → `getResults().size()==2` 原生 thinking block含 signature genBearer → 内联 `<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**R1003-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-sourcegamedef非 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 廉价 spikeP1-1**:与本环并行跑 throwaway U-C 探针M3 单轮工具循环可行性),去 R-6 险。
5. **Workflow-Opus 分析**6c6gopt-in每失败款一 Opus agent 根因 → 综合排序优化项。
6. **优化落地**:改单变量、同组 n≥10 A/B。
7. **重测 → 循环**至 ≥80%**留出集 final 测,非调优集**≥2 轮仍 <80% 且根因指向需自纠 U6天花板 退路
- **Patterns**:根因 v3「单变量 A/B 同组 n≥10」效率策略 #8Workflow 脚本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**R1003-U1 U-Cmeasure-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**R2T1回退硬前置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-donelive `: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+anthropiccheckpoint 开)一句话生成→入 feed→真玩 ≥1 款 CDP。
- asset 烟测P0-2step-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
- **R1gen-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.5hA/B 多轮累积mini-desktop 权威单实例串行排队。缓解 = Mac 快走查先筛、Workflow-Opus 减盲改轮。
- **R-6 M3 工具-use 可靠性未实证**U5 并行 U-C 廉价 spike 先证可行P1-1
- **R-7 ≥80% 可达性 + 标的未验P1-2**gamedef 真基线 n=0n=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 隐藏序列化路 → stagingcheckpoint 默认开)才炸。缓解 = 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` 旗/并发/sourceModePlan B **消费**R4→ 改动严格 additive。
- `application-staging.yaml`:本档加 dispatcher/saa-source-mode/saa-model-protocolPlan 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/9222flock 单实例)。
- **依赖**mini-desktop权威九门 e2ex86 prod-同构)+ lili-mac快走查M1 Pro 8 核/32G+ 6c6gWorkflow 编排)+ `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%R1measure→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 同步。

View File

@ -0,0 +1,61 @@
# 关闭九门 driver 判定 · 全面参考 OpenGame · Phase 04 决策记录
> **状态**:✅ 主线达成(2026-06-20rc 门修使 Phase 1 真生效 + 改 anthropic 流式 thinking → gamedef e2e **91.7%**、按 n=12 达标详见末节「§7 结果与修正」)。execution-spec/Codex×Opus 双评审仍为 follow-up。
> **决策人 / 日期**:创始人 / 2026-06-20。
> **一句话**:把"逐品类手写 driver 自动试玩"从生成硬门**降级**,生成环改只认**客观健康门(build-health, AF)**,验证全面对标 **OpenGame**(方法蓝图,非 Phaser 运行时)。
---
## 1. 决策内容
1. **关闭九门 driver 判定对生成的绑架**:生成环(SAA 管线)硬门 = 客观健康门 AF;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 率 ~8090%**(精确值待 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 04
| Phase | 内容 | 状态 |
|---|---|---|
| 0 | 去混杂基线(挖现有 verdict 样本:AF 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]`,但 AE 全过=游戏本身可跑可动)。"真用引擎/有特效"语义改由 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 04 **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 门耦合 bugPhase 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 无界、单局 2073min上线要治Phase 2/3/4模板 / 活调试 / VLM 门)仍可叠加作进一步增益。

View File

@ -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 2Template-First gamedef 骨架进 classpathSaaSkeletons 据此装载注入生成 prompt -->
<include>agent-loop/skeletons/*.json</include>
</includes>
</resource>
</resources>

View File

@ -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反向否决客观 passPhase 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 中出现的门只对客观门要求 passdriver 门跳过 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 中途崩钻空子误判 PASSplay.cdp.cjs runDriver( seek-food)
// D_render/E_live 之前执行且未 try/catchconc=12 CDP 抖动抛错 catch 只补 A_bootverdict 只剩
// 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));

View File

@ -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&gt;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-U1K=1 时是单线程池与改造前 {@code newSingleThreadExecutor}
* 逐字节等价天然串行K&gt;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-U1K=1 时等价改造前 {@code Semaphore(1)}严格串行
* K&gt;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 抽取专用 mapperW-G1 组B · 闭合 SAA split-braindispatcher 原无 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;
}
});
// 真玩门信号量容量=KK=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=KK=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&gt;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-1apiKey 取执行器配置密钥经环境变量注入
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build();
// checkpointstep5开关开 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勿用 stripV1Suffixspike 实测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-1apiKey 经配置注入openAiFactory 逐字调 model(api,...)字节等价改造前
String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase());
// Plan A U5openai 路同 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 U5maxtoken 大余量saaForceMaxTokens>0 包装 mf 覆盖各角色 maxTokensM3+thinking 思考+答案需大余量防截断doc M3 128K0=各角色默认字节零变
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-19saaForceModel 非空 全角色统一该模型 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 只用 M3forceModel={}(全 11 角色统一、无跨家回退)", forceModel.trim());
}
// 固定架构 B2透传救场阶梯 stage2 额外救场轮saaStage2ExtraRepairs factory-based build 重载recursionLimit 5+3 重算
// Plan A/U2historyEnabled=anthropicanthropic generate 跨轮保留 thinking 历史 + 连续对话救场openai 路关字节零变
compiledGraph = SaaStudioGraph.build(mf, models, gameRuntimeRoot,
properties.getSaaMaxRepairs(), properties.getSaaMaxPlayerRounds(),
properties.getSaaStage2ExtraRepairs(),
properties.getSaaSourceMode(), // plan U3factory(默认,iife) | gamedef(真结构化源)
anthropic, // plan U2historyEnabled 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.assetsDifyCallbackTxService.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 路游戏逻辑全在 engineBundlegameConfig 仍不可空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>>() {}));
}
}
// 可追溯日志接通了几个源 assets0 表示源里确无对象项等价历史空集不告警
log.info("[saa-dispatch] 包级 assets 接通源 assets[] 共 {} 个(产消接通,兑现 engine-E2/E3 消费侧 TODOtraceId={}", 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&gt;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()}默认 4320null 兜底 4320
* @param cdpBase cdpPort 基址{@link AigcExecutorProperties#getSaaCdpPortBase()}默认 9222null 兜底 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;
}
}

View File

@ -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-119system 注入 few-shotuser 末尾回喂上轮失败原因全量重出非 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 1b2026-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 永远 undefinedNaN比较恒 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.randomDateperformance(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这是判"机制是否真点亮"的命门别一律给 nonea技巧类挡板接球/接物玩法=连续控制体随手指水平移动去拦截`driver.type="paddle-intercept"``ballPath`=**球的水平 x 坐标字段**挡板水平移动去对齐它几乎总是 `ball.x`**绝不要填 ball.y**`paddleY`=挡板所在 yb离散点击目标类井字棋/五子棋/打地鼠/翻牌/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/>/>=/</<=/==/inpath 用你让代码导出的字段名须与 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 固定架构新增B2execution §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/buildGameDefMessagesopenai ·每轮全新 [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 userfalse=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 2anthropic 路首轮注骨架救场轮走 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 产物=JSONfalse=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 09 却被 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_repairA-5 加强
* 严格 Jackson 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格

View File

@ -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 2Template-First gamedef 骨架2026-06-20OpenGame +10.1 杠杆
* 给生成的 generate 角色按 archetype 注入一份已过九门的纯净 gameDefinition 骨架让模型在骨架上改成题面玩法
* 而非从零写降低rt 面接线 / 运动逻辑从零写错的概率哑火静态游戏=跨款最大失败簇
*
* <p><b>只用 3 个金标准过门纯净源</b>gd-breakout/tictactoe/simon verdict.pass=true + rt 面纯净 + Phase3 坑门
* (tick/input) profile 相近映射到 archetype无近似骨架的 archetypemerge/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 {
// 实时动作类 breakoutrealtime + vx/vy 物理 + pointer 控制 + 碰撞 + remaining 下降=进展
ARCHETYPE_SOURCE.put("generic", "breakout");
ARCHETYPE_SOURCE.put("dodge", "breakout");
ARCHETYPE_SOURCE.put("runner", "breakout");
ARCHETYPE_SOURCE.put("bubble", "breakout");
// 离散点选类 tictactoeclickable 网格 + 离散选 + rt.addScore 进展
ARCHETYPE_SOURCE.put("clicker", "tictactoe");
ARCHETYPE_SOURCE.put("match3", "tictactoe");
// 回合序列类 simonclickable + 回合 + 状态实体存 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 JSONflag / 无映射 / 资源缺失 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);
}
}

View File

@ -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-19bake-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 U5macOS 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用对 M3flag 旁挂的缝角色名+采样面 {@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 最大补全 tokensper-role code/fix=16000player_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 大输出整体生成 &gt;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用对 M3thinking 原生分离
* 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 协议 baseUrlnew-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 协议 baseUrlhost /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) 的小判定角色则关 thinkingP0-2
* @return Anthropic 角色模型
*/
public static AnthropicChatModel anthropicModel(String anthropicBase, String apiKey, String modelName,
Double temperature, int maxTokens, int thinkingBudget) {
// 代理旁路 + 连接/读超时macOS/容器系统代理clash 会把 Tailscale IP 走代理502NO_PROXY 直连 new-api
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory();
rf.setProxy(Proxy.NO_PROXY);
rf.setConnectTimeout(10_000);
// Plan A U5M3+thinking 阻塞 .call() 单次(16K content+8K thinking24K token) >120s 读超时;
// anthropic 路恒阻塞(流式 thinking bug #4407 不用),故读超时放宽至 600s(单局总超时 900s 兜底)
rf.setReadTimeout(600_000);
// 流式收取避读超时创始人 2026-06-20B 实测阻塞整取在 adaptive+256k 下大面积 SocketTimeout慢局 20-31min
// 流式走 WebClient须同 openAiApiNoProxy 注入 NO_PROXY webClient否则 macOS clash Tailscale IP502
// 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
} // elsemaxTokens 太小的判定类角色不开 thinking避免 budgetmaxTokens 判定/审查无需推理
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同一 .envNEWAPI_BASE_URL=.../v1两侧通用须 Java 侧剥之
@ -201,7 +413,7 @@ public final class SaaStudioGraph {
* <b>P0-4</b>deterministic 路本期 fail-loud END sourceProject 真构建管线待 B7+引擎线 E2不静默走会忽略
* sourceProject 的旧 buildregenerate-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 工厂建 ChatModelopenai 默认路字节等价anthropic flag 旁挂==
// designPython config.build_model 不传 temperature 服务端默认 temperature=null不下发
// code/fix_client.chat temperature=0.0max_tokens=16000确定性产出
// player_textstudio.py:167 max_tokens=4000temperature=0.3
// player_visionstudio.py:160 max_tokens=900temperature=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);
// 固定架构新增两角色B2classify物理优先分类温度低求稳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))) // U2anthropic 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 默认 falseopenai / 不关心历史的回归测试现行行为字节不变
// anthropic 路经 10 参重载传 historyEnabled=truedispatcher 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));

View File

@ -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 一经下发即被拍平成 textthinking interleaved
// 保留名不副实改只保留上一版响应文本连续对话救场价值不变=模型看上一版答案 + 失败 verdict 增量修不回灌推理白话
// 跨轮保留进 List<Message>system 不入历史·每轮现拼 user + assistant 答案块入历史
// 存储策略ReplaceStrategy + 节点内 read-append-write对齐 appendAttempt勿进全量 ReplaceStrategy 误判P1-4
// 可序列化 SpringAIJacksonStateSerializer 往返保 List<AssistantMessage> 类型P0-5SaaHistorySerdeTest
// 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 SAAM3+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.2thinking 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>size2一个是 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 "";
}
// GenerationOpenAI 兼容路 / Anthropic thinking 等价旧 getResult().getText()回归保证
if (gens.size() == 1) {
return textOf(gens.get(0));
}
// GenerationAnthropic+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 GenerationOpenAI 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 块在答案块前
* Anthropicassistant 轮以 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-5SaaHistorySerdeTest
* 极端非法值理论不可达防御性返空
*/
@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 400user/assistant 必须严格交替
*
* @param system 本路 systemgamedef=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-2stock
* 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 {
// messagesgamedef 路用 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; // 现行两元 promptopenai / 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) {
// gamedefopenai 题面 + 品类提示 + 回喂regenerate-module gamedef 路退化为带反馈整源重生成确定性点改走 modify 节点
msgs = SaaPrompts.buildGameDefMessages(enriched, feedback, archetype);
} else if (regeneratePath) {
// P0-2factory 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-2factory regenerate-module 路真消费 base ·informed-by-base 重生成否则普通 buildMessagesprompt 字节零变
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);
}
// 普通 buildMessagesfactory 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) {

View File

@ -197,6 +197,78 @@ public class AigcExecutorProperties {
*/
private String saaSourceMode = "factory";
/**
* SAA 图后台执行并发度 Kplan 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&gt;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>&lt;1 的误配按 1 兜底不崩退回单飞真跑 K 路并发校准verdict 对等 + 结构隔离
* 需九门环境 003-U5 执行期本字段先就位 + 纯端口分配逻辑可单测
*/
private Integer saaConcurrency = 1;
/**
* SAA 真玩 HTTP 端口基址端口池槽 0 playPortplan 003-U1 i playPort = {@code base + 2*i}步长 2 留头寸
* 默认 4320与改造前 buildInputs 常量serve-and-play.sh 默认逐字一致 K=1 字节零变内网地址按项目规则可入库
*/
private Integer saaPlayPortBase = 4320;
/**
* SAA 真玩 CDP 端口基址端口池槽 0 cdpPortplan 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-19bake-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 全角色强制 maxTokensPlan A U5M3+thinking 思考+答案需大余量防截断doc M3 128K=131072
* &gt;0 包装 RoleModelFactory 覆盖各角色 maxTokens 为此值<b>0(默认)= 各角色默认字节零变</b> dispatcher=saa 生效
*/
private Integer saaForceMaxTokens = 0;
/**
* SAA openai 兼容路读超时 msPlan A U5M3+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用对 M3flag 旁挂默认 openai 字节零变=====
/**
* SAA 图角色模型走的上游协议Plan A/U1plan 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 原生分离
* M3OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出根因根因 v3用对 M3
* <b> dispatcher=saa 生效</b>openai dispatcher 不构造 Anthropic 客户端U1 旁挂零回归
*/
private String saaModelProtocol = "openai";
/**
* SAA Anthropic 协议 baseUrlsaaModelProtocol=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 HTMLSpring AI 解析失败假挂起<b>亦不复用 OpenAI 路的 stripV1Suffix</b>
* 那是 OpenAI 兼容口径的坑 Anthropic 协议无关内网地址按项目规则可入库
*/
private String saaAnthropicBase = "http://100.64.0.8:3000";
/**
* SAA Anthropic 协议 thinking 预算 tokenssaaModelProtocol=anthropic 专用 thinking 时的 budget_tokens
* <b>Anthropic 协议硬约束 {@code budget_tokens < max_tokens}</b>client fail-fast 断言勿信网关宽松否则重演原截断 bug
* 默认 8192< code/fix 角色 maxTokens=16000openai 协议下本字段不读
*/
private Integer saaThinkingBudget = 8192;
/**
* 阈值关系铁律校验HJ-AGENT-LOOP-EXEC-002 §5.2 精确式执行器装配时调用误配置 fail-fast
*

View File

@ -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-2assemble 给判定类小角色硬编码小 {@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 造梦AIPlan A · U1 · P0-2 专项
*/
class SaaAnthropicAssembleTest {
/** dummy Anthropic 协议 baseUrlhost 根口径;只构造 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 角色 ChatModeldesignNode/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=900classify=1000narrative=2000playerText=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<16000thinking 开)");
assertNotNull(gen, "生成角色应构造出非空 client");
}
}

View File

@ -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:7897surefire 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 造梦AIPlan 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 必须 < maxTokensAnthropic 协议约束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_PROXYbudget<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 实测 size2thinking + 答案
assertTrue(genCount >= 2,
"x-api-key + thinking ENABLED 应返 ≥2 Generationthinking + 答案);实得 " + 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");
}
}

View File

@ -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 默认 = 1K=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 端口常量)");
}
// normalizeConcurrencyK 规整null/<1 1
@Test
@DisplayName("normalizeConcurrencynull/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("allocPortSlotsK=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("allocPortSlotsK 槽 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("allocPortSlotsnull 基址兜底 4320/9222k<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]);
}
}

View File

@ -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后台并发度 K1=串行生产字节零变基线&gt;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-desktopgame-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 造梦AIU7 集成顶石003-U1 验收门工具化
* @author 造梦AIU7 集成顶石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-6flip 须同切协议的量测面孪生-Dsaa.e2e.protocol 默认 openai字节零变基线
// anthropic 真基线R1 硬要求 ==在用协议=anthropic须显式 -Dsaa.e2e.protocol=anthropicbase/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 U5M3+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 U1anthropic=用对 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 U5openai 路读超时M3+thinking 量测 600000
if (forceMaxTokens != null) props.setSaaForceMaxTokens(forceMaxTokens); // Plan A U5全角色 maxTokensM3+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();

View File

@ -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 造梦AIPlan 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) {
// IOisRetryable=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/U2anthropic 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);
// 首轮 generaterole=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++) {
// 模拟失败 feedbackH_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 signatureU2 只留答案文本
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 悬空 userorderedTurn=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-1generate 全档失败后历史应仍为空(不入悬空 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 下发的 promptflaky 仅成功调用记 promptuser/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);
}
}

View File

@ -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 dataToBytesdataFromBytes} 往返坐实历史仍是<b>类型化 {@code List<Message>}</b>文本完整可再下发
*
* @author 造梦AIPlan A · U2 · P0-5 命门 · 简化为 text 历史
*/
class SaaHistorySerdeTest {
/** 建一条答案块 AssistantMessageU2 历史只存答案块,无 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 dataToBytesdataFromBytes}
* 往返后<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<>();
// 轮1user 答案
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;
}
}

View File

@ -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 造梦AIPlan 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+thinkinggen#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 列表返空串");
}
}

View File

@ -165,4 +165,95 @@ class SaaStudioNodesRegressionTest {
assertTrue(SaaPrompts.hasGatespecBlock("x\n```gatespec\n{bad json}\n```"));
assertFalse(SaaPrompts.hasGatespecBlock("没有任何 gatespec 块的设计稿"));
}
// ====================== 003-U18/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 各一最小 gatespecdriver.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-pairssteps/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-cyclekeys 数组逐项保真顺序/内容不被规整
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-fireshipPath/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-aimingorigin 嵌套对象 + 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 不应被误改");
}
}

View File

@ -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
#
打砖块:挡板接球把上方砖块全部消除即胜,掉球则负。
贪吃蛇:操控小蛇吃食物变长,撞到自己身体或墙壁则游戏结束。
小恐龙跑酷:按键让恐龙跳过仙人掌和飞鸟,撞到障碍即结束,跑得越远分越高。
点击消除:点击三个或以上相连的同色方块把它们消掉,消满目标分数过关。
太空射击:飞船左右移动并发射子弹击落持续下降的敌机,被敌机撞到则失败。
打地鼠:地鼠随机从洞口冒头,点击敲中得分,限时结束时分数越高越好。
钢琴块:只点黑色方块别碰白块,节奏越来越快,点错或漏点即结束。
像素鸟穿管:点击让小鸟扇翅上升,穿过一根根上下水管的缝隙,撞管或落地即结束。
愤怒投石:拖拽瞄准弹射石块,砸倒所有目标木箱即通关,弹药用尽未清场则失败。
太空躲陨石:上下左右移动飞船躲避飞来的陨石,坚持到计时归零即通关。

View File

@ -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 + 瞄准发射)──
泡泡射手:瞄准并发射泡泡,三个同色相连则消除,清空全部泡泡即胜。
祖玛吐珠:旋转中心炮台向滚动的珠链射入同色珠子凑三消,珠链到达终点则失败。

View File

@ -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
#
# ── 挡板/接球类 ──
保龄反弹:底部挡板把弹珠弹向顶部砖墙,敲碎全部砖块即胜,球落底则败。
# ── 蛇/网格类 ──
吃星变长:操控小蛇在网格内吃星星变长,撞墙或咬到自己则结束。
# ── 接物类 ──
接落叶:左右移动竹篮接住飘落的树叶,连漏五片则游戏结束。
# ── 跳跃/躲避类 ──
忍者翻墙:连续点击让忍者起跳越过迎面飞来的飞镖,被击中即结束。
# ── 消除类 ──
同色爆破:点击成片相连的同色气球一次性引爆,达到目标分数过关。
# ── 射击类 ──
炮台轰机:左右移动炮台射击编队俯冲的战机,被撞或漏过太多则失败。
# ── 打地鼠/点击反应类 ──
拍蚊子:屏幕四处乱飞的蚊子,快速点中拍死得分,限时结束统计战果。
# ── 节奏类 ──
鼓点连击:圆圈缩到判定环瞬间点击得分,连续错失三次则结束。
# ── 平台类 ──
攀岩登顶:交替左右蹬跳攀上一块块岩点抵达山顶,踩空坠落则失败。
# ── 飞行/穿缝类 ──
潜艇穿礁:点击上浮松手下沉,驾潜艇穿过上下错落的暗礁缝隙,触礁即结束。
# ── 躲避/生存类 ──
雷区穿行:上下左右移动小车躲开不断落下的地雷,坚持到倒计时归零即通关。
# ── 弹力/打靶类 ──
台球清台:拖动球杆瞄准击打母球,把台面所有彩球撞进袋即清台过关。

View File

@ -174,6 +174,17 @@ async function readGameState(cdp) {
);
}
/** Phase 4 E_live 2026-06-20score/remaining =
* 用于给 E_live 状态活性证据无运动点击类(打地鼠等)画面只在命中瞬间变像素采样易错过假阴
* 但其 score/remaining 会真变真冻屏 score/remaining 不变 falseE_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=玩后态。opincreased/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→gameoversnake 真终态=死亡),满足 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。受控面只给原始 pointerswipe/拖拽手势须自合成(愤怒小鸟蓄力等)。 */
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); })()');

View File

@ -86,6 +86,35 @@ function behaviorCode(b) {
return null;
}
/* Plan A·Phase 32026-06-20 GAMEDEF_SYSTEM prompt
* 只码化零误伤·高价值两族.tags Set 误用数组法/下标/length必每帧抛②漏 rt. 前缀的裸引擎调 ReferenceError
* LOGIC_BANS 不拦 getEngine/getInput/restart 的洞含此坑的源装配能过却运行期夭折gd-runner/whack/tetris 即此
* 命中 validationErrorsrepair真玩前就修省一轮 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 面无此法→ReferenceErrorgetEngine/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玩法哑火坑静态门
}
// rulescondition 须无副作用布尔表达式 + 静态扫描。
for (const r of (Array.isArray(cleaned.rules) ? cleaned.rules : [])) {

View 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(' || '));
});
}

View File

@ -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-20w/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 可读信号)。 */

View File

@ -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=Truestdout 重定向到文件时 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)

View File

@ -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_bodyMiniMax 系走官方 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兼容两套字段取 maxU4/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 处截断、永远到不了 JSONfinish_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]