dsh-cait 1.1.3 → 1.1.7

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/lib/aligner.js CHANGED
@@ -362,12 +362,12 @@ export const alignMethods = {
362
362
  * stored are skipped, and nothing else in the store is touched: no document,
363
363
  * no segment status.
364
364
  */
365
- writeAlignment({ pairs, tmId } = {}) {
365
+ writeAlignment({ pairs, tmId, sourceLang, targetLang } = {}) {
366
366
  const list = Array.isArray(pairs) ? pairs : []
367
367
  if (!list.length) throw new CatError('ALIGN_EMPTY', '没有可写入的配对。')
368
368
  const writeTmId = this.requireMountedTmLibrary(tmId)
369
- const sourceLang = cleanLang(this.settings.sourceLang, this.settings.sourceLang)
370
- const targetLang = cleanLang(this.settings.targetLang, this.settings.targetLang)
369
+ const writeSourceLang = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
370
+ const writeTargetLang = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
371
371
  let written = 0
372
372
  let updated = 0
373
373
  let skipped = 0
@@ -382,8 +382,8 @@ export const alignMethods = {
382
382
  const existing = this.tm.find(
383
383
  (entry) =>
384
384
  entry.tmId === writeTmId &&
385
- entry.sourceLang === sourceLang &&
386
- entry.targetLang === targetLang &&
385
+ entry.sourceLang === writeSourceLang &&
386
+ entry.targetLang === writeTargetLang &&
387
387
  (entry.contextHash ?? null) === null &&
388
388
  normalizeText(entry.source) === key,
389
389
  )
@@ -391,7 +391,7 @@ export const alignMethods = {
391
391
  skipped += 1
392
392
  continue
393
393
  }
394
- this.upsertTM(sourceText, targetText, sourceLang, targetLang, writeTmId, null)
394
+ this.upsertTM(sourceText, targetText, writeSourceLang, writeTargetLang, writeTmId, null)
395
395
  if (existing) updated += 1
396
396
  else written += 1
397
397
  }
@@ -0,0 +1,88 @@
1
+ import { countWords } from './store-utils.js'
2
+ import { normalizeText } from './similarity.js'
3
+
4
+ /**
5
+ * Translation leverage is a quoting aid, not an accounting system. We keep the
6
+ * buckets aligned with the TM match bands the workbench already shows so a
7
+ * project manager can explain every number from the same lookup result.
8
+ */
9
+ export const MATCH_BUCKETS = ['ice', 'exact', 'fuzzy85', 'fuzzy70', 'new']
10
+
11
+ export function classifyLeverage(match) {
12
+ if (!match) return 'new'
13
+ if (match.contextMatch === 'ice' || match.percent >= 101) return 'ice'
14
+ if (match.exact) return 'exact'
15
+ if (match.percent >= 85) return 'fuzzy85'
16
+ if (match.percent >= 70) return 'fuzzy70'
17
+ return 'new'
18
+ }
19
+
20
+ export const analysisMethods = {
21
+ analyzeDocument({ documentId } = {}) {
22
+ const doc = this.requireDocument(documentId)
23
+ const buckets = { ice: 0, exact: 0, fuzzy85: 0, fuzzy70: 0, new: 0 }
24
+ const duplicateMap = new Map()
25
+ let words = 0
26
+ let characters = 0
27
+ let translated = 0
28
+ let confirmed = 0
29
+
30
+ for (let index = 0; index < doc.segments.length; index += 1) {
31
+ const segment = doc.segments[index]
32
+ const source = String(segment.source ?? '')
33
+ words += countWords(source)
34
+ characters += Array.from(source).length
35
+ if (String(segment.target ?? '').trim()) translated += 1
36
+ if (segment.confirmedAt) confirmed += 1
37
+ const key = normalizeText(source)
38
+ if (key) duplicateMap.set(key, (duplicateMap.get(key) || 0) + 1)
39
+
40
+ const result = this.lookupTM({
41
+ query: source,
42
+ threshold: 0.7,
43
+ limit: 1,
44
+ documentId: doc.id,
45
+ index,
46
+ })
47
+ const bucket = classifyLeverage(result.matches[0])
48
+ buckets[bucket] += 1
49
+ }
50
+
51
+ const total = doc.segments.length
52
+ const duplicateGroups = Array.from(duplicateMap.values()).filter((count) => count > 1)
53
+ const duplicateSegments = duplicateGroups.reduce((sum, count) => sum + count, 0)
54
+ const matched = buckets.ice + buckets.exact + buckets.fuzzy85 + buckets.fuzzy70
55
+ const weighted =
56
+ buckets.ice * 1.01 +
57
+ buckets.exact +
58
+ buckets.fuzzy85 * 0.9 +
59
+ buckets.fuzzy70 * 0.75
60
+ const percent = (value) => (total ? Math.round((value / total) * 1000) / 10 : 0)
61
+
62
+ return {
63
+ documentId: doc.id,
64
+ name: doc.name,
65
+ sourceLang: doc.sourceLang,
66
+ targetLang: doc.targetLang,
67
+ segments: total,
68
+ words,
69
+ characters,
70
+ translated,
71
+ confirmed,
72
+ duplicates: {
73
+ groups: duplicateGroups.length,
74
+ segments: duplicateSegments,
75
+ },
76
+ buckets,
77
+ matched: {
78
+ segments: matched,
79
+ percent: percent(matched),
80
+ },
81
+ leverage: {
82
+ weighted: total ? Math.round((weighted / total) * 1000) / 10 : 0,
83
+ percent: percent(weighted),
84
+ },
85
+ generatedAt: new Date().toISOString(),
86
+ }
87
+ },
88
+ }
package/lib/api.js CHANGED
@@ -10,16 +10,35 @@ const MUTATIONS = new Set([
10
10
  'openDocument',
11
11
  'closeDocument',
12
12
  'importFile',
13
+ 'applyXliffReview',
13
14
  'importTmx',
14
15
  'loadDemo',
15
16
  'selectSegment',
16
17
  'saveDraft',
18
+ 'replaceInDocument',
19
+ 'undoReplace',
20
+ 'addSegmentNote',
21
+ 'updateSegmentNote',
22
+ 'deleteSegmentNote',
23
+ 'setSegmentLocked',
24
+ 'mergeSegments',
25
+ 'splitSegment',
26
+ 'mergeCues',
27
+ 'splitCue',
28
+ 'shiftSubtitles',
29
+ 'clearUnconfirmedTranslations',
17
30
  'adoptSuggestion',
18
31
  'proposeTranslation',
19
32
  'writeAlignment',
20
33
  'propagateDocument',
21
34
  'addGlossaryEntries',
22
35
  'preTranslate',
36
+ 'setBrief',
37
+ 'setDocumentResources',
38
+ 'mergeTmLibraries',
39
+ 'dedupeTmLibrary',
40
+ 'pruneTmLibrary',
41
+ 'unconfirmSegment',
23
42
  'confirmSegments',
24
43
  'confirmSegment',
25
44
  'moveSegment',
@@ -111,10 +130,25 @@ function createHandlers(store) {
111
130
  openDocument: (params) => store.openDocument(params),
112
131
  closeDocument: (params) => store.closeDocument(params),
113
132
  importFile: (params) => store.importFile(params),
133
+ applyXliffReview: (params) => store.applyXliffReview(params),
114
134
  importTmx: (params) => store.importTmx(params),
115
135
  loadDemo: () => store.loadDemo(),
116
136
  selectSegment: (params) => store.selectSegment(params),
117
137
  saveDraft: (params) => store.saveDraft(params),
138
+ replaceInDocument: (params) => store.replaceInDocument(params),
139
+ undoReplace: (params) => store.undoReplace(params),
140
+ notesForSegment: (params) => store.notesForSegment(params),
141
+ addSegmentNote: (params) => store.addSegmentNote(params),
142
+ updateSegmentNote: (params) => store.updateSegmentNote(params),
143
+ deleteSegmentNote: (params) => store.deleteSegmentNote(params),
144
+ setSegmentLocked: (params) => store.setSegmentLocked(params),
145
+ mergeSegments: (params) => store.mergeSegments(params),
146
+ splitSegment: (params) => store.splitSegment(params),
147
+ mergeCues: (params) => store.mergeCues(params),
148
+ splitCue: (params) => store.splitCue(params),
149
+ shiftSubtitles: (params) => store.shiftSubtitles(params),
150
+ getClearUnconfirmedPreview: (params) => store.clearUnconfirmedTranslations({ ...params, confirm: false }),
151
+ clearUnconfirmedTranslations: confirmDangerous((params) => store.clearUnconfirmedTranslations({ ...params, confirm: true })),
118
152
  adoptSuggestion: (params) => store.adoptSuggestion(params),
119
153
  proposeTranslation: (params) => store.proposeTranslation(params),
120
154
  writeAlignment: (params) => store.writeAlignment(params),
@@ -127,6 +161,18 @@ function createHandlers(store) {
127
161
  preTranslate: (params) => store.preTranslate(params),
128
162
  confirmSegments: (params) => store.confirmSegmentsForTool(params),
129
163
  getQaReport: (params) => store.getQaReport(params),
164
+ reviewDocument: (params) => store.reviewDocument(params),
165
+ analyzeDocument: (params) => store.analyzeDocument(params),
166
+ tmMaintenancePreview: (params) => store.tmMaintenancePreview(params),
167
+ mergeTmLibraries: confirmDangerous((params) => store.mergeTmLibraries({ ...params, confirm: true })),
168
+ dedupeTmLibrary: confirmDangerous((params) => store.dedupeTmLibrary({ ...params, confirm: true })),
169
+ pruneTmLibrary: confirmDangerous((params) => store.pruneTmLibrary({ ...params, confirm: true })),
170
+ getBrief: (params) => store.getBrief(params),
171
+ setBrief: (params) => store.setBrief(params),
172
+ setDocumentResources: (params) => store.setDocumentResources(params),
173
+ contextForTool: (params) => store.contextForTool(params),
174
+ concordance: (params) => store.concordance(params),
175
+ unconfirmSegment: (params) => store.unconfirmSegment(params),
130
176
  confirmSegment: (params) => store.confirmSegment(params),
131
177
  moveSegment: (params) => store.moveSegment(params),
132
178
 
package/lib/brief.js ADDED
@@ -0,0 +1,275 @@
1
+ import { CatError } from './errors.js'
2
+ import { canonicalLanguage, cleanText, clampInt, normalizeSegmentState, now } from './store-utils.js'
3
+
4
+ /**
5
+ * A document brief is deliberately small: the facts a translator would ask
6
+ * before starting. Project-level metadata (client, deadline, billing) belongs
7
+ * to the project layer in 1.1.5, not to every document.
8
+ */
9
+ export const BRIEF_FIELDS = ['domain', 'audience', 'tone', 'notes', 'doNotTranslate', 'styleSample']
10
+
11
+ const BRIEF_LIMITS = {
12
+ domain: 120,
13
+ audience: 200,
14
+ tone: 200,
15
+ notes: 2000,
16
+ doNotTranslate: 2000,
17
+ styleSample: 4000,
18
+ }
19
+
20
+ const BRIEF_LABELS = {
21
+ domain: 'Domain',
22
+ audience: 'Audience',
23
+ tone: 'Tone',
24
+ notes: 'Notes',
25
+ doNotTranslate: 'Do not translate',
26
+ styleSample: 'Style sample',
27
+ }
28
+
29
+ export function cleanBriefValue(field, value) {
30
+ const limit = BRIEF_LIMITS[field]
31
+ if (!limit) return ''
32
+ return String(value ?? '')
33
+ .replace(/\r\n?/g, '\n')
34
+ .trim()
35
+ .slice(0, limit)
36
+ }
37
+
38
+ /** Keep only known, non-empty fields. Used for new and restored documents. */
39
+ export function normalizeBrief(value) {
40
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
41
+ const brief = {}
42
+ for (const field of BRIEF_FIELDS) {
43
+ const text = cleanBriefValue(field, value[field])
44
+ if (text) brief[field] = text
45
+ }
46
+ if (!Object.keys(brief).length) return null
47
+ if (typeof value.updatedAt === 'string' && value.updatedAt) brief.updatedAt = value.updatedAt
48
+ return brief
49
+ }
50
+
51
+ function clip(text, max) {
52
+ const value = String(text ?? '')
53
+ return value.length > max ? `${value.slice(0, max)}…` : value
54
+ }
55
+
56
+ /** The prompt block shared by single-segment MT and batch MT. */
57
+ export function briefPromptBlock(brief) {
58
+ if (!brief) return ''
59
+ const rows = []
60
+ for (const field of BRIEF_FIELDS) {
61
+ if (!brief[field]) continue
62
+ rows.push(`- ${BRIEF_LABELS[field]}: ${clip(brief[field], field === 'styleSample' ? 800 : 400)}`)
63
+ }
64
+ return rows.length ? `Document brief:\n${rows.join('\n')}\n` : ''
65
+ }
66
+
67
+ /** Bounded neighbours for prompts and the context tool. */
68
+ export function neighborContext(doc, index, { before = 2, after = 2 } = {}) {
69
+ const total = doc.segments.length
70
+ const safeIndex = clampInt(index, 0, total - 1, 0)
71
+ const beforeCount = clampInt(before, 0, 5, 2)
72
+ const afterCount = clampInt(after, 0, 5, 2)
73
+ const start = Math.max(0, safeIndex - beforeCount)
74
+ const end = Math.min(total, safeIndex + afterCount + 1)
75
+ const view = (segment, at) => ({
76
+ index: at,
77
+ source: segment.source,
78
+ target: segment.target || '',
79
+ status: segment.status,
80
+ })
81
+ return {
82
+ before: doc.segments.slice(start, safeIndex).map((segment, offset) => view(segment, start + offset)),
83
+ after: doc.segments.slice(safeIndex + 1, end).map((segment, offset) => view(segment, safeIndex + 1 + offset)),
84
+ }
85
+ }
86
+
87
+ export function contextPromptBlock(doc, index) {
88
+ const { before, after } = neighborContext(doc, index)
89
+ if (!before.length && !after.length) return ''
90
+ const lines = ['Surrounding segments (for context only, do not translate them):']
91
+ for (const item of before) lines.push(`[previous] ${clip(item.source, 500)}`)
92
+ for (const item of after) lines.push(`[next] ${clip(item.source, 500)}`)
93
+ return `${lines.join('\n')}\n`
94
+ }
95
+
96
+ export const briefMethods = {
97
+ getBrief({ documentId } = {}) {
98
+ const doc = this.requireDocument(documentId)
99
+ return {
100
+ documentId: doc.id,
101
+ brief: doc.brief ? { ...doc.brief } : null,
102
+ }
103
+ },
104
+
105
+ /**
106
+ * Merge a patch into the document brief. Empty strings clear a field; an
107
+ * explicit null or `clear: true` removes the whole brief. A failed strict
108
+ * persist rolls the previous brief back.
109
+ */
110
+ setBrief({ documentId, brief, clear = false } = {}) {
111
+ const doc = this.requireDocument(documentId)
112
+ const previous = doc.brief
113
+ const previousUpdatedAt = doc.updatedAt
114
+ if (clear === true || brief === null) {
115
+ delete doc.brief
116
+ } else {
117
+ if (!brief || typeof brief !== 'object' || Array.isArray(brief)) {
118
+ throw new CatError('INVALID_BRIEF', 'brief 必须是对象。')
119
+ }
120
+ const next = { ...(doc.brief || {}) }
121
+ for (const field of BRIEF_FIELDS) {
122
+ if (!Object.prototype.hasOwnProperty.call(brief, field)) continue
123
+ const text = cleanBriefValue(field, brief[field])
124
+ if (text) next[field] = text
125
+ else delete next[field]
126
+ }
127
+ delete next.updatedAt
128
+ if (Object.keys(next).length) {
129
+ next.updatedAt = now()
130
+ doc.brief = next
131
+ } else {
132
+ delete doc.brief
133
+ }
134
+ }
135
+ doc.updatedAt = now()
136
+ this.touch()
137
+ try {
138
+ this.persist({ strict: true })
139
+ } catch (error) {
140
+ if (previous === undefined) delete doc.brief
141
+ else doc.brief = previous
142
+ doc.updatedAt = previousUpdatedAt
143
+ throw error
144
+ }
145
+ return { documentId: doc.id, brief: doc.brief ? { ...doc.brief } : null }
146
+ },
147
+
148
+ /**
149
+ * Everything an agent needs before translating or reviewing one segment:
150
+ * the brief, the segment, and a bounded window of its neighbours.
151
+ */
152
+ contextForTool({ documentId, index, before = 2, after = 2 } = {}) {
153
+ const doc = this.requireDocument(documentId)
154
+ const at = index === undefined || index === null ? doc.cursor : index
155
+ if (!Number.isInteger(at)) throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${at}`)
156
+ const segment = this.findSegment(doc, at)
157
+ const window = neighborContext(doc, at, { before, after })
158
+ const paragraphCount =
159
+ doc.segments.reduce(
160
+ (max, item) => Math.max(max, Number.isInteger(item.paragraph) ? item.paragraph : 0),
161
+ 0,
162
+ ) + 1
163
+ return {
164
+ document: {
165
+ id: doc.id,
166
+ name: doc.name,
167
+ sourceLang: doc.sourceLang,
168
+ targetLang: doc.targetLang,
169
+ total: doc.segments.length,
170
+ cursor: doc.cursor,
171
+ },
172
+ paragraph: {
173
+ index: Number.isInteger(segment.paragraph) ? segment.paragraph : 0,
174
+ count: paragraphCount,
175
+ // Paragraph headings are ordinary segments in CAIT; showing the
176
+ // document name is the one stable title every format provides.
177
+ title: doc.name,
178
+ },
179
+ brief: doc.brief ? { ...doc.brief } : null,
180
+ segment: {
181
+ index: at,
182
+ uid: segment.uid,
183
+ id: segment.id,
184
+ source: segment.source,
185
+ target: segment.target || '',
186
+ status: segment.status,
187
+ origin: segment.origin,
188
+ paragraph: segment.paragraph,
189
+ },
190
+ before: window.before,
191
+ after: window.after,
192
+ }
193
+ },
194
+
195
+ /** Substring search over every mounted TM entry, not just the active pair. */
196
+ concordance({ query, sourceLang, targetLang, limit = 20, documentId } = {}) {
197
+ const text = String(query ?? '').trim()
198
+ if (!text) throw new CatError('EMPTY_QUERY', '请输入要检索的词或短语。')
199
+ if (text.length > 200) throw new CatError('QUERY_TOO_LONG', '检索词最多 200 个字符。')
200
+ const doc = documentId ? this.requireDocument(documentId) : null
201
+ const src = sourceLang ? canonicalLanguage(sourceLang) : doc?.sourceLang || ''
202
+ const tgt = targetLang ? canonicalLanguage(targetLang) : doc?.targetLang || ''
203
+ const mounted = new Set(this.mounts.tmIds)
204
+ const needle = text.toLowerCase()
205
+ const max = clampInt(limit, 1, 50, 20)
206
+ const matches = []
207
+ for (const entry of this.tm) {
208
+ if (!mounted.has(entry.tmId)) continue
209
+ if (src && entry.sourceLang !== src) continue
210
+ if (tgt && entry.targetLang !== tgt) continue
211
+ if (!entry.source.toLowerCase().includes(needle) && !entry.target.toLowerCase().includes(needle)) {
212
+ continue
213
+ }
214
+ matches.push(entry)
215
+ }
216
+ matches.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
217
+ const libraries = new Map(this.tmLibraries.map((library) => [library.tmId, library.name]))
218
+ return {
219
+ query: text,
220
+ total: matches.length,
221
+ entries: matches.slice(0, max).map((entry) => ({
222
+ source: entry.source,
223
+ target: entry.target,
224
+ sourceLang: entry.sourceLang,
225
+ targetLang: entry.targetLang,
226
+ context: entry.context || null,
227
+ tmId: entry.tmId,
228
+ library: libraries.get(entry.tmId) || entry.tmId,
229
+ updatedAt: entry.updatedAt,
230
+ })),
231
+ }
232
+ },
233
+
234
+ /**
235
+ * Revoke a confirmation without touching TM. TM is a reusable asset, and
236
+ * mainstream CAT tools also keep it when a segment is unconfirmed; a later
237
+ * confirmation updates the same entry.
238
+ */
239
+ unconfirmSegment({ documentId, index } = {}) {
240
+ const doc = this.requireDocument(documentId)
241
+ const at = index === undefined || index === null ? doc.cursor : index
242
+ if (!Number.isInteger(at)) throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${at}`)
243
+ const segment = this.findSegment(doc, at)
244
+ if (segment.locked) throw new CatError('SEGMENT_LOCKED', '该句段已锁定,请先解锁再撤销确认。')
245
+ // Older stores may carry status=confirmed without a confirmedAt stamp.
246
+ if (segment.status !== 'confirmed' && !segment.confirmedAt) {
247
+ throw new CatError('NOT_CONFIRMED', '该句段还没有确认,不能撤销确认。', 409)
248
+ }
249
+ const previous = {
250
+ confirmedAt: segment.confirmedAt,
251
+ origin: segment.origin,
252
+ status: segment.status,
253
+ updatedAt: segment.updatedAt,
254
+ }
255
+ segment.confirmedAt = null
256
+ if (!segment.origin) segment.origin = 'manual'
257
+ segment.updatedAt = now()
258
+ normalizeSegmentState(segment)
259
+ doc.cursor = at
260
+ doc.updatedAt = now()
261
+ this.touch()
262
+ try {
263
+ this.persist({ strict: true })
264
+ } catch (error) {
265
+ Object.assign(segment, previous)
266
+ throw error
267
+ }
268
+ return {
269
+ segment: this.segmentView(segment, at),
270
+ progress: this.progress(doc),
271
+ documentId: doc.id,
272
+ index: at,
273
+ }
274
+ },
275
+ }