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:
parent
b780323e53
commit
2b59f53f44
13
SAA-INTEGRATION-TODO.md
Normal file
13
SAA-INTEGRATION-TODO.md
Normal 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。
|
||||
@ -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 Alibaba(SAA,v1.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 验 SAA(kill-criteria 见 §7);MVP 闭环期间继续跑现有执行器、互不干扰;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 worker(L1 裸路 + 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-cloud(Spring Boot 单体 / 未来微服务)]
|
||||
EXE[现有 AigcGenerateExecutor\n(MVP 闭环保留, 不动)]
|
||||
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-cloud(Spring 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) 嵌进去属未经官方验证的路径——见 §10,hybrid 降为独立后置 spike(Phase 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_cost),SAA/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-api(OpenAI 兼容)+ 便宜模型可调通出 JSON |
|
||||
| B 持久化 | 图状态**仅靠 MySQL+Redis** checkpoint/恢复,**零新中间件** |
|
||||
| C 接缝 | 图节点能 shell 调既有 build + 九门 harness 并读回 JSON |
|
||||
| D 真玩 | 跑通一款金样游戏端到端真玩(four-piece 证据,见 game-e2e-cdp-harness) |
|
||||
| E hybrid | `AgentScopeAgent.asNode()` 嵌一个自治修复节点跑通,验证 SAA↔AgentScope 接缝 |
|
||||
|
||||
> 红线:权威真玩门必须在 **mini-desktop(x86)** 复跑后才能下生产结论;lili-mac(ARM)只作快迭代证据。
|
||||
|
||||
---
|
||||
|
||||
## 8. 待确认项(创始人拍板)
|
||||
|
||||
1. MVP 闭环是否**上线前**迁 SAA,还是**上线后**?(建议:**上线后**,先并行 spike)
|
||||
2. **Nacos 部署时机**?(建议:Tier-3/A2A 真正启动时,不预铺)
|
||||
3. mode-C 的 C 端编排前端形态(远期单列,本轮不决)。
|
||||
4. agentscope-java 贡献:提供 GitHub 账号以便设 fork remote(fork→分支→PR)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 附:已核实事实 & 已纠正误判(自包含,防止重蹈)
|
||||
|
||||
**已核实**
|
||||
- SAA:GA,最新稳定 **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/File;Admin 可视化 + Dify DSL 导入;A2A-over-Nacos;JDK17。
|
||||
- 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+workflow,AgentScope 作为**可嵌节点**。
|
||||
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 文件引 reactor),asNode 内含 `block()` 时序坑;叠进阻塞单体=养「两套半」。→ E 门判据升级为「失败路径可控可观测」,且 hybrid 拆出主干。
|
||||
- **repair 自治节点孤儿**:升级为自治体缺失败/回滚/预算归属路。→ Phase1 **repair 仅保留受约束 LLM 节点**;自治化延到 Phase1.5 且须补三件套(失败路+预算归属+与九门交互)。
|
||||
|
||||
**v2 修订(已并入方案)**
|
||||
1. **spike 拆相**:**Phase 1 = 纯 SAA 图主干(无 asNode)**,过门 A/B/C/D + 新 **F**;**Phase 1.5 = hybrid(asNode)**,先解版本归属(D2),E 门记录 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-decide(Phase1 纯 SAA + 对照臂,时间盒,结果说话——推荐)/ ②defer(MVP 先用现路,待真消费者再上 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)。
|
||||
@ -0,0 +1,85 @@
|
||||
# SAA Agentic 编排 · Phase 1 spike 执行稿(lili-mac)
|
||||
|
||||
> 关联评审:`2026-06-15-SAA-AgentScope-agent平台-目标架构-review.md`(HJ-AGI-002,v2 双评审定稿)
|
||||
> 决策前提:**D1 = spike-to-decide**;**D2 = 生产 SAA-only**(SAA 自带 `ReactAgent`/`asNode()`/`subAgents`,AgentScope 嵌入未来备选)。
|
||||
> 状态:执行版 · 机器 = lili-mac(快迭代);**权威真玩门 = mini-desktop(x86)**
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与范围边界
|
||||
- **目标**:把生成主线表达成 SAA StateGraph,验证能否在我们的栈上跑通一款金样游戏真玩,并据"对照臂"判定**是否值得迁**。
|
||||
- **范围**:隔离 spike,目录 `spikes/saa-gen-graph/`,**零改生产 game-cloud 与 Python worker**。
|
||||
- **非目标**:不做 AgentScope hybrid(D2);不部署任何新中间件;不碰 MVP 闭环。
|
||||
|
||||
## 2. 前置
|
||||
- lili-mac:JDK17、Maven、Node(给既有 harness)、Tailscale 可达 new-api。
|
||||
- 已 clone:`/root/oss/spring-ai-alibaba`(v1.1.2.2)。
|
||||
- 既有 harness(esbuild 构建 + CDP 九门真玩)**复用为子进程工具,禁重写**。
|
||||
|
||||
## 3. kill-criteria(门;任一挂 → 停/回落)
|
||||
| 门 | 判据 | 源码锚点(已核) |
|
||||
|---|---|---|
|
||||
| A 模型 | SAA ChatModel 指 new-api(OpenAI 兼容 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 生成编排 spike(Phase1 纯 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 worker(run.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 源码确认编排 API:StateGraph/Node/Edge/addConditionalEdges、ReactAgent(+asNode)、SequentialAgent/ParallelAgent/LlmRoutingAgent/LoopAgent、SubGraphNode、MysqlSaver/RedisSaver。给类名与最小用法。
|
||||
3. 跑通门 A(new-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. 完成条件
|
||||
门 A–E 全绿 + 一款金样真玩四件套 + 对照臂数据 + `VERDICT.md`(go/no-go)+ **mini-desktop 复跑权威真玩门**。
|
||||
70
docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md
Normal file
70
docs/agent-specs/2026-06-15-SAA-能力API接入-dossier.md
Normal 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、工具调用、多 agent(sequential/parallel/routing/conditional/loop)、持久化 checkpoint/resume(MysqlSaver 仅需 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 网关、sandbox(Docker)、RAG 走 ES、Prompt 发布线上热更(写 Nacos)。
|
||||
|
||||
**两条最易误判**:① **Admin 整包 ≠ MySQL+Redis 就够**——只想用编排引擎就**别起 admin,直接用 `graph-core`+`agent-framework` 库依赖**。② 向量库在本 clone 只有 ES 是具体实现。
|
||||
|
||||
## 3. 接入 game-cloud(依赖冲突解法放最前)
|
||||
|
||||
**真冲突仅 2 处**:
|
||||
- **C1 Spring Boot**:项目 3.5.14(huijing-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`**,跑完单独短事务落库。
|
||||
|
||||
**最小验证门 E(6 步,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 与生成图实现直接照此落地。
|
||||
309
docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md
Normal file
309
docs/agent-specs/2026-06-15-python-to-SAA-migration-design.md
Normal file
@ -0,0 +1,309 @@
|
||||
# Python agentic 生成系统 → SAA(Spring 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`(九门 A–I)+`build.mjs`(esbuild)是模型无关、已沉淀的确定性地板(`game-runtime/games/_wg1-gen/_shared/play.cdp.cjs:10-26`)。SAA 在 build/play 节点内 `ProcessBuilder` 调同一套 shell(dossier §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 代理转走→502(spike 头号坑)。
|
||||
- **`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 harness(build + 九门)如何被调
|
||||
|
||||
- **入口**:`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` 锁参 esbuild(bundle+minify+iife+es2019+无 sourcemap),输出 `bundle.iife.js` + `.meta.json`。
|
||||
- **九门 A–I**(`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 prompt(state 即传参) | 直接对应 |
|
||||
| 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`(裸 openai,base_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` | 节点写 state;emit 节点从 state 组 `DifyCallbackReqVO`(+ 诊断落盘可选) | 直接对应 |
|
||||
| — | **新增(Python 无)** | **MysqlSaver checkpoint**(同 threadId 续跑/time-travel,dossier §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` Map,dossier §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-5(MysqlSaver 唯一权威 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-panel(vision\|text 可并行)封为子图,主图一个节点引用 | dossier §2(嵌套子图本期可用) |
|
||||
| 模型节点指 new-api | N 个 `OpenAiChatModel` 共用 `OpenAiApi`(baseUrl=new-api) | dossier §1-24 |
|
||||
| 可观测埋点 | 节点级埋点/图可视化,替代 Python 散落 print | dossier §2 |
|
||||
|
||||
### 3.3 关键节点骨架草图(伪代码级,**非实现**)
|
||||
|
||||
**state schema(KeyStrategyFactory,dossier §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 节点(NodeAction,dossier §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 节点(静态 = 纯 Java,node --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
|
||||
}
|
||||
```
|
||||
|
||||
**条件边(EdgeAction,dossier §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`(九门 A–I)/ `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-3);ReAct 那种无限循环坑(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-panel(vision\|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-desktop(x86 e2e 机)跑。SAA worker(无论形态①②)**必须与 harness 同机部署**(all localhost),cwd=game-runtime 根。形态①把图嵌进 game-cloud → game-cloud 须能在 mini-desktop 起且能 fork 子进程(确认权限/PATH)。 |
|
||||
| 中 | **MysqlSaver 注入 yudao Druid + 一图一 saver** | dossier §3-13/§5-5:MysqlSaver 收 `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 集 + 同 model(stage1)+ 同九门 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-api,per-role model 名),一个 generate 节点真出 ```js,断言能拿到工厂源码 + usage。
|
||||
3. **validate/scaffold/build/play 四节点接子进程**:build/play 用 `ProcessBuilder` 调既有 harness(mini-desktop,cwd=game-runtime),读回 `verdict.json`;单测验九门 verdict 能解析、退出码正确传递。
|
||||
4. **组装裸图 + 条件边**:render→design→generate→validate→[repair 环]→scaffold→build→play→player→emit/giveup,state `repair_count`/`feedback` 回喂;跑通**一款** brief(如 breakout)端到端过九门。
|
||||
5. **接 MysqlSaver checkpoint**:注 Druid DataSource,`releaseThread(false)`,验同 threadId 续跑 + time-travel。
|
||||
6. **接 streaming 进度**(可选,对比期可缓):节点塞 Flux,SSE 出口验前端收到逐节点进度。
|
||||
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`(gitignored,worktree 内无);跑测试经环境变量传入。
|
||||
4. **SAA model API(v1.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()`。
|
||||
94
docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md
Normal file
94
docs/agent-specs/2026-06-15-python-vs-SAA-对比测试.md
Normal 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:49–04: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 步 + repair,out tokens 偏高),wall 互有胜负(whack SAA 更快、breakout/flappy PY 更快)。
|
||||
- ⚠️ **单样本噪声大**:便宜模型随机性高,whack 的 PY 败/SAA 胜、修复轮差异都可能在噪声内——**这是信号,非统计定论**。
|
||||
- 观察:**两次 Python 失败都卡 G_input(输入有效门)**,SAA 未卡——多样本下值得看是否真模式。
|
||||
|
||||
## 建议
|
||||
1. **统计置信**:扩到 N≥3–5 样本 × 全 17 款 briefs 再下定论(当前各 1 样本)。
|
||||
2. **SAA 侧已就绪**:双评审 + 9 项必修后完整/公平/正确;可进生产收口(form① 进程内 dispatcher + MysqlSaver checkpoint + streaming,发挥 SAA 强项)。
|
||||
3. **并发铁律**:批跑独占 4320/9222,同一时刻只许一个驱动(本轮踩过并发污染坑,已纠正)。
|
||||
@ -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>
|
||||
|
||||
@ -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:四个生成流水 NodeAction(validate / 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-111(static_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-53(ProcessBuilder node scripts/build.mjs ... --global-name=__GameBundle);</li>
|
||||
* <li>{@link #playNode(Path)} ≈ run.py:56-72(ProcessBuilder bash serve-and-play.sh,timeout 180s,读 evidence/verdict.json)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>纪律(本步边界,对齐迁移设计 §6.2-step3):不接 LLM(不调 new-api)、不接 saver、不接 job 契约、不组全图。
|
||||
* harness(serve-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/<id>/,避免覆盖现有游戏);</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; // 合并后的 stdout(redirectErrorStream,已含 stderr)
|
||||
|
||||
ProcResult(boolean timedOut, int exitCode, String output) {
|
||||
this.timedOut = timedOut;
|
||||
this.exitCode = exitCode;
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一子进程运行器(build/play/node-check 三处共用):合并 stderr→stdout,<b>并发 drain 防管道死锁</b>,
|
||||
* 限时 {@code waitFor},超时 destroy→destroyForcibly→二次 waitFor 兜底强杀。
|
||||
*
|
||||
* @param pb 已配好 command/directory 的 ProcessBuilder(本方法负责 redirectErrorStream(true))。
|
||||
* @param timeoutSecs 超时秒数(对齐 Python 对应 subprocess.run(timeout=))。
|
||||
* @return ProcResult(timedOut / 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(省时)。
|
||||
* 写 state:validateOk(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/<id>/。
|
||||
* 写 generated-factory.js(entry-bundle 壳 import './generated-factory.js')
|
||||
* + 拷 _shared/entry-bundle.template.js → entry-bundle.js
|
||||
* + 拷 _shared/index.template.html → index.html
|
||||
* + 写 play-spec.json(来自 state.playSpec)。
|
||||
* 写 state:gameDir(落盘目录绝对路径)。
|
||||
*
|
||||
* @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.js(entry-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-53;harness 禁重写)。
|
||||
|
||||
/**
|
||||
* build 节点(对齐 run.py:build):node scripts/build.mjs <entry> <out> --global-name=__GameBundle,
|
||||
* cwd=主仓 game-runtime;断言出 bundle.iife.js。
|
||||
* 写 state:buildOk(boolean) / buildLog(esbuild 日志,失败回喂用) / feedback(失败时)。
|
||||
*
|
||||
* @param gameRuntimeRoot 主仓 game-runtime 根(cwd;esbuild 在此 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-72;harness 禁重写)。
|
||||
|
||||
/**
|
||||
* play 节点(对齐 run.py:play):bash games/_wg1-gen/_shared/serve-and-play.sh <id> 4320 9222,
|
||||
* cwd=主仓 game-runtime,timeout 180s,destroyForcibly;读 evidence/verdict.json,解析 pass。
|
||||
* 九门 pass 判定 = 退出码 0 ∧ verdict.pass(对齐 run.py:run_job 的 `rc==0 and verdict.pass`)。
|
||||
* 写 state:verdict(JSON) / playPass(boolean) / feedback(失败摘守卫,对齐 _verdict_feedback)。
|
||||
*
|
||||
* @param gameRuntimeRoot 主仓 game-runtime 根(cwd;serve-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/9222;worker 并行形态可每 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);
|
||||
}
|
||||
}
|
||||
@ -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()}:scaffold→build→play 三节点串成最小 StateGraph,
|
||||
* 金样经 SAA 真驱动既有九门 harness,断言 <b>verdict.pass=true</b>(需 mini-desktop:node+esbuild+Chrome,
|
||||
* 故 {@code -Dsaa.harness=1} 时才跑;否则跳过,避免在无 harness 机误判 fail)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>纪律(迁移设计 §6.2-step3):不接 LLM、不接 saver、不接 job 契约、不组全图(下一步);harness 子进程复用·禁重写。
|
||||
*
|
||||
* <p>运行(mini-desktop,cwd=主仓 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 / 缺 destroy),validate 必须判不过。
|
||||
*/
|
||||
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 在 PATH;mini-desktop 有)--
|
||||
String nc = SaaGenNodes.nodeCheck(golden);
|
||||
System.out.println("[validate/golden node--check] " + (nc.isEmpty() ? "OK" : nc));
|
||||
assertTrue(nc.isEmpty(), "金样应过 node --check,实得:" + nc);
|
||||
}
|
||||
|
||||
// ====================== ② scaffold→build→play 三节点串图:金样过九门 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.sh(harness 子进程地板),路径:" + GAME_RUNTIME_ROOT);
|
||||
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
|
||||
"缺 node_modules/esbuild(build 节点依赖),请在 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=true,feedback=" + 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 + " 应 pass,verdict=" + v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) -> END:generate 节点用 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 key:usage 累计(诊断用) */
|
||||
private static final String K_USAGE = "usage";
|
||||
|
||||
/**
|
||||
* 精简版 system prompt:prompt.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) 最小 StateGraph:START -> 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 "";
|
||||
}
|
||||
}
|
||||
@ -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-119(system 注入 few-shot;user 末尾回喂上轮失败原因,全量重出非 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.ctx(init 里存下 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)。
|
||||
· progress:0~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 用够强参数(count≥20、sizeStart 8~14、particleTime≥0.4、colorStart 用亮色且 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/>/>=/</<=/==/in),path 用你让代码导出的字段名(须与 exportState 一致)。""";
|
||||
|
||||
// ============================================================================
|
||||
// ③ player_system —— 逐字移植 roles.py:38-57(玩家 agent 人格 + 1-5 分锚 + 空心必 fix)。
|
||||
// ============================================================================
|
||||
|
||||
/** 玩家 agent system prompt(persona 注入),逐字对齐 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-119(few-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 解析出的 gatespec(JsonNode,对象),或 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_repair,A-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<N--> repair build --ok--> play build --fail&cnt<N--> repair
|
||||
* play --九门pass--> player play --fail&cnt<N--> repair play --fail&cnt>=N--> giveup
|
||||
* player --无体验问题/轮尽--> emit player --有问题&轮未尽--> repair
|
||||
* repair --> generate emit/giveup --> END
|
||||
* </pre>
|
||||
*
|
||||
* <p>公平性铁律(对比测试只换编排框架):prompt/角色/模型路由全部走 {@link SaaPrompts}(逐字移植 Python wg1/gen-worker),
|
||||
* 模型路由用 models.yaml stage1(design/code/fix/player_text=deepseek-v4-flash、player_vision=MiniMax-M3)。
|
||||
*
|
||||
* <p>验收口径(按任务要求):理想 verdict.pass=true;若 maxRepairs 内未过门,只要<b>全图机制</b>(条件边/repair 回环/
|
||||
* 各节点)真跑通也算 step4 达成(过门质量是后续调参/对比的事)。
|
||||
*
|
||||
* <p>纪律(step4 边界):emit 不接 DifyCallbackReqVO/HMAC(step7);saver 用默认内存态(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) 轮 × 每轮经过节点数 + 余量。
|
||||
* 每轮主链最长路径节点数 ≈ 7(render/design 仅首轮各 1 次,循环体 generate→validate→scaffold→build→play→player→repair);
|
||||
* 取 (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-3:per-role 采样面随请求下发(temperature/maxTokens 按角色,逐字对齐 Python studio.py 各位置)。
|
||||
*
|
||||
* @param temperature 采样温度;<b>null=不下发 temperature</b>(让网关用服务端默认,对齐 AgentScope 默认——
|
||||
* Python config.build_model 不传 generate_kwargs,design 等角色即用服务端默认温度)。
|
||||
* @param maxTokens 最大补全 tokens(code/fix=16000、player_text=4000、player_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.sh(harness 地板),路径:" + GAME_RUNTIME_ROOT);
|
||||
assertTrue(Files.exists(GAME_RUNTIME_ROOT.resolve("node_modules/esbuild")),
|
||||
"缺 node_modules/esbuild(build 节点依赖)");
|
||||
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-3:per-role 采样面(temperature/maxTokens)逐字对齐 Python——
|
||||
// design:Python config.build_model 不传 temperature → AgentScope 用服务端默认 → 此处 temperature=null(不下发);
|
||||
// code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出);
|
||||
// player_text:studio.py:167 _judge_text max_tokens=4000、temperature=0.3;
|
||||
// player_vision:studio.py:160 _judge_vision max_tokens=900、temperature=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) 跑一款真 brief(breakout,与 step2 同)端到端 ==
|
||||
// A-9:runId/gameId 参数化——每 run 唯一 gameId(基名+runId),落独立目录,避免并行/重跑互撞(对照 run.py:56-63)。
|
||||
// 端口 port/cdpPort 也入 state(默认 4320/9222);worker 并行形态可每 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-7:recursionLimit 静默不完整态兜底——超步等优雅终止时 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¥ + attempts,schema 对齐 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 JSON(schema 对齐 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/openai),cost.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/<name>.json。
|
||||
* play_spec 缺省给 "{}"(design 自产 gatespec 仍可 merge);brief 文件不可达直接抛(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);
|
||||
}
|
||||
}
|
||||
@ -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 4:LLM 驱动的编排节点 + 条件边谓词(generate/design/player/repair/emit/giveup)。
|
||||
*
|
||||
* <p>本类承接 Python studio.py 的<b>编排循环语义</b>(design→[code|fix 环]→九门→player 顾问轮),
|
||||
* 与 {@link SaaGenNodes}(validate/scaffold/build/play,step3 已建)拼成蓝图 §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/重出 fix,build_messages 回喂,抽 ```js);</li>
|
||||
* <li>{@link #playerNode(ChatModel,ChatModel,Path)} ≈ studio.py:171-280(M3 视觉位+文本位 panel,软门回喂);</li>
|
||||
* <li>{@link #repairNode} ≈ studio.py:231 的 attempt 自增 + role=fix(feedback 已由失败节点写好);</li>
|
||||
* <li>{@link #emitNode(Path)} ≈ service.py:184-195(读 bundle.iife.js→断 __GameBundle→status/engineBundle);</li>
|
||||
* <li>{@link #giveupNode} = 超 maxRepairs 的失败终态(status=failed/failureReason)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>纪律(本步边界):emit <b>不接 DifyCallbackReqVO/HMAC 回调</b>(那是 step7 形态②);不接 saver(step5);不接 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 自产 gatespec(JSON 串,诊断)
|
||||
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"; // 九门 verdict(JSON 串)
|
||||
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 token(JSON 串:{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|failed(emit/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 内部 key(scaffold/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-8:per-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-223):DESIGN_SYSTEM → 设计 model 出设计稿 + 自产 gatespec;
|
||||
* 抽 gatespec → merge 进 playSpec(覆盖 brief 默认,使确定性门适用任意游戏)。
|
||||
*
|
||||
* @param designModel 设计角色模型(指 new-api,model=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-1:design 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 的回喂 feedback(buildMessages 会附到 user 末尾,role 仍 code)。
|
||||
if (!gatespecParseErr.isEmpty()) {
|
||||
out.put(K_FEEDBACK, gatespecParseErr);
|
||||
}
|
||||
accumTokensByModel(s, out, designModelName, inDelta, outDelta); // B-8:per-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-245):role=code→codeModel / role=fix→fixModel;
|
||||
* 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-1:generate(含 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-8:LLM 失败也记一轮(stage_fail=generate,usage 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-8:per-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<maxRounds → 写 feedback 回 repair(七门仍是硬地板,player 软门)。
|
||||
*
|
||||
* @param playerTextModel 文本位模型(models.yaml stage1.player_text)。
|
||||
* @param playerVisionModel 视觉位模型(恒 MiniMax-M3;hook,失败优雅降级)。
|
||||
* @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-8:per-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-8:per-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 Media;OpenAI 兼容走 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=null、verdict="?"、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.1):ok? 下一步 : repairCount>=N? giveup : repair。
|
||||
*
|
||||
* @param okKey 成功标志 state key(validateOk / 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);
|
||||
}
|
||||
}
|
||||
@ -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:按已注册 KeyStrategy(ReplaceStrategy=覆盖)写回。
|
||||
}
|
||||
|
||||
// ====================== A-4:playerFeedback 串味回归 ======================
|
||||
|
||||
@Test
|
||||
void repair_consumes_and_clears_player_feedback() throws Exception {
|
||||
// 初态:player 软门写了体验问题 playerFeedback,repairCount=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=fix;feedback 提升为 playerFeedback 内容;playerFeedback 清空;playerRound+1;repairCount+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-4:repair 消费后 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-4:validate 硬门失败应保持 playerFeedback 为空");
|
||||
}
|
||||
|
||||
// ====================== A-6:player 评判归一回归(反射调私有 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-5:gatespec / 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() {
|
||||
// 设计稿带 gatespec(paddle-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 块的设计稿"));
|
||||
}
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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-Java(SaaStudioGraphTest)经子进程复用本折算口径(不在 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/out(SAA 口径)与 prompt_tokens/completion_tokens(Python 口径)。
|
||||
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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user