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
@@ -363,6 +363,33 @@ public class AiController extends BaseController {
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
} else if ("workflow_finished".equals(event) || "message_end".equals(event)) {
// 失败态先判定:outputs 为空时 answer 可能是 workflow_run_id
// 这类内部标识符,不能当正文推给端上(见 issue 03)。
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("manuscript workflow failed, taskId={}, workflowRunId={}",
taskId, workflowRunId);
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
HashMap<String, String> failureData = new HashMap<>();
failureData.put("answer", CREATIVE_FAILED_MESSAGE);
failureData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
failureData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
// reset=true:丢弃失败前可能已推给端的半截内容
failureData.put("reset", "true");
webSocketServer.sendMessage(getLoginUserId().toString(),
JSONUtil.toJSONString(failureData));
if (!endSent) {
endSent = true;
HashMap<String, String> endData = new HashMap<>();
endData.put("answer", "__END__");
endData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
endData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
webSocketServer.sendMessage(getLoginUserId().toString(),
JSONUtil.toJSONString(endData));
}
clearActiveAiTask(getLoginUserId(), taskId);
continue;
}
if (answer == null) {
answer = extractWorkflowOutputs(data);
}
@@ -714,6 +741,24 @@ public class AiController extends BaseController {
sendActivityOutlineSocketMessage(loadingText, conversationId, messageId, true);
}
} else if ("message_end".equals(event) || "workflow_finished".equals(event)) {
// 失败态必须先判定:此时 outputs 为空、answer 可能是
// workflow_run_id 这类内部标识符,直接下发等于把标识符
// 当正文推给端上(见 issue 03)。
if (isWorkflowFailed(workflowStatus(data))) {
logger.warn("activity outline workflow failed, conversationId={}, messageId={}",
conversationId, messageId);
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
// reset=true:丢弃失败前可能已推给端的半截内容,
// 否则失败文案会追加在流式残片后面(见 issue 03)。
sendActivityOutlineSocketMessage(CREATIVE_FAILED_MESSAGE,
conversationId, messageId, false, true);
if (!endSent) {
endSent = true;
sendActivityOutlineSocketEnd(conversationId, messageId);
}
continue;
}
String answer = extractAnswer(data);
if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) {
reply.append(answer);
@@ -742,6 +787,16 @@ public class AiController extends BaseController {
}
private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId, boolean loading) {
sendActivityOutlineSocketMessage(answer, conversationId, messageId, loading, false);
}
/**
* @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。
* 失败收场时用它覆盖失败前可能已推给端的半截内容
* (见 issue 03)。
*/
private void sendActivityOutlineSocketMessage(String answer, String conversationId, String messageId,
boolean loading, boolean reset) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", answer == null ? "" : answer);
data.put("conversationId", StrUtil.blankToDefault(conversationId, "0"));
@@ -749,6 +804,9 @@ public class AiController extends BaseController {
if (loading) {
data.put("loading", "true");
}
if (reset) {
data.put("reset", "true");
}
Integer loginUserId = getLoginUserId();
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
@@ -1113,6 +1171,7 @@ public class AiController extends BaseController {
String taskId = null;
Map<String, Object> lastData = null;
boolean endSent = false;
boolean workflowFailed = false;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
@@ -1158,6 +1217,27 @@ public class AiController extends BaseController {
taskId, workflowRunId, stopType, true, loginUserId);
}
} else if ("workflow_finished".equals(event) || "message_end".equals(event)) {
// 失败态必须先判定:outputs 为空时 answer 可能是
// workflow_run_id 这类内部标识符,不能当正文推给端上(见 issue 03)。
String eventStatus = workflowStatus(data);
if (isWorkflowFailed(eventStatus)) {
logger.warn("workflow failed, stopType={}, taskId={}, workflowRunId={}",
stopType, taskId, workflowRunId);
workflowFailed = true;
reply.setLength(0);
reply.append(CREATIVE_FAILED_MESSAGE);
if (streamToSocket) {
// reset=true:丢弃失败前可能已推给端的半截内容
sendWorkflowSocketMessage(CREATIVE_FAILED_MESSAGE,
taskId, workflowRunId, stopType, false, loginUserId, true);
}
if (streamToSocket) {
sendWorkflowSocketEnd(taskId, workflowRunId, stopType, loginUserId);
endSent = true;
}
clearActiveAiTask(loginUserId, taskId);
continue;
}
String finalAnswer = answer == null ? extractWorkflowOutputs(data) : answer;
if (finalAnswer != null && (finalAnswer.length() == 0 || reply.indexOf(finalAnswer) < 0)) {
reply.append(finalAnswer);
@@ -1188,6 +1268,12 @@ public class AiController extends BaseController {
if (StrUtil.isNotBlank(workflowRunId)) {
result.put("workflowRunId", workflowRunId);
}
// 失败态只回状态与可读文案,不下发 outputs / raw / 上游错误栈(见 issue 03)。
if (workflowFailed) {
result.put("status", "failed");
result.put("answer", CREATIVE_FAILED_MESSAGE);
return success(result);
}
if (lastData != null) {
Object data = lastData.get("data");
if (data instanceof Map) {
@@ -1216,6 +1302,20 @@ public class AiController extends BaseController {
String stopType,
boolean loading,
Integer loginUserId) {
sendWorkflowSocketMessage(answer, taskId, workflowRunId, stopType, loading, loginUserId, false);
}
/**
* @param reset 为真时端上应丢弃已累积的流式片段,只用本条内容。
* 失败收场时用它覆盖失败前可能已推给端的半截内容(见 issue 03)。
*/
private void sendWorkflowSocketMessage(String answer,
String taskId,
String workflowRunId,
String stopType,
boolean loading,
Integer loginUserId,
boolean reset) {
HashMap<String, String> data = new HashMap<>();
data.put("answer", answer == null ? "" : answer);
data.put("taskId", StrUtil.blankToDefault(taskId, "0"));
@@ -1224,6 +1324,9 @@ public class AiController extends BaseController {
if (loading) {
data.put("loading", "true");
}
if (reset) {
data.put("reset", "true");
}
try {
webSocketServer.sendMessage(loginUserId == null ? "0" : loginUserId.toString(), JSONUtil.toJSONString(data));
} catch (IOException e) {