fix(ai): 稿件/大纲链路失败态不再把标识符当正文推给端上

这三条路径在 workflow_finished 事件只 extractAnswer、从不读 status:
工作流失败时 outputs 为空、answer 可能被填成 workflow_run_id(UUID),
原样下发等于把内部标识符当正文推给端上(issue 03)。

- activityOutlineGen:失败先判定,推「生成失败,请稍后重试」(reset=true)
  再正常收尾
- executeWorkflowForResult:同上;最终结果失败时只回 status + 可读文案,
  不带 outputs / raw
- manuscriptGen:自查发现该方法不走通用执行、自带一份缺口,一并补齐
- socket 发送器增加 reset 字段:端上收到后丢弃失败前已累积的半截内容
- 新增 AiWorkflowFailureStatusTest 10 例:锁定「失败判定先于取 answer」
  的顺序契约与顶层/嵌套 status、大小写空白、成功不误判等边界

Refs: .scratch/ai-assistant/issues/03-other-links-raw-payload-fallback.md
This commit is contained in:
2026-09-24 04:22:29 +08:00
parent c598d88506
commit a2a83f7d9e
2 changed files with 280 additions and 0 deletions
@@ -0,0 +1,177 @@
package com.gxwebsoft.ai.controller;
import com.gxwebsoft.common.core.utils.JSONUtil;
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;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 稿件生成 / 活动策划大纲两条链路在「工作流失败」时的状态判定。
*
* <p>对应 issue 03:这两条链路走 WebSocket 流式 + {@code conversation_id}(有状态),
* 与 issue 01 的无状态创意链路结构不同,但失败态的判定语义应当一致——
* 都必须在读 {@code answer} 之前先看 {@code status}。
*
* <p>关键差异:{@code workflow_finished} 事件在失败时 {@code outputs} 为空,
* 顶层 {@code answer} 可能被填成 {@code workflow_run_id}(UUID)。
* 若不先判失败,UUID 就会被当作正文推给端上。
*
* <p>这两条链路的完整执行需要一个真实的 HTTP 连接,无法在单测里端到端跑;
* 这里锁定的是它们**共用的判定逻辑**({@code workflowStatus} / {@code isWorkflowFailed})
* 在这些形状下的行为,以及判定先于取值的顺序契约。
*/
class AiWorkflowFailureStatusTest {
private String workflowStatus(Object responseData) throws Exception {
AiController controller = new AiController();
Method method = AiController.class.getDeclaredMethod("workflowStatus", Map.class);
method.setAccessible(true);
return (String) method.invoke(controller, responseData);
}
private boolean isWorkflowFailed(String status) throws Exception {
AiController controller = new AiController();
Method method = AiController.class.getDeclaredMethod("isWorkflowFailed", String.class);
method.setAccessible(true);
return (Boolean) method.invoke(controller, status);
}
/**
* 复刻两条链路在 {@code workflow_finished} 处的判定顺序:
* 先看 status,失败则不取 answer。
*
* @return 应当下发给端上的文本;null 表示走正常取值路径
*/
@SuppressWarnings("unchecked")
private String decideEventText(Map<String, Object> event, String failedMessage) throws Exception {
if (isWorkflowFailed(workflowStatus(event))) {
return failedMessage;
}
return null;
}
// -------------------------------------------------------- 事件形状
private static final String OUTLINE_FAILED_EVENT = "{"
+ "\"event\":\"workflow_finished\","
+ "\"conversation_id\":\"conv-1\","
+ "\"message_id\":\"msg-1\","
+ "\"data\":{"
+ "\"status\":\"failed\","
+ "\"outputs\":{},"
+ "\"error\":\"req_id: dc688258de PluginInvokeError: Model disabled.\""
+ "}"
+ "}";
private static final String OUTLINE_OK_EVENT = "{"
+ "\"event\":\"workflow_finished\","
+ "\"conversation_id\":\"conv-1\","
+ "\"message_id\":\"msg-1\","
+ "\"data\":{\"status\":\"succeeded\",\"outputs\":{\"answer\":\"大纲正文\"}}"
+ "}";
private Map<String, Object> parse(String json) {
Map<String, Object> map = JSONUtil.parseObject(json, Map.class);
assertNotNull(map, "测试样本必须是合法 JSON");
return map;
}
// -------------------------------------------------------- 失败态
@Test
void 大纲失败事件_被判定为失败() throws Exception {
assertTrue(isWorkflowFailed(workflowStatus(parse(OUTLINE_FAILED_EVENT))));
}
@Test
void 稿件失败事件_被判定为失败() throws Exception {
// 稿件链路走通用执行,事件形状相同
String event = "{\"event\":\"workflow_finished\",\"data\":{\"status\":\"failed\",\"outputs\":{}}}";
assertTrue(isWorkflowFailed(workflowStatus(parse(event))));
}
@Test
void 失败事件_不应再走取answer的分支() throws Exception {
Map<String, Object> event = parse(OUTLINE_FAILED_EVENT);
String decided = decideEventText(event, "生成失败,请稍后重试");
assertEquals("生成失败,请稍后重试", decided,
"失败事件必须先被拦下,不能落到 extractAnswer");
assertFalse(decided.contains("workflow_run_id"));
assertFalse(decided.contains("req_id"));
assertFalse(decided.contains("PluginInvokeError"));
}
@Test
void 失败事件_顶层status形状同样被判定为失败() throws Exception {
// 少数事件把 status 放在顶层而非 data.status
String event = "{\"event\":\"workflow_finished\",\"status\":\"failed\",\"outputs\":{}}";
assertTrue(isWorkflowFailed(workflowStatus(parse(event))));
}
// -------------------------------------------------------- 成功态(防回归)
@Test
void 成功事件_不被误判为失败() throws Exception {
assertFalse(isWorkflowFailed(workflowStatus(parse(OUTLINE_OK_EVENT))));
}
@Test
void 成功事件_仍走正常取值分支() throws Exception {
Map<String, Object> event = parse(OUTLINE_OK_EVENT);
assertEquals(null, decideEventText(event, "生成失败,请稍后重试"),
"成功事件不应被失败分支拦下");
}
@Test
void 成功事件_输出字段仍可取到() throws Exception {
Map<String, Object> event = parse(OUTLINE_OK_EVENT);
Object data = event.get("data");
assertTrue(data instanceof Map, "data 应是对象");
Object outputs = ((Map<?, ?>) data).get("outputs");
assertTrue(outputs instanceof Map, "outputs 应是对象");
assertEquals("大纲正文", ((Map<?, ?>) outputs).get("answer"));
}
// -------------------------------------------------------- 边界
@Test
void 状态缺失_不误判为失败() throws Exception {
String event = "{\"event\":\"workflow_finished\",\"data\":{\"outputs\":{\"answer\":\"正文\"}}}";
assertFalse(isWorkflowFailed(workflowStatus(parse(event))));
}
@Test
void 状态大小写与空白_判定不敏感() throws Exception {
assertTrue(isWorkflowFailed(" FAILED "));
assertTrue(isWorkflowFailed("failed"));
assertTrue(isWorkflowFailed("FAILED"));
assertFalse(isWorkflowFailed("succeeded"));
assertFalse(isWorkflowFailed("stopped"));
assertFalse(isWorkflowFailed(null));
assertFalse(isWorkflowFailed(""));
assertFalse(isWorkflowFailed(" "));
}
@Test
void 非Map与空事件_不抛异常() throws Exception {
assertEquals(null, workflowStatus(null));
Map<String, Object> bare = new LinkedHashMap<>();
assertEquals(null, workflowStatus(bare));
Map<String, Object> nonMapData = new LinkedHashMap<>();
nonMapData.put("data", "not-a-map");
assertEquals(null, workflowStatus(nonMapData));
}
}