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.
- package/CHANGELOG.md +202 -0
- package/README.ja.md +147 -25
- package/README.md +176 -27
- package/README.zh-CN.md +137 -26
- package/client/components.js +2047 -340
- package/client/entry.js +13 -0
- package/client/helpers.js +110 -7
- package/client/locales.js +435 -6
- package/client/rpc.js +1 -1
- package/client/styles.js +137 -3
- package/client.js +2742 -356
- package/index.js +3 -0
- package/lib/aligner.js +411 -0
- package/lib/api.js +31 -0
- package/lib/context.js +71 -0
- package/lib/docx.js +12 -9
- package/lib/epub.js +355 -0
- package/lib/formats.js +88 -33
- package/lib/glossary.js +4 -4
- package/lib/html.js +271 -0
- package/lib/import-export.js +208 -95
- package/lib/libraries.js +55 -0
- package/lib/library-export.js +75 -0
- package/lib/library-view.js +215 -0
- package/lib/mt.js +17 -7
- package/lib/persistence.js +92 -7
- package/lib/pre-translate.js +263 -0
- package/lib/preprocess.js +65 -0
- package/lib/propagate.js +133 -0
- package/lib/qa.js +167 -0
- package/lib/store-utils.js +48 -6
- package/lib/store.js +143 -12
- package/lib/term-extract.js +190 -0
- package/lib/terms.js +102 -0
- package/lib/tm.js +123 -11
- package/lib/tmx.js +47 -2
- package/lib/tools.js +472 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/lib/propagate.js
ADDED
|
@@ -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
|
+
}
|