From 3e68260afa0577d3831d03f959a5f9a45c11b841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 22:14:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(citations):=20=E8=AF=86=E5=88=AB=E5=85=A8?= =?UTF-8?q?=E8=A7=92=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E8=A7=92=E6=A0=87?= =?UTF-8?q?(=E5=85=A8=E8=A7=92=E6=95=B0=E5=AD=97/=E5=90=88=E5=B9=B6/?= =?UTF-8?q?=E5=8C=BA=E9=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正文里的引用角标识别此前全角括号只支持单个半角数字 【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) --- .../skill-display/inline-citations.test.ts | 73 +++++++++++++++++++ .../src/core/skill-display/runtime.ts | 59 ++++++++++++++- 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 frontend-web/src/core/skill-display/inline-citations.test.ts diff --git a/frontend-web/src/core/skill-display/inline-citations.test.ts b/frontend-web/src/core/skill-display/inline-citations.test.ts new file mode 100644 index 00000000..1a09a798 --- /dev/null +++ b/frontend-web/src/core/skill-display/inline-citations.test.ts @@ -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); +}); diff --git a/frontend-web/src/core/skill-display/runtime.ts b/frontend-web/src/core/skill-display/runtime.ts index e65bc26e..877b397b 100644 --- a/frontend-web/src/core/skill-display/runtime.ts +++ b/frontend-web/src/core/skill-display/runtime.ts @@ -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]