feat(ai): 创意助手出图链路收口——去重试、图片模式包装提示词、输出形状兼容(issue 19)
- 删除 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。
This commit is contained in:
@@ -42,8 +42,10 @@ import java.util.Collections;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
@@ -76,17 +78,20 @@ public class AiController extends BaseController {
|
|||||||
private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试";
|
private static final String CREATIVE_BUSY_MESSAGE = "生成服务繁忙,请稍后重试";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创意助手工作流因上游瞬时故障失败后的自动重试次数。
|
* 图片模式的提示词包装。
|
||||||
*
|
*
|
||||||
* <p>一次出图要连调两次「即梦AI-图像生成」,单次约 1/5 概率撞上上游容量不足,
|
* <p>端上把「用户选了图片模式」这个事实以 {@code mode=image} 传上来,但上游开始节点
|
||||||
* 不重试时整体失败率约三成(2026-10-13 实测:同一 prompt 连跑 6 次,2 次失败)。
|
* 只有 {@code user_content} 一个文本变量;用户的原话常常只是画面描述(如
|
||||||
* 重试一次可把失败率压到约一成,代价是多等一次工作流(实测 45~105 秒),
|
* 「毕业季,图书馆门口,暖色」),在「问题分类器」里很容易被判成文案或脚本分支,
|
||||||
* 距离 {@link #AI_REQUEST_TIMEOUT} 还有充足余量。
|
* 于是同一个需求有时出图、有时出一段文字。
|
||||||
*
|
*
|
||||||
* <p>只对 {@link #isRetryableCreativeFailure(String)} 认定的瞬时故障重试;
|
* <p>这里在用户原话前加一段**固定的意图声明**,把「这是生图任务」摆在分类器
|
||||||
* 配置类失败重试无用,重试反而让用户白等一倍时间。
|
* 最先看到的位置;用户原话原样保留,不替用户改写画面描述。
|
||||||
*/
|
*/
|
||||||
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");
|
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdown 图片语法:{@code }。
|
||||||
|
*
|
||||||
|
* <p>出图工作流的产出就是这种字符串(实测 {@code image7} 里两个元素都是
|
||||||
|
* {@code })。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一组图片最多取几张。
|
||||||
|
*
|
||||||
|
* <p>上游换过的出图节点一次给 2 张;给数组的条数设上限,避免某个把整批素材
|
||||||
|
* 都塞进 outputs 的工作流把聊天气泡撑成图片墙。
|
||||||
|
*/
|
||||||
|
private static final int CREATIVE_IMAGE_LIST_MAX_ITEMS = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* loading 气泡文案的长度上限。
|
* loading 气泡文案的长度上限。
|
||||||
*
|
*
|
||||||
@@ -474,7 +504,7 @@ public class AiController extends BaseController {
|
|||||||
if (isWorkflowFailed(workflowStatus(data))) {
|
if (isWorkflowFailed(workflowStatus(data))) {
|
||||||
// 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR,
|
// 用 error 而非 warn:生产日志级别是 com.gxwebsoft: ERROR,
|
||||||
// warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」
|
// warn 会被整个吞掉,就成了「端上说生成失败、后端日志里什么都没有」
|
||||||
// (同 executeCreativeAssistantWithRetry 上方的注释)。
|
// (同 executeCreativeAssistant 上方的注释)。
|
||||||
logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}",
|
logger.error("manuscript workflow failed, taskId={}, workflowRunId={}, {}",
|
||||||
taskId, workflowRunId, failure.describe());
|
taskId, workflowRunId, failure.describe());
|
||||||
workflowFailed = true;
|
workflowFailed = true;
|
||||||
@@ -1047,69 +1077,43 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创意助手执行:**只跑一次**,不自动重试。
|
||||||
|
*
|
||||||
|
* <p>历史上这里对上游瞬时故障({@code 50500 no more instances to retry} 等)
|
||||||
|
* 重跑过一次,用来把出图整体失败率从约三成压到约一成。但出图工作流一次要等
|
||||||
|
* 45~105 秒,重试会把用户的一次失败拖成两次等待,且失败后没有任何界面能告诉
|
||||||
|
* 用户"还在重试"。现在失败即失败:直接给可读文案,由用户自己决定要不要再来一次。
|
||||||
|
*
|
||||||
|
* <p>失败以 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<String, Object> requestBody, Integer loginUserId) {
|
private ApiResult<?> executeCreativeAssistant(Map<String, Object> requestBody, Integer loginUserId) {
|
||||||
String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content"));
|
String userContent = stringValue(requestBody == null ? null : requestBody.get("user_content"));
|
||||||
if (StrUtil.isBlank(userContent)) {
|
if (StrUtil.isBlank(userContent)) {
|
||||||
return fail("user_content不能为空");
|
return fail("user_content不能为空");
|
||||||
}
|
}
|
||||||
return executeCreativeAssistantWithRetry(buildCreativeAssistantInputs(requestBody), loginUserId);
|
CreativeRun run;
|
||||||
}
|
try {
|
||||||
|
run = runCreativeAssistantOnce(buildCreativeAssistantInputs(requestBody), loginUserId);
|
||||||
/**
|
} catch (CreativeApiFailureException e) {
|
||||||
* 创意助手执行外壳:失败可重试时原样重跑一次工作流。
|
logger.error("creative assistant request rejected, message={}", e.getMessage());
|
||||||
*
|
return e.result;
|
||||||
* <p>为什么需要它:出图分支要连调两次「即梦AI-图像生成」,而上游火山引擎会
|
} catch (Exception e) {
|
||||||
* 偶发 {@code 50500 no more instances to retry}(实例不足,3 秒内即拒绝),
|
logger.error("creative assistant execute failed", e);
|
||||||
* 实测整体失败率约三成。这类失败与内容、参数、凭证都无关,重跑一次通常就成。
|
return fail("出错了,请晚点再试~");
|
||||||
*
|
|
||||||
* <p>每次失败都以 ERROR 记录失败节点与上游错误原文:生产日志级别是
|
|
||||||
* {@code com.gxwebsoft: ERROR},原先这里用 {@code logger.warn},于是出现
|
|
||||||
* "端上说生成失败、后端日志里却什么都没有"的情况。
|
|
||||||
*
|
|
||||||
* @param inputs 已构造好的工作流入参
|
|
||||||
* @param loginUserId 用户 id
|
|
||||||
* @return 成功结果为工作流产出;重试耗尽仍失败时为失败态结果(HTTP 仍是 200,靠 status 表达)
|
|
||||||
*/
|
|
||||||
private ApiResult<?> executeCreativeAssistantWithRetry(Map<String, Object> inputs, Integer loginUserId) {
|
|
||||||
int maxAttempts = CREATIVE_ASSISTANT_MAX_RETRY + 1;
|
|
||||||
Map<String, Object> 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);
|
|
||||||
}
|
}
|
||||||
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 {
|
|||||||
/**
|
/**
|
||||||
* 一次创意助手工作流的执行结论。
|
* 一次创意助手工作流的执行结论。
|
||||||
*
|
*
|
||||||
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志和决定是否重试,
|
* <p>{@code result} 是要下发端上的载荷;其余字段只用于记日志,
|
||||||
* 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露
|
* 绝不可混进 {@code result}——失败态一旦带出上游原文,就会泄露
|
||||||
* {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。
|
* {@code workflow_id} / {@code req_id} 等内部标识符(见 issue 01)。
|
||||||
*/
|
*/
|
||||||
@@ -1143,32 +1147,29 @@ public class AiController extends BaseController {
|
|||||||
private final String failedNode;
|
private final String failedNode;
|
||||||
private final String failureReason;
|
private final String failureReason;
|
||||||
private final boolean failed;
|
private final boolean failed;
|
||||||
private final boolean retryable;
|
|
||||||
|
|
||||||
private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId,
|
private CreativeRun(Map<String, Object> result, String workflowRunId, String taskId,
|
||||||
String failedNode, String failureReason, boolean failed, boolean retryable) {
|
String failedNode, String failureReason, boolean failed) {
|
||||||
this.result = result;
|
this.result = result;
|
||||||
this.workflowRunId = workflowRunId;
|
this.workflowRunId = workflowRunId;
|
||||||
this.taskId = taskId;
|
this.taskId = taskId;
|
||||||
this.failedNode = failedNode;
|
this.failedNode = failedNode;
|
||||||
this.failureReason = briefUpstreamError(failureReason);
|
this.failureReason = briefUpstreamError(failureReason);
|
||||||
this.failed = failed;
|
this.failed = failed;
|
||||||
this.retryable = retryable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreativeRun succeeded(Map<String, Object> result, String workflowRunId, String taskId) {
|
private static CreativeRun succeeded(Map<String, Object> 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<String, Object> result, String workflowRunId, String taskId,
|
private static CreativeRun failed(Map<String, Object> result, String workflowRunId, String taskId,
|
||||||
String failedNode, String failureReason) {
|
String failedNode, String failureReason) {
|
||||||
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true,
|
return new CreativeRun(result, workflowRunId, taskId, failedNode, failureReason, true);
|
||||||
isRetryableCreativeFailure(failureReason));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跑一次创意助手工作流(单次尝试,不含重试)。
|
* 跑一次创意助手工作流(单次尝试,失败即返回)。
|
||||||
*/
|
*/
|
||||||
private CreativeRun runCreativeAssistantOnce(Map<String, Object> inputs, Integer loginUserId)
|
private CreativeRun runCreativeAssistantOnce(Map<String, Object> inputs, Integer loginUserId)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
@@ -1345,12 +1346,14 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上游实例不足(重试有实际收益的那一类)。
|
* 上游实例不足。
|
||||||
*
|
*
|
||||||
* <p>2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于
|
* <p>2026-10-13 内网实例实测:同一 prompt 连跑 6 次,2 次失败于
|
||||||
* 「即梦AI-图像生成」节点,原文为
|
* 「即梦AI-图像生成」节点,原文为
|
||||||
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
|
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
|
||||||
* 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。
|
* 整节点仅耗时 3.1~3.5 秒即返回——即上游没有可用算力而立即拒绝,与内容无关。
|
||||||
|
*
|
||||||
|
* <p>这只影响给用户的文案({@link #CREATIVE_BUSY_MESSAGE}),不再触发自动重试。
|
||||||
*/
|
*/
|
||||||
private static boolean isUpstreamCapacityFailure(String reason) {
|
private static boolean isUpstreamCapacityFailure(String reason) {
|
||||||
if (StrUtil.isBlank(reason)) {
|
if (StrUtil.isBlank(reason)) {
|
||||||
@@ -1362,28 +1365,6 @@ public class AiController extends BaseController {
|
|||||||
|| lower.contains("code: 50500");
|
|| lower.contains("code: 50500");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 这次失败值不值得原样重跑。
|
|
||||||
*
|
|
||||||
* <p>只认能明确归因到上游瞬时的故障。凭证失效({@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"));
|
String userContent = stringValue(requestBody.get("user_content"));
|
||||||
if (StrUtil.isNotBlank(userContent)) {
|
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");
|
Object rawUserFiles = requestBody.get("user_files");
|
||||||
if (hasFileValue(rawUserFiles)) {
|
if (hasFileValue(rawUserFiles)) {
|
||||||
@@ -2094,10 +2080,22 @@ public class AiController extends BaseController {
|
|||||||
return result;
|
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<String, Object> normalizedOutputs = outputs instanceof Map
|
||||||
|
? normalizeCreativeOutputs((Map) outputs)
|
||||||
|
: null;
|
||||||
|
|
||||||
String answer = extractWorkflowOutputs(responseData);
|
String answer = extractWorkflowOutputs(responseData);
|
||||||
if (answer == null) {
|
if (answer == null) {
|
||||||
answer = extractAnswer(responseData);
|
answer = extractAnswer(responseData);
|
||||||
}
|
}
|
||||||
|
if (answer == null && normalizedOutputs != null) {
|
||||||
|
answer = imageMarkdownFromOutputs(normalizedOutputs);
|
||||||
|
}
|
||||||
if (answer != null) {
|
if (answer != null) {
|
||||||
result.put("answer", answer);
|
result.put("answer", answer);
|
||||||
}
|
}
|
||||||
@@ -2112,26 +2110,210 @@ public class AiController extends BaseController {
|
|||||||
result.put("taskId", taskId);
|
result.put("taskId", taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object data = responseData.get("data");
|
|
||||||
if (data instanceof Map) {
|
if (data instanceof Map) {
|
||||||
// status 已在方法开头经 workflowStatus 统一取出,
|
// status 已在方法开头经 workflowStatus 统一取出,
|
||||||
// 这里只负责下发(含顶层 status 的兜底情况)。
|
// 这里只负责下发(含顶层 status 的兜底情况)。
|
||||||
if (status != null) {
|
if (status != null) {
|
||||||
result.put("status", status);
|
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) {
|
if (normalizedOutputs != null) {
|
||||||
result.put("outputs", responseData.get("outputs"));
|
result.put("outputs", normalizedOutputs);
|
||||||
}
|
}
|
||||||
result.put("raw", responseData);
|
result.put("raw", responseData);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容上游 outputs 的形状漂移(不改上游报文,只做向上归一)。
|
||||||
|
*
|
||||||
|
* <p>2026-10-21 在 dev 实例实测:出图分支的终态是
|
||||||
|
* {@code {"image7": ["", ""]}}。
|
||||||
|
* 变量名({@code image7})和值的类型(数组)都不在端上白名单里,
|
||||||
|
* 于是四个页面全部显示「接口已返回,但未解析到可展示的内容」。
|
||||||
|
* 10-13 同一分支的产物还叫 {@code photo_message} / {@code photo_file},
|
||||||
|
* 节点叫「即梦AI-图像生成」;上游换过一轮节点,两端白名单就同时失效了。
|
||||||
|
*
|
||||||
|
* <p>与其把 {@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<String, Object> normalizeCreativeOutputs(Map<String, Object> outputs) {
|
||||||
|
Map<String, Object> normalized = new LinkedHashMap<>(outputs);
|
||||||
|
List<Object> photoFiles = new ArrayList<>();
|
||||||
|
Set<String> knownUrls = new LinkedHashSet<>();
|
||||||
|
collectPhotoFiles(outputs.get("photo_files"), photoFiles, knownUrls);
|
||||||
|
collectPhotoFiles(outputs.get("photo_file"), photoFiles, knownUrls);
|
||||||
|
|
||||||
|
// 其余变量按内容兜底:老字段(photo_*)已收过的 URL 不重复收。
|
||||||
|
for (Map.Entry<String, Object> 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} 收进统一列表。
|
||||||
|
*
|
||||||
|
* <p>上游两种写法都出现过:元素是文件对象({@code {url,name,...}}),
|
||||||
|
* 或直接是 markdown 图片 / 图片 URL 字符串。字符串统一转成文件对象,
|
||||||
|
* 端上的 {@code getCreativeFileUrl(item)} 才取得到地址。
|
||||||
|
*/
|
||||||
|
private void collectPhotoFiles(Object raw, List<Object> target, Set<String> 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<String> extractImageUrls(Object value) {
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
collectImageUrls(value, urls);
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectImageUrls(Object value, List<String> 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<String, Object> imageFileEntry(String url) {
|
||||||
|
Map<String, Object> 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 的最后兜底。
|
||||||
|
*
|
||||||
|
* <p>{@code formatCreativeResult} 虽然会自己渲染图片,但历史会话/其他调用方
|
||||||
|
* 可能只看 {@code answer};这里保证"有图就一定有一段能渲染的正文"。
|
||||||
|
*/
|
||||||
|
private String imageMarkdownFromOutputs(Map<String, Object> outputs) {
|
||||||
|
Object raw = outputs.get("photo_files");
|
||||||
|
if (!(raw instanceof List)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
for (Object item : (List) raw) {
|
||||||
|
String url = item instanceof Map ? creativeFileUrl((Map<?, ?>) item) : "";
|
||||||
|
if (StrUtil.isNotBlank(url)) {
|
||||||
|
lines.add("");
|
||||||
|
}
|
||||||
|
if (lines.size() >= CREATIVE_IMAGE_LIST_MAX_ITEMS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.isEmpty() ? null : String.join("\n\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取 Dify 工作流事件里的执行状态。
|
* 取 Dify 工作流事件里的执行状态。
|
||||||
*
|
*
|
||||||
@@ -2530,8 +2712,9 @@ public class AiController extends BaseController {
|
|||||||
return preferred;
|
return preferred;
|
||||||
}
|
}
|
||||||
for (Object value : source.values()) {
|
for (Object value : source.values()) {
|
||||||
if (isDisplayableText(value)) {
|
String text = findDisplayableValueText(value);
|
||||||
return (String) value;
|
if (text != null) {
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
if (value instanceof Map) {
|
if (value instanceof Map) {
|
||||||
String nestedValue = findTextValue((Map) value);
|
String nestedValue = findTextValue((Map) value);
|
||||||
@@ -2540,11 +2723,65 @@ public class AiController extends BaseController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。
|
// 关键:这里的「任意 String 值」兜底**必须**过可展示判定。
|
||||||
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
|
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个 outputs 值的取文。
|
||||||
|
*
|
||||||
|
* <p>除了 String(过可展示判定),还要认 **List**:上游出图节点把一组图片
|
||||||
|
* 直接放进一个数组(实测 {@code image7: ["", ""]}),
|
||||||
|
* 原来的实现只认 String / Map,List 被静默跳过,出图结果就整段丢了。
|
||||||
|
*
|
||||||
|
* <p>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<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数组元素里的图片字符串也属于可展示内容。
|
||||||
|
*
|
||||||
|
* <p>markdown 图片({@code })没有空格,且长签名 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) {
|
private String stringValue(Object value) {
|
||||||
return value == null ? null : value.toString();
|
return value == null ? null : value.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>2026-10-21 在 dev 实例实测到的真实终态是
|
||||||
|
* {@code {"image7": ["", ""]}}:
|
||||||
|
* 变量名和类型都不在端上白名单里,四个页面一律显示「接口已返回,但未解析到可展示的内容」。
|
||||||
|
* 修复思路是**按内容识别图片**并归一到 {@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<String, Object> 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<String, Object>) method.invoke(new AiController(), responseData, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject imageArrayOutputs() {
|
||||||
|
JSONArray images = new JSONArray();
|
||||||
|
images.add("");
|
||||||
|
images.add("");
|
||||||
|
|
||||||
|
JSONObject outputs = new JSONObject(true);
|
||||||
|
outputs.put("image7", images);
|
||||||
|
return outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void image7数组_归一到photo_files并出可渲染正文() throws Exception {
|
||||||
|
Map<String, Object> 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(""), "answer 也要能直接渲染:" + answer);
|
||||||
|
assertTrue(answer.contains(""), "两张图都要在:" + answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 归一只影响下发载荷_不动上游raw() throws Exception {
|
||||||
|
Map<String, Object> 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", "");
|
||||||
|
|
||||||
|
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<String, Object> result = buildResult(outputs);
|
||||||
|
assertEquals("### 脚本\n第一幕:开场舞", result.get("answer"));
|
||||||
|
assertFalse(((Map<?, ?>) result.get("outputs")).containsKey("photo_files"),
|
||||||
|
"没有图片时不应补出空的 photo_files");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>背景:上游创意助手工作流先经「问题分类器」分流。用户只写画面描述
|
||||||
|
* (「毕业季,图书馆门口,暖色」)时,分类器可能判成文案或脚本分支,
|
||||||
|
* 于是同一个需求有时出图、有时出一段文字。端上选了图片模式就已经知道意图,
|
||||||
|
* 后端把这份意图明确写进 {@code user_content},让分类器不必猜。
|
||||||
|
*/
|
||||||
|
class AiCreativeImagePromptTest {
|
||||||
|
|
||||||
|
private static Map<String, Object> buildInputs(Map<String, Object> requestBody) throws Exception {
|
||||||
|
Method method = AiController.class.getDeclaredMethod(
|
||||||
|
"buildCreativeAssistantInputs", Map.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
//noinspection unchecked
|
||||||
|
return (Map<String, Object>) method.invoke(new AiController(), requestBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> imageRequest(String userContent) {
|
||||||
|
Map<String, Object> 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<String, Object> inputs = buildInputs(imageRequest("校园夜景海报"));
|
||||||
|
|
||||||
|
assertEquals(1, inputs.size(), "参考素材已下线,不该再往上游送 user_files:" + inputs);
|
||||||
|
assertFalse(inputs.containsKey("user_files"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 文字模式_原样透传不包装() throws Exception {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("user_content", "写一篇迎新晚会推文");
|
||||||
|
body.put("mode", "writing");
|
||||||
|
|
||||||
|
assertEquals("写一篇迎新晚会推文", buildInputs(body).get("user_content"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 未传模式_原样透传不包装() throws Exception {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("user_content", "海报排版");
|
||||||
|
|
||||||
|
assertEquals("海报排版", buildInputs(body).get("user_content"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-39
@@ -16,18 +16,19 @@ import java.util.Map;
|
|||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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)。
|
||||||
*
|
*
|
||||||
* <p>真起一个本地 HTTP server 扮演 Dify:第一次返回上游容量不足的失败流,
|
* <p>真起一个本地 HTTP server 扮演 Dify:返回的流里带上游容量不足的失败,
|
||||||
* 第二次返回成功流,然后确认 controller **真的重跑了工作流**并把第二次的产出回给端上。
|
* 然后确认 controller 只请求了一次、且把失败文案直接回给端上。
|
||||||
*
|
*
|
||||||
* <p>单元测试只能证明"判定函数返回 true";这条用例证明的是"重试确实发生了、且结果被采用",
|
* <p>历史背景:这里曾经是「重试一次」的用例(issue 11)。重试确实能把失败率
|
||||||
* 也就是用户看到的"有时候能出图、有时候失败"是否真的被修掉。
|
* 从约三成压到约一成,但出图一次要等 45~105 秒,用户看不到"正在重试",
|
||||||
|
* 体验上就是把一次失败拖成两次等待。现在改为失败即失败。
|
||||||
*/
|
*/
|
||||||
class AiCreativeRetryIntegrationTest {
|
class AiCreativeNoRetryIntegrationTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上游容量不足的原文(2026-10-13 内网实测,request_id 为真实值)。
|
* 上游容量不足的原文(2026-10-13 内网实测,request_id 为真实值)。
|
||||||
@@ -40,37 +41,37 @@ class AiCreativeRetryIntegrationTest {
|
|||||||
+ "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'";
|
+ "\"request_id\":\"202610131604021580A5E0F3C983DD3D63\",\"status\":50500}'";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 凭证失效:重试无用,必须只跑一次。
|
* 凭证失效:同样只跑一次,文案仍是统一的失败提示。
|
||||||
*/
|
*/
|
||||||
private static final String ACCESS_DENIED_ERROR =
|
private static final String ACCESS_DENIED_ERROR =
|
||||||
"req_id: dc688258de PluginInvokeError: API request failed with status code 50400,"
|
"req_id: dc688258de PluginInvokeError: API request failed with status code 50400,"
|
||||||
+ " message: Access Denied";
|
+ " message: Access Denied";
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void 上游容量不足_自动重跑一次并把第二次产出回给端上() throws Exception {
|
void 上游容量不足_不重跑且直接回可读文案() throws Exception {
|
||||||
AtomicInteger hits = new AtomicInteger();
|
AtomicInteger hits = new AtomicInteger();
|
||||||
try (MockDify dify = MockDify.start(exchange -> {
|
try (MockDify dify = MockDify.start(exchange -> {
|
||||||
int attempt = hits.incrementAndGet();
|
hits.incrementAndGet();
|
||||||
String stream = attempt == 1 ? capacityFailureStream() : succeededStream();
|
MockDify.reply(exchange, capacityFailureStream());
|
||||||
MockDify.reply(exchange, stream);
|
|
||||||
})) {
|
})) {
|
||||||
ApiResult<?> result = executeCreative(dify.baseUrl());
|
ApiResult<?> result = executeCreative(dify.baseUrl());
|
||||||
|
|
||||||
assertEquals(2, hits.get(), "第一次撞上上游容量不足后应自动重跑一次");
|
assertEquals(1, hits.get(), "不再自动重试,工作流只应被请求一次");
|
||||||
assertEquals(0, result.getCode(), "重试成功后对端仍是正常返回");
|
assertEquals(0, result.getCode(), "失败态仍以 HTTP 200 + status 表达");
|
||||||
|
|
||||||
Map<?, ?> payload = (Map<?, ?>) result.getData();
|
Map<?, ?> payload = (Map<?, ?>) result.getData();
|
||||||
assertEquals("succeeded", payload.get("status"), "第二次的状态应透出");
|
assertEquals("failed", payload.get("status"));
|
||||||
assertEquals("迎新晚会海报已生成", payload.get("answer"), "第二次的产出应回给端上");
|
assertEquals("生成服务繁忙,请稍后重试", payload.get("answer"));
|
||||||
|
assertFalse(payload.containsKey("raw"), "失败态不得下发整包");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void 凭证失效_不重试且只跑一次() throws Exception {
|
void 凭证失效_只跑一次且文案不变() throws Exception {
|
||||||
AtomicInteger hits = new AtomicInteger();
|
AtomicInteger hits = new AtomicInteger();
|
||||||
try (MockDify dify = MockDify.start(exchange -> {
|
try (MockDify dify = MockDify.start(exchange -> {
|
||||||
hits.incrementAndGet();
|
hits.incrementAndGet();
|
||||||
MockDify.reply(exchange, failureStream(ACCESS_DENIED_ERROR));
|
MockDify.reply(exchange, failureBody(ACCESS_DENIED_ERROR));
|
||||||
})) {
|
})) {
|
||||||
ApiResult<?> result = executeCreative(dify.baseUrl());
|
ApiResult<?> result = executeCreative(dify.baseUrl());
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ class AiCreativeRetryIntegrationTest {
|
|||||||
Map<?, ?> payload = (Map<?, ?>) result.getData();
|
Map<?, ?> payload = (Map<?, ?>) result.getData();
|
||||||
assertEquals("failed", payload.get("status"));
|
assertEquals("failed", payload.get("status"));
|
||||||
assertEquals("生成失败,请稍后重试", payload.get("answer"));
|
assertEquals("生成失败,请稍后重试", payload.get("answer"));
|
||||||
assertTrue(!payload.containsKey("raw"), "失败态仍不得下发整包");
|
assertFalse(payload.containsKey("raw"), "失败态仍不得下发整包");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ class AiCreativeRetryIntegrationTest {
|
|||||||
|
|
||||||
Map<String, Object> requestBody = new HashMap<>();
|
Map<String, Object> requestBody = new HashMap<>();
|
||||||
requestBody.put("user_content", "生成一张迎新晚会海报");
|
requestBody.put("user_content", "生成一张迎新晚会海报");
|
||||||
|
requestBody.put("mode", "image");
|
||||||
|
|
||||||
Method method = AiController.class.getDeclaredMethod(
|
Method method = AiController.class.getDeclaredMethod(
|
||||||
"executeCreativeAssistant", Map.class, Integer.class);
|
"executeCreativeAssistant", Map.class, Integer.class);
|
||||||
@@ -111,10 +113,6 @@ class AiCreativeRetryIntegrationTest {
|
|||||||
+ failureBody(CAPACITY_ERROR);
|
+ failureBody(CAPACITY_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String failureStream(String error) {
|
|
||||||
return failureBody(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String failureBody(String error) {
|
private static String failureBody(String error) {
|
||||||
JSONObject data = new JSONObject(true);
|
JSONObject data = new JSONObject(true);
|
||||||
data.put("status", "failed");
|
data.put("status", "failed");
|
||||||
@@ -129,22 +127,6 @@ class AiCreativeRetryIntegrationTest {
|
|||||||
return sse(finished);
|
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) {
|
private static String node(String title, String status, String error) {
|
||||||
JSONObject data = new JSONObject(true);
|
JSONObject data = new JSONObject(true);
|
||||||
data.put("title", title);
|
data.put("title", title);
|
||||||
@@ -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)。
|
|
||||||
*
|
|
||||||
* <p>背景:内网实例上「即梦AI-图像生成」偶发
|
|
||||||
* {@code {"code":50500,...,"status_code":110001,"status_message":"no more instances to retry"}},
|
|
||||||
* 2026-10-13 同一 prompt 连跑 6 次有 2 次失败,整节点 3 秒即返回——是上游算力不足,
|
|
||||||
* 与内容、参数、凭证都无关,重试一次通常就成。
|
|
||||||
*
|
|
||||||
* <p>而凭证失效、模型停用、审核拒绝重试无用,必须排除,
|
|
||||||
* 否则会把用户的一次失败拖成两次等待。
|
|
||||||
*/
|
|
||||||
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: <nil>\"},\"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<String, Object> 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<String, Object>) 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<String, Object> 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user