Skip to content

Commit d230413

Browse files
zeweihanclaude
andauthored
fix(ai): 修复活跃文档兜底的三个缺陷——串名、坏 JSON、null 名 (#215)
复查 PR#209/#210 发现三个问题,均已修复并补测试: 1. **文档名会串(真缺陷)**:dispatchTool 底部跟踪逻辑在模型中途打开别的 文档时更新了 guard.activeFileId,却没动 guard.activeFileName。于是打开 文档 B 后名字仍是 A 的,后续短路反馈与列表加钉会输出「《A的名字》(id=B)」 ——等于主动喂给模型一条错误信息。现抽出 activeDocNameAfterOpen:切文档 即作废旧名(这一层拿不到新名),回退为通称。 2. **手拼 JSON 未转义**:短路返回值是字符串拼接的 JSON,文件名含半角引号或 反斜杠(macOS 合法字符)即产出坏 JSON。改为纯文本——doc_open_file 本身 返回的就是纯文本,格式反而更一致,且从根上消除该类问题。 3. **null 文档名漏给模型**:ContextAssemblerService 末位提醒直接插值 getName(),name 为空时输出「《null》」。两处各加 activeDocDisplayName 兜底,空名回退为「当前文档」。 另核查确认无问题(不改动): - 末位提醒不入库:持久化的是 request.getMessage() 原文,不会污染历史; - 短路跳过 applyToolSideEffects 安全:doc_open_file 无 @ToolMeta; - 短路传 tool 可能为 null 但两处消费点(:270 :607)均已 null 判断,无 NPE; - 短路对原生 function calling 与 XML 两条分发路径同时生效; - 检查点逻辑在短路之前执行,doc_open_file 非 MODIFIED 类,不受影响。 后端全量 318 测试通过(新增 5 个:切文档作废旧名/重开同一文档保留/此前无活跃 文档/引号反斜杠文件名/列表加钉空名回退)。 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 44aad27 commit d230413

3 files changed

Lines changed: 83 additions & 14 deletions

File tree

backend/src/main/java/com/checkba/service/ai/AgentOrchestrator.java

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,12 +201,31 @@ private ToolRegistry.ToolResult dispatchTool(String toolName, String argsJson,
201201
if (guard != null && "doc_open_file".equals(toolName)) {
202202
try {
203203
String fid = extractArg(argsJson, "fileId");
204-
if (fid != null && !fid.isEmpty()) guard.activeFileId = Long.parseLong(fid.trim());
204+
if (fid != null && !fid.isEmpty()) {
205+
Long opened = Long.parseLong(fid.trim());
206+
guard.activeFileName = activeDocNameAfterOpen(guard.activeFileId, guard.activeFileName, opened);
207+
guard.activeFileId = opened;
208+
}
205209
} catch (Exception ignore) { /* fileId 非数字时保持原值 */ }
206210
}
207211
return result;
208212
}
209213

214+
/**
215+
* 模型中途 doc_open_file 后,活跃文档名该保留还是作废。
216+
*
217+
* <p>切到别的文档时必须作废:否则后续短路反馈/列表加钉会拿**旧名配新 id**,等于主动喂给
218+
* 模型一条错误信息。这一层拿不到新文档名,置 null 即回退为通称「当前文档」。
219+
*/
220+
static String activeDocNameAfterOpen(Long previousId, String previousName, Long openedId) {
221+
return openedId != null && openedId.equals(previousId) ? previousName : null;
222+
}
223+
224+
/** 活跃文档的展示名:名字缺失(含模型中途切文档后作废的情况)时回退为通称,避免出现「《null》」。 */
225+
private static String activeDocDisplayName(String activeFileName) {
226+
return (activeFileName == null || activeFileName.isBlank()) ? "当前文档" : "《" + activeFileName + "》";
227+
}
228+
210229
/**
211230
* doc_open_file 打的就是当前已打开的活跃文档时,返回给模型的短路反馈;否则返回 null(正常执行)。
212231
*
@@ -224,10 +243,11 @@ static String activeDocOpenShortCircuit(Long activeFileId, String activeFileName
224243
} catch (NumberFormatException e) {
225244
return null;
226245
}
227-
String name = (activeFileName == null || activeFileName.isBlank()) ? "该文档" : "《" + activeFileName + "》";
228-
return "{\"status\":\"success\",\"alreadyOpen\":true,\"message\":\"" + name
229-
+ "(id=" + activeFileId + ")本来就在编辑器中打开着,无需打开。"
230-
+ "请直接调用 doc_* 工具对它操作,不要再调 doc_open_file 或 doc_list_project_files。\"}";
246+
// 返回纯文本而非手拼 JSON:doc_open_file 本身返回的就是纯文本,格式保持一致;
247+
// 手拼 JSON 遇到文件名里的引号/反斜杠(macOS 合法字符)会产出坏 JSON。
248+
return activeDocDisplayName(activeFileName) + "(id=" + activeFileId
249+
+ ")本来就在编辑器中打开着,无需打开,可直接进行后续操作。"
250+
+ "请直接调用 doc_* 工具对它操作,不要再调 doc_open_file 或 doc_list_project_files。";
231251
}
232252

233253
/**
@@ -237,9 +257,9 @@ static String appendActiveDocNotice(String listOutput, Long activeFileId, String
237257
if (activeFileId == null) {
238258
return listOutput;
239259
}
240-
String name = (activeFileName == null || activeFileName.isBlank()) ? "当前文档" : "《" + activeFileName + "》";
241260
return (listOutput == null ? "" : listOutput)
242-
+ "\n\n[系统提醒] 用户此刻打开的是 " + name + "(id=" + activeFileId + ")。"
261+
+ "\n\n[系统提醒] 用户此刻打开的是 " + activeDocDisplayName(activeFileName)
262+
+ "(id=" + activeFileId + ")。"
243263
+ "若本次任务针对的就是它,直接用 doc_* 工具操作即可,不要再调 doc_open_file。";
244264
}
245265

backend/src/main/java/com/checkba/service/ai/ContextAssemblerService.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,19 @@ public java.util.List<dev.langchain4j.data.message.ChatMessage> assemble(
375375
return messages;
376376
}
377377

378+
/** 活跃文档展示名:名字缺失时回退为通称,避免给模型看到「《null》」。 */
379+
private static String activeDocDisplayName(String name) {
380+
return (name == null || name.isBlank()) ? "当前文档" : "《" + name + "》";
381+
}
382+
378383
/**
379384
* 活跃文档的末位提醒(拼在用户消息尾部)。无活跃文档时返回空串。
380385
*/
381386
private String activeDocumentReminder(com.checkba.controller.ai.AiAgentController.ContextItem activeContext) {
382387
if (activeContext == null || activeContext.getId() == null || activeContext.getId().isEmpty()) {
383388
return "";
384389
}
385-
return "\n\n[系统提醒] 编辑器中当前已打开文档" + activeContext.getName() + "(id="
390+
return "\n\n[系统提醒] 编辑器中当前已打开文档" + activeDocDisplayName(activeContext.getName()) + "(id="
386391
+ activeContext.getId() + "),其正文见 system prompt 的 <active_document>。"
387392
+ "用户未指明别的文档时,「这个」「当前文档」「修订一下」等都指它——"
388393
+ "直接调用 doc_* 工具操作,**禁止**再调 doc_list_project_files 或 doc_open_file 去重新发现或打开它。";

backend/src/test/java/com/checkba/service/ai/AgentOrchestratorActiveDocTest.java

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,21 @@ void shortCircuitsOpeningTheAlreadyOpenDocument() {
2525

2626
assertNotNull(msg, "打自己应短路");
2727
assertTrue(msg.contains("合作框架协议.docx"), "反馈里应点名文档");
28-
assertTrue(msg.contains("alreadyOpen"), "应给出结构化标记供模型识别");
28+
assertTrue(msg.contains("本来就在编辑器中打开着"), "应说明无需打开");
2929
assertTrue(msg.contains("doc_list_project_files"), "应顺带堵住再去列文件的退路");
3030
}
3131

32+
@Test
33+
@DisplayName("文件名含引号/反斜杠也不产出坏结构——短路走纯文本,与工具自身返回格式一致")
34+
void shortCircuitSurvivesQuotesInFileName() {
35+
String msg = AgentOrchestrator.activeDocOpenShortCircuit(
36+
123L, "他说\"你好\"\\备份.docx", "123");
37+
38+
assertNotNull(msg);
39+
assertTrue(msg.contains("他说\"你好\"\\备份.docx"), "文件名应原样呈现,不被转义破坏");
40+
assertTrue(!msg.trim().startsWith("{"), "不应手拼 JSON——doc_open_file 本身返回纯文本");
41+
}
42+
3243
@Test
3344
@DisplayName("打开的是别的文档 → 不短路,跨文档场景必须照常执行")
3445
void doesNotShortCircuitOtherDocuments() {
@@ -51,13 +62,15 @@ void doesNotShortCircuitNonNumericId() {
5162
}
5263

5364
@Test
54-
@DisplayName("文档名缺失时短路反馈仍可用,不出现空书名号")
65+
@DisplayName("文档名缺失时短路反馈仍可用,不出现《null》或空书名号")
5566
void shortCircuitToleratesMissingName() {
56-
String msg = AgentOrchestrator.activeDocOpenShortCircuit(123L, null, "123");
67+
for (String noName : new String[]{null, "", " "}) {
68+
String msg = AgentOrchestrator.activeDocOpenShortCircuit(123L, noName, "123");
5769

58-
assertNotNull(msg);
59-
assertTrue(msg.contains("该文档"), "无名时应回退为通称");
60-
assertTrue(!msg.contains("《》"), "不应出现空书名号");
70+
assertNotNull(msg);
71+
assertTrue(msg.contains("当前文档"), "无名时应回退为通称");
72+
assertTrue(!msg.contains("《》") && !msg.contains("null"), "不应漏出 null 或空书名号");
73+
}
6174
}
6275

6376
// ==== doc_list_project_files 结果加钉 ====
@@ -81,4 +94,35 @@ void leavesListOutputUntouchedWithoutActiveDoc() {
8194
org.junit.jupiter.api.Assertions.assertEquals(
8295
raw, AgentOrchestrator.appendActiveDocNotice(raw, null, "x.docx"));
8396
}
97+
98+
@Test
99+
@DisplayName("列表加钉在文档名缺失时回退通称,不漏出 null")
100+
void listNoticeToleratesMissingName() {
101+
String out = AgentOrchestrator.appendActiveDocNotice("[]", 123L, null);
102+
103+
assertTrue(out.contains("当前文档"), "无名时应回退为通称");
104+
assertTrue(!out.contains("null"), "不应把 null 漏给模型");
105+
}
106+
107+
// ==== 模型中途切文档后的文档名归属 ====
108+
109+
@Test
110+
@DisplayName("模型打开别的文档 → 旧文档名必须作废,不能拿旧名配新 id")
111+
void invalidatesNameWhenModelOpensDifferentDocument() {
112+
assertNull(AgentOrchestrator.activeDocNameAfterOpen(123L, "合作框架协议.docx", 456L),
113+
"切文档后旧名必须作废,否则会喂给模型错误信息");
114+
}
115+
116+
@Test
117+
@DisplayName("模型重复打开同一文档 → 文档名保留")
118+
void keepsNameWhenReopeningSameDocument() {
119+
org.junit.jupiter.api.Assertions.assertEquals("合作框架协议.docx",
120+
AgentOrchestrator.activeDocNameAfterOpen(123L, "合作框架协议.docx", 123L));
121+
}
122+
123+
@Test
124+
@DisplayName("此前无活跃文档时打开任意文档 → 名字仍未知")
125+
void nameStaysUnknownWhenThereWasNoActiveDoc() {
126+
assertNull(AgentOrchestrator.activeDocNameAfterOpen(null, null, 456L));
127+
}
84128
}

0 commit comments

Comments
 (0)