fix(ai): 创意助手繁忙归类补 503/50508 与流式中断,失败日志保留尾部

线上日志(2026-10-21 18:10/18:50)显示失败节点已从出图节点移到最前面的
「问题分类器」(SiliconFlow):503 code 50508 System is too busy,以及
requests/urllib3 read_chunked 流式读取中断。旧归类只认 50500,用户看到的是
「生成失败」而不是「生成服务繁忙」;且 briefUpstreamError 只留头部,
Python traceback 的异常类型在尾部被截掉。

- isUpstreamCapacityFailure 增补 50508 / 503 / 流式中断等模式
- briefUpstreamError 改为头 2/3 + 标记 + 尾 1/3(仍单行、仍 <900 字符)
- buildCreativeAssistantResult 在成功但无可展示内容时记 WARN(含 outputKeys
  与 photo_files 条数),让解析兜底路径下一次可诊断
- 新增 AiCreativeBusyClassificationTest(6 条)

issue 19;不恢复自动重试。
This commit is contained in:
2026-10-21 19:09:03 +08:00
parent f4590001d8
commit 31eb75cdad
2 changed files with 367 additions and 2 deletions
@@ -0,0 +1,120 @@
package com.gxwebsoft.ai.controller;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 创意生成失败文案的归类。
*
* <p>2026-10-21 线上日志(dev 实例 18:10 / 18:50 两例)显示:失败节点从
* 「即梦AI-图像生成」换成了最前面的「问题分类器」(LLM 节点,provider 为 SiliconFlow),
* 上游返回的是 503 / {@code code:50508 "System is too busy"} 与流式读取中断。
* 旧实现只认 {@code 50500} 与 {@code no more instances to retry},
* 于是这两例都落到「生成失败,请稍后重试」——把上游繁忙说成用户内容的问题。
*
* <p>这里直接复刻 {@code creativeFailureAnswer} 的入口,锁住「哪些原文算繁忙」。
*/
class AiCreativeBusyClassificationTest {
/** 期望的繁忙文案(与 {@code CREATIVE_BUSY_MESSAGE} 同值)。 */
private static final String BUSY = "生成服务繁忙,请稍后重试";
/** 期望的通用失败文案(与 {@code CREATIVE_FAILED_MESSAGE} 同值)。 */
private static final String FAILED = "生成失败,请稍后重试";
/**
* 复刻调用入口:把上游 error 放进 {@code data.error},交给私有的
* {@code creativeFailureAnswer(responseData)}。
*/
private String answerFor(String upstreamError) throws Exception {
Map<String, Object> data = new LinkedHashMap<>();
data.put("status", "failed");
data.put("error", upstreamError);
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("event", "workflow_finished");
responseData.put("data", data);
AiController controller = new AiController();
Method method = AiController.class.getDeclaredMethod("creativeFailureAnswer", Map.class);
method.setAccessible(true);
return String.valueOf(method.invoke(controller, responseData));
}
/** 2026-10-21 18:50 的真实原文:SiliconFlow 503 / 50508。 */
@Test
void 硅基流动503繁忙_归为繁忙文案() throws Exception {
String reason = "req_id: 1cdf48dd63 PluginInvokeError: {\"args\":{\"description\":\"[models] "
+ "Error: API request failed with status code 503: {\"code\":50508,"
+ "\"message\":\"System is too busy now. Please try again later.\",\"data\":null}\"}}";
assertEquals(BUSY, answerFor(reason));
}
/** 2026-10-21 18:10 的真实原文:流式响应读到一半被掐断(尾部才是异常类型)。 */
@Test
void 流式读取中断_归为繁忙文案() throws Exception {
String reason = "req_id: b0f0928ba8 PluginInvokeError: {\"args\":{\"traceback\":\"Traceback (most recent call last):\\n"
+ " File \\\"/app/.../requests/models.py\\\", line 937, in generate\\n"
+ " yield from self.raw.stream(chunk_size, decode_content=True)\\n"
+ " File \\\"/app/.../urllib3/response.py\\\", line 1260, in stream\\n"
+ " yield from self.read_chunked(amt, decode_content=decode_content)\\n"
+ "requests.exceptions.ChunkedEncodingError: ('Connection broken: IncompleteRead(0 bytes read)')\"}";
assertEquals(BUSY, answerFor(reason));
}
/** 老规则仍然生效:图片工具没有可用实例。 */
@Test
void 即梦无可用实例_归为繁忙文案() throws Exception {
String reason = "{\"code\":50500,\"status_code\":110001,\"status_message\":\"no more instances to retry\"}";
assertEquals(BUSY, answerFor(reason));
}
/** 凭证/权限类失败不是繁忙,别把「去检查配置」说成「稍后再试」。 */
@Test
void 权限失败_仍为通用失败文案() throws Exception {
String reason = "req_id: dc688258de PluginInvokeError: [models] Error: API request failed with status code 403";
assertEquals(FAILED, answerFor(reason));
}
/** 无 error 字段时不能抛异常,按通用失败处理。 */
@Test
void 无error字段_按通用失败处理() throws Exception {
Map<String, Object> responseData = new LinkedHashMap<>();
responseData.put("data", new LinkedHashMap<String, Object>());
AiController controller = new AiController();
Method method = AiController.class.getDeclaredMethod("creativeFailureAnswer", Map.class);
method.setAccessible(true);
assertEquals(FAILED, String.valueOf(method.invoke(controller, responseData)));
}
/** 截断必须保留尾部:Python traceback 的异常类型只在最后一行。 */
@Test
void 超长上游原文_头尾都保留() throws Exception {
StringBuilder reason = new StringBuilder("HEAD_MARKER");
while (reason.length() < 900) {
reason.append(" middle-padding");
}
reason.append("ChunkedEncodingError: TAIL_MARKER");
AiController controller = new AiController();
Method method = AiController.class.getDeclaredMethod("briefUpstreamError", String.class);
method.setAccessible(true);
String brief = String.valueOf(method.invoke(null, reason.toString()));
org.junit.jupiter.api.Assertions.assertTrue(brief.startsWith("HEAD_MARKER"), brief);
org.junit.jupiter.api.Assertions.assertTrue(brief.endsWith("TAIL_MARKER"), brief);
org.junit.jupiter.api.Assertions.assertTrue(brief.contains("...(truncated)..."), brief);
org.junit.jupiter.api.Assertions.assertTrue(brief.length() < 900, "截断后仍应短:" + brief.length());
org.junit.jupiter.api.Assertions.assertFalse(brief.contains("\n"), "日志行不能带换行");
}
}