@ggboy123/dsh-paper-reader 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -297
- package/dist/babeldoc-install.js +6 -2
- package/dist/formulas.js +87 -14
- package/dist/host.js +107 -3
- package/dist/translate-config.js +4 -1
- package/dist/translate-selection.js +772 -0
- package/lib/client.js +70 -0
- package/package.json +1 -1
- package/reader/index.html +1064 -64
- package/reader/vendor/KaTeX_AMS-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_Main-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/reader/vendor/KaTeX_Math-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/reader/vendor/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Script-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size1-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size2-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size3-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Size4-Regular.woff2 +0 -0
- package/reader/vendor/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/reader/vendor/katex.LICENSE +21 -0
- package/reader/vendor/katex.NOTICE.md +26 -0
- package/reader/vendor/katex.min.css +1 -0
- package/reader/vendor/katex.min.js +1 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
// translate-selection.ts — 划词翻译(公式保留)的服务端核心。
|
|
2
|
+
//
|
|
3
|
+
// 契约:docs/selection-translate-requirements.md §3.3–§3.8(冻结 v1)。本模块只做**服务端**
|
|
4
|
+
// 的部分,且全部纯函数化(`fetchImpl` 可注入 ⇒ 单测不需要网络、不产生费用):
|
|
5
|
+
// ① `alignSelectionToBlock`:行内公式还原(文本层退化文本 ↔ MinerU text 块文本对齐,§3.4)
|
|
6
|
+
// ② `assembleSource`:把客户端的 chunk 序列组装成「公式已是单 $ 定界紧凑 LaTeX」的源文本
|
|
7
|
+
// ③ `buildRequestBody`:写死的 system 提示词 + §3.6 的请求体
|
|
8
|
+
// ④ `translateSelection`:调用端点(含唯一的 temperature 重试)+ 响应清洗 + 内存 LRU
|
|
9
|
+
// ⑤ `verifyFormulas`:把「译文原样保留公式」从口头要求变成可判定断言(§3.6-4)
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ 为什么行内还原在服务端(§3.4 / C8):客户端拿不到块文本;`compactLatex` 是 TS 侧函数,
|
|
12
|
+
// 阅读器是单文件内联脚本、没有打包器,复制一份必然与 src/formulas.ts 漂移;而放在这里
|
|
13
|
+
// 可以用 `node --test` 直接构造成本极低的攻击用例。
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ 日志红线(§3.7③):本模块**只**允许打印 `{model, status, ms, chars}` ——
|
|
16
|
+
// 绝不把端点对象或配置对象送进日志,API key 不进日志、不进响应、不进错误文案。
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { compactLatex } from "./formulas.js";
|
|
19
|
+
import { sanitizeDetail } from "./mineru.js";
|
|
20
|
+
/* ── 冻结参数表(§3.4;调参必须回改需求文档)────────────────────────────── */
|
|
21
|
+
/** 最短可对齐归一化长度 */
|
|
22
|
+
export const MIN_NORM = 4;
|
|
23
|
+
/** 种子长度上下限 */
|
|
24
|
+
export const SEED_MAX = 12;
|
|
25
|
+
export const SEED_MIN = 4;
|
|
26
|
+
/** 跳过预算系数:`max(4, ceil(0.4*|S|))` */
|
|
27
|
+
export const SKIP_RATIO = 0.4;
|
|
28
|
+
/** 接受阈值 */
|
|
29
|
+
export const MIN_SCORE = 0.75;
|
|
30
|
+
/** 跨度长度比 `[0.6, 1.35]` */
|
|
31
|
+
export const RATIO_LO = 0.6;
|
|
32
|
+
export const RATIO_HI = 1.35;
|
|
33
|
+
/** 同一 seed 的候选出现数 */
|
|
34
|
+
export const SEED_TRIES = 3;
|
|
35
|
+
/* ── 规模上限(§3.9)────────────────────────────────────────────────────── */
|
|
36
|
+
export const MAX_CHUNKS = 200;
|
|
37
|
+
export const MAX_TEXT_CHARS = 4000;
|
|
38
|
+
export const MAX_LATEX_CHARS = 2000;
|
|
39
|
+
export const MAX_TOTAL_CHARS = 8000;
|
|
40
|
+
/** 服务端上游超时(§3.6) */
|
|
41
|
+
export const UPSTREAM_TIMEOUT_MS = 30_000;
|
|
42
|
+
/** LRU 上限(§3.7④) */
|
|
43
|
+
export const CACHE_LIMIT = 200;
|
|
44
|
+
/* ── 提示词契约(§3.6,逐字冻结;改动必须回改需求文档并重跑 A6/A7)────────── */
|
|
45
|
+
export const SELECTION_SYSTEM_PROMPT = `You are a professional, authentic machine translation engine for academic papers.
|
|
46
|
+
Translate the user's input into Simplified Chinese.
|
|
47
|
+
|
|
48
|
+
Hard rules:
|
|
49
|
+
1. Any content wrapped in $...$ is a LaTeX formula. Keep every formula EXACTLY as
|
|
50
|
+
given: do not translate it, do not rewrite or simplify it, do not change spaces,
|
|
51
|
+
braces, or backslashes, do not drop it, do not add any.
|
|
52
|
+
2. Output the translation ONLY. No preamble, no explanation, no notes, no headings,
|
|
53
|
+
no Markdown code fences, no quotes around the whole output.
|
|
54
|
+
3. Preserve the paragraph and line structure of the input. Never add content that is
|
|
55
|
+
not in the input.
|
|
56
|
+
4. Translate terminology by field convention (policy -> 策略, world model -> 世界模型).
|
|
57
|
+
Keep model names, dataset names, method names and abbreviations (LCVN-WM, DiT, CLIP)
|
|
58
|
+
in the original form.
|
|
59
|
+
5. If the input is already Chinese, return it as is.`;
|
|
60
|
+
export class SelectionTranslateError extends Error {
|
|
61
|
+
code;
|
|
62
|
+
status;
|
|
63
|
+
upstreamStatus;
|
|
64
|
+
constructor(code, status, message,
|
|
65
|
+
/** 上游 HTTP 状态码(仅 code==='upstream' 时有意义):客户端据此出 §3.7② 的分档文案 */
|
|
66
|
+
upstreamStatus = null) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.code = code;
|
|
69
|
+
this.status = status;
|
|
70
|
+
this.upstreamStatus = upstreamStatus;
|
|
71
|
+
this.name = 'SelectionTranslateError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/* ── 文本清洗(§3.3②)──────────────────────────────────────────────────── */
|
|
75
|
+
/** 文本层乱码字符类(§3.3② 的冻结清单):C0/C1(\t 与 \n 是空白,另行折叠)、私用区、
|
|
76
|
+
* U+FFFD、走音符号类。**只用于 `$…$` 之外的文本层内容**——见下面的 LaTeX 例外。 */
|
|
77
|
+
function isTextJunk(cp) {
|
|
78
|
+
if (cp <= 0x1f && cp !== 0x09 && cp !== 0x0a && cp !== 0x0b && cp !== 0x0c && cp !== 0x0d)
|
|
79
|
+
return true;
|
|
80
|
+
if (cp >= 0x7f && cp <= 0x9f)
|
|
81
|
+
return true;
|
|
82
|
+
if (cp >= 0xe000 && cp <= 0xf8ff)
|
|
83
|
+
return true;
|
|
84
|
+
if (cp === 0xfffd)
|
|
85
|
+
return true;
|
|
86
|
+
if (cp === 0x02c6 || cp === 0x02dc || cp === 0xb4 || cp === 0x005e || cp === 0x0060)
|
|
87
|
+
return true;
|
|
88
|
+
return cp >= 0x300 && cp <= 0x36f;
|
|
89
|
+
}
|
|
90
|
+
const isLatexJunk = (cp) => (cp <= 0x1f && cp !== 0x0a) || (cp >= 0x7f && cp <= 0x9f) || (cp >= 0xe000 && cp <= 0xf8ff) || cp === 0xfffd;
|
|
91
|
+
function dropJunk(s, junk) {
|
|
92
|
+
let out = '';
|
|
93
|
+
for (const ch of s) {
|
|
94
|
+
if (junk(ch.codePointAt(0) ?? 0))
|
|
95
|
+
continue;
|
|
96
|
+
out += ch;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
/** 空白折叠:连续空白(\t/\r/空格…)折叠成一个空格,**换行是结构**只清它两侧的空格。 */
|
|
101
|
+
function collapse(s) {
|
|
102
|
+
return s.replace(/[^\S\n]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n{2,}/g, '\n').trim();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 收尾清洗:删除 C0/C1 控制字符、私用区(E000-F8FF)、U+FFFD、走音符号类
|
|
106
|
+
* (`\u02c6 \u02dc \u00b4 \u005e \u0060 \u0300-\u036f`),并把连续空白折叠成一个空格。
|
|
107
|
+
*
|
|
108
|
+
* **两条实现约束(都是实测/契约必需的,见实现报告)**:
|
|
109
|
+
* ① `\n` 是结构:§3.5 ④「页之间插一个 `\n`」+ §3.6 system 第 3 条「保持行结构」,
|
|
110
|
+
* 所以只折叠它两侧的空格与连续空行。
|
|
111
|
+
* ② 本函数**只能作用在 `$…$` 之外**:清单里的 `\u005e`(`^`)在 LaTeX 里是上标运算符,
|
|
112
|
+
* 整段组装文本过一遍会把 `v^\top` 变成 `v\top`、`x^2` 变成 `x2`(实测)——那既毁公式,
|
|
113
|
+
* 又会让「公式原样保留」的校验失去意义。`assembleSource` 因此对每个 piece 分类清洗:
|
|
114
|
+
* `$…$` 内只删控制字符/私用区(`isLatexJunk`),外面才用本函数。
|
|
115
|
+
*/
|
|
116
|
+
export function stripJunk(s) {
|
|
117
|
+
return collapse(dropJunk(s, isTextJunk));
|
|
118
|
+
}
|
|
119
|
+
/** 一个组装 piece 的清洗:`$…$` 内是 MinerU 的 LaTeX(逐字保留),外面是文本层内容(清乱码)。 */
|
|
120
|
+
function cleanPiece(s) {
|
|
121
|
+
return s.split(/(\$[^$]*\$)/)
|
|
122
|
+
.map((part) => (part.startsWith('$') && part.endsWith('$') && part.length >= 2
|
|
123
|
+
? '$' + dropJunk(part.slice(1, -1), isLatexJunk) + '$'
|
|
124
|
+
: dropJunk(part, isTextJunk)))
|
|
125
|
+
.join('');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 中和文本层原文里的**字面 `$`**(R-03):换成全角 `$`(U+FF04)。
|
|
129
|
+
* 组装文本的 `$…$` 因此**只**可能是真公式(formula chunk 或块跨度里的 LaTeX 片段),
|
|
130
|
+
* `verifyFormulas` 的逐 token 校验才是可判定的;否则 `$5` 这种字面符会与别处的 `$` 配成
|
|
131
|
+
* 假片段,把真公式挤掉(A7 空转)。模型看到的仍是同一个货币符号的常见全角写法。
|
|
132
|
+
* 用于**未对齐分支**(那一段是文本层原文,里面不可能有 MinerU 的 LaTeX)。
|
|
133
|
+
*/
|
|
134
|
+
function neutralizeDollars(s) {
|
|
135
|
+
return s.includes('$') ? s.replace(/\$/g, '\uff04') : s;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* 一个 `$…$` 片段**看起来是 LaTeX**吗(N-02 的判据)。
|
|
139
|
+
* 真公式来自 MinerU 的 LaTeX 投影:含命令/上下标/花括号(`\ _ ^ { } &`),或者整体**不含空白**
|
|
140
|
+
* (紧凑投影后的 `$x_t$`/`$F$` 这类纯符号公式)。正文里两个**字面** `$` 之间通常是自然语言
|
|
141
|
+
* (`$5 up to $`)——含空白且没有 LaTeX 字符 ⇒ 判为非公式。
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ 已知边界:紧凑的成对字面 `$`(如正文写成 `$USD$`、`$5$`)与 `$F$` 这类纯符号公式形态相同,
|
|
144
|
+
* 无法区分,会按公式计入 token。本机两份语料(154+112 个 text 块、两条真实文本层)中不存在
|
|
145
|
+
* 这类形态;遇到时行为与旧版一致(计入并校验),不会更糟。
|
|
146
|
+
*/
|
|
147
|
+
export function isLatexLikeFragment(fragment) {
|
|
148
|
+
const inner = fragment.startsWith('$') && fragment.endsWith('$') ? fragment.slice(1, -1) : fragment;
|
|
149
|
+
if (/[\\_^{}&]/.test(inner))
|
|
150
|
+
return true;
|
|
151
|
+
return !/\s/.test(inner);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* 中和**块跨度**里的成对字面 `$`(N-02):保留看起来是 LaTeX 的 `$…$` 片段(那是真公式),
|
|
155
|
+
* 其余 `$` 一律换成全角 `$`。这样组装文本里的每个 `$…$` 都可判定为真公式,
|
|
156
|
+
* `formulaTokens`/`verifyFormulas` 不会再把 `$5 up to $` 当成公式(假警告)或塞进提示词。
|
|
157
|
+
*/
|
|
158
|
+
function neutralizeNonLatexPairs(s) {
|
|
159
|
+
if (!s.includes('$'))
|
|
160
|
+
return s;
|
|
161
|
+
return s.split(/(\$[^$]*\$)/)
|
|
162
|
+
.map((part) => {
|
|
163
|
+
if (part.startsWith('$') && part.endsWith('$') && part.length >= 2 && isLatexLikeFragment(part))
|
|
164
|
+
return part;
|
|
165
|
+
return part.replace(/\$/g, '\uff04');
|
|
166
|
+
})
|
|
167
|
+
.join('');
|
|
168
|
+
}
|
|
169
|
+
/** 对齐用的归一化:小写 + 只留 `[a-z0-9]` 与 CJK,其余(空白/标点/数学符号/控制字符)丢弃。 */
|
|
170
|
+
export function normForAlign(raw) {
|
|
171
|
+
const chars = [];
|
|
172
|
+
const idx = [];
|
|
173
|
+
for (let i = 0; i < raw.length; i++) {
|
|
174
|
+
const c = raw[i].toLowerCase();
|
|
175
|
+
if (/[a-z0-9\u4e00-\u9fff]/.test(c)) {
|
|
176
|
+
chars.push(c);
|
|
177
|
+
idx.push(i);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { A: chars.join(''), idx };
|
|
181
|
+
}
|
|
182
|
+
/* ── 行内对齐(§3.4,原型 .probe/align-sim4.mjs 的冻结版)──────────────────
|
|
183
|
+
⚠️ t6 修复(t3 报告 F1/F2,见 docs/selection-translate-verification.md §6.1/§6.2):
|
|
184
|
+
冻结版有两个缺陷,都是「静默送错文本」:
|
|
185
|
+
F1 跨度端点丢词:公式的 LaTeX 比文本层退化形态长得多(`\mathbf`/`\left`/`\right`/命令名),
|
|
186
|
+
双向行走的 skip 预算被这些「LaTeX 噪声」吃掉,于是在**选区侧**跳过公式前的正文词,
|
|
187
|
+
`bMin` 落在 `$` 上 → 组装文本少一个词(实测 `where …` 的 `where` 丢失)。
|
|
188
|
+
F2 张冠李戴:`score ≥ 0.75` 对短选区没有判别力(逐字母匹配 5/8 就 0.83),于是「本段不在
|
|
189
|
+
候选集里」时会被**别的段落**接走(`, while a learner encoder` → `expert and learner encoders`)。
|
|
190
|
+
修复(三条,均为「绝对门槛」,宁可拒绝还原并如实提示,也不许静默送错文本):
|
|
191
|
+
① **块侧跳过只允许发生在 `$…$` 片段内且不消耗预算**(LaTeX 命令/括号本来就无法被文本层
|
|
192
|
+
表示 ⇒ 它们是结构性差异,不是「多抓了内容」)。正文里的块侧跳字 = 张冠李戴 ⇒ 该候选作废。
|
|
193
|
+
这同时让行走能回到选区两端(F1 的根因)。
|
|
194
|
+
② **端点回填**:行走仍够不到时(选区侧未配对的前缀/后缀),把它按同样的规则映射回块侧,
|
|
195
|
+
因此「选区里有、块里也有」的词一定进跨度(F1 §12.1 的处方)。
|
|
196
|
+
③ **覆盖门槛**:跨度必须解释选区的**每一个字母**(未配对的字母只能是数字/符号,如公式编号
|
|
197
|
+
`(4)` 的 `4`),且跨度内不得出现「块侧多出来的散文内容」(prose slack,如选区是
|
|
198
|
+
`Instruction:` 而块里是 `instructions`)⇒ 否则拒绝(返回原样 + 走 §3.8 的诚实文案)。
|
|
199
|
+
另:跨度长度比 `ratio` 改用**内容口径**(`$…$` 内的 `\命令名` 不算内容)——旧口径把
|
|
200
|
+
`\mathbf`s_t=\left[…\right]` 的 15 个命令名字符算成选区内容,回填 `where` 后 ratio 反而
|
|
201
|
+
从 1.30 涨到 1.46 被误拒。阈值 `[0.6,1.35]`、`score≥0.75`、种子/预算参数**全部未动**。 */
|
|
202
|
+
const WORD_CH = /[A-Za-z0-9]/;
|
|
203
|
+
/** `$…$` 片段范围(raw 下标)。 */
|
|
204
|
+
function fragmentRanges(raw) {
|
|
205
|
+
const out = [];
|
|
206
|
+
const re = /\$[^$]*\$/g;
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = re.exec(raw)) !== null)
|
|
209
|
+
out.push([m.index, m.index + m[0].length]);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
/** 归一化下标 → 是否落在 `$…$` 片段内部(`$` 定界符本身不算内部)。 */
|
|
213
|
+
function fragmentFlags(raw, Bidx) {
|
|
214
|
+
const ranges = fragmentRanges(raw);
|
|
215
|
+
return Bidx.map((ri) => ranges.some(([f0, f1]) => ri > f0 && ri < f1));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 「内容」口径的归一化:`$…$` 内先删掉 LaTeX 命令名(`\mathbf`/`\left`/`\theta`…)与花括号/定界符,
|
|
219
|
+
* 只保留真正会被文本层呈现出来的字母数字。用于跨度长度比 `ratio`——文本层永远表示不出命令名,
|
|
220
|
+
* 把它们算进跨度长度等于用「LaTeX 写得多长」去衡量「有没有多抓一行」。
|
|
221
|
+
*/
|
|
222
|
+
export function contentNormForRatio(raw) {
|
|
223
|
+
const ranges = fragmentRanges(raw);
|
|
224
|
+
let out = '';
|
|
225
|
+
for (let i = 0; i < raw.length; i++) {
|
|
226
|
+
const inFrag = ranges.some(([f0, f1]) => i > f0 && i < f1);
|
|
227
|
+
if (inFrag && raw[i] === '\\') {
|
|
228
|
+
i++;
|
|
229
|
+
while (i + 1 < raw.length && /[A-Za-z]/.test(raw[i + 1]))
|
|
230
|
+
i++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
out += raw[i];
|
|
234
|
+
}
|
|
235
|
+
return normForAlign(out).A;
|
|
236
|
+
}
|
|
237
|
+
/** 种子 + 双向行走;块侧跳过必须整段落在 `$…$` 片段内(且免费),选区侧跳过消耗预算。 */
|
|
238
|
+
function alignSeeds(S, B, inFrag, skipRatio = SKIP_RATIO) {
|
|
239
|
+
const k = Math.min(SEED_MAX, Math.max(SEED_MIN, Math.floor(S.length / 4)));
|
|
240
|
+
const seeds = [...new Set([0, Math.max(0, Math.floor((S.length - k) / 2)), Math.max(0, S.length - k)])];
|
|
241
|
+
const budget0 = Math.max(4, Math.ceil(skipRatio * S.length));
|
|
242
|
+
const walk = (Sx, Bx, frag, ss, at) => {
|
|
243
|
+
const pairs = [];
|
|
244
|
+
let i = ss;
|
|
245
|
+
let j = at;
|
|
246
|
+
let budget = budget0;
|
|
247
|
+
while (i < Sx.length && j < Bx.length) {
|
|
248
|
+
if (Sx[i] === Bx[j]) {
|
|
249
|
+
pairs.push([i, j]);
|
|
250
|
+
i++;
|
|
251
|
+
j++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const nS = Bx.indexOf(Sx[i], j); // 块侧:跳到该字符的下一次出现
|
|
255
|
+
let freeB = false;
|
|
256
|
+
if (nS >= 0) {
|
|
257
|
+
freeB = true;
|
|
258
|
+
for (let t = j; t < nS; t++) {
|
|
259
|
+
if (!frag[t]) {
|
|
260
|
+
freeB = false;
|
|
261
|
+
break;
|
|
262
|
+
} // 跳过范围里含正文 → 张冠李戴,候选作废
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const nB = Sx.indexOf(Bx[j], i); // 选区侧多出的字符(文本层公式字形)→ 跳选区侧
|
|
266
|
+
const costS = nB < 0 ? Infinity : nB - i;
|
|
267
|
+
if (freeB) {
|
|
268
|
+
j = nS;
|
|
269
|
+
continue;
|
|
270
|
+
} // 片段内跳过:结构性差异,免费
|
|
271
|
+
if (costS === Infinity || costS > budget)
|
|
272
|
+
break;
|
|
273
|
+
budget -= costS;
|
|
274
|
+
i += costS;
|
|
275
|
+
}
|
|
276
|
+
return pairs;
|
|
277
|
+
};
|
|
278
|
+
const rev = (s) => [...s].reverse().join('');
|
|
279
|
+
const Sr = rev(S);
|
|
280
|
+
const Br = rev(B);
|
|
281
|
+
const fragR = [...inFrag].reverse();
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const ss of seeds) {
|
|
284
|
+
const seed = S.slice(ss, ss + k);
|
|
285
|
+
if (seed.length < 4)
|
|
286
|
+
continue;
|
|
287
|
+
let at = B.indexOf(seed);
|
|
288
|
+
let tries = 0;
|
|
289
|
+
while (at >= 0 && tries < SEED_TRIES) {
|
|
290
|
+
const R = walk(S, B, inFrag, ss, at);
|
|
291
|
+
const L = walk(Sr, Br, fragR, S.length - 1 - ss, B.length - 1 - at).map(([a, b]) => [S.length - 1 - a, B.length - 1 - b]);
|
|
292
|
+
const pairs = R.concat(L)
|
|
293
|
+
.sort((a, b) => a[0] - b[0])
|
|
294
|
+
.filter((p, i, arr) => i === 0 || p[0] !== arr[i - 1][0]);
|
|
295
|
+
out.push({ score: pairs.length / S.length, pairs });
|
|
296
|
+
at = B.indexOf(seed, at + 1);
|
|
297
|
+
tries++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
/** ① 词边界外扩:端点不再是词字符为止(防 v1 实测的 `…solving the follo` 截断)。 */
|
|
303
|
+
function expandWords(raw, a, b) {
|
|
304
|
+
while (a > 0 && WORD_CH.test(raw[a - 1]) && WORD_CH.test(raw[a] ?? ''))
|
|
305
|
+
a--;
|
|
306
|
+
while (b < raw.length && WORD_CH.test(raw[b] ?? '') && WORD_CH.test(raw[b - 1] ?? ''))
|
|
307
|
+
b++;
|
|
308
|
+
return [a, b];
|
|
309
|
+
}
|
|
310
|
+
/** ② 片段边界外扩:端点落在 `$…$` 内部时扩到片段边界(防切出半个公式)。 */
|
|
311
|
+
function expandFragments(raw, a, b) {
|
|
312
|
+
for (const [f0, f1] of fragmentRanges(raw)) {
|
|
313
|
+
if (a > f0 && a < f1)
|
|
314
|
+
a = f0;
|
|
315
|
+
if (b > f0 && b < f1)
|
|
316
|
+
b = f1;
|
|
317
|
+
}
|
|
318
|
+
return [a, b];
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 端点回填(F1 的处方,§12.1):选区侧**未配对的前缀/后缀**映射回块侧。
|
|
322
|
+
* 规则与行走一致:块侧跳过必须整段在 `$…$` 片段内,且总跳过量受同一预算约束;
|
|
323
|
+
* 只要有一字符映射不到,就整体放弃回填(绝不让端点落在猜出来的位置上)。
|
|
324
|
+
*
|
|
325
|
+
* ⚠️ **防御性代码:当前没有任何用例能区分它**(t4 评审 R-08 的独立变异 M16:把本函数关掉,
|
|
326
|
+
* 全量套件 0 红、真实 Dong 语料 0 差异;t7 又专门构造了「前缀 + N 个重复字形 + 公式」
|
|
327
|
+
* (N=5/20/40/80)与「公式在前、正文词在后 + 重复字形」等形态,仍然构造不出差异)。
|
|
328
|
+
* 原因是可论证的:规则 ① 让块侧跳过「只在 `$…$` 内且免费」之后,反向行走用**独立的**
|
|
329
|
+
* 预算从锚点一路回到选区开头;凡是回填能映射的前缀(在块里紧邻、只需片段内跳过),
|
|
330
|
+
* 反向行走同样能配对;而反向行走配不上的前缀,其间的块侧字符必含正文 —— 回填的
|
|
331
|
+
* 片段内检查也会同样失败。**F1 的真正修复是规则 ① + 覆盖门槛**(两者都有可区分的用例)。
|
|
332
|
+
* 保留本函数是纵深防御:万一将来放宽规则 ①,端点回填仍在(且不改变任何已接受的结果)。
|
|
333
|
+
*/
|
|
334
|
+
function backfillEnds(S, B, inFrag, p, q, bMin, bMax, budget) {
|
|
335
|
+
const out = { lo: null, hi: null, covered: 0 };
|
|
336
|
+
if (p > 0 && bMin > 0) {
|
|
337
|
+
let j = bMin - 1;
|
|
338
|
+
let rest = budget;
|
|
339
|
+
let earliest = -1;
|
|
340
|
+
let ok = true;
|
|
341
|
+
for (let i = p - 1; i >= 0; i--) {
|
|
342
|
+
const at = B.lastIndexOf(S[i], j);
|
|
343
|
+
if (at < 0 || j - at > rest) {
|
|
344
|
+
ok = false;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
for (let t = at + 1; t <= j; t++) {
|
|
348
|
+
if (!inFrag[t]) {
|
|
349
|
+
ok = false;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!ok)
|
|
354
|
+
break;
|
|
355
|
+
rest -= j - at;
|
|
356
|
+
j = at - 1;
|
|
357
|
+
earliest = at;
|
|
358
|
+
}
|
|
359
|
+
if (ok) {
|
|
360
|
+
out.lo = earliest;
|
|
361
|
+
out.covered += p;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (q < S.length - 1 && bMax + 1 < B.length) {
|
|
365
|
+
let j = bMax + 1;
|
|
366
|
+
let rest = budget;
|
|
367
|
+
let latest = -1;
|
|
368
|
+
let ok = true;
|
|
369
|
+
for (let i = q + 1; i < S.length; i++) {
|
|
370
|
+
const at = B.indexOf(S[i], j);
|
|
371
|
+
if (at < 0 || at - j > rest) {
|
|
372
|
+
ok = false;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
for (let t = j; t < at; t++) {
|
|
376
|
+
if (!inFrag[t]) {
|
|
377
|
+
ok = false;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (!ok)
|
|
382
|
+
break;
|
|
383
|
+
rest -= at - j;
|
|
384
|
+
j = at + 1;
|
|
385
|
+
latest = at;
|
|
386
|
+
}
|
|
387
|
+
if (ok) {
|
|
388
|
+
out.hi = latest;
|
|
389
|
+
out.covered += S.length - 1 - q;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
/** 选区原文最后一段词(小写;忽略行尾标点/连字符)。用于端点补词门槛。 */
|
|
395
|
+
function trailingWordOf(chunkText) {
|
|
396
|
+
const t = chunkText.replace(/[^A-Za-z0-9]+$/, '');
|
|
397
|
+
const m = /[A-Za-z0-9]+$/.exec(t);
|
|
398
|
+
return m ? m[0].toLowerCase() : '';
|
|
399
|
+
}
|
|
400
|
+
/** 单个(块, 种子)候选:求跨度 → 端点回填 → 三次修正 → `$` 校验 → 绝对门槛 + 长度保险。 */
|
|
401
|
+
function candidateFor(S, raw, B, Bidx, inFrag, hit, chunkText) {
|
|
402
|
+
if (!hit.pairs.length)
|
|
403
|
+
return null;
|
|
404
|
+
let bMin = Infinity;
|
|
405
|
+
let bMax = -1;
|
|
406
|
+
for (const [, b] of hit.pairs) {
|
|
407
|
+
if (b < bMin)
|
|
408
|
+
bMin = b;
|
|
409
|
+
if (b > bMax)
|
|
410
|
+
bMax = b;
|
|
411
|
+
}
|
|
412
|
+
const paired = new Set(hit.pairs.map((p) => p[0]));
|
|
413
|
+
let p = 0;
|
|
414
|
+
while (p < S.length && !paired.has(p))
|
|
415
|
+
p++;
|
|
416
|
+
let q = S.length - 1;
|
|
417
|
+
while (q >= 0 && !paired.has(q))
|
|
418
|
+
q--;
|
|
419
|
+
const bf = backfillEnds(S, B, inFrag, p, q, bMin, bMax, Math.max(4, Math.ceil(SKIP_RATIO * S.length)));
|
|
420
|
+
const loIdx = bf.lo ?? bMin;
|
|
421
|
+
const hiIdx = bf.hi ?? bMax;
|
|
422
|
+
let [a, b] = [Bidx[loIdx], Bidx[hiIdx] + 1];
|
|
423
|
+
[a, b] = expandWords(raw, a, b);
|
|
424
|
+
[a, b] = expandFragments(raw, a, b);
|
|
425
|
+
const text = raw.slice(a, b);
|
|
426
|
+
const dollars = (text.match(/\$/g) ?? []).length;
|
|
427
|
+
// ⚠️ **防御性代码:当前没有用例能区分它**(t4 评审 R-08 的变异 M19:关掉本行,全量套件 0 红、
|
|
428
|
+
// 真实 Dong 语料 0 差异;t7 又用「块里含字面 `$`(`the cost is $5 and the value $x_t$`,`$` 计数为奇数)」
|
|
429
|
+
// 试图触发,拒绝都发生在别的闸门(completedTail/no-match)之后)。
|
|
430
|
+
// 按构造即可论证:块内 `$` 计数恒偶(t1 实测),端点在片段内时 expandFragments 会把端点贴到
|
|
431
|
+
// 片段边界 ⇒ 跨度内含的片段必成对。保留它是纵深防御(`$` 是原子的,半公式比不还原更糟)。
|
|
432
|
+
if (dollars % 2 !== 0)
|
|
433
|
+
return null;
|
|
434
|
+
// ③ 覆盖门槛(绝对):跨度必须解释选区的**每一个字母**。
|
|
435
|
+
// 数字/符号允许留在外面(公式编号 `(4)` 会以裸数字出现在选区里),字母不行:
|
|
436
|
+
// 选区里有字母而块里没有 ⇒ 本段不在候选块里(t3 报告 F2 的 `, while a learner encoder`)。
|
|
437
|
+
// 块侧不可能出现「跨度内部被吞掉的散文」:行走的块侧跳过只允许发生在 `$…$` 片段内(规则 ①),
|
|
438
|
+
// 所以内部多出来的块侧字符必然是 LaTeX 命令/括号,不是别人的句子。
|
|
439
|
+
let unpairedLetters = '';
|
|
440
|
+
for (let i = 0; i < S.length; i++) {
|
|
441
|
+
if (paired.has(i))
|
|
442
|
+
continue;
|
|
443
|
+
if (p > 0 && i < p && bf.lo !== null)
|
|
444
|
+
continue;
|
|
445
|
+
if (q < S.length - 1 && i > q && bf.hi !== null)
|
|
446
|
+
continue;
|
|
447
|
+
if (/[a-z]/.test(S[i]))
|
|
448
|
+
unpairedLetters += S[i];
|
|
449
|
+
}
|
|
450
|
+
// ④ 端点补词门槛:跨度末尾的词比**选区原文**末尾的词更长(expandWords 补了词尾)时,
|
|
451
|
+
// 选区原文必须带换行连字符 —— 否则就是「被别处的词接走」(`Instruction:` → `instructions`)。
|
|
452
|
+
let completedTail = '';
|
|
453
|
+
const selWord = trailingWordOf(chunkText);
|
|
454
|
+
if (selWord && b > 0 && WORD_CH.test(raw[b - 1])) {
|
|
455
|
+
let spanWord = '';
|
|
456
|
+
for (let k = b - 1; k >= 0 && WORD_CH.test(raw[k]); k--)
|
|
457
|
+
spanWord = raw[k] + spanWord;
|
|
458
|
+
if (spanWord.toLowerCase() !== selWord && !/[-\u2010\u2011\u2012\u2013\u2014\u2212]$/.test(chunkText.trimEnd())) {
|
|
459
|
+
completedTail = spanWord;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const ratio = contentNormForRatio(text).length / S.length;
|
|
463
|
+
const ok = ratio >= RATIO_LO && ratio <= RATIO_HI && unpairedLetters === '' && completedTail === '';
|
|
464
|
+
return { text, score: hit.score, ratio, ok, unpairedLetters, completedTail };
|
|
465
|
+
}
|
|
466
|
+
/** 候选择优规则(原型同款):先 `ok`,再 `score`。 */
|
|
467
|
+
function better(a, b) {
|
|
468
|
+
if (!a)
|
|
469
|
+
return b;
|
|
470
|
+
if (!b)
|
|
471
|
+
return a;
|
|
472
|
+
if (b.ok !== a.ok)
|
|
473
|
+
return b.ok ? b : a;
|
|
474
|
+
return b.score > a.score ? b : a;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* 一行选区文本 ↔ 一个 MinerU text 块 → 还原后的文本。
|
|
478
|
+
* 纯函数;块文本在内部过一遍 `compactLatex`(幂等:块索引里已经是紧凑形态)。
|
|
479
|
+
*
|
|
480
|
+
* 交付侧语义(t6 修复后):`applied=1` ⟺ 跨度覆盖了选区**每一个字母**、没有块侧多出来的散文、
|
|
481
|
+
* 长度比在 `[0.6,1.35]`、`score ≥ 0.75`。任一条不满足 ⇒ `applied=0` 且**原样返回** chunkText,
|
|
482
|
+
* 由 §3.8 的文案表如实提示(绝不静默换掉用户的句子)。
|
|
483
|
+
*/
|
|
484
|
+
export function alignSelectionToBlock(chunkText, blockText) {
|
|
485
|
+
const { A: S } = normForAlign(chunkText);
|
|
486
|
+
if (S.length < MIN_NORM)
|
|
487
|
+
return { text: chunkText, applied: 0, score: null, skipped: true, reason: 'short' };
|
|
488
|
+
const raw = compactLatex(blockText);
|
|
489
|
+
const { A: B, idx: Bidx } = normForAlign(raw);
|
|
490
|
+
if (!B.length)
|
|
491
|
+
return { text: chunkText, applied: 0, score: null, skipped: false, reason: 'no-match' };
|
|
492
|
+
const inFrag = fragmentFlags(raw, Bidx);
|
|
493
|
+
let best = null;
|
|
494
|
+
for (const hit of alignSeeds(S, B, inFrag)) {
|
|
495
|
+
best = better(best, candidateFor(S, raw, B, Bidx, inFrag, hit, chunkText));
|
|
496
|
+
}
|
|
497
|
+
if (!best || !best.ok || best.score < MIN_SCORE) {
|
|
498
|
+
return {
|
|
499
|
+
text: chunkText, applied: 0, skipped: false, reason: best ? 'gate' : 'no-match',
|
|
500
|
+
score: best ? best.score : null,
|
|
501
|
+
...(best ? { ratio: best.ratio, unpairedLetters: best.unpairedLetters, completedTail: best.completedTail } : {}),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return { text: best.text, applied: 1, score: best.score, ratio: best.ratio, skipped: false, unpairedLetters: '', completedTail: '' };
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* 从一个**已中和**的 piece 里按出现顺序抽出 `$…$` 片段 = 真公式(N-02)。
|
|
508
|
+
* 这里刻意不再做一次「看起来像 LaTeX」的过滤:唯一那道闸门是 `neutralizeNonLatexPairs`
|
|
509
|
+
* (它已把非 LaTeX 的成对 `$` 换成全角),再加一层过滤会变成**没有变异能区分的冗余代码**
|
|
510
|
+
* (t9 的 M-N02b/M-N02c 实测:删掉过滤,全部用例仍绿)。单一闸门 = 单一事实源。
|
|
511
|
+
*/
|
|
512
|
+
function latexFragments(s) {
|
|
513
|
+
return [...s.matchAll(/\$[^$]*\$/g)].map((m) => m[0]);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* 组装源文本(§3.3②/§3.4/§3.5)。
|
|
517
|
+
* - `formula` chunk → `$` + 紧凑 LaTeX + `$`(**该段乱码字符不进入请求体**)
|
|
518
|
+
* - `text` chunk → 在**该页所有候选块**里取最高分(跨块/跨页各自命中自己的页)
|
|
519
|
+
* - 页码变化处插一个 `\n`(§3.5 ④)
|
|
520
|
+
* - 清洗按 piece 分类做:`$…$` 内逐字保留 LaTeX,外面清文本层乱码(见 `stripJunk` 的说明)
|
|
521
|
+
*
|
|
522
|
+
* 行尾换行必须保留:客户端把换行作为 text 段的**结尾字符**发过来(§3.2-6),而替换用的块跨度
|
|
523
|
+
* 不含换行;不补回去,两行正文会被粘成一行(system 第 3 条要求保持行结构)。
|
|
524
|
+
*/
|
|
525
|
+
export function assembleSource(chunks, index) {
|
|
526
|
+
const parts = [];
|
|
527
|
+
const tokens = [];
|
|
528
|
+
let textChunkCount = 0;
|
|
529
|
+
let applied = 0;
|
|
530
|
+
let skipped = 0;
|
|
531
|
+
let lastPage = null;
|
|
532
|
+
for (const c of chunks) {
|
|
533
|
+
if (lastPage !== null && c.page !== lastPage)
|
|
534
|
+
parts.push('\n');
|
|
535
|
+
lastPage = c.page;
|
|
536
|
+
if (c.kind === 'formula') {
|
|
537
|
+
const piece = '$' + (c.latex ?? '') + '$';
|
|
538
|
+
parts.push(piece);
|
|
539
|
+
tokens.push(piece); // 客户端按 equation bbox 切出来的 ⇒ 一定是真公式
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
textChunkCount++;
|
|
543
|
+
const chunkText = c.text ?? '';
|
|
544
|
+
const eol = /[ \t]*\r?\n[ \t]*$/.test(chunkText) ? '\n' : '';
|
|
545
|
+
let best = null;
|
|
546
|
+
let wasSkipped = false;
|
|
547
|
+
for (const blk of index?.blocks ?? []) {
|
|
548
|
+
if (blk.page !== c.page)
|
|
549
|
+
continue;
|
|
550
|
+
const r = alignSelectionToBlock(chunkText, blk.text);
|
|
551
|
+
if (r.skipped) {
|
|
552
|
+
wasSkipped = true;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
best = better(best, r.applied === 1
|
|
556
|
+
? { text: r.text + eol, score: r.score ?? 0, ratio: r.ratio ?? 1, ok: true, unpairedLetters: '', completedTail: '' }
|
|
557
|
+
: null);
|
|
558
|
+
}
|
|
559
|
+
if (wasSkipped)
|
|
560
|
+
skipped++;
|
|
561
|
+
if (best) {
|
|
562
|
+
applied++;
|
|
563
|
+
// 块跨度:里面的 `$…$` 是块文本的 LaTeX 片段;但块正文里也可能印了**字面** `$`(`$5 up to $`)。
|
|
564
|
+
// 只保留「看起来是 LaTeX」的片段,其余 `$` 中和成全角(N-02);token 在**组装期**收集。
|
|
565
|
+
const span = neutralizeNonLatexPairs(best.text);
|
|
566
|
+
parts.push(span);
|
|
567
|
+
for (const frag of latexFragments(span))
|
|
568
|
+
tokens.push(frag);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
// 未对齐 → 这段就是文本层原文,里面的 `$` 只可能是**字面字符**(`$5` 之类),
|
|
572
|
+
// 不是定界符。必须中和掉:否则它会与别处的 `$` 配成假片段,
|
|
573
|
+
// 让 `formulaTokens`/`verifyFormulas` 把真公式漏掉(R-03 实测:源 `cost is $5 and $x_t$`
|
|
574
|
+
// 只识别出一个 token `$5 and $`,`$x_t$` 丢失时 formulaLost 仍为 0 ⇒ A7 失效)。
|
|
575
|
+
parts.push(neutralizeDollars(chunkText));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
source: collapse(parts.map(cleanPiece).join('')),
|
|
580
|
+
textChunkCount,
|
|
581
|
+
applied,
|
|
582
|
+
skipped,
|
|
583
|
+
hasBlockIndex: index?.source === 'mineru',
|
|
584
|
+
formulaTokens: tokens,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/** `formulaStatus` 判定(§3.9 表)。 */
|
|
588
|
+
export function formulaStatusOf(r) {
|
|
589
|
+
if (!r.hasBlockIndex)
|
|
590
|
+
return 'no-artifact';
|
|
591
|
+
if (r.textChunkCount === 0)
|
|
592
|
+
return 'applied';
|
|
593
|
+
if (r.applied === 0)
|
|
594
|
+
return 'unavailable';
|
|
595
|
+
if (r.applied < r.textChunkCount)
|
|
596
|
+
return 'partial';
|
|
597
|
+
return 'applied';
|
|
598
|
+
}
|
|
599
|
+
/* ── 提示词与请求体(§3.6)────────────────────────────────────────────── */
|
|
600
|
+
export function clampMaxTokens(chars) {
|
|
601
|
+
return Math.min(4096, Math.max(256, Math.ceil(1.5 * chars)));
|
|
602
|
+
}
|
|
603
|
+
/** 推理型模型关思考(与 src/translate.ts:264 同一判据、同一理由:自定义端点不加)。 */
|
|
604
|
+
export function wantsThinkingOff(baseUrl) {
|
|
605
|
+
return /bigmodel|kimi|moonshot|deepseek/i.test(baseUrl);
|
|
606
|
+
}
|
|
607
|
+
/** 请求体:**只有** model/messages/max_tokens/temperature(命中域名时才多 thinking)。 */
|
|
608
|
+
export function buildRequestBody(model, source, baseUrl = '') {
|
|
609
|
+
const body = {
|
|
610
|
+
model,
|
|
611
|
+
messages: [
|
|
612
|
+
{ role: 'system', content: SELECTION_SYSTEM_PROMPT },
|
|
613
|
+
{ role: 'user', content: source },
|
|
614
|
+
],
|
|
615
|
+
max_tokens: clampMaxTokens(source.length),
|
|
616
|
+
temperature: 0,
|
|
617
|
+
};
|
|
618
|
+
if (wantsThinkingOff(baseUrl))
|
|
619
|
+
body['thinking'] = { type: 'disabled' };
|
|
620
|
+
return body;
|
|
621
|
+
}
|
|
622
|
+
/* ── 响应清洗与公式保全校验(§3.6-2/3/4)──────────────────────────────── */
|
|
623
|
+
/** 整体被 ``` 包裹时剥掉围栏与语言标注。 */
|
|
624
|
+
export function stripFence(s) {
|
|
625
|
+
const t = s.trim();
|
|
626
|
+
const m = /^```[A-Za-z0-9_+-]*[ \t]*\r?\n([\s\S]*?)\r?\n?```$/.exec(t);
|
|
627
|
+
return m ? m[1].trim() : t;
|
|
628
|
+
}
|
|
629
|
+
/** 剥掉行首的 `译文:` / `翻译:` / `Translation:`。 */
|
|
630
|
+
export function stripPrefix(s) {
|
|
631
|
+
return s.replace(/^\s*(?:译文|翻译|Translation)\s*[::]\s*/, '');
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* 源文本里的 `$…$` token(按出现顺序)。
|
|
635
|
+
* **权威来源是 `assembleSource` 在组装期收集的 `formulaTokens` 列表**(N-02)——组装文本里的
|
|
636
|
+
* 每个 `$…$` 都在组装期过了唯一那道闸门(`neutralizeNonLatexPairs`:字面 `$` 中和为全角),
|
|
637
|
+
* 所以这里再解析一遍得到的列表与那个列表**逐项相同**(测试 `N-02` 直接断言这条等价性)。
|
|
638
|
+
* 保留本函数是为了兼容「只拿到源文本」的调用方(如 host 路由,不在本轮 inScope)与单测。
|
|
639
|
+
*/
|
|
640
|
+
export function formulaTokens(source) {
|
|
641
|
+
return [...source.matchAll(/\$[^$]*\$/g)].map((m) => m[0]);
|
|
642
|
+
}
|
|
643
|
+
/** 逐 token 原样 `includes` 校验(§3.6-4)。v1 不做自动回插,只回报缺失数。 */
|
|
644
|
+
export function verifyFormulas(source, translation, tokens) {
|
|
645
|
+
// tokens 优先:调用方(路由/组装方)应传 `assembleSource(...).formulaTokens`,避免回头解析字符串
|
|
646
|
+
const list = tokens ?? formulaTokens(source);
|
|
647
|
+
const missing = list.filter((t) => !translation.includes(t));
|
|
648
|
+
return { total: list.length, kept: list.length - missing.length, lost: missing.length, missing };
|
|
649
|
+
}
|
|
650
|
+
/* ── 缓存(§3.7④):进程内存 LRU,不落盘 ───────────────────────────────── */
|
|
651
|
+
/** 键 = `sha256(model + '\0' + assembledText)`;**不含 key**(不做任何密钥派生)。 */
|
|
652
|
+
export function selectionCacheKey(model, source) {
|
|
653
|
+
return createHash('sha256').update(model + '\0' + source, 'utf8').digest('hex');
|
|
654
|
+
}
|
|
655
|
+
/** 极简 LRU:Map 的插入序即最近使用序(命中先删再插)。零依赖。 */
|
|
656
|
+
export class TranslateSelectionCache {
|
|
657
|
+
limit;
|
|
658
|
+
map = new Map();
|
|
659
|
+
constructor(limit = CACHE_LIMIT) {
|
|
660
|
+
this.limit = limit;
|
|
661
|
+
}
|
|
662
|
+
get(key) {
|
|
663
|
+
const hit = this.map.get(key);
|
|
664
|
+
if (hit === undefined)
|
|
665
|
+
return undefined;
|
|
666
|
+
this.map.delete(key);
|
|
667
|
+
this.map.set(key, hit);
|
|
668
|
+
return hit;
|
|
669
|
+
}
|
|
670
|
+
set(key, value) {
|
|
671
|
+
if (this.map.has(key))
|
|
672
|
+
this.map.delete(key);
|
|
673
|
+
this.map.set(key, value);
|
|
674
|
+
while (this.map.size > this.limit) {
|
|
675
|
+
const oldest = this.map.keys().next().value;
|
|
676
|
+
if (oldest === undefined)
|
|
677
|
+
break;
|
|
678
|
+
this.map.delete(oldest);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
get size() { return this.map.size; }
|
|
682
|
+
clear() { this.map.clear(); }
|
|
683
|
+
}
|
|
684
|
+
/** 进程级默认缓存(服务端路由用它;测试可注入自己的实例)。 */
|
|
685
|
+
export const selectionCache = new TranslateSelectionCache();
|
|
686
|
+
function networkDetail(err) {
|
|
687
|
+
const cause = err instanceof Error && err.cause instanceof Error ? `: ${err.cause.message}` : '';
|
|
688
|
+
return (err instanceof Error ? err.message : String(err)) + cause;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* 调端点翻译一段源文本。
|
|
692
|
+
* - body/URL/头与 §3.6 写死一致;**不**发 response_format/reasoning/tools/top_p/stream
|
|
693
|
+
* - 唯一的重试:HTTP 400 且响应体匹配 `/temperature/i` → 去掉 `temperature` 重发一次
|
|
694
|
+
* - 上游错误详情一律过 `sanitizeDetail(text, apiKey)`;本函数不打印任何 key
|
|
695
|
+
*/
|
|
696
|
+
export async function translateSelection(endpoint, source, deps = {}) {
|
|
697
|
+
const baseUrl = endpoint.baseUrl ?? '';
|
|
698
|
+
const apiKey = endpoint.apiKey ?? '';
|
|
699
|
+
const model = endpoint.model ?? '';
|
|
700
|
+
if (!baseUrl || !apiKey || !model) {
|
|
701
|
+
throw new SelectionTranslateError('no-endpoint', 503, '未配置翻译端点:在阅读器里点 ⚙ 填写(OpenAI 兼容端点即可)');
|
|
702
|
+
}
|
|
703
|
+
const cache = deps.cache ?? selectionCache;
|
|
704
|
+
const key = selectionCacheKey(model, source);
|
|
705
|
+
const hit = cache.get(key);
|
|
706
|
+
if (hit !== undefined) {
|
|
707
|
+
const v = verifyFormulas(source, hit);
|
|
708
|
+
return { translation: hit, model, cached: true, ms: 0, formulaTotal: v.total, formulaKept: v.kept, formulaLost: v.lost };
|
|
709
|
+
}
|
|
710
|
+
const fetchImpl = (deps.fetchImpl ?? globalThis.fetch);
|
|
711
|
+
const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
|
|
712
|
+
const headers = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` };
|
|
713
|
+
const started = Date.now();
|
|
714
|
+
const send = async (body) => {
|
|
715
|
+
try {
|
|
716
|
+
return await fetchImpl(url, {
|
|
717
|
+
method: 'POST',
|
|
718
|
+
headers,
|
|
719
|
+
body: JSON.stringify(body),
|
|
720
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? UPSTREAM_TIMEOUT_MS),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
// 超时 vs 连不上:两者文案不同(§3.7②)
|
|
725
|
+
const name = err instanceof Error ? err.name : '';
|
|
726
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
727
|
+
throw new SelectionTranslateError('timeout', 504, '翻译超时,请重试');
|
|
728
|
+
}
|
|
729
|
+
// 网络异常文本同样必须过 sanitizeDetail(R-04):fetch 抛出的原因串可能带上
|
|
730
|
+
// baseUrl(含凭据/查询参数)或回显 Authorization,而这条消息会原样进响应体到前端。
|
|
731
|
+
throw new SelectionTranslateError('network', 502, `无法连接端点:${sanitizeDetail(networkDetail(err), apiKey)}`);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
let res = await send(buildRequestBody(model, source, baseUrl));
|
|
735
|
+
let resText = null;
|
|
736
|
+
if (res.status === 400) {
|
|
737
|
+
const probe = await res.text().catch(() => '');
|
|
738
|
+
// 唯一的白名单重试:严格端点不认 temperature
|
|
739
|
+
if (/temperature/i.test(probe)) {
|
|
740
|
+
const body = buildRequestBody(model, source, baseUrl);
|
|
741
|
+
delete body['temperature'];
|
|
742
|
+
res = await send(body);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
resText = probe;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (!res.ok) {
|
|
749
|
+
const text = (resText ?? await res.text().catch(() => '')).slice(0, 200);
|
|
750
|
+
throw new SelectionTranslateError('upstream', 502, `上游返回 HTTP ${res.status}${text ? ':' + sanitizeDetail(text, apiKey) : ''}`, res.status);
|
|
751
|
+
}
|
|
752
|
+
const rawText = resText ?? await res.text().catch(() => '');
|
|
753
|
+
let data;
|
|
754
|
+
try {
|
|
755
|
+
data = JSON.parse(rawText);
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
throw new SelectionTranslateError('upstream', 502, `端点响应不是合法 JSON:${sanitizeDetail(rawText.slice(0, 200), apiKey)}`, res.status);
|
|
759
|
+
}
|
|
760
|
+
const content = data?.choices?.[0]?.message?.content;
|
|
761
|
+
if (typeof content !== 'string' || content.trim() === '') {
|
|
762
|
+
throw new SelectionTranslateError('upstream', 502, '端点没有返回补全内容(choices[0].message.content 为空)', res.status);
|
|
763
|
+
}
|
|
764
|
+
const translation = stripPrefix(stripFence(content));
|
|
765
|
+
const ms = Date.now() - started;
|
|
766
|
+
cache.set(key, translation);
|
|
767
|
+
const v = verifyFormulas(source, translation);
|
|
768
|
+
// 日志白名单:只有 model/status/ms/chars(§3.7③)——key 与 baseUrl 永不出现
|
|
769
|
+
const log = deps.log ?? ((info) => console.log('[dsh-paper-reader] translate-selection', JSON.stringify(info)));
|
|
770
|
+
log({ model, status: 200, ms, chars: source.length });
|
|
771
|
+
return { translation, model, cached: false, ms, formulaTotal: v.total, formulaKept: v.kept, formulaLost: v.lost };
|
|
772
|
+
}
|