fix(ai): 失败态在返回体里带 status=failed,前端才能区分失败文案与正文(issue 10)
上游工作流 failed 时,后端把 reply 置为失败文案("生成失败,请稍后重试")
并照常走成功路径返回(HTTP 200 / code=0),而返回体里**没有 status**:
data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}
两端前端的 isFailedPayload(读 payload.status / payload.data.status)因此
永远落空,只能把失败文案当正文渲染(实测三个 admin 页面都会这样)。
修 issue 09 时上游每次都 failed,这条是必现的。
改动:
- buildManuscriptResult 增加 failed 参数(保留三参重载,等价 failed=false,
以免破坏 issue 08 既有测试的调用形态),失败时 put("status","failed")。
- 新增 buildActivityOutlineResult(同构),activityOutlineGen 收尾改用之。
- 两个方法都在外层作用域记录 workflowFailed:失败分支里有 continue,
不能用分支内的局部布尔。
刻意不改的东西:
- 失败仍走 HTTP 200 / code=0。改成 code!=0 会与既有约定冲突、影响所有调用方
的 catch 分支,本条只加一个字段即可让两端现有判定生效。
- answer 键在失败态下依然存在(issue 08 契约),只是现在有了 status 可区分。
测试:新增 AiWorkflowFailureHttpStatusTest 8/8,含反向用例——
成功态**不得**带 status(否则 isFailedPayload 会误伤正常结果),
以及「成功态正文恰好是失败文案」时唯一判据是 status。
全量 210 跑 0 失败(2 个 ServerContainer not available 为预存问题)。
验证:用桩上游(probe/stub-dify-failure.py,回 workflow_finished{status:failed})
起第二个后端实例,实测两条链路的返回体都带 status='failed' 且
answer 仍为可读文案,前端 isFailedPayload 判定为 true —— 全链路闭合。
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
package com.gxwebsoft.ai.controller;
|
||||
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* issue 10 的回归:失败态必须靠 {@code data.status} 表达,而不是靠 {@code answer} 的内容。
|
||||
*
|
||||
* <p>缺陷回顾:上游工作流 failed 时,后端把 {@code reply} 置为失败文案
|
||||
* ({@code "生成失败,请稍后重试"})并<b>照常走成功路径</b>返回
|
||||
* (HTTP 200 / {@code code=0})。于是:
|
||||
* <pre>
|
||||
* data = {"answer":"生成失败,请稍后重试", "conversationId":"…", "messageId":"…"}
|
||||
* </pre>
|
||||
* 返回体里<b>没有 status</b>,前端的 {@code isFailedPayload}(读
|
||||
* {@code payload.status} / {@code payload.data.status})永远落空,
|
||||
* 只能把失败文案当正文渲染。
|
||||
*
|
||||
* <p>实测(node 复刻 admin 的解析函数):三个页面都会把
|
||||
* 「生成失败,请稍后重试」渲染成正文字段。
|
||||
*
|
||||
* <p>本测试锁定:失败时 {@code status=failed};成功时<b>不带</b> {@code status}
|
||||
* —— 后者同样重要,否则 {@code isFailedPayload} 会把正常结果误判为失败
|
||||
* (uniapp 的 {@code isFailedPayload} 只在明确 {@code 'failed'} 时为真)。
|
||||
*/
|
||||
class AiWorkflowFailureHttpStatusTest {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId,
|
||||
String workflowRunId, boolean failed) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod("buildManuscriptResult",
|
||||
StringBuilder.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (Map<String, Object>) method.invoke(new AiController(), reply, taskId, workflowRunId, failed);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> buildOutlineResult(StringBuilder reply, String conversationId,
|
||||
String messageId, boolean failed) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod("buildActivityOutlineResult",
|
||||
StringBuilder.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (Map<String, Object>) method.invoke(new AiController(), reply, conversationId, messageId, failed);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 缺陷本体:失败必须可判定
|
||||
|
||||
@Test
|
||||
void 稿件失败态_返回体带status_failed() throws Exception {
|
||||
Map<String, Object> result = buildManuscriptResult(
|
||||
new StringBuilder("生成失败,请稍后重试"), "task-1", "run-1", true);
|
||||
|
||||
assertEquals("failed", result.get("status"),
|
||||
"失败态必须带 status,否则前端 isFailedPayload 无法判定");
|
||||
// 失败文案仍在 answer 里:前端据此显示可读提示是允许的,
|
||||
// 但它必须能被 status 识别出来,不能只靠文案内容猜。
|
||||
assertEquals("生成失败,请稍后重试", result.get("answer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 大纲失败态_返回体带status_failed() throws Exception {
|
||||
Map<String, Object> result = buildOutlineResult(
|
||||
new StringBuilder("生成失败,请稍后重试"), "conv-1", "msg-1", true);
|
||||
|
||||
assertEquals("failed", result.get("status"));
|
||||
assertEquals("生成失败,请稍后重试", result.get("answer"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 反向:成功态不得误报失败
|
||||
|
||||
@Test
|
||||
void 稿件成功态_不得带status() throws Exception {
|
||||
// 关键反向用例:若成功也带 status(哪怕是 "succeeded" 之外的值),
|
||||
// 前端失败判定就会误伤正常结果。
|
||||
Map<String, Object> result = buildManuscriptResult(
|
||||
new StringBuilder("# 标题\n\n正文"), "task-1", "run-1", false);
|
||||
|
||||
assertFalse(result.containsKey("status"),
|
||||
"成功态不应带 status —— 前端只认 'failed',多带字段徒增误判面");
|
||||
assertEquals("# 标题\n\n正文", result.get("answer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 大纲成功态_不得带status() throws Exception {
|
||||
Map<String, Object> result = buildOutlineResult(
|
||||
new StringBuilder("正文"), "conv-1", "msg-1", false);
|
||||
|
||||
assertFalse(result.containsKey("status"));
|
||||
assertEquals("正文", result.get("answer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 成功态正文恰好是失败文案时_仍以status为准() throws Exception {
|
||||
// 极端情形:模型真的生成了「生成失败,请稍后重试」这几个字作为正文。
|
||||
// 没有 status 时前端无法区分,这正是要靠 status 表达失败的原因。
|
||||
Map<String, Object> success = buildManuscriptResult(
|
||||
new StringBuilder("生成失败,请稍后重试"), null, null, false);
|
||||
Map<String, Object> failure = buildManuscriptResult(
|
||||
new StringBuilder("生成失败,请稍后重试"), null, null, true);
|
||||
|
||||
// 两者 answer 完全相同,唯一的判据就是 status
|
||||
assertEquals(success.get("answer"), failure.get("answer"));
|
||||
assertFalse(success.containsKey("status"), "没有上游失败信号时不得臆断为 failed");
|
||||
assertEquals("failed", failure.get("status"), "有失败信号时必须以 status 表达");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 与 issue 08 的契约并存
|
||||
|
||||
@Test
|
||||
void 失败态_answer键仍必须存在() throws Exception {
|
||||
// issue 08 的契约不能被本条破坏:answer 始终存在,空正文给空串,
|
||||
// 否则 data 会被 @JsonInclude(NON_NULL) 省略、前端回落到 message。
|
||||
Map<String, Object> result = buildManuscriptResult(new StringBuilder(), null, null, true);
|
||||
|
||||
assertTrue(result.containsKey("answer"), "answer 键必须存在(issue 08)");
|
||||
assertEquals("", result.get("answer"));
|
||||
assertEquals("failed", result.get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空reply为null_不抛异常() throws Exception {
|
||||
Map<String, Object> result = buildManuscriptResult(null, null, null, true);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.get("answer"));
|
||||
assertEquals("failed", result.get("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 三参重载_等价于未失败() throws Exception {
|
||||
// 三参重载目前只被既有测试使用;保留它是为了不破坏 AiManuscriptHttpResultTest
|
||||
// 的调用形态,这里锁定它等价于 failed=false。
|
||||
Method method = AiController.class.getDeclaredMethod("buildManuscriptResult",
|
||||
StringBuilder.class, String.class, String.class);
|
||||
method.setAccessible(true);
|
||||
Object value = method.invoke(new AiController(), new StringBuilder("正文"), "t", "r");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> result = (Map<String, Object>) value;
|
||||
assertFalse(result.containsKey("status"));
|
||||
assertEquals("正文", result.get("answer"));
|
||||
assertEquals("t", result.get("taskId"));
|
||||
assertEquals("r", result.get("workflowRunId"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user