From 19f666cd5d3cbf9a8f4c828fdff6b48d6d441ea9 Mon Sep 17 00:00:00 2001 From: lili Date: Fri, 19 Jun 2026 06:18:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(saa):=20U1=20=E7=94=A8=E5=AF=B9=20M3=20?= =?UTF-8?q?=E2=80=94=20Anthropic=20=E5=8D=8F=E8=AE=AE=20client(flag=20?= =?UTF-8?q?=E6=97=81=E6=8C=82)+=20=E5=A4=9A=20Gen=20=E5=8F=96=E7=AD=94?= =?UTF-8?q?=E6=A1=88=20+=20per-role=20thinking=20budget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 003-U1「用对 agentic M3」: - AigcExecutorProperties: saaModelProtocol(默认 openai)/saaAnthropicBase(host 根)/saaThinkingBudget - SaaStudioGraph: RoleModelFactory + anthropicModel(host 根 baseUrl·x-api-key·Proxy.NO_PROXY·per-role budget=min(全局,maxTokens-1024)≥1024 才开 thinking) + openAiFactory(逐字等价旧路) + factory-based build 重载 - SaaGraphDispatcher: ensureGraph 按 saaModelProtocol flag 选 openai/anthropic 工厂(openai 默认路调用不变) - SaaStudioNodes: pickAnswerText 遍历 getResults 取「无 signature 末块」为答案(单 Gen=旧路字节等价),替 callAndRecord/judgeVision 的 getResult().getText() 实测定死(spike + 创始人 2026-06-19 网关治本):x-api-key→M3 原生 thinking 分离 block(getResults size=2,thinking 带 signature);Bearer / 带 /anthropic 前缀 = 坑。 验证全绿:编译;烟测 size2 取对答案;P0-2 专项(全 11 角色 assemble 不崩,SaaAnthropicAssembleTest 2/0/0);回归 43/0/0(openai 字节零变);依赖树 spring-ai-anthropic 1.1.2 无漂移/无 autoconfigure。 flag 默认 openai 现行字节零变;不碰 gd-runtime/build-from-source(Plan B 域)。 Co-Authored-By: Claude Opus 4.8 --- .../game-module-aigc-server/pom.xml | 7 + .../module/aigc/saa/SaaGraphDispatcher.java | 33 +++- .../game/module/aigc/saa/SaaStudioGraph.java | 167 ++++++++++++++++-- .../game/module/aigc/saa/SaaStudioNodes.java | 73 +++++++- .../executor/AigcExecutorProperties.java | 27 +++ .../aigc/saa/SaaAnthropicAssembleTest.java | 100 +++++++++++ .../aigc/saa/SaaAnthropicSmokeTest.java | 146 +++++++++++++++ .../aigc/saa/SaaPickAnswerTextTest.java | 89 ++++++++++ 8 files changed, 617 insertions(+), 25 deletions(-) create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicSmokeTest.java create mode 100644 game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml index 331724c5..7f5cafbb 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml +++ b/game-cloud/game-module-aigc/game-module-aigc-server/pom.xml @@ -47,6 +47,13 @@ com.alibaba.cloud.aispring-ai-alibaba-graph-core com.alibaba.cloud.aispring-ai-alibaba-starter-graph-observation org.springframework.aispring-ai-starter-model-openai + + org.springframework.aispring-ai-anthropic com.wanxiang diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java index ae01244b..d5ec42d6 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaGraphDispatcher.java @@ -312,22 +312,39 @@ public class SaaGraphDispatcher implements GenerationDispatcher { } synchronized (this) { if (compiledGraph == null) { - // new-api 上游:baseUrl 取 host 根(剥末尾 /v1,坑见 §7-1);apiKey 取执行器配置(密钥经环境变量注入)。 - String baseUrl = SaaStudioGraph.stripV1Suffix(properties.getLlmBase()); - OpenAiApi api = OpenAiApi.builder().baseUrl(baseUrl).apiKey(properties.getApiKey()).build(); // checkpoint(step5):开关开 ∧ DataSource 在席 → 懒建权威单 MysqlSaver(首个 dispatch 才触 DDL, // dispatcher=http 永不到此);否则降级内存态(saverConfig=null)。一图一 saver(只 register 一个)。 SaverConfig saverConfig = buildSaverConfigIfEnabled(); - // 固定架构 B2:透传救场阶梯 stage2 额外救场轮(saaStage2ExtraRepairs),走 8 参 build 重载(recursionLimit 按 5+3 重算)。 - compiledGraph = SaaStudioGraph.build(api, SaaStudioGraph.Models.stage1(), gameRuntimeRoot, + // Plan A/U1「用对 M3」:据 saaModelProtocol flag 选 per-role 模型工厂—— + // openai(默认)= OpenAI 兼容口径(OpenAiApi+OpenAiChatModel,字节零变); + // anthropic = Anthropic Messages 协议(thinking 原生分离,治 M3 thinking 内联污染)。布线唯一源仍是 assemble(两路共用)。 + boolean anthropic = "anthropic".equalsIgnoreCase(properties.getSaaModelProtocol()); + String upstreamDesc; + SaaStudioGraph.RoleModelFactory mf; + if (anthropic) { + // Anthropic 路:baseUrl=host 根(绝不加 /anthropic、勿用 stripV1Suffix,spike 实测);x-api-key 鉴权;budget回归命门:openai 工厂({@link #openAiFactory(OpenAiApi)})逐字调现行 {@link #model(OpenAiApi, String, Double, int)}, + * 产出与改造前完全相同的 {@link OpenAiChatModel} 对象 → 默认 openai 路运行时字节等价;anthropic 工厂 + * ({@link #anthropicFactory(String, String, int)})产 {@link AnthropicChatModel}(thinking 原生分离),仅 flag=anthropic 时注入。 + * + * @param modelName 角色模型名(per-role,如 deepseek-v4-flash / MiniMax-M3) + * @param temperature 采样温度(null=不下发,用服务端默认;anthropic 路同口径透传) + * @param maxTokens 最大补全 tokens(per-role,如 code/fix=16000、player_vision=900) + * @return 该角色的 {@link ChatModel}(OpenAI 或 Anthropic 实现) + */ + @FunctionalInterface + public interface RoleModelFactory { + ChatModel create(String modelName, Double temperature, int maxTokens); + } + + /** + * OpenAI 兼容口径的 per-role 工厂(默认路):逐字调 {@link #model(OpenAiApi, String, Double, int)},运行时字节等价改造前。 + * + * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) + * @return openai per-role 工厂 + */ + public static RoleModelFactory openAiFactory(OpenAiApi api) { + return (name, temperature, maxTokens) -> model(api, name, temperature, maxTokens); + } + + /** + * 建一个经 Anthropic Messages 协议指向 new-api 的 {@link AnthropicChatModel}(Plan A/U1「用对 M3」;thinking 原生分离, + * 治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因)。lili-mac spike 1.1.2 实测定死配方: + *
    + *
  • baseUrl = new-api host 根(如 {@code http://100.64.0.8:3000},completionsPath 保持默认 {@code /v1/messages} + * → 真实打 {@code .../v1/messages})。绝不加 {@code /anthropic} 前缀(命中 SPA catch-all 返 HTML→假挂起); + * 亦不复用 {@link #stripV1Suffix}(那是 OpenAI 口径的坑,与 Anthropic 协议无关);
  • + *
  • 鉴权 = {@code apiKey(key)}(发 {@code x-api-key} 头)。实测:x-api-key → 多 Generation 原生 thinking block + * (含 signature=推理 / 无 signature=答案);勿用 Authorization Bearer(实测退化为字面 {@code } 内联,正是要躲的坑);
  • + *
  • 代理旁路:经 {@link SimpleClientHttpRequestFactory} 设 {@link Proxy#NO_PROXY} + 连接/读超时,经 + * {@code AnthropicApi.builder().restClientBuilder(...)} 注入(1.1.2 的 {@code AnthropicChatModel$Builder} 无 customHeaders, + * 自定义只能经 restClientBuilder);
  • + *
  • thinking:{@code thinking(ENABLED, budget)},{@code budget < maxTokens} 硬约束(client 侧 fail-fast 断言, + * 勿信网关宽松、否则重演原截断 bug);阻塞 {@code .call()}(保留默认 RetryTemplate,流式 thinking 有 bug #4407 不用)。
  • + *
+ * + * @param anthropicBase Anthropic 协议 baseUrl(new-api host 根,无 /anthropic 前缀;= AigcExecutorProperties.saaAnthropicBase) + * @param apiKey new-api 密钥(发 x-api-key 头;密钥经配置注入,绝不入 repo) + * @param thinkingBudget thinking 预算 tokens(须 < 各角色 maxTokens;= AigcExecutorProperties.saaThinkingBudget) + * @return anthropic per-role 工厂 + */ + public static RoleModelFactory anthropicFactory(String anthropicBase, String apiKey, int thinkingBudget) { + return (name, temperature, maxTokens) -> anthropicModel(anthropicBase, apiKey, name, temperature, maxTokens, thinkingBudget); + } + + /** + * 构造单个 Anthropic 协议角色模型(配方见 {@link #anthropicFactory})。 + * + * @param anthropicBase Anthropic 协议 baseUrl(host 根、无 /anthropic) + * @param apiKey new-api 密钥(x-api-key) + * @param modelName 角色模型名 + * @param temperature 采样温度(null=不下发) + * @param maxTokens 最大补全 tokens + * @param thinkingBudget thinking 预算(全局上限;per-role 自适应 clamp 到 {@code min(全局, maxTokens-余量)} 恒 {@code < maxTokens}; + * 容不下 Anthropic 最小(1024) 的小判定角色则关 thinking,P0-2) + * @return Anthropic 角色模型 + */ + public static AnthropicChatModel anthropicModel(String anthropicBase, String apiKey, String modelName, + Double temperature, int maxTokens, int thinkingBudget) { + // 代理旁路 + 连接/读超时:macOS/容器系统代理(clash 等)会把 Tailscale IP 走代理→502;NO_PROXY 直连 new-api。 + SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); + rf.setProxy(Proxy.NO_PROXY); + rf.setConnectTimeout(10_000); + rf.setReadTimeout(120_000); + AnthropicApi api = AnthropicApi.builder() + .baseUrl(anthropicBase) // host 根;completionsPath 默认 /v1/messages(绝不加 /anthropic,勿用 stripV1Suffix) + .apiKey(apiKey) // 发 x-api-key 头(勿用 Authorization Bearer) + .restClientBuilder(RestClient.builder().requestFactory(rf)) // 1.1.2 无 customHeaders,自定义只能经 restClientBuilder + .build(); + // P0-2 修:per-role thinking budget 自适应(替原 fail-fast 抛)。assemble 给判定类角色硬编码小 maxTokens + // (playerVision=900/classify=1000/narrative=2000/playerText=4000),单一全局 budget(默认8192) 撞之即抛、anthropic 路 assemble 崩。 + // 正解:budget = min(全局, maxTokens-答案余量),恒 < maxTokens(守协议约束);容不下 Anthropic 最小 thinking(1024) + // 的小判定角色不开 thinking(判定/审查非生成、无需推理);生成角色(maxTokens 16000)仍用全局 8192。 + final int MIN_THINKING = 1024; // Anthropic thinking budget_tokens 下限 + final int ANSWER_RESERVE = 1024; // 给答案/工具调用留的最小补全空间 + int effectiveBudget = Math.min(thinkingBudget, maxTokens - ANSWER_RESERVE); + AnthropicChatOptions.Builder opts = AnthropicChatOptions.builder() + .model(modelName) + .maxTokens(maxTokens); + if (effectiveBudget >= MIN_THINKING) { + opts.thinking(AnthropicApi.ThinkingType.ENABLED, effectiveBudget); // 生成角色:thinking 开(原生分离 block) + } // else:maxTokens 太小的判定类角色不开 thinking(避免 budget≥maxTokens 崩;判定/审查无需推理) + if (temperature != null) { + opts.temperature(temperature); // null = 不下发(与 openai 路同口径) + } + return AnthropicChatModel.builder() + .anthropicApi(api) + .defaultOptions(opts.build()) // 生产保留默认 RetryTemplate(流式 thinking bug #4407 → 只阻塞 .call()) + .build(); + } + /** * 剥 new-api host 根末尾的 {@code /v1}(Spring AI OpenAiApi 自拼 completionsPath=/v1/chat/completions, * baseUrl 带 /v1 → 404 /v1/v1/...;坑见迁移设计 §7-1)。同一 .env(NEWAPI_BASE_URL=.../v1)两侧通用须 Java 侧剥之。 @@ -201,7 +307,7 @@ public final class SaaStudioGraph { * P0-4:deterministic 路本期 fail-loud 到 END(「从 sourceProject 真构建」管线待 B7+引擎线 E2;不静默走会忽略 * sourceProject 的旧 build);regenerate-module 路走 generate(本期可达)。modify 非孤儿、真做事(真改源/真置终态,无空壳)。 * - * @param api 共用 new-api 上游(baseUrl=host 根、apiKey 已设) + * @param mf per-role 模型工厂({@link #openAiFactory} 默认路·字节等价 / {@link #anthropicFactory} flag=anthropic) * @param models 角色模型名(含固定架构 classify/narrative) * @param gameRuntimeRoot game-runtime 根(harness/few-shot/证据所在;build/play 子进程 cwd) * @param maxRepairs stage1 修复轮上限(对齐 studio.py:max_repairs 默认 5) @@ -210,29 +316,29 @@ public final class SaaStudioGraph { * @return 未编译的全图(供 {@link #build} 挂 CompileConfig 后编译) * @throws GraphStateException 图结构非法(节点/边布线错误,编译期暴露) */ - private static StateGraph assemble(OpenAiApi api, Models models, Path gameRuntimeRoot, + private static StateGraph assemble(RoleModelFactory mf, Models models, Path gameRuntimeRoot, int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, String sourceMode) throws GraphStateException { - // == per-role 采样面(temperature/maxTokens)逐字对齐 Python == + // == per-role 采样面(temperature/maxTokens)逐字对齐 Python(经 mf 工厂建 ChatModel:openai 默认路字节等价;anthropic flag 旁挂)== // design:Python config.build_model 不传 temperature → 服务端默认 → temperature=null(不下发); // code/fix:_client.chat temperature=0.0、max_tokens=16000(确定性产出); // player_text:studio.py:167 max_tokens=4000、temperature=0.3; // player_vision:studio.py:160 max_tokens=900、temperature=0.3。 - OpenAiChatModel designModel = model(api, models.design(), null, 16000); - OpenAiChatModel codeModel = model(api, models.code(), 0.0, 16000); - OpenAiChatModel fixModel = model(api, models.fix(), 0.0, 16000); + ChatModel designModel = mf.create(models.design(), null, 16000); + ChatModel codeModel = mf.create(models.code(), 0.0, 16000); + ChatModel fixModel = mf.create(models.fix(), 0.0, 16000); // P1-5:救场 stage2 强档 code/fix(同 16000/temperature=0,仅模型名换强档);generate 据 modelTier 真切,使升档生效。 - OpenAiChatModel codeStage2Model = model(api, models.codeStage2(), 0.0, 16000); - OpenAiChatModel fixStage2Model = model(api, models.fixStage2(), 0.0, 16000); + ChatModel codeStage2Model = mf.create(models.codeStage2(), 0.0, 16000); + ChatModel fixStage2Model = mf.create(models.fixStage2(), 0.0, 16000); // U4 回退档(同采样面 0.0/16000,仅模型名换跨家;generateNode 主耗尽重试后再试一档)。 - OpenAiChatModel codeFallbackModel = model(api, models.codeFallback(), 0.0, 16000); - OpenAiChatModel fixFallbackModel = model(api, models.fixFallback(), 0.0, 16000); - OpenAiChatModel playerTextModel = model(api, models.playerText(), 0.3, 4000); - OpenAiChatModel playerVisionModel = model(api, models.playerVision(), 0.3, 900); + ChatModel codeFallbackModel = mf.create(models.codeFallback(), 0.0, 16000); + ChatModel fixFallbackModel = mf.create(models.fixFallback(), 0.0, 16000); + ChatModel playerTextModel = mf.create(models.playerText(), 0.3, 4000); + ChatModel playerVisionModel = mf.create(models.playerVision(), 0.3, 900); // 固定架构新增两角色(B2):classify(物理优先分类,温度低求稳)、narrative-reviewer(叙事审查,温度低)。 - OpenAiChatModel classifyModel = model(api, models.classify(), 0.0, 1000); - OpenAiChatModel narrativeModel = model(api, models.narrative(), 0.3, 2000); + ChatModel classifyModel = mf.create(models.classify(), 0.0, 1000); + ChatModel narrativeModel = mf.create(models.narrative(), 0.3, 2000); // == state schema:全 key 用 ReplaceStrategy(节点 return 覆盖写,对齐 dossier §1-22)== KeyStrategyFactory keyFactory = () -> { @@ -408,6 +514,11 @@ public final class SaaStudioGraph { * 组装并编译全图(plan 2026-06-18-001 U3 全量重载:显式 {@code sourceMode}=factory|gamedef)。 * 布线唯一源 = {@link #assemble}(所有 build 重载共用)。 * + *

本重载 = OpenAI 兼容路(默认,行为字节不变):内部 delegate 到 {@link #build(RoleModelFactory, Models, Path, + * int, int, int, String, SaverConfig, ObservationRegistry)} 并传 {@link #openAiFactory(OpenAiApi)}(逐字调现行 + * {@link #model(OpenAiApi, String, Double, int)},运行时字节等价)。anthropic 路 = dispatcher 经 flag 改传 + * {@link #anthropicFactory(String, String, int)}(U1 旁挂)。 + * * @param sourceMode 生成产物形态(factory=iife 现行 / gamedef=真结构化源;dispatcher 据 AigcExecutorProperties.saaSourceMode 传)。 */ public static CompiledGraph build(OpenAiApi api, Models models, Path gameRuntimeRoot, @@ -415,7 +526,33 @@ public final class SaaStudioGraph { String sourceMode, SaverConfig saverConfig, ObservationRegistry observationRegistry) throws GraphStateException { - StateGraph graph = assemble(api, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, + // openai 工厂 delegate(字节等价改造前:openAiFactory 逐字调 model(api,...))。 + return build(openAiFactory(api), models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs, + sourceMode, saverConfig, observationRegistry); + } + + /** + * 组装并编译全图(Plan A/U1 协议旁挂全量重载:显式 {@link RoleModelFactory}=openai 默认 / anthropic flag)。 + * 布线唯一源仍是 {@link #assemble}(openai/anthropic 两路共用同一布线,仅 per-role 模型实现不同,杜绝 split-brain)。 + * + * @param mf per-role 模型工厂({@link #openAiFactory} / {@link #anthropicFactory}) + * @param models 角色模型名 + * @param gameRuntimeRoot game-runtime 根 + * @param maxRepairs stage1 修复轮上限 + * @param maxPlayerRounds player 顾问轮上限 + * @param stage2ExtraRepairs stage2 额外修复轮上限 + * @param sourceMode 生成产物形态(factory|gamedef) + * @param saverConfig checkpoint saver 配置(null=默认内存态) + * @param observationRegistry 观测注册表(null=不埋点) + * @return 已编译图 + * @throws GraphStateException 图结构非法 + */ + public static CompiledGraph build(RoleModelFactory mf, Models models, Path gameRuntimeRoot, + int maxRepairs, int maxPlayerRounds, int stage2ExtraRepairs, + String sourceMode, + SaverConfig saverConfig, ObservationRegistry observationRegistry) + throws GraphStateException { + StateGraph graph = assemble(mf, models, gameRuntimeRoot, maxRepairs, maxPlayerRounds, stage2ExtraRepairs, sourceMode); CompileConfig.Builder cc = CompileConfig.builder() diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java index 519281cd..f0ea4900 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/saa/SaaStudioNodes.java @@ -6,11 +6,13 @@ import com.alibaba.cloud.ai.graph.action.NodeAction; import com.fasterxml.jackson.core.JsonPointer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.content.Media; import org.springframework.core.io.ByteArrayResource; @@ -231,12 +233,78 @@ final class SaaStudioNodes { return false; } + /** Anthropic thinking 块的在场标志键(实测 1.1.2:thinking Generation 的 {@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */ + private static final String ANTHROPIC_THINKING_SIGNATURE_KEY = "signature"; + + /** + * 从 {@link ChatResponse} 多 Generation 中取「答案文本」(Plan A/U1「用对 M3」;纯函数、无网络、可单测)。 + * + *

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

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

回归保证(命门):OpenAI 兼容路 {@code getResults().size()==1} → 直接取 {@code gens.get(0).getText()}, + * 与旧 {@code getResult().getOutput().getText()} 字节等价({@code getResult()} 即返首个 Generation)。 + * 故 design/classify/nreview/player 等任一走单 Generation 的路行为完全不变。 + * + * @param resp 模型阻塞调用响应(OpenAI 单 Generation / Anthropic+thinking 多 Generation)。 + * @return 答案文本(空响应返 "",绝不抛——交由调用方按节点语义兜底)。 + */ + static String pickAnswerText(ChatResponse resp) { + if (resp == null) { + return ""; + } + List gens = resp.getResults(); + if (gens == null || gens.isEmpty()) { + return ""; + } + // 单 Generation(OpenAI 兼容路 / Anthropic thinking 关):等价旧 getResult().getText()(回归保证)。 + if (gens.size() == 1) { + return textOf(gens.get(0)); + } + // 多 Generation(Anthropic+thinking):从尾向前找第一个 metadata 不含 signature 的 Generation(=答案,非 thinking 推理)。 + for (int i = gens.size() - 1; i >= 0; i--) { + Generation g = gens.get(i); + if (!hasThinkingSignature(g)) { + return textOf(g); + } + } + // 兜底(全是 thinking 块,异常态):取末个 Generation 文本(best-effort,不抛)。 + return textOf(gens.get(gens.size() - 1)); + } + + /** 取 Generation 的输出文本(空安全,null 归 "")。 */ + private static String textOf(Generation g) { + if (g == null || g.getOutput() == null) { + return ""; + } + String t = g.getOutput().getText(); + return t == null ? "" : t; + } + + /** 判该 Generation 是否为 Anthropic thinking 推理块({@code AssistantMessage.getMetadata()} 含 {@code signature} 键)。 */ + private static boolean hasThinkingSignature(Generation g) { + if (g == null || g.getOutput() == null) { + return false; + } + AssistantMessage out = g.getOutput(); + Map meta = out.getMetadata(); + return meta != null && meta.containsKey(ANTHROPIC_THINKING_SIGNATURE_KEY); + } + /** 调模型并把 usage 累计回 state(对齐 RecordingChatModel),含单次 180s 超时 + ≤3 重试。返回 [文本, 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(); + // U1:多 Generation 重组取答案(Anthropic+thinking 路答案在「无 signature」的 Generation;OpenAI 单 Generation 字节等价旧行为)。 + String text = pickAnswerText(resp); long in = 0, out = 0; Usage u = resp.getMetadata().getUsage(); if (u != null) { @@ -695,7 +763,8 @@ final class SaaStudioNodes { Prompt prompt = new Prompt(List.of(new SystemMessage(SaaPrompts.playerSystem(persona)), um)); return callWithTimeoutAndRetry(() -> { ChatResponse resp = visionModel.call(prompt); - String text = resp.getResult().getOutput().getText(); + // U1:多 Generation 重组取答案(同 callAndRecord;视觉位走 Anthropic+thinking 时答案在「无 signature」的 Generation)。 + String text = pickAnswerText(resp); long in = 0, out = 0; Usage u = resp.getMetadata().getUsage(); if (u != null) { diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java index cb264184..a8d3c163 100644 --- a/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/main/java/com/wanxiang/huijing/game/module/aigc/service/executor/AigcExecutorProperties.java @@ -197,6 +197,33 @@ public class AigcExecutorProperties { */ private String saaSourceMode = "factory"; + // ===== SAA 模型协议(Plan A · U1「用对 M3」;flag 旁挂,默认 openai 字节零变)===== + + /** + * SAA 图角色模型走的上游协议(Plan A/U1,plan 2026-06-19-002):{@code openai}=现行 OpenAI 兼容口径 + * ({@code OpenAiApi}+{@code OpenAiChatModel},经 new-api {@code /v1/chat/completions},默认·行为字节不变); + * {@code anthropic}=经 new-api Anthropic Messages 协议({@code AnthropicChatModel},{@code /v1/messages},thinking 原生分离) + * 调 M3——治「OpenAI 兼容口径下 M3 thinking 内联进正文污染结构化输出」根因(根因 v3「用对 M3」)。 + * 仅 dispatcher=saa 生效;openai 时 dispatcher 不构造 Anthropic 客户端(U1 旁挂、零回归)。 + */ + private String saaModelProtocol = "openai"; + + /** + * SAA Anthropic 协议 baseUrl(saaModelProtocol=anthropic 专用):取 new-api host 根 + * {@code http://100.64.0.8:3000}({@code AnthropicApi} 自拼 completionsPath 默认 {@code /v1/messages} → 真实打 + * {@code .../v1/messages})。绝不加 {@code /anthropic} 前缀(lili-mac spike 1.1.2 实测:{@code /anthropic/v1/messages} + * 命中 new-api SPA catch-all 返 HTML→Spring AI 解析失败假挂起);亦不复用 OpenAI 路的 stripV1Suffix + * (那是 OpenAI 兼容口径的坑,与 Anthropic 协议无关)。内网地址按项目规则可入库。 + */ + private String saaAnthropicBase = "http://100.64.0.8:3000"; + + /** + * SAA Anthropic 协议 thinking 预算 tokens(saaModelProtocol=anthropic 专用):开 thinking 时的 budget_tokens。 + * Anthropic 协议硬约束 {@code budget_tokens < max_tokens}(client 侧 fail-fast 断言,勿信网关宽松、否则重演原截断 bug); + * 默认 8192(< code/fix 角色 maxTokens=16000)。openai 协议下本字段不读。 + */ + private Integer saaThinkingBudget = 8192; + /** * 阈值关系铁律校验(HJ-AGENT-LOOP-EXEC-002 §5.2 精确式;执行器装配时调用,误配置 fail-fast) * diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java new file mode 100644 index 00000000..42780326 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaAnthropicAssembleTest.java @@ -0,0 +1,100 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import com.alibaba.cloud.ai.graph.CompiledGraph; +import org.junit.jupiter.api.Test; +import org.springframework.ai.anthropic.AnthropicChatModel; + +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * SaaAnthropicAssembleTest —— Plan A/U1 P0-2 专项命门模型无关单测(纯逻辑、无网络/无 key,普通 {@code mvn test} 即跑)。 + * + *

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

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

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

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

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

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

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

+ *   export NEWAPI_KEY=$(grep -oE 'sk-[A-Za-z0-9_-]{20,}' docs/内网凭据与端点.md | head -1)
+ *   mvn -pl game-module-aigc/game-module-aigc-server -am \
+ *       -Dtest=SaaAnthropicSmokeTest -Danthropic.smoke=1 -DfailIfNoTests=false \
+ *       -DargLine='-Dhttp.nonProxyHosts=100.64.0.8|localhost|127.0.0.1 -Dhttps.nonProxyHosts=100.64.0.8|localhost|127.0.0.1' \
+ *       test
+ * 
+ * 实测坑(必带 -DargLine 那行):macOS 系统代理 = clash 127.0.0.1:7897,surefire fork 的 JVM 自动继承 → 对 Tailscale IP + * 100.64.0.8 走 clash 代理 → 502 TransientAiException。{@link SaaStudioGraph#anthropicModel} 内已设 + * {@code SimpleClientHttpRequestFactory.setProxy(NO_PROXY)} 旁路;{@code -DargLine} 的 nonProxyHosts 是双保险。 + * + * @author 造梦AI(Plan A · U1 Anthropic client 门控烟测) + */ +class SaaAnthropicSmokeTest { + + /** new-api Anthropic 协议 baseUrl = host 根(completionsPath 默认 /v1/messages → 真实打 .../v1/messages;绝不加 /anthropic)。 */ + private static final String ANTHROPIC_BASE_URL = + System.getenv().getOrDefault("NEWAPI_BASE_URL_RAW", "http://100.64.0.8:3000"); + + /** 目标模型(经 new-api 转发到 MiniMax-M3,走 Anthropic Messages 协议)。 */ + private static final String MODEL = "MiniMax-M3"; + + /** 省钱预算:maxTokens 给上限,budget 必须 < maxTokens(Anthropic 协议约束,client 侧 fail-fast 断言)。 */ + private static final int MAX_TOKENS = 4096; + private static final int THINKING_BUDGET = 2048; + + /** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径)。 */ + private static final String SIGNATURE_KEY = "signature"; + + @Test + @EnabledIfSystemProperty(named = "anthropic.smoke", matches = "1", + disabledReason = "Plan A U1 Anthropic client 门控烟测:需 NEWAPI_KEY + 可达 new-api host 根 /v1/messages;开关 -Danthropic.smoke=1") + void anthropic_client_recipe_and_pick_answer_text() { + String key = System.getenv("NEWAPI_KEY"); + assertNotNull(key, "NEWAPI_KEY 未设(密钥须经环境变量,绝不入库)"); + assertTrue(!key.isBlank(), "NEWAPI_KEY 为空"); + + // ── 用生产同款工厂构造 client(定死配方:host 根 baseUrl + x-api-key + thinking ENABLED + NO_PROXY;budget gens = resp.getResults(); + int genCount = gens == null ? 0 : gens.size(); + System.out.println("[generationCount] resp.getResults().size() = " + genCount); + + boolean sawThinkingSignature = false; + for (int i = 0; i < genCount; i++) { + Generation g = gens.get(i); + AssistantMessage out = g.getOutput(); + String text = (out == null || out.getText() == null) ? "" : out.getText(); + String head = text.length() > 120 ? text.substring(0, 120).replace("\n", "\\n") : text.replace("\n", "\\n"); + Map meta = (out == null) ? null : out.getMetadata(); + boolean hasSig = meta != null && meta.containsKey(SIGNATURE_KEY); + sawThinkingSignature |= hasSig; + System.out.println(" [gen#" + i + "] textLen=" + text.length() + + " hasSignature=" + hasSig + " metaKeys=" + (meta == null ? "(null)" : meta.keySet())); + System.out.println(" textHead120=\"" + head + "\""); + // 内联 探测(x-api-key 路不应出现;若出现则网关方言变了,需复核配方)。 + if (text.contains("")) { + System.out.println(" >> [警示] text 含字面 (thinking 内联进正文,非原生 block)——配方可能失效,请复核"); + } + } + + // usage(成本证据)。 + try { + var u = resp.getMetadata() == null ? null : resp.getMetadata().getUsage(); + if (u != null) { + System.out.println("[usage] prompt=" + u.getPromptTokens() + + " completion=" + u.getCompletionTokens() + " total=" + u.getTotalTokens()); + } + } catch (Exception ignore) { + // usage 取不到不连坐证据打印 + } + + // ── pickAnswerText 取答案(生产同款重组)── + String answer = SaaStudioNodes.pickAnswerText(resp); + System.out.println("[pickAnswerText] len=" + answer.length() + + " head120=\"" + (answer.length() > 120 ? answer.substring(0, 120).replace("\n", "\\n") : answer.replace("\n", "\\n")) + "\""); + System.out.println("=================================================================="); + + // ── 断言(坐实配方真生效)── + // ① 多 Generation 形(x-api-key + thinking ENABLED 实测 size≥2:thinking + 答案)。 + assertTrue(genCount >= 2, + "x-api-key + thinking ENABLED 应返 ≥2 Generation(thinking + 答案);实得 " + genCount + + "——可能:端点路径错(应 host 根 /v1/messages,非 /anthropic) / 网关未开 Claude 兼容路 / 鉴权方言变 / 代理坑"); + // ② 存在含 signature 的 thinking 推理块(原生 thinking 分离,非内联 )。 + assertTrue(sawThinkingSignature, + "应存在含 signature 键的 thinking Generation(原生 thinking block 分离);缺则 thinking 未原生分离(配方失效)"); + // ③ pickAnswerText 取到「无 signature 的答案 Generation」且非空(生产重组语义真生效)。 + assertFalse(answer.isBlank(), "pickAnswerText 应取到非空答案文本(无 signature 的答案 Generation)"); + } +} diff --git a/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java new file mode 100644 index 00000000..dc285745 --- /dev/null +++ b/game-cloud/game-module-aigc/game-module-aigc-server/src/test/java/com/wanxiang/huijing/game/module/aigc/saa/SaaPickAnswerTextTest.java @@ -0,0 +1,89 @@ +package com.wanxiang.huijing.game.module.aigc.saa; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * SaaPickAnswerTextTest —— {@link SaaStudioNodes#pickAnswerText(ChatResponse)} 的模型无关单测(Plan A/U1)。 + * + *

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

    + *
  1. OpenAI 兼容路(单 Generation):直接取 gen0,断字节等价旧 {@code getResult().getText()} 行为(回归保证);
  2. + *
  3. Anthropic+thinking 路(多 Generation):取「末个 metadata 不含 signature 的 Generation」=答案, + * 跳过含 signature 的 thinking 推理块(实测识别靠 {@code signature} 键存在性,不靠 {@code "thinking"} 键)。
  4. + *
+ * 这把「取答案」逻辑与底层协议(OpenAI/Anthropic)解耦验证:无需真模型/真网关即可坐实 U1 重组语义正确 + 回归不破。 + * + * @author 造梦AI(Plan A · U1) + */ +class SaaPickAnswerTextTest { + + /** thinking 块在场标志键(与 {@link SaaStudioNodes} 内常量同口径:Anthropic thinking Generation 的 metadata 含此键)。 */ + private static final String SIGNATURE_KEY = "signature"; + + /** 建一条「无 metadata signature」的 Generation(=答案 Generation / 或 OpenAI 单 Generation)。 */ + private static Generation answerGen(String text) { + return new Generation(new AssistantMessage(text)); + } + + /** 建一条「含 metadata signature」的 Generation(=Anthropic thinking 推理块)。 */ + private static Generation thinkingGen(String text) { + AssistantMessage msg = AssistantMessage.builder() + .content(text) + .properties(Map.of("messageType", "ASSISTANT", SIGNATURE_KEY, "sig-abc123")) + .build(); + return new Generation(msg); + } + + @Test + void openai_single_generation_is_byte_equivalent_to_old_getResult() { + // OpenAI 兼容路:单 Generation → 直接取 gen0(等价旧 getResult().getOutput().getText())。 + ChatResponse resp = new ChatResponse(List.of(answerGen("{\"entities\":[]}"))); + assertEquals("{\"entities\":[]}", SaaStudioNodes.pickAnswerText(resp), + "单 Generation 必须字节等价旧 getResult().getText()(回归命门)"); + } + + @Test + void anthropic_thinking_picks_the_no_signature_answer_generation() { + // Anthropic+thinking:gen#0=thinking(含 signature,是推理)、gen#1=答案(无 signature)。 + ChatResponse resp = new ChatResponse(List.of( + thinkingGen("让我想一想:先比较小数部分……"), + answerGen("```js\nexport default function(){}\n```"))); + String picked = SaaStudioNodes.pickAnswerText(resp); + assertEquals("```js\nexport default function(){}\n```", picked, + "多 Generation 须取无 signature 的答案 Generation,而非含 signature 的 thinking 推理块"); + assertTrue(!picked.contains("让我想一想"), "绝不能取到 thinking 推理文本(那会污染 ```js/JSON 抽取)"); + } + + @Test + void anthropic_picks_last_no_signature_when_multiple_answers() { + // 多个无 signature 块(极端):取末个(末答案优先,对齐「从尾向前找第一个无 signature」)。 + ChatResponse resp = new ChatResponse(List.of( + thinkingGen("推理…"), + answerGen("早期答案"), + answerGen("最终答案"))); + assertEquals("最终答案", SaaStudioNodes.pickAnswerText(resp), "应取末个无 signature 的答案 Generation"); + } + + @Test + void all_thinking_falls_back_to_last_generation_without_throwing() { + // 兜底(异常态:全是 thinking 块)→ 取末个 Generation,绝不抛。 + ChatResponse resp = new ChatResponse(List.of(thinkingGen("推理A"), thinkingGen("推理B"))); + assertEquals("推理B", SaaStudioNodes.pickAnswerText(resp), "全 thinking 时兜底取末个(best-effort 不抛)"); + } + + @Test + void empty_or_null_returns_empty_string() { + // 空响应/空 Generation 列表 → 返 ""(交调用方按节点语义兜底,绝不抛/NPE)。 + assertEquals("", SaaStudioNodes.pickAnswerText(null), "null 响应返空串"); + assertEquals("", SaaStudioNodes.pickAnswerText(new ChatResponse(List.of())), "空 Generation 列表返空串"); + } +}