Skip to content

Commit afb5272

Browse files
committed
feat(edit): 增强智能匹配功能并添加编辑纠错工具
实现多种匹配策略以处理 LLM 编辑时的常见问题: - 添加反转义功能处理过度转义的字符串 - 实现弹性缩进匹配以忽略缩进差异 - 重构智能匹配逻辑为多策略渐进式匹配 - 添加测试用例验证各种匹配场景
1 parent 15f911c commit afb5272

3 files changed

Lines changed: 346 additions & 14 deletions

File tree

src/tools/builtin/file/edit.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import { ToolSchemas } from '../../validation/zodSchemas.js';
88
import { generateDiffSnippetWithMatch } from './diffUtils.js';
99
import { FileAccessTracker } from './FileAccessTracker.js';
1010
import { SnapshotManager } from './SnapshotManager.js';
11+
import {
12+
unescapeString,
13+
flexibleMatch,
14+
MatchStrategy,
15+
type MatchResult,
16+
} from './editCorrector.js';
1117

1218
/**
1319
* EditTool - 文件编辑工具
@@ -173,9 +179,9 @@ export const editTool = createTool({
173179
}
174180

175181
// 智能匹配并查找匹配项
176-
const actualString = smartMatch(content, old_string);
182+
const matchResult = smartMatch(content, old_string);
177183

178-
if (!actualString) {
184+
if (!matchResult.matched) {
179185
return {
180186
success: false,
181187
llmContent: `在文件中未找到要替换的字符串: "${old_string}"`,
@@ -187,6 +193,13 @@ export const editTool = createTool({
187193
};
188194
}
189195

196+
const actualString = matchResult.matched;
197+
198+
// 记录使用的匹配策略(用于调试和优化)
199+
if (matchResult.strategy !== MatchStrategy.EXACT) {
200+
console.log(`[SmartEdit] 使用策略: ${matchResult.strategy}`);
201+
}
202+
190203
// 使用实际匹配的字符串查找所有位置
191204
const matches = findMatches(content, old_string);
192205

@@ -374,41 +387,57 @@ function normalizeQuotes(text: string): string {
374387

375388
/**
376389
* 智能匹配字符串
377-
* 渐进式匹配:先直接匹配,失败后标准化匹配
390+
* 渐进式匹配:依次尝试多种策略
378391
*
379392
* @param content 文件内容
380393
* @param searchString 要搜索的字符串
381-
* @returns 匹配的字符串(保留原文件中的实际字符)或 null
394+
* @returns { matched: string | null, strategy: MatchStrategy }
382395
*/
383-
function smartMatch(content: string, searchString: string): string | null {
384-
// 第一步:直接匹配
396+
function smartMatch(content: string, searchString: string): MatchResult {
397+
// 策略 1: 精确匹配
385398
if (content.includes(searchString)) {
386-
return searchString;
399+
return { matched: searchString, strategy: MatchStrategy.EXACT };
387400
}
388401

389-
// 第二步:标准化引号后匹配
402+
// 策略 2: 标准化引号后匹配
390403
const normalizedSearch = normalizeQuotes(searchString);
391404
const normalizedContent = normalizeQuotes(content);
392405

393-
const index = normalizedContent.indexOf(normalizedSearch);
394-
if (index !== -1) {
406+
const quoteIndex = normalizedContent.indexOf(normalizedSearch);
407+
if (quoteIndex !== -1) {
395408
// 返回原文件中的实际字符串(保持格式)
396-
return content.substring(index, index + searchString.length);
409+
const actualString = content.substring(quoteIndex, quoteIndex + searchString.length);
410+
return { matched: actualString, strategy: MatchStrategy.NORMALIZE_QUOTES };
411+
}
412+
413+
// 策略 3: 反转义后匹配
414+
const unescaped = unescapeString(searchString);
415+
if (unescaped !== searchString && content.includes(unescaped)) {
416+
return { matched: unescaped, strategy: MatchStrategy.UNESCAPE };
417+
}
418+
419+
// 策略 4: 弹性缩进匹配
420+
const flexible = flexibleMatch(content, searchString);
421+
if (flexible) {
422+
return { matched: flexible, strategy: MatchStrategy.FLEXIBLE };
397423
}
398424

399-
return null;
425+
// 所有策略都失败
426+
return { matched: null, strategy: MatchStrategy.FAILED };
400427
}
401428

402429
/**
403430
* 查找所有匹配项的位置
404431
*/
405432
function findMatches(content: string, searchString: string): number[] {
406433
// 先尝试智能匹配
407-
const actualString = smartMatch(content, searchString);
408-
if (!actualString) {
434+
const matchResult = smartMatch(content, searchString);
435+
if (!matchResult.matched) {
409436
return []; // 未找到匹配
410437
}
411438

439+
const actualString = matchResult.matched;
440+
412441
// 使用实际匹配的字符串查找所有位置
413442
const matches: number[] = [];
414443
let index = content.indexOf(actualString);
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Edit Corrector - 编辑纠错工具
3+
*
4+
* 提供多种策略自动修复 LLM 在调用 Edit 工具时的常见错误:
5+
* - 转义字符过多(\\n → \n)
6+
* - 缩进不匹配(2 空格 vs 4 空格)
7+
* - 引号类型差异(智能引号 vs 普通引号)
8+
*/
9+
10+
/**
11+
* 匹配策略枚举
12+
*/
13+
export enum MatchStrategy {
14+
EXACT = 'exact', // 精确匹配
15+
NORMALIZE_QUOTES = 'normalize_quotes', // 引号标准化匹配
16+
UNESCAPE = 'unescape', // 反转义匹配
17+
FLEXIBLE = 'flexible', // 弹性缩进匹配
18+
FAILED = 'failed', // 所有策略都失败
19+
}
20+
21+
/**
22+
* 匹配结果
23+
*/
24+
export interface MatchResult {
25+
matched: string | null; // 匹配到的实际字符串(保持原文件格式)
26+
strategy: MatchStrategy; // 使用的匹配策略
27+
}
28+
29+
/**
30+
* 反转义字符串
31+
* 修复 LLM 过度转义的问题
32+
*
33+
* @param input 输入字符串
34+
* @returns 反转义后的字符串
35+
*
36+
* @example
37+
* unescapeString('line1\\nline2') // → 'line1\nline2'
38+
* unescapeString('say \\"hello\\"') // → 'say "hello"'
39+
* unescapeString('\\`template\\`') // → '`template`'
40+
*/
41+
export function unescapeString(input: string): string {
42+
// 正则说明:
43+
// \\+ : 匹配一个或多个反斜杠
44+
// (n|t|r|'|"|`|\\|\n) : 捕获组,匹配需要转义的字符
45+
// - n, t, r: 字面字符(需要转换为 \n, \t, \r)
46+
// - ', ", `: 引号字符
47+
// - \\: 反斜杠本身
48+
// - \n: 真实的换行符
49+
// g: 全局标志,替换所有匹配
50+
51+
return input.replace(
52+
/\\+(n|t|r|'|"|`|\\|\n)/g,
53+
(match, capturedChar) => {
54+
switch (capturedChar) {
55+
case 'n':
56+
return '\n'; // 换行符
57+
case 't':
58+
return '\t'; // 制表符
59+
case 'r':
60+
return '\r'; // 回车符
61+
case "'":
62+
return "'"; // 单引号
63+
case '"':
64+
return '"'; // 双引号
65+
case '`':
66+
return '`'; // 反引号
67+
case '\\':
68+
return '\\'; // 反斜杠
69+
case '\n':
70+
return '\n'; // 真实换行符
71+
default:
72+
return match; // 保持原样
73+
}
74+
}
75+
);
76+
}
77+
78+
/**
79+
* 弹性缩进匹配
80+
* 忽略缩进差异,在文件内容中查找匹配的字符串
81+
*
82+
* @param content 文件内容
83+
* @param searchString 要搜索的字符串
84+
* @returns 匹配到的实际字符串(保持原文件缩进),如果未找到则返回 null
85+
*
86+
* @example
87+
* const content = ' function foo() {\n return 1;\n }';
88+
* const search = ' function foo() {\n return 1;\n }';
89+
* flexibleMatch(content, search) // → ' function foo() {\n return 1;\n }'
90+
*/
91+
export function flexibleMatch(
92+
content: string,
93+
searchString: string
94+
): string | null {
95+
const searchLines = searchString.split('\n');
96+
97+
// 如果只有一行,无法使用弹性匹配
98+
if (searchLines.length === 1) {
99+
return null;
100+
}
101+
102+
// 1. 提取搜索字符串的第一行缩进
103+
const firstLine = searchLines[0];
104+
const indentMatch = firstLine.match(/^(\s+)/);
105+
106+
if (!indentMatch) {
107+
return null; // 第一行没有缩进,无法使用弹性匹配
108+
}
109+
110+
const searchIndent = indentMatch[1];
111+
112+
// 2. 去除搜索字符串的缩进
113+
const deindentedSearchLines = searchLines.map(line => {
114+
if (line.startsWith(searchIndent)) {
115+
return line.slice(searchIndent.length);
116+
}
117+
return line;
118+
});
119+
const deindentedSearch = deindentedSearchLines.join('\n');
120+
121+
// 3. 在文件内容中搜索
122+
const contentLines = content.split('\n');
123+
124+
// 尝试在每个可能的位置匹配
125+
for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
126+
const lineIndentMatch = contentLines[i].match(/^(\s+)/);
127+
const fileIndent = lineIndentMatch ? lineIndentMatch[1] : '';
128+
129+
// 提取从当前行开始的内容片段(与搜索字符串行数相同)
130+
const snippet = contentLines.slice(i, i + searchLines.length);
131+
132+
// 去除文件片段的缩进
133+
const deindentedSnippet = snippet.map(line => {
134+
if (line.startsWith(fileIndent)) {
135+
return line.slice(fileIndent.length);
136+
}
137+
return line;
138+
});
139+
const deindentedContent = deindentedSnippet.join('\n');
140+
141+
// 如果去除缩进后完全匹配
142+
if (deindentedContent === deindentedSearch) {
143+
// 返回原文件中的实际字符串(保持原始缩进)
144+
return snippet.join('\n');
145+
}
146+
}
147+
148+
return null;
149+
}

0 commit comments

Comments
 (0)