feat(ai): 自由模式转创意助手,失败日志补失败节点与上游 error(issue 17 / 18)

自由模式(issue 18):
- docType 为空时不调「内容创作与宣传助手」——它开始节点的 doc_type 是必填 select,
  空值直接被上游判 failed(error="doc_type is required in input form")。改为转
  「AI 创意助手」,只发它唯一的必填入参 user_content。
- ManuscriptGenInputs 增加 userContent;doc_type / Number 仍无条件带键(空串能过
  Dify 的必填校验,缺键不行)。
- 用例:AiManuscriptFreeModeRouteTest、AiManuscriptInputsContractTest。

失败日志(issue 17):
- 稿件流程、大纲流程、executeWorkflowForResult 三处失败日志补 status + failedNode +
  upstreamError + elapsed。失败信息拆在 node_finished 与 workflow_finished 两类事件,
  入参校验类失败只有后者,只取节点事件等于没取。
- 补 event=error 与「静默断流」(没有 workflow_finished / message_end)的日志,
  两者都带 taskId,可与请求对上。
- 日志级用 error 而非 warn:生产 com.gxwebsoft 只开 ERROR。
- 用例:AiWorkflowFailureDetailTest。
This commit is contained in:
2026-10-21 12:40:05 +08:00
parent 9e20014cd6
commit 1b61fe5586
5 changed files with 704 additions and 27 deletions
@@ -0,0 +1,85 @@
package com.gxwebsoft.ai.controller;
import com.gxwebsoft.ai.entity.ManuscriptGenInputs;
import com.gxwebsoft.common.core.web.ApiResult;
import org.junit.jupiter.api.Test;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 「未选文稿模板 = 自由模式,转创意助手」这条分流的回归(issue 18)。
*
* <p>依据:「内容创作与宣传助手」开始节点的 {@code doc_type} 是必填 select,
* 空值会被上游判 {@code doc_type is required in input form} 直接失败;
* 而「AI 创意助手」只有 {@code user_content} 一个必填入参。
* 所以 {@code docType} 为空时必须改走创意助手,而不是把空 doc_type 发给稿件应用。
*/
class AiManuscriptFreeModeRouteTest {
private String freeModeText(ManuscriptGenInputs message) throws Exception {
Method method = AiController.class.getDeclaredMethod("freeModeText",
ManuscriptGenInputs.class);
method.setAccessible(true);
try {
return (String) method.invoke(new AiController(), message);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
// ------------------------------------------------ 自由模式素材的来源
@Test
void 自由模式_优先用前端给的原话() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setUserContent("帮我写一段迎新晚会的主持人开场词");
message.setTheme("迎新晚会");
message.setHighlights("口语化一点");
assertEquals("帮我写一段迎新晚会的主持人开场词", freeModeText(message));
}
@Test
void 自由模式_老客户端没有原话时用主题与要求拼接() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setTheme("迎新晚会");
message.setHighlights("口语化一点");
assertEquals("迎新晚会。口语化一点", freeModeText(message));
}
@Test
void 自由模式_只有主题也能出素材() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setTheme("校运会");
assertEquals("校运会", freeModeText(message));
}
@Test
void 自由模式_什么都不填时没有素材() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setTheme(" ");
message.setHighlights("");
assertNull(freeModeText(message), "空白素材不该拼成一段空串下发给上游");
}
// ------------------------------------------------ 分流本身
@Test
void 未选模板且没有素材_直接返回可读错误且不打上游() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
// docType 为空 → 走自由模式分支;素材为空 → 在发起任何网络请求之前就返回。
ApiResult<?> result = new AiController().manuscriptGen(message);
assertEquals("请先填写要创作的内容", result.getMessage());
assertEquals(Integer.valueOf(com.gxwebsoft.common.core.Constants.RESULT_ERROR_CODE),
result.getCode(), "应是失败结果(code=1)");
}
}
@@ -0,0 +1,119 @@
package com.gxwebsoft.ai.controller;
import com.gxwebsoft.ai.entity.ManuscriptGenInputs;
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;
/**
* 稿件生成入参的契约回归(2026-10-19 契约修正)。
*
* <p>背景:界面上的「文体 / 字数」的<b>自动识别</b>指的是由上游 Dify 识别,
* 前端此时不下发 {@code doc_type} / {@code Number} 的值(见
* {@code writing-params.js} 头部的契约说明)。
*
* <p>于是后端必须守住一条不变量:<b>这两个键无条件带上下发</b>。
* 依据是 Dify 的必填校验只看「键是否存在」——
* 缺键会直接 {@code xxx is required in input form} 失败,而空串能过
* (实测对照见 {@code .scratch/dify-probe/README.md});对没声明必填的变量,
* 空串与不传等价(开始节点自己会补空)。
*
* <p>同时锁定既有行为不回归:其余字段仍然「空串不下发」。
*/
class AiManuscriptInputsContractTest {
@SuppressWarnings("unchecked")
private Map<String, Object> buildManuscriptInputs(ManuscriptGenInputs message) throws Exception {
Method method = AiController.class.getDeclaredMethod("buildManuscriptInputs",
ManuscriptGenInputs.class);
method.setAccessible(true);
try {
return (Map<String, Object>) method.invoke(new AiController(), message);
} catch (java.lang.reflect.InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
// ------------------------------------------------ 自动识别:带键、值留空
@Test
void 自动识别_两个主控变量仍带键下发空串() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setTheme("迎新晚会");
message.setHighlights("帮我写篇推文");
Map<String, Object> inputs = buildManuscriptInputs(message);
assertTrue(inputs.containsKey("doc_type"), "缺 doc_type 键会被上游判为必填失败");
assertEquals("", inputs.get("doc_type"), "自动识别时不该由我们猜一个文体");
assertTrue(inputs.containsKey("Number"), "缺 Number 键会被上游判为必填失败");
assertEquals("", inputs.get("Number"), "自动识别时不该由我们猜一个字数");
}
@Test
void 自动识别_空串与null等价_都带键() throws Exception {
ManuscriptGenInputs blank = new ManuscriptGenInputs();
blank.setDocType("");
blank.setNumber(null);
Map<String, Object> inputs = buildManuscriptInputs(blank);
assertEquals("", inputs.get("doc_type"));
assertEquals("", inputs.get("Number"));
}
// ------------------------------------------------ 显式选择:按值下发
@Test
void 显式选择_按值下发() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setDocType("微信推文");
message.setEventType("迎新");
message.setNumber(300);
Map<String, Object> inputs = buildManuscriptInputs(message);
assertEquals("微信推文", inputs.get("doc_type"));
assertEquals("迎新", inputs.get("event_type"));
assertEquals(300, inputs.get("Number"));
// 变量名大小写敏感:发 "number" 会因缺参报错
assertFalse(inputs.containsKey("number"), "必须按上游真实变量名 Number 下发");
}
// ------------------------------------------------ 其余字段:空串仍不下发(防回归)
@Test
void 其余字段空串_仍然不下发() throws Exception {
ManuscriptGenInputs message = new ManuscriptGenInputs();
message.setTheme("");
message.setTime("");
message.setLocation("");
message.setParticipants("");
message.setHighlights("");
Map<String, Object> inputs = buildManuscriptInputs(message);
assertFalse(inputs.containsKey("theme"), "空主题不该下发空串");
assertFalse(inputs.containsKey("time"));
assertFalse(inputs.containsKey("location"));
assertFalse(inputs.containsKey("participants"));
assertFalse(inputs.containsKey("highlights"));
// 只有两个主控变量是「无条件带键」的例外
assertTrue(inputs.containsKey("doc_type"));
assertTrue(inputs.containsKey("Number"));
}
@Test
void 空报文_返回空入参() throws Exception {
Map<String, Object> inputs = buildManuscriptInputs(null);
assertNotNull(inputs);
assertTrue(inputs.isEmpty(), "没有报文时不该编出任何入参");
}
}
@@ -0,0 +1,202 @@
package com.gxwebsoft.ai.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 工作流失败日志的「现场」契约(2026-10-21)。
*
* <p>背景:{@code manuscript workflow failed, taskId=..., workflowRunId=...} 这样
* 一行日志里没有任何可排查信息——不知道是哪个节点挂的,也不知道上游说了什么。
* 修法是 {@link AiController.WorkflowFailure}:把流里的失败事件收集起来,
* 在失败日志后追加 {@code failedNode} / {@code upstreamError} / {@code elapsed}。
*
* <p>本测试直接钉住 {@code describe()} 的输出形状,防止以后为了「少打点日志」
* 把节点名或错误原文又丢掉——那正是这次要修的问题。
*/
class AiWorkflowFailureDetailTest {
private Class<?> failureClass;
private Constructor<?> constructor;
@BeforeEach
void setUp() throws Exception {
failureClass = Class.forName("com.gxwebsoft.ai.controller.AiController$WorkflowFailure");
constructor = failureClass.getDeclaredConstructor(AiController.class);
constructor.setAccessible(true);
}
// ------------------------------------------------ 工具
/** 按「键, 值, 键, 值…」构造事件 Map,保持顺序便于阅读。 */
private static Map<String, Object> event(Object... pairs) {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i < pairs.length; i += 2) {
map.put((String) pairs[i], pairs[i + 1]);
}
return map;
}
private Object newFailure() throws Exception {
return constructor.newInstance(new AiController());
}
private void observe(Object failure, Map<String, Object> event) throws Exception {
Method method = failureClass.getDeclaredMethod("observe", Map.class);
method.setAccessible(true);
method.invoke(failure, event);
}
private boolean happened(Object failure) throws Exception {
Method method = failureClass.getDeclaredMethod("happened");
method.setAccessible(true);
return (Boolean) method.invoke(failure);
}
private String describe(Object failure) throws Exception {
Method method = failureClass.getDeclaredMethod("describe");
method.setAccessible(true);
return (String) method.invoke(failure);
}
// ------------------------------------------------ 只有 workflow_finished 失败
/**
* 复刻 2026-10-21 实际抓到的失败流(见 /tmp/manuscript-fail.txt):
* {@code inputs={}} 触发开始节点必填校验,全程没有 {@code node_finished} 事件,
* 失败信息只在 {@code workflow_finished.data.error} 里。
*
* <p>这条路径最容易被写成「只取节点事件」,那样这行日志依旧是空的。
*/
@Test
void 仅工作流级失败_也要打出上游error与耗时() throws Exception {
Object failure = newFailure();
observe(failure, event("event", "workflow_started"));
observe(failure, event(
"event", "workflow_finished",
"data", event(
"status", "failed",
"outputs", null,
"error", "doc_type is required in input form",
"elapsed_time", 0.0)));
assertTrue(happened(failure), "值得记日志的是 failed 收场");
String line = describe(failure);
assertTrue(line.startsWith("status=failed"), line);
assertTrue(line.contains("failedNode=<unknown>"), "没有节点事件时也要显式标出未知:" + line);
assertTrue(line.contains("upstreamError=doc_type is required in input form"), line);
assertTrue(line.contains("elapsed=0.0s"), "0.0 秒说明是入参校验就挂了,要看得出来:" + line);
}
// ------------------------------------------------ 节点级失败
/**
* 节点级失败:{@code node_finished} 带节点名与该节点的异常原文,
* {@code workflow_finished} 再补一句工作流级概括。两条原文都要留。
*/
@Test
void 节点级失败_带节点名与两层error() throws Exception {
Object failure = newFailure();
observe(failure, event(
"event", "node_finished",
"data", event(
"node_id", "1711522123456",
"title", "文档内容生成",
"status", "failed",
"error", "HTTP 400: invalid api key")));
observe(failure, event(
"event", "workflow_finished",
"data", event(
"status", "failed",
"error", "Run failed: 文档内容生成",
"elapsed_time", 3.42)));
String line = describe(failure);
assertTrue(line.contains("failedNode=文档内容生成(1711522123456)"),
"节点名与 node_id 都要有:名字给人看,id 用来对 Dify 日志:" + line);
assertTrue(line.contains("upstreamError=Run failed: 文档内容生成"), line);
assertTrue(line.contains("nodeError=HTTP 400: invalid api key"),
"节点级原文含 provider 报错体,丢了就排不到根因:" + line);
assertTrue(line.contains("elapsed=3.42s"), line);
}
// ------------------------------------------------ 不该有噪音
/** 成功收场不该产出任何内容,否则成功路径的日志里会冒出 failedNode=null。 */
@Test
void 成功收场_describe为空() throws Exception {
Object failure = newFailure();
observe(failure, event(
"event", "workflow_finished",
"data", event("status", "succeeded", "elapsed_time", 1.5)));
assertFalse(happened(failure));
assertTrue(describe(failure).isEmpty(), "成功路径不该打失败现场");
}
/** 没收到任何事件时也必须是空串,不能因为「没见过失败」就当成失败。 */
@Test
void 无事件_describe为空() throws Exception {
Object failure = newFailure();
assertFalse(happened(failure));
assertTrue(describe(failure).isEmpty());
}
/** 上游只给了 status=failed 却没带 error 字段时,要显式说明缺字段。 */
@Test
void 失败但无error字段_显式说明() throws Exception {
Object failure = newFailure();
observe(failure, event(
"event", "workflow_finished",
"data", event("status", "failed")));
String line = describe(failure);
assertTrue(line.contains("upstreamError=<事件未带 error 字段>"), line);
}
/**
* 上游 error 可能塞进整段 provider 报错(含换行),必须压成单行并截断,
* 否则一条日志能撑出几十行、把真正有用的上下文挤出日志文件。
*/
@Test
void 超长error_截断成单行() throws Exception {
Object failure = newFailure();
StringBuilder huge = new StringBuilder("b'{\"code\":50500");
while (huge.length() < 2000) {
huge.append(", \"message\": \"upstream exploded\"\\n");
}
observe(failure, event(
"event", "workflow_finished",
"data", event("status", "failed", "error", huge.toString())));
String line = describe(failure);
assertFalse(line.contains("\n"), "日志行不能带换行:" + line.substring(0, Math.min(120, line.length())));
assertTrue(line.contains("...(truncated)"), "超长原文必须截断:" + line.length());
assertTrue(line.length() < 900, "截断后仍应是单行短文本,实际 " + line.length());
}
@Test
void describe非空时必然带status前缀() throws Exception {
Object failure = newFailure();
observe(failure, event("event", "workflow_finished",
"data", event("status", "failed", "error", "boom")));
String line = describe(failure);
assertNotNull(line);
assertTrue(line.startsWith("status=failed, failedNode="), line);
}
}