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:
@@ -33,6 +33,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -45,6 +46,8 @@ import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Api(tags = "AI")
|
||||
@RestController
|
||||
@@ -61,6 +64,48 @@ public class AiController extends BaseController {
|
||||
*/
|
||||
private static final String CREATIVE_FAILED_MESSAGE = "生成失败,请稍后重试";
|
||||
|
||||
/**
|
||||
* 内部标识符形状:纯 UUID({@code workflow_run_id} / 节点 id 等)。
|
||||
* 与前端 {@code ai-result.js} 的 {@code isDisplayableAnswer} 同一口径。
|
||||
*/
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}");
|
||||
|
||||
/**
|
||||
* 无空格的长 token({@code task_id} / {@code req_id} 等)。
|
||||
*/
|
||||
private static final Pattern LONG_TOKEN_PATTERN = Pattern.compile("[0-9a-zA-Z_-]{32,}");
|
||||
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
|
||||
|
||||
/**
|
||||
* loading 气泡文案的长度上限。
|
||||
*
|
||||
* <p>「加载中」气泡位置只够显示一句短提示;{@code agent_thought} 的思维链
|
||||
* 实测可达数 KB,整段塞进去等于把思维链推给端上,故超限即弃用。
|
||||
*/
|
||||
private static final int LOADING_TEXT_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* 从「活动资金」的自由文本里抽金额。
|
||||
*
|
||||
* <p>两个捕获组:数值(容忍千分位逗号与小数)+ 可选的中文倍率单位。
|
||||
* 单位前用 {@code \s*} 容忍「1 万元」这类写法 —— 只认紧邻的「万」会把 1 万元抽成 1 元。
|
||||
*/
|
||||
private static final Pattern EVENT_FUNDING_AMOUNT_PATTERN =
|
||||
Pattern.compile("(\\d[\\d,]*(?:\\.\\d+)?)\\s*([亿万千百]?)");
|
||||
|
||||
/**
|
||||
* 日期/时间表达式,用于在抽金额前剔除。
|
||||
*
|
||||
* <p>{@code "2026年4月办活动,预算5000元"} 里 2026 会被金额正则当成第一处数字,
|
||||
* 静默发出错误金额 —— 比留空更糟,故先整体剔除。
|
||||
*/
|
||||
private static final Pattern EVENT_FUNDING_DATE_PATTERN = Pattern.compile(
|
||||
"\\d[\\d,]*\\s*(?:年|月|日|号|时|点|分|秒)"
|
||||
+ "|\\d{4}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{1,2}"
|
||||
+ "|\\d{1,2}\\s*[-/.]\\s*\\d{1,2}\\s*[-/.]\\s*\\d{2,4}");
|
||||
|
||||
@Resource
|
||||
private WebSocketServer webSocketServer;
|
||||
@Resource
|
||||
@@ -288,7 +333,10 @@ public class AiController extends BaseController {
|
||||
connection.setReadTimeout(aiTimeout());
|
||||
|
||||
Map<String, Object> inputs = buildManuscriptInputs(message);
|
||||
Object files = normalizeFiles(message.getFiles());
|
||||
// 附件当前不转发给 Dify:该应用没有文件类输入变量(file_upload.enabled=false),
|
||||
// 上游也不认这个字段。原先这里调了 normalizeFiles 却从不使用其返回值,
|
||||
// 反而让人以为附件已生效(见 issue 07)。要真正支持附件需先在 Dify
|
||||
// 开始节点加文件变量,再按文件类变量的契约(transfer_method / upload_file_id)改造。
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("response_mode", "streaming");
|
||||
payload.put("user", getLoginUserId() == null ? "0" : getLoginUserId().toString());
|
||||
@@ -343,6 +391,8 @@ public class AiController extends BaseController {
|
||||
recordActiveAiTask(getLoginUserId(), "workflow", taskId, appKeys().getManuscript());
|
||||
}
|
||||
System.out.println(data);
|
||||
// 正文只认正文事件(text_chunk / message);其余事件的
|
||||
// extractAnswer 结果可能是 data.id 这类内部标识符,不得进 reply(见 issue 06)。
|
||||
String answer = extractAnswer(data);
|
||||
if (answer != null && ("text_chunk".equals(event) || "message".equals(event))) {
|
||||
reply.append(answer);
|
||||
@@ -355,13 +405,18 @@ public class AiController extends BaseController {
|
||||
|| "node_finished".equals(event) || "agent_log".equals(event)
|
||||
|| "agent_thought".equals(event)) {
|
||||
|
||||
String loadingText = StrUtil.blankToDefault(answer, event);
|
||||
HashMap<String, String> answerData = new HashMap<>();
|
||||
answerData.put("answer", loadingText);
|
||||
answerData.put("loading", "true");
|
||||
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
|
||||
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
|
||||
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
|
||||
// 这些事件只用于「加载中」提示,不下发正文。
|
||||
// 不能用 extractAnswer 兜底:它可能在 outputs 里取到**整篇**稿件,
|
||||
// 当成 loading 帧发出去等于把正文重复推送一遍(见 issue 06)。
|
||||
String loadingText = buildLoadingText(event, data);
|
||||
if (loadingText != null) {
|
||||
HashMap<String, String> answerData = new HashMap<>();
|
||||
answerData.put("answer", loadingText);
|
||||
answerData.put("loading", "true");
|
||||
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
|
||||
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)。
|
||||
@@ -390,13 +445,21 @@ public class AiController extends BaseController {
|
||||
clearActiveAiTask(getLoginUserId(), taskId);
|
||||
continue;
|
||||
}
|
||||
if (answer == null) {
|
||||
answer = extractWorkflowOutputs(data);
|
||||
// 收尾事件不产出正文:正文来自 text_chunk / message。
|
||||
// 这里只在流式正文缺失时用 outputs 兜底,且兜底值同样要过
|
||||
// 可展示性校验——否则会重演 issue 06(把 data.id 当正文)。
|
||||
String finalAnswer = answer;
|
||||
if (finalAnswer == null) {
|
||||
finalAnswer = extractWorkflowOutputs(data);
|
||||
}
|
||||
if (answer != null && (answer.length() == 0 || reply.indexOf(answer) < 0)) {
|
||||
reply.append(answer);
|
||||
if (isDisplayableText(finalAnswer)
|
||||
&& reply.indexOf(finalAnswer) < 0) {
|
||||
// 注:isDisplayableText 已排除空串,故无需再判 length()==0。
|
||||
// 反过来若把空串放进来,reply.indexOf("") 恒为 0 会误判"已存在"
|
||||
// 而短路掉正常内容,所以两道判断缺一不可。
|
||||
reply.append(finalAnswer);
|
||||
HashMap<String, String> answerData = new HashMap<>();
|
||||
answerData.put("answer", answer);
|
||||
answerData.put("answer", finalAnswer);
|
||||
answerData.put("taskId", StrUtil.blankToDefault(taskId, "0"));
|
||||
answerData.put("workflowRunId", StrUtil.blankToDefault(workflowRunId, "0"));
|
||||
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
|
||||
@@ -424,6 +487,11 @@ public class AiController extends BaseController {
|
||||
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(endData));
|
||||
clearActiveAiTask(getLoginUserId(), taskId);
|
||||
}
|
||||
// 必须把累积的正文放进 data 一起回:只回一句状态文案时,
|
||||
// 前端(uni-app 的 apiRequest 会回落 body.message)会把
|
||||
// "Stream processing completed" 当成正文显示(见 issue 08)。
|
||||
return success("Stream processing completed",
|
||||
buildManuscriptResult(reply, taskId, workflowRunId));
|
||||
} catch (Exception e) {
|
||||
System.out.println(e.getMessage());
|
||||
for (StackTraceElement stackTraceElement : e.getStackTrace()) {
|
||||
@@ -435,7 +503,29 @@ public class AiController extends BaseController {
|
||||
webSocketServer.sendMessage(getLoginUserId().toString(), JSONUtil.toJSONString(answerData));
|
||||
return fail("出错了,请晚点再试~");
|
||||
}
|
||||
return success("Stream processing completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* 稿件生成的 HTTP 返回体。
|
||||
*
|
||||
* <p>本接口同时走 WebSocket 推流,但**不能**因此省略返回体:不是所有调用方
|
||||
* 都连了 WebSocket(如小程序的问答助手页在稿件模式下只发 HTTP),
|
||||
* 它们唯一的正文来源就是这里的 {@code answer}。
|
||||
*
|
||||
* <p>只回 {@code message}(如 "Stream processing completed")时,
|
||||
* 前端会把它当正文渲染——uni-app 的 {@code apiRequest} 在 {@code data}
|
||||
* 缺失时会回落 {@code body.message}(见 issue 08)。
|
||||
*/
|
||||
private Map<String, Object> buildManuscriptResult(StringBuilder reply, String taskId, String workflowRunId) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("answer", reply == null ? "" : reply.toString());
|
||||
if (StrUtil.isNotBlank(taskId)) {
|
||||
result.put("taskId", taskId);
|
||||
}
|
||||
if (StrUtil.isNotBlank(workflowRunId)) {
|
||||
result.put("workflowRunId", workflowRunId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@PostMapping("/creativeAssistant")
|
||||
@@ -647,17 +737,9 @@ public class AiController extends BaseController {
|
||||
if (message == null) {
|
||||
return fail("请求参数不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(message.getEventTheme())) {
|
||||
return fail("event_theme不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(message.getPeople())) {
|
||||
return fail("people不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(message.getEventTime())) {
|
||||
return fail("event_time不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(message.getEventFunding())) {
|
||||
return fail("event_funding不能为空");
|
||||
String validationError = validateActivityOutline(message);
|
||||
if (validationError != null) {
|
||||
return fail(validationError);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -777,7 +859,18 @@ public class AiController extends BaseController {
|
||||
if (!endSent) {
|
||||
sendActivityOutlineSocketEnd(conversationId, messageId);
|
||||
}
|
||||
return success("Stream processing completed");
|
||||
// 同 manuscriptGen:不能只回状态文案。activityOutlineGen 的调用方
|
||||
// (小程序「活动策划大纲」模式)同样只发 HTTP、不连 WebSocket,
|
||||
// 返回体是它唯一的正文来源(见 issue 08)。
|
||||
Map<String, Object> outlineResult = new LinkedHashMap<>();
|
||||
outlineResult.put("answer", reply.toString());
|
||||
if (StrUtil.isNotBlank(conversationId)) {
|
||||
outlineResult.put("conversationId", conversationId);
|
||||
}
|
||||
if (StrUtil.isNotBlank(messageId)) {
|
||||
outlineResult.put("messageId", messageId);
|
||||
}
|
||||
return success("Stream processing completed", outlineResult);
|
||||
} catch (Exception e) {
|
||||
logger.error("activity outline request failed", e);
|
||||
sendActivityOutlineSocketMessage("出错了,请晚点再试~", null, null, false);
|
||||
@@ -1020,35 +1113,188 @@ public class AiController extends BaseController {
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「活动策划大纲生成」的前置校验。
|
||||
*
|
||||
* <p>只校验 {@code event_theme} / {@code people} / {@code event_time} 三项 ——
|
||||
* 它们缺失时生成的大纲没有主题、受众、时间,对用户无意义。
|
||||
*
|
||||
* <p>刻意<b>不</b>校验另外三个上游必填项,因为"上游必填"不等于"用户必须填":
|
||||
* <ul>
|
||||
* <li>{@code event_member} / {@code tel_people}:两端前端都不采集,
|
||||
* 由 {@link #buildActivityOutlineInputs} 补空串占位。
|
||||
* 实测空串可让工作流 succeeded,加校验会把默认入参直接挡在门外。</li>
|
||||
* <li>{@code event_funding}:上游只要求"键存在且可解析为数值",
|
||||
* <b>空串是合法取值</b>(实测 {@code ""} → succeeded + 7268 条 message)。
|
||||
* 而问答助手页的资金是从用户自然语言里解析的,用户没提钱时就是空,
|
||||
* 此时大纲照样生成得出来,不该拦。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>由此得到必须守住的不变量:<b>上游声明的必填集合,其键必须全部下发</b>——
|
||||
* 本方法放行的任何入参都不得让 {@code buildActivityOutlineInputs} 漏键。
|
||||
* 改这里的校验集合时,务必同步 {@code AiActivityOutlineContractTest} 里的断言。
|
||||
*
|
||||
* @return 错误文案;通过校验时返回 {@code null}
|
||||
*/
|
||||
private String validateActivityOutline(ActivityOutlineInputs message) {
|
||||
if (StrUtil.isBlank(message.getEventTheme())) {
|
||||
return "event_theme不能为空";
|
||||
}
|
||||
if (StrUtil.isBlank(message.getPeople())) {
|
||||
return "people不能为空";
|
||||
}
|
||||
if (StrUtil.isBlank(message.getEventTime())) {
|
||||
return "event_time不能为空";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把大纲入参映射成 Dify 开始节点的 inputs(见 issue 09)。
|
||||
*
|
||||
* <p>与该实例上其它工作流的 remap 逻辑有一处<b>本质区别</b>:
|
||||
* Dify 的 {@code xxx is required in input form} 检查的是<b>键是否存在</b>,
|
||||
* 不是值是否非空 —— 实测对照:
|
||||
* <pre>
|
||||
* 缺 event_member 键 → failed: event_member is required in input form
|
||||
* "event_member": "" → succeeded(4752 条 message)
|
||||
* </pre>
|
||||
* 所以必填变量必须<b>无条件占位下发</b>,空值给空串;
|
||||
* 而 {@code event_type} / {@code event_location} / {@code event_meme}
|
||||
* 实测非必填(抽掉后照样 succeeded),保持「为空则不发」的旧行为。
|
||||
*
|
||||
* <p>{@code event_funding} 另有类型问题:上游是 number,见
|
||||
* {@link #normalizeEventFunding(String)}。
|
||||
*/
|
||||
private Map<String, Object> buildActivityOutlineInputs(ActivityOutlineInputs message) {
|
||||
Map<String, Object> inputs = new LinkedHashMap<>();
|
||||
if (message == null) {
|
||||
return inputs;
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getEventTheme())) {
|
||||
inputs.put("event_theme", message.getEventTheme());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getPeople())) {
|
||||
inputs.put("people", message.getPeople());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getEventTime())) {
|
||||
inputs.put("event_time", message.getEventTime());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getEventFunding())) {
|
||||
inputs.put("event_funding", message.getEventFunding());
|
||||
message = new ActivityOutlineInputs();
|
||||
}
|
||||
|
||||
// —— 上游实测必填的 6 个变量:键必须存在,空缺用空串占位 ——
|
||||
inputs.put("event_theme", blankToEmpty(message.getEventTheme()));
|
||||
inputs.put("people", blankToEmpty(message.getPeople()));
|
||||
inputs.put("event_time", blankToEmpty(message.getEventTime()));
|
||||
// number 类型:抽出数值,抽不出给空串(都是上游接受的形态)
|
||||
inputs.put("event_funding", normalizeEventFunding(message.getEventFunding()));
|
||||
// 两端前端都不采集这两个字段,因此空串是常态而非错误
|
||||
inputs.put("event_member", blankToEmpty(message.getEventMember()));
|
||||
inputs.put("tel_people", blankToEmpty(message.getTelPeople()));
|
||||
|
||||
// —— 上游实测非必填:为空不下发 ——
|
||||
if (StrUtil.isNotBlank(message.getEventType())) {
|
||||
inputs.put("event_type", message.getEventType());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getEventLocation())) {
|
||||
inputs.put("event_location", message.getEventLocation());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getPeopleManage())) {
|
||||
inputs.put("people_manage", message.getPeopleManage());
|
||||
}
|
||||
if (StrUtil.isNotBlank(message.getEventMeme())) {
|
||||
inputs.put("event_meme", message.getEventMeme());
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
private String blankToEmpty(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「活动资金」的<b>用户原文</b>归一化成 Dify 要的 number。
|
||||
*
|
||||
* <p>上游该变量声明为 {@code number},而两端前端的输入框是自由文本:
|
||||
* admin 的示例值是「预算 5000 元,来源为校团委志愿服务专项经费」,
|
||||
* 小程序问答助手页解析用户自然语言后默认填「待定」。实测取值判定:
|
||||
* <pre>
|
||||
* 5000 / "5000" / 5000.5 / 0 → succeeded(可解析的数值,上游会强制转换)
|
||||
* "" → succeeded(7268 条 message)
|
||||
* "待定" / "预算 5000 元" → failed: event_funding in input form must be a valid number
|
||||
* </pre>
|
||||
* 即「不可解析的文本必然让整个工作流失败,空串反而是安全的」,
|
||||
* 因此这里抽出金额;抽不出就降级为空串,<b>绝不原样下发</b>。
|
||||
*
|
||||
* <p>两条<b>必须先处理的坑</b>,否则会静默发出错误金额(比留空更糟):
|
||||
* <ol>
|
||||
* <li><b>日期</b>:{@code "2026年4月办活动,预算5000元"} 里的 2026 会被当成金额,
|
||||
* 故先用 {@link #EVENT_FUNDING_DATE_PATTERN} 剔除日期/时间表达式。</li>
|
||||
* <li><b>数字与单位之间可能有空格</b>:{@code "预算 1 万元"} 若只认紧邻的「万」,
|
||||
* 会抽出 1 元。故用 {@code \s*} 容忍空白。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>支持「万 / 亿 / 千 / 百」倍率,以及 {@code "3万5千"} 这类连续降序单位相加。
|
||||
* 只取<b>第一处</b>金额(相邻的单位片段相加),不会把「另有 3000 元物资」也累加进来。
|
||||
*
|
||||
* @return {@link java.math.BigDecimal}(序列化成不带引号的 JSON 数值)或空串
|
||||
*/
|
||||
private Object normalizeEventFunding(String raw) {
|
||||
if (StrUtil.isBlank(raw)) {
|
||||
return "";
|
||||
}
|
||||
// 先剔除日期/时间,避免把年份当金额
|
||||
String text = EVENT_FUNDING_DATE_PATTERN.matcher(raw).replaceAll(" ");
|
||||
Matcher matcher = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text);
|
||||
// 负号不在捕获组里,会被静默丢成正数:"-5000" -> 5000 是错的,直接降级
|
||||
if (!matcher.find() || isNegated(text, matcher.start())) {
|
||||
logger.info("activity outline event_funding 无法解析为数值,降级为空串: {}", raw);
|
||||
return "";
|
||||
}
|
||||
BigDecimal amount = toAmount(matcher.group(1), matcher.group(2));
|
||||
BigDecimal lastMultiplier = multiplierOf(matcher.group(2));
|
||||
int cursor = matcher.end();
|
||||
// 相邻的降序单位继续相加:"3万5千" -> 30000 + 5000
|
||||
while (true) {
|
||||
Matcher next = EVENT_FUNDING_AMOUNT_PATTERN.matcher(text);
|
||||
if (!next.find(cursor) || next.start() > cursor + 2) {
|
||||
break;
|
||||
}
|
||||
BigDecimal multiplier = multiplierOf(next.group(2));
|
||||
if (multiplier.compareTo(lastMultiplier) >= 0) {
|
||||
break;
|
||||
}
|
||||
amount = amount.add(toAmount(next.group(1), next.group(2)));
|
||||
lastMultiplier = multiplier;
|
||||
cursor = next.end();
|
||||
}
|
||||
return amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断数字前是否带负号(容忍中间的空格:「- 5000」)。
|
||||
*/
|
||||
private boolean isNegated(String text, int numberStart) {
|
||||
for (int i = numberStart - 1; i >= 0; i--) {
|
||||
char c = text.charAt(i);
|
||||
if (Character.isWhitespace(c)) {
|
||||
continue;
|
||||
}
|
||||
return c == '-';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private BigDecimal toAmount(String digits, String unit) {
|
||||
return new BigDecimal(digits.replace(",", "")).multiply(multiplierOf(unit));
|
||||
}
|
||||
|
||||
private BigDecimal multiplierOf(String unit) {
|
||||
if ("亿".equals(unit)) {
|
||||
return BigDecimal.valueOf(100000000L);
|
||||
}
|
||||
if ("万".equals(unit)) {
|
||||
return BigDecimal.valueOf(10000L);
|
||||
}
|
||||
if ("千".equals(unit)) {
|
||||
return BigDecimal.valueOf(1000L);
|
||||
}
|
||||
if ("百".equals(unit)) {
|
||||
return BigDecimal.valueOf(100L);
|
||||
}
|
||||
return BigDecimal.ONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把入参实体的业务字段转成 Dify 工作流的 inputs。
|
||||
*
|
||||
@@ -1575,13 +1821,16 @@ public class AiController extends BaseController {
|
||||
String[] keys = new String[]{"answer", "text", "chunk", "thought"};
|
||||
for (String key : keys) {
|
||||
Object value = data.get(key);
|
||||
if (value instanceof String) {
|
||||
if (isDisplayableText(value)) {
|
||||
return (String) value;
|
||||
}
|
||||
}
|
||||
Object dataObj = data.get("data");
|
||||
if (dataObj instanceof Map) {
|
||||
String nestedAnswer = findTextValue((Map) dataObj);
|
||||
// 注意用 findPreferredTextValue 而非 findTextValue:
|
||||
// data 里除了正文还有 id / status 等元数据,后者的通用扫描
|
||||
// 会把内部标识符或 "succeeded" 这类状态词当成正文(见 issue 06)。
|
||||
String nestedAnswer = findPreferredTextValue((Map) dataObj);
|
||||
if (nestedAnswer != null) {
|
||||
return nestedAnswer;
|
||||
}
|
||||
@@ -1589,6 +1838,36 @@ public class AiController extends BaseController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断一个值是否适合当作**正文**下发给端上。
|
||||
*
|
||||
* <p>Dify 事件的 {@code data.id} 是 workflow / 节点的内部标识符(UUID),
|
||||
* 而 {@code findTextValue} 的兜底会把它当成文本取出来;一旦被当正文追加,
|
||||
* 用户看到的稿件末尾就会挂一串 UUID(见 issue 06)。
|
||||
*
|
||||
* <p>与前端 {@code tuanwei-uniapp/utils/ai-result.js} 的
|
||||
* {@code isDisplayableAnswer} 同一口径:UUID 与无空格长 token 都不算可展示内容。
|
||||
* 前端那道防线只覆盖**失败**路径,成功路径必须由后端拦住。
|
||||
*/
|
||||
private boolean isDisplayableText(Object value) {
|
||||
if (!(value instanceof String)) {
|
||||
return false;
|
||||
}
|
||||
String text = ((String) value).trim();
|
||||
if (text.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 纯 UUID(workflow_run_id / 节点 id 等)
|
||||
if (UUID_PATTERN.matcher(text).matches()) {
|
||||
return false;
|
||||
}
|
||||
// 无空格的长 token(task_id / req_id 等)
|
||||
if (!WHITESPACE_PATTERN.matcher(text).find() && LONG_TOKEN_PATTERN.matcher(text).matches()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String extractWorkflowOutputs(Map data) {
|
||||
if (data == null) {
|
||||
return null;
|
||||
@@ -1608,19 +1887,84 @@ public class AiController extends BaseController {
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findTextValue(Map source) {
|
||||
/**
|
||||
* 为「加载中」事件生成提示文案。
|
||||
*
|
||||
* <p>只取**节点标题**这类天然短小、面向用户的字段(如「用户输入」「LLM」),
|
||||
* 不再回退到 {@code extractAnswer}——后者会在 outputs 里取到整篇稿件,
|
||||
* 当 loading 帧发出去就等于把正文又推了一遍(见 issue 06)。
|
||||
*
|
||||
* <p>{@code thought}(思维链)同样有风险:{@code agent_thought} 的 thought
|
||||
* 实测可达数 KB,整个塞进 loading 气泡等于把思维链当提示推给端上。
|
||||
* 故超过 {@link #LOADING_TEXT_MAX_LENGTH} 的一律弃用、退到事件名。
|
||||
*
|
||||
* @return 提示文案;无可展示内容时返回 {@code null}(调用方不下发)
|
||||
*/
|
||||
private String buildLoadingText(String event, Map data) {
|
||||
if (data != null) {
|
||||
Object dataObj = data.get("data");
|
||||
if (dataObj instanceof Map) {
|
||||
String title = stringValue(((Map) dataObj).get("title"));
|
||||
if (isDisplayableText(title) && title.length() <= LOADING_TEXT_MAX_LENGTH) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
String thought = stringValue(data.get("thought"));
|
||||
if (isDisplayableText(thought) && thought.length() <= LOADING_TEXT_MAX_LENGTH) {
|
||||
return thought;
|
||||
}
|
||||
}
|
||||
// 兜底用事件名(如 node_started),它是稳定的枚举而非内部标识符
|
||||
return StrUtil.isBlank(event) ? null : event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已知的正文键名里取值,并递归进嵌套对象。
|
||||
*
|
||||
* <p>用于 {@code data} 这类**混有元数据**的对象:只认正文键名,
|
||||
* 不做「任意 String 值」的通用扫描。
|
||||
*/
|
||||
private String findPreferredTextValue(Map source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String[] preferredKeys = new String[]{"answer", "content", "text", "result", "output", "article", "manuscript"};
|
||||
String[] preferredKeys = new String[]{"answer", "content", "text", "chunk", "result",
|
||||
"output", "article", "manuscript", "generated_content"};
|
||||
for (String key : preferredKeys) {
|
||||
Object value = source.get(key);
|
||||
if (value instanceof String) {
|
||||
if (isDisplayableText(value)) {
|
||||
return (String) value;
|
||||
}
|
||||
}
|
||||
for (Object value : source.values()) {
|
||||
if (value instanceof String) {
|
||||
if (value instanceof Map) {
|
||||
String nestedValue = findPreferredTextValue((Map) value);
|
||||
if (nestedValue != null) {
|
||||
return nestedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 outputs 这类**只装业务产物**的对象里取正文。
|
||||
*
|
||||
* <p>先按已知键名找,找不到再退一步取任意可展示的 String
|
||||
* (工作流的输出变量名由编排决定,无法穷举,如 {@code generated_content})。
|
||||
* 与 {@link #findPreferredTextValue} 的区别正在于这一层兜底 ——
|
||||
* 它对 outputs 安全,对混有 id/status 的 data 不安全。
|
||||
*/
|
||||
private String findTextValue(Map source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String preferred = findPreferredTextValue(source);
|
||||
if (preferred != null) {
|
||||
return preferred;
|
||||
}
|
||||
for (Object value : source.values()) {
|
||||
if (isDisplayableText(value)) {
|
||||
return (String) value;
|
||||
}
|
||||
if (value instanceof Map) {
|
||||
@@ -1630,6 +1974,8 @@ public class AiController extends BaseController {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 关键:这里的「任意 String 值」兜底**必须**过 isDisplayableText。
|
||||
// 修复前它无条件返回第一个 String,正是把 data.id(UUID)当正文的放大器(见 issue 06)。
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user