fix(ai): 大纲链路补齐上游必填变量,资金归一化为数值(issue 09)
活动策划大纲当时必然失败,界面只显示「生成失败,请稍后重试」。
两条根因(单变量对照实测确证):
1. Dify 开始节点 event_funding 是 number,而实体与两端前端都是字符串,
小程序问答助手页默认值还是中文「待定」→ must be a valid number,0.2s 即 failed。
2. event_member / tel_people 是上游必填变量,实体里根本没有字段,从不发送
→ event_member is required in input form。
实施中发现的关键事实,推翻了 issue 初稿的一条建议:
「is required in input form」检查的是键是否存在,不是值是否非空 ——
缺 event_member 键 / null → failed: is required in input form
"event_member": "" → succeeded(4752 条 message)
"event_funding": "" → succeeded(7268 条 message)
"event_funding": "待定" → failed: must be a valid number
所以「干脆不发该字段」行不通(不发 = 报 required),正确做法是键始终下发、
值空串兜底。
改动:
- ActivityOutlineInputs 补 eventMember / peopleManage / telPeople 三字段
(snake + 驼峰别名);eventFunding 保持 String 承载用户原文。
- buildActivityOutlineInputs:6 个上游必填变量无条件占位(空值走空白转空串,
不能给 null —— null 会被上游当缺失);event_type / event_location /
people_manage / event_meme 实测非必填,保持「为空不发」。
- 新增 normalizeEventFunding:抽出首个金额并归一化为 BigDecimal。
两个必须先处理的坑,否则静默发出错误金额(比留空更糟):
· 日期:'2026年4月办活动,预算5000元' 的 2026 会被当金额 → 先剔除日期
· 单位前空格:'预算 1 万元' 只认紧邻的「万」会抽成 1 元 → \s* 容忍
另支持 万/亿/千/百 倍率与 '3万5千' 连续降序单位相加;抽不出即降级空串。
- 前置校验抽成 validateActivityOutline,只校验主题/受众/时间三项。
刻意不校验另外三个上游必填项 —— 上游必填不等于用户必填,加校验会把两端
默认入参挡在门外(改前端默认值为空串后,旧校验会直接返回
'event_funding不能为空',比原缺陷更糟)。
- buildLoadingText 加 120 字上限:agent_thought 的思维链实测可达数 KB,
整段塞进 loading 气泡等于把思维链推给端上。
- 去掉 isDisplayableText 之后的恒假条件(空串已被排除),并注释说明
reply.indexOf("") 恒为 0 会误判已存在,故两道判断都不能少。
配套前端:
- uniapp 问答助手页资金默认值 '待定' → ''(未提及 != 待定)
- admin 入参类型补注释与 3 个可选键
测试:新增 AiActivityOutlineContractTest 23/23,含「凡校验放行的入参必带上游
全部必填键」的不变量用例;AiManuscriptUuidLeakTest 增至 21 条,补 loading
长度上限与真实 message_end 形状。全量 202 跑 0 失败
(2 个 ServerContainer not available 为预存问题)。
验收:probe/outline-e2e.py 走真实后端,用两端前端默认入参与 issue 原文要求的
'待定' 一档,复刻 apiRequest 回落 + formatOutlineResult + parseAssistantContent
判定,29/29 PASS。
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
package com.gxwebsoft.ai.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gxwebsoft.ai.entity.ActivityOutlineInputs;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* issue 09 的回归:{@code /activityOutlineGen} 的入参必须满足 Dify 开始节点契约。
|
||||
*
|
||||
* <p>两条根因(均为单变量对照实测确证,见 {@code .scratch/ai-assistant/issues/09-*.md}):
|
||||
* <ol>
|
||||
* <li>{@code event_funding} 在 Dify 上是 <b>number</b>。发 "待定"/"预算 5000 元"
|
||||
* 这类不可解析的字符串 → {@code event_funding in input form must be a valid number},
|
||||
* 工作流 0.2s 内直接 failed,一个字都不生成。</li>
|
||||
* <li>Dify 开始节点要求 {@code event_member} / {@code tel_people}(必填),
|
||||
* 而实体里根本没有这两个字段,自然从不发送 → {@code event_member is required in input form}。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>关键实测事实</b>(决定了修复形态,与 issue 初稿的猜测不同):
|
||||
* Dify 的 "is required in input form" 检查的是 <b>键是否存在</b>,不是值是否非空 ——
|
||||
* <pre>
|
||||
* inputs 缺 event_member 键 → failed: event_member is required in input form
|
||||
* "event_member": "" → succeeded(4752 条 message)
|
||||
* "event_funding": "" → succeeded(7268 条 message)
|
||||
* "event_funding": "待定" → failed: must be a valid number
|
||||
* </pre>
|
||||
* 所以 issue 初稿里「改为不发送该字段」这条路径是<b>行不通的</b>(不发 = 报 required),
|
||||
* 正确做法是<b>键始终下发</b>,值用空串兜底;且绝不能对 event_member / tel_people
|
||||
* 加前置非空校验 —— 那会把两端前端本来就不采集这两个字段的默认入参挡在门外。
|
||||
*/
|
||||
class AiActivityOutlineContractTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> buildInputs(ActivityOutlineInputs message) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod(
|
||||
"buildActivityOutlineInputs", ActivityOutlineInputs.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (Map<String, Object>) method.invoke(new AiController(), message);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(Map<String, Object> inputs) throws Exception {
|
||||
return objectMapper.writeValueAsString(inputs);
|
||||
}
|
||||
|
||||
private String validate(ActivityOutlineInputs message) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod(
|
||||
"validateActivityOutline", ActivityOutlineInputs.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (String) method.invoke(new AiController(), message);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private ActivityOutlineInputs baseInputs() {
|
||||
ActivityOutlineInputs inputs = new ActivityOutlineInputs();
|
||||
inputs.setEventTheme("校园科技创新大赛");
|
||||
inputs.setPeople("全校师生");
|
||||
inputs.setEventTime("2026年10月");
|
||||
return inputs;
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 根因 1:event_funding 必须是数字
|
||||
|
||||
@Test
|
||||
void event_funding_纯数字字符串_下发为JSON数值而非字符串() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("5000");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
assertTrue(result.get("event_funding") instanceof Number,
|
||||
"event_funding 必须是数值类型,实际: " + result.get("event_funding"));
|
||||
assertEquals("5000", String.valueOf(result.get("event_funding")));
|
||||
// 真正的判据是序列化后的 JSON 片段:加引号就是字符串,Dify 会报 must be a valid number
|
||||
String json = toJson(result);
|
||||
assertTrue(json.contains("\"event_funding\":5000"),
|
||||
"JSON 里 event_funding 不能带引号,实际: " + json);
|
||||
assertFalse(json.contains("\"event_funding\":\"5000\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_自由文本_抽出其中数值() throws Exception {
|
||||
// admin 端输入框的 placeholder / 示例值就是这种"预算 X 元,来源为…"的散文
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("预算 5000 元,来源为校团委专项经费");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
assertTrue(result.get("event_funding") instanceof Number);
|
||||
assertEquals("5000", String.valueOf(result.get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_带千分位逗号_仍能解析() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("12,000");
|
||||
|
||||
assertEquals("12000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_小数_保留小数() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("5000.5");
|
||||
|
||||
assertEquals("5000.5", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_数字与单位间有空格_仍按倍率换算() throws Exception {
|
||||
// "预算 1 万元" 若只认紧邻的「万」,会抽出 1 元 —— 静默错值,比留空更糟
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("预算 1 万元");
|
||||
|
||||
assertEquals("10000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_亿与万_按倍率换算() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("预算 2 亿元");
|
||||
assertEquals("200000000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
|
||||
inputs = baseInputs();
|
||||
inputs.setEventFunding("1万元");
|
||||
assertEquals("10000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_连续降序单位_相加() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("3万5千");
|
||||
|
||||
assertEquals("35000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_日期不得被当成金额() throws Exception {
|
||||
// 这是最危险的一类:2026 会被抽成金额并静默发出去
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("2026年4月办活动,预算5000元");
|
||||
|
||||
assertEquals("5000", String.valueOf(buildInputs(inputs).get("event_funding")),
|
||||
"日期里的年份不能当金额");
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_只有日期没有金额_降级为空串() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("2026年4月18日");
|
||||
|
||||
assertEquals("", buildInputs(inputs).get("event_funding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_不得把后续的独立金额累加进来() throws Exception {
|
||||
// 只取第一处金额;「另有 3000 元物资」是另一笔,不参与本字段
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("预算 5000 元,另有 3000 元物资");
|
||||
|
||||
assertEquals("5000", String.valueOf(buildInputs(inputs).get("event_funding")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_负数不作为合法金额() throws Exception {
|
||||
// Dify 侧 number 变量不接受负的资金;降级为空串比发负数安全
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("-5000");
|
||||
|
||||
assertEquals("", buildInputs(inputs).get("event_funding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_中文待定_降级为空串而不是原样下发() throws Exception {
|
||||
// 这是缺陷现象本身:uniapp 问答助手页的默认值就是 '待定'。
|
||||
// 发 '待定' → 工作流必定 failed;发 '' → 实测 succeeded。
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("待定");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
assertEquals("", result.get("event_funding"),
|
||||
"不可解析的资金文本必须降级为空串,原样下发会让整个工作流失败");
|
||||
assertTrue(toJson(result).contains("\"event_funding\":\"\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void event_funding_空白_下发为空串且键仍存在() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding(" ");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
assertTrue(result.containsKey("event_funding"), "键必须存在,缺失会报 is required");
|
||||
assertEquals("", result.get("event_funding"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 根因 2:补齐 3 个变量,且键必须存在
|
||||
|
||||
@Test
|
||||
void 新增的三个变量_映射为Dify的真实变量名() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventMember("学生会科创部");
|
||||
inputs.setPeopleManage("由学生会统一组织");
|
||||
inputs.setTelPeople("张同学 13800000000");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
assertEquals("学生会科创部", result.get("event_member"));
|
||||
assertEquals("由学生会统一组织", result.get("people_manage"));
|
||||
assertEquals("张同学 13800000000", result.get("tel_people"));
|
||||
// 不能漏成驼峰原名
|
||||
assertFalse(result.containsKey("eventMember"));
|
||||
assertFalse(result.containsKey("peopleManage"));
|
||||
assertFalse(result.containsKey("telPeople"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未采集的必填变量_以空串占位下发() throws Exception {
|
||||
// 两端前端都不采集 event_member / tel_people。空串实测被上游接受,
|
||||
// 缺席则报 is required —— 所以必须补空串,不能省略。
|
||||
Map<String, Object> result = buildInputs(baseInputs());
|
||||
|
||||
assertTrue(result.containsKey("event_member"), "event_member 键缺失会被上游拒绝");
|
||||
assertEquals("", result.get("event_member"));
|
||||
assertTrue(result.containsKey("tel_people"), "tel_people 键缺失会被上游拒绝");
|
||||
assertEquals("", result.get("tel_people"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 前置校验:不得比上游更严
|
||||
|
||||
@Test
|
||||
void 校验_资金为空串时放行() throws Exception {
|
||||
// 上游实测接受 event_funding="",而问答助手页的资金解析自用户自然语言,
|
||||
// 用户没提钱时就是空。若在这里拦下,正常请求会变成 "event_funding不能为空"。
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("");
|
||||
|
||||
assertNull(validate(inputs), "空资金是上游合法取值,不得阻塞");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验_资金为不可解析文本时放行并降级() throws Exception {
|
||||
// 填「待定」不该报错退出,而应由 normalizeEventFunding 降级成空串后照常生成
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding("待定");
|
||||
|
||||
assertNull(validate(inputs), "不可解析的资金应降级而非报错");
|
||||
assertEquals("", buildInputs(inputs).get("event_funding"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验_未采集的两个必填变量不阻塞() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventMember(null);
|
||||
inputs.setTelPeople(null);
|
||||
|
||||
assertNull(validate(inputs), "event_member/tel_people 前端不采集,不得阻塞");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验_主题受众时间为空时拦截() throws Exception {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventTheme(" ");
|
||||
assertNotNull(validate(inputs));
|
||||
|
||||
inputs = baseInputs();
|
||||
inputs.setPeople("");
|
||||
assertNotNull(validate(inputs));
|
||||
|
||||
inputs = baseInputs();
|
||||
inputs.setEventTime(null);
|
||||
assertNotNull(validate(inputs));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不变量_校验放行的入参必带上游全部必填键() throws Exception {
|
||||
// 这是本修复的核心契约:凡是 validateActivityOutline 放行的入参,
|
||||
// buildActivityOutlineInputs 都不能漏掉上游的必填变量。
|
||||
// 逐个把字段清空,只要校验放行,键就必须在。
|
||||
String[] required = {"event_theme", "people", "event_time",
|
||||
"event_funding", "event_member", "tel_people"};
|
||||
|
||||
String[][] variants = {
|
||||
{"", "", "", ""}, // 只填主题受众时间,资金空
|
||||
{"待定", "待定", "待定", "待定"}, // 全填不可解析文本
|
||||
{"5000", null, null, null}, // 资金数值,其余未采集
|
||||
{"12,000", " ", "", null},
|
||||
};
|
||||
for (String[] v : variants) {
|
||||
ActivityOutlineInputs inputs = baseInputs();
|
||||
inputs.setEventFunding(v[0]);
|
||||
inputs.setEventMember(v[1]);
|
||||
inputs.setPeopleManage(v[2]);
|
||||
inputs.setTelPeople(v[3]);
|
||||
|
||||
if (validate(inputs) != null) {
|
||||
continue; // 被拦下的入参不在本不变量的讨论范围
|
||||
}
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
for (String key : required) {
|
||||
assertTrue(result.containsKey(key),
|
||||
"校验放行了 " + java.util.Arrays.toString(v)
|
||||
+ ",但 inputs 漏了必填键 " + key + " → 上游会报 is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------ 决策:不做事件级分支,只测边界
|
||||
|
||||
@Test
|
||||
void 前端默认入参_全部键齐全且资金为空串() throws Exception {
|
||||
// 复刻本仓库两端的默认入参形态:实体 7 字段 + '待定' 资金
|
||||
ActivityOutlineInputs inputs = new ActivityOutlineInputs();
|
||||
inputs.setEventTheme("入学教育主题活动");
|
||||
inputs.setPeople("团员青年");
|
||||
inputs.setEventTime("待定");
|
||||
inputs.setEventFunding("待定");
|
||||
inputs.setEventType("");
|
||||
inputs.setEventLocation("");
|
||||
inputs.setEventMeme("入学教育主题");
|
||||
|
||||
Map<String, Object> result = buildInputs(inputs);
|
||||
|
||||
// 上游声明的 required 集合(实测)必须全在
|
||||
for (String required : new String[]{"event_theme", "people", "event_time",
|
||||
"event_funding", "event_member", "tel_people"}) {
|
||||
assertTrue(result.containsKey(required), "缺少必填变量 " + required);
|
||||
}
|
||||
assertEquals("", result.get("event_funding"));
|
||||
assertEquals("待定", result.get("event_time"));
|
||||
// 非必填且为空的字段不下发,保持原行为
|
||||
assertFalse(result.containsKey("event_type"));
|
||||
assertFalse(result.containsKey("event_location"));
|
||||
assertEquals("入学教育主题", result.get("event_meme"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空实体_不抛异常() throws Exception {
|
||||
Map<String, Object> result = buildInputs(new ActivityOutlineInputs());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.get("event_funding"));
|
||||
assertEquals("", result.get("event_member"));
|
||||
assertEquals("", result.get("tel_people"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 前端字段名反序列化_同时兼容snake与驼峰() {
|
||||
String snake = "{\"event_theme\":\"主题\",\"event_member\":\"成员\","
|
||||
+ "\"people_manage\":\"组织\",\"tel_people\":\"联系方式\",\"event_funding\":\"5000\"}";
|
||||
ActivityOutlineInputs a = JSONUtil.parseObject(snake, ActivityOutlineInputs.class);
|
||||
assertNotNull(a);
|
||||
assertEquals("成员", a.getEventMember());
|
||||
assertEquals("组织", a.getPeopleManage());
|
||||
assertEquals("联系方式", a.getTelPeople());
|
||||
|
||||
String camel = "{\"eventTheme\":\"主题\",\"eventMember\":\"成员\","
|
||||
+ "\"peopleManage\":\"组织\",\"telPeople\":\"联系方式\"}";
|
||||
ActivityOutlineInputs b = JSONUtil.parseObject(camel, ActivityOutlineInputs.class);
|
||||
assertNotNull(b);
|
||||
assertEquals("主题", b.getEventTheme());
|
||||
assertEquals("成员", b.getEventMember());
|
||||
assertEquals("组织", b.getPeopleManage());
|
||||
assertEquals("联系方式", b.getTelPeople());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.gxwebsoft.ai.controller;
|
||||
|
||||
import com.gxwebsoft.ai.entity.ManuscriptGenInputs;
|
||||
import com.gxwebsoft.common.core.utils.JSONUtil;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* issue 07 的回归:稿件生成的 {@code files} 入参必须能承接两端前端实际发的形状。
|
||||
*
|
||||
* <p>缺陷回顾:字段曾是 {@code List<java.io.File>},而 Jackson 无法从 JSON 对象
|
||||
* 构造 {@code java.io.File},于是**只要选过附件**({@code files} 非空)请求就在进
|
||||
* Controller 前以 {@code MismatchedInputException} 400。{@code files: []} 时看不出来,
|
||||
* 所以长期没被发现。
|
||||
*
|
||||
* <p>这里锁的是**反序列化契约**本身——用真实的 JSON 文本(与前端
|
||||
* {@code buildPayload()} 产出一致)解析,而不是绕开 Jackson 直接 set 字段,
|
||||
* 否则测不到缺陷所在的那一层。
|
||||
*/
|
||||
class AiManuscriptFilesContractTest {
|
||||
|
||||
/** tuanwei-admin / tuanwei-uniapp 的 buildPayload() 实际发出的形状。 */
|
||||
private static final String REAL_FILES_JSON = "[{"
|
||||
+ "\"id\":1,"
|
||||
+ "\"name\":\"通知.pdf\","
|
||||
+ "\"path\":\"/uploads/notice.pdf\","
|
||||
+ "\"url\":\"http://localhost:38927/api/file/1\","
|
||||
+ "\"downloadUrl\":\"http://localhost:38927/api/file/1/download\","
|
||||
+ "\"contentType\":\"application/pdf\""
|
||||
+ "}]";
|
||||
|
||||
private ManuscriptGenInputs parse(String filesJson) {
|
||||
String json = "{\"docType\":\"新闻稿\",\"eventType\":\"校园活动\",\"theme\":\"主题\","
|
||||
+ "\"highlights\":\"亮点\",\"number\":800,\"files\":" + filesJson + "}";
|
||||
return JSONUtil.parseObject(json, ManuscriptGenInputs.class);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 缺陷本体
|
||||
|
||||
@Test
|
||||
void 前端真实形状的附件_能反序列化() {
|
||||
ManuscriptGenInputs inputs = assertDoesNotThrow(() -> parse(REAL_FILES_JSON),
|
||||
"前端发的文件对象数组必须能反序列化——这正是修复前 400 的那一步");
|
||||
|
||||
assertNotNull(inputs);
|
||||
assertEquals(1, inputs.getFiles().size());
|
||||
assertEquals("通知.pdf", inputs.getFiles().get(0).getName());
|
||||
assertEquals("application/pdf", inputs.getFiles().get(0).getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 附件为空数组_不回归() {
|
||||
ManuscriptGenInputs inputs = parse("[]");
|
||||
|
||||
assertNotNull(inputs);
|
||||
assertNotNull(inputs.getFiles());
|
||||
assertTrue(inputs.getFiles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 附件字段缺失_不回归() {
|
||||
String json = "{\"docType\":\"新闻稿\",\"theme\":\"主题\"}";
|
||||
|
||||
ManuscriptGenInputs inputs = assertDoesNotThrow(
|
||||
() -> JSONUtil.parseObject(json, ManuscriptGenInputs.class));
|
||||
|
||||
assertNotNull(inputs);
|
||||
assertEquals(null, inputs.getFiles(), "未传则应为 null,不得抛异常");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 宽容度
|
||||
|
||||
@Test
|
||||
void 附件里的未知字段_被忽略而非整单失败() {
|
||||
// 前端传的是 Partial<FileRecord>,字段集合随上传接口演进
|
||||
String json = "[{\"id\":1,\"name\":\"a.png\","
|
||||
+ "\"thumbnail\":\"http://x/thumb.png\","
|
||||
+ "\"createUserId\":7,\"createTime\":\"2026-10-12 10:00:00\","
|
||||
+ "\"someFutureField\":\"whatever\"}]";
|
||||
|
||||
ManuscriptGenInputs inputs = assertDoesNotThrow(() -> parse(json));
|
||||
|
||||
assertEquals("a.png", inputs.getFiles().get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 多个附件_全部解析() {
|
||||
String json = "[{\"id\":1,\"name\":\"a.png\"},{\"id\":2,\"name\":\"b.docx\"},{\"id\":3}]";
|
||||
|
||||
ManuscriptGenInputs inputs = parse(json);
|
||||
|
||||
assertEquals(3, inputs.getFiles().size());
|
||||
assertEquals("a.png", inputs.getFiles().get(0).getName());
|
||||
assertEquals("b.docx", inputs.getFiles().get(1).getName());
|
||||
assertEquals(null, inputs.getFiles().get(2).getName(), "缺字段的项也应能解析");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 边界:形状确实不对时仍应报错
|
||||
|
||||
@Test
|
||||
void 附件是字符串而非数组_仍应报错() {
|
||||
// 宽容不等于什么都收:契约仍是「对象数组」。
|
||||
// 注意 JSONUtil.parseObject 内部吞异常返回 null,故这里断言 null 而不是抛异常。
|
||||
ManuscriptGenInputs inputs = parse("\"not-an-array\"");
|
||||
|
||||
assertEquals(null, inputs, "形状不对时不该静默产出一个半成品对象");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 其余业务字段_映射不回归() {
|
||||
String json = "{\"docType\":\"发言稿\",\"eventType\":\"会议论坛\",\"theme\":\"主题\","
|
||||
+ "\"time\":\"2026年4月\",\"location\":\"礼堂\",\"participants\":\"120人\","
|
||||
+ "\"highlights\":\"亮点\",\"number\":1500,\"files\":[]}";
|
||||
|
||||
ManuscriptGenInputs inputs = JSONUtil.parseObject(json, ManuscriptGenInputs.class);
|
||||
|
||||
assertNotNull(inputs);
|
||||
assertEquals("发言稿", inputs.getDocType());
|
||||
assertEquals("会议论坛", inputs.getEventType());
|
||||
assertEquals("2026年4月", inputs.getTime());
|
||||
assertEquals("礼堂", inputs.getLocation());
|
||||
assertEquals("120人", inputs.getParticipants());
|
||||
assertEquals(1500, inputs.getNumber());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* issue 08 的回归:{@code /manuscriptGen} 必须把正文放进 HTTP 返回的 {@code data}。
|
||||
*
|
||||
* <p>缺陷回顾:该方法结尾是 {@code return success("Stream processing completed")}——
|
||||
* 只回一句状态文案,累积的 {@code reply}(整篇正文)被丢弃,且 {@code ApiResult}
|
||||
* 带 {@code @JsonInclude(NON_NULL)} 会把空的 {@code data} 整个省略。
|
||||
*
|
||||
* <p>于是 uni-app 的 {@code apiRequest}({@code chat-service.js:365})
|
||||
* 这一行回落生效:
|
||||
* <pre>return body.data !== undefined ? body.data : body.message</pre>
|
||||
* {@code data} 为 undefined → 返回 {@code body.message} →
|
||||
* 聊天气泡里出现 "Stream processing completed"。
|
||||
*
|
||||
* <p>关键点:本接口虽同时走 WebSocket 推流,但**不是所有调用方都连了 WS**
|
||||
* (小程序问答助手页在稿件模式下只发 HTTP),它们的正文唯一来源就是这个返回体。
|
||||
*/
|
||||
class AiManuscriptHttpResultTest {
|
||||
|
||||
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId)
|
||||
throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod(
|
||||
"buildManuscriptResult", StringBuilder.class, String.class, String.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (Map<String, Object>) method.invoke(new AiController(), reply, taskId, workflowRunId);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 缺陷本体
|
||||
|
||||
@Test
|
||||
void 返回体必须带正文() throws Exception {
|
||||
StringBuilder reply = new StringBuilder("# 标题\n\n这是生成的正文。");
|
||||
|
||||
Map<String, Object> result = buildManuscriptResult(reply, "task-1", "run-1");
|
||||
|
||||
assertEquals("# 标题\n\n这是生成的正文。", result.get("answer"),
|
||||
"正文必须出现在 data.answer,否则前端会回落到 message(状态文案)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 返回体不得只含状态文案() throws Exception {
|
||||
StringBuilder reply = new StringBuilder("正文内容");
|
||||
|
||||
Map<String, Object> result = buildManuscriptResult(reply, null, null);
|
||||
|
||||
assertFalse("Stream processing completed".equals(result.get("answer")),
|
||||
"data.answer 不能是状态文案——那正是缺陷现象");
|
||||
assertEquals("正文内容", result.get("answer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 返回体始终含answer键_即使正文为空() throws Exception {
|
||||
// 空正文也要给 answer:"",保证 data 非空(否则仍会回落到 message)
|
||||
Map<String, Object> result = buildManuscriptResult(new StringBuilder(), null, null);
|
||||
|
||||
assertTrue(result.containsKey("answer"), "answer 键必须存在,data 才不会被 Jackson 省略");
|
||||
assertEquals("", result.get("answer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reply为null_不抛异常且给空正文() throws Exception {
|
||||
Map<String, Object> result = buildManuscriptResult(null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.get("answer"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 附带字段
|
||||
|
||||
@Test
|
||||
void 有taskId与runId时_一并返回() throws Exception {
|
||||
Map<String, Object> result = buildManuscriptResult(new StringBuilder("正文"), "task-1", "run-1");
|
||||
|
||||
assertEquals("task-1", result.get("taskId"));
|
||||
assertEquals("run-1", result.get("workflowRunId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空taskId与runId_不出现在返回体() throws Exception {
|
||||
// 前端有 `typeof result.taskId === "string"` 的消费点,空串反而会写进状态
|
||||
Map<String, Object> result = buildManuscriptResult(new StringBuilder("正文"), null, " ");
|
||||
|
||||
assertNull(result.get("taskId"));
|
||||
assertNull(result.get("workflowRunId"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* issue 06 的回归:Dify 的**内部标识符不得被当作正文**。
|
||||
*
|
||||
* <p>缺陷回顾:{@code workflow_finished} 分支用 {@code extractAnswer} 的结果当正文追加,
|
||||
* 而 {@code extractAnswer} 的兜底经 {@code findTextValue} 会取到 {@code data.id}
|
||||
* (workflow_run_id / 节点 id),于是稿件正文末尾恒挂一个裸 UUID。
|
||||
* 该缺陷发生在**成功路径**上,此前的失败态测试({@link AiWorkflowFailureStatusTest})
|
||||
* 覆盖不到,故此文件单独锁住。
|
||||
*
|
||||
* <p>这些测试直接调用 private 方法(与既有测试同一手法),因为它们正是缺陷所在层:
|
||||
* 换用 MockMvc 端到端跑反而需要真实 Dify 连接。
|
||||
*/
|
||||
class AiManuscriptUuidLeakTest {
|
||||
|
||||
/**
|
||||
* 真实的 workflow_finished 事件形状(取自内网实例实测):
|
||||
* 顶层有 workflow_run_id,data.id 也是同一个 UUID,正文在 outputs.generated_content。
|
||||
*/
|
||||
private static final String WORKFLOW_FINISHED_REAL = "{"
|
||||
+ "\"event\":\"workflow_finished\","
|
||||
+ "\"task_id\":\"f5bc2921-92f1-4875-8967-f15d3ecd485e\","
|
||||
+ "\"workflow_run_id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\","
|
||||
+ "\"data\":{"
|
||||
+ "\"id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\","
|
||||
+ "\"status\":\"succeeded\","
|
||||
+ "\"outputs\":{\"generated_content\":\"# 标题\\n\\n正文内容。\"},"
|
||||
+ "\"files\":[]"
|
||||
+ "}"
|
||||
+ "}";
|
||||
|
||||
/** node_started 的形状:data 里没有正文,只有节点 id 等内部字段。 */
|
||||
private static final String NODE_STARTED_REAL = "{"
|
||||
+ "\"event\":\"node_started\","
|
||||
+ "\"workflow_run_id\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\","
|
||||
+ "\"data\":{"
|
||||
+ "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\","
|
||||
+ "\"title\":\"LLM\","
|
||||
+ "\"node_type\":\"llm\","
|
||||
+ "\"index\":3"
|
||||
+ "}"
|
||||
+ "}";
|
||||
|
||||
private Map<String, Object> parse(String json) {
|
||||
Map<String, Object> map = JSONUtil.parseObject(json, Map.class);
|
||||
assertNotNull(map, "测试样本必须是合法 JSON");
|
||||
return map;
|
||||
}
|
||||
|
||||
private Object invoke(String name, Class<?>[] types, Object... args) throws Exception {
|
||||
Method method = AiController.class.getDeclaredMethod(name, types);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return method.invoke(new AiController(), args);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDisplayableText(Object value) throws Exception {
|
||||
return (Boolean) invoke("isDisplayableText", new Class<?>[]{Object.class}, value);
|
||||
}
|
||||
|
||||
private String extractAnswer(Map<String, Object> data) throws Exception {
|
||||
return (String) invoke("extractAnswer", new Class<?>[]{Map.class}, data);
|
||||
}
|
||||
|
||||
private String extractWorkflowOutputs(Map<String, Object> data) throws Exception {
|
||||
return (String) invoke("extractWorkflowOutputs", new Class<?>[]{Map.class}, data);
|
||||
}
|
||||
|
||||
private String findTextValue(Map<String, Object> source) throws Exception {
|
||||
return (String) invoke("findTextValue", new Class<?>[]{Map.class}, source);
|
||||
}
|
||||
|
||||
private String buildLoadingText(String event, Map<String, Object> data) throws Exception {
|
||||
return (String) invoke("buildLoadingText", new Class<?>[]{String.class, Map.class}, event, data);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 缺陷本体:UUID 不得被当正文
|
||||
|
||||
@Test
|
||||
void 真实workflow_finished事件_取不到UUID当正文() throws Exception {
|
||||
Map<String, Object> event = parse(WORKFLOW_FINISHED_REAL);
|
||||
|
||||
// 修复前:兜底进 findTextValue(data) 会返回 data.id 那个 UUID。
|
||||
// 修复后:要么取不到,要么取到 outputs 里的真正文——绝不能是 UUID。
|
||||
String answer = extractAnswer(event);
|
||||
|
||||
assertFalse("73ba38bf-dd1a-4f05-bf8f-e5c0603401df".equals(answer),
|
||||
"workflow_finished 的 data.id 是 workflow_run_id,绝不能被当成 answer");
|
||||
assertFalse(answer != null && answer.contains("73ba38bf"),
|
||||
"answer 中不得出现 workflow_run_id");
|
||||
assertEquals("# 标题\n\n正文内容。", answer,
|
||||
"既然 outputs 里有正文,就该取正文而不是放弃");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 真实node_started事件_取不到节点id当正文() throws Exception {
|
||||
assertNull(extractAnswer(parse(NODE_STARTED_REAL)),
|
||||
"node_started 的 data.id 是节点 id,绝不能被当成 answer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findTextValue_对内部标识符不再兜底() throws Exception {
|
||||
// 这是放大器本身:唯一 String 值是 UUID 时,修复前会被当成正文返回。
|
||||
Map<String, Object> onlyUuid = new LinkedHashMap<>();
|
||||
onlyUuid.put("id", "73ba38bf-dd1a-4f05-bf8f-e5c0603401df");
|
||||
onlyUuid.put("files", new java.util.ArrayList<>());
|
||||
|
||||
assertNull(findTextValue(onlyUuid),
|
||||
"「取第一个 String 值」必须过可展示性校验,否则会把 data.id 当正文");
|
||||
|
||||
Map<String, Object> onlyToken = new LinkedHashMap<>();
|
||||
onlyToken.put("req_id", "req_0123456789abcdef0123456789abcdef");
|
||||
assertNull(findTextValue(onlyToken));
|
||||
}
|
||||
|
||||
@Test
|
||||
void findTextValue_未知输出变量名仍能取到正文() throws Exception {
|
||||
// outputs 的变量名由工作流编排决定,无法穷举,故保留通用扫描
|
||||
Map<String, Object> outputs = new LinkedHashMap<>();
|
||||
outputs.put("some_future_output_name", "工作流产出的正文");
|
||||
|
||||
assertEquals("工作流产出的正文", findTextValue(outputs),
|
||||
"outputs 里的未知变量名仍应能取到");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findTextValue_已知键名优先() throws Exception {
|
||||
Map<String, Object> outputs = new LinkedHashMap<>();
|
||||
outputs.put("noise", "不该选中的噪声");
|
||||
outputs.put("generated_content", "# 真正的正文");
|
||||
|
||||
assertEquals("# 真正的正文", findTextValue(outputs), "已知正文键名优先于通用扫描");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 收尾事件_outputs兜底取到的是正文而非id() throws Exception {
|
||||
Map<String, Object> event = parse(WORKFLOW_FINISHED_REAL);
|
||||
|
||||
assertEquals("# 标题\n\n正文内容。", extractWorkflowOutputs(event),
|
||||
"outputs.generated_content 才是正文");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 可展示性判定
|
||||
|
||||
@Test
|
||||
void UUID形状_判为不可展示() throws Exception {
|
||||
assertFalse(isDisplayableText("73ba38bf-dd1a-4f05-bf8f-e5c0603401df"));
|
||||
assertFalse(isDisplayableText("468b9a16-f18f-475b-a467-59b1836c9f88"));
|
||||
assertFalse(isDisplayableText(" 73ba38bf-dd1a-4f05-bf8f-e5c0603401df "),
|
||||
"前后空白不应绕过判定");
|
||||
assertFalse(isDisplayableText("73BA38BF-DD1A-4F05-BF8F-E5C0603401DF"),
|
||||
"大写 UUID 同样是内部标识符");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 无空格长token_判为不可展示() throws Exception {
|
||||
assertFalse(isDisplayableText("abcdefghijklmnopqrstuvwxyz0123456789"));
|
||||
assertFalse(isDisplayableText("req_0123456789abcdef0123456789abcdef"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 正文_判为可展示() throws Exception {
|
||||
assertTrue(isDisplayableText("# 青春启航 筑梦新程\n\n正文内容。"));
|
||||
assertTrue(isDisplayableText("短句。"));
|
||||
// 含空格的长串是正常正文(英文段落),不能误杀
|
||||
assertTrue(isDisplayableText("This is a normal English sentence with spaces."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 非字符串与空值_判为不可展示() throws Exception {
|
||||
assertFalse(isDisplayableText(null));
|
||||
assertFalse(isDisplayableText(""));
|
||||
assertFalse(isDisplayableText(" "));
|
||||
assertFalse(isDisplayableText(123));
|
||||
assertFalse(isDisplayableText(new LinkedHashMap<>()));
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 加载中提示(issue 06 的次生面)
|
||||
|
||||
/**
|
||||
* 加载中气泡只能带**短提示**。
|
||||
*
|
||||
* <p>这条是实施时踩到的坑:把 loading 分支改成「过可展示性校验就发」之后,
|
||||
* {@code node_finished} 的 outputs 里有整篇稿件,于是 11KB 正文被当 loading 帧
|
||||
* 又推了一遍。修法是只取节点标题,绝不回退到 extractAnswer。
|
||||
*/
|
||||
@Test
|
||||
void 加载中提示_取节点标题而非整篇正文() throws Exception {
|
||||
String json = "{\"event\":\"node_finished\",\"data\":{"
|
||||
+ "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\","
|
||||
+ "\"title\":\"LLM\","
|
||||
+ "\"status\":\"succeeded\","
|
||||
+ "\"outputs\":{\"generated_content\":\"这里是很长很长的整篇稿件正文…\"}"
|
||||
+ "}}";
|
||||
|
||||
assertEquals("LLM", buildLoadingText("node_finished", parse(json)),
|
||||
"加载提示应取节点标题,而不是 outputs 里的整篇正文");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 加载中提示_取不到标题时不吐内部标识符() throws Exception {
|
||||
// 无 title,只有 data.id:不能把 UUID 当提示
|
||||
String json = "{\"event\":\"node_started\",\"data\":{"
|
||||
+ "\"id\":\"468b9a16-f18f-475b-a467-59b1836c9f88\"}}";
|
||||
|
||||
String text = buildLoadingText("node_started", parse(json));
|
||||
|
||||
assertFalse("468b9a16-f18f-475b-a467-59b1836c9f88".equals(text),
|
||||
"取不到标题时应退到事件名,而不是节点 id");
|
||||
assertEquals("node_started", text);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 加载中提示_agent_thought的thought优先可读() throws Exception {
|
||||
String json = "{\"event\":\"agent_thought\",\"thought\":\"正在梳理提纲\"}";
|
||||
|
||||
assertEquals("正在梳理提纲", buildLoadingText("agent_thought", parse(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 加载中提示_超长的thought弃用并退到事件名() throws Exception {
|
||||
// agent_thought 的 thought 是思维链,实测可达数 KB。
|
||||
// 整个塞进 loading 气泡等于把思维链当提示推给端上。
|
||||
String longThought = repeat("让我先分析一下用户的需求,", 200);
|
||||
String json = "{\"event\":\"agent_thought\",\"thought\":\"" + longThought + "\"}";
|
||||
|
||||
String text = buildLoadingText("agent_thought", parse(json));
|
||||
|
||||
assertEquals("agent_thought", text,
|
||||
"超长 thought 不得作为 loading 气泡,应退到事件名");
|
||||
assertFalse(text != null && text.contains("让我先分析"),
|
||||
"loading 气泡不得携带思维链");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 加载中提示_超长的title也弃用() throws Exception {
|
||||
String longTitle = repeat("标题很长", 100);
|
||||
String json = "{\"event\":\"node_started\",\"data\":{\"title\":\"" + longTitle + "\"}}";
|
||||
|
||||
assertEquals("node_started", buildLoadingText("node_started", parse(json)));
|
||||
}
|
||||
|
||||
// ---------------------------------- 收尾事件:/chat-messages 的 message_end 形状
|
||||
|
||||
/**
|
||||
* {@code /chat-messages} 的真实 message_end 形状:**没有** answer 字段,
|
||||
* 正文早已由前面的 {@code message} 事件累积。大纲链路
|
||||
* ({@code activityOutlineGen})的收尾分支也用 {@code extractAnswer},
|
||||
* 这里锁定它在这种事件上取不到东西,不会凭空追加内容。
|
||||
*/
|
||||
private static final String CHAT_MESSAGE_END_REAL = "{"
|
||||
+ "\"event\":\"message_end\","
|
||||
+ "\"task_id\":\"f5bc2921-92f1-4875-8967-f15d3ecd485e\","
|
||||
+ "\"message_id\":\"8f3a1c22-1111-4222-8333-abcdefabcdef\","
|
||||
+ "\"conversation_id\":\"c1d2e3f4-5555-4666-8777-1234567890ab\","
|
||||
+ "\"metadata\":{\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20}},"
|
||||
+ "\"usage\":{\"total_tokens\":30}"
|
||||
+ "}";
|
||||
|
||||
@Test
|
||||
void 真实message_end事件_取不到任何内容() throws Exception {
|
||||
Map<String, Object> event = parse(CHAT_MESSAGE_END_REAL);
|
||||
|
||||
assertNull(extractAnswer(event),
|
||||
"message_end 不带正文,不应被取出来追加(否则会重复或混入元数据)");
|
||||
assertNull(extractWorkflowOutputs(event),
|
||||
"message_end 没有 outputs,兜底也应为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
void message_end里混入UUID形态的answer_同样被拒() throws Exception {
|
||||
// 防御性用例:即便某个版本在 message_end 里塞了 answer,
|
||||
// 只要它是内部标识符,就不得被当正文下发给端上。
|
||||
String json = "{\"event\":\"message_end\",\"conversation_id\":"
|
||||
+ "\"c1d2e3f4-5555-4666-8777-1234567890ab\","
|
||||
+ "\"answer\":\"73ba38bf-dd1a-4f05-bf8f-e5c0603401df\"}";
|
||||
|
||||
assertNull(extractAnswer(parse(json)),
|
||||
"UUID 形态的 answer 必须被 isDisplayableText 挡掉");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 加载中提示_绝不返回整篇正文() throws Exception {
|
||||
String longBody = "# 标题\n\n" + repeat("这是一段足够长的正文内容。", 40);
|
||||
String json = "{\"event\":\"node_finished\",\"data\":{\"outputs\":"
|
||||
+ "{\"generated_content\":\"" + longBody.replace("\n", "\\n") + "\"}}}";
|
||||
|
||||
String text = buildLoadingText("node_finished", parse(json));
|
||||
|
||||
assertFalse(text != null && text.length() > 200,
|
||||
"loading 帧不得携带整篇正文,否则等于把正文重复推送一遍");
|
||||
assertEquals("node_finished", text, "无 title 时退到事件名");
|
||||
}
|
||||
|
||||
private static String repeat(String unit, int times) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < times; i++) {
|
||||
sb.append(unit);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- 防过度收敛(正文不能被误杀)
|
||||
|
||||
@Test
|
||||
void 正文事件_仍正常取到chunk() throws Exception {
|
||||
String json = "{\"event\":\"text_chunk\",\"data\":{\"text\":\"正文片段\"}}";
|
||||
assertEquals("正文片段", extractAnswer(parse(json)), "text_chunk 的正文必须照常取到");
|
||||
}
|
||||
|
||||
@Test
|
||||
void agent_thought的thought_仍可取到() throws Exception {
|
||||
String json = "{\"event\":\"agent_thought\",\"thought\":\"正在思考中\"}";
|
||||
assertEquals("正在思考中", extractAnswer(parse(json)),
|
||||
"thought 是面向用户的加载提示,不应被 UUID 过滤误伤");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractWorkflowOutputs_嵌套data形状不回归() throws Exception {
|
||||
String json = "{\"data\":{\"outputs\":{\"answer\":\"嵌套正文\"}}}";
|
||||
assertEquals("嵌套正文", extractWorkflowOutputs(parse(json)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user