From b583bd6dc68bb1c3b22c7d4c38979a1f45af38f0 Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 8 Jul 2026 01:25:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai):=20S7d=20=E7=94=9F=E6=88=90=20provider?= =?UTF-8?q?=20=E5=88=87=20Dify=20=E7=9A=84=20fail-closed=20=E7=A1=AC?= =?UTF-8?q?=E5=8C=96=20+=20=E8=B6=85=E6=97=B6=E5=AF=B9=E9=BD=90(=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=B1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分支①第四步代码/配置层:RoutingMuseAiRuntimeClient 补 Dify 装配为占位 Unavailable 时的 fail-closed ——dify 选中但未装配/凭据缺 → 返回 Dify 专属 AI_DIFY_UNAVAILABLE 并拒绝,绝不回退 New-API(此前会 误报 AI_NEW_API_UNAVAILABLE);dify 分支三出口(requiresSourceRefs→REF_INVALID / !difyAvailable→ UNAVAILABLE / 否则 difyClient.execute)均不触碰 newApiClient(亲验)。Dify non-stream-read 超时默认 90→180s 对齐总预算(180≤180 --- .../facade/RoutingMuseAiRuntimeClient.java | 11 ++++- .../framework/ai/config/MuseAiProperties.java | 4 +- .../src/main/resources/application.yaml | 4 +- .../muse/MuseAiRuntimeClientTest.java | 48 +++++++++++++++++++ .../scripts/dev/p1r-external-acceptance.env | 3 +- .../scripts/dev/solo-compose.env.example | 11 ++++- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/facade/RoutingMuseAiRuntimeClient.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/facade/RoutingMuseAiRuntimeClient.java index 8c2c72b3..3de8b2ae 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/facade/RoutingMuseAiRuntimeClient.java +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/application/muse/facade/RoutingMuseAiRuntimeClient.java @@ -19,10 +19,15 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient { private final MuseAiRuntimeClient newApiClient; private final MuseAiRuntimeClient difyClient; + private final boolean difyAvailable; public RoutingMuseAiRuntimeClient(MuseAiRuntimeClient newApiClient, MuseAiRuntimeClient difyClient) { this.newApiClient = newApiClient == null ? new UnavailableMuseAiRuntimeClient() : newApiClient; - this.difyClient = difyClient == null ? null : difyClient; + this.difyClient = difyClient; + // Dify 槽位未装配(null)或仅装配为占位 Unavailable adapter 时,一律视为 Dify 不可用。 + // WHY 单列 Unavailable:占位 Unavailable 会返回 New-API 语义错误码(AI_NEW_API_UNAVAILABLE), + // 对 Dify 路径具误导性;S7d 要求选中 dify 必须失败关闭并抛 Dify 专属错误,绝不回退 New-API 伪成功。 + this.difyAvailable = difyClient != null && !(difyClient instanceof UnavailableMuseAiRuntimeClient); } @Override @@ -38,7 +43,9 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient { return failure(command, "provider_bad_request", "AI_AGENT_DIFY_REF_INVALID", "Dify runtime cannot satisfy Muse source attribution contract", false); } - if (difyClient == null) { + // fail-closed:Dify 未装配/装配为 Unavailable/凭据缺失 → 拒绝,绝不回退 New-API。 + // (凭据/appId 缺失等细分错误由 difyClient 内部 validateConfig 失败关闭并返回 Dify 专属错误码。) + if (!difyAvailable) { return failure(command, "runtime_unavailable", "AI_DIFY_UNAVAILABLE", "AI Dify runtime unavailable", false); } diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/framework/ai/config/MuseAiProperties.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/framework/ai/config/MuseAiProperties.java index dc606fba..13560d7a 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/framework/ai/config/MuseAiProperties.java +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/framework/ai/config/MuseAiProperties.java @@ -267,7 +267,9 @@ public class MuseAiProperties { private List credentials = List.of(); private Integer connectTimeoutSeconds = 5; - private Integer nonStreamReadTimeoutSeconds = 90; + // 生成主链切 Dify(S7d)默认对齐 180s 总预算:实际请求超时取 min(该值, 总预算), + // 若低于总预算会把长篇生成截短,故默认 180(须 < SSE 死线 240s)。 + private Integer nonStreamReadTimeoutSeconds = 180; private Integer totalTimeoutSeconds = 180; @Data diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml index 12cceb3b..99d15815 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/main/resources/application.yaml @@ -248,7 +248,9 @@ muse: - ref: ${MUSE_AI_DIFY_PARSER_CREDENTIAL_REF:dify-parser-s1} api-key: ${MUSE_AI_DIFY_PARSER_API_KEY:} connect-timeout-seconds: ${MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS:5} - non-stream-read-timeout-seconds: ${MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS:90} + # 生成主链切 Dify(S7d):短读超时对齐 180s 总预算。WHY 不是 90:实际请求超时取 min(非流式短读, 总预算), + # 90 会把长篇生成截到 90s;须 ≥ 总预算(180s)且 < SSE 死线(240s)才能让上游先出话、后端不提前关门。 + non-stream-read-timeout-seconds: ${MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS:180} total-timeout-seconds: ${MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS:180} --- #################### 芋道相关配置 #################### diff --git a/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeClientTest.java b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeClientTest.java index 453d7fbf..952e0a47 100644 --- a/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeClientTest.java +++ b/muse-cloud/muse-module-ai/muse-module-ai-server/src/test/java/cn/iocoder/muse/module/ai/application/muse/MuseAiRuntimeClientTest.java @@ -525,6 +525,54 @@ class MuseAiRuntimeClientTest { assertTrue(response.failure().retryable()); } + @Test + void should_failClosedForDifyWhenAssembledUnavailableWithoutFallbackToNewApi() { + // S7d fail-closed 反向:Dify 槽位装配为占位 Unavailable(非 null)时,选中 dify 仍必须失败关闭, + // 返回 Dify 专属不可用错误码,且绝不回退 New-API —— newApi lambda 若被命中会返回 wrong 结果使断言失败。 + RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient( + command -> new MuseAiRuntimeClient.RuntimeResponse( + new MuseAiRuntimeClient.RuntimeResult("corr-real-1", "success", Map.of("summary", "wrong"), + Map.of(), null, "new-api-should-not-run", "stop", Instant.now()), null), + new UnavailableMuseAiRuntimeClient()); + + MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent"))); + + assertNull(response.result()); + assertEquals("runtime_unavailable", response.failure().failureType()); + assertEquals("AI_DIFY_UNAVAILABLE", response.failure().errorCode()); + assertFalse(response.failure().retryable()); + } + + // S7d Dify 形态:完整正文(>80 字、句末标点)经护栏通过后,fullOutput 承载完整正文(仅内存供 S7b), + // 落库摘要 content 仍只 80 字截断,证明「审的是完整正文、主权摘要不含全文」。 + private static final String COMPLETE_DIFY_OUTPUT_SAMPLE = + "夜色像一层薄薄的墨慢慢浸透了整座城市,街灯在雨里晕开成一团团柔和的光," + + "她合上笔记本望向窗外那条空荡荡的长街,心里第一次生出一种前所未有的笃定," + + "仿佛所有漂泊多年的疑问都在这一刻悄然有了答案。"; + + @Test + void should_routeDifyCompleteOutputWithFullOutputWhileSummaryTruncated() throws Exception { + startServer(exchange -> writeJson(exchange, 200, + "{\"message_id\":\"dify-msg-3\",\"answer\":\"" + COMPLETE_DIFY_OUTPUT_SAMPLE + "\"}", null)); + RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient( + new RealNewApiMuseAiRuntimeClient(newApiProperties(true)), + new RealDifyMuseAiRuntimeClient(difyProperties(true))); + + MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent"))); + + assertNull(response.failure()); + assertEquals("success", response.result().status()); + assertEquals("dify", response.result().outputSummary().get("provider")); + assertEquals("untraceable_provider_context", response.result().outputSummary().get("traceability")); + // fullOutput = 完整正文(仅内存,供 S7b 瞬态通路);落库摘要 content 只 80 字、不等于完整正文。 + assertEquals(COMPLETE_DIFY_OUTPUT_SAMPLE, response.result().fullOutput()); + String summary = String.valueOf(response.result().outputSummary().get("content")); + assertTrue(summary.length() <= 80, "落库摘要不得超过 80 字快照"); + assertTrue(response.result().fullOutput().length() > summary.length(), + "完整正文长度须大于落库摘要,证明摘要为截断快照而非全文"); + assertFalse(summary.equals(response.result().fullOutput()), "落库摘要不得等于完整正文"); + } + private MuseAiRuntimeClient.RuntimeCommand command(Map inputSummary) { return new MuseAiRuntimeClient.RuntimeCommand( "cmd-real-1", "3001", "4001", "corr-real-1", 1, "model-a", "prompt-v9", diff --git a/muse-cloud/scripts/dev/p1r-external-acceptance.env b/muse-cloud/scripts/dev/p1r-external-acceptance.env index 65e864b2..3455fa5f 100644 --- a/muse-cloud/scripts/dev/p1r-external-acceptance.env +++ b/muse-cloud/scripts/dev/p1r-external-acceptance.env @@ -42,7 +42,8 @@ MUSE_AI_DIFY_PARSER_API_KEY=app-It4V7WukHohP8rhWV0gOn2H5 MUSE_AI_DIFY_PARSER_CREDENTIAL_REF=dify-parser-s1 MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS=5 MUSE_AI_DIFY_FIRST_BYTE_TIMEOUT_SECONDS=15 -MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=90 +# S7d 生成主链切 Dify:短读超时对齐 180s 总预算(实际请求超时取 min(短读,总预算),90 会截短长篇生成)。 +MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=180 MUSE_AI_DIFY_STREAM_IDLE_TIMEOUT_SECONDS=30 MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS=180 MUSE_KNOWLEDGE_DIFY_BASE_URL=http://100.64.0.8:18080/v1 diff --git a/muse-cloud/scripts/dev/solo-compose.env.example b/muse-cloud/scripts/dev/solo-compose.env.example index c8776c79..9b0a7d14 100644 --- a/muse-cloud/scripts/dev/solo-compose.env.example +++ b/muse-cloud/scripts/dev/solo-compose.env.example @@ -10,6 +10,13 @@ MUSE_ACCOUNT_EVENTS_PUBLISH_WORKER_ENABLED=false MUSE_ACCOUNT_EVENTS_ATTRIBUTION_WORKER_ENABLED=false MUSE_ACCOUNT_EVENTS_QUOTA_REQUEST_WORKER_ENABLED=false -# AI 运行时后续由 S7/S8 切到 Dify;S4 保持 New-API/Dify adapter 未配置即 fail-closed。 +# AI 运行时:S7d 生成主链切 Dify,启用 Dify adapter;New-API adapter 代码保留但配置默认关闭(S8 验收后才摘)。 MUSE_AI_NEW_API_ENABLED=false -MUSE_AI_DIFY_ENABLED=false +MUSE_AI_DIFY_ENABLED=true +# Dify 生成 app 的真实凭据不在样例提交(见文件开头);base-url / writing app-id / api-key 从 +# scripts/dev/p1r-external-acceptance.env 的 DIFY_S1 块(Muse 专属 workspace)注入,勿在此新造。 +# credentialRef 仅为引用名(非密钥),须与注入的 Dify 凭据 ref 一致。 +MUSE_AI_DIFY_WRITING_CREDENTIAL_REF=dify-writing-s1 +# 生成正文时延上限对齐 180s 总预算(< SSE 死线 240s);短读超时须 ≥ 总预算,否则被 min() 截到 90s。 +MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=180 +MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS=180