From f4590001d8d55ba90db53497342e1ee6d558d116 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 21 Oct 2026 15:42:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=88=9B=E6=84=8F=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E5=87=BA=E5=9B=BE=E9=93=BE=E8=B7=AF=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8E=BB=E9=87=8D=E8=AF=95=E3=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E6=A8=A1=E5=BC=8F=E5=8C=85=E8=A3=85=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E3=80=81=E8=BE=93=E5=87=BA=E5=BD=A2=E7=8A=B6=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=EF=BC=88issue=2019=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 CREATIVE_ASSISTANT_MAX_RETRY / executeCreativeAssistantWithRetry / isRetryableCreativeFailure,失败即失败;日志仍带 failedNode 与上游 error。 - 图片模式(mode=image)把 user_content 包装成「【任务类型】生成图片…」意图声明, 避免上游「问题分类器」把画面描述判成文案/脚本分支;不带 mode 的入口保持原话直送。 - outputs 形状兼容:image7 这类数组里的 markdown 图片按内容归一到 photo_files ({url,name,type}),findTextValue 支持 List 中的图片字符串;raw 保持上游原样。 - 测试:新增 AiCreativeNoRetryIntegrationTest(2)、AiCreativeImagePromptTest(4)、 AiCreativeImageOutputTest(5),删除两个重试用例;全量 148 跑 0 failures。 --- .../gxwebsoft/ai/controller/AiController.java | 453 +++++++++++++----- .../controller/AiCreativeImageOutputTest.java | 130 +++++ .../controller/AiCreativeImagePromptTest.java | 74 +++ ... => AiCreativeNoRetryIntegrationTest.java} | 60 +-- .../ai/controller/AiCreativeRetryTest.java | 124 ----- 5 files changed, 570 insertions(+), 271 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeImageOutputTest.java create mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeImagePromptTest.java rename src/test/java/com/gxwebsoft/ai/controller/{AiCreativeRetryIntegrationTest.java => AiCreativeNoRetryIntegrationTest.java} (74%) delete mode 100644 src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryTest.java diff --git a/src/main/java/com/gxwebsoft/ai/controller/AiController.java b/src/main/java/com/gxwebsoft/ai/controller/AiController.java index 478e63a..ef04980 100644 --- a/src/main/java/com/gxwebsoft/ai/controller/AiController.java +++ b/src/main/java/com/gxwebsoft/ai/controller/AiController.java @@ -42,8 +42,10 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -76,17 +78,20 @@ public class AiController extends BaseController { private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试"; /** - * 创意助手工作流因上游瞬时故障失败后的自动重试次数。 + * 图片模式的提示词包装。 * - *

一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足, - * 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。 - * 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒), - * 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。 + *

端上把「用户选了图片模式」这个事实以 {@code mode=image} 传上来,但上游开始节点 + * 只有 {@code user_content} 一个文本变量;用户的原话常常只是画面描述(如 + * 「毕业季,图书馆门口,暖色」),在「问题分类器」里很容易被判成文案或脚本分支, + * 于是同一个需求有时出图、有时出一段文字。 * - *

只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试; - * 配置类失败重试无用,重试反而让用户白等一倍时间。 + *

这里在用户原话前加一段**固定的意图声明**,把「这是生图任务」摆在分类器 + * 最先看到的位置;用户原话原样保留,不替用户改写画面描述。 */ - private static final int CREATIVE_ASSISTANT_MAX_RETRY = 1; + private static final String CREATIVE_IMAGE_PROMPT_TEMPLATE = + "【任务类型】生成图片\n" + + "【用户需求】%s\n" + + "【输出要求】直接生成图片,不要输出文案、脚本或视频。"; /** * 写进日志的上游错误原文长度上限。 @@ -110,6 +115,31 @@ public class AiController extends BaseController { private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s"); + /** + * markdown 图片语法:{@code ![alt](https://...)}。 + * + *

出图工作流的产出就是这种字符串(实测 {@code image7} 里两个元素都是 + * {@code ![](https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/...jpeg?X-Tos-...)})。 + */ + private static final Pattern MARKDOWN_IMAGE_PATTERN = + Pattern.compile("!\\[[^\\]]*\\]\\((https?://[^)\\s]+)\\)"); + + /** + * 裸图片 URL(按扩展名判定)。签名参数里的长串会误伤 + * {@link #LONG_TOKEN_PATTERN},图片地址必须单独认。 + */ + private static final Pattern IMAGE_URL_PATTERN = Pattern.compile( + "https?://[^\\s)]+\\.(?:png|jpe?g|gif|webp|bmp|svg)(?:\\?[^\\s)]*)?", + Pattern.CASE_INSENSITIVE); + + /** + * 一组图片最多取几张。 + * + *

上游换过的出图节点一次给 2 张;给数组的条数设上限,避免某个把整批素材 + * 都塞进 outputs 的工作流把聊天气泡撑成图片墙。 + */ + private static final int CREATIVE_IMAGE_LIST_MAX_ITEMS = 12; + /** * loading 气泡文案的长度上限。 * @@ -474,7 +504,7 @@ public class AiController extends BaseController { if (isWorkflowFailed(workflowStatus(data))) { // 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR, // warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」 - // (同 executeCreativeAssistantWithRetry 上方的注释)。 + // (同 executeCreativeAssistant 上方的注释)。 logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}", taskId, workflowRunId, failure.describe()); workflowFailed = true; @@ -1047,69 +1077,43 @@ public class AiController extends BaseController { } } + /** + * 创意助手执行:**只跑一次**,不自动重试。 + * + *

历史上这里对上游瞬时故障({@code 50500 no more instances to retry} 等) + * 重跑过一次,用来把出图整体失败率从约三成压到约一成。但出图工作流一次要等 + * 45~105 秒,重试会把用户的一次失败拖成两次等待,且失败后没有任何界面能告诉 + * 用户"还在重试"。现在失败即失败:直接给可读文案,由用户自己决定要不要再来一次。 + * + *

失败以 ERROR 记录失败节点与上游错误原文:生产日志级别是 + * {@code com.gxwebsoft: ERROR},用 {@code logger.warn} 会被整个吞掉, + * 就成了"端上说生成失败、后端日志里却什么都没有"。 + * + * @param requestBody 端上请求体({@code user_content} / {@code user_files} / {@code mode}) + * @param loginUserId 用户 id + * @return 成功结果为工作流产出;失败时为失败态结果(HTTP 仍是 200,靠 status 表达) + */ private ApiResult executeCreativeAssistant(Map requestBody, Integer loginUserId) { String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content")); if (StrUtil.isBlank(userContent)) { return fail("user_content不能为空"); } - return executeCreativeAssistantWithRetry(buildCreativeAssistantInputs(requestBody), loginUserId); - } - - /** - * 创意助手执行外壳:失败可重试时原样重跑一次工作流。 - * - *

为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会 - * 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝), - * 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。 - * - *

每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是 - * {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现 - * "端上说生成失败、后端日志里却什么都没有"的情况。 - * - * @param inputs 已构造好的工作流入参 - * @param loginUserId 用户 id - * @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达) - */ - private ApiResult executeCreativeAssistantWithRetry(Map inputs, Integer loginUserId) { - int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1; - Map failedResult = null; - for (int attempt = 1; attempt <= maxAttempts; attempt++) { - CreativeRun run; - try { - run = runCreativeAssistantOnce(inputs, loginUserId); - } catch (CreativeApiFailureException e) { - logger.error("creative assistant request rejected, attempt={}/{}, message={}", - attempt, maxAttempts, e.getMessage()); - return e.result; - } catch (Exception e) { - logger.error("creative assistant execute failed, attempt={}/{}", attempt, maxAttempts, e); - if (attempt < maxAttempts) { - continue; - } - return fail("出错了,请晚点再试~"); - } - - if (!run.failed) { - if (attempt > 1) { - logger.warn("creative assistant recovered after retry, attempt={}/{}, workflowRunId={}, taskId={}", - attempt, maxAttempts, run.workflowRunId, run.taskId); - } - return success(run.result); - } - - logger.error("creative assistant workflow failed, attempt={}/{}, workflowRunId={}, taskId={}, " - + "failedNode={}, retryable={}, upstreamError={}", - attempt, maxAttempts, run.workflowRunId, run.taskId, - run.failedNode, run.retryable, run.failureReason); - failedResult = run.result; - - if (!run.retryable || attempt >= maxAttempts) { - break; - } - logger.warn("creative assistant retrying after upstream failure, nextAttempt={}/{}, failedNode={}", - attempt + 1, maxAttempts, run.failedNode); + CreativeRun run; + try { + run = runCreativeAssistantOnce(buildCreativeAssistantInputs(requestBody), loginUserId); + } catch (CreativeApiFailureException e) { + logger.error("creative assistant request rejected, message={}", e.getMessage()); + return e.result; + } catch (Exception e) { + logger.error("creative assistant execute failed", e); + return fail("出错了,请晚点再试~"); } - return success(failedResult); + if (run.failed) { + logger.error("creative assistant workflow failed, workflowRunId={}, taskId={}, " + + "failedNode={}, upstreamError={}", + run.workflowRunId, run.taskId, run.failedNode, run.failureReason); + } + return success(run.result); } /** @@ -1132,7 +1136,7 @@ public class AiController extends BaseController { /** * 一次创意助手工作流的执行结论。 * - *

{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试, + *

{@code result} 是要下发端上的载荷;其余字段只用于记日志, * 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露 * {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。 */ @@ -1143,32 +1147,29 @@ public class AiController extends BaseController { private final String failedNode; private final String failureReason; private final boolean failed; - private final boolean retryable; private CreativeRun(Map result, String workflowRunId, String taskId, - String failedNode, String failureReason, boolean failed, boolean retryable) { + String failedNode, String failureReason, boolean failed) { this.result = result; this.workflowRunId = workflowRunId; this.taskId = taskId; this.failedNode = failedNode; this.failureReason = briefUpstreamError(failureReason); this.failed = failed; - this.retryable = retryable; } private static CreativeRun succeeded(Map result, String workflowRunId, String taskId) { - return new CreativeRun(result, workflowRunId, taskId, null, null, false, false); + return new CreativeRun(result, workflowRunId, taskId, null, null, false); } private static CreativeRun failed(Map result, String workflowRunId, String taskId, String failedNode, String failureReason) { - return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true, - isRetryableCreativeFailure(failureReason)); + return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true); } } /** - * 跑一次创意助手工作流(单次尝试,不含重试)。 + * 跑一次创意助手工作流(单次尝试,失败即返回)。 */ private CreativeRun runCreativeAssistantOnce(Map inputs, Integer loginUserId) throws IOException { @@ -1345,12 +1346,14 @@ public class AiController extends BaseController { } /** - * 上游实例不足(重试有实际收益的那一类)。 + * 上游实例不足。 * *

2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于 * 「即梦AI-图像生成」节点,原文为 * {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}}, * 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。 + * + *

这只影响给用户的文案({@link #CREATIVE_BUSY_MESSAGE}),不再触发自动重试。 */ private static boolean isUpstreamCapacityFailure(String reason) { if (StrUtil.isBlank(reason)) { @@ -1362,28 +1365,6 @@ public class AiController extends BaseController { || lower.contains("code: 50500"); } - /** - * 这次失败值不值得原样重跑。 - * - *

只认能明确归因到上游瞬时的故障。凭证失效({@code 50400 Access Denied})、 - * 模型被停用({@code Model disabled})、内容审核拒绝重试无用, - * 重试只会把用户的一次失败拖成两次等待,必须排除。 - */ - private static boolean isRetryableCreativeFailure(String reason) { - if (isUpstreamCapacityFailure(reason)) { - return true; - } - if (StrUtil.isBlank(reason)) { - return false; - } - String lower = reason.toLowerCase(Locale.ROOT); - return lower.contains("502 bad gateway") - || lower.contains("503 service unavailable") - || lower.contains("504 gateway") - || lower.contains("connection reset") - || lower.contains("timed out"); - } - /** * 把上游原文裁到可写进单行日志的长度。 */ @@ -1757,7 +1738,12 @@ public class AiController extends BaseController { } String userContent = stringValue(requestBody.get("user_content")); if (StrUtil.isNotBlank(userContent)) { - inputs.put("user_content", userContent); + // mode=image 由端上在选择「图片」模式时带上;单页表单入口不带 mode, + // 保持原话直送(那类页面由用户自己写清指令)。 + boolean imageMode = "image".equalsIgnoreCase(stringValue(requestBody.get("mode"))); + inputs.put("user_content", imageMode + ? String.format(CREATIVE_IMAGE_PROMPT_TEMPLATE, userContent) + : userContent); } Object rawUserFiles = requestBody.get("user_files"); if (hasFileValue(rawUserFiles)) { @@ -2094,10 +2080,22 @@ public class AiController extends BaseController { return result; } + Object data = responseData.get("data"); + Object outputs = data instanceof Map ? ((Map) data).get("outputs") : null; + if (outputs == null) { + outputs = responseData.get("outputs"); + } + Map normalizedOutputs = outputs instanceof Map + ? normalizeCreativeOutputs((Map) outputs) + : null; + String answer = extractWorkflowOutputs(responseData); if (answer == null) { answer = extractAnswer(responseData); } + if (answer == null && normalizedOutputs != null) { + answer = imageMarkdownFromOutputs(normalizedOutputs); + } if (answer != null) { result.put("answer", answer); } @@ -2112,26 +2110,210 @@ public class AiController extends BaseController { result.put("taskId", taskId); } - Object data = responseData.get("data"); if (data instanceof Map) { // status 已在方法开头经 workflowStatus 统一取出, // 这里只负责下发(含顶层 status 的兜底情况)。 if (status != null) { result.put("status", status); } - Object outputs = ((Map) data).get("outputs"); - if (outputs != null) { - result.put("outputs", outputs); - } } - if (!result.containsKey("outputs") && responseData.get("outputs") != null) { - result.put("outputs", responseData.get("outputs")); + if (normalizedOutputs != null) { + result.put("outputs", normalizedOutputs); } result.put("raw", responseData); return result; } + /** + * 兼容上游 outputs 的形状漂移(不改上游报文,只做向上归一)。 + * + *

2026-10-21 在 dev 实例实测:出图分支的终态是 + * {@code {"image7": ["![](https://.../a.jpeg?...)", "![](https://.../b.jpeg?...)"]}}。 + * 变量名({@code image7})和值的类型(数组)都不在端上白名单里, + * 于是四个页面全部显示「接口已返回,但未解析到可展示的内容」。 + * 10-13 同一分支的产物还叫 {@code photo_message} / {@code photo_file}, + * 节点叫「即梦AI-图像生成」;上游换过一轮节点,两端白名单就同时失效了。 + * + *

与其把 {@code image7} 写进白名单(下次改名叫 {@code image8} 又要再来一遍), + * 不如按**内容**识别:任何 outputs 变量里出现的图片(markdown 图片语法或图片 URL, + * 字符串或数组)都收进规范的 {@code photo_files},元素统一成 {@code {url,name,type}}。 + * 端上四个页面认的正是 {@code photo_files},因此不必逐个页面加字段。 + * + * @param outputs 上游 outputs(不改动原对象,返回值是新 Map) + * @return 归一后的 outputs + */ + private Map normalizeCreativeOutputs(Map outputs) { + Map normalized = new LinkedHashMap<>(outputs); + List photoFiles = new ArrayList<>(); + Set knownUrls = new LinkedHashSet<>(); + collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls); + collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls); + + // 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。 + for (Map.Entry entry : outputs.entrySet()) { + String key = entry.getKey(); + if ("photo_files".equals(key) || "photo_file".equals(key)) { + continue; + } + for (String url : extractImageUrls(entry.getValue())) { + if (knownUrls.add(url)) { + photoFiles.add(imageFileEntry(url)); + } + } + } + + if (!photoFiles.isEmpty()) { + normalized.put("photo_files", photoFiles); + } + return normalized; + } + + /** + * 把 {@code photo_files} / {@code photo_file} 收进统一列表。 + * + *

上游两种写法都出现过:元素是文件对象({@code {url,name,...}}), + * 或直接是 markdown 图片 / 图片 URL 字符串。字符串统一转成文件对象, + * 端上的 {@code getCreativeFileUrl(item)} 才取得到地址。 + */ + private void collectPhotoFiles(Object raw, List target, Set knownUrls) { + if (raw == null) { + return; + } + List items = raw instanceof List ? (List) raw : Collections.singletonList(raw); + for (Object item : items) { + if (item instanceof Map) { + Map file = (Map) item; + String url = creativeFileUrl(file); + if (StrUtil.isBlank(url)) { + continue; + } + target.add(file); + knownUrls.add(url); + continue; + } + for (String url : extractImageUrls(item)) { + if (knownUrls.add(url)) { + target.add(imageFileEntry(url)); + } + } + } + } + + /** + * 从一个任意形状的 outputs 值里抽出图片地址(保持出现顺序、允许重复)。 + */ + private List extractImageUrls(Object value) { + List urls = new ArrayList<>(); + collectImageUrls(value, urls); + return urls; + } + + private void collectImageUrls(Object value, List urls) { + if (value == null || urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + return; + } + if (value instanceof String) { + String text = ((String) value).trim(); + if (text.isEmpty()) { + return; + } + Matcher markdown = MARKDOWN_IMAGE_PATTERN.matcher(text); + boolean found = false; + while (markdown.find() && urls.size() < CREATIVE_IMAGE_LIST_MAX_ITEMS) { + urls.add(markdown.group(1)); + found = true; + } + if (!found && IMAGE_URL_PATTERN.matcher(text).matches()) { + urls.add(text); + } + return; + } + if (value instanceof List) { + for (Object item : (List) value) { + collectImageUrls(item, urls); + if (urls.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + return; + } + } + return; + } + if (value instanceof Map) { + // 嵌套结构(如 files/json)里只认能按扩展名判定为图片的字符串,避免把 + // 文档/视频地址误当图片;键名不做猜测。 + for (Object item : ((Map) value).values()) { + if (item instanceof String) { + String text = ((String) item).trim(); + if (IMAGE_URL_PATTERN.matcher(text).matches()) { + urls.add(text); + } + } else if (item instanceof List) { + collectImageUrls(item, urls); + } + } + } + } + + /** + * 取文件对象的可用地址,与端上的 {@code getCreativeFileUrl} 同一口径。 + */ + private String creativeFileUrl(Map file) { + if (file == null) { + return ""; + } + String[] keys = new String[]{"url", "remote_url", "downloadUrl", "download_url", "path"}; + for (String key : keys) { + String value = stringValue(file.get(key)); + if (StrUtil.isNotBlank(value)) { + return value.trim(); + } + } + return ""; + } + + private Map imageFileEntry(String url) { + Map file = new LinkedHashMap<>(); + file.put("url", url); + file.put("name", fileNameFromUrl(url)); + file.put("type", "image"); + return file; + } + + private String fileNameFromUrl(String url) { + String path = url == null ? "" : url.trim(); + int query = path.indexOf('?'); + if (query >= 0) { + path = path.substring(0, query); + } + int slash = path.lastIndexOf('/'); + String name = slash >= 0 ? path.substring(slash + 1) : path; + return StrUtil.isBlank(name) ? "生成图片" : name; + } + + /** + * 归一后的 {@code photo_files} 转成 markdown 图片段落,作为 answer 的最后兜底。 + * + *

{@code formatCreativeResult} 虽然会自己渲染图片,但历史会话/其他调用方 + * 可能只看 {@code answer};这里保证"有图就一定有一段能渲染的正文"。 + */ + private String imageMarkdownFromOutputs(Map outputs) { + Object raw = outputs.get("photo_files"); + if (!(raw instanceof List)) { + return null; + } + List lines = new ArrayList<>(); + for (Object item : (List) raw) { + String url = item instanceof Map ? creativeFileUrl((Map) item) : ""; + if (StrUtil.isNotBlank(url)) { + lines.add("![](" + url + ")"); + } + if (lines.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + break; + } + } + return lines.isEmpty() ? null : String.join("\n\n", lines); + } + /** * 取 Dify 工作流事件里的执行状态。 * @@ -2530,8 +2712,9 @@ public class AiController extends BaseController { return preferred; } for (Object value : source.values()) { - if (isDisplayableText(value)) { - return (String) value; + String text = findDisplayableValueText(value); + if (text != null) { + return text; } if (value instanceof Map) { String nestedValue = findTextValue((Map) value); @@ -2540,11 +2723,65 @@ public class AiController extends BaseController { } } } - // 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。 + // 关键:这里的「任意 String 值」兜底**必须**过可展示判定。 // 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。 return null; } + /** + * 单个 outputs 值的取文。 + * + *

除了 String(过可展示判定),还要认 **List**:上游出图节点把一组图片 + * 直接放进一个数组(实测 {@code image7: ["![](...)", "![](...)"]}), + * 原来的实现只认 String / Map,List 被静默跳过,出图结果就整段丢了。 + * + *

List 里**只取字符串元素**,不递归进 Map:{@code video_result} / + * {@code photo_files} 这类列表装的是文件对象,递归进去会把文件 URL 当正文, + * 挤掉真正的脚本/文案字段。 + */ + private String findDisplayableValueText(Object value) { + if (isDisplayableText(value)) { + return (String) value; + } + if (value instanceof List) { + List texts = new ArrayList<>(); + for (Object item : (List) value) { + if (texts.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) { + break; + } + if (isDisplayableImageText(item)) { + texts.add(((String) item).trim()); + } + } + if (!texts.isEmpty()) { + return String.join("\n\n", texts); + } + } + return null; + } + + /** + * 数组元素里的图片字符串也属于可展示内容。 + * + *

markdown 图片({@code ![](https://...)})没有空格,且长签名 URL 会命中 + * {@link #LONG_TOKEN_PATTERN} 的「无空格长 token」拦截;但它是用户要看的产物本身, + * 必须放行,否则「列表支持」等于白做。 + */ + private boolean isDisplayableImageText(Object value) { + if (!(value instanceof String)) { + return false; + } + String text = ((String) value).trim(); + if (text.isEmpty()) { + return false; + } + if (isDisplayableText(text)) { + return true; + } + return MARKDOWN_IMAGE_PATTERN.matcher(text).find() + || IMAGE_URL_PATTERN.matcher(text).matches(); + } + private String stringValue(Object value) { return value == null ? null : value.toString(); } diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImageOutputTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImageOutputTest.java new file mode 100644 index 0000000..a406227 --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImageOutputTest.java @@ -0,0 +1,130 @@ +package com.gxwebsoft.ai.controller; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 出图 outputs 的形状兼容(见 .scratch/ai-assistant/issues/19)。 + * + *

2026-10-21 在 dev 实例实测到的真实终态是 + * {@code {"image7": ["![](https://.../a.jpeg?...)", "![](https://.../b.jpeg?...)"]}}: + * 变量名和类型都不在端上白名单里,四个页面一律显示「接口已返回,但未解析到可展示的内容」。 + * 修复思路是**按内容识别图片**并归一到 {@code photo_files},而不是把 {@code image7} + * 写进白名单——上游把节点换一次名,白名单就会再失效一次。 + */ +class AiCreativeImageOutputTest { + + private static final String IMAGE_A = + "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-5-0-pro/2026/a.jpeg" + + "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=0123456789abcdef0123456789abcdef"; + private static final String IMAGE_B = + "https://ark-acg-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-5-0-pro/2026/b.jpeg" + + "?X-Tos-Algorithm=TOS4-HMAC-SHA256&X-Tos-Signature=fedcba9876543210fedcba9876543210"; + + private static Map buildResult(JSONObject outputs) throws Exception { + JSONObject data = new JSONObject(true); + data.put("status", "succeeded"); + data.put("outputs", outputs); + + JSONObject responseData = new JSONObject(true); + responseData.put("event", "workflow_finished"); + responseData.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff"); + responseData.put("task_id", "0b1fdd88-cc6f-45b8-989d-be4f85bcff74"); + responseData.put("data", data); + + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantResult", Map.class, String.class); + method.setAccessible(true); + //noinspection unchecked + return (Map) method.invoke(new AiController(), responseData, ""); + } + + private static JSONObject imageArrayOutputs() { + JSONArray images = new JSONArray(); + images.add("![](" + IMAGE_A + ")"); + images.add("![](" + IMAGE_B + ")"); + + JSONObject outputs = new JSONObject(true); + outputs.put("image7", images); + return outputs; + } + + @Test + void image7数组_归一到photo_files并出可渲染正文() throws Exception { + Map result = buildResult(imageArrayOutputs()); + + assertEquals("succeeded", result.get("status")); + Map outputs = (Map) result.get("outputs"); + assertNotNull(outputs, "成功态必须下发 outputs"); + List photoFiles = (List) outputs.get("photo_files"); + assertNotNull(photoFiles, "图片必须归一到 photo_files,端上四个页面才认得:" + outputs); + assertEquals(2, photoFiles.size()); + + Map first = (Map) photoFiles.get(0); + assertEquals(IMAGE_A, first.get("url")); + assertEquals("a.jpeg", first.get("name")); + assertEquals("image", first.get("type")); + + String answer = String.valueOf(result.get("answer")); + assertTrue(answer.contains("![](" + IMAGE_A + ")"), "answer 也要能直接渲染:" + answer); + assertTrue(answer.contains("![](" + IMAGE_B + ")"), "两张图都要在:" + answer); + } + + @Test + void 归一只影响下发载荷_不动上游raw() throws Exception { + Map result = buildResult(imageArrayOutputs()); + + Map raw = (Map) result.get("raw"); + Map rawOutputs = (Map) ((Map) raw.get("data")).get("outputs"); + assertTrue(rawOutputs.containsKey("image7"), "raw 必须保持上游原样"); + assertFalse(rawOutputs.containsKey("photo_files"), + "补出来的规范字段不能写回 raw,否则日志里的原始报文会被改写:" + rawOutputs); + } + + @Test + void 老字段photo_file字符串_同样归一() throws Exception { + JSONObject outputs = new JSONObject(true); + outputs.put("photo_file", "![](" + IMAGE_A + ")"); + + List photoFiles = (List) ((Map) buildResult(outputs).get("outputs")).get("photo_files"); + assertNotNull(photoFiles, "10-13 的老字段也要能收进规范字段:" + outputs); + assertEquals(1, photoFiles.size()); + assertEquals(IMAGE_A, ((Map) photoFiles.get(0)).get("url")); + } + + @Test + void photo_files里的裸URL字符串_转成文件对象() throws Exception { + JSONArray files = new JSONArray(); + files.add(IMAGE_A); + files.add(IMAGE_B); + + JSONObject outputs = new JSONObject(true); + outputs.put("photo_files", files); + + List photoFiles = (List) ((Map) buildResult(outputs).get("outputs")).get("photo_files"); + assertEquals(2, photoFiles.size(), "字符串元素要转成 {url,name,type}:" + photoFiles); + assertEquals(IMAGE_A, ((Map) photoFiles.get(0)).get("url")); + } + + @Test + void 图片与脚本并存_不把文件地址当脚本() throws Exception { + JSONObject outputs = new JSONObject(true); + outputs.put("Script_result", "### 脚本\n第一幕:开场舞"); + outputs.put("image7", new JSONArray()); + + Map result = buildResult(outputs); + assertEquals("### 脚本\n第一幕:开场舞", result.get("answer")); + assertFalse(((Map) result.get("outputs")).containsKey("photo_files"), + "没有图片时不应补出空的 photo_files"); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImagePromptTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImagePromptTest.java new file mode 100644 index 0000000..519ecba --- /dev/null +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeImagePromptTest.java @@ -0,0 +1,74 @@ +package com.gxwebsoft.ai.controller; + +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; + +/** + * 图片模式的提示词包装(见 .scratch/ai-assistant/issues/19)。 + * + *

背景:上游创意助手工作流先经「问题分类器」分流。用户只写画面描述 + * (「毕业季,图书馆门口,暖色」)时,分类器可能判成文案或脚本分支, + * 于是同一个需求有时出图、有时出一段文字。端上选了图片模式就已经知道意图, + * 后端把这份意图明确写进 {@code user_content},让分类器不必猜。 + */ +class AiCreativeImagePromptTest { + + private static Map buildInputs(Map requestBody) throws Exception { + Method method = AiController.class.getDeclaredMethod( + "buildCreativeAssistantInputs", Map.class); + method.setAccessible(true); + //noinspection unchecked + return (Map) method.invoke(new AiController(), requestBody); + } + + private static Map imageRequest(String userContent) { + Map body = new HashMap<>(); + body.put("user_content", userContent); + body.put("mode", "image"); + return body; + } + + @Test + void 图片模式_用户原话前带出图意图() throws Exception { + String query = "毕业季,图书馆门口,暖色"; + String content = String.valueOf(buildInputs(imageRequest(query)).get("user_content")); + + assertTrue(content.startsWith("【任务类型】生成图片"), + "出图意图必须放在最前面,分类器才会先看到它:" + content); + assertTrue(content.contains(query), "用户原话必须原样保留:" + content); + assertTrue(content.contains("不要输出文案、脚本或视频"), + "要明确排除另外几个分支,否则分类器仍可能走文案分支:" + content); + } + + @Test + void 图片模式_只发user_content不带多余变量() throws Exception { + Map inputs = buildInputs(imageRequest("校园夜景海报")); + + assertEquals(1, inputs.size(), "参考素材已下线,不该再往上游送 user_files:" + inputs); + assertFalse(inputs.containsKey("user_files")); + } + + @Test + void 文字模式_原样透传不包装() throws Exception { + Map body = new HashMap<>(); + body.put("user_content", "写一篇迎新晚会推文"); + body.put("mode", "writing"); + + assertEquals("写一篇迎新晚会推文", buildInputs(body).get("user_content")); + } + + @Test + void 未传模式_原样透传不包装() throws Exception { + Map body = new HashMap<>(); + body.put("user_content", "海报排版"); + + assertEquals("海报排版", buildInputs(body).get("user_content")); + } +} diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryIntegrationTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeNoRetryIntegrationTest.java similarity index 74% rename from src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryIntegrationTest.java rename to src/test/java/com/gxwebsoft/ai/controller/AiCreativeNoRetryIntegrationTest.java index 4ed9b44..13cc1cd 100644 --- a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryIntegrationTest.java +++ b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeNoRetryIntegrationTest.java @@ -16,18 +16,19 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; /** - * 创意助手自动重试的端到端验证(见 .scratch/ai-assistant/issues/11)。 + * 创意助手**不自动重试**的端到端验证(见 .scratch/ai-assistant/issues/19)。 * - *

真起一个本地 HTTP server 扮演 Dify:第一次返回上游容量不足的失败流, - * 第二次返回成功流,然后确认 controller **真的重跑了工作流**并把第二次的产出回给端上。 + *

真起一个本地 HTTP server 扮演 Dify:返回的流里带上游容量不足的失败, + * 然后确认 controller 只请求了一次、且把失败文案直接回给端上。 * - *

单元测试只能证明"判定函数返回 true";这条用例证明的是"重试确实发生了、且结果被采用", - * 也就是用户看到的"有时候能出图、有时候失败"是否真的被修掉。 + *

历史背景:这里曾经是「重试一次」的用例(issue 11)。重试确实能把失败率 + * 从约三成压到约一成,但出图一次要等 45~105 秒,用户看不到"正在重试", + * 体验上就是把一次失败拖成两次等待。现在改为失败即失败。 */ -class AiCreativeRetryIntegrationTest { +class AiCreativeNoRetryIntegrationTest { /** * 上游容量不足的原文(2026-10-13 内网实测,request_id 为真实值)。 @@ -40,37 +41,37 @@ class AiCreativeRetryIntegrationTest { + "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'"; /** - * 凭证失效:重试无用,必须只跑一次。 + * 凭证失效:同样只跑一次,文案仍是统一的失败提示。 */ private static final String ACCESS_DENIED_ERROR = "req_id: dc688258de PluginInvokeError: API request failed with status code 50400," + " message: Access Denied"; @Test - void 上游容量不足_自动重跑一次并把第二次产出回给端上() throws Exception { + void 上游容量不足_不重跑且直接回可读文案() throws Exception { AtomicInteger hits = new AtomicInteger(); try (MockDify dify = MockDify.start(exchange -> { - int attempt = hits.incrementAndGet(); - String stream = attempt == 1 ? capacityFailureStream() : succeededStream(); - MockDify.reply(exchange, stream); + hits.incrementAndGet(); + MockDify.reply(exchange, capacityFailureStream()); })) { ApiResult result = executeCreative(dify.baseUrl()); - assertEquals(2, hits.get(), "第一次撞上上游容量不足后应自动重跑一次"); - assertEquals(0, result.getCode(), "重试成功后对端仍是正常返回"); + assertEquals(1, hits.get(), "不再自动重试,工作流只应被请求一次"); + assertEquals(0, result.getCode(), "失败态仍以 HTTP 200 + status 表达"); Map payload = (Map) result.getData(); - assertEquals("succeeded", payload.get("status"), "第二次的状态应透出"); - assertEquals("迎新晚会海报已生成", payload.get("answer"), "第二次的产出应回给端上"); + assertEquals("failed", payload.get("status")); + assertEquals("生成服务繁忙,请稍后重试", payload.get("answer")); + assertFalse(payload.containsKey("raw"), "失败态不得下发整包"); } } @Test - void 凭证失效_不重试且只跑一次() throws Exception { + void 凭证失效_只跑一次且文案不变() throws Exception { AtomicInteger hits = new AtomicInteger(); try (MockDify dify = MockDify.start(exchange -> { hits.incrementAndGet(); - MockDify.reply(exchange, failureStream(ACCESS_DENIED_ERROR)); + MockDify.reply(exchange, failureBody(ACCESS_DENIED_ERROR)); })) { ApiResult result = executeCreative(dify.baseUrl()); @@ -78,7 +79,7 @@ class AiCreativeRetryIntegrationTest { Map payload = (Map) result.getData(); assertEquals("failed", payload.get("status")); assertEquals("生成失败,请稍后重试", payload.get("answer")); - assertTrue(!payload.containsKey("raw"), "失败态仍不得下发整包"); + assertFalse(payload.containsKey("raw"), "失败态仍不得下发整包"); } } @@ -96,6 +97,7 @@ class AiCreativeRetryIntegrationTest { Map requestBody = new HashMap<>(); requestBody.put("user_content", "生成一张迎新晚会海报"); + requestBody.put("mode", "image"); Method method = AiController.class.getDeclaredMethod( "executeCreativeAssistant", Map.class, Integer.class); @@ -111,10 +113,6 @@ class AiCreativeRetryIntegrationTest { + failureBody(CAPACITY_ERROR); } - private static String failureStream(String error) { - return failureBody(error); - } - private static String failureBody(String error) { JSONObject data = new JSONObject(true); data.put("status", "failed"); @@ -129,22 +127,6 @@ class AiCreativeRetryIntegrationTest { return sse(finished); } - private static String succeededStream() { - JSONObject outputs = new JSONObject(true); - outputs.put("photo_message", "迎新晚会海报已生成"); - - JSONObject data = new JSONObject(true); - data.put("status", "succeeded"); - data.put("outputs", outputs); - - JSONObject finished = new JSONObject(true); - finished.put("event", "workflow_finished"); - finished.put("workflow_run_id", "run-2"); - finished.put("task_id", "task-2"); - finished.put("data", data); - return node("即梦AI-图像生成", "succeeded", null) + sse(finished); - } - private static String node(String title, String status, String error) { JSONObject data = new JSONObject(true); data.put("title", title); diff --git a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryTest.java b/src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryTest.java deleted file mode 100644 index 426de46..0000000 --- a/src/test/java/com/gxwebsoft/ai/controller/AiCreativeRetryTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.gxwebsoft.ai.controller; - -import com.alibaba.fastjson.JSONObject; -import com.gxwebsoft.common.core.utils.JSONUtil; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; -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; - -/** - * 上游瞬时故障的重试判定与失败文案(见 .scratch/ai-assistant/issues/11)。 - * - *

背景:内网实例上「即梦AI-图像生成」偶发 - * {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}}, - * 2026-10-13 同一 prompt 连跑 6 次有 2 次失败,整节点 3 秒即返回——是上游算力不足, - * 与内容、参数、凭证都无关,重试一次通常就成。 - * - *

而凭证失效、模型停用、审核拒绝重试无用,必须排除, - * 否则会把用户的一次失败拖成两次等待。 - */ -class AiCreativeRetryTest { - - /** - * 2026-10-13 内网实测的上游原文(保留关键结构,request_id 为真实值)。 - */ - private static final String CAPACITY_ERROR = - "An error occurred in the shaoruidong/volcengine_ai/volcengine_ai, please contact the" - + " author of shaoruidong/volcengine_ai/volcengine_ai for help, error type:" - + " ToolProviderCredentialValidationError, error details: b'{\"code\":50500," - + "\"data\":{\"algorithm_base_resp\":{\"status_code\":110001,\"status_message\":" - + "\"no more instances to retry: last error: \"},\"binary_data_base64\":[]}," - + "\"message\":\"Internal Error\"," - + "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'"; - - private static final String ACCESS_DENIED_ERROR = - "req_id: dc688258de PluginInvokeError: [models] Error: API request failed with status" - + " code 50400, message: Access Denied"; - - private static final String MODEL_DISABLED_ERROR = - "req_id: dc688258de PluginInvokeError: Model disabled."; - - private static JSONObject failedEvent(String error) { - JSONObject data = new JSONObject(true); - data.put("status", "failed"); - data.put("outputs", new JSONObject(true)); - data.put("error", error); - - JSONObject event = new JSONObject(true); - event.put("event", "workflow_finished"); - event.put("workflow_run_id", "b2e18332-68bd-49e6-90e2-bd9d16d428ff"); - event.put("task_id", "0b1fdd88-cc6f-45b8-989d-be4f85bcff74"); - event.put("data", data); - return event; - } - - private static Map buildFailedResult(String error) throws Exception { - // 直接构造 JSONObject 作为报文体,别绕 JSONUtil.toJSONString——它按值序列化, - // 对字符串不加引号,拼出来的报文非法,parseObject 会静默返回 null。 - JSONObject responseData = failedEvent(error); - Method method = AiController.class.getDeclaredMethod( - "buildCreativeAssistantResult", Map.class, String.class); - method.setAccessible(true); - //noinspection unchecked - return (Map) method.invoke(new AiController(), responseData, ""); - } - - private static boolean retryable(String reason) throws Exception { - Method method = AiController.class.getDeclaredMethod("isRetryableCreativeFailure", String.class); - method.setAccessible(true); - return (Boolean) method.invoke(null, reason); - } - - @Test - void 上游容量不足_值得重试() throws Exception { - assertTrue(retryable(CAPACITY_ERROR), "no more instances to retry 属于瞬时故障,应重试"); - } - - @Test - void 上游容量不足_文案换成服务繁忙() throws Exception { - Map result = buildFailedResult(CAPACITY_ERROR); - assertEquals("failed", result.get("status"), "状态仍须可被判为失败"); - assertEquals("生成服务繁忙,请稍后重试", result.get("answer")); - } - - @Test - void 上游容量不足_失败态仍不得下发上游原文() throws Exception { - String json = JSONUtil.toJSONString(buildFailedResult(CAPACITY_ERROR)); - assertFalse(json.contains("50500"), "不得下发上游错误码:" + json); - assertFalse(json.contains("no more instances"), "不得下发上游错误原文:" + json); - assertFalse(json.contains("request_id"), "不得下发 req_id:" + json); - assertFalse(json.contains("volcengine"), "不得下发上游插件名:" + json); - assertFalse(json.contains("raw"), "失败态不得带 raw:" + json); - } - - @Test - void 凭证失效_不重试且文案不变() throws Exception { - assertFalse(retryable(ACCESS_DENIED_ERROR), "凭证问题重试无用"); - assertEquals("生成失败,请稍后重试", buildFailedResult(ACCESS_DENIED_ERROR).get("answer")); - } - - @Test - void 模型停用_不重试且文案不变() throws Exception { - assertFalse(retryable(MODEL_DISABLED_ERROR), "模型被停用重试无用"); - assertEquals("生成失败,请稍后重试", buildFailedResult(MODEL_DISABLED_ERROR).get("answer")); - } - - @Test - void 没有错误信息_不重试() throws Exception { - String blank = ""; - assertFalse(retryable(null), "错误信息缺失时无从判断,保守不重试"); - assertFalse(retryable(blank), "错误信息为空时不重试"); - } - - @Test - void 网关瞬时故障_值得重试() throws Exception { - assertTrue(retryable("502 Bad Gateway")); - assertTrue(retryable("connection reset by peer")); - assertTrue(retryable("Read timed out")); - } -}