dsh-cait 1.0.4 → 1.1.3

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.
@@ -0,0 +1,263 @@
1
+ import { CatError } from './errors.js'
2
+ import { DEFAULT_TM_ID, clampInt, normalizeSegmentState, now } from './store-utils.js'
3
+ import { normalizeText } from './similarity.js'
4
+ import { contextHashOf, contextOf } from './context.js'
5
+
6
+ export const PRETRANSLATE_BLOCK_MAX = 32
7
+ export const PRETRANSLATE_DEFAULT_COUNT = 8
8
+ export const PRETRANSLATE_CONCURRENCY = 2
9
+
10
+ /**
11
+ * Run `worker` over `items` with a small pool. Machine translation is network
12
+ * bound, so a little concurrency buys most of the speed without hammering the
13
+ * provider or the event loop.
14
+ */
15
+ async function runPool(items, limit, worker) {
16
+ const results = new Array(items.length)
17
+ let cursor = 0
18
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
19
+ while (cursor < items.length) {
20
+ const position = cursor
21
+ cursor += 1
22
+ results[position] = await worker(items[position], position)
23
+ }
24
+ })
25
+ await Promise.all(runners)
26
+ return results
27
+ }
28
+
29
+ export const preTranslateMethods = {
30
+ /**
31
+ * Exact TM lookup by key, for pre-filling a block without scoring every entry.
32
+ * Mounted libraries only, matching the document's language pair. Each key gets
33
+ * one generic entry (no context) plus its context variants, so a segment can
34
+ * be resolved as ICE first and as a plain 100% match second.
35
+ */
36
+ tmExactIndex(doc) {
37
+ const mounted = new Set(this.mounts.tmIds)
38
+ const index = new Map()
39
+ for (const entry of this.tm) {
40
+ if (!mounted.has(entry.tmId || DEFAULT_TM_ID)) continue
41
+ if (entry.sourceLang !== doc.sourceLang || entry.targetLang !== doc.targetLang) continue
42
+ if (!entry.key) continue
43
+ let slot = index.get(entry.key)
44
+ if (!slot) {
45
+ slot = { generic: null, byContext: new Map(), latest: null }
46
+ index.set(entry.key, slot)
47
+ }
48
+ if (!slot.latest || entry.updatedAt > slot.latest.updatedAt) slot.latest = entry
49
+ if (!entry.contextHash) {
50
+ if (!slot.generic) slot.generic = entry
51
+ } else if (!slot.byContext.has(entry.contextHash)) {
52
+ slot.byContext.set(entry.contextHash, entry)
53
+ }
54
+ }
55
+ return index
56
+ }
57
+ ,
58
+ /**
59
+ * Resolve one segment against the exact index: context match first, then the
60
+ * generic entry, then any variant of the same text. A text match with a
61
+ * different context is still worth 100%, so it must not be dropped.
62
+ */
63
+ tmEntryForSegment(index, doc, segmentIndex, segment) {
64
+ const slot = index.get(normalizeText(segment.source))
65
+ if (!slot) return { entry: null, ice: false }
66
+ const hash = contextHashOf(contextOf(doc, segmentIndex))
67
+ if (hash) {
68
+ const ice = slot.byContext.get(hash)
69
+ if (ice) return { entry: ice, ice: true }
70
+ }
71
+ return { entry: slot.generic ?? slot.latest, ice: false }
72
+ }
73
+ ,
74
+ /**
75
+ * Pre-translate one block of segments: TM exact matches first, then machine
76
+ * translation. Never overwrites a segment that already has a target, so
77
+ * human work and earlier passes survive. Returns the next block offset so the
78
+ * client can drive progress and cancellation by looping.
79
+ */
80
+ async preTranslate({ documentId, from = 0, count = PRETRANSLATE_DEFAULT_COUNT, mode = 'mt', overwrite = false } = {}) {
81
+ const doc = this.requireDocument(documentId)
82
+ const total = doc.segments.length
83
+ if (!total) throw new CatError('NO_SEGMENTS', '当前文档没有句段。', 409)
84
+
85
+ const translationMode = String(mode ?? 'mt').trim() || 'mt'
86
+ if (!['mt', 'tm'].includes(translationMode)) {
87
+ throw new CatError('INVALID_PRETRANSLATE_MODE', '预翻译模式必须是 mt 或 tm。')
88
+ }
89
+
90
+ const start = clampInt(from, 0, Math.max(0, total - 1), 0)
91
+ const size = clampInt(count, 1, PRETRANSLATE_BLOCK_MAX, PRETRANSLATE_DEFAULT_COUNT)
92
+ const end = Math.min(total, start + size)
93
+ const tmIndex = this.tmExactIndex(doc)
94
+ const skipped = []
95
+ const work = []
96
+
97
+ for (let index = start; index < end; index += 1) {
98
+ const segment = doc.segments[index]
99
+ if (segment.status === 'confirmed') {
100
+ skipped.push({ index, reason: 'confirmed' })
101
+ continue
102
+ }
103
+ if (segment.target && !overwrite) {
104
+ skipped.push({ index, reason: 'translated' })
105
+ continue
106
+ }
107
+ work.push({ index, segment })
108
+ }
109
+
110
+ const failed = []
111
+ const filled = await runPool(work, PRETRANSLATE_CONCURRENCY, async ({ index, segment }) => {
112
+ const match = this.tmEntryForSegment(tmIndex, doc, index, segment)
113
+ try {
114
+ let text = ''
115
+ let origin = 'mt'
116
+ if (match.entry && match.entry.target.trim()) {
117
+ text = match.entry.target
118
+ origin = 'tm'
119
+ } else if (translationMode === 'tm') {
120
+ return { index, reason: 'no-tm-match' }
121
+ } else {
122
+ const result = await this.mtSuggest({
123
+ text: segment.source,
124
+ documentId: doc.id,
125
+ index,
126
+ sourceLang: doc.sourceLang,
127
+ targetLang: doc.targetLang,
128
+ })
129
+ text = String(result?.text ?? '').trim()
130
+ if (!text) throw new CatError('MT_EMPTY', '机器翻译没有返回译文。', 502)
131
+ }
132
+ segment.target = text
133
+ // Keep the baseline the machine produced: the review pane shows it in
134
+ // the suggestion column so the human can compare against their edit.
135
+ segment.suggestion = text
136
+ segment.origin = origin
137
+ segment.updatedAt = now()
138
+ normalizeSegmentState(segment)
139
+ return { index, status: segment.status, origin, ice: origin === 'tm' && match.ice }
140
+ } catch (error) {
141
+ failed.push({ index, code: error?.code || 'PRETRANSLATE_FAILED', message: error?.message || String(error) })
142
+ return null
143
+ }
144
+ })
145
+
146
+ const segments = filled.filter(Boolean)
147
+ const unchanged = segments.filter((item) => item.reason)
148
+ if (segments.length || unchanged.length) {
149
+ this.touch()
150
+ this.persist()
151
+ }
152
+
153
+ return {
154
+ documentId: doc.id,
155
+ from: start,
156
+ to: end,
157
+ count: end - start,
158
+ mode: translationMode,
159
+ filled: segments.filter((item) => !item.reason).length,
160
+ noMatch: unchanged.map((item) => item.index),
161
+ skipped,
162
+ failed,
163
+ progress: this.progress(doc),
164
+ next: end < total ? end : null,
165
+ segments: segments.filter((item) => !item.reason).map((item) => ({
166
+ index: item.index,
167
+ status: item.status,
168
+ origin: item.origin,
169
+ ice: Boolean(item.ice),
170
+ })),
171
+ }
172
+ }
173
+ ,
174
+ /** Segment listing for agents: bounded, status-filterable, cursor-free. */
175
+ segmentsForTool({ documentId, from = 0, count = 20, status } = {}) {
176
+ const doc = this.requireDocument(documentId)
177
+ const total = doc.segments.length
178
+ const start = clampInt(from, 0, Math.max(0, total - 1), 0)
179
+ const size = clampInt(count, 1, 50, 20)
180
+ const wanted = String(status ?? '').trim()
181
+ const rows = []
182
+ for (let index = start; index < total && rows.length < size; index += 1) {
183
+ const segment = doc.segments[index]
184
+ if (wanted && segment.status !== wanted) continue
185
+ rows.push({
186
+ index,
187
+ id: segment.id,
188
+ source: segment.source,
189
+ target: segment.target,
190
+ status: segment.status,
191
+ origin: segment.origin,
192
+ suggestion: segment.suggestion || '',
193
+ })
194
+ }
195
+ return {
196
+ document: {
197
+ id: doc.id,
198
+ name: doc.name,
199
+ sourceLang: doc.sourceLang,
200
+ targetLang: doc.targetLang,
201
+ total,
202
+ cursor: doc.cursor,
203
+ },
204
+ from: start,
205
+ segments: rows,
206
+ next: start + size < total ? start + size : null,
207
+ progress: this.progress(doc),
208
+ }
209
+ }
210
+ ,
211
+ /**
212
+ * Batch confirm. Segments without a target are reported, not invented; each
213
+ * confirmed pair goes through the normal confirmSegment path so the TM write
214
+ * and the strict persistence rollback keep working per segment.
215
+ */
216
+ confirmSegmentsForTool({ documentId, indices, from = 0, count = 20, status } = {}) {
217
+ const doc = this.requireDocument(documentId)
218
+ const total = doc.segments.length
219
+ let targets
220
+ if (Array.isArray(indices) && indices.length) {
221
+ targets = [...new Set(indices.map((index) => Math.trunc(Number(index))))].filter(
222
+ (index) => Number.isInteger(index) && index >= 0 && index < total,
223
+ )
224
+ } else if (status === 'translated') {
225
+ // "Confirm everything awaiting review" — the bulk action behind the
226
+ // review pane. Bounded so one click cannot confirm an enormous document
227
+ // without the user seeing a count first.
228
+ targets = []
229
+ for (let index = 0; index < total && targets.length < 500; index += 1) {
230
+ if (doc.segments[index].status === 'translated') targets.push(index)
231
+ }
232
+ } else {
233
+ const start = clampInt(from, 0, Math.max(0, total - 1), 0)
234
+ const size = clampInt(count, 1, 50, 20)
235
+ targets = []
236
+ for (let index = start; index < total && targets.length < size; index += 1) targets.push(index)
237
+ }
238
+
239
+ const confirmed = []
240
+ const skipped = []
241
+ for (const index of targets) {
242
+ const segment = doc.segments[index]
243
+ if (segment.status === 'confirmed') {
244
+ skipped.push({ index, reason: 'confirmed' })
245
+ continue
246
+ }
247
+ if (!segment.target) {
248
+ skipped.push({ index, reason: 'no-target' })
249
+ continue
250
+ }
251
+ this.confirmSegment({ documentId: doc.id, index, target: segment.target })
252
+ confirmed.push(index)
253
+ }
254
+
255
+ return {
256
+ documentId: doc.id,
257
+ confirmed,
258
+ skipped,
259
+ progress: this.progress(doc),
260
+ done: this.progress(doc).pending === 0 && this.progress(doc).translated === 0,
261
+ }
262
+ }
263
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Import-time preprocessing stage.
3
+ *
4
+ * Parsers keep the source roughly as authored: pasted documents often contain
5
+ * non-breaking spaces, zero-width characters, CRLF line endings and stray
6
+ * indentation. None of that is meaningful for translation memory or MT, but it
7
+ * does leak into segments and makes matches fail. The stage is deliberately
8
+ * conservative: it only touches characters that carry no linguistic meaning and
9
+ * never rewrites punctuation, casing or inline placeholder tokens.
10
+ */
11
+ export const DEFAULT_PREPROCESS = {
12
+ normalizeWhitespace: true,
13
+ stripInvisible: true,
14
+ collapseBlankLines: true,
15
+ }
16
+
17
+ const INVISIBLE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]/g
18
+
19
+ export function normalizePreprocessOptions(value) {
20
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
21
+ return {
22
+ normalizeWhitespace:
23
+ typeof source.normalizeWhitespace === 'boolean'
24
+ ? source.normalizeWhitespace
25
+ : DEFAULT_PREPROCESS.normalizeWhitespace,
26
+ stripInvisible:
27
+ typeof source.stripInvisible === 'boolean' ? source.stripInvisible : DEFAULT_PREPROCESS.stripInvisible,
28
+ collapseBlankLines:
29
+ typeof source.collapseBlankLines === 'boolean'
30
+ ? source.collapseBlankLines
31
+ : DEFAULT_PREPROCESS.collapseBlankLines,
32
+ }
33
+ }
34
+
35
+ export function preprocessText(value, options = DEFAULT_PREPROCESS) {
36
+ const config = normalizePreprocessOptions(options)
37
+ let text = String(value ?? '')
38
+ // Line endings carry no linguistic meaning and would otherwise split CRLF
39
+ // imports into different segment fingerprints than the same text pasted in.
40
+ text = text.replace(/\r\n?/g, '\n')
41
+ if (config.stripInvisible) text = text.replace(INVISIBLE, '')
42
+ if (config.normalizeWhitespace) {
43
+ text = text
44
+ .replace(/\u00A0/g, ' ')
45
+ .replace(/[ \t]+\n/g, '\n')
46
+ .replace(/[ \t]{2,}/g, ' ')
47
+ .replace(/\n[ \t]+/g, '\n')
48
+ }
49
+ if (config.collapseBlankLines) text = text.replace(/\n{3,}/g, '\n\n')
50
+ return config.normalizeWhitespace || config.collapseBlankLines ? text.trim() : text
51
+ }
52
+
53
+ /**
54
+ * Apply the configured stage to parsed import segments. Inline tokens such as
55
+ * `{1}` are untouched because they contain no whitespace or control characters.
56
+ */
57
+ export function preprocessSegments(segments, options) {
58
+ const config = normalizePreprocessOptions(options)
59
+ return segments.map((segment) => {
60
+ if (!segment || typeof segment !== 'object') return segment
61
+ const source = preprocessText(segment.source, config)
62
+ if (source === segment.source) return segment
63
+ return { ...segment, source }
64
+ })
65
+ }
@@ -0,0 +1,133 @@
1
+ import { normalizeText } from './similarity.js'
2
+ import { normalizeSegmentState } from './store-utils.js'
3
+
4
+ /**
5
+ * Segments that repeat the same source text inside one document. The map is
6
+ * rebuilt on demand: documents are small (MAX_SEGMENTS 20000) and keeping an
7
+ * index in every document would have to be migrated forever.
8
+ */
9
+ function sourceGroups(doc) {
10
+ const groups = new Map()
11
+ doc.segments.forEach((segment, index) => {
12
+ const key = normalizeText(segment.source)
13
+ if (!key) return
14
+ const bucket = groups.get(key)
15
+ if (bucket) bucket.push(index)
16
+ else groups.set(key, [index])
17
+ })
18
+ return groups
19
+ }
20
+
21
+ function groupOf(doc, index) {
22
+ const key = normalizeText(doc.segments[index]?.source)
23
+ if (!key) return []
24
+ return sourceGroups(doc).get(key) ?? []
25
+ }
26
+
27
+ /**
28
+ * The text a repetition should copy: a confirmed segment wins, then a human
29
+ * edit, then anything with text at all.
30
+ */
31
+ function referenceOf(doc, indices) {
32
+ let fallback = null
33
+ for (const index of indices) {
34
+ const segment = doc.segments[index]
35
+ if (!segment?.target) continue
36
+ if (segment.status === 'confirmed') return { index, segment }
37
+ if (segment.origin === 'manual') return { index, segment }
38
+ if (!fallback) fallback = { index, segment }
39
+ }
40
+ return fallback
41
+ }
42
+
43
+ export const propagateMethods = {
44
+ sourceGroups(doc) {
45
+ return sourceGroups(doc)
46
+ },
47
+
48
+ /**
49
+ * Fill unconfirmed repetitions of `index`'s source with its target. Called
50
+ * right after a confirmation, so one decision settles every duplicate.
51
+ */
52
+ propagateFromSegment(doc, index) {
53
+ const group = groupOf(doc, index)
54
+ if (group.length < 2) return 0
55
+ const reference = referenceOf(doc, group.filter((item) => item !== index)) || {
56
+ index,
57
+ segment: doc.segments[index],
58
+ }
59
+ const source = reference.segment?.target
60
+ if (!source) return 0
61
+ let filled = 0
62
+ for (const item of group) {
63
+ if (item === index) continue
64
+ const segment = doc.segments[item]
65
+ if (segment.status === 'confirmed') continue
66
+ if (segment.target === source) continue
67
+ segment.target = source
68
+ segment.origin = 'auto'
69
+ segment.updatedAt = nowIso(reference.segment.updatedAt)
70
+ normalizeSegmentState(segment)
71
+ filled += 1
72
+ }
73
+ if (filled) doc.updatedAt = doc.segments[index].updatedAt
74
+ return filled
75
+ },
76
+
77
+ /**
78
+ * A human rewrote one copy: siblings that nobody has confirmed yet follow,
79
+ * otherwise the document would keep a pile of stale duplicates. Confirmed
80
+ * siblings are never touched.
81
+ */
82
+ propagateEdit(doc, index) {
83
+ const segment = doc.segments[index]
84
+ if (!segment?.target || segment.status === 'confirmed') return 0
85
+ const group = groupOf(doc, index)
86
+ if (group.length < 2) return 0
87
+ let filled = 0
88
+ for (const item of group) {
89
+ if (item === index) continue
90
+ const sibling = doc.segments[item]
91
+ if (sibling.status === 'confirmed') continue
92
+ if (sibling.target === segment.target) continue
93
+ sibling.target = segment.target
94
+ sibling.origin = 'auto'
95
+ sibling.updatedAt = segment.updatedAt
96
+ normalizeSegmentState(sibling)
97
+ filled += 1
98
+ }
99
+ return filled
100
+ },
101
+
102
+ propagateDocument({ documentId } = {}) {
103
+ const doc = this.requireDocument(documentId)
104
+ const groups = sourceGroups(doc)
105
+ let filled = 0
106
+ for (const indices of groups.values()) {
107
+ if (indices.length < 2) continue
108
+ const reference = referenceOf(doc, indices)
109
+ if (!reference?.segment?.target) continue
110
+ for (const index of indices) {
111
+ if (index === reference.index) continue
112
+ const segment = doc.segments[index]
113
+ if (segment.status === 'confirmed') continue
114
+ if (segment.target === reference.segment.target) continue
115
+ segment.target = reference.segment.target
116
+ segment.origin = 'auto'
117
+ segment.updatedAt = reference.segment.updatedAt
118
+ normalizeSegmentState(segment)
119
+ filled += 1
120
+ }
121
+ }
122
+ if (filled) {
123
+ doc.updatedAt = new Date().toISOString()
124
+ this.touch()
125
+ this.persist()
126
+ }
127
+ return { filled, groups: groups.size, progress: this.progress(doc) }
128
+ },
129
+ }
130
+
131
+ function nowIso(value) {
132
+ return typeof value === 'string' && value ? value : new Date().toISOString()
133
+ }
package/lib/qa.js ADDED
@@ -0,0 +1,167 @@
1
+ import { missingInlineTokens } from './formats.js'
2
+ import { normalizeText } from './similarity.js'
3
+ import { clampInt, DEFAULT_GLOSSARY_ID } from './store-utils.js'
4
+
5
+ const TOKEN_PATTERN = /\{\d+\}/g
6
+ const NUMBER_PATTERN = /\d+(?:[.,]\d+)*/g
7
+
8
+ /** Inline tokens are `{0}`, `{1}`… — the only markup CAIT keeps verbatim. */
9
+ function tokensIn(text) {
10
+ return String(text ?? '').match(TOKEN_PATTERN) || []
11
+ }
12
+
13
+ /**
14
+ * Numbers are compared as a multiset: "1,200" and "1200" are the same value,
15
+ * while "2024" and "2025" are not. Thousands separators are the only thing we
16
+ * normalise, because that is the one difference translators introduce on purpose.
17
+ */
18
+ function numbersIn(text) {
19
+ // `{0}` is a placeholder, not the number zero — strip tokens before scanning.
20
+ const raw = String(text ?? '').replace(/\{\d+\}/g, ' ').match(NUMBER_PATTERN) || []
21
+ return raw.map((value) => value.replace(/(\d),(?=\d{3}(?:\D|$))/g, '$1'))
22
+ }
23
+
24
+ function sameMultiset(left, right) {
25
+ if (left.length !== right.length) return false
26
+ const a = [...left].sort()
27
+ const b = [...right].sort()
28
+ return a.every((value, index) => value === b[index])
29
+ }
30
+
31
+ /**
32
+ * Lightweight QA. Everything here is advisory: it never blocks an export and
33
+ * nothing is stored, so the checks can evolve without a migration.
34
+ */
35
+ export const qaMethods = {
36
+ /**
37
+ * Groups of segments that share one source but disagree on the target. The
38
+ * mechanical part of a consistency review; an agent can then judge which
39
+ * variant is right and propose a fix.
40
+ */
41
+ consistencyReport({ documentId, limit = 50 } = {}) {
42
+ const doc = this.requireDocument(documentId)
43
+ const groups = new Map()
44
+ doc.segments.forEach((segment, index) => {
45
+ const key = normalizeText(segment.source)
46
+ if (!key) return
47
+ const bucket = groups.get(key)
48
+ const item = { index, target: String(segment.target ?? '') }
49
+ if (bucket) bucket.push(item)
50
+ else groups.set(key, [item])
51
+ })
52
+ const conflicts = []
53
+ for (const [key, items] of groups) {
54
+ const variants = new Map()
55
+ for (const item of items) {
56
+ const value = normalizeText(item.target)
57
+ if (!value) continue
58
+ if (!variants.has(value)) variants.set(value, [])
59
+ variants.get(value).push(item.index)
60
+ }
61
+ if (variants.size < 2) continue
62
+ conflicts.push({
63
+ source: items[0] ? doc.segments[items[0].index].source : key,
64
+ variants: [...variants.entries()].map(([target, indices]) => ({ target, indices })),
65
+ indices: items.map((item) => item.index),
66
+ })
67
+ }
68
+ return {
69
+ documentId: doc.id,
70
+ conflicts: conflicts.slice(0, clampInt(limit, 1, 200, 50)),
71
+ total: conflicts.length,
72
+ }
73
+ },
74
+
75
+ getQaReport({ documentId } = {}) {
76
+ const doc = this.requireDocument(documentId)
77
+ const mounted = new Set(this.mounts.glossaryIds)
78
+ const terms = this.glossary.filter(
79
+ (entry) =>
80
+ mounted.has(entry.glossaryId || DEFAULT_GLOSSARY_ID) &&
81
+ (!entry.sourceLang || entry.sourceLang === doc.sourceLang),
82
+ )
83
+ const issues = []
84
+ const push = (index, code, message) => issues.push({ index, code, message })
85
+ let checked = 0
86
+ let withText = 0
87
+ // Group by source so a repeated sentence with two different renderings is
88
+ // visible: propagation makes this the most likely mistake after an import.
89
+ const bySource = new Map()
90
+
91
+ for (let index = 0; index < doc.segments.length; index += 1) {
92
+ const segment = doc.segments[index]
93
+ const target = String(segment.target ?? '')
94
+ checked += 1
95
+
96
+ if (segment.status !== 'pending' && !target.trim()) {
97
+ push(index, 'EMPTY_TARGET', '句段标记为已翻译/已确认,但译文为空。')
98
+ continue
99
+ }
100
+ if (!target.trim()) continue
101
+ withText += 1
102
+
103
+ const missing = missingInlineTokens(segment.meta?.inline, target)
104
+ for (const token of missing) {
105
+ push(index, 'PLACEHOLDER_MISSING', `译文缺少标签占位符 ${token}。`)
106
+ }
107
+ const sourceTokens = new Set(tokensIn(segment.source))
108
+ for (const token of sourceTokens) {
109
+ if (!missing.includes(token) && !target.includes(token)) {
110
+ push(index, 'PLACEHOLDER_MISSING', `译文缺少占位符 ${token}。`)
111
+ }
112
+ }
113
+
114
+ if (!sameMultiset(numbersIn(segment.source), numbersIn(target))) {
115
+ push(index, 'NUMBER_MISMATCH', '源文与译文中的数字不一致。')
116
+ }
117
+
118
+ for (const entry of terms) {
119
+ const present = entry.caseSensitive
120
+ ? segment.source.includes(entry.source)
121
+ : segment.source.toLocaleLowerCase().includes(entry.source.toLocaleLowerCase())
122
+ if (!present) continue
123
+ const used = entry.caseSensitive
124
+ ? target.includes(entry.target)
125
+ : target.toLocaleLowerCase().includes(entry.target.toLocaleLowerCase())
126
+ if (!used) push(index, 'TERM_NOT_USED', `术语「${entry.source} → ${entry.target}」未在译文中出现。`)
127
+ }
128
+
129
+ // Copying the source into the target is the most common disguise for a
130
+ // missed translation. Pure digits or punctuation are identical by nature.
131
+ if (/\p{L}/u.test(segment.source) && normalizeText(target) === normalizeText(segment.source)) {
132
+ push(index, 'UNTRANSLATED_RESIDUE', '译文与源文完全相同,可能是漏译。')
133
+ }
134
+
135
+ const sourceKey = normalizeText(segment.source)
136
+ if (sourceKey) {
137
+ const bucket = bySource.get(sourceKey)
138
+ if (bucket) bucket.push({ index, target: normalizeText(target) })
139
+ else bySource.set(sourceKey, [{ index, target: normalizeText(target) }])
140
+ }
141
+ }
142
+
143
+ for (const group of bySource.values()) {
144
+ if (group.length < 2) continue
145
+ const variants = new Set(group.map((item) => item.target))
146
+ if (variants.size < 2) continue
147
+ for (const item of group) {
148
+ push(item.index, 'INCONSISTENT_TARGET', `同一源文出现了 ${variants.size} 种不同译文。`)
149
+ }
150
+ }
151
+
152
+ const counts = issues.reduce(
153
+ (acc, issue) => ({ ...acc, [issue.code]: (acc[issue.code] || 0) + 1 }),
154
+ { PLACEHOLDER_MISSING: 0, NUMBER_MISMATCH: 0, TERM_NOT_USED: 0, EMPTY_TARGET: 0, UNTRANSLATED_RESIDUE: 0, INCONSISTENT_TARGET: 0 },
155
+ )
156
+
157
+ return {
158
+ documentId: doc.id,
159
+ total: doc.segments.length,
160
+ checked,
161
+ withText,
162
+ issueCount: issues.length,
163
+ counts,
164
+ issues,
165
+ }
166
+ },
167
+ }