feat(saa): Python agentic 生成系统迁移到 SAA(Spring AI Alibaba)裸图 spike (HJ-AGI-002)

全图 design->generate->validate->[repair环]->scaffold->build->play(九门)->player->emit;build/play 复用既有 harness 子进程。
SAA 完整框架集接入 aigc-server(graph-core/agent-framework/builtin-nodes/observation/openai,3 BOM;Boot3.5.14/Redisson4.4.0 保住)。
双评审(Opus+Codex)9 项对比前必修:LLM超时重试/ProcessBuilder超时修复/采样面对齐/playerFeedback清串味/gatespec强解析/problems归一/recursionLimit兜底/cost/端口参数化。
首轮对比 breakout/whack/flappy:SAA 通过率 2/3 >= Python 1/3。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-16 07:55:27 +08:00
parent b780323e53
commit 2b59f53f44
17 changed files with 3840 additions and 1 deletions

13
SAA-INTEGRATION-TODO.md Normal file
View File

@ -0,0 +1,13 @@
# SAA 接入·依赖统一 TODO (HJ-AGI-002 spike)
日期 2026-06-15 · 分支 spike/saa-int · 机器 mini-desktop
现状: dependency:tree BUILD SUCCESS;Boot 3.5.14 / Redisson 4.4.0 / SCA 2025.0.0.0 / jackson 2.21.2 全保住,零冲突。
框架集(完整推荐): graph-core + agent-framework + starter-graph-observation + starter-builtin-nodes(钉1.1.2.2) + spring-ai-starter-model-openai;经 spring-ai-bom:1.1.2 + spring-ai-alibaba-bom:1.1.2.2 + extensions-bom:1.1.2.2 管理。
## 待统一(unify)TODO
1. builtin-nodes 不在 SAA BOM → 现显式钉 1.1.2.2;待 BOM 纳入后改随 BOM,或提到 huijing-dependencies 用属性统一管理。
2. SAA 编译基线 Boot 3.5.8 vs 本项目 3.5.14(已保住);若后续出 API 不兼容,再评估统一 Boot/SAA 版本。
3. SAA redisson 3.x(optional,未拉) vs 项目 4.4.0;若启用 RedisSaver 需实测 redisson 4.x 兼容 RMap/RBucket/RLock。
4. SAA 公共仓最新已到 1.1.2.3(本 spike 钉 1.1.2.2 对齐已分析源码);稳定后评估 bump 1.1.2.3。
5. 三 BOM import 现放 aigc-server 局部;若多模块用 SAA,提到 huijing-dependencies 统一 import。
6. builtin-nodes 连带拉入 document-parser-tika/markdown/bshtml/yaml,体积增;如不需可 exclusion。

View File

@ -0,0 +1,216 @@
# SAA + AgentScope Agent 平台 · 目标架构(评审版)
> 决策 ID**HJ-AGI-002**(演进自 HJ-AGI-001「agentic 基建 = AgentScope」
> 日期2026-06-15 · 状态:**评审版 v2已过 Codex + Opus 双评审,见 §10** · 双评审 verdict = 需重议 → 已修订为「带修改采纳」 · **两项前置决策待创始人拍板(见 §10-决策 D1/D2**
> 适用范围game-cloud agentic 编排基建的**生产目标架构** + 分期落地路径
> 关联:[.agents/knowledge/tech-decisions.md]、`docs/agent-specs/2026-06-12-agentic基建框架选型-review.md`、本轮 spike 执行稿(`2026-06-15-SAA-AgentScope-spike-execution.md`,定稿后产出)
---
## 0. 结论先行(给决策)
1. **主干 = Spring AI AlibabaSAAv1.1.2.2 稳定/GA的 Graph 编排**。**自治节点用 SAA 自带的 `ReactAgent`tool+Hook+HITL+中断恢复,已测 OpenAI/DeepSeek`ReactAgent.asNode()` 入图;子 agent 调用用 `FlowAgent.subAgents`/子图(`SubGraphNode`)——均 SAA 原生**。**生产默认 SAA-only、养一个框架**2026-06-15 源码实证,见 §10-D2。AgentScope 嵌入降为"未来若 SAA ReactAgent 不够再议";对 agentscope 的开源贡献作独立轨道、与生产解耦。
2. **本项目绝大多数 agentic 活是"编排型"**(生成主线 / 五资产 / 远期 mode-C只有少数"自治型"Tier-3 多 agent 开发组)才下沉成嵌入式 AgentScope 节点。
3. **基建按消费者驱动分期上**Phase1 只用已部署的 **MySQL + Redis零新中间件** → Phase2 拓扑成型时上 Nacos/A2A/RocketMQ/Sentinel → Phase3 mode-C 可视化面。
4. **落地顺序**:先 lili-mac spike 验 SAAkill-criteria 见 §7MVP 闭环期间继续跑现有执行器、互不干扰spike 通过且不压日历闸门,再迁生成主线。
5. **不变量**§6共用 new-api 单一成本/凭证层;"done"由确定性门(九门真玩)裁定、不让 LLM 自评;写工具幂等;生成逻辑藏在 job 契约后(框架可换);完成 MVP 的约束是日历闸门,平台建设不得拖垮 MVP 闭环。
---
## 1. 背景与触发
- 起于"是否对用户开放 agent 编排"的讨论,逐步收敛到 **agentic 基建的 Java 化 + 编排框架选型**
- 期间核实并**纠正了外部建议的四处误判**(见 §9所有关键结论均有据仓内取证 + 联网核实 + 已 clone 源码到 `/root/oss/`)。
- **现状(已核实)**
- 生成主线 = `game-module-aigc``AigcGenerateExecutor` + 自研薄客户端 `ExecutorLlmClient` → new-api**硬编码 7 步**,无图/DSL 编排)。
- 另有隔离 Python workerL1 裸路 + L2 Python AgentScope 2.0.1**编排均手写**)。
- staging 实际只跑 **`redis:7` + `mysql:8.4.8`**Nacos/RocketMQ/Sentinel/Seata/Gateway/XXL-Job **未部署**
---
## 2. 目标 / 非目标
**目标**
- 用真正的图/DSL 编排取代硬编码生成流程,且**可观测、可 checkpoint / 失败恢复**。
- 选定面向**生产云拓扑**Spring Cloud Alibaba的 agentic 主干框架,**Java 原生**、消除 Python↔Java 跨进程缝。
- 为远期 Tier-3 自治开发组、mode-C 用户编排预留生长路径——**无孤儿设计**(每能力带入口/失败路/验收)。
**非目标(本轮明确不做)**
- 不现在搭微服务舰队、不预先部署 Nacos/RocketMQ/Sentinel/Seata。
- 不把 MVP 闭环切换到新框架(先并行 spike证后再迁
- 不决定 mode-C 的 C 端编排前端形态(远期单列)。
- 不使用 Dify项目已降级、无存量工作流DSL 导入对我们 moot
---
## 3. 推荐方案
### 3.1 架构:全局可控、局部自治
```mermaid
flowchart LR
subgraph GC[game-cloudSpring Boot 单体 / 未来微服务)]
EXE[现有 AigcGenerateExecutor\nMVP 闭环保留, 不动)]
JOB[job/callback 契约\n稳定接缝, 框架可换)]
end
subgraph SAA[SAA Graph 编排主干 · GA v1.1.2.2]
direction TB
N1[render prompt] --> N2[LLM 生成 GameConfig]
N2 --> N3{schema 校验}
N3 -- 不合法 --> NR
N3 -- 合法 --> N4[scaffold 脚手架]
N4 --> N5[build 工具节点]
N5 --> N6[play 工具节点\n九门真玩]
N6 -- 门失败 --> NR
N6 -- 门通过 --> N7[emit]
NR["repair 节点\n可升级为 AgentScopeAgent.asNode<br/>= 嵌入式自治体)"] --> N2
end
subgraph EXT[复用:既有确定性 harness子进程工具]
BUILD[esbuild/node 构建脚本]
PLAY[CDP 九门真玩 harness]
end
subgraph STORE[已部署存储Phase1 零新基建)]
MY[(MySQL)]
RD[(Redis)]
end
JOB --> SAA
N5 -. shell .-> BUILD
N6 -. shell .-> PLAY
SAA -. Graph state checkpoint .-> MY
SAA -. session/短期记忆 .-> RD
```
要点:
- **确定性门(九门)= 图的条件边**,由 harness 真玩裁定"done",不让 LLM 自评(反 Goodhart
- **build/play 复用既有 harness**(子进程工具节点),**零重写**generate/validate/scaffold 进入 SAA 图。
- **repair 节点**起步是受约束的 LLM 节点;需要开放式修复时升级为 `AgentScopeAgent.asNode()` 的嵌入自治体——这是 hybrid 的落点。
### 3.2 四类工作负载映射
| 工作负载 | 主力 | 形态 | 时序 |
|---|---|---|---|
| **游戏生成主线**MVP 关键) | **SAA 图** | 编排为主 + repair 节点(可选自治) | spike 验 → 证后迁 |
| 五资产生成(美术/音乐等) | SAA Sequential/Parallel Agent | 工具/模型调用流水 | 随主线 |
| **Tier-3 多 agent 开发组**(复杂游戏:编码/测试/录屏核验) | **AgentScope `asNode()` 嵌入** | 真·开放式自治,外层图卡预算与门 | 远期 / 高阶层 |
| **mode-C 用户编排**(远期) | SAA Admin前端形态待定 | 可视化工作流面 | 远期单列 |
### 3.3 为什么主干是 SAA而非 AgentScope-java 或纯自研)
- SAA v1.1.2.x 是 **GA**agentscope-java 2.0 仅 **RC** → 生产主干用 GA 的 SAA把 RC 风险**关进"可选的嵌入节点"**而非主干。
- SAA Graph 状态可 checkpoint 到 **MySQL/Redis/PostgreSQL/Oracle/MongoDB/File**——其中 **MySQL+Redis 我们已在跑Phase1 零新基建**
- **Spring-native**,贴 game-cloudSpring Boot与生产 Spring Cloud Alibaba 目标Nacos/A2A/MCP 原生,待 Phase2 自然接入。
- SAA 提供 `AgentScopeAgent.asNode()`(在 SAA 的 `spring-ai-alibaba-starter-agentscope` 模块)把 AgentScope 的 **`ReActAgent`** 嵌进 SAA 图。**⚠️双评审源码证伪:该官方接缝当前绑定 `agentscope-core 1.0.9`(稳定线),既非 agentscope-runtime-java、也非 2.0;且只代理单个 ReActAgent不直接适配 §3.2 的 Tier-3 多 agent 自治组。把 2.0(-SNAPSHOT) 嵌进去属未经官方验证的路径——见 §10hybrid 降为独立后置 spikePhase 1.5),不与主干同批。**
### 3.4 分期落地(消费者驱动,不预铺控制面)
```mermaid
flowchart LR
P1[Phase1 · MVP 期\n零新基建] --> P2[Phase2 · 生产拓扑成型] --> P3[Phase3 · mode-C]
P1 -.- P1d["SAA 图编排生成主线<br/>state 仅 MySQL+Redis<br/>挂 job 契约后<br/>共用 new-api 成本层"]
P2 -.- P2d["部署 Nacos→A2A+MCP-registry+发现<br/>嵌 AgentScope 跑 Tier-3 自治组<br/>RocketMQ 事件触发 · Sentinel 限流"]
P3 -.- P3d["SAA Admin 可视化面<br/>C 端编排前端形态另定"]
```
---
## 4. 关键取舍
- **引入 Spring AI + SAA 依赖**(本仓今天零 spring-ai——成本有界且与生产栈同向收益是 GA 编排 + 持久化 + 生态。
- **单看 MVP 生成流,上图框架是轻度过度设计**;真正理由是**生产拓扑 + mode-C + 可恢复性**这条轨迹。→ 用"藏在 job 契约后、先 spike、不压日历"三条控制其代价。
- **reactive↔blocking 边界**SAA / AgentScope 近期都转向 Flux 响应式核心(程度待 spike 实证)——保持"独立 worker / job 契约"边界,规避在全阻塞的 Spring MVC 舰队里硬塞响应式岛。
---
## 5. 影响面 / 兼容性 / 风险
- **影响面**:新增 agentic 编排栈game-cloud 增 Spring AI/SAA 依赖;生成逻辑迁移(藏契约后)。**MVP 闭环不动**(保留现有执行器)。
- **风险与缓解**
1. SAA 学习曲线 / 响应式核心 → spike 先证小流;保持边界。
2. agentscope-java 2.0 RC 稳定性 → **限定在可选嵌入节点**,主干用 GA。
3. Spring AI 栈引入成本 → 与生产 Spring Cloud Alibaba 同向,非沉没。
4. **日历风险**(平台建设挤占 MVP→ 不变量第 5 条 + 分期 + 时间盒。
- **回滚**spike 任一 kill-criterion 挂 → 回落**现有 Java 执行器 + 已 spike 的 Python worker**(两者都在跑),零损失。
---
## 6. 不变量(硬约束,任何阶段不破)
1. **单一 new-api 成本/凭证层**:模型 key 与用量计费只有一处权威new-api + newapi_costSAA/AgentScope 都读它。
2. **"done"由确定性门裁定**:九门真玩 harness 是验收唯一权威,禁止 LLM 自评。
3. **写工具幂等**:幂等键 = sessionId + step循环/重试不得产生重复副作用或重复计费。
4. **成本/迭代上限**:每次生成有 max-iters 与 max-cost-per-run 天花板。
5. **框架可换**:生成逻辑藏在 job/callback 契约后,换框架不改业务契约。
6. **日历优先**:完成 MVP 的约束是 ICP/支付/广告日历闸门,本平台线在其下,必须时间盒、不得拖垮 MVP 闭环。
---
## 7. 验收标准(= lili-mac spike 的 kill-criteria
| 门 | 判据 |
|---|---|
| A 模型 | SAA ChatModel 指向 new-apiOpenAI 兼容)+ 便宜模型可调通出 JSON |
| B 持久化 | 图状态**仅靠 MySQL+Redis** checkpoint/恢复,**零新中间件** |
| C 接缝 | 图节点能 shell 调既有 build + 九门 harness 并读回 JSON |
| D 真玩 | 跑通一款金样游戏端到端真玩four-piece 证据,见 game-e2e-cdp-harness |
| E hybrid | `AgentScopeAgent.asNode()` 嵌一个自治修复节点跑通,验证 SAA↔AgentScope 接缝 |
> 红线:权威真玩门必须在 **mini-desktopx86** 复跑后才能下生产结论lili-macARM只作快迭代证据。
---
## 8. 待确认项(创始人拍板)
1. MVP 闭环是否**上线前**迁 SAA还是**上线后**?(建议:**上线后**,先并行 spike
2. **Nacos 部署时机**建议Tier-3/A2A 真正启动时,不预铺)
3. mode-C 的 C 端编排前端形态(远期单列,本轮不决)。
4. agentscope-java 贡献:提供 GitHub 账号以便设 fork remotefork→分支→PR
---
## 9. 附:已核实事实 & 已纠正误判(自包含,防止重蹈)
**已核实**
- SAAGA最新稳定 **v1.1.2.2**(已 clone `/root/oss/spring-ai-alibaba`POM `<revision>1.1.2.2`Graph runtime内置 Sequential/Parallel/Routing/Loop Agent持久化 MySQL/Redis/PG/Oracle/Mongo/FileAdmin 可视化 + Dify DSL 导入A2A-over-NacosJDK17。
- agentscope-java稳定线 **1.0.12****本地 clone 的 main HEAD `<revision>` 实为 `2.0.0-SNAPSHOT`(非 RC比 RC 更易漂移)**。`agentscope-harness` 是 agent 工作区/文件系统基建(**≠ 本项目"九门真玩"确定性验收 harness二者无复用关系**,防误读)。`AgentScopeAgent.asNode()` **不在** agentscope-runtime-java而在 **SAA 的 `spring-ai-alibaba-starter-agentscope`,绑 `agentscope-core 1.0.9`**(双评审证伪原稿表述)。
**已纠正误判(外部建议)**
1. AgentScope Java 2.0 **非 GA**(曾被误称已 GA
2. 本 fork **无 yudao-module-ai、零 spring-ai 依赖**(曾被误称已存在)→ SAA 是**新引入**而非扩展。
3. SAA README **未"叫你改用 AgentScope"**SAA 自身横跨 agentic+workflowAgentScope 作为**可嵌节点**。
4. Dify DSL 导入对本项目 **moot**(无存量工作流)。
---
## 10. 双评审结论与 v2 修订2026-06-15 · Codex + Opus
**双评审 verdict两方独立均判「需重议」**(非照单采纳)。评审在我提供的 `/root/oss` clone 源码上验出**一处承重事实错误 + 一道缺失门 + 两记战略挑战**。据此修订,文档状态降为「带修改采纳」,并上浮两项创始人前置决策。
**P0已接受必改**
- **P0-1 asNode 事实错误(源码证伪)**`asNode()`**SAA `spring-ai-alibaba-starter-agentscope`,绑 `agentscope-core 1.0.9`**,且只代理单个 `ReActAgent`;本地 agentscope-java 是 `2.0.0-SNAPSHOT`(非 RC。→ §3.3/§9 已就地更正;"hybrid 是官方集成层不是拼凑"的定性**收回**——把 2.0 嵌进 1.0.9 接缝是未验证路径。
- **P0-2 缺依赖启动门**SAA 1.1.2.2 ⇒ Spring AI 1.1.2 + Boot 3.5.8;本项目 Boot 3.5.9/3.5.14 + SCA 2025.0.0.0、零 spring-ai。功能 demo 过 ≠ 在 game-cloud 依赖树能起。→ 新增**门 F**。
- **P0-3战略图编排是否过度设计**:实测执行器确为「线性 + 1 次重试」,图的分支/并行/checkpoint 核心价值当前几无消费者现执行器已有完整失败恢复CAS 认领+watchdog+幂等回调+预算墙。迁移理由实为押注「mode-C + 生产微服务拓扑」两个未来时。→ 上浮为**决策 D1**。
**P1已接受含一处我的校正**
- **基线选错**:正确对照不是「硬编码执行器 vs SAA 图」,而是「**已 spike 的 Python AgentScope worker 演进** vs Java SAA hybrid」。→ spike 增**对照臂**。**【校正】Python worker 是正确对照基线且已 spike但 Opus"已在产派发面"属高估——本轮早先取证 `workerUrl` 默认空、`dispatchGeneric` 未配置即判 failed其当前是否真接线随配置而定对照臂须先确认其现状。**
- **hybrid 隐藏复杂度**agentscope-core 2.0 深度响应式72 文件引 reactorasNode 内含 `block()` 时序坑;叠进阻塞单体=养「两套半」。→ E 门判据升级为「失败路径可控可观测」,且 hybrid 拆出主干。
- **repair 自治节点孤儿**:升级为自治体缺失败/回滚/预算归属路。→ Phase1 **repair 仅保留受约束 LLM 节点**;自治化延到 Phase1.5 且须补三件套(失败路+预算归属+与九门交互)。
**v2 修订(已并入方案)**
1. **spike 拆相****Phase 1 = 纯 SAA 图主干(无 asNode**,过门 A/B/C/D + 新 **F****Phase 1.5 = hybridasNode**先解版本归属D2E 门记录 reactive↔blocking 失败模式;**hybrid 不阻塞主干**。
2. **新增门 F依赖/启动)**game-cloud 实引 SAA BOM/starter`mvn dependency:tree` + 最小 ApplicationContext 启动 + `huijing-server` 编译门;明确哪个 BOM 管 Boot 版本3.5.8 vs 3.5.9/3.5.14)。
3. **门 A/B 细化**A 加「JSON schema 输出」实测B 改为「同 MySQL 8.4.8 + Redis 7崩溃后按 threadId 恢复到正确节点」的真恢复实测(不止类存在)。
4. **对照臂**同款金样「Python worker 演进」vs「SAA hybrid」同口径比工时/稳定性/运维hybrid 不显著胜出 → **不迁移**为默认。
5. **§6 软控制 → 硬闸门**:① spike **绝对时间盒**(超时按 kill 处理);② **MVP 闭环ICP/支付/广告链路)未交付前禁碰任何 Phase2 基建**,违反等同破不变量;③ 给「是否压日历」可观测判据(关键路径 battle 是否因平台线滑期)。
6. **§3.4 触发可证伪化**Phase2 触发 = "Tier-3 自治组有真实首个消费者 ∧ MVP 闭环已上线"Phase3 同理给客观事件。
7. **§5 回滚措辞**Phase1 低成本可逆;**Phase2 起基建可逆性显著下降**(非"零损失")。
8. **reactive 边界**:长图一律走 job/worker 边界 + 后台线程池;线程池/超时/取消/checkpoint 恢复纳入验收。
**评审肯定(避免过度否定)**SAA 的 Graph/Node/Edge、MysqlSaver/RedisSaver、Sequential/Parallel/Routing/Loop、ChatModel 自定义 baseUrl 接 new-api——**均源码证实成立****job/callback 契约=框架可换+回滚路**是全稿最硬的一块。
**上浮创始人前置决策**
- **D1战略本质商业判断**:迁移理由是押注 mode-C + 生产拓扑。①spike-to-decidePhase1 纯 SAA + 对照臂,时间盒,结果说话——推荐)/ ②deferMVP 先用现路,待真消费者再上 SAA/ ③直接 commit。
- **D2版本归属→【2026-06-15 创始人追问后源码实证解决】**SAA 自带完整 agentic——`ReactAgent`tool+Hook+HITL+中断恢复,已测 OpenAI/DeepSeek`spring-ai-alibaba-agent-framework/.../ReactAgent.java`+ `ReactAgent.asNode()`第296行+ `FlowAgent.subAgents``List<Agent>`+ 子图 `SubGraphNode`。**子 agent 调用是 SAA 原生能力,不需 AgentScope**。→ **改判:生产 = SAA-only**(养一个框架、回归 HJ-AGI-001 初衷、P0-1 版本错配消失AgentScope 嵌入降为"未来 SAA ReactAgent 不够时再议"agentscope 开源贡献=独立轨道。**唯一待确认**:是否仍要在生产里 dogfood AgentScope 作为贡献载体(默认否=SAA-only

View File

@ -0,0 +1,85 @@
# SAA Agentic 编排 · Phase 1 spike 执行稿lili-mac
> 关联评审:`2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md`HJ-AGI-002v2 双评审定稿)
> 决策前提:**D1 = spike-to-decide****D2 = 生产 SAA-only**SAA 自带 `ReactAgent`/`asNode()`/`subAgents`AgentScope 嵌入未来备选)。
> 状态:执行版 · 机器 = lili-mac快迭代**权威真玩门 = mini-desktopx86**
---
## 1. 目标与范围边界
- **目标**:把生成主线表达成 SAA StateGraph验证能否在我们的栈上跑通一款金样游戏真玩并据"对照臂"判定**是否值得迁**。
- **范围**:隔离 spike目录 `spikes/saa-gen-graph/`**零改生产 game-cloud 与 Python worker**。
- **非目标**:不做 AgentScope hybridD2不部署任何新中间件不碰 MVP 闭环。
## 2. 前置
- lili-macJDK17、Maven、Node给既有 harness、Tailscale 可达 new-api。
- 已 clone`/root/oss/spring-ai-alibaba`v1.1.2.2)。
- 既有 harnessesbuild 构建 + CDP 九门真玩)**复用为子进程工具,禁重写**。
## 3. kill-criteria任一挂 → 停/回落)
| 门 | 判据 | 源码锚点(已核) |
|---|---|---|
| A 模型 | SAA ChatModel 指 new-apiOpenAI 兼容 baseUrl+ 便宜模型deepseek 类)出 JSON含 **schema 输出**实测 | `NacosReactAgentBuilder``OpenAiApi.builder().baseUrl(...)``ModelVO.baseUrl` |
| B 持久化 | 图状态**仅靠 MySQL8.4.8 + Redis7** checkpoint**崩溃后按 threadId 恢复到正确节点**(真恢复实测,非类存在) | `MysqlSaver`/`RedisSaver`/`CompiledGraph` |
| C 接缝 | 图节点能 shell 调既有 build + 九门 harness 并读回 JSON | — |
| D 真玩 | 跑通一款金样游戏端到端真玩four-piece 证据,见 game-e2e-cdp-harness | — |
| **E 依赖/启动** | game-cloud 实引 SAA BOM/starter → `mvn dependency:tree` + 最小 ApplicationContext 启动 + `huijing-server` 编译门;**定哪个 BOM 管 Boot 版本SAA 要 3.5.8 vs 项目 3.5.9/3.5.14** | SAA pom 锁 Spring AI 1.1.2 + Boot 3.5.8 |
> 注:原 "E=hybrid asNode" 已按 D2 移除;依赖门提为正式门 E。自治 repair 节点用 **SAA 自带 `ReactAgent.asNode()`**,不引 AgentScope。
## 4. 对照臂(评审 P1-1
同一款金样,**「现有 Python worker 演进」vs「SAA 图」**同口径比工时/稳定性/运维。先确认 Python worker **当前真实接线状态**`workerUrl` 配置——早先取证默认空)。**SAA 不显著胜出 → 不迁移为默认。**
## 5. 时间盒(硬闸门,评审 P0-3
lili-mac **≤ N 人日**N 由创始人定),超时按 kill 处理。**MVP 闭环ICP/支付/广告链路)未交付前,不得触发任何 Phase2 基建动作Nacos/RocketMQ/Sentinel。**
## 6. 分阶段 prompt顺序贴给 lili-mac 会话)
### 背景块(先贴一次)
```
【背景·SAA 生成编排 spikePhase1 纯 SAA
- 项目=AI 游戏生成平台,后端 game-cloud=Java17+Spring Boot。先读 AGENTS.md、.agents/skills/{game-e2e-cdp-harness,cheap-model-game-generation}.md 对齐口径。
- 现状生成主线=game-module-aigc 的 AigcGenerateExecutor + 自研 ExecutorLlmClient→new-api硬编码 7 步);另有隔离 Python workerrun.py 裸路 + agent_loop Python AgentScope。两条无图编排。
- 目标=用 SAA(v1.1.2.2, 已 clone /root/oss/spring-ai-alibaba)的 StateGraph 把生成流表达成可编排图,验证能否原生跑在 Java 栈、状态只靠 MySQL+Redis。
- 硬约束:① 隔离 spike目录 spikes/saa-gen-graph/,零改生产 game-cloud 与 Python worker② build+CDP 九门 harness 子进程复用、禁重写;③ 证据沿用 game-e2e-cdp-harness 四件套;④ 不引 AgentScope自治节点用 SAA 自带 ReactAgent.asNode⑤ 严守时间盒,超时按 kill 处理。
- 派子代理显式传 model机械活 haiku/有裁量 opus禁继承。
```
### P1 — 侦察 + 依赖门 E最便宜的刹车过不了就停
```
任务:为 SAA 生成-编排 spike 做侦察与依赖门验证,产出 spikes/saa-gen-graph/DESIGN.md。先别建完整图。
1. 摸清现状:精读 wg1/gen-worker/worker/run.py 七步 + agent_loop/studio.py列出要在 SAA 复刻的"节点+重试/修复条件边+九门证据"清单。
2. 读 SAA 源码确认编排 APIStateGraph/Node/Edge/addConditionalEdges、ReactAgent(+asNode)、SequentialAgent/ParallelAgent/LlmRoutingAgent/LoopAgent、SubGraphNode、MysqlSaver/RedisSaver。给类名与最小用法。
3. 跑通门 Anew-api+便宜模型出 JSON含 schema、门 C节点 shell 调既有 build+九门 harness 读回 JSON的最小验证。
4. 【门 E关键】在 game-cloud 实引 SAA BOM/starter跑 mvn dependency:tree 看与现有 Boot 3.5.9/3.5.14 + SCA 2025.0.0.0 是否冲突;做最小 ApplicationContext 启动 + huijing-server 编译门。明确哪个 BOM 管 Boot 版本。
STOP任一门挂→停在本步、写 BLOCKER 与可选方案不建图。全过→DESIGN.md 末尾标 GREENLIGHT→P2。
```
### P2 — 建 SAA 图 + 跑通金样DESIGN 亮绿灯后)
```
前置DESIGN.md 门 A/C/E 全绿。否则停。
任务:在 spikes/saa-gen-graph/ 用 SAA StateGraph 把生成路径建成真图,端到端跑通一款金样游戏。
1. 图render prompt → ReactAgent/LLM 节点生成 GameConfig(JSON) → schema 校验 → [conditional: 不合法→repair 节点(SAA ReactAgent.asNode, 受预算/迭代上限约束)→回灌] → scaffold → build(工具节点:子进程调既有 esbuild) → play(工具节点:子进程调既有 CDP 九门) → [conditional: 门失败→repair] → emit。
2. 状态 checkpoint 到 MySQL+Redis做门 B 真恢复实测(中途 kill按 threadId 恢复到正确节点)。
3. 复用纪律build+九门 harness 子进程复用、零重写。
4. 目标产物:复刻一款 Python L1 已产金样Pong/打地鼠four-piece 证据落盘。
5. repair 节点须带:失败路 + 预算归属(与 max-iters/max-cost 天花板协调)+ 与九门确定性门的交互done 由门定,不让 LLM 自评)。
```
### P3 — 裁决 + 对照臂
```
任务:照 spikes/agentscope-wg1/VERDICT.md 结构,写 spikes/saa-gen-graph/VERDICT.md给 go/no-go。必答
1. SAA 图能否干净表达生成流(含重试/修复条件边 + checkpoint 恢复)?贴证据。
2. 状态是否真只靠 MySQL+Redis 落地(零新基建)?门 B 恢复实测结果。
3. 依赖门 E与现有 Boot/SCA 是否冲突,解法是什么。
4. reactive↔blocking 边界:长图走 job/worker 后台线程池的实测失败模式(超时/取消/异常传播/checkpoint×响应式流
5. 【对照臂】同款金样,"Python worker 演进" vs "SAA 图" 同口径工时/稳定性/运维对照表SAA 不显著胜出→建议不迁移。
红线:权威真玩门必须在 mini-desktop(x86) 复跑后才能下生产结论VERDICT 注明本机(ARM)仅快迭代证据。
末尾:给生产接入建议(图原生进 game-cloud vs 独立 Java worker——留给后端侧另开本 spike 不动手。
```
## 7. 失败/回滚
任一门挂 → 回落**现有 Java 执行器 + Python worker**都在跑spike 目录可弃零损失Phase1 低成本可逆)。
## 8. 完成条件
门 AE 全绿 + 一款金样真玩四件套 + 对照臂数据 + `VERDICT.md`go/no-go+ **mini-desktop 复跑权威真玩门**

View File

@ -0,0 +1,70 @@
# SAA 能力 / API / 接入 档案(多路源码调查汇总)
> 来源4 个 Claude/Opus 子代理逐行源码取证API 面 / 能力盘点 / 接入部署 / 用法范式 四视角彼此交叉验证一致Codex 第 5 路 51min 卡死已取消,不影响结论(四路已互验)。
> 基线:`/root/oss/spring-ai-alibaba` @ tag **v1.1.2.2**HEAD 7405a7d底层 spring-ai 1.1.2。
> 关联spike 执行稿 `2026-06-15-SAA-AgentScope-spike-execution.md`、目标架构 `…目标架构-review.md`、决策 [[saa-agentic-infra-decision]]。
---
## 0. 一句话结论(决策级)
1. **我们的生成流水 = 确定性多步编排 → 用「裸 `StateGraph` + 自定义 `NodeAction`」**,不用 `ReactAgent`/flow agent那是给"LLM+工具自治"的。repair 环用 `addConditionalEdges` 回边 + state 计数器,不用 LoopAgent。
2. **模型**SAA 复用上游 Spring AI 的 `OpenAiChatModel`/`OpenAiApi`SAA 无自有 OpenAI 封装)。**baseUrl 指 new-api**,每节点一个 `OpenAiChatModel` bean共用一个 `OpenAiApi`,仅 `defaultOptions.model` 不同),节点按 `@Qualifier` 取。便宜模型(DeepSeek)同构,有原生 starter。
3. **状态**`MysqlSaver`(收 `DataSource`,直接注入 yudao Druid作引擎**唯一权威 saver** + `RunnableConfig.threadId(任务id)`Redis 作业务层旁路缓存(**一张图只能注册一个 saver**,注册俩抛异常)。`releaseThread(false)` 才能长期续跑。
4. **依赖冲突风险 = 低-中、可控**:只引 `graph-core` + 3 个 BOM**不引 SAA 的 Boot BOM**(让项目 3.5.14 胜出);**不用 RedisSaver 即避开** Redisson 3.x↔4.x。fastjson/okhttp 双方完全一致graph-core **不引 web 框架**(只 reactor-core不与 yudao spring-mvc 抢栈。
5. **运行形态**:进程内嵌,抽 `GenerationDispatcher` 接口挂在现有 `AigcTaskService.submitGenerate → completeWithVersion` 契约后HttpWorkerDispatcher | SaaGraphDispatcher 灰度切,框架可换);图用自带阻塞 `invoke()` 在**后台线程池**跑(禁 Tomcat/Reactor 线程),图执行不包大事务。独立 worker 留 Phase2。
---
## 1. 关键 API照着写代码那层
- **图**`new StateGraph(name, KeyStrategyFactory)``addNode(id, node_async(NodeAction))``addEdge(START,..)/(..,END)``addConditionalEdges(from, edge_async(EdgeAction→String), Map<key,目标节点>)`**repair 回边靠它mappings 不可空**`compile(CompileConfig)→CompiledGraph`
- **状态读写契约**:节点 `NodeAction.apply(OverAllState)`**读** `state.value("k", T.class)`**写** 靠 `return Map.of("k", v)`(框架按 key 的 `KeyStrategy` 合并;`ReplaceStrategy` 覆盖 / `AppendStrategy` 追加 messages——**不是 setter**。
- **执行**`invoke(inputs, RunnableConfig)→Optional<OverAllState>`(内部即 `stream().last().block()`,自带阻塞);`stream(...)→Flux<NodeOutput>`
- **模型(上游三件套)**`OpenAiApi.builder().baseUrl(NEW_API).apiKey(KEY).build()``OpenAiChatModel.builder().openAiApi(api).defaultOptions(OpenAiChatOptions.builder().model("deepseek-chat").build()).build()`
- **持久化**`MysqlSaver.builder().dataSource(ds).createOption(CREATE_IF_NOT_EXISTS).build()` / `RedisSaver.builder().redisson(redissonClient).build()``SaverConfig.builder().register(saver).build()``CompileConfig.builder().saverConfig(..).releaseThread(false)``RunnableConfig.builder().threadId("..")`
- **流式**`stream()→Flux<NodeOutput>``instanceof StreamingOutput` 区分 LLM token chunk`.message().getText()` 取增量。**⚠️ 无 `streamEvents`**。
## 2. 能力盘点:本期(MySQL+Redis only)可用 vs Phase2
**✅ 本期即可落(纯 JVM 或仅 MySQL+Redis覆盖我们核心用例**:图编排全集(条件/并行/嵌套子图/循环、agentic ReAct、工具调用、多 agentsequential/parallel/routing/conditional/loop、持久化 checkpoint/resumeMysqlSaver 仅需 MySQL、RedisSaver 仅需 Redis**均已验证零 Nacos/MQ import**、流式、HITL 中断恢复、Hook(10)/拦截器(13)、OpenAI 兼容(new-api)+DeepSeek、A2A 点对点、可观测埋点。
**⛔ 需 Phase2 基建**:完整 **Admin 平台MySQL+Redis+ES+RocketMQ+Nacos 五件套全硬,缺一启动失败)**、A2A-Nacos 服务发现、config-nacos、MCP-Nacos 网关、sandboxDocker、RAG 走 ES、Prompt 发布线上热更(写 Nacos
**两条最易误判**:① **Admin 整包 ≠ MySQL+Redis 就够**——只想用编排引擎就**别起 admin直接用 `graph-core`+`agent-framework` 库依赖**。② 向量库在本 clone 只有 ES 是具体实现。
## 3. 接入 game-cloud依赖冲突解法放最前
**真冲突仅 2 处**
- **C1 Spring Boot**:项目 3.5.14huijing-dependencies BOM 实际生效vs SAA 要 3.5.8 → **不引 SAA 的 Boot BOM让 3.5.14 胜**(同 minor 补丁前向兼容jackson/reactor/spring 仅补丁差,实测安全)。
- **C2 Redisson**:项目 4.4.0 vs SAA graph-core 用 3.22.0**但 `optional=true`**)→ **不用 RedisSaver 即零冲突**;若用,需实测 redisson 4.x 对 `RMap/RBucket/RLock` 三 API 兼容。
- **零冲突**fastjson 1.2.83、okhttp 4.12.0 双方一致graph-core 不引 web 框架。
**Maven只加到 `game-module-aigc-server/pom.xml`,不动根 POM/BOM**import 三 BOM = `spring-ai-bom:1.1.2` + `spring-ai-alibaba-bom:1.1.2.2` + `spring-ai-alibaba-extensions-bom:1.1.2.2`;依赖只加 `spring-ai-alibaba-graph-core`(图引擎,唯一必需)+ 模型 starter走 new-api 用 `spring-ai-openai`)。
**reactive↔blocking 桥接**`CompiledGraph.invoke()` 自带阻塞(内部 `.block()`)→ 在**后台 `ExecutorService`**(非 Web/Reactor 线程)调用,否则 block-in-reactor 死锁;超时用 `Future.get(timeout)` 外包,取消接现有 `cancelTask`;图执行**不包大 `@Transactional`**,跑完单独短事务落库。
**最小验证门 E6 步mini-desktop 跑)**:① 加 3 BOM+graph-core → ② `mvn -pl …aigc-server -am dependency:tree` 断言boot 全 3.5.14 / redisson 4.4.0 / 无 3.22.0 泄漏 / jackson 2.21.x→ ③ 写一个纯 Java 节点(不调 LLM的 hello `StateGraph` → ④ `mvn package` 编译门 → ⑤ 单体启动门(验 SAA 与 yudao 自动配置共存无 BeanCreation 失败)→ ⑥ 调 `invoke` 跑一节点断言返回 state。**先不接模型/不接 MysqlSaver/不接 job 链**,隔离验"SAA 库能进 game-cloud 进程"这一最便宜命题。
## 4. 面向我们生成流水的用法范式7 条,骨架见各子代理报告)
1. **主干**`StateGraph` + 每步一个 `node_async(NodeAction)`render→generate→validate→scaffold→build→play→emit
2. **repair 环**`addConditionalEdges("validate", edge_async(s→ ok?"done": cnt>=N?"giveup":"repair"), …)` 回边 + state `repair_count` 计数器;`recursionLimit`(默认 100**优雅终止非异常**)作硬刹车,不替代业务 max-iters。
3. **build/play 节点**:自定义 `NodeAction``new ProcessBuilder(...)` shell 调 node/esbuild + CDP 九门 harness`mapper.readTree` 收 JSON`waitFor(timeout)/destroyForcibly`。**别用 sandbox 模块**(那是 agentscope 远程容器);骨架抄 `LocalFilesystemBackend.java:457-507`
4. **进度推前端**:节点 `apply` 返回的 Map 里塞一个 `Flux` → 框架自动在 `stream()` 上展开 → 出口 `@PostMapping(produces=TEXT_EVENT_STREAM_VALUE) Flux<ServerSentEvent<String>>`
5. **checkpoint**MySQL 权威单 saver + `releaseThread(false)`Redis 业务旁路;同 threadId 二次 invoke 即续跑time-travel 用 `getStateHistory`+`checkPointId`
6. **可选 HITL**:节点实现 `InterruptableAction``interrupt()` 里按 state 开关 `review_required` 返回停/放行(条件式,**默认自动放行**);恢复 `updateState(...)`+`withResume()`+`stream(null, cfg)`。**HumanNode 已废,必须配 saver**。
7. **模型**N 个 `OpenAiChatModel` bean 全指 new-api、仅 `defaultOptions.model` 不同,节点 `@Qualifier` 取;**`AGENT_MODEL_NAME` 不是选模型机制**(只是节点 ID + 流式事件前缀)。
## 5. ⚠️ 六个最易踩的坑(四路交叉验证确认)
1. **`HumanNode` 整文件被注释、不可用** → 用 `CompileConfig.interruptBefore/After` + `resume()`
2. **无 `streamEvents`**`stream()→Flux<NodeOutput>` + `instanceof StreamingOutput`
3. **ReAct 循环默认无限上限**`Integer.MAX_VALUE`)→ 用 ReactAgent 时生产必挂 `ModelCallLimitHook/ToolCallLimitHook`;我们用裸 StateGraph 则靠 state 计数器 + `recursionLimit`
4. **supervisor / handoff 没实现**(仅枚举占位)→ 用 routing 替代。
5. **一张图只能注册一个 saver**(注册俩抛 `IllegalStateException`)→ MySQL 权威 + Redis 业务旁路(或自写双写装饰器)。
6. **Admin 整包要五件套**MySQL+Redis+ES+RocketMQ+Nacos→ 只要引擎别起 admin直接用 graph-core + agent-framework 库。
---
> 全量逐行证据(含每个类的 文件:行见本轮四个子代理报告API 速查 / 能力矩阵 / 接入部署 / 用法范式手册。本档案为决策级蒸馏spike 门 E 与生成图实现直接照此落地。

View File

@ -0,0 +1,309 @@
# Python agentic 生成系统 → SAASpring AI Alibaba / Java迁移设计
> 类型:迁移设计(只读分析 + 目标图设计,**不含完整实现代码**)。
> 范围:把「已跑通的 Python agentic 生成系统」wg1/gen-worker迁移并**充分发挥 SAA 强项**地适配到 SAA 裸图编排随后做「Python 生成游戏 vs SAA 生成游戏」对比测试。
> 基线证据Python 侧 `wg1/gen-worker/worker/**`逐行已读Java 契约 `game-cloud/game-module-aigc/**`dispatcher 接缝已读SAA 源码 `/root/oss/spring-ai-alibaba` @ v1.1.2.2核心类文件位已核SAA 用法/坑见 `2026-06-15-SAA-能力API接入-dossier.md`
---
## 0. 结论先行(决策级)
1. **迁移本质 = 换「worker 躯体」,不换「契约骨架」。** Java 执行器侧已有干净接缝:`AigcGenerateExecutor.dispatchGeneric`**§6.1 job-in** → `WorkerDispatchClient.dispatch` 投递 → worker 异步产 `engineBundle` → 回调 `DifyCallbackReqVO``/dify/callback-internal``AigcGenerateExecutor.java:435-494``WorkerDispatchClient.java:69-107``DifyCallbackReqVO.java:25-67`。SAA 图只替换 **job→engineBundle 这段躯体**,两端契约逐字段不动。
2. **躯体对应 = Python 的 L2 studio 闭环 → SAA「裸 `StateGraph` + 自定义 `NodeAction`」。** Python 已自证design→[code|fix 循环]→九门真玩→player 顾问轮(`agent_loop/studio.py:197-314`)。这是**确定性多步编排**不是「LLM+工具自治」——故用裸图 + 条件边回环 + state 计数器,**不用 ReactAgent/LoopAgent**dossier §0-1
3. **build + 九门 harness 子进程整体复用,禁重写。** `serve-and-play.sh``static-serve`+headless Chrome→`play.cdp.cjs`(九门 AI+`build.mjs`esbuild是模型无关、已沉淀的确定性地板`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs:10-26`。SAA 在 build/play 节点内 `ProcessBuilder` 调同一套 shelldossier §4-3**不进 SAA 图、不用 sandbox 模块**。
4. **SAA 比 Python 手写编排净增 5 项强项**MySQL checkpoint 可恢复/续跑、Hook+state 双重限次、节点级流式进度SSE、子图封装player 顾问轮)、图可视化与可观测埋点。逐条映射见 §5。
5. **部署两形态二选一**:① **进程内 `SaaGraphDispatcher`**(消灭 HTTP+HMAC 回调跳dossier §0-5 首选);② **Java worker 进程**顶替 Python `service.py`,沿用 `/generate` HTTP 端点drop-in零改 Java 执行器)。建议**对比测试期走形态②**(隔离变量、与 Python 并排),**生产收口走形态①**。
---
## 1. 现有 Python agentic 系统精确画像(带 文件:行)
### 1.1 两层闭环L1 裸路 vs L2 agentic studio
| 维度 | L1 裸路(`worker/run.py` | L2 agentic studio`worker/agent_loop/studio.py` |
|---|---|---|
| 角色 | 单模型直出(无 design/无 player | design → code/fix 多角色 + player 顾问 |
| 框架 | 裸 openai 客户端,零 AgentScope`_client.py:1-5` 纪律) | AgentScope 2.0.1 进程内多 agent`studio.py:31-32` |
| 管线 | generate→validate→scaffold→build→play→retry`run.py:97-136` | design→[code\|fix 环]→九门→player 顾问轮(`studio.py:213-280` |
| 重试上限 | `max_retries`(默认 2`run.py:88` | `max_repairs`(默认 5+ `player_rounds`(默认 1`studio.py:197` |
| 产出落盘 | `results/<id>.json``run.py:154` | `results/<id>.<stage>.json``studio.py:312` |
**现行生产入口 = L2**`service.py:173` 在 HTTP job 处理里调 `run_studio(...)`L1 是早期裸路/冒烟studio 复用其 `scaffold/build/play``studio.py:8`)。**迁移目标锚定 L2。**
### 1.2 L2 管线步骤(逐节点,`studio.py`
1. **design设计 agent**`studio.py:214``roles.DESIGN_SYSTEM``roles.py:8-32`)。一句话 brief → 设计稿 + **末尾自产 ```gatespec JSON 块**gate-H 推广:让确定性门适用任意游戏)。
2. **gatespec 抽取与归一**`studio.py:218``_extract_gatespec``studio.py:72-102`):正则抠 ```gatespec 块,`json_repair` 兜脏 JSON`expectLatch` 缺省补 True`paddle-intercept.ballPath` 的 `.y→.x` 安全归一。抽出的 `exportState/driver/controlCheck/assertAfterPlay/expectLatch` 合入 `play_spec``studio.py:219-223`)。
3. **code / fix 循环**`studio.py:231-265`:首轮 role=code后续 role=fix`prompt.build_messages(enriched, retry_feedback=feedback)``prompt.py:110-119`)把上轮失败原因回喂。每轮:
- `validate.extract_code``validate.py:13-28`)→ 抠 ```js 块;
- `validate.validate``validate.py:103-111`= 静态契约扫描(`static_check``validate.py:55-77`:默认导出/init·update·render·destroy 四方法/ctx 用法/getEngine 必现/12 条禁用词)+ `node --check` ESM 语法门(`validate.py:80-100`
- `run.scaffold``run.py:33-42`)落 `generated-factory.js` + 拷 entry-bundle/index 模板 + 写 play-spec.json
- `run.build``run.py:45-53``ProcessBuilder``node scripts/build.mjs ... --global-name=__GameBundle`
- `run.play``run.py:56-72``ProcessBuilder``serve-and-play.sh`,读回 `evidence/verdict.json`
4. **九门判定(确定性硬地板)**`studio.py:259``seven_ok = rc==0 and verdict.pass`。未过 → `run._verdict_feedback(verdict)``run.py:75-85`)摘失败守卫回喂 → 继续 fix 环。
5. **player 顾问轮(七门过后才跑,软门)**`studio.py:267-280``run_player_panel``studio.py:171-193`M3 视觉位(`_judge_vision` 读 first-paint/after-play 截图,`studio.py:148-162`+ flash 文本位(`_judge_text``studio.py:165-168`)多人格评判 → 若 verdict=fix 且 `player_round_used<player_rounds`,把体验问题回喂再修一轮(**七门仍是硬地板**)。
6. **成本结算**`studio.py:282-295``cost.py` 按 new-api `/api/pricing`+`/api/status` quota 公式逐模型拆账(`cost.py:54-66`)。
### 1.3 模型路由
- **配置即旋钮**`models.yaml` 三阶段stage1 便宜 DeepSeek-flash / stage1_mmx 跨家 MiniMax-M2.7 / stage2 强档 DeepSeek-v4-pro每阶段 5 角色design/code/fix/player_text/player_vision+ `_default`**视觉位恒 MiniMax-M3**`models.yaml:1-40`)。
- **取值**`config.model_name_for(role, stage)``config.py:51-56`)。
- **客户端**`config.build_model``config.py:59-68`)建 AgentScope `OpenAIChatModel``base_url``OpenAICredential`(指 new-api子类 `RecordingChatModel``config.py:26-42`)记每次调用 usage 供成本/对比。
- **唯一出口 = new-api OpenAI 兼容网关**`_client.py:31` `BASE_URL=http://100.64.0.8:3000/v1`**代理旁路铁律**import openai 前把网关 host 并入 `NO_PROXY``_client.py:35-44`),否则本机 clash 代理转走→502spike 头号坑)。
- **`AGENT_MODEL_NAME` 不是选模型机制**dossier §4-7选模型靠每节点指定 model 名。
### 1.4 重试/修复逻辑(迁移的核心循环语义)
- **失败即回喂重出全量**(非 diff`prompt.py:114-118` 明示「不要只给 diff」。
- **失败分桶 → 不同回喂文案**extract 失败 / validate 失败(静态错误列表)/ build 失败esbuild 日志前 600 字)/ 九门失败(`_verdict_feedback` 摘未过守卫)/ player fix体验问题列表——`studio.py:243-279`
- **计数器双层**repair 环 `max_repairs` + player 顾问 `player_rounds`,均 state 内自增(`studio.py:231``275-276`)。
- **网关瞬时错误重试**`_client.chat``tries=3``_client.py:60,94-97`)。
### 1.5 harnessbuild + 九门)如何被调
- **入口**`run.play(game_id, port, cdp_port)``run.py:56-72``subprocess.run(["bash", "serve-and-play.sh", game_id, port, cdp_port], timeout=180)`
- **serve-and-play.sh**`game-runtime/games/_wg1-gen/_shared/serve-and-play.sh:1-68`):净场杀端口 → 起 `static-serve.cjs`serve game-runtime 根)→ 起 headless Chrome`--remote-debugging-port` + `--remote-allow-origins=*` + `--autoplay-policy`)→ 就绪轮询 → `node play.cdp.cjs <gameId> --base --cdp` → trap 清理,返 play 退出码。
- **build**`run.build``run.py:45-53``node scripts/build.mjs <entry> <out> --global-name=__GameBundle``build.mjs:23-31` 锁参 esbuildbundle+minify+iife+es2019+无 sourcemap输出 `bundle.iife.js` + `.meta.json`
- **九门 AI**`play.cdp.cjs:10-20`,全过才 PASS判定汇总 `play.cdp.cjs:543-667`
- **A 装载** `__genBooted ∧ __gameHostEngineInitFired ∧ 无 bootError`
- **B 未捕获** `__genInternals.uncaught 空`
- **C 掌帧** 500ms 内 `frame` 增量 ∈ [20,45]
- **D 真渲染** `#game-engine` 有色像素 `bright>50 ∧ maxCh>80`
- **E 活性** 真玩前后帧哈希集 `distinct≥2`
- **F 真接线** `__engineCalls` 命中 `spec.expectedEngineCallPrefixes` 任一前缀(证经 `getEngine()` 真调引擎,非自绘)
- **G 输入有效** 同 seed 起无输入对照实例推进到同帧号,整帧哈希不同
- **H 机制进展 + latch** `spec.assertAfterPlay[]` 全过 + `expectLatch` 时真玩到 `phase='gameover'` 且**驻留**600ms依赖游戏 `_forensicsView()` 导出 state未声明=SKIP 向后兼容)
- **I 控制跟手** `spec.controlCheck` 声明时连点验控制体平滑逼近(未声明=SKIP
- **适配性真玩驱动**`spec.driver` 存在=读 state 自动出招(`runDriver``play.cdp.cjs:204-212` 分发 8 类paddle-intercept/tap-targets/flap-to-gap/seek-x/tap-pairs/key-cycle/drag-aiming/aim-fire解「盲打固定坐标打不动技巧游戏」假阴性否则走 `spec.inputs` 固定序列。
### 1.6 job / callback 契约(迁移的 I/O 边界)
- **job-in§6.1**Java `dispatchGeneric` 组(`AigcGenerateExecutor.java:464-481``job_id, tier="L1", kind="generate", brief, model, budget{maxYuan,maxLlmCalls}, gameId, templateId, renderCtx{template_schema,banned_list,findings}, idempotency_key, deadline_ms, callback{type,target}, traceId`。Python `service.do_POST` 收(`service.py:239-262`)。
- **握手**worker 立即回 **202**`service.py:262`),后台线程异步跑(`service.py:261`);忙时 **409**(串行锁,`service.py:254-257`serve 4320/CDP 9222 不可并发)。
- **callback-out**`build_callback_payload``service.py:91-123`)组 `DifyCallbackReqVO` 形态:`traceId, status(succeeded|failed), templateId, gameConfig{templateId,title,theme,engineDriven}, assets:[], engineBundle(成功路=bundle.iife.js 全文) | failureReason(失败路)`**HMAC-SHA256 签名**`service.py:135-143`,逐字节对账 Java `CallbackSignatureVerifier`POST 回 `/dify/callback-internal`。Java 侧 `DifyCallbackReqVO.engineBundle``DifyCallbackReqVO.java:53-66`= P3 additive 字段,`resolveEngineBundleText` 取值写 `GamePackage.engineBundle` 落包入 feed。
- **出厂自检**bundle 必含 `__GameBundle` 全局名,否则判失败不落坏包(`service.py:184-195`)。
### 1.7 prompt 与证据口径
- **prompt**`prompt.SYSTEM``prompt.py:24-107`= 模块形状(默认导出 createGame五方法 + `_forensicsView`+ 能力面(`ctx.getEngine/getInput/time/random``boot.mainContext`+ 12 条硬禁止(禁 import/Math.random/Date.now/addEventListener/rAF...+ 硬规则(闭包态/受控时钟/getEngine 容错/destroy 幂等/必调一次引擎能力/每帧可见变化)+ **P0 latch 终态硬约束**gameover 驻留不自动重开)+ few-shot 参照(读 `_goldenpath-probe/factory.js` 防漂移,`prompt.py:9-20`)。
- **证据四件套**`verdict.json`(九门 guards + pass+ `first-paint.png` + `after-play.png``play.cdp.cjs:26`),落 `games/_wg1-gen/<id>/evidence/`;结果 JSON 含 `attempts[]`(每轮 usage/stage_fail/guards`seven_gate_verdict``player.panel``tokens_by_model``cost``studio.py:297-311`)。
---
## 2. Python → SAA 映射表
| # | Python 构件(文件:行) | SAA 构件 | 性质 |
|---|---|---|---|
| 1 | L2 主编排 `run_studio` 循环 `studio.py:197-314` | `StateGraph(name, KeyStrategyFactory)` + `CompiledGraph` | **需适配**(命令式循环→声明式图) |
| 2 | design agent 调用 `studio.py:214` | `render`/`design` 节点 = `node_async(NodeAction)` 调 design model | 直接对应 |
| 3 | `_extract_gatespec` `studio.py:72-102` | design 节点内纯 Java 逻辑(正则 + Jackson 容错解析;`json_repair`→自写宽松解析或 `JsonNode` 容错) | **需适配**(无 json_repair 等价;做最小宽松解析) |
| 4 | code/fix 循环体 `studio.py:231-265` | `generate` 节点(首轮 code+ `repair` 节点fix+ `addConditionalEdges` 回边 | **需适配**(用 state `role`/`repair_count` 区分) |
| 5 | `validate.validate` 静态+node --check `validate.py:103-111` | `validate` 节点(静态契约 = 纯 Java 正则;`node --check` = `ProcessBuilder` | 直接对应(子进程复用) |
| 6 | `run.scaffold` `run.py:33-42` | `scaffold` 节点 = 纯 Java 文件 IO写 factory + 拷模板 + 写 play-spec | 直接对应 |
| 7 | `run.build`esbuild 子进程)`run.py:45-53` | `build` 节点 = `NodeAction``ProcessBuilder("node","scripts/build.mjs",...)` | **复用·禁重写**(骨架抄 `LocalFilesystemBackend.java:457-507`dossier §4-3 |
| 8 | `run.play`(九门 CDP 子进程)`run.py:56-72` | `play` 节点 = `ProcessBuilder("bash","serve-and-play.sh",...)` + 读 `verdict.json` | **复用·禁重写** |
| 9 | 九门 verdict.pass 判定 `play.cdp.cjs:666` | **条件边** `addConditionalEdges("play", s→pass?"emit":cnt>=N?"giveup":"repair")` | **SAA 更强**(确定性门作条件边,图天然支持) |
| 10 | repair 计数 `max_repairs` `studio.py:231` | state `repair_count` 计数器 + `recursionLimit` 硬刹车dossier §4-2 | **SAA 更强**Hook 可选叠加限次) |
| 11 | player 顾问轮 `run_player_panel` `studio.py:171-193` | **子图** `player-panel`vision+text 两节点)或单 `player` 节点 + 回边 | **SAA 更强**(子图封装/可并行 vision\|text |
| 12 | 失败回喂 `prompt.build_messages(retry_feedback)` `prompt.py:110` | repair 节点读 state `feedback` 注入 user promptstate 即传参) | 直接对应 |
| 13 | 模型路由 `config.model_name_for` + `models.yaml` `config.py:51-56` | N 个 `OpenAiChatModel` bean共用 `OpenAiApi`,仅 `defaultOptions.model` 异),节点 `@Qualifier`dossier §1-24 | **需适配**YAML 阶段→Spring 配置/Map<role,model> |
| 14 | `_client.chat`(裸 openaibase_url=new-api`_client.py:60-98` | 上游 `OpenAiApi.builder().baseUrl(NEW_API)` + `OpenAiChatModel`dossier §1-24 | 直接对应new-api OpenAI 兼容同构) |
| 15 | `RecordingChatModel` usage 记录 `config.py:26-42` | `ChatModel` 调用后读 `ChatResponse.metadata().usage()`,写入 state token 累计 | **需适配** |
| 16 | `cost.py` quota 折算 `cost.py:54-66` | 复用 Python `cost.py` 子进程**或** Java 重写 HttpClient 调 `/api/pricing`+`/api/status` | **需适配**(测而不闸,建议先复用脚本) |
| 17 | 网关代理旁路 `_client.py:35-44` | **不适用**JVM 不经 clash直连 new-api无此坑 | 直接对应(坑消失) |
| 18 | `service.py` HTTP 壳 + HMAC 回调 `service.py:155-262` | 形态①去掉(进程内 `SaaGraphDispatcher` 直调 `handleCallback`形态②Spring `@RestController /generate` + 后台线程池跑图 | **需适配**(见 §3.4 |
| 19 | 串行锁 `service.py:80` | 形态②`Semaphore(1)` 守 serve 4320/CDP 9222图执行在后台 `ExecutorService`dossier §3-45 | 直接对应 |
| 20 | 结果落盘 `results/*.json` `studio.py:312` | 节点写 stateemit 节点从 state 组 `DifyCallbackReqVO`+ 诊断落盘可选) | 直接对应 |
| — | **新增Python 无)** | **MysqlSaver checkpoint**(同 threadId 续跑/time-traveldossier §4-5 | **SAA 净增** |
| — | **新增Python 无)** | **节点级 streaming 进度**`stream()→Flux<NodeOutput>`SSE 推前端dossier §4-4 | **SAA 净增** |
---
## 3. SAA 目标图设计
### 3.1 节点 + 边组织
```mermaid
flowchart TD
START([START]) --> render[render<br/>取 job→state:brief/model/gameId/budget]
render --> design[design 节点<br/>设计稿+自产 gatespec→merge play_spec]
design --> generate[generate 节点<br/>code 模型出工厂源码]
generate --> validate[validate 节点<br/>静态契约+node --check 子进程]
validate -->|ok| scaffold[scaffold 节点<br/>落 factory+模板+play-spec]
validate -->|fail & cnt<N| repair[repair 节点<br/>fix 模型回喂失败原因]
scaffold --> build[build 节点<br/>ProcessBuilder esbuild]
build -->|ok| play[play 节点<br/>ProcessBuilder serve-and-play 九门]
build -->|fail & cnt<N| repair
play -->|九门 pass| player{{player-panel 子图<br/>vision+text 顾问·软门}}
play -->|fail & cnt<N| repair
play -->|fail & cnt>=N| giveup[giveup 节点<br/>status=failed]
repair --> generate
player -->|无体验问题 或 player轮用尽| emit[emit 节点<br/>读 bundle.iife.js→组 DifyCallbackReqVO]
player -->|有问题 & playerRound<M| repair
giveup --> END([END])
emit --> END
```
**关键边语义(条件边靠 `EdgeAction→String` + `mappings` Mapdossier §1-21**
- `validate`/`build`/`play` 三处失败回边**共用同一判据**`ok?下一步 : repair_count>=maxRepairs?"giveup" : "repair"`;失败原因写 state `feedback`repair 节点读取注入 prompt。
- `play` 成功边 → `player``player` 输出 verdict=fix 且 `player_round<maxPlayerRounds` → 回 `repair`**九门仍是硬地板**player 是软门,与 `studio.py:267-280` 同语义)。
- `repair` 节点统一自增 `repair_count` 后回 `generate`fix 与 code 同节点、靠 state `role` 区分模型;或拆两节点,建议**同节点 + role 标志**省图复杂度)。
### 3.2 用到的 SAA 强项(逐项对应本管线)
| SAA 强项 | 本管线落点 | 证据 |
|---|---|---|
| 图编排(条件/回环/子图) | 九门作条件边、repair 回环、player 子图 | dossier §0-1、§4-2 |
| MySQL checkpoint 恢复 | 单 job 多步昂贵design+多轮 LLM+九门 ~数十秒);崩溃后同 threadId 续跑 | dossier §4-5MysqlSaver 唯一权威 saver`releaseThread(false)` |
| Hook 限次 + state 计数器双保险 | `repair_count` 主限次 + `recursionLimit` 硬刹车(优雅终止非异常) | dossier §4-2、§5-3 |
| streaming 进度 | 节点 `apply` 返回塞 `Flux``stream()` 展开 → SSE 推前端「设计中/写码中/真玩中」 | dossier §4-4无 streamEvents`stream()`+`StreamingOutput` |
| 确定性九门作条件边 | `verdict.json` pass→边路由零猜测 | `play.cdp.cjs:666` |
| 子图封装 | player-panelvision\|text 可并行)封为子图,主图一个节点引用 | dossier §2嵌套子图本期可用 |
| 模型节点指 new-api | N 个 `OpenAiChatModel` 共用 `OpenAiApi`(baseUrl=new-api) | dossier §1-24 |
| 可观测埋点 | 节点级埋点/图可视化,替代 Python 散落 print | dossier §2 |
### 3.3 关键节点骨架草图(伪代码级,**非实现**
**state schemaKeyStrategyFactorydossier §1-22 用 ReplaceStrategy/AppendStrategy**
```
keys: brief, model(role→modelName map), gameId, templateId, budget,
designText, playSpec(JSON), factorySrc, verdict(JSON),
feedback, role(code|fix), repairCount, playerRound,
tokensIn/Out, status(succeeded|failed), failureReason, engineBundle
```
**generate / repair 节点NodeActiondossier §1-22 写靠 return Map**
```java
// 伪代码:读 state 拼 prompt首轮 code/重出 fix 回喂 feedback调对应模型抽 ```js
Map apply(OverAllState s){
String role = s.value("role","code"); // code|fix
ChatModel m = role.equals("fix") ? fixModel : codeModel; // @Qualifier 取,全指 new-api
String user = buildUser(s.value("brief"), s.value("designText"), s.value("feedback")); // 回喂全量重出
String text = m.call(systemPrompt, user); // systemPrompt = prompt.SYSTEM 等价
String src = extractJsBlock(text); // 等价 validate.extract_code
recordUsage(m, s); // 累计 token 入 state
return Map.of("factorySrc", src);
}
```
**validate 节点(静态 = 纯 Javanode --check = 子进程)**
```java
Map apply(OverAllState s){
List<String> errs = staticCheck(s.value("factorySrc")); // 默认导出/四方法/getEngine/12 禁用词
if(errs.isEmpty()) errs = nodeCheck(src); // ProcessBuilder("node","--check",tmp.mjs)
return errs.isEmpty()? Map.of("validateOk",true)
: Map.of("validateOk",false,"feedback","校验未过:\n"+join(errs));
}
```
**build / play 节点ProcessBuilder禁重写 harness骨架抄 LocalFilesystemBackend.java:457-507**
```java
Map apply(OverAllState s){ // play 节点
Process p = new ProcessBuilder("bash",
"games/_wg1-gen/_shared/serve-and-play.sh", gameDir, "4320","9222")
.directory(gameRuntimeRoot).start();
boolean done = p.waitFor(180, SECONDS); // 超时 destroyForcibly对齐 timeout=180
if(!done){ p.destroyForcibly(); return Map.of("playPass",false,"feedback","真玩超时"); }
JsonNode verdict = mapper.readTree(Path.of(gameDir,"evidence","verdict.json").toFile());
boolean pass = p.exitValue()==0 && verdict.path("pass").asBoolean(false);
return Map.of("verdict",verdict,"playPass",pass,
"feedback", pass? "" : summarizeFailedGuards(verdict)); // 等价 _verdict_feedback
}
```
**条件边EdgeActiondossier §1-21 mappings 不可空)**
```java
graph.addConditionalEdges("play",
edge_async(s -> {
if (Boolean.TRUE.equals(s.value("playPass"))) return "ok";
return s.value("repairCount",0) >= MAX_REPAIRS ? "giveup" : "repair";
}),
Map.of("ok","player", "repair","repair", "giveup","giveup"));
```
**emit 节点(组回调,复用现有写入路径)**
```java
Map apply(OverAllState s){
String bundle = readFile(gameDir+"/bundle.iife.js"); // 读 __GameBundle iife 全文
if(!bundle.contains("__GameBundle")) return Map.of("status","failed","failureReason","bundle_missing_global_name");
return Map.of("status","succeeded","engineBundle",bundle); // → DifyCallbackReqVO.engineBundle
}
```
### 3.4 与 Java 契约的对接(两形态)
- **形态①进程内 `SaaGraphDispatcher`生产收口首选dossier §0-5/§3-45**:实现 `GenerationDispatcher`(与 `WorkerDispatchClient` 同接口位),`dispatch(job)` 把 job 转 state在**后台 `ExecutorService`** `compiledGraph.invoke(state, RunnableConfig.threadId(traceId))`(自带阻塞,禁 Tomcat/Reactor 线程),出口直接进程内调 `difyCallbackService.handleCallback(reqVO)`——**消灭 HTTP 跳 + HMAC**(同信任域)。图执行**不包大 `@Transactional`**,跑完单独短事务落库。
- **形态②Java worker 进程(对比测试期 drop-in**Spring `@RestController` 暴露 `POST /generate`(顶替 Python `service.py`),收 §6.1 job→202 握手→后台线程跑图→组 `DifyCallbackReqVO`+HMAC POST 回 `/dify/callback-internal`。**Java 执行器零改**`aigc.executor.worker-url` 指向新 Java worker 即可),与 Python worker 并排,隔离变量做对比。
---
## 4. 复用 vs 重写边界(硬约束)
| 组件 | 决策 | 理由 |
|---|---|---|
| `serve-and-play.sh` / `play.cdp.cjs`(九门 AI/ `static-serve.cjs` | **子进程复用·禁重写** | 模型无关的确定性地板;重写 = 引入新假阴性/假绿风险,且 Java 无 CDP+esbuild 等价生态现货 |
| `scripts/build.mjs`esbuild 锁参) | **子进程复用·禁重写** | 锁参影响 SIZES 口径Java 无 esbuild 等价 |
| entry-bundle/index 模板、`_goldenpath-probe/factory.js` few-shot | **复用(读文件)** | 装载契约/few-shot 防漂移 |
| `cost.py` 成本折算 | **建议子进程复用**(或后续 Java 重写) | 测而不闸Python 脚本已沉淀,先复用省事 |
| `run.scaffold` 文件 IO / `validate.static_check` 静态扫描 / `extract_code` | **重写进 SAA 节点(纯 Java** | 轻逻辑,无生态依赖,进图后受 checkpoint/可观测管辖 |
| `run_studio` 命令式循环 / code-fix-player 编排 | **重写为 SAA 裸图** | 迁移主体;命令式→声明式图换取可恢复/限次/流式 |
| `_client.chat` / `config.build_model` 模型调用 | **重写为 SAA `OpenAiChatModel`** | 上游 OpenAI 兼容指 new-api同构 |
| `service.py` HTTP 壳 + HMAC | **形态①去掉 / 形态②Java 重写** | 见 §3.4 |
| `_extract_gatespec``json_repair` 兜脏 | **重写为最小宽松解析** | Java 无 json_repair做「strip ```gatespec→Jackson 容错→缺省补 expectLatch/ballPath 归一」最小等价 |
---
## 5. 「充分发挥 SAA 强项」清单(相比 Python 手写编排)
1. **可恢复checkpoint/resume**Python `run_studio` 一旦进程崩,整 job 从零重跑(`results/*.json` 仅事后产物。SAA MysqlSaver + `threadId(traceId)` → 崩溃后同 threadId 二次 invoke 即从最后节点续跑dossier §4-5与现有 watchdog 收尸(`AigcGenerateExecutor.java:620-644`)互补:**watchdog 兜超时、checkpoint 兜续跑**。
2. **限次双保险**Python 靠裸 `for attempt in range(max_repairs+1)` 计数(`studio.py:231`。SAA = state `repair_count`(业务语义主限次)+ `recursionLimit`(引擎级硬刹车,**优雅终止非异常**dossier §5-3ReAct 那种无限循环坑dossier §5-3在裸图下天然不触发。
3. **节点级流式进度**Python 仅 stdout `print``run.py:176``studio.py:338`前端无实时进度。SAA `stream()→Flux<NodeOutput>` + `StreamingOutput`dossier §4-4注意**无 streamEvents**)→ 出口 `@PostMapping(produces=TEXT_EVENT_STREAM)` 把「设计中→写码中→打包中→真玩中→第 k 次修复」推前端,直接对接 studio 创作页进度条。
4. **子图封装**Python `run_player_panel` 是内联函数(`studio.py:171`。SAA 把 player-panelvision\|text封为**子图**dossier §2 嵌套子图本期可用主图一节点引用vision/text 两位可并行Python 当前串行 `studio.py:180-192`)→ 顾问轮提速。
5. **可观测 + 可视化**Python 编排是黑盒命令式流。SAA 节点级埋点 + 图结构可导出dossier §2 可观测埋点),排障/审计/给非工程同事讲清「九门卡在哪个节点」远优于读 print 日志。
6. **确定性门即一等公民边**:九门 `verdict.pass` 在 Python 是 `if` 分支(`studio.py:262`);在 SAA 是 `addConditionalEdges` 的路由谓词——把「能玩才算 pass」这条铁律**固化进图拓扑**,杜绝顺手改 if 绕过门。
---
## 6. 风险与待定 + 迁移分步
### 6.1 风险与待定
| 级别 | 项 | 说明 / 缓解 |
|---|---|---|
| 高 | **AgentScope 多角色语义损失** | Python design/code/fix/player 是 AgentScope `Agent``studio.py:127-134``max_iters=1` 单轮。SAA 裸图下每角色=一个节点调一次模型,**单轮语义天然等价**(无工具自治),低风险;但 player 的「人格 + JSON 评判」需在节点内复刻 `roles.player_system``roles.py:38-57`),按字段对齐。 |
| 高 | **build/play 子进程环境耦合 mini-desktop** | serve-and-play.sh 依赖 Chrome+node+esbuild+lsof`serve-and-play.sh:11-44`),只在 mini-desktopx86 e2e 机跑。SAA worker无论形态①②**必须与 harness 同机部署**all localhostcwd=game-runtime 根。形态①把图嵌进 game-cloud → game-cloud 须能在 mini-desktop 起且能 fork 子进程(确认权限/PATH。 |
| 中 | **MysqlSaver 注入 yudao Druid + 一图一 saver** | dossier §3-13/§5-5MysqlSaver 收 `DataSource`(注 Druid但**一图只能注册一个 saver**注册俩抛异常Redis 走业务旁路或自写双写装饰器。首次接需验 `CREATE_IF_NOT_EXISTS` 建表与 yudao Flyway 不冲突。 |
| 中 | **依赖冲突Boot 3.5.14 / Redisson 4.4.0** | dossier §3不引 SAA Boot BOM让 3.5.14 胜)、不用 RedisSaver避 Redisson 3.x↔4.x。形态②独立 worker 可用独立 Boot 版避此坑——**对比期形态②额外降风险**。 |
| 中 | **json_repair 无 Java 等价** | 便宜模型 gatespec/player JSON 常脏(`studio.py:80-90` 靠 json_repair 兜。Java 需最小宽松解析截块→Jackson 容错→字段缺省);脏到 Jackson 也解不动时按「gatespec 缺失→退 brief 默认 play_spec」降级`_extract_gatespec` 返 None 同语义)。 |
| 低 | **代理旁路坑消失** | `_client.py:35-44` 的 NO_PROXY 坑是 Python+clash 本机特有JVM 不经 clash**迁移后此坑不存在**(净简化)。 |
| 待定 | **对比测试口径** | 「Python vs SAA 生成游戏」对比需固定:同 brief 集 + 同 modelstage1+ 同九门 harness + 同 maxRepairs比较维度 = 九门通过率/平均修复轮数/wall/¥/player 评分。建议形态②并排跑同一批 briefs`wg1/gen-worker/briefs/*.json` 16 款现货)。 |
### 6.2 迁移分步(可执行,供后续实现)
1. **门 E最便宜命题dossier §3-47**aigc-server pom 加 3 BOM + graph-core**当前 pom 尚无 SAA 依赖**,已核 `game-module-aigc-server/pom.xml`)→ `dependency:tree` 断言Boot 全 3.5.14 / Redisson 4.4.0 / 无 3.22.0 泄漏)→ 纯 Java hello `StateGraph` 节点 → `mvn package` + 单体启动门SAA 与 yudao 自动配置共存无 BeanCreation 失败)→ `invoke` 跑一节点。**先不接模型/saver/job 链。**
2. **模型节点接 new-api**:建 N 个 `OpenAiChatModel` bean共用 `OpenAiApi` baseUrl=new-apiper-role model 名),一个 generate 节点真出 ```js断言能拿到工厂源码 + usage。
3. **validate/scaffold/build/play 四节点接子进程**build/play 用 `ProcessBuilder` 调既有 harnessmini-desktopcwd=game-runtime读回 `verdict.json`;单测验九门 verdict 能解析、退出码正确传递。
4. **组装裸图 + 条件边**render→design→generate→validate→[repair 环]→scaffold→build→play→player→emit/giveupstate `repair_count`/`feedback` 回喂;跑通**一款** brief如 breakout端到端过九门。
5. **接 MysqlSaver checkpoint**:注 Druid DataSource`releaseThread(false)`,验同 threadId 续跑 + time-travel。
6. **接 streaming 进度**(可选,对比期可缓):节点塞 FluxSSE 出口验前端收到逐节点进度。
7. **形态②Java worker `/generate` 上线**Spring `@RestController` + 后台线程池 + `Semaphore(1)` 串行 + HMAC 回调;`aigc.executor.worker-url` 切到 Java worker并排 Python worker。
8. **对比测试**:同批 briefs 各跑 Python worker / SAA worker按 §6.1 待定口径出对比表(九门通过率/修复轮数/wall/¥/player 分)。
9. **形态①收口(生产)**:对比验证 SAA 不残废后,实现进程内 `SaaGraphDispatcher`(去 HTTP+HMAC灰度切 `HttpWorkerDispatcher | SaaGraphDispatcher`
---
> 验证状态:本设计为**只读分析 + 目标图设计**未写实现代码、未跑构建。Python 侧画像与 Java 契约边界均带 文件:行 证据已逐行读SAA 核心类文件位已核(`spring-ai-alibaba-graph-core`SAA 用法/坑引自 dossier四路源码取证。**下一步可验证项 = 迁移分步①门 E**最便宜、隔离「SAA 库能进 game-cloud」单命题
---
## 7. 实测进度与坑(增量更新)
### step 2 已过2026-06-15— SAA→new-api→生成源码 最小链
- 证据:`game-module-aigc-server``SaaNewApiGenerateSmokeTest` 绿1 passed`deepseek-v4-flash@100.64.0.8:3000` 真出 breakout 工厂源码5779 chars`export default createGame`usage prompt=336 / completion=5717。
- 文件:`.../aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaNewApiGenerateSmokeTest.java`
### 实测迁移坑(后续节点必看)
1. **【高频坑】Spring AI `OpenAiApi` baseUrl 不要带 `/v1`**Spring AI 自拼 `completionsPath=/v1/chat/completions`baseUrl 带 `/v1` → 404 `/v1/v1/...`。而 openai-python 的 base_url 要带 `/v1`。同一 `.env``NEWAPI_BASE_URL=.../v1`)两侧通用须 Java 侧 `stripV1Suffix()` 剥末尾 `/v1` → baseUrl 用 **host 根**
2. **`-am` reactor 跑单测**:上游模块无匹配测试会 "No tests matching" 失败 → 加 `-Dsurefire.failIfNoSpecifiedTests=false`
3. **key**`NEWAPI_KEY` 在 mini-desktop `/root/games-development-ai/wg1/gen-worker/.env`gitignoredworktree 内无);跑测试经环境变量传入。
4. **SAA model APIv1.1.2.2 javap 实证)**`OpenAiApi.builder().baseUrl(host根).apiKey()``OpenAiChatModel.builder().openAiApi().defaultOptions(OpenAiChatOptions.builder().model().temperature().maxTokens())``model.call(new Prompt(List.of(SystemMessage,UserMessage))).getResult().getOutput().getText()`usage=`resp.getMetadata().getUsage().getPromptTokens()`

View File

@ -0,0 +1,94 @@
# P4 · Python 生成 vs SAA 生成 对比测试(首轮 3 款)
> 日期 2026-06-15(执行 2026-06-16 凌晨,mini-desktop) · 框架对比 spike(HJ-AGI-002 后续)
> **一句话裁定见文末 §5。本页结论先行,证据路径见 §6。**
## 1. 目标与公平性铁律
同 brief 集、同 stage1 模型、同 maxRepairs、同九门 harness 下,**只换编排框架**——Python L2 studio(AgentScope 手写编排循环)vs SAA 裸 StateGraph 图——各跑一遍,公平对比「生成的游戏 + 生成过程」。
**公平性铁律(两侧严格相等):**
| 维度 | 取值(两侧一致) |
|---|---|
| brief 集 | breakout(paddle-intercept)/ whack(tap-targets via inputs)/ flappy(flap-to-gap),取自 `wg1/gen-worker/briefs/<name>.json` |
| 模型路由 | models.yaml **stage1**:design/code/fix/player_text=`deepseek-v4-flash`、player_vision=`MiniMax-M3` |
| maxRepairs | 5 |
| playerRounds | 1(player 为软门顾问;九门是硬地板) |
| 九门 harness | 同一 `serve-and-play.sh` + `play.cdp.cjs`(A_boot/B_uncaught/C_frame/D_render/E_live/F_wiring/G_input/H_progress/I_control) |
| prompt/角色 | SAA 侧 `SaaPrompts` 逐字移植 Python `prompt.py`/`roles.py`/`studio.py` 抽取逻辑 |
| play_spec 基线 | **两侧均取 brief 自带 `play_spec`**(含 inputs/driver/controlCheck/assertAfterPlay/expectedEngineCallPrefixes),design 自产 gatespec 再 merge 覆盖 exportState/driver/controlCheck/assertAfterPlay/expectLatch |
| 端口/cwd/key | 共用 4320/9222(串行,每 brief 净场);cwd=主仓 game-runtime;NEWAPI_KEY 经 env(baseUrl 含 /v1,SAA 侧剥 /v1) |
> **只换编排框架**:Python = `worker/agent_loop/studio.py` 的 AgentScope 进程内多 agent 手写循环;SAA = `SaaStudioGraphTest` 组装的 Spring-AI-Alibaba Graph 裸 StateGraph(render→design→generate→validate→[scaffold→build→play]→player→[repair 回环]→emit/giveup)。其余(模型/prompt/门/brief)全等。
## 2. 执行方式
- **串行**:两侧共用 4320/9222 不可并发;每 brief 先 Python 后 SAA,跑前净场(杀残留 Chrome/serve);每 run 唯一 gameId+目录避撞。Python gameId=`p4-py-<brief>`、SAA gameId=`saa-<brief>-<runId>`
- **单实例锁**:驱动脚本加 `flock` 单实例锁(首轮曾因外部编排并发双跑撞端口,数据作废重跑;v2 锁后杜绝)。
- **Python 侧**:`python -m worker.agent_loop.studio <gid> --stage stage1 --max-repairs 5 --player-rounds 1 --brief briefs/<brief>.json --port 4320 --cdp-port 9222``results/<gid>.stage1.json`(schema:seven_gate_verdict/attempts/tokens_by_model/cost/player)。
- **SAA 侧**:`mvn -o -pl ...aigc-server -am -Dtest=SaaStudioGraphTest -Dsaa.harness=1 -Dsaa.brief=<brief> -DGAME_RUNTIME_DIR=... -Dsaa.port=4320 -Dsaa.cdpPort=9222 test``results/saa-<brief>-<runId>.saa.json`(schema 已对齐 Python:含 ¥/per-model/attempts/player)。
- **本次为对比做的最小改动**(仅 SAA 测试脚手架,不动图/节点/prompt 等忠实部分):① `SaaStudioGraphTest` 参数化 `-Dsaa.brief`(原硬编码 breakout)+ 读 brief 自带 `play_spec` 作基线(公平对齐 Python `_load_brief`,原传 `{}` 会致 whack 无 inputs/各 brief 无 F 门期望前缀=比 Python 宽松);② diag JSON 落 `player` 字段(原仅打印 console)。
## 3. 对比表(每 brief × {Python, SAA})
<!-- 数据待全部跑完后填入。每格:九门pass(y/n) | 过门数(x/9) | 挂门 | repair | wall(s) | ¥ | player(vision c/f, text c/f, verdict) | tokens(in/out) -->
| brief | side | 九门pass | 过门数 | 挂门 | repair | wall(s) | ¥ | player vision(c/f·v) | player text(c/f·v) | tokens(in/out) | 备注 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| breakout | Python | _待填_ | | | | | | | | | |
| breakout | SAA | _待填_ | | | | | | | | | |
| whack | Python | _待填_ | | | | | | | | | |
| whack | SAA | _待填_ | | | | | | | | | |
| flappy | Python | _待填_ | | | | | | | | | |
| flappy | SAA | _待填_ | | | | | | | | | |
> 列说明:`player vision/text` 格式 = 完整度/好玩·verdict(c=completeness 1-5、f=fun 1-5、v=pass/fix)。vision=MiniMax-M3(看首帧+玩后截图)、text=deepseek-v4-flash(只读运行数据)。
## 4. 分析
<!-- 待数据填入后撰写 -->
## 5. 一句话裁定
<!-- 待填 -->
## 6. 证据路径
- 结果 JSON:`wg1/gen-worker/results/p4-py-{breakout,whack,flappy}.stage1.json`(Python)、`wg1/gen-worker/results/saa-{breakout,whack,flappy}-<runId>.saa.json`(SAA)
- 运行日志:`mini-desktop:/root/p4-logs/{py,saa}-<brief>.log`、驱动 `mini-desktop:/root/p4-logs/_driver.log`
- 九门证据四件套(每 run):`game-runtime/games/_wg1-gen/<gameId>/evidence/{verdict.json,first-paint.png,after-play.png}`
- SAA 图/节点/prompt(忠实部分):`saa-int/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/.../saa/{SaaStudioGraphTest,SaaStudioNodes,SaaGenNodes,SaaPrompts}.java`
- Python L2 studio:`wg1/gen-worker/worker/agent_loop/studio.py` + `worker/{run,prompt,validate,_client,cost}.py` + `worker/models.yaml`
## 7. 已知公平性脚注(便宜模型噪声 + 微差异)
- **单样本噪声**:design/code/fix=deepseek-v4-flash(便宜档,温度高/输出抖),首轮每 brief×side 各仅 **1 样本**,九门通过率/player 分/修复轮数受随机性影响大。**首轮 3 款是信号、非定论**;定量结论需多样本(≥3-5/格)。
- **code/fix 温度微差**:Python L2 的 design/code/fix 经 AgentScope `build_model`(不下发 temperature → 网关服务端默认);SAA code/fix 显式 temperature=0.0(更确定)。player 两位两侧均 0.3。此为已知微差异(SAA 作者 A-3 标注的对齐选择),不改两侧忠实部分,记此脚注。
- **player 软门不计入 pass**:九门(尤其 H_progress+latch)是硬地板;player 仅在九门过后作顾问轮,verdict=fix 且轮未尽才回修。两侧同口径。
---
## 结果(首轮 3 款 · 单样本 · 2026-06-16 03:4904:45
| brief | 侧 | 九门pass | 门(过/总) | 修复轮 | wall(s) | ¥ | tokens(in/out) | player(文 c/f, 视 c/f) | 失败门 |
|---|---|---|---|---|---|---|---|---|---|
| breakout | PY | ✅ | 9/9 | 0 | 186 | 0.049 | 12131/11029 | 4/4 · 4/3 | - |
| breakout | SAA | ✅ | 9/9 | 1 | 332 | 0.070 | 16847/21895 | 4/3 · 5/3 | - |
| whack | PY | ❌ | 8/9 | 5(giveup) | 652 | 0.083 | 27632/38238 | — | G_input |
| whack | SAA | ✅ | 9/9 | 3 | 415 | 0.088 | 24821/30900 | 4/4 · 4/3 | - |
| flappy | PY | ❌ | 8/9 | 5(giveup) | 837 | 0.104 | 29602/50636 | — | G_input |
| flappy | SAA | ❌ | 8/9 | 5(giveup) | 938 | 0.127 | 28441/66411 | — | H_progress |
## 裁定
- **SAA 与 Python 平手或更优**breakout 两侧均过;**whack SAA 过、PY 未过SAA 胜)**flappy 两侧均未过flap-to-gap 难便宜模型都吃力。SAA 通过率 2/3 ≥ Python 1/3。
- **SAA 迁移功能性等价、不残废**:同 prompt/模型/九门 harness 下,质量(player 分)相当、通过率不低于 Python。**「换编排框架不换效果」成立。**
- **成本/wall**SAA 略贵(多 design 步 + repairout tokens 偏高wall 互有胜负whack SAA 更快、breakout/flappy PY 更快)。
- ⚠️ **单样本噪声大**便宜模型随机性高whack 的 PY 败/SAA 胜、修复轮差异都可能在噪声内——**这是信号,非统计定论**。
- 观察:**两次 Python 失败都卡 G_input输入有效门**SAA 未卡——多样本下值得看是否真模式。
## 建议
1. **统计置信**:扩到 N≥35 样本 × 全 17 款 briefs 再下定论(当前各 1 样本)。
2. **SAA 侧已就绪**:双评审 + 9 项必修后完整/公平/正确可进生产收口form① 进程内 dispatcher + MysqlSaver checkpoint + streaming发挥 SAA 强项)。
3. **并发铁律**:批跑独占 4320/9222同一时刻只许一个驱动本轮踩过并发污染坑已纠正

View File

@ -18,7 +18,23 @@
被 huijing-server 聚合启动(随单体一起加载,无需独立 bootstrap
</description>
<!-- SAA(Spring AI Alibaba) BOM:仅管 SAA/spring-ai 制品版本;不引 spring-boot-dependencies,Boot/Redisson 以 huijing-dependencies 为准(保住 3.5.14/4.4.0) -->
<dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>1.1.2</version><type>pom</type><scope>import</scope></dependency>
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-bom</artifactId><version>1.1.2.2</version><type>pom</type><scope>import</scope></dependency>
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-extensions-bom</artifactId><version>1.1.2.2</version><type>pom</type><scope>import</scope></dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- ===== SAA agentic 编排框架 · 完整推荐集(HJ-AGI-002 spike;版本随上方 BOM,builtin-nodes 不在 BOM 故钉 1.1.2.2,TODO 统一) ===== -->
<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-agent-framework</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-graph-observation</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud.ai</groupId><artifactId>spring-ai-alibaba-starter-builtin-nodes</artifactId><version>1.1.2.2</version></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-openai</artifactId></dependency>
<!-- Spring Cloud 基础环境 -->
<dependency>
<groupId>com.wanxiang</groupId>

View File

@ -0,0 +1,468 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
/**
* SaaGenNodes SAA 迁移 step 3四个生成流水 NodeActionvalidate / scaffold / build / play
*
* <p>目标()证明SAA-Java 能驱动既有 build + 九门 harness本步与LLM 能否生成过门游戏<b>解耦</b>
* <b>金样 factory</b>已知过九门的 breakout/generated-factory.js作输入只验 harness 驱动链
*
* <p>语义逐一对齐 Python 参照wg1/gen-worker/worker/
* <ul>
* <li>{@link #validateNode()} validate.py:55-111static_check 静态契约扫描 + node --check ESM 语法门</li>
* <li>{@link #scaffoldNode(Path)} run.py:33-42 generated-factory.js + entry-bundle/index 模板 + play-spec.json</li>
* <li>{@link #buildNode(Path)} run.py:45-53ProcessBuilder node scripts/build.mjs ... --global-name=__GameBundle</li>
* <li>{@link #playNode(Path)} run.py:56-72ProcessBuilder bash serve-and-play.shtimeout 180s evidence/verdict.json</li>
* </ul>
*
* <p>纪律本步边界对齐迁移设计 §6.2-step3不接 LLM不调 new-api不接 saver不接 job 契约不组全图
* harnessserve-and-play.sh / play.cdp.cjs / build.mjs<b>子进程复用·禁重写</b>模型无关确定性地板
*
* <p>状态 key 约定OverAllState
* <ul>
* <li>{@code factorySrc}(in) 工厂源码金样或后续 generate 节点产出</li>
* <li>{@code playSpec}(in) play-spec JSON 文本驱动九门 F/G/H/I</li>
* <li>{@code gameId}(in) 唯一游戏 id games/_wg1-gen/&lt;id&gt;/避免覆盖现有游戏</li>
* <li>{@code validateOk}/{@code validateErrors} validate 产出</li>
* <li>{@code gameDir} scaffold 落盘目录绝对路径</li>
* <li>{@code buildOk}/{@code buildLog} build 产出</li>
* <li>{@code verdict}(JSON)/{@code playPass}/{@code feedback} play 产出playPass=九门是否全过</li>
* </ul>
*
* @author 造梦AI
*/
final class SaaGenNodes {
private SaaGenNodes() {
}
/** Jackson读 verdict.json / play-spec 透传。yudao 自带 jackson-databind 2.21.x。 */
private static final ObjectMapper MAPPER = new ObjectMapper();
/** esbuild iife 全局名(契约冻结:宿主从 window.__GameBundle 取;出厂自检也据此)。 */
private static final String GLOBAL_NAME = "__GameBundle";
// ====================== 子进程驱动统一超时·并发 drain·强杀对齐 Python subprocess.run(timeout=) ======================
/**
* 子进程运行结果{@link #runProcess}
* <p>修复点评审最高优原实现先 {@code readAllBytes()} {@code waitFor()}若子进程输出量大塞满管道缓冲
* 而我们又卡在 readAllBytes 读不完子进程会因写阻塞永不退出 {@code waitFor(timeout)} 形同虚设timeout 到不了
* 故改为启动后<b>另起线程并发 drain stdout 到内存 buffer</b>管道不堵主线程只 {@code waitFor(timeout)}
* 超时则 {@code destroy()}{@code destroyForcibly()}二次 {@code waitFor}日志取 tail对齐 run.py 子进程 timeout 语义
*/
static final class ProcResult {
final boolean timedOut; // true=超时被强杀未在限时内退出
final int exitCode; // 进程退出码timedOut=true 时无意义 -1
final String output; // 合并后的 stdoutredirectErrorStream已含 stderr
ProcResult(boolean timedOut, int exitCode, String output) {
this.timedOut = timedOut;
this.exitCode = exitCode;
this.output = output;
}
}
/**
* 统一子进程运行器build/play/node-check 三处共用合并 stderrstdout<b>并发 drain 防管道死锁</b>
* 限时 {@code waitFor}超时 destroydestroyForcibly二次 waitFor 兜底强杀
*
* @param pb 已配好 command/directory ProcessBuilder本方法负责 redirectErrorStream(true)
* @param timeoutSecs 超时秒数对齐 Python 对应 subprocess.run(timeout=)
* @return ProcResulttimedOut / exitCode / output
* @throws IOException 进程启动失败
* @throws InterruptedException 等待被中断向上抛由调用方按节点语义处理
*/
static ProcResult runProcess(ProcessBuilder pb, long timeoutSecs) throws IOException, InterruptedException {
pb.redirectErrorStream(true);
Process p = pb.start();
// 并发 drain另起守护线程把 stdout 抽干到内存 buffer确保子进程写管道永不阻塞修复 readAllBytes/waitFor 死锁
ByteArrayOutputStream buf = new ByteArrayOutputStream();
Thread drain = new Thread(() -> {
try (InputStream in = p.getInputStream()) {
byte[] chunk = new byte[8192];
int n;
while ((n = in.read(chunk)) != -1) {
synchronized (buf) {
buf.write(chunk, 0, n);
}
}
} catch (IOException ignore) {
// 进程结束/管道关闭引发的读异常可忽略已尽量抽干
}
}, "saa-proc-drain");
drain.setDaemon(true);
drain.start();
boolean done = p.waitFor(timeoutSecs, TimeUnit.SECONDS);
if (!done) {
// 超时先温和 destroy再强杀二次短等确保回收对齐 Python destroy()destroyForcibly()二次 waitFor
p.destroy();
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.destroyForcibly();
p.waitFor(2, TimeUnit.SECONDS);
}
}
// drain 线程收尾最多 2s把已抽到的输出取出超时场景取 tail 由调用方做
drain.join(2000);
String output;
synchronized (buf) {
output = buf.toString(StandardCharsets.UTF_8);
}
return new ProcResult(!done, done ? p.exitValue() : -1, output.trim());
}
// ====================== validate 节点 ======================
// 静态契约扫描 validate.py:55-77 语义+ node --check ESM validate.py:80-100
/** 禁用模式(正则, 说明)——违反即【契约违约】,拦在打包前。逐条对齐 validate.py:_FORBIDDEN。 */
private static final Object[][] FORBIDDEN = {
{Pattern.compile("(^|\\n)\\s*import\\s"), "禁 import工厂必须自包含单文件含禁 import littlejsengine"},
{Pattern.compile("\\bimport\\s*\\("), "禁动态 import()"},
{Pattern.compile("\\brequire\\s*\\("), "禁 require"},
{Pattern.compile("littlejsengine"), "禁引擎名字空间"},
{Pattern.compile("\\bMath\\.random\\b"), "禁 Math.random用 ctx.random"},
{Pattern.compile("\\bDate\\.now\\b"), "禁 Date.now用 ctx.time.nowMs"},
{Pattern.compile("\\bperformance\\.now\\b"), "禁 performance.now用 ctx.time"},
{Pattern.compile("\\baddEventListener\\b"), "禁 addEventListener用 ctx.getInput().on"},
{Pattern.compile("\\brequestAnimationFrame\\b"), "禁 requestAnimationFrame宿主驱动"},
{Pattern.compile("new\\s+AudioContext"), "禁 new AudioContext用 ctx.getAudioContext"},
{Pattern.compile("\\bdocument\\s*\\."), "禁直接操作 document"},
{Pattern.compile("\\bwindow\\s*\\."), "禁直接操作 window"},
};
/**
* 去行注释/块注释避免禁用词扫描被注释里的字样误伤对齐 validate.py:_strip_comments
*/
private static String stripComments(String src) {
String s = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(src).replaceAll("");
s = Pattern.compile("//[^\\n]*").matcher(s).replaceAll("");
return s;
}
/**
* 静态契约检查对齐 validate.py:static_check返回错误列表=
* 规则源码非空且60 / export default / 四方法(init·update·render·destroy)标识符存在 /
* ctx 用法 / 至少一次 getEngine真接线门信号/ 12 条禁用词
*/
static List<String> staticCheck(String src) {
List<String> errors = new ArrayList<>();
if (src == null || src.trim().length() < 60) {
errors.add("源码为空或过短(疑似未产出有效代码)");
return errors;
}
if (!src.contains("export default")) {
errors.add("缺少 export default必须默认导出工厂函数");
}
String scan = stripComments(src);
// 四方法存在性宽松方法名作为标识符出现
for (String meth : new String[]{"init", "update", "render", "destroy"}) {
if (!Pattern.compile("\\b" + meth + "\\b").matcher(scan).find()) {
errors.add("缺少 " + meth + " 方法");
}
}
// 必须经 boot.ctx 取能力至少出现 ctx. 用法 宽松判 ctx 标识符存在
if (!scan.contains("ctx")) {
errors.add("未见 ctx 用法(能力须经 boot.ctx");
}
// 必须至少用一次 getEngine硬性验收点真接线门要有信号
if (!scan.contains("getEngine")) {
errors.add("未调用 getEngine()(必须在某事件上用一次引擎能力,否则真接线门无信号)");
}
// 禁用词扫描
for (Object[] f : FORBIDDEN) {
Pattern pat = (Pattern) f[0];
if (pat.matcher(scan).find()) {
errors.add("违禁:" + f[1]);
}
}
return errors;
}
/**
* node --check ESM 语法门对齐 validate.py:node_syntax_check返回错误=
* 写临时 .mjs ProcessBuilder("node","--check",tmp)退出码 0=否则取 stderr
*/
static String nodeCheck(String src) {
Path tmp = null;
try {
tmp = Files.createTempFile("saa-validate-", ".mjs");
Files.write(tmp, src.getBytes(StandardCharsets.UTF_8));
// 子进程经统一运行器并发 drain 防死锁 + 30s 超时强杀对齐 validate.py subprocess.run(timeout=30)
ProcResult r = runProcess(new ProcessBuilder("node", "--check", tmp.toString()), 30);
if (r.timedOut) {
return "node --check 超时";
}
if (r.exitCode == 0) {
return "";
}
return r.output.isEmpty() ? "node --check 失败(无输出)" : r.output;
} catch (IOException | InterruptedException e) {
return "node --check 异常:" + e.getMessage();
} finally {
if (tmp != null) {
try {
Files.deleteIfExists(tmp);
} catch (IOException ignore) {
// 临时文件清理失败可忽略
}
}
}
}
/**
* validate 节点对齐 validate.py:validate静态过了才跑 node --check省时
* statevalidateOk(boolean) / validateErrors(List) / feedback(失败回喂文字)
*/
static NodeAction validateNode() {
return (OverAllState s) -> {
String src = s.value("factorySrc", String.class).orElse("");
List<String> errs = staticCheck(src);
if (errs.isEmpty()) {
String nc = nodeCheck(src);
if (!nc.isEmpty()) {
String trimmed = nc.length() > 500 ? nc.substring(0, 500) : nc;
errs = new ArrayList<>();
errs.add("语法错误node --check" + trimmed);
}
}
Map<String, Object> out = new HashMap<>();
boolean ok = errs.isEmpty();
out.put("validateOk", ok);
out.put("validateErrors", errs);
if (!ok) {
StringBuilder fb = new StringBuilder("校验未过:\n");
for (String e : errs) {
fb.append("- ").append(e).append("\n");
}
out.put("feedback", fb.toString());
clearPlayerFeedback(s, out); // A-4硬门失败清空 player 软门反馈杜绝串味仅全图注册了该 key 时生效
}
return out;
};
}
// ====================== scaffold 节点 ======================
// Java 文件 IO run.py:33-42 语义
/**
* scaffold 节点对齐 run.py:scaffold把工厂源码落 game-runtime/games/_wg1-gen/&lt;id&gt;/
* generated-factory.jsentry-bundle import './generated-factory.js'
* + _shared/entry-bundle.template.js entry-bundle.js
* + _shared/index.template.html index.html
* + play-spec.json来自 state.playSpec
* stategameDir落盘目录绝对路径
*
* @param gameRuntimeRoot 主仓 game-runtime node_modules+esbuild+_shared 脚本+金样
*/
static NodeAction scaffoldNode(Path gameRuntimeRoot) {
return (OverAllState s) -> {
String gameId = s.value("gameId", String.class).orElseThrow(
() -> new IllegalStateException("scaffold 节点缺 gameId"));
String factorySrc = s.value("factorySrc", String.class).orElse("");
String playSpec = s.value("playSpec", String.class).orElse("{\"inputs\":[],\"expectedEngineCallPrefixes\":[]}");
Path genDir = gameRuntimeRoot.resolve("games").resolve("_wg1-gen");
Path shared = genDir.resolve("_shared");
Path gameDir = genDir.resolve(gameId);
Files.createDirectories(gameDir);
// 官方靶名 generated-factory.jsentry-bundle.template.js import './generated-factory.js'
Files.write(gameDir.resolve("generated-factory.js"), factorySrc.getBytes(StandardCharsets.UTF_8));
// 拷模板entry-bundle + index
Files.copy(shared.resolve("entry-bundle.template.js"), gameDir.resolve("entry-bundle.js"),
StandardCopyOption.REPLACE_EXISTING);
Files.copy(shared.resolve("index.template.html"), gameDir.resolve("index.html"),
StandardCopyOption.REPLACE_EXISTING);
// play-spec.json驱动九门 F/G/H/I
Files.write(gameDir.resolve("play-spec.json"), playSpec.getBytes(StandardCharsets.UTF_8));
Map<String, Object> out = new HashMap<>();
out.put("gameDir", gameDir.toString());
return out;
};
}
// ====================== build 节点 ======================
// ProcessBuilder 调既有 esbuild 锁参脚本 run.py:45-53harness 禁重写
/**
* build 节点对齐 run.py:buildnode scripts/build.mjs &lt;entry&gt; &lt;out&gt; --global-name=__GameBundle
* cwd=主仓 game-runtime断言出 bundle.iife.js
* statebuildOk(boolean) / buildLog(esbuild 日志失败回喂用) / feedback(失败时)
*
* @param gameRuntimeRoot 主仓 game-runtime cwdesbuild 在此 node_modules
*/
static NodeAction buildNode(Path gameRuntimeRoot) {
return (OverAllState s) -> {
String gameId = s.value("gameId", String.class).orElseThrow(
() -> new IllegalStateException("build 节点缺 gameId"));
String rel = "games/_wg1-gen/" + gameId;
ProcessBuilder pb = new ProcessBuilder(
"node", "scripts/build.mjs",
rel + "/entry-bundle.js", rel + "/bundle.iife.js",
"--global-name=" + GLOBAL_NAME);
pb.directory(gameRuntimeRoot.toFile());
// 子进程经统一运行器并发 drain 防死锁 + 120s 超时强杀对齐 run.py:build subprocess.run(timeout=120)
ProcResult r;
try {
r = runProcess(pb, 120);
} catch (IOException | InterruptedException e) {
Map<String, Object> oe = new HashMap<>();
oe.put("buildOk", false);
String msg = "打包子进程异常:" + e.getMessage();
oe.put("buildLog", msg);
oe.put("feedback", "打包失败esbuild\n" + msg);
return oe;
}
String log = r.output;
boolean ok;
if (r.timedOut) {
ok = false;
log = "打包超时esbuild\n" + tail(log, 600);
} else {
ok = r.exitCode == 0
&& Files.exists(gameRuntimeRoot.resolve(rel).resolve("bundle.iife.js"));
}
Map<String, Object> out = new HashMap<>();
out.put("buildOk", ok);
out.put("buildLog", log);
if (!ok) {
String head = log.length() > 600 ? log.substring(0, 600) : log;
out.put("feedback", "打包失败esbuild\n" + head);
clearPlayerFeedback(s, out); // A-4硬门失败清空 player 软门反馈杜绝串味
}
return out;
};
}
// ====================== play 节点 ======================
// ProcessBuilder 调既有九门 harness run.py:56-72harness 禁重写
/**
* play 节点对齐 run.py:playbash games/_wg1-gen/_shared/serve-and-play.sh &lt;id&gt; 4320 9222
* cwd=主仓 game-runtimetimeout 180sdestroyForcibly evidence/verdict.json解析 pass
* 九门 pass 判定 = 退出码 0 verdict.pass对齐 run.py:run_job `rc==0 and verdict.pass`
* stateverdict(JSON) / playPass(boolean) / feedback(失败摘守卫对齐 _verdict_feedback)
*
* @param gameRuntimeRoot 主仓 game-runtime cwdserve-and-play.sh / Chrome / static-serve 在此
*/
static NodeAction playNode(Path gameRuntimeRoot) {
return (OverAllState s) -> {
String gameId = s.value("gameId", String.class).orElseThrow(
() -> new IllegalStateException("play 节点缺 gameId"));
// serve-and-play.sh 第二参=HTTP 端口第三参=CDP 端口脚本自带净场杀这两端口
// A-9端口参数化 state 默认 4320/9222worker 并行形态可每 run 错开对照 run.py:56-63 port/cdp_port 入参
int port = ((Number) s.value("port", 4320)).intValue();
int cdpPort = ((Number) s.value("cdpPort", 9222)).intValue();
ProcessBuilder pb = new ProcessBuilder(
"bash", "games/_wg1-gen/_shared/serve-and-play.sh", gameId,
String.valueOf(port), String.valueOf(cdpPort));
pb.directory(gameRuntimeRoot.toFile());
// 子进程经统一运行器并发 drain 防死锁 + 180s 超时强杀对齐 run.py:play subprocess.run(timeout=180)
ProcResult r = runProcess(pb, 180);
String pout = r.output;
Map<String, Object> out = new HashMap<>();
if (r.timedOut) {
out.put("playPass", false);
out.put("feedback", "真玩超时180s\n" + tail(pout, 600));
clearPlayerFeedback(s, out); // A-4硬门失败清空 player 软门反馈杜绝串味
return out;
}
int rc = r.exitCode;
// 读回 evidence/verdict.json九门判定权威产物
Path vpath = gameRuntimeRoot.resolve("games").resolve("_wg1-gen")
.resolve(gameId).resolve("evidence").resolve("verdict.json");
JsonNode verdict = null;
if (Files.exists(vpath)) {
try {
verdict = MAPPER.readTree(vpath.toFile());
} catch (IOException e) {
// verdict 解析失败 null下方判失败
out.put("feedback", "verdict.json 解析失败:" + e.getMessage());
}
}
boolean pass = rc == 0 && verdict != null && verdict.path("pass").asBoolean(false);
out.put("playPass", pass);
if (verdict != null) {
// 以字符串形态塞 state避免 KeyStrategy 序列化 JsonNode 的额外约束诊断读回即可
out.put("verdict", verdict.toString());
}
if (!pass && !out.containsKey("feedback")) {
out.put("feedback", summarizeFailedGuards(verdict));
}
if (!pass) {
clearPlayerFeedback(s, out); // A-4硬门失败清空 player 软门反馈杜绝串味
}
return out;
};
}
/**
* A-4 辅助硬门validate/build/play失败时清空 player 软门反馈避免上一轮 player 体验问题串到本轮失败回喂
* <p>仅当当前图<b>注册了 playerFeedback 策略</b>全图 SaaStudioGraphTest时才写step3 最小图未注册该 key跳过不破坏
*/
private static void clearPlayerFeedback(OverAllState s, Map<String, Object> out) {
if (s.containStrategy("playerFeedback")) {
out.put("playerFeedback", "");
}
}
/**
* 把失败的守卫摘成回喂文字对齐 run.py:_verdict_feedback
*/
static String summarizeFailedGuards(JsonNode verdict) {
if (verdict == null) {
return "真玩阶段无 verdict 产出(疑似装载即崩/超时)。";
}
List<String> lines = new ArrayList<>();
JsonNode guards = verdict.path("guards");
if (guards.isObject()) {
Iterator<Map.Entry<String, JsonNode>> it = guards.fields();
while (it.hasNext()) {
Map.Entry<String, JsonNode> e = it.next();
if (!e.getValue().path("pass").asBoolean(false)) {
String g = e.getValue().toString();
lines.add("- 守卫 " + e.getKey() + " 未过:" + (g.length() > 200 ? g.substring(0, 200) : g));
}
}
}
if (verdict.hasNonNull("error")) {
String err = verdict.get("error").asText();
lines.add("- 运行错误:" + (err.length() > 300 ? err.substring(0, 300) : err));
}
return lines.isEmpty() ? "真玩未过(原因未知)。" : "真玩未过:\n" + String.join("\n", lines);
}
/** 取字符串尾部 n 字(日志摘要用)。 */
private static String tail(String s, int n) {
if (s == null) {
return "";
}
return s.length() <= n ? s : s.substring(s.length() - n);
}
}

View File

@ -0,0 +1,206 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static com.alibaba.cloud.ai.graph.StateGraph.END;
import static com.alibaba.cloud.ai.graph.StateGraph.START;
import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SaaHarnessDriveTest SAA 迁移 step 3证明SAA-Java 能驱动既有 build + 九门 harness
*
* <p>LLM 能否生成过门游戏<b>解耦</b><b>金样 factory</b>breakout/generated-factory.js
* 已知过九门作输入只验 {@link SaaGenNodes} 四节点validate/scaffold/build/play<b>驱动链</b>
*
* <p>两组验证
* <ol>
* <li>{@link #validate_passes_golden_and_rejects_broken()}validate 节点单独验金样过故意坏样不过不依赖 harness 跑机故无条件跑</li>
* <li>{@link #golden_drives_nine_gates_to_pass()}scaffoldbuildplay 三节点串成最小 StateGraph
* 金样经 SAA 真驱动既有九门 harness断言 <b>verdict.pass=true</b> mini-desktopnode+esbuild+Chrome
* {@code -Dsaa.harness=1} 时才跑否则跳过避免在无 harness 机误判 fail</li>
* </ol>
*
* <p>纪律迁移设计 §6.2-step3不接 LLM不接 saver不接 job 契约不组全图下一步harness 子进程复用·禁重写
*
* <p>运行mini-desktopcwd=主仓 game-runtime node_modules+esbuild+_shared 脚本+金样
* <pre>
* mvn -pl ...aigc-server -am -Dtest=SaaHarnessDriveTest \
* -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
* -Dsurefire.failIfNoSpecifiedTests=false test
* </pre>
*
* @author 造梦AI
*/
class SaaHarnessDriveTest {
/** 主仓 game-runtime 根harness 运行时所在;有 node_modules+esbuild+_shared 脚本+金样)。可被系统属性覆盖。 */
private static final Path GAME_RUNTIME_ROOT = Paths.get(
System.getProperty("GAME_RUNTIME_DIR",
System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime")));
/** step3 专用唯一 game id避免覆盖现有游戏 breakout。 */
private static final String GAME_ID = "saa-step3-golden";
/**
* 读金样 factory 源码test resources权威已过九门 breakout/generated-factory.js 的拷贝
*/
private static String readGoldenFactory() throws Exception {
try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/generated-factory.js")) {
assertNotNull(in, "金样 factory 资源 /saa-golden/generated-factory.js 缺失");
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
/** 读金样 play-spec驱动九门 F/G/H/I。 */
private static String readGoldenPlaySpec() throws Exception {
try (var in = SaaHarnessDriveTest.class.getResourceAsStream("/saa-golden/play-spec.json")) {
assertNotNull(in, "金样 play-spec 资源 /saa-golden/play-spec.json 缺失");
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
/**
* 故意坏样违反多条静态契约 import / Math.random / getEngine / destroyvalidate 必须判不过
*/
private static final String BROKEN_FACTORY = ""
+ "'use strict';\n"
+ "import {foo} from 'bar';\n" // 违禁import
+ "export default function createGame(opts){\n"
+ " const o = opts || {};\n"
+ " let r = Math.random();\n" // 违禁Math.random
+ " function init(b){ /* no ctx, no getEngine */ }\n" // ctx/getEngine
+ " function update(dt){ r += dt; }\n"
+ " function render(g){ g.fillRect(0,0,r,r); }\n"
+ " return { init, update, render };\n" // destroy
+ "}\n";
// ====================== validate 节点单测金样过 / 坏样不过 ======================
@Test
void validate_passes_golden_and_rejects_broken() throws Exception {
// -- 坏样静态契约即应拦下不跑 node --check 也能列错断言不过且命中预期违约 --
List<String> brokenErrs = SaaGenNodes.staticCheck(BROKEN_FACTORY);
System.out.println("[validate/broken] errors=" + brokenErrs);
assertFalse(brokenErrs.isEmpty(), "故意坏样应被静态契约判不过");
assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("import")), "应命中 import 违禁");
assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("Math.random")), "应命中 Math.random 违禁");
assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("getEngine")), "应命中缺 getEngine");
assertTrue(brokenErrs.stream().anyMatch(e -> e.contains("destroy")), "应命中缺 destroy 方法");
// -- 金样静态契约应全过空错误列表--
String golden = readGoldenFactory();
List<String> goldenStaticErrs = SaaGenNodes.staticCheck(golden);
System.out.println("[validate/golden static] errors=" + goldenStaticErrs);
assertTrue(goldenStaticErrs.isEmpty(), "金样应过静态契约,实得错误:" + goldenStaticErrs);
// -- 金样node --check ESM 语法门也应过 node PATHmini-desktop --
String nc = SaaGenNodes.nodeCheck(golden);
System.out.println("[validate/golden node--check] " + (nc.isEmpty() ? "OK" : nc));
assertTrue(nc.isEmpty(), "金样应过 node --check实得" + nc);
}
// ====================== scaffoldbuildplay 三节点串图金样过九门 verdict.pass=true ======================
@Test
@EnabledIfSystemProperty(named = "saa.harness", matches = "1",
disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过避免误判 fail开关 -Dsaa.harness=1")
void golden_drives_nine_gates_to_pass() throws Exception {
// 预检主仓 game-runtime + _shared 脚本 + esbuild 必须就位否则给清晰指引而非晦涩子进程错误
assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT);
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")),
"缺 serve-and-play.shharness 子进程地板),路径:" + GAME_RUNTIME_ROOT);
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
"缺 node_modules/esbuildbuild 节点依赖),请在 game-runtime 跑 npm i -D esbuild");
String golden = readGoldenFactory();
String playSpec = readGoldenPlaySpec();
// -- 串接START -> scaffold -> build -> play -> END条件边留下一步本步直串验驱动链通--
KeyStrategyFactory keyFactory = () -> {
Map<String, KeyStrategy> keys = new HashMap<>();
for (String k : new String[]{"factorySrc", "playSpec", "gameId",
"gameDir", "buildOk", "buildLog", "verdict", "playPass", "feedback"}) {
keys.put(k, new ReplaceStrategy());
}
return keys;
};
StateGraph graph = new StateGraph("saa-step3-harness-drive", keyFactory)
.addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT)))
.addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT)))
.addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT)))
.addEdge(START, "scaffold")
.addEdge("scaffold", "build")
.addEdge("build", "play")
.addEdge("play", END);
CompiledGraph compiled = graph.compile();
Map<String, Object> inputs = new HashMap<>();
inputs.put("factorySrc", golden);
inputs.put("playSpec", playSpec);
inputs.put("gameId", GAME_ID);
long t0 = System.currentTimeMillis();
Optional<OverAllState> result = compiled.invoke(inputs);
long dt = System.currentTimeMillis() - t0;
assertTrue(result.isPresent(), "图 invoke 应返回 state");
OverAllState s = result.get();
boolean buildOk = s.value("buildOk", Boolean.class).orElse(false);
boolean playPass = s.value("playPass", Boolean.class).orElse(false);
String gameDir = s.value("gameDir", String.class).orElse("?");
String verdict = s.value("verdict", String.class).orElse("(no verdict)");
String feedback = s.value("feedback", String.class).orElse("");
// == 证据各节点产出 + verdict 全文含各门 guard 结果==
System.out.println("================ SAA step3 harness 驱动证据 ================");
System.out.println("[gameId] " + GAME_ID + " cwd=" + GAME_RUNTIME_ROOT);
System.out.println("[gameDir] " + gameDir);
System.out.println("[buildOk] " + buildOk);
System.out.println("[playPass] " + playPass + " wall=" + dt + "ms");
if (!feedback.isEmpty()) {
System.out.println("[feedback] " + feedback);
}
System.out.println("[verdict] " + verdict);
System.out.println("==========================================================");
// == 断言金样经 SAA 四节点真驱动九门 verdict.pass=true ==
assertTrue(buildOk, "build 节点应成功产出 bundle.iife.js");
assertTrue(playPass, "金样应经 SAA play 节点驱动九门拿到 verdict.pass=truefeedback=" + feedback);
// 进一步落锤直接读回 verdict.json断言 pass=true 且九门核心守卫全过证明真到九门判定非空过
Path vpath = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(GAME_ID)
.resolve("evidence/verdict.json");
assertTrue(Files.exists(vpath), "应产出 verdict.json" + vpath);
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var v = mapper.readTree(vpath.toFile());
assertEquals(true, v.path("pass").asBoolean(false), "verdict.json pass 应为 true");
// 核心确定性门A 装载 / B 未捕获 / C 掌帧 / D 真渲 / E 活性 / F 真接线 / G 输入全过
for (String g : new String[]{"A_boot", "B_uncaught", "C_frame", "D_render", "E_live", "F_wiring", "G_input"}) {
assertEquals(true, v.path("guards").path(g).path("pass").asBoolean(false),
"九门守卫 " + g + " 应 passverdict=" + v);
}
}
}

View File

@ -0,0 +1,222 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
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.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 java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.alibaba.cloud.ai.graph.StateGraph.END;
import static com.alibaba.cloud.ai.graph.StateGraph.START;
import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SAA -> new-api -> 生成游戏工厂源码 最小冒烟迁移分步 step 2 首件
*
* 目标()证明SAA game-cloud 内能调 new-api 生成游戏工厂源码这一条链
* START -> generate(node) -> ENDgenerate 节点用 OpenAiChatModel( new-api,
* 模型=models.yaml stage1 code 角色=deepseek-v4-flash) js 代码块, js
* (等价 Python validate.extract_code),断言拿到非空工厂源码
*
* 纪律(对齐 dossier 0/6.2-step2)
* - 只验SAA->new-api->生成源码最小链不接 build/play harness不接 saver不接 job 契约
* - 密钥只走环境变量 NEWAPI_KEY( Python worker _client.get_api_key 口径,绝不入库);
* 未设 NEWAPI_KEY @EnabledIfEnvironmentVariable 直接跳过(不算失败,"密钥外置"一致)
* - system prompt.SYSTEM 的精简等价(模块默认导出 createGame + init/update/render/destroy +
* getEngine 容错),先不求完整(harness 九门是后续 step 的事)
*
* 运行 mini-desktop
* NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaNewApiGenerateSmokeTest test
*
* @author 造梦AI
*/
@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+")
class SaaNewApiGenerateSmokeTest {
/**
* new-api OpenAI 兼容网关 host ( Python _client.BASE_URL;可被 NEWAPI_BASE_URL 覆盖)
* 注意 Spring-AI 口径 openai-python 口径openai SDK base_url 要带 /v1(Python worker 配的就是 .../v1),
* Spring AI OpenAiApi 会自己拼默认 completionsPath=/v1/chat/completions, baseUrl 必须是host 不带 /v1,
* 否则拼成 /v1/v1/chat/completions 404(spike 实测坑)这里把末尾 /v1 剥掉,让同一 .env 值两侧通用
*/
private static final String NEW_API_BASE_URL = stripV1Suffix(
System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1"));
/** 剥掉末尾的 /v1(及多余结尾斜杠),适配 Spring AI baseUrl=host 根的口径。 */
private static String stripV1Suffix(String url) {
String u = url.trim();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
if (u.endsWith("/v1")) {
u = u.substring(0, u.length() - 3);
}
return u;
}
/** stage1 code 角色模型(models.yaml: stage1.roles.code = deepseek-v4-flash) */
private static final String STAGE1_CODE_MODEL = "deepseek-v4-flash";
/** state key工厂源码(generate 节点产出) */
private static final String K_FACTORY_SRC = "factorySrc";
/** state key本次模型名(诊断用) */
private static final String K_MODEL = "modelName";
/** state keyusage 累计(诊断用) */
private static final String K_USAGE = "usage";
/**
* 精简版 system promptprompt.SYSTEM 的最小等价
* 只保留模块形状 + 生命周期 + getEngine 容错 + 只输出一个 js 代码块
* 足够让便宜模型产出一个可抽取的工厂源码;不含九门/好玩接地(后续 step)
*/
private static final String SYSTEM_PROMPT = ""
+ "你是轻量 H5 小游戏代码生成器。唯一产出 = 一个独立的 GameHostFactory 模块(原生 JS ESM 单文件)。\n"
+ "【输出格式·严格】只输出一个 ```js 代码块,块内是完整工厂源码,块外不要任何解释文字。\n"
+ "【模块形状】\n"
+ "- 文件用 'use strict'; 开头。\n"
+ "- 默认导出工厂函数:export default function createGame(opts) { ... return { init, update, render, destroy, getEngine }; }\n"
+ "- 返回的实例必须有 init / update / render / destroy 四个方法;另导出 getEngine() 返回引擎能力或 null(必须容错)。\n"
+ "【生命周期(宿主这样驱动)】\n"
+ "- game = createGame(opts) // opts 可能 undefined,需 const o = opts || {} 防御\n"
+ "- await game.init(boot) // boot = { ctx, mainContext, canvas, seed }\n"
+ "- game.update(dt) // dt 恒 = 1/60 秒\n"
+ "- game.render(g) // g === boot.mainContext(2D 画布上下文)\n"
+ "- game.destroy() // 卸载,幂等\n"
+ "【硬禁止】禁 import 任何包(自包含单文件);禁 Math.random/Date.now(用 ctx.random/ctx.time);禁 addEventListener/requestAnimationFrame。\n";
@Test
void saa_graph_calls_newapi_and_generates_factory_source() throws Exception {
String key = System.getenv("NEWAPI_KEY");
assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)");
// == 1) 建指向 new-api OpenAiChatModel(共用一个 OpenAiApi,defaultOptions.model=便宜档) ==
OpenAiApi openAiApi = OpenAiApi.builder()
.baseUrl(NEW_API_BASE_URL)
.apiKey(key)
.build();
OpenAiChatModel codeModel = OpenAiChatModel.builder()
.openAiApi(openAiApi)
.defaultOptions(OpenAiChatOptions.builder()
.model(STAGE1_CODE_MODEL)
.temperature(0.0) // 确定性产出(对齐 Python _client.chat temperature=0)
.maxTokens(16000)
.build())
.build();
// == 2) 最小 StateGraphSTART -> generate -> END ==
// KeyStrategyFactory三个 key 都用 ReplaceStrategy(覆盖,非追加)
KeyStrategyFactory keyFactory = () -> {
Map<String, KeyStrategy> keys = new HashMap<>();
keys.put(K_FACTORY_SRC, new ReplaceStrategy());
keys.put(K_MODEL, new ReplaceStrategy());
keys.put(K_USAGE, new ReplaceStrategy());
return keys;
};
StateGraph graph = new StateGraph("saa-newapi-smoke", keyFactory)
.addNode("generate", node_async(state -> {
// generate 节点 code 模型出 js , js (等价 validate.extract_code)
String brief = state.value("brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜。");
Prompt prompt = new Prompt(List.of(
new SystemMessage(SYSTEM_PROMPT),
new UserMessage("请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 js 代码块):\n\n" + brief)));
ChatResponse resp = codeModel.call(prompt);
String content = resp.getResult().getOutput().getText();
String src = extractJsBlock(content); // js
// usage(对齐 Python RecordingChatModel每次调用记 token)
String usageStr = "(no usage)";
Usage u = resp.getMetadata().getUsage();
if (u != null) {
usageStr = "prompt=" + u.getPromptTokens()
+ " completion=" + u.getCompletionTokens()
+ " total=" + u.getTotalTokens();
}
Map<String, Object> out = new HashMap<>();
out.put(K_FACTORY_SRC, src);
out.put(K_MODEL, STAGE1_CODE_MODEL);
out.put(K_USAGE, usageStr);
return out;
}))
.addEdge(START, "generate")
.addEdge("generate", END);
CompiledGraph compiled = graph.compile();
// == 3) invoke ,断言拿到非空工厂源码 ==
Optional<OverAllState> result = compiled.invoke(Map.of(
"brief", "打砖块 breakout:挡板接球消砖,清空所有砖块即胜,掉球则负。"));
assertTrue(result.isPresent(), "图 invoke 应返回 state");
OverAllState finalState = result.get();
String factorySrc = finalState.value(K_FACTORY_SRC, String.class).orElse("");
String modelName = finalState.value(K_MODEL, String.class).orElse("?");
String usage = finalState.value(K_USAGE, String.class).orElse("?");
// == 真实响应证据模型名 + usage + 源码前 20 ==
System.out.println("================ SAA->new-api 冒烟证据 ================");
System.out.println("[model] " + modelName + " @ " + NEW_API_BASE_URL);
System.out.println("[usage] " + usage);
System.out.println("[src len] " + factorySrc.length() + " chars");
System.out.println("[src head 20 lines] >>>>>>>>");
String[] lines = factorySrc.split("\n", -1);
for (int i = 0; i < Math.min(20, lines.length); i++) {
System.out.println(String.format("%3d| %s", i + 1, lines[i]));
}
System.out.println("<<<<<<<< end head");
System.out.println("=====================================================");
// 断言拿到非空工厂源码,且形状像工厂( export default + createGame)
assertTrue(factorySrc.length() > 60, "工厂源码应非空且有实质内容,实得长度=" + factorySrc.length());
assertTrue(factorySrc.contains("export default"),
"工厂源码应含 export default(默认导出工厂),实得头部:\n" + factorySrc.substring(0, Math.min(200, factorySrc.length())));
assertTrue(factorySrc.contains("createGame"),
"工厂源码应含 createGame 工厂名");
}
/**
* 从模型输出抽 js 代码块(等价 Python validate.extract_code)
* 优先 ```js/```javascript;退而取首个 ``` ;再无则:若含 export default 用全文,否则空串
*/
private static String extractJsBlock(String content) {
if (content == null || content.isEmpty()) {
return "";
}
// ```js / ```javascript
Matcher m = Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(content);
if (m.find()) {
return m.group(1).strip();
}
// 任意 ``` (取第一个)
m = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL).matcher(content);
if (m.find()) {
return m.group(1).strip();
}
// fence export default 则用全文
if (content.contains("export default")) {
return content.strip();
}
return "";
}
}

View File

@ -0,0 +1,319 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* SaaPrompts SAA 迁移 step 4 Python 生成系统的 prompt / 角色 / gatespec 抽取<b>逐字移植</b> Java
*
* <p>公平性铁律对比测试只换编排框架不换 prompt/模型本类内容与 Python 侧逐字对齐<b>不得改写语义</b>
* <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</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>
*
* <p>few-shot Python 同口径 game-runtime/games/_wg1-gen/_goldenpath-probe/factory.js读文件防漂移prompt.py:9-20
*
* @author 造梦AI
*/
final class SaaPrompts {
private SaaPrompts() {
}
private static final ObjectMapper MAPPER = new ObjectMapper();
/**
* 宽松 JSON 解析器A-5等价 Python json_repair 的常见容错
* 容许单引号未引号字段名尾随逗号注释控制字符便宜模型高频脏点尽量救活而非静默退 {}
*/
private static final ObjectMapper LENIENT = com.fasterxml.jackson.databind.json.JsonMapper.builder()
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
.enable(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES)
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS)
.build();
/** few-shot 参照工厂相对 game-runtime 根的路径(与 prompt.py:_PROBE 同一文件,读文件防漂移)。 */
static final String PROBE_REL = "games/_wg1-gen/_goldenpath-probe/factory.js";
// ============================================================================
// SYSTEM 逐字移植 prompt.py:24-107__FEWSHOT__ 运行时注入
// Java 文本块对 \\ " 有转义,原文为 Python 原始串(r""")且不含反斜杠故内容逐字一致
// ============================================================================
static final String SYSTEM = """
你是轻量 H5 小游戏代码生成器你的唯一产出 = 一个独立的 GameHostFactory 模块原生 JS ESM 单文件
宿主会把它打成 bundle 在浏览器里真玩你不写 HTML不写宿主不写引擎只写这一个工厂模块
输出格式·严格只输出一个 ```js 代码块块内是完整工厂源码块外不要任何解释文字
模块形状
- 文件用 'use strict'; 开头
- 默认导出一个工厂函数export default function createGame(opts) { ... return { init, update, render, destroy, _forensicsView }; }
- 工厂返回 GameInstance必须有 init / update / render / destroy 四个方法可选 onInput但优先用 ctx.getInput()
必须导出 _forensicsView可测性红线见下harness 据此确定性判"游戏真有进展"
生命周期宿主这样驱动你
- game = createGame(opts) // opts 可能为 undefined必须 const o = opts || {} 防御
- await game.init(boot) // 一次性boot = { ctx, mainContext, canvas, seed }
- game.update(dt) // 每逻辑步dt 恒定 = 1/60
- game.render(g) // 每渲染帧g === boot.mainContext同一个 2D 画布上下文
- game.destroy() // 卸载幂等
能力一律经 boot.ctxinit 里存下 ctx 再用
- ctx.getEngine() -> 引擎能力 | null可能为 null必须容错返回三个面
· getEngine().particles.spawnEmitter(spec) -> 句柄{isActive(),stop()}引擎自动渲染粒子到画布你无需手动画
spec 字段( pos 全可选)pos:{x,y}, count(爆发颗数), emitRate, particleTime, speed, coneAngle,
sizeStart, sizeEnd, colorStart:{r,g,b,a}(0..1), colorEnd:{r,g,b,a}, additive坐标=逻辑像素
· getEngine().audio.synth.synthSfx(paramArray) -> 合成音效样本ZzFX 参数数组 [1,0.05,220,,,0.18,,1.4]
· getEngine().math.lerp(a,b,t) / math.smoothStep(t) / math.easing?.quadOut(t)easing 是可选 ?. 防御
- ctx.getInput() -> { on(type, handler) -> {cancel()}, off(...) }
type 'pointerdown'|'pointermove'|'pointerup'|'keydown'|'keyup'
handler 收事件 { type, x, y, key, tMs }x/y 已是逻辑像素坐标x[0,390]y[0,844]可直接当像素用做命中判定/定位**绝对不要再乘以视口宽高**key 仅键盘有意义
- ctx.time.nowMs() / ctx.time.elapsedMs() // 受控时钟update 首行应 const now = ctx.time.nowMs();
- ctx.random.next() / ctx.random.range(min,max) / ctx.random.reseed(seed) // 受控随机
- boot.mainContext 就是 render(g) g 一个标准 CanvasRenderingContext2D画在它上面fillRect/arc/fillText/drawImage
视口逻辑坐标系固定 390 × 844 移动竖屏按逻辑像素绘制不要自己处理 DPR
硬禁止违反则装载/运行直接失败
- import 任何引擎/外部包不要 import 'littlejsengine'不要 import 任何东西只写一个自包含模块
- Math.random() ctx.random Date.now()/performance.now() ctx.time
注意Math.sin/cos/floor/max/min/PI 允许只有 Math.random
- addEventListener ctx.getInput().on requestAnimationFrame宿主驱动 update/render
- new AudioContext / document / window / 直接操作全局 canvas / 直接 new 引擎对象
- render 只画到入参 g不要去拿别的画布
硬规则务必遵守
1. 闭包状态游戏对象/分数/数组/订阅句柄 init 内初始化不要写在工厂函数顶层常量里
2. update 首行刷新 const now = ctx.time.nowMs(); 全帧用它不要自己累加私有时钟
3. ctx.getEngine() 可能返回 null所有用法都要 const eng = ctx.getEngine(); if (eng && eng.particles) {...}easing ?.
4. destroy 里取消所有输入订阅try { sub.cancel(); } catch(_){}幂等
5. 必须在某个游戏事件点击/得分/碰撞/胜负上至少调用一次 getEngine() 的能力粒子爆发或音效
这样真玩门能证明游戏真用了引擎/插件库这是硬性验收点别只用纯 canvas 自绘
6. 画面必须每帧有可见变化动起来背景要铺满 390×844别留透明否则判白屏
7. 资产容错不要依赖外部图片 canvas 几何图形画矩形//路径/文字
可测性红线 + P0 latch 终态必须做宿主据此判游戏结束代发 game_end 走结算做不到=游戏关不上闭环=判不通过
- 工厂返回的实例除 init/update/render/destroy 必须导出 _forensicsView 方法纯只读视图仿官方范本 generated-factory.js
_forensicsView() { return { seed, state: () => ({ phase, score, remaining, progress, gameoverReason }) }; }
- state() 必须是闭包实时读当前游戏状态的函数每次调用返回最新值不要返回开局快照常量
- phase = 权威终态字段取值 'booting' | 'playing' | 'gameover'
· init 后置 'playing'可玩
· 满足结束条件通关 / 失败 / 超时 任一 'gameover'
- P0 latch 驻留·硬约束一旦进入 'gameover'phase 必须恒驻留 'gameover'**禁止下一帧自动重开 / 复位回 playing**
原因宿主每 500ms 轮询 host.state().phase读到 'gameover' 才代发 game_end 结算若终态是瞬时态某帧 gameover 下帧又变轮询会漏读 游戏永远结算不了**结束后必须停住驻留终态不自动重开**
- 其余字段反映真实状态与画面一致
· score当前分数数字 0
· remaining剩余目标数剩余砖块/敌人/回合/目标数字确无此概念可置 null
· progress0~1 进度可选数字
· gameoverReason终态原因短串 'cleared_all' / 'ball_lost' / 'timeout'未结束置 ''
· 题面若要求额外导出关键实体的实时位置如球/挡板/玩家/敌人的 {x,y}速度 {vx,vy}尺寸 {w,h}
按题面指定的字段名导出 ball:{x,y,vx,vy}paddle:{x,y,w}harness 据此适配性真玩自动接球等+
确定性验手感控制是否跟手碰撞是否正确位置必须是闭包实时真值与画面一致
- 红线含义撞砖加分score 真涨砖被消remaining 真减判负/通关phase 真切 'gameover' **驻留**
harness 会真玩到 gameover 并验驻留 + 真有进展remaining 下降等终态瞬时/数值原地不动 = 判不通过
好玩接地玩家 agent 反复揪的高频缺口务必做到否则真玩判分必低
- HUD 看得到进展除分数外显示剩余目标数(剩余砖块/敌人/回合/目标)让玩家 30 秒内明确感到在推进HUD 文字置顶部留白区别和游戏元素叠字
- 反馈看得见getEngine().particles.spawnEmitter 用够强参数(count20sizeStart 8~14particleTime0.4colorStart 用亮色且 a=1)否则截图里看不到粒子=等于没反馈关键事件(命中/得分/胜负)都要爆一次
- 必有失败态 + 胜负演出游戏要会输(掉命/超时/破产任一)且胜/负时有明确画面变化或文案(别只停住不动)
- 节奏可上手开局不要立刻高难或死局 30 秒要能上手有正反馈看得到分数增长
下面是一个最小但处处合规的参照实现请严格模仿它的结构取能力方式防御写法但实现成题目要求的游戏注意参照件只示范合规骨架你要在其上做到上面的好玩接地
```js
__FEWSHOT__
```
""";
// ============================================================================
// DESIGN_SYSTEM 逐字移植 roles.py:8-32设计 agent + 末尾自产 gatespec
// ============================================================================
static final String DESIGN_SYSTEM = """
你是轻量 H5 小游戏的设计 agent输入是一句话/一段题面你的产出是一份**简洁可直接实现**的设计稿供下游代码 agent 写成游戏
只产出设计稿绝不写代码输出用紧凑中文条目覆盖
1. 核心机制玩家做什么目标是什么一两句说清玩法循环
2. 胜负条件怎样算赢怎样算输**必须有"会输"的失败态**超时/失误/资源耗尽任一
3. 操作触摸/按键如何映射到动作记住坐标是逻辑像素 390×844 竖屏
4. 关键数值速度/数量/尺寸/难度梯度的建议区间给具体数别含糊
5. juice 反馈哪些事件该有粒子/音效/屏震至少 1 处必须触发引擎能力
6. 画面要点背景铺满主要元素形状与颜色HUD分数等
7. 可观测进展点明"玩家在推进"的可量化信号如剩余目标数下降 / 分数上升 / 关卡推进并说明胜负如何切状态这是判"真有进展、非空心"的依据
约束设计正文控制在 ~200 字内聚焦"代码 agent 实现时需要的决策"不堆砌辞藻不写引擎/契约细节那是代码 agent 的事
末尾必附 gate-spec机器可读·harness 据此确定性验"能玩 + 手感"·这是 gate-H 推广到任意游戏的关键
设计正文之后另起一个 ```gatespec 代码块块内为 JSON声明本游戏怎样被确定性验证
```gatespec
{
"exportState": ["phase","score","remaining","progress","<关键实体位置:如 ball:{x,y,vx,vy}、paddle:{x,y,w};纯点击类可省位置>"],
"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游戏必须能真玩到 gameover 且驻留 "连续控制体(挡板/角色随手指平滑移动)"才给 `controlCheck`纯点击/无连续控制给 null 技巧类挡板接球/接物`driver.type="paddle-intercept"``ballPath`=**球的水平 x 坐标字段**挡板水平移动去对齐它几乎总是 `ball.x`**绝不要填 ball.y**`paddleY`=挡板所在 y纯点击类用 `{"type":"none"}` `assertAfterPlay` 至少一条"真有进展"断言op increased/decreased/changed/>/>=/</<=/==/inpath 用你让代码导出的字段名须与 exportState 一致""";
// ============================================================================
// player_system 逐字移植 roles.py:38-57玩家 agent 人格 + 1-5 分锚 + 空心必 fix
// ============================================================================
/** 玩家 agent system promptpersona 注入),逐字对齐 roles.py:player_system。 */
static String playerSystem(String persona) {
return "你是一名【玩家 agent】人格设定" + persona + "\n"
+ "你会拿到一款 H5 小游戏的【运行数据】(确定性 harness 真玩取证:是否真渲染/输入是否真生效/调了哪些引擎能力、"
+ "以及 H_progress 机制进展门是否过),可能还附【首帧截图】与【玩后截图】。"
+ "请像真玩家那样、严格基于**证据**判断(不要脑补没看到的东西)。\n\n"
+ "【第一步必须先判「真有进展」,再谈好玩】对比首帧↔玩后截图、看运行数据:\n"
+ "- 若画面玩前玩后几乎无变化,或数据显示分数/剩余/进度【零变化】(如剩余砖数没减、分数没动),"
+ "或 H_progress(机制进展)门未过 → 判定为【空心游戏】fun 必须给 1~2、verdict=fix。**空心绝不给 3 分及以上。**\n"
+ "- 截图里看不到反馈(粒子/数字跳动/元素消除)→ 按「反馈缺失」扣分。\n\n"
+ "【好玩 fun(1-5) 分锚——务必拉开档,别都给 3】\n"
+ " 1 = 不能玩 / 空心零进展2 = 能跑但无聊(无反馈 / 无失败态 / 原地打转);"
+ "3 = 基础可玩循环成立、有反馈但平淡4 = 有手感、看得到进展、想再玩一把5 = 惊艳,节奏与反馈都到位。\n"
+ "【完整度 completeness(1-5)】能玩吗?核心循环 / 可见反馈 / 胜负态是否齐全(空心或缺胜负 → ≤2\n"
+ "【verdict】真有进展且至少基础可玩(fun≥3) → 可 pass空心或明显体验缺陷 → fix。"
+ "别对每个游戏都 fix会失去区分力也别放过空心。\n"
+ "诚实、挑剔,但分数要反映**真实差距**:好游戏和空壳必须给出不同的分。\n"
+ "【只输出 JSON】{\"completeness\":1-5, \"fun\":1-5, \"problems\":[\"...\"], "
+ "\"verdict\":\"pass\"\"fix\", \"note\":\"一句话总评\"}。";
}
// ============================================================================
// build_messages 逐字移植 prompt.py:110-119few-shot 注入 + 回喂全量重出
// ============================================================================
/**
* 组装 (system, user)逐字对齐 prompt.py:build_messages
*
* @param briefText 题面含设计稿对齐 studio.py:216 enriched
* @param retryFeedback 重试时回喂上轮失败原因null=首轮 code
* @param gameRuntimeRoot game-runtime few-shot 探针 factory
* @return [0]=system已注入 few-shot[1]=user
*/
static String[] buildMessages(String briefText, String retryFeedback, Path gameRuntimeRoot) {
String system = SYSTEM.replace("__FEWSHOT__", fewShot(gameRuntimeRoot));
String user = "请实现下面这款游戏,产出一个合规的 GameHostFactory 模块(只输出一个 ```js 代码块):\n\n" + briefText;
if (retryFeedback != null && !retryFeedback.isEmpty()) {
user += "\n\n———\n上一次生成【未通过】失败原因如下请针对性修正后重新产出完整模块不要只给 diff\n"
+ retryFeedback;
}
return new String[]{system, user};
}
/** 读 few-shot 探针 factory读文件防漂移对齐 prompt.py:_few_shot。 */
static String fewShot(Path gameRuntimeRoot) {
try {
return new String(Files.readAllBytes(gameRuntimeRoot.resolve(PROBE_REL)), StandardCharsets.UTF_8);
} catch (Exception e) {
return "(few-shot 参照缺失)";
}
}
// ============================================================================
// extractGatespec 逐字移植 studio.py:72-102 json_repair做最小宽松解析
// ============================================================================
/** ```gatespec 块抽取正则(对齐 studio.py:75。 */
private static final Pattern GATESPEC = Pattern.compile("```gatespec\\s*(.+?)```", Pattern.DOTALL);
/**
* A-5设计稿里是否<b>存在</b> ```gatespec 不论能否解析
* 用于区分无块静默退 brief 默认 play_spec对齐 Python有块但解析失败须记 gatespecParseError 并回喂
*/
static boolean hasGatespecBlock(String designText) {
return designText != null && GATESPEC.matcher(designText).find();
}
/**
* 从设计稿抽 ```gatespec JSON gate-H 推广对齐 studio.py:_extract_gatespec
* 正则抠块 Jackson 容错解析 json_repair做最小宽松解析 缺省补 expectLatch=true
* paddle-intercept ballPath .y.x 安全归一无块/解析失败返回 null
*
* @return 解析出的 gatespecJsonNode对象 null
*/
static JsonNode extractGatespec(String designText) {
if (designText == null) {
return null;
}
Matcher m = GATESPEC.matcher(designText);
if (!m.find()) {
return null;
}
String raw = m.group(1).strip();
JsonNode obj = looseParse(raw);
if (obj == null || !obj.isObject()) {
return null;
}
com.fasterxml.jackson.databind.node.ObjectNode on = (com.fasterxml.jackson.databind.node.ObjectNode) obj;
// 轻校验assertAfterPlay list list 则置空数组对齐 studio.py:93-94
if (on.has("assertAfterPlay") && !on.get("assertAfterPlay").isArray()) {
on.set("assertAfterPlay", MAPPER.createArrayNode());
}
// expectLatch 缺省补 true官方 latch 契约恒需对齐 studio.py:95
if (!on.has("expectLatch")) {
on.put("expectLatch", true);
}
// 安全归一paddle-intercept ballPath .y.x便宜模型偶把 ball.y 当横向对齐 studio.py:97-101
JsonNode drv = on.get("driver");
if (drv != null && drv.isObject() && "paddle-intercept".equals(drv.path("type").asText())) {
JsonNode bp = drv.get("ballPath");
if (bp != null && bp.isTextual() && bp.asText().endsWith(".y")) {
String fixed = bp.asText();
((com.fasterxml.jackson.databind.node.ObjectNode) drv)
.put("ballPath", fixed.substring(0, fixed.length() - 2) + ".x");
}
}
return on;
}
/**
* 宽松 JSON 解析替代 Python json_repairA-5 加强
* 严格 Jackson 截最外层 {...} 去前后说明文字 + 去尾随逗号后再严格
* 仍不行则用 {@link #LENIENT}容单引号/未引号字段名/尾逗号/注释/控制字符兜底
* 三关全败返回 null降级=退 brief 默认 play_spec
*/
static JsonNode looseParse(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
// 严格解析
try {
return MAPPER.readTree(raw);
} catch (Exception ignore) {
// 落到宽松分支
}
// 截最外层 {...}去掉块内可能混入的前后说明文字+ 去对象/数组尾随逗号,] / ,}
int lo = raw.indexOf('{');
int hi = raw.lastIndexOf('}');
if (lo < 0 || hi <= lo) {
return null;
}
String body = raw.substring(lo, hi + 1).replaceAll(",\\s*([}\\]])", "$1");
try {
return MAPPER.readTree(body);
} catch (Exception ignore) {
// 落到宽松 mapper 兜底单引号/未引号字段名/注释等
}
// 宽松 mapper等价 json_repair 常见容错最后一搏
try {
return LENIENT.readTree(body);
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,451 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.CompileConfig;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import static com.alibaba.cloud.ai.graph.StateGraph.END;
import static com.alibaba.cloud.ai.graph.StateGraph.START;
import static com.alibaba.cloud.ai.graph.action.AsyncEdgeAction.edge_async;
import static com.alibaba.cloud.ai.graph.action.AsyncNodeAction.node_async;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* SaaStudioGraphTest SAA 迁移 <b>step 4关键里程碑</b>组装<b> StateGraph</b> + 跑通一款真 brief 端到端
* 产出第一款 SAA 真生成的游戏
*
* <p>全图拓扑蓝图 §3.1
* <pre>
* START render design generate validate
* validate --ok--> scaffold --> build
* validate --fail&cnt&lt;N--> repair build --ok--> play build --fail&cnt&lt;N--> repair
* play --九门pass--> player play --fail&cnt&lt;N--> repair play --fail&cnt&gt;=N--> giveup
* player --无体验问题/轮尽--> emit player --有问题&轮未尽--> repair
* repair --> generate emit/giveup --> END
* </pre>
*
* <p>公平性铁律对比测试只换编排框架prompt/角色/模型路由全部走 {@link SaaPrompts}逐字移植 Python wg1/gen-worker
* 模型路由用 models.yaml stage1design/code/fix/player_text=deepseek-v4-flashplayer_vision=MiniMax-M3
*
* <p>验收口径按任务要求理想 verdict.pass=true maxRepairs 内未过门只要<b>全图机制</b>条件边/repair 回环/
* 各节点真跑通也算 step4 达成过门质量是后续调参/对比的事
*
* <p>纪律step4 边界emit 不接 DifyCallbackReqVO/HMACstep7saver 用默认内存态MysqlSaver step5不接 job 契约
*
* <p>运行mini-desktop NEWAPI_KEY + harness
* <pre>
* NEWAPI_KEY=sk-... mvn -pl ...aigc-server -am -Dtest=SaaStudioGraphTest \
* -Dsaa.harness=1 -DGAME_RUNTIME_DIR=/root/games-development-ai/game-runtime \
* -Dsurefire.failIfNoSpecifiedTests=false test
* </pre>
*
* @author 造梦AI
*/
@EnabledIfEnvironmentVariable(named = "NEWAPI_KEY", matches = ".+")
@EnabledIfSystemProperty(named = "saa.harness", matches = "1",
disabledReason = "需 mini-desktop harness 运行机(node+esbuild+Chrome),无机时跳过;开关 -Dsaa.harness=1")
class SaaStudioGraphTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 主仓 game-runtime 根harness + few-shot 探针所在)。 */
private static final Path GAME_RUNTIME_ROOT = Paths.get(
System.getProperty("GAME_RUNTIME_DIR",
System.getenv().getOrDefault("GAME_RUNTIME_DIR", "/root/games-development-ai/game-runtime")));
/**
* 对比测试 brief -Dsaa.brief=breakout|whack|flappy|...默认 breakout
* P4 对比测试参数化同一全图按不同 brief 各跑一遍只换 brief编排框架/模型/门全等
*/
private static final String BRIEF_NAME = System.getProperty("saa.brief", "breakout");
/** step4/P4 专用唯一 game id 基名(避免撞现有游戏;随 brief 名变run 内再缀 runId。 */
private static final String GAME_ID = "saa-" + BRIEF_NAME;
/** new-api host 根Spring AI baseUrl 不带 /v1剥之坑见 §7-1。 */
private static final String NEW_API_BASE_URL = stripV1Suffix(
System.getenv().getOrDefault("NEWAPI_BASE_URL", "http://100.64.0.8:3000/v1"));
/** 修复轮上限(对齐 studio.py:max_repairs 默认 5。 */
private static final int MAX_REPAIRS = 5;
/** player 顾问轮上限(对齐 studio.py:player_rounds 默认 1。 */
private static final int MAX_PLAYER_ROUNDS = 1;
/**
* 递归硬刹车repair 回环成环时的超步预算优雅终止非异常对齐蓝图 §3.2 限次双保险
* A-7写死推导式 = (maxRepairs+1) × 每轮经过节点数 + 余量
* 每轮主链最长路径节点数 7render/design 仅首轮各 1 循环体 generatevalidatescaffoldbuildplayplayerrepair
* (5+1)×7=42 再宽放余量至 120远超实际所需确保正常收口不会误触超步
*/
private static final int RECURSION_LIMIT = (MAX_REPAIRS + 1) * 7 + 78; // =42+78=120
// models.yaml stage1 角色模型名逐字对齐 wg1/gen-worker/worker/models.yaml
private static final String M_DESIGN = "deepseek-v4-flash";
private static final String M_CODE = "deepseek-v4-flash";
private static final String M_FIX = "deepseek-v4-flash";
private static final String M_PLAYER_TEXT = "deepseek-v4-flash";
private static final String M_PLAYER_VISION = "MiniMax-M3";
private static String stripV1Suffix(String url) {
String u = url.trim();
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
if (u.endsWith("/v1")) {
u = u.substring(0, u.length() - 3);
}
return u;
}
/**
* 建一个指向 new-api OpenAiChatModel共用 OpenAiApi defaultOptions 对齐 dossier §1-24
* A-3per-role 采样面随请求下发temperature/maxTokens 按角色逐字对齐 Python studio.py 各位置
*
* @param temperature 采样温度<b>null=不下发 temperature</b>让网关用服务端默认对齐 AgentScope 默认
* Python config.build_model 不传 generate_kwargsdesign 等角色即用服务端默认温度
* @param maxTokens 最大补全 tokenscode/fix=16000player_text=4000player_vision=900对齐 studio.py:160/167
*/
private static OpenAiChatModel model(OpenAiApi api, String modelName, Double temperature, int maxTokens) {
OpenAiChatOptions.Builder opts = OpenAiChatOptions.builder()
.model(modelName)
.maxTokens(maxTokens);
if (temperature != null) {
opts.temperature(temperature);
}
return OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(opts.build())
.build();
}
@Test
void full_graph_generates_one_breakout_end_to_end() throws Exception {
String key = System.getenv("NEWAPI_KEY");
assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)");
// 预检harness + few-shot 探针就位
assertTrue(Files.isDirectory(GAME_RUNTIME_ROOT), "GAME_RUNTIME_DIR 不存在:" + GAME_RUNTIME_ROOT);
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("games/_wg1-gen/_shared/serve-and-play.sh")),
"缺 serve-and-play.shharness 地板),路径:" + GAME_RUNTIME_ROOT);
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
"缺 node_modules/esbuildbuild 节点依赖)");
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve(SaaPrompts.PROBE_REL)),
"缺 few-shot 探针 factory" + SaaPrompts.PROBE_REL);
// == 1) N 个共用 OpenAiApi 的角色模型全指 new-api公平性= Python 同模型路由==
OpenAiApi api = OpenAiApi.builder().baseUrl(NEW_API_BASE_URL).apiKey(key).build();
// A-3per-role 采样面temperature/maxTokens逐字对齐 Python
// designPython config.build_model 不传 temperature AgentScope 用服务端默认 此处 temperature=null不下发
// code/fix_client.chat temperature=0.0max_tokens=16000确定性产出
// player_textstudio.py:167 _judge_text max_tokens=4000temperature=0.3
// player_visionstudio.py:160 _judge_vision max_tokens=900temperature=0.3
OpenAiChatModel designModel = model(api, M_DESIGN, null, 16000);
OpenAiChatModel codeModel = model(api, M_CODE, 0.0, 16000);
OpenAiChatModel fixModel = model(api, M_FIX, 0.0, 16000);
OpenAiChatModel playerTextModel = model(api, M_PLAYER_TEXT, 0.3, 4000);
OpenAiChatModel playerVisionModel = model(api, M_PLAYER_VISION, 0.3, 900);
// == 2) 组装全 StateGraph蓝图 §3.1==
KeyStrategyFactory keyFactory = () -> {
Map<String, KeyStrategy> keys = new HashMap<>();
for (String k : SaaStudioNodes.ALL_KEYS) {
keys.put(k, new ReplaceStrategy());
}
return keys;
};
StateGraph graph = new StateGraph("saa-studio-full", keyFactory)
// -- 节点 --
.addNode("render", node_async(SaaStudioNodes.renderNode()))
.addNode("design", node_async(SaaStudioNodes.designNode(designModel, M_DESIGN, GAME_RUNTIME_ROOT)))
.addNode("generate", node_async(SaaStudioNodes.generateNode(codeModel, fixModel, M_CODE, M_FIX, GAME_RUNTIME_ROOT)))
.addNode("validate", node_async(SaaGenNodes.validateNode()))
.addNode("scaffold", node_async(SaaGenNodes.scaffoldNode(GAME_RUNTIME_ROOT)))
.addNode("build", node_async(SaaGenNodes.buildNode(GAME_RUNTIME_ROOT)))
.addNode("play", node_async(SaaGenNodes.playNode(GAME_RUNTIME_ROOT)))
.addNode("player", node_async(SaaStudioNodes.playerNode(playerTextModel, playerVisionModel, M_PLAYER_TEXT, M_PLAYER_VISION, GAME_RUNTIME_ROOT)))
.addNode("repair", node_async(SaaStudioNodes.repairNode()))
.addNode("emit", node_async(SaaStudioNodes.emitNode(GAME_RUNTIME_ROOT)))
.addNode("giveup", node_async(SaaStudioNodes.giveupNode()))
// -- 直边 --
.addEdge(START, "render")
.addEdge("render", "design")
.addEdge("design", "generate")
.addEdge("generate", "validate")
.addEdge("scaffold", "build")
.addEdge("repair", "generate")
.addEdge("emit", END)
.addEdge("giveup", END)
// -- 条件边失败回边共用判据九门=硬地板player=软门--
.addConditionalEdges("validate",
edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_VALIDATE_OK, MAX_REPAIRS)),
Map.of("ok", "scaffold", "repair", "repair", "giveup", "giveup"))
.addConditionalEdges("build",
edge_async(SaaStudioNodes.okElseRepairOrGiveup(SaaStudioNodes.K_BUILD_OK, MAX_REPAIRS)),
Map.of("ok", "play", "repair", "repair", "giveup", "giveup"))
.addConditionalEdges("play",
edge_async(SaaStudioNodes.playRouter(MAX_REPAIRS)),
Map.of("ok", "player", "repair", "repair", "giveup", "giveup"))
.addConditionalEdges("player",
edge_async(SaaStudioNodes.playerRouter(MAX_REPAIRS, MAX_PLAYER_ROUNDS)),
Map.of("emit", "emit", "repair", "repair"));
// recursionLimit 给足repair 回环成环超步=优雅终止
CompiledGraph compiled = graph.compile(
CompileConfig.builder().recursionLimit(RECURSION_LIMIT).build());
// == 3) 跑一款真 briefbreakout step2 端到端 ==
// A-9runId/gameId 参数化 run 唯一 gameId基名+runId落独立目录避免并行/重跑互撞对照 run.py:56-63
// 端口 port/cdpPort 也入 state默认 4320/9222worker 并行形态可每 run 错开端口并配 Semaphore(1) 串行真玩门
// Semaphore(1) 挂点真玩门(play)用固定端口+Chrome run 并行须串行化worker 形态在此 acquire 一把
// 全局 Semaphore(1)跑完 release本测试单 run仅留挂点注释不引入并发
String runId = new java.text.SimpleDateFormat("yyyyMMdd-HHmmss").format(new java.util.Date());
String gameId = GAME_ID + "-" + runId;
int port = Integer.getInteger("saa.port", 4320);
int cdpPort = Integer.getInteger("saa.cdpPort", 9222);
// 跑前清旧 evidence/产物唯一 gameId 目录若残留极少数同秒重跑先净场保证证据是本 run
Path gameDir = GAME_RUNTIME_ROOT.resolve("games/_wg1-gen").resolve(gameId);
deleteRecursively(gameDir);
BriefData bd = readBrief(BRIEF_NAME);
Map<String, Object> inputs = new HashMap<>();
inputs.put(SaaStudioNodes.K_BRIEF, bd.brief);
inputs.put(SaaStudioNodes.K_GAME_ID, gameId);
// 公平性铁律play_spec 基线取 brief 自带 play_spec inputs/driver/controlCheck/assertAfterPlay/
// expectedEngineCallPrefixes Python run._load_brief 同口径design 自产 gatespec merge 覆盖
// exportState/driver/controlCheck/assertAfterPlay/expectLatch studio.py:218-223 inputs/
// expectedEngineCallPrefixes 不在覆盖名单故保留否则 SAA whack inputs brief F 门期望前缀 Python 宽松不公平
inputs.put(SaaStudioNodes.K_PLAY_SPEC, bd.playSpec);
inputs.put("port", port);
inputs.put("cdpPort", cdpPort);
inputs.put("runId", runId);
RunnableConfig runConfig = RunnableConfig.builder().threadId(gameId).build();
long t0 = System.currentTimeMillis();
Optional<OverAllState> result = compiled.invoke(inputs, runConfig);
long wallMs = System.currentTimeMillis() - t0;
assertTrue(result.isPresent(), "全图 invoke 应返回 state");
OverAllState s = result.get();
// == 读出最终态 ==
// A-7recursionLimit 静默不完整态兜底超步等优雅终止时 K_STATUS 可能为空未抵 emit/giveup
// 此处把 status兜底当 failed视为未收口的失败终态杜绝下游误判为成功
String status = s.value(SaaStudioNodes.K_STATUS, String.class)
.filter(v -> !v.isBlank()).orElse("failed");
boolean playPass = s.value(SaaStudioNodes.K_PLAY_PASS, Boolean.class).orElse(false);
int repairs = ((Number) s.value(SaaStudioNodes.K_REPAIR_COUNT, 0)).intValue();
int playerRounds = ((Number) s.value(SaaStudioNodes.K_PLAYER_ROUND, 0)).intValue();
long tin = ((Number) s.value(SaaStudioNodes.K_TOKENS_IN, 0)).longValue();
long tout = ((Number) s.value(SaaStudioNodes.K_TOKENS_OUT, 0)).longValue();
String designText = s.value(SaaStudioNodes.K_DESIGN_TEXT, String.class).orElse("");
String gatespec = s.value(SaaStudioNodes.K_GATESPEC, String.class).orElse("");
String playSpec = s.value(SaaStudioNodes.K_PLAY_SPEC, String.class).orElse("");
String panel = s.value(SaaStudioNodes.K_PLAYER_PANEL, String.class).orElse("(no panel)");
String engineBundle = s.value(SaaStudioNodes.K_ENGINE_BUNDLE, String.class).orElse("");
String failure = s.value(SaaStudioNodes.K_FAILURE, String.class).orElse("");
String feedback = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
// == 证据落锤 ==gameDir 已在 run 前按唯一 gameId 算好此处复用
Path vpath = gameDir.resolve("evidence/verdict.json");
System.out.println("================ SAA step4 全图端到端证据 ================");
System.out.println("[gameId] " + gameId + " runId=" + runId + " cwd=" + GAME_RUNTIME_ROOT);
System.out.println("[status] " + status + " playPass=" + playPass);
System.out.println("[repairs] " + repairs + " (上限 " + MAX_REPAIRS + ") playerRounds=" + playerRounds);
System.out.println("[wall] " + wallMs + "ms");
System.out.println("[tokens] in=" + tin + " out=" + tout + " total=" + (tin + tout));
System.out.println("[模型路由] design/code/fix/player_text=" + M_CODE + " player_vision=" + M_PLAYER_VISION);
System.out.println("[gatespec] " + (gatespec.isEmpty() ? "(无)" : gatespec));
System.out.println("[merged playSpec] " + playSpec);
System.out.println("[player panel] " + panel);
if (!failure.isEmpty()) {
System.out.println("[failureReason] " + failure);
}
if (!feedback.isEmpty()) {
System.out.println("[last feedback] " + (feedback.length() > 600 ? feedback.substring(0, 600) : feedback));
}
System.out.println("[设计稿头 12 行] >>>>>>>>");
String[] dl = designText.split("\n", -1);
for (int i = 0; i < Math.min(12, dl.length); i++) {
System.out.println(String.format("%3d| %s", i + 1, dl[i]));
}
System.out.println("<<<<<<<< end 设计稿");
System.out.println("[evidence 四件套] verdict.json / first-paint.png / after-play.png @ " + gameDir.resolve("evidence"));
System.out.println("[engineBundle] len=" + engineBundle.length()
+ " 含__GameBundle=" + engineBundle.contains("__GameBundle"));
if (Files.exists(vpath)) {
var v = MAPPER.readTree(vpath.toFile());
System.out.println("[verdict.pass] " + v.path("pass").asBoolean(false));
var guards = v.path("guards");
if (guards.isObject()) {
var it = guards.fields();
StringBuilder gb = new StringBuilder("[九门] ");
while (it.hasNext()) {
var e = it.next();
gb.append(e.getKey()).append("=").append(e.getValue().path("pass").asBoolean(false) ? "" : "").append(" ");
}
System.out.println(gb.toString());
}
}
System.out.println("=========================================================");
// == B-8 诊断 JSON 落盘per-model token + cost¥ + attemptsschema 对齐 studio.py:297-307==
// cost ¥ 复用 Python cost.py 子进程 tokens_by_model ¥不在 Java 重写折算口径
try {
String tokensByModel = s.value(SaaStudioNodes.K_TOKENS_BY_MODEL, String.class).orElse("{}");
String attempts = s.value(SaaStudioNodes.K_ATTEMPTS, String.class).orElse("[]");
JsonNode costNode = computeCostViaPython(tokensByModel); // 子进程算 ¥失败返回 error 节点不中断
com.fasterxml.jackson.databind.node.ObjectNode diag = MAPPER.createObjectNode();
diag.put("game_id", gameId);
diag.put("run_id", runId);
diag.put("stage", "stage1");
diag.put("pass", playPass);
diag.put("status", status);
diag.put("repairs", repairs);
diag.put("player_rounds_used", playerRounds);
diag.put("wall_ms", wallMs);
diag.set("tokens", MAPPER.createObjectNode().put("prompt", tin).put("completion", tout));
diag.set("tokens_by_model", MAPPER.readTree(tokensByModel));
diag.set("attempts", MAPPER.readTree(attempts));
diag.set("cost", costNode);
diag.set("models", MAPPER.createObjectNode()
.put("design", M_DESIGN).put("code", M_CODE).put("fix", M_FIX)
.put("player_text", M_PLAYER_TEXT).put("player_vision", M_PLAYER_VISION));
diag.set("seven_gate_verdict", Files.exists(vpath) ? MAPPER.readTree(vpath.toFile()) : null);
// P4 player panel({text,vision} 评判)落进 diag JSONschema 对齐 studio.py:304 player 字段便于对比脚本直读
try {
JsonNode panelNode = SaaPrompts.looseParse(panel);
diag.set("player", (panelNode != null && panelNode.isObject()) ? panelNode : null);
} catch (Exception ignorePanel) {
diag.set("player", null);
}
Path diagDir = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/results");
Files.createDirectories(diagDir);
Path diagPath = diagDir.resolve(gameId + ".saa.json");
Files.write(diagPath, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsBytes(diag));
System.out.println("[诊断JSON] " + diagPath);
System.out.println("[cost¥] " + costNode);
System.out.println("[tokensByModel] " + tokensByModel);
} catch (Exception e) {
System.out.println("[诊断JSON] 写失败(不影响 step4 判定):" + e.getMessage());
}
// == 断言step4 达成口径==
// 全图机制真跑通必抵达一个终态节点emit succeeded / failed-emit / giveup
assertTrue("succeeded".equals(status) || "failed".equals(status),
"全图必须抵达终态emit/giveup实得 status=" + status + " failure=" + failure);
// 编排链真执行过设计稿 + 至少一次生成gatespec factorySrc 落盘
assertTrue(designText.length() > 20, "design 节点应产出设计稿");
assertTrue(Files.exists(gameDir.resolve("generated-factory.js")),
"scaffold 应至少落盘一次 generated-factory.js证明 generate→validate→scaffold 链通)");
// 理想路径pass=true落锤若过门verdict.pass true + engineBundle 含全局名
if (playPass) {
assertTrue(Files.exists(vpath), "过门应产出 verdict.json");
var v = MAPPER.readTree(vpath.toFile());
assertTrue(v.path("pass").asBoolean(false), "playPass=true 时 verdict.json pass 必 true");
assertTrue("succeeded".equals(status), "过门应 status=succeeded");
assertTrue(engineBundle.contains("__GameBundle"), "succeeded 的 engineBundle 必含 __GameBundle 全局名");
} else {
// 未过门机制仍须真跑repair 回环至少触发一次或抵 giveup
System.out.println("[note] 本次未过九门status=" + status + "。step4 达成口径=全图机制真跑通(条件边/repair 回环/各节点);过门质量留后续调参。");
assertTrue(repairs >= 1 || "failed".equals(status),
"未过门时应至少触发一次 repair 回环或抵 giveup实得 repairs=" + repairs + " status=" + status);
}
}
/**
* B-8复用 Python cost.py 子进程把 tokens_by_model 折成 ¥不在 Java 重写 new-api quota 口径
* gen-worker .venv python httpx/openaicost.py --stdin {"tokens_by_model":...} {by_model,total_rmb,gate_rmb}
* 子进程缺失/网络失败 返回 {"error":...}不中断 step4 判定
*/
private static JsonNode computeCostViaPython(String tokensByModelJson) {
try {
Path workerRoot = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker");
Path venvPy = workerRoot.resolve(".venv/bin/python");
String py = Files.exists(venvPy) ? venvPy.toString() : "python3";
ProcessBuilder pb = new ProcessBuilder(py, "-m", "worker.cost", "--stdin");
pb.directory(workerRoot.toFile());
pb.redirectErrorStream(false); // 分离 stderr避免污染 JSON
Process p = pb.start();
// stdin{"tokens_by_model": {...}}
try (var os = p.getOutputStream()) {
os.write(("{\"tokens_by_model\":" + tokensByModelJson + "}").getBytes(StandardCharsets.UTF_8));
}
String out = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
String err = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
boolean done = p.waitFor(60, java.util.concurrent.TimeUnit.SECONDS);
if (!done) {
p.destroyForcibly();
return MAPPER.createObjectNode().put("error", "cost.py 子进程超时(60s)");
}
if (p.exitValue() != 0 || out.isBlank()) {
return MAPPER.createObjectNode().put("error",
"cost.py exit=" + p.exitValue() + " stderr=" + (err.length() > 300 ? err.substring(0, 300) : err));
}
return MAPPER.readTree(out);
} catch (Exception e) {
return MAPPER.createObjectNode().put("error", "cost.py 调用异常:" + e.getMessage());
}
}
/** 净场删目录递归不存在则跳过。A-9每 run 唯一 gameId 目录跑前清旧。 */
private static void deleteRecursively(Path dir) throws Exception {
if (!Files.exists(dir)) {
return;
}
try (var walk = Files.walk(dir)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (Exception ignore) {
// 单文件删失败不阻断极少目录唯一名残留无害
}
});
}
}
/** brief 题面 + play_spec 串(与 Python run._load_brief 同口径brief 正文 + 自带 play_spec JSON 串)。 */
private record BriefData(String brief, String playSpec) {
}
/**
* 读指定 brief题面正文 + 自带 play_spec Python 同一来源 wg1/gen-worker/briefs/&lt;name&gt;.json
* play_spec 缺省给 "{}"design 自产 gatespec 仍可 mergebrief 文件不可达直接抛P4 必须真 brief不走内联兜底以免静默换题
*/
private static BriefData readBrief(String name) throws Exception {
// briefs 在主仓根 game-runtime GAME_RUNTIME_ROOT 上推一级
Path briefPath = GAME_RUNTIME_ROOT.getParent().resolve("wg1/gen-worker/briefs/" + name + ".json");
assertTrue(Files.exists(briefPath), "brief 文件不存在P4 须真 brief" + briefPath);
var node = MAPPER.readTree(briefPath.toFile());
String b = node.path("brief").asText("");
assertTrue(!b.isEmpty(), "brief 文件缺 brief 正文:" + briefPath);
JsonNode ps = node.path("play_spec");
String psStr = (ps != null && !ps.isMissingNode() && !ps.isNull()) ? ps.toString() : "{}";
return new BriefData(b, psStr);
}
}

View File

@ -0,0 +1,745 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.EdgeAction;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.util.MimeTypeUtils;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* SaaStudioNodes SAA 迁移 step 4LLM 驱动的编排节点 + 条件边谓词generate/design/player/repair/emit/giveup
*
* <p>本类承接 Python studio.py <b>编排循环语义</b>design[code|fix ]九门player 顾问轮
* {@link SaaGenNodes}validate/scaffold/build/playstep3 已建拼成蓝图 §3.1 的全 StateGraph
*
* <p>公平性铁律prompt/角色/模型路由全部走 {@link SaaPrompts}逐字移植 Python本类只负责把它接进图<b>不改 prompt 语义</b>
*
* <p>语义逐一对齐 Python 参照wg1/gen-worker/worker/
* <ul>
* <li>{@link #renderNode} studio.py:213 起初始化role=code/repairCount=0/playerRound=0 brief state</li>
* <li>{@link #designNode(ChatModel,Path)} studio.py:214-223设计 agent + gatespec + merge play_spec</li>
* <li>{@link #generateNode(ChatModel,ChatModel,Path)} studio.py:231-245首轮 code/重出 fixbuild_messages 回喂 ```js</li>
* <li>{@link #playerNode(ChatModel,ChatModel,Path)} studio.py:171-280M3 视觉位+文本位 panel软门回喂</li>
* <li>{@link #repairNode} studio.py:231 attempt 自增 + role=fixfeedback 已由失败节点写好</li>
* <li>{@link #emitNode(Path)} service.py:184-195 bundle.iife.js __GameBundlestatus/engineBundle</li>
* <li>{@link #giveupNode} = maxRepairs 的失败终态status=failed/failureReason</li>
* </ul>
*
* <p>纪律本步边界emit <b>不接 DifyCallbackReqVO/HMAC 回调</b>那是 step7 形态不接 saverstep5不接 job 契约
*
* @author 造梦AI
*/
final class SaaStudioNodes {
private SaaStudioNodes() {
}
private static final ObjectMapper MAPPER = new ObjectMapper();
/** esbuild iife 全局名(契约冻结:出厂自检据此,对齐 service.py:184。 */
private static final String GLOBAL_NAME = "__GameBundle";
// ====================== state key 约定OverAllState ======================
static final String K_BRIEF = "brief"; // 原始题面in
static final String K_ENRICHED = "enriched"; // brief + 设计稿design 节点产出 generate
static final String K_DESIGN_TEXT = "designText"; // 设计稿正文
static final String K_GATESPEC = "gatespec"; // 设计 agent 自产 gatespecJSON 诊断
static final String K_GATESPEC_ERROR = "gatespecParseError"; // gatespec 有块但解析失败的错因A-5诊断+回喂
static final String K_PLAY_SPEC = "playSpec"; // play-spec JSON merge gatespec scaffold 落盘
static final String K_FACTORY_SRC = "factorySrc"; // 工厂源码generate 产出validate/scaffold
static final String K_GAME_ID = "gameId"; // 唯一游戏 id
static final String K_ROLE = "role"; // code|fix区分模型
static final String K_REPAIR_COUNT = "repairCount"; // 修复轮计数主限次
static final String K_PLAYER_ROUND = "playerRound"; // player 顾问轮计数软门限次
static final String K_FEEDBACK = "feedback"; // 上轮失败回喂generate 注入 prompt失败路由写
static final String K_PLAYER_FEEDBACK = "playerFeedback"; // player 软门体验问题与失败 feedback 解耦避免串味
static final String K_VALIDATE_OK = "validateOk"; // validate 节点产出SaaGenNodes
static final String K_BUILD_OK = "buildOk"; // build 节点产出
static final String K_PLAY_PASS = "playPass"; // play 九门是否全过
static final String K_VERDICT = "verdict"; // 九门 verdictJSON
static final String K_PLAYER_PANEL = "playerPanel"; // player panel 评判JSON 诊断
static final String K_TOKENS_IN = "tokensIn"; // 累计 prompt tokens
static final String K_TOKENS_OUT = "tokensOut"; // 累计 completion tokens
static final String K_TOKENS_BY_MODEL = "tokensByModel"; // per-model tokenJSON {model:{in,out}}B 对比指标/成本拆账
static final String K_ATTEMPTS = "attempts"; // 每轮记录JSON 数组串{attempt,role,usage,stage_fail,guards}对齐 studio.py:attempts
static final String K_STATUS = "status"; // succeeded|failedemit/giveup
static final String K_FAILURE = "failureReason"; // 失败原因giveup/emit
static final String K_ENGINE_BUNDLE = "engineBundle"; // bundle.iife.js 全文emit 产出
/** 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,
K_ROLE, K_REPAIR_COUNT, K_PLAYER_ROUND, K_FEEDBACK, K_VALIDATE_OK, K_BUILD_OK,
K_PLAY_PASS, K_VERDICT, K_PLAYER_PANEL, K_PLAYER_FEEDBACK, K_TOKENS_IN, K_TOKENS_OUT,
K_TOKENS_BY_MODEL, K_ATTEMPTS,
K_STATUS, K_FAILURE, K_ENGINE_BUNDLE,
// SaaGenNodes 内部 keyscaffold/build/play
"gameDir", "validateErrors", "buildLog",
// A-9 参数化入参port/cdpPort/runId invoke inputs 注入play port/cdpPort默认 4320/9222
"port", "cdpPort", "runId",
};
// ====================== ```js 抽取对齐 validate.extract_code ======================
private static final Pattern JS_FENCE =
Pattern.compile("```(?:js|javascript)\\s*\\n(.*?)```", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
private static final Pattern ANY_FENCE = Pattern.compile("```\\s*\\n(.*?)```", Pattern.DOTALL);
/** 从模型输出抽 ```js 代码块(对齐 validate.py:extract_code / step2 extractJsBlock。 */
static String extractJsBlock(String content) {
if (content == null || content.isEmpty()) {
return "";
}
Matcher m = JS_FENCE.matcher(content);
if (m.find()) {
return m.group(1).strip();
}
m = ANY_FENCE.matcher(content);
if (m.find()) {
return m.group(1).strip();
}
return content.contains("export default") ? content.strip() : "";
}
// ====================== 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 _client.py:60 tries=3。 */
private static final int LLM_MAX_TRIES = 3;
/** 重试间隔毫秒(对齐 Python _client.py:60 retry_delay=2.0)。 */
private static final long LLM_RETRY_DELAY_MS = 2000;
/** LLM 调用的专用执行器(守护线程,供 Future 超时取消)。 */
private static final ExecutorService LLM_POOL = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "saa-llm-call");
t.setDaemon(true);
return t;
});
/**
* 给一次 LLM 调用套<b>单次 180s 墙钟超时 + 最多 3 次重试</b>只重 429/5xx/IO/timeout对齐 Python _client.py:57-98
* <p>纪律评审 A-1模型异常不再裸退图超限后由调用方写 feedback/status避免一次瞬时网关 502/超时就让整张图崩
* Spring AI 自身的 maxRetries 只兜瞬时 HTTP这里再加<b>墙钟超时</b>防单次调用挂死无限期阻塞节点+ 统一重试分类
*
* @param call 实际产出 [文本,in,out] 的调用同步阻塞由本方法在独立线程跑 + 限时取消
* @return [文本, inDelta(Long), outDelta(Long)]
* @throws Exception 超过最大尝试次数仍失败最后一次异常上抛调用方按节点语义兜底
*/
private static Object[] callWithTimeoutAndRetry(Callable<Object[]> call) throws Exception {
Exception last = null;
for (int attempt = 0; attempt < LLM_MAX_TRIES; attempt++) {
Future<Object[]> f = LLM_POOL.submit(call);
try {
return f.get(LLM_TIMEOUT_SECS, TimeUnit.SECONDS);
} catch (TimeoutException te) {
f.cancel(true); // 取消挂死的调用中断底层 HTTP 等待
last = new RuntimeException("LLM 调用超时(" + LLM_TIMEOUT_SECS + "s", te);
} catch (ExecutionException ee) {
Throwable cause = ee.getCause() != null ? ee.getCause() : ee;
last = (cause instanceof Exception) ? (Exception) cause : new RuntimeException(cause);
if (!isRetryable(cause)) {
throw last; // 不可重试错误 4xx 鉴权/参数立即上抛不浪费重试
}
}
// 还有下一次 退避后重试最后一次失败落到循环外上抛
if (attempt < LLM_MAX_TRIES - 1) {
try {
Thread.sleep(LLM_RETRY_DELAY_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw last != null ? last : ie;
}
}
}
throw last != null ? last : new RuntimeException("LLM 调用失败(重试 " + LLM_MAX_TRIES + " 次后)");
}
/**
* 判定异常是否值得重试只重 429 / 5xx / IO / 超时瞬时类4xx鉴权/参数不重对齐 Python only retry 429/5xx/IO/timeout
* 用类型 + 消息双判Spring AI 把网关错误包成 NonTransient/Transient/HttpStatusCodeException消息里带状态码
*/
private static boolean isRetryable(Throwable t) {
for (Throwable c = t; c != null; c = c.getCause()) {
if (c instanceof java.io.IOException || c instanceof TimeoutException
|| c instanceof org.springframework.ai.retry.TransientAiException) {
return true;
}
String cn = c.getClass().getName();
String msg = c.getMessage() == null ? "" : c.getMessage();
// ResourceAccessException(连不上/读超时)SocketTimeout 等按 IO/超时处理
if (cn.contains("ResourceAccessException") || cn.contains("SocketTimeout")
|| cn.contains("ConnectException") || cn.contains("Timeout")) {
return true;
}
// HTTP 状态码429 5xx 才重4xx 不重
if (msg.contains("429") || msg.matches("(?s).*\\b5\\d\\d\\b.*")
|| msg.contains("Too Many Requests") || msg.contains("Internal Server Error")
|| msg.contains("Bad Gateway") || msg.contains("Service Unavailable")
|| msg.contains("Gateway Timeout")) {
return true;
}
}
return false;
}
/** 调模型并把 usage 累计回 state对齐 RecordingChatModel含单次 180s 超时 + ≤3 重试。返回 [文本, inDelta, outDelta]。 */
private static Object[] callAndRecord(ChatModel model, String system, String user) throws Exception {
return callWithTimeoutAndRetry(() -> {
Prompt prompt = new Prompt(List.of(new SystemMessage(system), new UserMessage(user)));
ChatResponse resp = model.call(prompt);
String text = resp.getResult().getOutput().getText();
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};
});
}
/**
* usage delta 累加进 out map基线优先取本节点 out 内已写值否则取 state 旧值 + delta
* 同一节点多次累加 player 文本位+视觉位第二次须叠加在第一次已写入 out 的值上
* 而非重读未变的 state否则后者会覆盖前者
*/
private static void accumTokens(OverAllState s, Map<String, Object> out, long inDelta, long outDelta) {
long in0 = out.containsKey(K_TOKENS_IN)
? ((Number) out.get(K_TOKENS_IN)).longValue() : ((Number) s.value(K_TOKENS_IN, 0)).longValue();
long out0 = out.containsKey(K_TOKENS_OUT)
? ((Number) out.get(K_TOKENS_OUT)).longValue() : ((Number) s.value(K_TOKENS_OUT, 0)).longValue();
out.put(K_TOKENS_IN, in0 + inDelta);
out.put(K_TOKENS_OUT, out0 + outDelta);
}
/**
* B-8per-model token 累计 state JSON 累加本模型 in/out 写回
* 用于成本拆账cost.py tokens_by_model ¥+ Stage 对比对齐 studio.py:per_model
*/
private static void accumTokensByModel(OverAllState s, Map<String, Object> out,
String modelName, long inDelta, long outDelta) {
// 与总量累加同步先把总量也加上调用方不必再单独调 accumTokens
accumTokens(s, out, inDelta, outDelta);
// 基线优先取本节点 out 内已写值同一节点多次累加须叠加否则取 state 旧值
String prev = out.containsKey(K_TOKENS_BY_MODEL)
? (String) out.get(K_TOKENS_BY_MODEL)
: s.value(K_TOKENS_BY_MODEL, String.class).orElse("{}");
com.fasterxml.jackson.databind.node.ObjectNode root;
try {
JsonNode n = SaaPrompts.looseParse(prev);
root = (n != null && n.isObject())
? (com.fasterxml.jackson.databind.node.ObjectNode) n
: MAPPER.createObjectNode();
} catch (Exception e) {
root = MAPPER.createObjectNode();
}
com.fasterxml.jackson.databind.node.ObjectNode m = root.has(modelName) && root.get(modelName).isObject()
? (com.fasterxml.jackson.databind.node.ObjectNode) root.get(modelName)
: MAPPER.createObjectNode();
m.put("in", m.path("in").asLong(0) + inDelta);
m.put("out", m.path("out").asLong(0) + outDelta);
root.set(modelName, m);
out.put(K_TOKENS_BY_MODEL, root.toString());
}
/**
* B-8追加一条 attempt 记录 state JSON 数组 append 写回
* fields 对齐 studio.py:attempts 元素{attempt,role,model,usage:{in,out},stage_fail,guards}
*/
private static void appendAttempt(OverAllState s, Map<String, Object> out,
com.fasterxml.jackson.databind.node.ObjectNode rec) {
com.fasterxml.jackson.databind.node.ArrayNode arr;
try {
JsonNode n = MAPPER.readTree(s.value(K_ATTEMPTS, String.class).orElse("[]"));
arr = (n != null && n.isArray())
? (com.fasterxml.jackson.databind.node.ArrayNode) n
: MAPPER.createArrayNode();
} catch (Exception e) {
arr = MAPPER.createArrayNode();
}
arr.add(rec);
out.put(K_ATTEMPTS, arr.toString());
}
// ====================== render 节点初始化 ======================
/**
* render 节点对齐 studio.py:213 起的初始化 job 入参落 state 初值role=code/计数器归零
* 入参 brief/gameId/playSpec 来自 invoke inputs本节点保证默认值齐全图入口
*/
static NodeAction renderNode() {
return (OverAllState s) -> {
Map<String, Object> out = new HashMap<>();
out.put(K_ROLE, "code"); // 首轮 code 角色对齐 studio.py:232
out.put(K_REPAIR_COUNT, 0);
out.put(K_PLAYER_ROUND, 0);
out.put(K_TOKENS_IN, 0);
out.put(K_TOKENS_OUT, 0);
return out;
};
}
// ====================== design 节点设计 agent + 自产 gatespec ======================
/**
* design 节点对齐 studio.py:214-223DESIGN_SYSTEM 设计 model 出设计稿 + 自产 gatespec
* gatespec merge playSpec覆盖 brief 默认使确定性门适用任意游戏
*
* @param designModel 设计角色模型 new-apimodel=models.yaml stage1.design
* @param designModelName 设计模型名per-model token 拆账用B-8
* @param gameRuntimeRoot game-runtime few-shot design 不读仅口径一致
*/
static NodeAction designNode(ChatModel designModel, String designModelName, Path gameRuntimeRoot) {
return (OverAllState s) -> {
String brief = s.value(K_BRIEF, String.class).orElse("");
String designText;
long inDelta = 0, outDelta = 0;
// A-1design LLM 调用套 180s 超时 + 3 重试超限不裸退图降级为无设计稿继续generate 仍可凭 brief
// 并把错因落 feedback诊断可见设计稿正文给可读占位保证下游断言/enriched 不空
try {
Object[] r = callAndRecord(designModel, SaaPrompts.DESIGN_SYSTEM, brief);
designText = (String) r[0];
inDelta = (Long) r[1];
outDelta = (Long) r[2];
} catch (Exception e) {
designText = "(设计 agent 调用失败,降级为无设计稿:" + trunc(e.getMessage(), 200) + "";
}
// enriched = brief + 设计稿对齐 studio.py:216
String enriched = brief + "\n\n## 设计稿(设计 agent 产出)\n" + designText;
// gate-H gatespec merge play_spec覆盖 brief 默认对齐 studio.py:218-223
JsonNode gatespec = SaaPrompts.extractGatespec(designText);
// A-5 ```gatespec 块但解析失败gatespec==null 且块存在 不静默退 {} gatespecParseError 并作为设计回喂
// 提示代码 agent 重新产出规范 gatespec区别于无块的静默降级对齐评审失败不静默要求
String gatespecParseErr = "";
if (gatespec == null && SaaPrompts.hasGatespecBlock(designText)) {
gatespecParseErr = "设计稿的 ```gatespec 块解析失败JSON 非法),已退回 brief 默认 play_spec"
+ "请在设计稿末尾重附一个合法 JSON 的 ```gatespec 块(双引号、无尾逗号)。";
}
String playSpecStr = s.value(K_PLAY_SPEC, String.class).orElse("{}");
JsonNode playSpec = SaaPrompts.looseParse(playSpecStr);
com.fasterxml.jackson.databind.node.ObjectNode merged =
(playSpec != null && playSpec.isObject())
? (com.fasterxml.jackson.databind.node.ObjectNode) playSpec.deepCopy()
: MAPPER.createObjectNode();
if (gatespec != null) {
for (String k : new String[]{"exportState", "driver", "controlCheck", "assertAfterPlay", "expectLatch"}) {
if (gatespec.has(k) && !gatespec.get(k).isNull()) {
merged.set(k, gatespec.get(k));
}
}
}
Map<String, Object> out = new HashMap<>();
out.put(K_DESIGN_TEXT, designText);
out.put(K_ENRICHED, enriched);
out.put(K_PLAY_SPEC, merged.toString());
out.put(K_GATESPEC, gatespec == null ? "" : gatespec.toString());
out.put(K_GATESPEC_ERROR, gatespecParseErr);
// gatespec 解析错 作为首轮 generate 的回喂 feedbackbuildMessages 会附到 user 末尾role code
if (!gatespecParseErr.isEmpty()) {
out.put(K_FEEDBACK, gatespecParseErr);
}
accumTokensByModel(s, out, designModelName, inDelta, outDelta); // B-8per-model 拆账含总量累加
// B-8 design 这一轮attempt 0 之前的设计步role=design
appendAttempt(s, out, MAPPER.createObjectNode()
.put("role", "design").put("model", designModelName)
.set("usage", MAPPER.createObjectNode().put("in", inDelta).put("out", outDelta)));
return out;
};
}
// ====================== generate 节点code/fix回喂全量重出 ======================
/**
* generate 节点对齐 studio.py:231-245role=codecodeModel / role=fixfixModel
* buildMessages(enriched, feedback) 回喂上轮失败原因 ```js factorySrc
*
* @param codeModel code 角色模型首轮
* @param fixModel fix 角色模型repair
* @param codeModelName code 模型名per-model 拆账用B-8
* @param fixModelName fix 模型名per-model 拆账用B-8
* @param gameRuntimeRoot game-runtime few-shot 探针
*/
static NodeAction generateNode(ChatModel codeModel, ChatModel fixModel,
String codeModelName, String fixModelName, Path gameRuntimeRoot) {
return (OverAllState s) -> {
String role = s.value(K_ROLE, "code");
boolean isFix = "fix".equals(role);
ChatModel model = isFix ? fixModel : codeModel;
String mname = isFix ? fixModelName : codeModelName;
int attemptNo = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue(); // 当前修复轮序首轮=0
String enriched = s.value(K_ENRICHED, String.class)
.orElse(s.value(K_BRIEF, String.class).orElse(""));
String feedback = s.value(K_FEEDBACK, String.class).orElse(null);
String[] msgs = SaaPrompts.buildMessages(enriched, feedback, gameRuntimeRoot);
Map<String, Object> out = new HashMap<>();
// A-1generate repair role=fix LLM 调用套 180s 超时 + 3 重试
// 超限不裸退图把错因写 feedback + factorySrc 留空validate 判空过短 路由 repair/giveup对齐 run.py:103-105
Object[] r;
try {
r = callAndRecord(model, msgs[0], msgs[1]);
} catch (Exception e) {
out.put(K_FACTORY_SRC, "");
out.put(K_FEEDBACK, "模型调用失败(已重试):" + trunc(e.getMessage(), 300));
// B-8LLM 失败也记一轮stage_fail=generateusage 0
appendAttempt(s, out, MAPPER.createObjectNode()
.put("attempt", attemptNo).put("role", role).put("model", mname)
.put("stage_fail", "generate")
.set("usage", MAPPER.createObjectNode().put("in", 0).put("out", 0)));
return out;
}
String src = extractJsBlock((String) r[0]);
out.put(K_FACTORY_SRC, src);
// 抽不到 ```js feedback对齐 studio.py:242-245 extract 失败桶validate 会判空过短再兜
if (src.isEmpty()) {
out.put(K_FEEDBACK, "未能从输出中提取 ```js 代码块。请只输出一个完整的 ```js 代码块,块内是完整工厂源码。");
}
long in = (Long) r[1], outTok = (Long) r[2];
accumTokensByModel(s, out, mname, in, outTok); // B-8per-model 拆账含总量累加
// B-8记本生成轮attempt 序号/角色/模型/usage后续 validate/build/play 失败由各节点补 stage_fail 到尾记录
appendAttempt(s, out, MAPPER.createObjectNode()
.put("attempt", attemptNo).put("role", role).put("model", mname)
.put("stage_fail", src.isEmpty() ? "extract" : (String) null)
.set("usage", MAPPER.createObjectNode().put("in", in).put("out", outTok)));
return out;
};
}
// ====================== player 节点M3 视觉位 + 文本位软门 ======================
/**
* player 节点对齐 studio.py:171-280 run_player_panel + 软门回喂
* 文本位仅运行数据+ 视觉位M3 + 首帧/玩后截图hook 容错多人格评判
* 任一位 verdict=fix playerRound&lt;maxRounds feedback repair七门仍是硬地板player 软门
*
* @param playerTextModel 文本位模型models.yaml stage1.player_text
* @param playerVisionModel 视觉位模型 MiniMax-M3hook失败优雅降级
* @param playerTextModelName 文本位模型名per-model 拆账用B-8
* @param playerVisionModelName 视觉位模型名per-model 拆账用B-8
* @param gameRuntimeRoot game-runtime evidence 截图
*/
static NodeAction playerNode(ChatModel playerTextModel, ChatModel playerVisionModel,
String playerTextModelName, String playerVisionModelName, Path gameRuntimeRoot) {
return (OverAllState s) -> {
String gameId = s.value(K_GAME_ID, String.class).orElse("");
String brief = s.value(K_BRIEF, String.class).orElse("");
String verdictStr = s.value(K_VERDICT, String.class).orElse("");
JsonNode verdict = SaaPrompts.looseParse(verdictStr);
String summary = playSummary(verdict);
String base = "游戏题面:\n" + brief + "\n\n" + summary + "\n\n"
+ "请基于以上证据(及截图,若有)给出你的玩家评判。";
com.fasterxml.jackson.databind.node.ObjectNode panel = MAPPER.createObjectNode();
List<String> problems = new ArrayList<>();
Map<String, Object> out = new HashMap<>();
// -- 文本位细致完整度审查者只信运行数据对齐 studio.py:183--
try {
String sys = SaaPrompts.playerSystem("细致的完整度审查者,只信运行数据、不脑补");
Object[] r = callAndRecord(playerTextModel, sys, base);
accumTokensByModel(s, out, playerTextModelName, (Long) r[1], (Long) r[2]); // B-8per-model 拆账
JsonNode j = SaaPrompts.looseParse((String) r[0]);
JsonNode nj = normalizeJudge(j, (String) r[0]); // 先归一problems数组类型校验再收集避免读原始脏值
panel.set("text", nj);
collectFixProblems(nj, problems);
} catch (Exception e) {
panel.set("text", MAPPER.createObjectNode().put("error", trunc(e.getMessage(), 200)));
}
// -- 视觉位M3 + 首帧/玩后截图hook失败优雅降级不连坐软门对齐 studio.py:191-192--
try {
Object[] r = judgeVision(playerVisionModel, "急性子休闲玩家,凭第一眼观感和反馈下判断",
base, gameRuntimeRoot, gameId);
accumTokensByModel(s, out, playerVisionModelName, (Long) r[1], (Long) r[2]); // B-8per-model 拆账
JsonNode j = SaaPrompts.looseParse((String) r[0]);
JsonNode nj = normalizeJudge(j, (String) r[0]); // 先归一再收集同文本位口径
panel.set("vision", nj);
collectFixProblems(nj, problems);
} catch (Exception e) {
panel.set("vision", MAPPER.createObjectNode().put("error", trunc(e.getMessage(), 200)));
}
out.put(K_PLAYER_PANEL, panel.toString());
// 软门有体验问题 playerFeedback与失败 feedback 解耦避免串味对齐 studio.py:277-278
// 无问题写空串 playerRouter 拿到干净信号playerRound repair 节点统一自增
if (!problems.isEmpty()) {
StringBuilder fb = new StringBuilder("七门已过,但玩家 agent 反馈以下体验问题,请修复并保持七门可过:\n");
int n = 0;
for (String p : problems) {
if (n++ >= 8) {
break;
}
fb.append("- ").append(p).append("\n");
}
out.put(K_PLAYER_FEEDBACK, fb.toString());
} else {
out.put(K_PLAYER_FEEDBACK, "");
}
return out;
};
}
/** 把九门 verdict 摘成给玩家 agent 的运行数据文字(对齐 studio.py:_play_summary。 */
private static String playSummary(JsonNode verdict) {
if (verdict == null) {
return "(无 verdict:疑似装载即崩)";
}
JsonNode guards = verdict.path("guards");
StringBuilder sb = new StringBuilder("七门真玩取证:\n");
if (guards.isObject()) {
var it = guards.fields();
while (it.hasNext()) {
var e = it.next();
boolean pass = e.getValue().path("pass").asBoolean(false);
String g = e.getValue().toString();
sb.append("- 七门 ").append(e.getKey()).append(": ").append(pass ? "" : "未过")
.append(" ").append(g.length() > 160 ? g.substring(0, 160) : g).append("\n");
}
}
return sb.toString();
}
/**
* M3 多模态评判文本 + 首帧/玩后截图Spring AI MediaOpenAI 兼容走 image_url返回 [文本, in, out]
* A-1调用同样套 180s 超时 + 3 重试视觉位 hook调用方 catch 后优雅降级不连坐软门
*/
private static Object[] judgeVision(ChatModel visionModel, String persona, String userText,
Path gameRuntimeRoot, String gameId) throws Exception {
Path ev = gameRuntimeRoot.resolve("games").resolve("_wg1-gen").resolve(gameId).resolve("evidence");
List<Media> medias = new ArrayList<>();
for (String fn : new String[]{"first-paint.png", "after-play.png"}) {
Path png = ev.resolve(fn);
try {
if (Files.exists(png)) {
byte[] bytes = Files.readAllBytes(png);
medias.add(Media.builder()
.mimeType(MimeTypeUtils.IMAGE_PNG)
.data(new ByteArrayResource(bytes))
.id(fn)
.build());
}
} catch (Exception ignore) {
// 单张截图读失败不连坐视觉位整体仍可用文字判
}
}
UserMessage um = medias.isEmpty()
? new UserMessage(userText)
: UserMessage.builder().text(userText).media(medias).build();
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();
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};
});
}
/**
* 容错归一玩家评判 JSON对齐 studio.py:_parse_judge 118-122
* 解不动给占位completeness/fun=nullverdict="?"note=raw 截断
* A-6解出对象后做字段归一problems 缺省补 []非数组字符串等转单元素数组
* verdict/fun/completeness 做类型校验verdict 非文本"?"fun/completeness 非数字null
*/
private static JsonNode normalizeJudge(JsonNode j, String raw) {
if (j == null || !j.isObject()) {
return MAPPER.createObjectNode()
.putNull("completeness")
.putNull("fun")
.put("verdict", "?")
.put("note", trunc(raw, 120));
}
com.fasterxml.jackson.databind.node.ObjectNode on = (com.fasterxml.jackson.databind.node.ObjectNode) j;
// problems缺省补 []非数组字符串/对象/数字转单元素字符串数组对齐 studio.py:120-122
JsonNode ps = on.get("problems");
if (ps == null || ps.isNull()) {
on.set("problems", MAPPER.createArrayNode());
} else if (!ps.isArray()) {
com.fasterxml.jackson.databind.node.ArrayNode arr = MAPPER.createArrayNode();
arr.add(ps.isValueNode() ? ps.asText() : ps.toString());
on.set("problems", arr);
}
// verdict非文本归一为 "?" null/数字混入误判软门
if (!on.path("verdict").isTextual()) {
on.put("verdict", "?");
}
// fun / completeness非数字归一为 null分锚口径只认数字防字符串/对象污染对比指标
for (String f : new String[]{"fun", "completeness"}) {
JsonNode v = on.get(f);
if (v != null && !v.isNumber()) {
on.putNull(f);
}
}
return on;
}
/** 若评判 verdict=fix收集 problems对齐 studio.py:189-190。入参应为已 normalizeJudge 归一后的节点problems 必为数组)。 */
private static void collectFixProblems(JsonNode j, List<String> problems) {
if (j != null && j.isObject() && "fix".equals(j.path("verdict").asText())) {
JsonNode ps = j.path("problems");
if (ps.isArray()) {
ps.forEach(p -> problems.add(p.asText()));
}
}
}
// ====================== repair 节点自增计数 + role=fix ======================
/**
* repair 节点对齐 studio.py:231 attempt 自增 + studio.py:232 role=fix
* 统一自增 repairCount失败回边若来自 player 软门playerRound 路径则另自增 playerRound
* feedback 已由失败节点validate/build/play player 节点写好本节点只翻 role + 计数
*/
static NodeAction repairNode() {
return (OverAllState s) -> {
Map<String, Object> out = new HashMap<>();
out.put(K_ROLE, "fix");
int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue();
out.put(K_REPAIR_COUNT, rc + 1);
// player 软门触发的修复 playerFeedback体验问题 提升为 generate 的回喂 feedback + 自增 playerRound
// 失败路触发的修复feedback 已由 validate/build/play 写好本节点不动
String pfb = s.value(K_PLAYER_FEEDBACK, String.class).orElse("");
if (!pfb.isEmpty()) {
out.put(K_FEEDBACK, pfb);
int pr = ((Number) s.value(K_PLAYER_ROUND, 0)).intValue();
out.put(K_PLAYER_ROUND, pr + 1);
// A-4消费后立即清空 playerFeedback避免本轮 player 体验问题在下一轮 validate/build/play 硬门失败时
// playerRouter/repair 误读为仍有体验问题而串味player 反馈与失败 feedback 必须解耦
out.put(K_PLAYER_FEEDBACK, "");
}
return out;
};
}
// ====================== emit 节点组结果本步不接回调契约 ======================
/**
* emit 节点对齐 service.py:184-195 出厂自检 bundle.iife.js __GameBundle
* status=succeeded + engineBundle全文缺全局名判失败不落坏包
* <p><b>本步不接 DifyCallbackReqVO/HMAC 回调</b>step7 形态
*
* @param gameRuntimeRoot game-runtime
*/
static NodeAction emitNode(Path gameRuntimeRoot) {
return (OverAllState s) -> {
String gameId = s.value(K_GAME_ID, String.class).orElse("");
Path bundlePath = gameRuntimeRoot.resolve("games").resolve("_wg1-gen")
.resolve(gameId).resolve("bundle.iife.js");
Map<String, Object> out = new HashMap<>();
try {
String bundle = new String(Files.readAllBytes(bundlePath), StandardCharsets.UTF_8);
if (!bundle.contains(GLOBAL_NAME)) {
out.put(K_STATUS, "failed");
out.put(K_FAILURE, "bundle_missing_global_name");
return out;
}
out.put(K_STATUS, "succeeded");
out.put(K_ENGINE_BUNDLE, bundle);
} catch (Exception e) {
out.put(K_STATUS, "failed");
out.put(K_FAILURE, "bundle_read_error:" + trunc(e.getMessage(), 200));
}
return out;
};
}
// ====================== giveup 节点超限失败终态 ======================
/** giveup 节点:超 maxRepairs 仍未过门 → status=failed失败原因取最后 feedback。 */
static NodeAction giveupNode() {
return (OverAllState s) -> {
Map<String, Object> out = new HashMap<>();
out.put(K_STATUS, "failed");
String fb = s.value(K_FEEDBACK, String.class).orElse("超过最大修复轮数仍未过门");
out.put(K_FAILURE, "max_repairs_exceeded: " + trunc(fb, 400));
return out;
};
}
// ====================== 条件边谓词EdgeAction String ======================
/**
* validate/build 失败回边共用判据对齐蓝图 §3.1ok? 下一步 : repairCount>=N? giveup : repair
*
* @param okKey 成功标志 state keyvalidateOk / buildOk
* @param maxRepairs 修复轮上限
*/
static EdgeAction okElseRepairOrGiveup(String okKey, int maxRepairs) {
return (OverAllState s) -> {
if (s.value(okKey, Boolean.class).orElse(false)) {
return "ok";
}
int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue();
return rc >= maxRepairs ? "giveup" : "repair";
};
}
/** play 条件边:九门 pass→player否则 repairCount>=N? giveup : repair九门=硬地板)。 */
static EdgeAction playRouter(int maxRepairs) {
return okElseRepairOrGiveup(K_PLAY_PASS, maxRepairs);
}
/**
* player 条件边软门对齐 studio.py:274-280
* 无体验问题(feedback ) playerRound 已尽 emit否则 repairCount 未尽 repair
* player 是软门七门已过这里 repair fix 修体验repairCount 仍受 maxRepairs 兜底
*
* @param maxRepairs 修复轮上限硬刹车
* @param maxPlayerRounds player 顾问轮上限
*/
static EdgeAction playerRouter(int maxRepairs, int maxPlayerRounds) {
return (OverAllState s) -> {
String pfb = s.value(K_PLAYER_FEEDBACK, String.class).orElse("");
int pr = ((Number) s.value(K_PLAYER_ROUND, 0)).intValue();
int rc = ((Number) s.value(K_REPAIR_COUNT, 0)).intValue();
// 有体验问题 player 轮未尽 repair 轮未尽 回修否则收口 emit
if (!pfb.isEmpty() && pr < maxPlayerRounds && rc < maxRepairs) {
return "repair";
}
return "emit";
};
}
/** 截断字符串(日志/失败原因摘要)。 */
private static String trunc(String s, int n) {
if (s == null) {
return "";
}
return s.length() <= n ? s : s.substring(0, n);
}
}

View File

@ -0,0 +1,168 @@
package com.wanxiang.huijing.game.module.aigc.saa;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.HashMap;
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;
/**
* SaaStudioNodesRegressionTest 对比前必修项的<b>纯单元回归</b> LLM/ harness确定性
*
* <p>覆盖评审整改项
* <ul>
* <li>A-4 playerFeedback 串味player 软门修复后若下一硬门validate失败回喂必须是 validate 失败原因<b>非旧 player 反馈</b>
* repairNode 消费 playerFeedback 后立即清空</li>
* <li>A-6 player 评判归一problems 非数组字符串 单元素数组缺省[]verdict 非文本"?"fun/completeness 非数字null</li>
* <li>A-5 gatespec 宽松解析单引号 / 尾随逗号 / 未引号字段名的脏 JSON 仍能救活不静默退 {}</li>
* </ul>
*
* @author 造梦AI
*/
class SaaStudioNodesRegressionTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 用 SaaStudioNodes.ALL_KEYS 全 ReplaceStrategy 建一个可读写的 OverAllState含 playerFeedback 等键)。 */
private static OverAllState newFullState(Map<String, Object> init) {
// 公共构造data + 注册全 key ReplaceStrategy覆盖语义便于链式断言
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;
}
/** 执行一个 NodeAction同步把其返回的 partial 按已注册策略并回 state便于链式断言。 */
private static void applyNode(OverAllState s, NodeAction node) throws Exception {
Map<String, Object> partial = node.apply(s);
s.updateState(partial); // 实例 updateState按已注册 KeyStrategyReplaceStrategy=覆盖写回
}
// ====================== A-4playerFeedback 串味回归 ======================
@Test
void repair_consumes_and_clears_player_feedback() throws Exception {
// 初态player 软门写了体验问题 playerFeedbackrepairCount=0
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_PLAYER_FEEDBACK, "七门已过,但玩家 agent 反馈以下体验问题:\n- 反馈太弱");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 0);
init.put(SaaStudioNodes.K_PLAYER_ROUND, 0);
OverAllState s = newFullState(init);
applyNode(s, SaaStudioNodes.repairNode());
// repair role=fixfeedback 提升为 playerFeedback 内容playerFeedback 清空playerRound+1repairCount+1
assertEquals("fix", s.value(SaaStudioNodes.K_ROLE, String.class).orElse(""));
assertTrue(s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("").contains("反馈太弱"),
"feedback 应被提升为 player 体验问题");
assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse("<null>"),
"A-4repair 消费后 playerFeedback 必须清空");
assertEquals(1, ((Number) s.value(SaaStudioNodes.K_PLAYER_ROUND, 0)).intValue());
assertEquals(1, ((Number) s.value(SaaStudioNodes.K_REPAIR_COUNT, 0)).intValue());
}
@Test
void player_then_validate_fail_feedback_is_validate_not_player() throws Exception {
// 链路player pfb repair 促升+清空 validate 失败坏样 断言回喂=validate 失败原因非旧 player 反馈
Map<String, Object> init = new HashMap<>();
init.put(SaaStudioNodes.K_PLAYER_FEEDBACK, "玩家反馈:粒子太弱看不到");
init.put(SaaStudioNodes.K_REPAIR_COUNT, 0);
init.put(SaaStudioNodes.K_PLAYER_ROUND, 0);
// 放一个会被 validate 判不过的坏 factorySrc export default / 过短
init.put("factorySrc", "const x=1; // 不是工厂validate 必不过");
OverAllState s = newFullState(init);
// repair 促升 player 反馈为 feedback 并清空 pfb
applyNode(s, SaaStudioNodes.repairNode());
assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse("<null>"));
// validate 节点硬门失败 feedback=校验未过A-4清空 pfb本就已空幂等
applyNode(s, SaaGenNodes.validateNode());
String fb = s.value(SaaStudioNodes.K_FEEDBACK, String.class).orElse("");
assertTrue(fb.contains("校验未过"), "A-4硬门失败后回喂必须是 validate 失败原因,实得:" + fb);
assertFalse(fb.contains("粒子太弱"), "A-4回喂不得再串入旧 player 体验反馈,实得:" + fb);
assertEquals(false, s.value(SaaStudioNodes.K_VALIDATE_OK, Boolean.class).orElse(true));
assertEquals("", s.value(SaaStudioNodes.K_PLAYER_FEEDBACK, String.class).orElse("<null>"),
"A-4validate 硬门失败应保持 playerFeedback 为空");
}
// ====================== A-6player 评判归一回归反射调私有 normalizeJudge ======================
private static JsonNode callNormalizeJudge(JsonNode j, String raw) throws Exception {
Method m = SaaStudioNodes.class.getDeclaredMethod("normalizeJudge", JsonNode.class, String.class);
m.setAccessible(true);
return (JsonNode) m.invoke(null, j, raw);
}
@Test
void normalize_judge_problems_and_types() throws Exception {
// problems 是字符串非数组 单元素数组verdict 非文本数字 "?"fun 非数字字符串 null
JsonNode dirty = MAPPER.readTree("{\"completeness\":3,\"fun\":\"high\",\"problems\":\"反馈太弱\",\"verdict\":1}");
JsonNode n = callNormalizeJudge(dirty, "raw");
assertTrue(n.path("problems").isArray(), "problems 应归一为数组");
assertEquals(1, n.path("problems").size());
assertEquals("反馈太弱", n.path("problems").get(0).asText());
assertTrue(n.path("verdict").isTextual() && "?".equals(n.path("verdict").asText()),
"verdict 非文本应归一为 \"?\"");
assertTrue(n.path("fun").isNull(), "fun 非数字应归一为 null");
assertEquals(3, n.path("completeness").asInt(), "completeness 数字应保留");
}
@Test
void normalize_judge_missing_problems_becomes_empty_array() throws Exception {
JsonNode j = MAPPER.readTree("{\"verdict\":\"pass\",\"fun\":4,\"completeness\":4}");
JsonNode n = callNormalizeJudge(j, "raw");
assertTrue(n.path("problems").isArray() && n.path("problems").isEmpty(),
"缺省 problems 应补为空数组");
}
@Test
void normalize_judge_unparsable_gives_placeholder() throws Exception {
JsonNode n = callNormalizeJudge(null, "这不是JSON只是一句话");
assertTrue(n.path("completeness").isNull());
assertTrue(n.path("fun").isNull());
assertEquals("?", n.path("verdict").asText());
assertTrue(n.path("note").asText().contains("这不是JSON"), "解不动应保留 raw 占位 note");
}
// ====================== A-5gatespec / looseParse 宽松解析回归 ======================
@Test
void loose_parse_recovers_single_quotes_and_trailing_comma() {
// 单引号 + 尾随逗号 + 未引号字段名便宜模型高频脏 JSON 仍能救活非静默退 null
JsonNode n = SaaPrompts.looseParse("{ phase: 'playing', score: 0, items: [1,2,3,], }");
org.junit.jupiter.api.Assertions.assertNotNull(n, "宽松解析应救活脏 JSON");
assertEquals("playing", n.path("phase").asText());
assertEquals(3, n.path("items").size());
}
@Test
void extract_gatespec_ballpath_y_to_x_and_default_latch() {
// 设计稿带 gatespecpaddle-intercept 误填 ball.y 抽取后应 .y.x 归一 + 缺省补 expectLatch=true
String design = "设计正文……\n```gatespec\n{\"driver\":{\"type\":\"paddle-intercept\",\"ballPath\":\"ball.y\",\"paddleY\":800}}\n```";
JsonNode g = SaaPrompts.extractGatespec(design);
org.junit.jupiter.api.Assertions.assertNotNull(g);
assertEquals("ball.x", g.path("driver").path("ballPath").asText(), "ballPath .y 应归一为 .x");
assertTrue(g.path("expectLatch").asBoolean(false), "expectLatch 缺省应补 true");
}
@Test
void has_gatespec_block_detects_presence() {
assertTrue(SaaPrompts.hasGatespecBlock("x\n```gatespec\n{bad json}\n```"));
assertFalse(SaaPrompts.hasGatespecBlock("没有任何 gatespec 块的设计稿"));
}
}

View File

@ -0,0 +1,385 @@
'use strict';
export default function createGame(opts) {
const o = opts || {};
let ctx = null;
let boot = null;
let gameState = null; // { phase, score, remaining, ball, paddle, bricks, targetX, ... }
let inputSubs = [];
let nowMs = 0;
// 砖块配置
const COLS = 6;
const ROWS = 5;
const BRICK_W = 62;
const BRICK_H = 20;
const BRICK_GAP = 3;
const BOARD_Y = 50;
const PADDLE_W = 70;
const PADDLE_H = 16;
const PADDLE_Y = 810;
const BALL_R = 7;
const BALL_SPEED = 370; // px/s
const MAX_DEFLECT_ANGLE = 70 * Math.PI / 180;
function init(bootData) {
boot = bootData;
ctx = boot.ctx;
nowMs = ctx.time.nowMs();
// 初始化砖块
const bricks = [];
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const health = ctx.random.range(0, 1) > 0.7 ? 2 : 1; // 30% 需要2次
bricks.push({
x: col * (BRICK_W + BRICK_GAP) + 4,
y: BOARD_Y + row * (BRICK_H + BRICK_GAP),
w: BRICK_W,
h: BRICK_H,
health: health,
maxHealth: health
});
}
}
// 挡板(坐标为中心)
const paddle = {
x: 195,
y: PADDLE_Y,
w: PADDLE_W,
h: PADDLE_H
};
// 球(中心)
const ball = {
x: 195,
y: PADDLE_Y - 20,
vx: 0,
vy: 0
};
// 初始球方向随机(向上偏)
const angle = ctx.random.range(-45, 45) * Math.PI / 180;
ball.vx = BALL_SPEED * Math.sin(angle);
ball.vy = -BALL_SPEED * Math.cos(angle);
gameState = {
phase: 'playing',
score: 0,
remaining: bricks.length,
bricks: bricks,
paddle: paddle,
ball: ball,
targetX: 195,
tapToRestart: false,
gameoverReason: ''
};
// 输入订阅
const input = ctx.getInput();
if (input) {
const subDown = input.on('pointerdown', onPointer);
const subMove = input.on('pointermove', onPointer);
const subKey = input.on('keydown', onKey);
inputSubs = [subDown, subMove, subKey];
}
// 尝试首次触发引擎能力(常驻粒子)
const eng = ctx.getEngine();
if (eng && eng.particles) {
try {
eng.particles.spawnEmitter({
pos: { x: 195, y: 300 },
emitRate: 15,
emitTime: 0,
particleTime: 1.0,
speed: 60,
colorStart: { r: 1, g: 0.8, b: 0.2, a: 1 },
colorEnd: { r: 1, g: 0.2, b: 0, a: 0 }
});
} catch (_) {}
}
}
function onPointer(e) {
if (!gameState) return;
if (e.type === 'pointerdown') {
// 检查是否gameover后点击重玩
if (gameState.phase === 'gameover') {
restartGame();
return;
}
}
// 更新目标位置(逻辑像素坐标)
gameState.targetX = Math.max(0, Math.min(390, e.x || 0));
}
function onKey(e) {
if (!gameState) return;
if (e.type === 'keydown' && e.key === ' ') {
if (gameState.phase === 'gameover') {
restartGame();
}
}
}
function restartGame() {
// 销毁当前订阅
destroySubs();
// 重新初始化
init(boot);
}
function update(dt) {
// 每个逻辑步刷新时间
nowMs = ctx.time.nowMs();
if (!gameState || gameState.phase !== 'playing') return;
const paddle = gameState.paddle;
const ball = gameState.ball;
const bricks = gameState.bricks;
// 挡板平滑跟随目标
paddle.x += (gameState.targetX - paddle.x) * 0.25;
paddle.x = Math.max(paddle.w / 2, Math.min(390 - paddle.w / 2, paddle.x));
// 球移动
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
// 左右墙反弹(考虑球半径)
if (ball.x - BALL_R < 0) {
ball.x = BALL_R;
ball.vx = -ball.vx;
playHitSound();
}
if (ball.x + BALL_R > 390) {
ball.x = 390 - BALL_R;
ball.vx = -ball.vx;
playHitSound();
}
// 顶墙反弹
if (ball.y - BALL_R < 0) {
ball.y = BALL_R;
ball.vy = -ball.vy;
playHitSound();
}
// 底部失败
if (ball.y + BALL_R > 844) {
gameState.phase = 'gameover';
gameState.gameoverReason = 'ball_lost';
// 爆炸粒子
spawnBurst(ball.x, ball.y, { r: 1, g: 0.2, b: 0.2, a: 1 });
playHitSound();
return;
}
// 砖块碰撞(按顺序,每块一次)
for (let i = bricks.length - 1; i >= 0; i--) {
const b = bricks[i];
// 检测矩形与圆的碰撞简化为AABB + 圆球)
const closestX = Math.max(b.x, Math.min(ball.x, b.x + b.w));
const closestY = Math.max(b.y, Math.min(ball.y, b.y + b.h));
const dx = ball.x - closestX;
const dy = ball.y - closestY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < BALL_R) {
// 确定碰撞方向(主要从哪面进入)
const overlapX = (b.w / 2 + BALL_R) - Math.abs(ball.x - (b.x + b.w/2));
const overlapY = (b.h / 2 + BALL_R) - Math.abs(ball.y - (b.y + b.h/2));
if (overlapX < overlapY) {
ball.vx = -ball.vx;
} else {
ball.vy = -ball.vy;
}
// 将球推出砖块
if (overlapX < overlapY) {
if (ball.x < b.x + b.w/2) ball.x = b.x - BALL_R;
else ball.x = b.x + b.w + BALL_R;
} else {
if (ball.y < b.y + b.h/2) ball.y = b.y - BALL_R;
else ball.y = b.y + b.h + BALL_R;
}
// 砖块耐久减1
b.health--;
if (b.health <= 0) {
bricks.splice(i, 1);
gameState.score++;
gameState.remaining = bricks.length;
// 粒子爆发
spawnBurst(b.x + b.w/2, b.y + b.h/2, { r: 0.2, g: 0.9, b: 1, a: 1 });
// 音效
playScoreSound();
} else {
// 击中但未碎,给个小型粒子
spawnBurst(b.x + b.w/2, b.y + b.h/2, { r: 0.9, g: 0.6, b: 0.2, a: 0.8 }, 8);
playHitSound();
}
break; // 每帧只处理一个砖块碰撞,避免多个同时
}
}
// 挡板碰撞
const paddleLeft = paddle.x - paddle.w/2;
const paddleRight = paddle.x + paddle.w/2;
const paddleTop = paddle.y - paddle.h/2;
const paddleBottom = paddle.y + paddle.h/2;
if (ball.y + BALL_R >= paddleTop && ball.y - BALL_R <= paddleBottom &&
ball.x + BALL_R >= paddleLeft && ball.x - BALL_R <= paddleRight) {
// 击中挡板,计算反弹角度
const offset = (ball.x - paddle.x) / (paddle.w/2);
const clampedOffset = Math.max(-1, Math.min(1, offset)); // -1~1
const angle = clampedOffset * MAX_DEFLECT_ANGLE;
ball.vx = BALL_SPEED * Math.sin(angle);
ball.vy = -BALL_SPEED * Math.cos(angle);
// 将球置于挡板上沿
ball.y = paddleTop - BALL_R;
playHitSound();
// 粒子
spawnBurst(ball.x, ball.y, { r: 0.3, g: 0.8, b: 0.3, a: 1 }, 12);
}
// 检查通关
if (gameState.bricks.length === 0) {
gameState.phase = 'gameover';
gameState.gameoverReason = 'cleared_all';
// 胜利粒子
spawnBurst(195, 400, { r: 1, g: 1, b: 0.2, a: 1 }, 30);
playScoreSound();
}
}
function spawnBurst(x, y, color, count) {
const eng = ctx.getEngine();
if (eng && eng.particles) {
try {
eng.particles.spawnEmitter({
pos: { x, y },
count: count || 22,
speed: 80,
particleTime: 0.5,
sizeStart: 10,
sizeEnd: 2,
colorStart: color || { r: 1, g: 0.8, b: 0.2, a: 1 },
colorEnd: { r: 0.2, g: 0.5, b: 1, a: 0 }
});
} catch (_) {}
}
}
function playHitSound() {
const eng = ctx.getEngine();
if (eng && eng.audio && eng.audio.synth) {
try {
eng.audio.synth.synthSfx([0.5, 0.05, 440, , , 0.1, , 1.2]);
} catch (_) {}
}
}
function playScoreSound() {
const eng = ctx.getEngine();
if (eng && eng.audio && eng.audio.synth) {
try {
eng.audio.synth.synthSfx([1, 0.1, 880, , , 0.15, , 1.8]);
} catch (_) {}
}
}
function render(g) {
if (!gameState) return;
const state = gameState;
const paddle = state.paddle;
const ball = state.ball;
const bricks = state.bricks;
// 背景
g.fillStyle = '#121212';
g.fillRect(0, 0, 390, 844);
// 砖块
for (const b of bricks) {
const hpRatio = b.health / b.maxHealth;
let color;
if (hpRatio > 0.66) color = '#00cc66';
else if (hpRatio > 0.33) color = '#ffaa00';
else color = '#ff3333';
g.fillStyle = color;
g.fillRect(b.x, b.y, b.w, b.h);
g.strokeStyle = '#222';
g.lineWidth = 1;
g.strokeRect(b.x, b.y, b.w, b.h);
}
// 挡板
g.fillStyle = '#43c6e6';
g.fillRect(paddle.x - paddle.w/2, paddle.y - paddle.h/2, paddle.w, paddle.h);
// 球
g.fillStyle = '#ffcc00';
g.beginPath();
g.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
g.fill();
// HUD
g.fillStyle = '#cccccc';
g.font = '18px monospace';
g.fillText('Score: ' + state.score + ' Remaining: ' + state.remaining, 10, 30);
// 游戏结束覆盖
if (state.phase === 'gameover') {
g.fillStyle = 'rgba(0,0,0,0.6)';
g.fillRect(0, 0, 390, 844);
g.fillStyle = '#ffffff';
g.font = 'bold 36px sans-serif';
g.textAlign = 'center';
if (state.gameoverReason === 'cleared_all') {
g.fillText('YOU WIN!', 195, 380);
} else {
g.fillText('GAME OVER', 195, 380);
}
g.font = '18px sans-serif';
g.fillText('Tap to restart', 195, 440);
g.textAlign = 'start';
}
}
function destroySubs() {
inputSubs.forEach(sub => {
try { sub.cancel(); } catch(_) {}
});
inputSubs = [];
}
function destroy() {
destroySubs();
gameState = null;
boot = null;
ctx = null;
}
function _forensicsView() {
return {
seed: o.seed || 0,
state: function() {
const state = gameState;
if (!state) return { phase: 'unknown', score: 0, remaining: 0, ball: {x:0,y:0,vx:0,vy:0}, paddle: {x:0,y:0,w:0} };
return {
phase: state.phase,
score: state.score,
remaining: state.remaining,
progress: state.remaining === 0 ? 1 : (1 - state.remaining / (COLS*ROWS)),
gameoverReason: state.gameoverReason || '',
ball: { x: state.ball.x, y: state.ball.y, vx: state.ball.vx, vy: state.ball.vy },
paddle: { x: state.paddle.x, y: state.paddle.y, w: state.paddle.w }
};
}
};
}
return { init, update, render, destroy, _forensicsView };
}

View File

@ -0,0 +1,33 @@
{
"controlCheck": {
"paddlePath": "paddle.x",
"paddleY": 800,
"tapXs": [
50,
340
],
"tapsPerTarget": 6,
"moveMin": 25,
"tolerance": 70,
"why": "挡板须平滑逼近触点 x逮「一格一跳」/卡死:连点同一 x 后挡板应明显移动且最终靠近该 x"
},
"driver": {
"type": "paddle-intercept",
"ballPath": "ball.x",
"paddleY": 800,
"steps": 60,
"stepMs": 130
},
"expectedEngineCallPrefixes": [
"particles.",
"audio.synth"
],
"assertAfterPlay": [
{
"path": "remaining",
"op": "decreased",
"why": "适配性真玩(挡板自动接球)下击碎砖块→剩余下降;接不住/碰撞失效则不降"
}
],
"expectLatch": true
}

View File

@ -78,6 +78,40 @@ def cost_for_result(result, pricing, qpu, usd_rate):
return c
def main_stdin():
"""B-8 接缝:从 stdin 读 {"tokens_by_model": {model: {in, out}}} → 出 per-model + total ¥ JSON 到 stdout。
SAA-JavaSaaStudioGraphTest经子进程复用本折算口径不在 Java 重写 new-api quota 公式
出参{"by_model": {model: {in, out, rmb}}, "total_rmb": x, "gate_rmb": GATE_RMB,
"quota_per_unit": qpu, "usd_rate": rate, "group_ratio": 1.0, "note": "¥上界(不计缓存折扣)"}
"""
import sys
payload = json.loads(sys.stdin.read() or "{}")
tbm = payload.get("tokens_by_model", {}) or {}
pricing = fetch_pricing()
qpu, usd_rate = fetch_status()
by_model = {}
total = 0.0
for model, tok in tbm.items():
# 兼容 in/outSAA 口径)与 prompt_tokens/completion_tokensPython 口径)。
pin = int(tok.get("in", tok.get("prompt_tokens", 0)) or 0)
cout = int(tok.get("out", tok.get("completion_tokens", 0)) or 0)
c = compute(model, pin, cout, pricing, qpu, usd_rate)
by_model[model] = {"in": pin, "out": cout, "rmb": c["rmb"]}
total += c["rmb"]
out = {
"by_model": by_model,
"total_rmb": round(total, 5),
"gate_rmb": GATE_RMB,
"quota_per_unit": qpu,
"usd_rate": usd_rate,
"group_ratio": 1.0,
"note": "¥上界(不计缓存折扣)",
}
# 只把 JSON 打到 stdout调用方按行解析诊断信息走 stderr
print(json.dumps(out, ensure_ascii=False))
def main():
pricing = fetch_pricing()
qpu, usd_rate = fetch_status()
@ -96,4 +130,9 @@ def main():
if __name__ == "__main__":
import sys
# B-8--stdin 模式SAA-Java 子进程复用 ¥ 折算);无参=原 results/ 汇总表。
if "--stdin" in sys.argv:
main_stdin()
else:
main()