fix(citations): 识别全角参考文献角标(全角数字/合并/区间)

正文里的引用角标识别此前全角括号只支持单个半角数字 【1】,
导致 【1】(全角数字)、【1,2】/【1,2】/【1、2】(合并)、
【1-3】/【2~5】(区间) 等中文常见写法无法识别成参考文献。

补齐全角括号的「全角数字 / 合并 / 区间」三种形态,转成半角后交给
原有半角逻辑统一处理;仍保留 isValidIndex 闸门,避免把正文里的
【3】(越界)、【附录】(非数字) 误判为引用。新增 inline-citations.test.ts。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-23 22:14:52 +08:00
1 parent b5e682cfd2
commit 3e68260afa
2 files changed
+129 -3

No files matched your search

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyInlineCitations, type RuntimeDisplaySource } from "./runtime";
function makeSources(count: number): RuntimeDisplaySource[] {
return Array.from({ length: count }, (_unused, position) => ({
index: position + 1,
skillName: "knowledge-base-search",
mode: "citation",
title: `参考文献 ${position + 1}`,
type: "result",
snippet: "",
raw: {},
}));
}
function citation(index: number): string {
return `[citation:${index}](#reference-${index})`;
}
test("全角单编号 【1】【2】 解析为引用徽章", () => {
const out = applyInlineCitations("结论见【1】【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
assert.equal(out.includes("【1】"), false);
assert.equal(out.includes("【2】"), false);
});
test("全角数字 【1】 也能识别", () => {
const out = applyInlineCitations("参考【1】和【2】。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("全角括号合并编号 【1,2】/【1,2】/【1、2】 拆成多个引用", () => {
for (const marker of ["【1,2】", "【1,2】", "【1、2】"]) {
const out = applyInlineCitations(`见${marker}。`, makeSources(2));
assert.equal(out.includes(citation(1)), true, marker);
assert.equal(out.includes(citation(2)), true, marker);
}
});
test("全角区间编号 【1-3】/【2~5】 展开为连续引用", () => {
const dash = applyInlineCitations("见【1-3】。", makeSources(3));
assert.equal(dash.includes(citation(1)), true);
assert.equal(dash.includes(citation(2)), true);
assert.equal(dash.includes(citation(3)), true);
const tilde = applyInlineCitations("见【2~4】。", makeSources(4));
assert.equal(tilde.includes(citation(2)), true);
assert.equal(tilde.includes(citation(3)), true);
assert.equal(tilde.includes(citation(4)), true);
});
test("超出来源范围的全角编号保持原样,不误判正文", () => {
// 只有 1 条来源,【3】 没有对应来源 → 不应转换
const out = applyInlineCitations("正文【3】不是引用。", makeSources(1));
assert.equal(out.includes("【3】"), true);
assert.equal(out.includes("#reference-3"), false);
});
test("非数字的全角括号不被改写", () => {
const out = applyInlineCitations("见【附录】小节。", makeSources(2));
assert.equal(out.includes("【附录】"), true);
});
test("半角写法仍然正常工作", () => {
const out = applyInlineCitations("见[1][2]和[1,3]。", makeSources(3));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
assert.equal(out.includes(citation(3)), true);
});
+56 -3
View File
@@ -623,9 +623,62 @@ function normalizeLooseCitationTokens(
return indexes.map((index) => `[${index}]`).join("");
};
// 全角括号写法:【1】→ [1]
let next = content.replace(/【\s*(\d{1,3})\s*】/g, (match, rawIndex: string) =>
isValidIndex(Number(rawIndex)) ? `[${rawIndex}]` : match,
// 全角数字转半角:123 → 123(仅用于括号内的编号识别)
const toAsciiDigits = (value: string) =>
value.replace(/[0-9]/g, (digit) =>
String.fromCharCode(digit.charCodeAt(0) - 0xfee0),
);
// 全角括号写法(含全角数字):
// 【1】/【1】 → [1]
// 【1,2】/【1,2】/【1、2】 → [1][2]
// 【1-3】/【2~5】 → [1][2][3]
// 三种形态互斥(单编号内不含分隔符),且都要求编号能对应到参考来源,
// 避免把正文里的【…】误判成引用。转成半角后交给下面的半角逻辑统一处理。
// 全角单编号:【1】/【1】
let next = content.replace(
/【\s*([0-90-9]{1,3})\s*】/g,
(match, rawIndex: string) => {
const index = Number(toAsciiDigits(rawIndex));
return isValidIndex(index) ? `[${index}]` : match;
},
);
// 全角合并编号:【1,2】/【1,2】/【1、2】
next = next.replace(
/【\s*([0-90-9]{1,3}(?:\s*[,,、]\s*[0-90-9]{1,3})+)\s*】/g,
(match, rawList: string) => {
const indexes = rawList
.split(/[,,、]/)
.map((part) => Number(toAsciiDigits(part.trim())));
if (indexes.some((index) => !isValidIndex(index))) {
return match;
}
return indexes.map((index) => `[${index}]`).join("");
},
);
// 全角区间编号:【1-3】/【2~5】
next = next.replace(
/【\s*([0-90-9]{1,3})\s*[-–~~]\s*([0-90-9]{1,3})\s*】/g,
(match, rawStart: string, rawEnd: string) => {
const start = Number(toAsciiDigits(rawStart));
const end = Number(toAsciiDigits(rawEnd));
if (
end <= start ||
end - start > 20 ||
!isValidIndex(start) ||
!isValidIndex(end)
) {
return match;
}
const tokens: string[] = [];
for (let index = start; index <= end; index++) {
tokens.push(`[${index}]`);
}
return tokens.join("");
},
);
// 合并写法:[1,2,3] / [1,2] / [1、2] → [1][2][3]