Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .claude/agents/ai-doc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ description: AI↔文档编辑桥接领域。任务涉及 doc_*/sheet_*/slide_*
回归用例 `ParagraphIndexBaseTest`(反射扫 `@P`,写成 1 基即转红)。

**matchIndex 是唯一的例外:模型面 1 基、worker 0 基,在后端归一**(2026-09-02 顺带修掉)。`doc_replace_nth_match` / `doc_delete_match` 的描述、system prompt 第 7 节第 3 条、`ToolRegistry.LEGACY_DEFAULTS`(缺省 1)都对模型说「第 N 处,从 1 开始」,而 worker 的 `replace_nth_match` / `delete_match` 与它的其它整数定位一样从 `i = 0` 起数——原先后端原样透传,模型说第 1 处改的是第 2 处。归一落在 `DocumentEditTools` 下发前减 1,**不改 worker**:JAR/Web 插件经 `PluginHostImpl.DOC_ACTIONS` / `pluginDocActions.js` 直接按 worker 契约调这两个 action。回归 `MatchIndexBaseTest`(后端换算)+ lowa-e2e 组 11 末两步(worker 0 基钉住)。
**归一必须两个方向都做**(dev-board#369):worker 的 `find_text_locations` 返回的 `matchIndex` 同样 0 起,
`doc_find_text` 回给模型前在 `DocumentEditTools.oneBasedMatchIndexes` 加 1——否则模型从查找结果看到
「第二个匹配 matchIndex=1」照传给 `doc_replace_nth_match`,减一后改的是第一个。描述里「只计算用户可见的匹配」
不是 worker 过滤出来的,靠页边模式成立:删除型修订文本不在正文流、`XSearchable` 搜不到(e2e 组 11 实测
删掉第二处后再找只剩两处),关掉 ShowChangesInMargin 就不再成立。

同类:`doc_goto` 的描述曾宣告 `paragraph/bookmark/line` 三种定位,而 `office_thread.js` 的
`goto()` 只实现 `start/end`,其余一律返回 "goto type not supported yet"——
Expand Down Expand Up @@ -300,7 +305,7 @@ txt/md/markdown 自 dev-board#37 起不进 LOWA(前端走 PlainTextEditor.vue
## 验证

- worker 纯函数(`minimalEdits` 一族)改动后先跑秒级用例:`cd frontend && npm run test:lowa-unit`(`tests/lowa-unit/`,靠 `_workerFns.mjs` 从 `office_thread.js` 里按函数名抠出顶层纯函数在 node 里执行——被抠的函数不能碰 UNO 对象、字符串/注释里不能有花括号)。
- 编辑器三件套(原语/白名单/worker)改动后必跑:`cd frontend && npm run test:lowa-e2e`(基线随批次增长,2026-09-02 dev-board#365 组 11 补七步 + matchIndex 契约两步后为 475 步全绿;2026-08-08 slide_* Phase 3 落地后为 324 步,含组 21 Impress 冒烟 27 步 + 组 22 Impress 结构 44 步 + 组 23 Impress 格式与表格约 59 步)。**改 `office_thread.js` 后必须先 `npm run build:zetaoffice` 再跑 e2e**——测试服务器serve 的是 `frontend/dist/zetaoffice/office_thread.js`(构建产物),不是 `frontend/src/zetaoffice/public/office_thread.js`(源文件);只改源文件不重新构建,e2e 会静默测着旧代码(本次 Phase 2 debugging 踩过一次,白跑了好几轮"改代码→跑测试→结果一模一样"才发现)。跑之前先 `diff frontend/src/zetaoffice/public/office_thread.js frontend/dist/zetaoffice/office_thread.js` 确认两边一致。
- 编辑器三件套(原语/白名单/worker)改动后必跑:`cd frontend && npm run test:lowa-e2e`(基线随批次增长,2026-09-02 dev-board#369 组 11 再补 matchIndex 四步后为 479 步全绿;2026-09-02 dev-board#365 组 11 补七步 + matchIndex 契约两步后为 475 ;2026-08-08 slide_* Phase 3 落地后为 324 步,含组 21 Impress 冒烟 27 步 + 组 22 Impress 结构 44 步 + 组 23 Impress 格式与表格约 59 步)。**改 `office_thread.js` 后必须先 `npm run build:zetaoffice` 再跑 e2e**——测试服务器serve 的是 `frontend/dist/zetaoffice/office_thread.js`(构建产物),不是 `frontend/src/zetaoffice/public/office_thread.js`(源文件);只改源文件不重新构建,e2e 会静默测着旧代码(本次 Phase 2 debugging 踩过一次,白跑了好几轮"改代码→跑测试→结果一模一样"才发现)。跑之前先 `diff frontend/src/zetaoffice/public/office_thread.js frontend/dist/zetaoffice/office_thread.js` 确认两边一致。
- 全链路回归:`npm run test:app-e2e`;前端事件契约 `npm run check:emits`。
- 后端:`cd backend && mvn test`(JDK 21,默认 25 会 SIGBUS);EvalHarness 回放评测在其中。
- 原语级测试不够,必须走完 UI 链路验证(用户明确要求过)。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ public String doc_set_selection(

// ==================== 查找和替换 ====================

@Tool("【找】在文档中查找文本。每个匹配返回:anchorId(稳定锚点,编辑后依然有效)、前后文 contextBefore/contextAfter、所在段落 paragraph。" +
@Tool("【找】在文档中查找文本。每个匹配返回:matchIndex(序号,从 1 开始,可直接作为 doc_replace_nth_match / doc_delete_match 的 matchIndex)、anchorId(稳定锚点,编辑后依然有效)、前后文 contextBefore/contextAfter、所在段落 paragraph。" +
"有多个匹配时先根据上下文确认哪一个才是目标,再用 anchorId 直接 doc_replace_at_anchor(精准替换,会自动滚动定位并返回改后段落)。" +
"多处独立修改:拿到各自 anchorId 后在同一轮连续输出多个替换调用。目标文本全文唯一时不必先找,直接 doc_find_replace。")
public String doc_find_text(
Expand All @@ -317,14 +317,38 @@ public String doc_find_text(
log.info("Tool: doc_find_text called keyword={}", keyword);
try {
// Updated to call 'find_text_locations' which returns detailed positions
return editorBridgeService.executeEditorCommand("find_text_locations",
String raw = editorBridgeService.executeEditorCommand("find_text_locations",
java.util.Map.of("keyword", keyword, "matchCase", matchCase != null ? matchCase : false));
return oneBasedMatchIndexes(raw);
} catch (Exception e) {
log.error("Failed to find text", e);
return "Error: " + e.getMessage();
}
}

/**
* worker 的 find_text_locations 返回 matchIndex: i(0 起);模型面的 matchIndex 一律 1 基
* (doc_replace_nth_match / doc_delete_match 下发前减 1)。回给模型前加 1,模型从这里看到的
* 序号才能原样喂给 doc_replace_nth_match——否则「第二个匹配 matchIndex=1」传过去减一改的是第一个。
* 解析失败(error / 非 JSON)原样透传。
*/
static String oneBasedMatchIndexes(String raw) {
if (raw == null || !raw.contains("\"matchIndex\"")) return raw;
try {
com.fasterxml.jackson.databind.JsonNode root = EVIDENCE_JSON.readTree(raw);
com.fasterxml.jackson.databind.JsonNode matches = root.get("matches");
if (matches == null || !matches.isArray()) return raw;
for (com.fasterxml.jackson.databind.JsonNode m : matches) {
if (m instanceof com.fasterxml.jackson.databind.node.ObjectNode obj && obj.has("matchIndex") && obj.get("matchIndex").isInt()) {
obj.put("matchIndex", obj.get("matchIndex").asInt() + 1);
}
}
return EVIDENCE_JSON.writeValueAsString(root);
} catch (Exception e) {
return raw;
}
}

@ToolMeta(displayName = "查找替换", category = "document", fileEffect = "MODIFIED")
@Tool("在文档中查找并替换文本。所有修改将以修订模式进行,用户可以审阅后接受或拒绝。" +
"全文替换一次调用即可完成(大量命中也很快;纯插入型替换会分批处理并回传进度),不要因为耗时较长而重复调用;" +
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/prompts/system_prompt.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ You can directly edit documents in the user's project, like a human editor sitti

| Tool | Purpose |
|-----|------|
| `doc_find_text(keyword, matchCase)` | Find text. Each match returns an **anchorId** (stable anchor) + surrounding context + containing paragraph; with multiple matches, identify the target by context |
| `doc_find_text(keyword, matchCase)` | Find text. Each match returns an **anchorId** (stable anchor) + matchIndex (1-based, usable directly as the matchIndex of `doc_replace_nth_match`) + surrounding context + containing paragraph; with multiple matches, identify the target by context |

**Select (move cursor/selection - visible to the user)**

Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/prompts/system_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ for file_id in file_ids:

| 工具 | 用途 |
|-----|------|
| `doc_find_text(keyword, matchCase)` | 查找文本。每个匹配返回 **anchorId**(稳定锚点)+ 前后文 + 所在段落,多个匹配时靠上下文分辨目标 |
| `doc_find_text(keyword, matchCase)` | 查找文本。每个匹配返回 **anchorId**(稳定锚点)+ matchIndex(序号,从 1 开始,可直接喂 `doc_replace_nth_match`)+ 前后文 + 所在段落,多个匹配时靠上下文分辨目标 |

**选(移动光标/选区,用户可见)**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import dev.langchain4j.agent.tool.Tool;
import org.mockito.Mockito;

import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
Expand Down Expand Up @@ -87,4 +92,74 @@ void invalidMatchIndexIsRejectedBeforeDispatch() {
assertTrue(tools.doc_delete_match("a", 0).startsWith("Error"));
verify(bridge, never()).executeEditorCommand(anyString(), any());
}

// ---- doc_find_text 返回给模型的 matchIndex 也要归一成 1 基(dev-board#369)----
// worker 的 find_text_locations 返回 matchIndex: i(0 起)。后端只在下发时减一、不在返回时加一,
// 就留下第二条差一路:模型从 doc_find_text 看到「第二个匹配 matchIndex=1」,照着传给
// doc_replace_nth_match,减一后改的是第一个。归一必须两个方向都做。

private static String toolDescription(String name) {
for (Method m : DocumentEditTools.class.getDeclaredMethods()) {
if (!m.getName().equals(name)) continue;
Tool t = m.getAnnotation(Tool.class);
assertNotNull(t, name + " 应当是 @Tool");
return String.join("", t.value());
}
throw new AssertionError("DocumentEditTools 里没有工具 " + name);
}

@Test
@DisplayName("doc_find_text:worker 的 0 基 matchIndex 回到模型前加 1,与 doc_replace_nth_match 同口径")
void findTextRemapsMatchIndexToOneBased() {
EditorBridgeService bridge = Mockito.mock(EditorBridgeService.class);
Mockito.when(bridge.executeEditorCommand(eq("find_text_locations"), any())).thenReturn(
"{\"success\":true,\"count\":2,\"matches\":["
+ "{\"matchIndex\":0,\"anchorId\":\"__ai_anchor_1\",\"text\":\"甲方\"},"
+ "{\"matchIndex\":1,\"anchorId\":\"__ai_anchor_2\",\"text\":\"甲方\"}]}");
DocumentEditTools tools = toolsWithBridge(bridge);

String out = tools.doc_find_text("甲方", false);

assertTrue(out.contains("\"matchIndex\":1") && out.contains("\"matchIndex\":2"),
"matches 里的 matchIndex 应当是 1、2,实际是:" + out);
assertTrue(!out.contains("\"matchIndex\":0"), "不许再出现 0 基序号,实际是:" + out);
assertTrue(out.contains("__ai_anchor_2") && out.contains("\"count\":2"), "其余字段原样保留,实际是:" + out);
}

@Test
@DisplayName("doc_find_text:错误 / 非 JSON 结果原样透传,不因归一而吞掉")
void findTextPassesThroughErrorsUntouched() {
EditorBridgeService bridge = Mockito.mock(EditorBridgeService.class);
Mockito.when(bridge.executeEditorCommand(eq("find_text_locations"), any()))
.thenReturn("{\"error\": \"No active conversation. Please ensure a document is open.\"}")
.thenReturn("not json at all");
DocumentEditTools tools = toolsWithBridge(bridge);

assertEquals("{\"error\": \"No active conversation. Please ensure a document is open.\"}", tools.doc_find_text("x", false));
assertEquals("not json at all", tools.doc_find_text("x", false));
}

@Test
@DisplayName("doc_find_text 的描述点明返回的 matchIndex 从 1 开始、可直接喂 doc_replace_nth_match")
void findTextDescriptionStatesReturnedMatchIndexIsOneBased() {
String d = toolDescription("doc_find_text");
assertTrue(d.contains("matchIndex") && d.contains("从 1 开始") && d.contains("doc_replace_nth_match"),
"doc_find_text 描述要说明 matchIndex 1 基且可直接喂 doc_replace_nth_match,实际是:" + d);
}

@Test
@DisplayName("system prompt(中/英)工具表的 doc_find_text 一行同样点明 matchIndex 1 基")
void systemPromptFindTextRowStatesOneBasedMatchIndex() throws Exception {
String zh = readResource("prompts/system_prompt.md");
String en = readResource("prompts/system_prompt.en.md");
assertTrue(zh.contains("matchIndex(序号,从 1 开始"), "system_prompt.md 的 doc_find_text 一行缺 matchIndex 1 基说明");
assertTrue(en.contains("matchIndex (1-based"), "system_prompt.en.md doc_find_text row lacks the 1-based matchIndex note");
}

private static String readResource(String path) throws Exception {
try (InputStream in = MatchIndexBaseTest.class.getClassLoader().getResourceAsStream(path)) {
assertNotNull(in, "classpath 上找不到 " + path);
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
12 changes: 12 additions & 0 deletions frontend/tests/lowa-e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,18 @@ try {
await reset('甲方一、甲方二、甲方三。', true)
const dm = await exec('delete_match', { findText: '甲方', matchIndex: 1 })
check('worker delete_match matchIndex:1 删的是第二个匹配(0 基)', dm.success && (await doc()) === '甲方一、二、甲方三。', JSON.stringify(dm) + ' ' + (await doc()))
// dev-board#369 补钉三件事,都是后端「下发减一 / 回传加一」赖以成立的 worker 事实:
// (a) 0 基边界:matchIndex = 命中数-1 是最后一处、= 命中数 越界且不动文档;
// (b) find_text_locations 返回的 matchIndex 也是 0 起(后端 doc_find_text 回给模型前加 1);
// (c) 「只计可见匹配」靠页边模式成立:上一步删掉的第二处已成删除型修订、不在正文流,再找只剩两处,
// replace_nth_match 的计数也跟着只数可见的——关掉 ShowChangesInMargin 这条就不再成立。
const ftn = await exec('find_text_locations', { keyword: '甲方' })
check('find_text_locations 只计可见匹配(删除型修订不计)', ftn.success && ftn.count === 2, JSON.stringify(ftn))
check('find_text_locations 的 matchIndex 0 起(0,1),由后端回传时加 1', ftn.success && (ftn.matches || []).map((m) => m.matchIndex).join(',') === '0,1', JSON.stringify((ftn.matches || []).map((m) => m.matchIndex)))
const nmLast = await exec('replace_nth_match', { findText: '甲方', replaceText: '丙方', matchIndex: 1 })
check('replace_nth_match 按可见匹配计数:0 基的 1 = 原第三处', nmLast.success && (await doc()) === '甲方一、二、丙方三。', JSON.stringify(nmLast) + ' ' + (await doc()))
const nmOver = await exec('replace_nth_match', { findText: '甲方', replaceText: '丁方', matchIndex: 1 })
check('matchIndex = 可见命中数 越界:拒绝且不动文档', !nmOver.success && (await doc()) === '甲方一、二、丙方三。', JSON.stringify(nmOver) + ' ' + (await doc()))

console.log('== 12) add_comment 批注:解释文字挂批注、不进正文 ==')
await reset('本合同自签署之日起生效。', true)
Expand Down
Loading