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.
@@ -1,22 +1,56 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { CatError } from './errors.js'
3
3
  import { clamp, clampInt } from './similarity.js'
4
+ import { DEFAULT_PREPROCESS, normalizePreprocessOptions } from './preprocess.js'
4
5
 
5
6
  export { clamp, clampInt }
6
7
 
7
8
  export const MAX_SEGMENTS = 20000
8
- export const CAIT_VERSION = '1.0.4'
9
+ export const CAIT_VERSION = '1.1.3'
9
10
  export const MAX_SEGMENT_CHARS = 20000
10
11
  export const MAX_IMPORT_CHARS = 8_000_000
11
12
  export const MAX_DOCX_BASE64 = 28_000_000
12
13
  export const MAX_STORE_BYTES = 64 * 1024 * 1024
13
- export const STORE_VERSION = 2
14
+ export const STORE_VERSION = 4
14
15
  export const STORE_FILENAME = 'store.json'
15
16
  export const DEFAULT_TM_ID = 'tm_default'
16
17
  export const DEFAULT_GLOSSARY_ID = 'glossary_default'
17
18
  export const DEFAULT_TM_NAME = 'Default TM'
18
19
  export const DEFAULT_GLOSSARY_NAME = 'Default glossary'
19
20
 
21
+ // Segment lifecycle: nothing translated yet → a draft or a proposal exists →
22
+ // a human confirmed it. `origin` records where the current target/suggestion
23
+ // came from, which is what makes "MT filled it, I only reviewed" auditable.
24
+ export const SEGMENT_STATUSES = ['pending', 'translated', 'confirmed']
25
+ export const SEGMENT_ORIGINS = ['tm', 'mt', 'agent', 'manual', 'auto']
26
+
27
+ export function segmentStatus(segment) {
28
+ const target = String(segment?.target ?? '')
29
+ const suggestion = String(segment?.suggestion ?? '')
30
+ if (target && segment?.confirmedAt) return 'confirmed'
31
+ if (target || suggestion) return 'translated'
32
+ return 'pending'
33
+ }
34
+
35
+ /**
36
+ * Re-derive the stored fields from the facts (target/suggestion/confirmedAt)
37
+ * so the three-state model cannot drift out of sync with the text.
38
+ */
39
+ export function normalizeSegmentState(segment) {
40
+ if (!String(segment.target ?? '')) segment.confirmedAt = null
41
+ if (!String(segment.suggestion ?? '')) {
42
+ segment.suggestion = ''
43
+ segment.suggestionReason = ''
44
+ }
45
+ segment.status = segmentStatus(segment)
46
+ if (segment.status === 'pending') {
47
+ segment.origin = null
48
+ } else if (!SEGMENT_ORIGINS.includes(segment.origin)) {
49
+ segment.origin = 'manual'
50
+ }
51
+ return segment
52
+ }
53
+
20
54
  // UI languages the workbench can switch between; 'auto' follows the DSH locale.
21
55
  export const UI_LANG_CODES = ['auto', 'zh', 'en', 'ja']
22
56
 
@@ -26,6 +60,7 @@ export const DEFAULT_SETTINGS = {
26
60
  fuzzyThreshold: 0.55,
27
61
  tmLimit: 5,
28
62
  uiLang: 'auto',
63
+ preprocess: { ...DEFAULT_PREPROCESS },
29
64
  mt: {
30
65
  provider: 'mock',
31
66
  model: 'cait-mock',
@@ -161,11 +196,11 @@ function cleanInlineTokens(value) {
161
196
  export function cleanSegmentMeta(value) {
162
197
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null
163
198
  const meta = {}
164
- for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph']) {
199
+ for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph', 'epubBlock']) {
165
200
  const number = Number(value[key])
166
201
  if (Number.isFinite(number) && number >= 0) meta[key] = Math.round(number)
167
202
  }
168
- for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide']) {
203
+ for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide', 'epubHref']) {
169
204
  if (typeof value[key] === 'string' && value[key].trim()) {
170
205
  meta[key] = value[key].slice(0, 500)
171
206
  }
@@ -212,6 +247,7 @@ export function restoreSettings(raw) {
212
247
  if (Number.isFinite(limit)) settings.tmLimit = clampInt(limit, 1, 20, settings.tmLimit)
213
248
  const uiLang = String(raw.uiLang ?? '').trim().toLowerCase()
214
249
  if (UI_LANG_CODES.includes(uiLang)) settings.uiLang = uiLang
250
+ settings.preprocess = normalizePreprocessOptions(raw.preprocess)
215
251
 
216
252
  if (raw.mt && typeof raw.mt === 'object' && !Array.isArray(raw.mt)) {
217
253
  const provider = String(raw.mt.provider ?? '').trim()
@@ -330,10 +366,16 @@ export function parseGlossaryCsv(text) {
330
366
 
331
367
  const entries = []
332
368
  for (const cells of rows) {
333
- const [source, target, note, lang] = cells.map((cell) => cell.trim())
369
+ const [source, target, note, lang, caseFlag] = cells.map((cell) => cell.trim())
334
370
  if (!source || !target) continue
335
371
  if (/^(source|term|原文|术语)$/i.test(source) && /^(target|translation|译文)$/i.test(target)) continue
336
- entries.push({ source, target, note: note || '', sourceLang: lang || '' })
372
+ entries.push({
373
+ source,
374
+ target,
375
+ note: note || '',
376
+ sourceLang: lang || '',
377
+ caseSensitive: /^(true|1|yes|y)$/i.test(caseFlag || ''),
378
+ })
337
379
  }
338
380
  return entries
339
381
  }
package/lib/store.js CHANGED
@@ -2,16 +2,24 @@ import { CatError } from './errors.js'
2
2
  import { createRequire } from 'node:module'
3
3
  import { join, resolve } from 'node:path'
4
4
  import { splitIntoSegments } from './segmenter.js'
5
- import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
5
+ import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, normalizeSegmentState, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, SEGMENT_ORIGINS, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
6
6
  import { libraryMethods } from './libraries.js'
7
7
  import { tmMethods } from './tm.js'
8
8
  import { glossaryMethods } from './glossary.js'
9
9
  import { persistenceMethods } from './persistence.js'
10
10
  import { importExportMethods } from './import-export.js'
11
+ import { preTranslateMethods } from './pre-translate.js'
12
+ import { qaMethods } from './qa.js'
13
+ import { libraryViewMethods } from './library-view.js'
14
+ import { propagateMethods } from './propagate.js'
15
+ import { alignMethods } from './aligner.js'
16
+ import { termMethods } from './terms.js'
11
17
  import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
18
+ import { contextOf } from './context.js'
12
19
  import { MOCK_MODEL, MOCK_PROVIDER, NO_MODEL_MESSAGE, NO_ROUTE_MESSAGE, collectStreamText } from './mt.js'
13
20
  import { buildTmx } from './tmx.js'
14
21
  import { missingInlineTokens } from './formats.js'
22
+ import { normalizePreprocessOptions, preprocessSegments } from './preprocess.js'
15
23
 
16
24
  export { CatError } from './errors.js'
17
25
  export { canonicalLanguage } from './store-utils.js'
@@ -31,6 +39,7 @@ export class CatStore {
31
39
  this.resetLibraryState()
32
40
  this.dataDir = String(dataDir ?? '').trim() ? resolve(String(dataDir)) : ''
33
41
  this.storageFile = this.dataDir ? join(this.dataDir, STORE_FILENAME) : ''
42
+ this.saveTimer = null
34
43
  this.persistence = {
35
44
  enabled: Boolean(this.storageFile),
36
45
  file: this.storageFile || null,
@@ -84,10 +93,14 @@ export class CatStore {
84
93
  progress(doc) {
85
94
  const total = doc.segments.length
86
95
  const confirmed = doc.segments.filter((segment) => segment.status === 'confirmed').length
96
+ const translated = doc.segments.filter((segment) => segment.status === 'translated').length
87
97
  return {
88
98
  total,
89
99
  confirmed,
90
- pending: total - confirmed,
100
+ // "translated" means a draft or a proposal exists and only review is left;
101
+ // "pending" is everything with no text at all.
102
+ translated,
103
+ pending: total - confirmed - translated,
91
104
  percent: total ? Math.round((confirmed / total) * 100) : 0,
92
105
  }
93
106
  }
@@ -112,6 +125,9 @@ export class CatStore {
112
125
  source: segment.source,
113
126
  target: segment.target,
114
127
  status: segment.status,
128
+ origin: segment.origin ?? null,
129
+ suggestion: segment.suggestion ?? '',
130
+ suggestionReason: segment.suggestionReason ?? '',
115
131
  paragraph: segment.paragraph,
116
132
  confirmedAt: segment.confirmedAt,
117
133
  }
@@ -127,6 +143,8 @@ export class CatStore {
127
143
  query: segment.source,
128
144
  sourceLang: doc.sourceLang,
129
145
  targetLang: doc.targetLang,
146
+ documentId: doc.id,
147
+ index,
130
148
  }).matches,
131
149
  glossaryHits: this.findGlossaryHits(segment.source, doc.sourceLang),
132
150
  }
@@ -177,6 +195,10 @@ export class CatStore {
177
195
  (sum, item) => sum + item.segments.filter((segment) => segment.status === 'confirmed').length,
178
196
  0,
179
197
  ),
198
+ translated: [...this.docs.values()].reduce(
199
+ (sum, item) => sum + item.segments.filter((segment) => segment.status === 'translated').length,
200
+ 0,
201
+ ),
180
202
  tm: this.tm.length,
181
203
  glossary: this.glossary.length,
182
204
  },
@@ -216,6 +238,7 @@ export class CatStore {
216
238
  }
217
239
 
218
240
  if (!rawSegments.length) throw new CatError('EMPTY_SOURCE', '没有可翻译的句段。')
241
+ rawSegments = preprocessSegments(rawSegments, this.settings.preprocess)
219
242
  if (rawSegments.length > MAX_SEGMENTS) {
220
243
  throw new CatError('TOO_MANY_SEGMENTS', `句段数量超过 ${MAX_SEGMENTS} 上限`)
221
244
  }
@@ -232,16 +255,19 @@ export class CatStore {
232
255
  ? raw.paragraph
233
256
  : index
234
257
  const meta = cleanSegmentMeta(raw.meta)
235
- return {
258
+ const segment = {
236
259
  id: `s${index + 1}`,
237
260
  source,
238
261
  target,
239
262
  status: target ? 'confirmed' : 'pending',
263
+ origin: target ? 'manual' : null,
264
+ suggestion: '',
240
265
  paragraph,
241
266
  ...(meta ? { meta } : {}),
242
267
  confirmedAt: target ? createdAt : null,
243
268
  updatedAt: createdAt,
244
269
  }
270
+ return normalizeSegmentState(segment)
245
271
  })
246
272
 
247
273
  const format = typeof input.format === 'string' ? input.format.slice(0, 24) : ''
@@ -251,6 +277,7 @@ export class CatStore {
251
277
  if (existing) {
252
278
  if (input.docxSource) this.attachDocxSource(existing, input.docxSource, input.docxName)
253
279
  if (input.pptxSource) this.attachPptxSource(existing, input.pptxSource, input.pptxName)
280
+ if (input.epubSource) this.attachEpubSource(existing, input.epubSource, input.epubName)
254
281
  if (input.format) existing.format = String(input.format).slice(0, 24)
255
282
  if (formatMeta) existing.formatMeta = cleanFormatMeta({ ...(existing.formatMeta || {}), ...formatMeta })
256
283
  this.activeDocId = existing.id
@@ -278,6 +305,7 @@ export class CatStore {
278
305
  if (doc.cursor < 0) doc.cursor = 0
279
306
  if (input.docxSource) this.attachDocxSource(doc, input.docxSource, input.docxName)
280
307
  if (input.pptxSource) this.attachPptxSource(doc, input.pptxSource, input.pptxName)
308
+ if (input.epubSource) this.attachEpubSource(doc, input.epubSource, input.epubName)
281
309
 
282
310
  this.docs.set(doc.id, doc)
283
311
  this.activeDocId = doc.id
@@ -299,6 +327,7 @@ export class CatStore {
299
327
  const tabIndex = this.openDocIds.indexOf(doc.id)
300
328
  this.removeDocxSource(doc)
301
329
  this.removePptxSource(doc)
330
+ this.removeEpubSource(doc)
302
331
  this.docs.delete(doc.id)
303
332
  this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
304
333
  if (this.activeDocId === doc.id) {
@@ -341,20 +370,98 @@ export class CatStore {
341
370
  doc.cursor = target
342
371
  doc.updatedAt = now()
343
372
  this.touch()
373
+ this.schedulePersist()
344
374
  return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
345
375
  }
346
376
 
347
377
  saveDraft({ documentId, index, target } = {}) {
348
378
  const doc = this.requireDocument(documentId)
349
- const segment = this.findSegment(doc, Number(index ?? doc.cursor))
379
+ const segmentIndex = Number(index ?? doc.cursor)
380
+ const segment = this.findSegment(doc, segmentIndex)
350
381
  const value = cleanText(target, '译文')
351
- segment.target = value
382
+ if (value !== segment.target) {
383
+ segment.target = value
384
+ segment.updatedAt = now()
385
+ doc.updatedAt = segment.updatedAt
386
+ // Typed by a human. A model proposal stays visible next to it for
387
+ // reference until it is adopted or replaced.
388
+ if (value) segment.origin = 'manual'
389
+ // Duplicates of this source follow the human's wording instead of
390
+ // keeping a stale copy. Confirmed siblings are never touched.
391
+ if (value) this.propagateEdit(doc, segmentIndex)
392
+ }
393
+ normalizeSegmentState(segment)
394
+ this.touch()
395
+ this.schedulePersist()
396
+ return {
397
+ segment: this.segmentView(segment, segmentIndex),
398
+ cursor: doc.cursor,
399
+ }
400
+ }
401
+
402
+ adoptSuggestion({ documentId, index } = {}) {
403
+ const doc = this.requireDocument(documentId)
404
+ const segmentIndex = Number(index ?? doc.cursor)
405
+ const segment = this.findSegment(doc, segmentIndex)
406
+ if (!segment.suggestion) {
407
+ throw new CatError('NO_SUGGESTION', '当前句段没有可供采纳的建议。')
408
+ }
409
+ if (segment.status === 'confirmed') {
410
+ throw new CatError('ALREADY_CONFIRMED', '已确认的句段不能采纳建议,请先撤销确认。')
411
+ }
412
+ segment.target = segment.suggestion
352
413
  segment.updatedAt = now()
353
414
  doc.updatedAt = segment.updatedAt
415
+ // Adoption must keep the proposal's provenance (mt/agent/tm), not mark it
416
+ // as human-authored.
417
+ normalizeSegmentState(segment)
354
418
  this.touch()
419
+ this.persist()
355
420
  return {
356
- segment: this.segmentView(segment, doc.segments.indexOf(segment)),
421
+ segment: this.segmentView(segment, segmentIndex),
357
422
  cursor: doc.cursor,
423
+ progress: this.progress(doc),
424
+ }
425
+ }
426
+
427
+ proposeTranslation({ documentId, index, target, reason = '' } = {}) {
428
+ const doc = this.requireDocument(documentId)
429
+ const segmentIndex = Number(index ?? doc.cursor)
430
+ const segment = this.findSegment(doc, segmentIndex)
431
+ if (segment.status === 'confirmed') {
432
+ throw new CatError('ALREADY_CONFIRMED', '已确认的句段不能提建议,请先撤销确认。')
433
+ }
434
+ const text = cleanText(target, '建议译文')
435
+ if (!text) throw new CatError('EMPTY_TARGET', '建议译文为空。')
436
+ const before = {
437
+ revision: this.revision,
438
+ updatedAt: doc.updatedAt,
439
+ segment: {
440
+ origin: segment.origin,
441
+ suggestion: segment.suggestion,
442
+ suggestionReason: segment.suggestionReason,
443
+ updatedAt: segment.updatedAt,
444
+ },
445
+ }
446
+ segment.suggestion = text
447
+ segment.suggestionReason = String(reason ?? '').slice(0, 500)
448
+ // A proposal never touches `target`: the human keeps the decision.
449
+ if (!segment.target) segment.origin = 'agent'
450
+ segment.updatedAt = now()
451
+ doc.updatedAt = segment.updatedAt
452
+ normalizeSegmentState(segment)
453
+ this.touch()
454
+ try {
455
+ this.persist({ strict: true })
456
+ } catch (error) {
457
+ this.revision = before.revision
458
+ doc.updatedAt = before.updatedAt
459
+ Object.assign(segment, before.segment)
460
+ throw error
461
+ }
462
+ return {
463
+ segment: this.segmentView(segment, segmentIndex),
464
+ progress: this.progress(doc),
358
465
  }
359
466
  }
360
467
 
@@ -370,6 +477,8 @@ export class CatStore {
370
477
  segment: {
371
478
  target: segment.target,
372
479
  status: segment.status,
480
+ origin: segment.origin,
481
+ suggestion: segment.suggestion,
373
482
  confirmedAt: segment.confirmedAt,
374
483
  updatedAt: segment.updatedAt,
375
484
  },
@@ -377,7 +486,11 @@ export class CatStore {
377
486
  }
378
487
 
379
488
  if (target !== undefined) {
380
- segment.target = cleanText(target, '译文')
489
+ const value = cleanText(target, '译文')
490
+ // Confirming with the proposal's own text is adoption: keep where it came
491
+ // from. Any other text is authored right here.
492
+ if (value !== segment.target && value !== segment.suggestion) segment.origin = 'manual'
493
+ segment.target = value
381
494
  }
382
495
  if (!segment.target) {
383
496
  throw new CatError('EMPTY_TARGET', '译文为空,无法确认句段。')
@@ -391,17 +504,25 @@ export class CatStore {
391
504
  )
392
505
  }
393
506
 
394
- segment.status = 'confirmed'
395
507
  segment.confirmedAt = now()
396
508
  segment.updatedAt = segment.confirmedAt
509
+ normalizeSegmentState(segment)
397
510
  doc.updatedAt = segment.confirmedAt
398
511
 
399
512
  const tmResult = addToTm
400
- ? this.upsertTM(segment.source, segment.target, doc.sourceLang, doc.targetLang, writeTmId)
513
+ ? this.upsertTM(
514
+ segment.source,
515
+ segment.target,
516
+ doc.sourceLang,
517
+ doc.targetLang,
518
+ writeTmId,
519
+ contextOf(doc, segmentIndex),
520
+ )
401
521
  : null
402
522
 
403
523
  const nextIndex = this.findNextUnconfirmed(doc, segmentIndex)
404
524
  doc.cursor = nextIndex
525
+ this.propagateFromSegment(doc, segmentIndex)
405
526
  this.touch()
406
527
  try {
407
528
  this.persist({ strict: true })
@@ -415,12 +536,15 @@ export class CatStore {
415
536
  }
416
537
 
417
538
  const progress = this.progress(doc)
539
+ // Translated-but-unreviewed segments still have to be visited, so the
540
+ // remaining count is driven by confirmed only.
541
+ const remaining = progress.total - progress.confirmed
418
542
  return {
419
543
  confirmed: this.segmentView(segment, segmentIndex),
420
- next: progress.pending ? this.segmentDetail(doc, doc.cursor) : null,
544
+ next: remaining ? this.segmentDetail(doc, doc.cursor) : null,
421
545
  cursor: doc.cursor,
422
546
  progress,
423
- done: progress.pending === 0,
547
+ done: remaining === 0,
424
548
  tm: tmResult
425
549
  ? {
426
550
  added: tmResult.created ? 1 : 0,
@@ -473,6 +597,13 @@ export class CatStore {
473
597
  this.settings.uiLang = uiLang
474
598
  }
475
599
 
600
+ if (patch.preprocess && typeof patch.preprocess === 'object') {
601
+ this.settings.preprocess = normalizePreprocessOptions({
602
+ ...this.settings.preprocess,
603
+ ...patch.preprocess,
604
+ })
605
+ }
606
+
476
607
  if (patch.mt && typeof patch.mt === 'object') {
477
608
  const mt = patch.mt
478
609
  if (mt.provider !== undefined) {
@@ -731,4 +862,4 @@ export class CatStore {
731
862
  }
732
863
  }
733
864
 
734
- Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods)
865
+ Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods, preTranslateMethods, qaMethods, libraryViewMethods, propagateMethods, alignMethods, termMethods)
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Terminology candidates, found by counting rather than by asking a model to
3
+ * read a document. Models are good at judging a short list ("is this a domain
4
+ * term? what does it translate to?") and bad at enumerating one: without
5
+ * frequency evidence a long list cannot be reviewed and gets abandoned, which
6
+ * is exactly what happened the first time.
7
+ *
8
+ * So this module only proposes candidates with evidence — occurrences, length,
9
+ * boundary entropy, containment — and the model (or the human) picks.
10
+ */
11
+
12
+ const STOPWORDS = new Set([
13
+ 'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'than', 'that', 'this', 'these', 'those',
14
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'to', 'of', 'in', 'on', 'at', 'by', 'for',
15
+ 'with', 'from', 'as', 'it', 'its', 'we', 'you', 'they', 'he', 'she', 'i', 'not', 'no', 'do',
16
+ 'does', 'did', 'can', 'could', 'will', 'would', 'should', 'may', 'might', 'must', 'have', 'has',
17
+ 'had', 'there', 'here', 'when', 'where', 'which', 'who', 'what', 'how', 'all', 'any', 'some',
18
+ 'more', 'most', 'other', 'into', 'over', 'under', 'about', 'after', 'before', 'also', 'just',
19
+ 'only', 'very', 'so', 'such', 'use', 'used', 'using', 'one', 'two', 'new', 'get', 'set', 'see',
20
+ ])
21
+
22
+ const CJK = /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/
23
+
24
+ const WORD = /[A-Za-z][A-Za-z0-9'’.-]*/g
25
+
26
+ export function splitSentencesForTerms(text) {
27
+ return String(text ?? '')
28
+ .split(/(?<=[.!?。!?])\s+|\n+/)
29
+ .map((line) => line.trim())
30
+ .filter(Boolean)
31
+ }
32
+
33
+ function wordsOf(sentence) {
34
+ return (sentence.match(WORD) || []).map((word) => word.replace(/[.'’-]+$/, ''))
35
+ }
36
+
37
+ function normalizedTerm(value) {
38
+ return String(value ?? '')
39
+ .replace(/\s+/g, ' ')
40
+ .trim()
41
+ .toLocaleLowerCase()
42
+ }
43
+
44
+ /** Latin candidates are word n-grams (1–4) without a stopword at either edge. */
45
+ export function latinCandidates(sentences, options = {}) {
46
+ const maxN = options.maxN ?? 4
47
+ const counts = new Map()
48
+ const examples = new Map()
49
+ const display = new Map()
50
+ for (const sentence of sentences) {
51
+ const words = wordsOf(sentence)
52
+ for (let n = 1; n <= maxN; n += 1) {
53
+ for (let start = 0; start + n <= words.length; start += 1) {
54
+ const slice = words.slice(start, start + n)
55
+ if (STOPWORDS.has(slice[0].toLocaleLowerCase())) continue
56
+ if (STOPWORDS.has(slice[n - 1].toLocaleLowerCase())) continue
57
+ const term = slice.join(' ')
58
+ // A single token has to look like a name or a compound; plain everyday
59
+ // words are noise.
60
+ if (n === 1 && !/^[A-Z][A-Za-z0-9]*$|[-_]|[a-z][A-Z]/.test(slice[0])) continue
61
+ const key = normalizedTerm(term)
62
+ counts.set(key, (counts.get(key) || 0) + 1)
63
+ if (!display.has(key)) display.set(key, term)
64
+ if (!examples.has(key)) examples.set(key, [])
65
+ const bucket = examples.get(key)
66
+ if (bucket.length < 2 && !bucket.includes(sentence)) bucket.push(sentence)
67
+ }
68
+ }
69
+ }
70
+ return [...counts.entries()].map(([key, count]) => ({
71
+ term: display.get(key) ?? key,
72
+ key,
73
+ count,
74
+ kind: 'latin',
75
+ examples: examples.get(key) ?? [],
76
+ }))
77
+ }
78
+
79
+ function entropy(values) {
80
+ if (!values.length) return 0
81
+ const counts = new Map()
82
+ for (const value of values) counts.set(value, (counts.get(value) || 0) + 1)
83
+ let value = 0
84
+ for (const amount of counts.values()) {
85
+ const p = amount / values.length
86
+ value -= p * Math.log2(p)
87
+ }
88
+ return value
89
+ }
90
+
91
+ /**
92
+ * Chinese, Japanese and Korean have no spaces, so candidates are repeated
93
+ * substrings. Left/right neighbour entropy filters out fragments that are
94
+ * merely the middle of a longer word: a real term appears after many different
95
+ * characters, a fragment appears after the same one.
96
+ */
97
+ export function cjkCandidates(text, options = {}) {
98
+ const min = options.min ?? 2
99
+ const max = options.max ?? 6
100
+ const limit = options.limit ?? 400
101
+ const body = String(text ?? '').replace(/\s+/g, ' ')
102
+ const counts = new Map()
103
+ const left = new Map()
104
+ const right = new Map()
105
+ for (let start = 0; start < body.length; start += 1) {
106
+ if (!CJK.test(body[start])) continue
107
+ for (let size = min; size <= max; size += 1) {
108
+ const end = start + size
109
+ if (end > body.length) break
110
+ const piece = body.slice(start, end)
111
+ if (!CJK.test(piece[0]) || !CJK.test(piece[size - 1])) continue
112
+ counts.set(piece, (counts.get(piece) || 0) + 1)
113
+ if (!left.has(piece)) left.set(piece, [])
114
+ if (!right.has(piece)) right.set(piece, [])
115
+ left.get(piece).push(body[start - 1] ?? '')
116
+ right.get(piece).push(body[end] ?? '')
117
+ }
118
+ }
119
+ const candidates = []
120
+ for (const [term, count] of counts) {
121
+ if (count < 2) continue
122
+ const boundary = Math.min(entropy(left.get(term) || []), entropy(right.get(term) || []))
123
+ if (boundary < 0.5) continue
124
+ candidates.push({ term, count, kind: 'cjk', boundary, examples: [] })
125
+ }
126
+ return candidates.sort((a, b) => b.count - a.count).slice(0, limit)
127
+ }
128
+
129
+ /**
130
+ * C-value in spirit: frequent, long candidates win, and a string that is only
131
+ * ever contained in a longer candidate loses to that longer one.
132
+ */
133
+ export function rankCandidates(candidates) {
134
+ for (const item of candidates) {
135
+ let contained = 0
136
+ let longerCount = 0
137
+ for (const other of candidates) {
138
+ if (other === item) continue
139
+ if (other.term.length > item.term.length && other.term.includes(item.term)) {
140
+ contained += 1
141
+ longerCount += other.count
142
+ }
143
+ }
144
+ const lengthBonus = Math.log2(2 + Array.from(item.term).length)
145
+ const cvalue = item.count * lengthBonus - (contained ? longerCount / contained : 0)
146
+ item.score = Math.round(Math.max(0, cvalue) * 1000) / 1000
147
+ }
148
+ // A fragment that only ever shows up inside a longer term is part of that
149
+ // term, not a term of its own ("design" next to "Design System").
150
+ const covered = new Set()
151
+ for (const item of candidates) {
152
+ for (const other of candidates) {
153
+ if (other === item) continue
154
+ if (other.term.length > item.term.length && other.term.includes(item.term) && other.count >= item.count) {
155
+ covered.add(item)
156
+ break
157
+ }
158
+ }
159
+ }
160
+ const seen = new Set()
161
+ const unique = []
162
+ for (const item of candidates.filter((candidate) => !covered.has(candidate)).sort((a, b) => b.score - a.score || b.count - a.count)) {
163
+ // Fold case and a trailing plural: "Mode"/"modes" are one concept.
164
+ const fold = normalizedTerm(item.term).replace(/(\w)s$/, '$1')
165
+ if (seen.has(fold)) continue
166
+ seen.add(fold)
167
+ unique.push(item)
168
+ }
169
+ return unique
170
+ }
171
+
172
+ /**
173
+ * Candidates for one text, annotated against the terms already in memory.
174
+ */
175
+ export function extractTerms(text, options = {}) {
176
+ const sentences = splitSentencesForTerms(text)
177
+ const latin = latinCandidates(sentences, options)
178
+ const cjk = cjkCandidates(text, options)
179
+ const ranked = rankCandidates([...latin, ...cjk])
180
+ const known = new Set((options.knownTerms ?? []).map(normalizedTerm))
181
+ const limit = options.limit ?? 60
182
+ const minimumCount = options.minCount ?? 2
183
+ return ranked
184
+ .filter((item) => item.count >= minimumCount)
185
+ .map((item) => ({
186
+ ...item,
187
+ inLibrary: known.has(normalizedTerm(item.term)),
188
+ }))
189
+ .slice(0, limit)
190
+ }