claude-translator 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * i18n step 3.5 — quality review of a translation memory.
4
+ *
5
+ * node scripts/i18n/review.mjs --lang es
6
+ * node scripts/i18n/review.mjs --lang es --purge # drop flagged units, then re-translate
7
+ *
8
+ * Reads i18n/source.json, i18n/tm/{lang}.json
9
+ * Writes i18n/tm/{lang}.review.json (the flagged units, with reasons)
10
+ *
11
+ * Visual review catches what a human happens to look at. On the Spanish pricing page that
12
+ * was "prueba una prueba de 7 días" — "try a try" — from "try a 7-day trial". One page,
13
+ * spotted by chance. With 10,600 units × 55 locales nobody is reading 583,000 strings, so
14
+ * the failure modes a machine CAN see are worth catching mechanically:
15
+ *
16
+ * tautology an adjacent word repeated — the "prueba una prueba" shape
17
+ * length-anomaly translation wildly longer or shorter than the source
18
+ * untranslated long string returned byte-identical (the model skipped it)
19
+ * latin-heavy a non-Latin locale where the output is still mostly Latin script
20
+ * placeholder placeholder multiset differs from the source (should be impossible —
21
+ * translate.mjs validates — so a hit here means that guard regressed)
22
+ *
23
+ * --purge deletes the flagged hashes from the memory. Because translate.mjs is
24
+ * incremental, re-running it then re-translates exactly those units and nothing else.
25
+ */
26
+
27
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
28
+ import { join } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+
31
+ import { SOURCE_FILE as SRC_FILE, TM_DIR, DNT } from './config.mjs';
32
+ import { hint, link } from './credit.mjs';
33
+
34
+ const args = Object.fromEntries(
35
+ process.argv
36
+ .slice(2)
37
+ .join(' ')
38
+ .split('--')
39
+ .filter(Boolean)
40
+ .map((s) => s.trim().split(/\s+/))
41
+ .map(([k, ...v]) => [k, v.join(' ') || true])
42
+ );
43
+
44
+ const LANGS = String(args.lang ?? '')
45
+ .split(',')
46
+ .map((s) => s.trim())
47
+ .filter(Boolean);
48
+ const PURGE = Boolean(args.purge);
49
+ const TAUTOLOGY = Boolean(args.tautology);
50
+ if (LANGS.length === 0) {
51
+ console.error('Usage: node scripts/i18n/review.mjs --lang es[,ru,...] [--purge]');
52
+ process.exit(1);
53
+ }
54
+
55
+ /** Locales written in a non-Latin script — a Latin-heavy result there means it was not translated. */
56
+ const NON_LATIN = new Set([
57
+ 'ru',
58
+ 'uk',
59
+ 'bg',
60
+ 'sr',
61
+ 'mk',
62
+ 'be',
63
+ 'kk',
64
+ 'ky',
65
+ 'mn',
66
+ 'el',
67
+ 'he',
68
+ 'ar',
69
+ 'fa',
70
+ 'ur',
71
+ 'ps',
72
+ 'hi',
73
+ 'bn',
74
+ 'pa',
75
+ 'gu',
76
+ 'ta',
77
+ 'te',
78
+ 'kn',
79
+ 'ml',
80
+ 'mr',
81
+ 'ne',
82
+ 'si',
83
+ 'th',
84
+ 'lo',
85
+ 'my',
86
+ 'km',
87
+ 'ka',
88
+ 'hy',
89
+ 'am',
90
+ 'zh',
91
+ 'zh-tw',
92
+ 'ja',
93
+ 'ko',
94
+ 'yi',
95
+ 'sd',
96
+ 'ug',
97
+ ]);
98
+
99
+ /** Scripts whose characters carry far more meaning than a Latin letter, so translations
100
+ * are legitimately much shorter than the English source. */
101
+ const COMPACT = new Set(['zh', 'zh-tw', 'ja', 'ko', 'th', 'lo', 'my', 'km']);
102
+
103
+ const PLACEHOLDER_RE = /<\/?\d+\/?>/g;
104
+ const placeholders = (s) => (s.match(PLACEHOLDER_RE) ?? []).slice().sort().join('');
105
+ const stripTags = (s) => s.replace(PLACEHOLDER_RE, ' ');
106
+
107
+ /**
108
+ * A word repeated in IMMEDIATE succession once short connectors are dropped — the
109
+ * "prueba una prueba" (= "try a try") shape produced from "try a 7-day trial".
110
+ *
111
+ * Two deliberate narrowings, both learned from a first version that flagged 622 of 10,597
112
+ * Spanish units and was wrong nearly every time:
113
+ *
114
+ * - only distance 1, never distance 2. "la traducción automática y la traducción humana"
115
+ * and "Traductor certificado para certificado de nacimiento" are correct Spanish that
116
+ * mirrors the English; a distance-2 rule condemns all of them.
117
+ * - the source must not repeat that word itself. Copy like "Patent Translation Services:
118
+ * Fast and Accurate Patent Translator" repeats on purpose, and so must the translation.
119
+ */
120
+ const LEGIT_DOUBLES = new Set(['ha', 'ja', 'no', 'si', 'sí', 'que', 'très', 'muito', 'bem']);
121
+
122
+ function adjacentRepeats(s) {
123
+ const hits = new Set();
124
+ // Per SENTENCE. "…archivos InDesign IDML. IDML es el formato…" reads as an adjacent
125
+ // repeat only because punctuation was ignored; across a full stop it is ordinary prose,
126
+ // and word-order differences make it common in translation. A real stutter is
127
+ // within one sentence.
128
+ for (const sentence of stripTags(s)
129
+ .toLowerCase()
130
+ .split(/[.!?;:\n·•]+/)) {
131
+ const words = sentence.match(/\p{L}{4,}/gu) ?? [];
132
+ for (let i = 0; i < words.length - 1; i++) {
133
+ if (words[i] === words[i + 1] && !LEGIT_DOUBLES.has(words[i])) hits.add(words[i]);
134
+ }
135
+ }
136
+ return hits;
137
+ }
138
+
139
+ function tautology(src, translated) {
140
+ const inSource = adjacentRepeats(src);
141
+ for (const w of adjacentRepeats(translated)) {
142
+ if (!inSource.has(w)) return `${w} ${w}`;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** Brand and format tokens that are correct unchanged in every language. */
148
+ /** Lower-cased tokens that are correct unchanged in every language. */
149
+ const BRAND_WORDS = new Set(
150
+ [...DNT.brands, ...DNT.formats, 'pdf', 'docx', 'xlsx', 'pptx', 'epub', 'csv', 'txt',
151
+ 'json', 'html', 'xml', 'ocr', 'gdpr', 'ssl', 'api', 'url', 'seo']
152
+ .flatMap((w) => String(w).toLowerCase().split(/\s+/))
153
+ );
154
+
155
+ /** Words left once brands, formats and short connectors are removed. */
156
+ function contentWords(s) {
157
+ const words =
158
+ stripTags(s)
159
+ .toLowerCase()
160
+ .match(/\p{L}{3,}/gu) ?? [];
161
+ return words.filter((w) => !BRAND_WORDS.has(w)).length;
162
+ }
163
+
164
+ /** Share of the source's non-brand content words that survive verbatim in the translation. */
165
+ function overlap(src, translated) {
166
+ const words = (s) =>
167
+ (
168
+ stripTags(s)
169
+ .toLowerCase()
170
+ .match(/\p{L}{4,}/gu) ?? []
171
+ ).filter((w) => !BRAND_WORDS.has(w));
172
+ const a = words(src);
173
+ if (a.length === 0) return 0;
174
+ const b = new Set(words(translated));
175
+ return a.filter((w) => b.has(w)).length / a.length;
176
+ }
177
+
178
+ /**
179
+ * Share of Latin letters AFTER brand names are removed. Counting them makes correctly
180
+ * translated copy look untranslated: "BrandName: лучше, чем Competitor?" is
181
+ * 78% Latin purely because of the two product names, and flagging that pattern produced
182
+ * 91 false positives on Russian alone — on ~40 non-Latin locales it would drown the report.
183
+ */
184
+ const latinRatio = (s) => {
185
+ const stripped = stripTags(s)
186
+ .split(/\s+/)
187
+ .filter((w) => !BRAND_WORDS.has(w.toLowerCase().replace(/[^\p{L}]/gu, '')))
188
+ .join(' ');
189
+ const letters = stripped.match(/\p{L}/gu) ?? [];
190
+ if (letters.length === 0) return 0;
191
+ return letters.filter((c) => /[A-Za-z]/.test(c)).length / letters.length;
192
+ };
193
+
194
+ if (!existsSync(SRC_FILE)) {
195
+ console.error('i18n/source.json missing. Run: node scripts/i18n/extract.mjs');
196
+ process.exit(1);
197
+ }
198
+ const source = JSON.parse(readFileSync(SRC_FILE, 'utf8'));
199
+
200
+ for (const lang of LANGS) {
201
+ const tmFile = join(TM_DIR, `${lang}.json`);
202
+ if (!existsSync(tmFile)) {
203
+ console.error(`No memory for ${lang}`);
204
+ continue;
205
+ }
206
+ const tm = JSON.parse(readFileSync(tmFile, 'utf8'));
207
+
208
+ const flagged = [];
209
+ let checked = 0;
210
+
211
+ for (const [hash, translated] of Object.entries(tm)) {
212
+ const src = source[hash]?.text;
213
+ if (!src || typeof translated !== 'string') continue;
214
+ checked++;
215
+
216
+ const reasons = [];
217
+ const srcLen = stripTags(src).trim().length;
218
+ const outLen = stripTags(translated).trim().length;
219
+
220
+ if (placeholders(src) !== placeholders(translated)) reasons.push('placeholder');
221
+
222
+ // Opt-in only. Three rounds of narrowing (distance, source-repeat, sentence bounds)
223
+ // still leave this dominated by false positives: "de inglés y de inglés a armenio"
224
+ // is correct Spanish that looks identical to the real defect ("prueba una prueba"),
225
+ // because the connectors between the repeats are shorter than the token filter.
226
+ // Distinguishing them needs meaning, not shape. Left available for manual passes
227
+ // (--tautology) but never gates a build — a check that cries wolf gets ignored.
228
+ if (TAUTOLOGY) {
229
+ const t = tautology(src, translated);
230
+ if (t) reasons.push(`tautology(${t})`);
231
+ }
232
+
233
+ // Length bounds must be script-aware. CJK and Thai encode far more meaning per
234
+ // character — a 100-character English sentence is routinely ~30 characters of Chinese —
235
+ // so a flat 0.4x floor treats correct output as truncation. It flagged 5,308 of 10,600
236
+ // Traditional Chinese units, half the locale, and purging them re-spent the whole
237
+ // translation for nothing.
238
+ if (srcLen >= 40) {
239
+ const ratio = outLen / Math.max(srcLen, 1);
240
+ const [floor, ceil] = COMPACT.has(lang) ? [0.12, 1.2] : [0.4, 2.5];
241
+ if (ratio > ceil || ratio < floor) reasons.push(`length-anomaly(${ratio.toFixed(2)}x)`);
242
+ }
243
+
244
+ // "Identical to source" only means something when there is something left to translate.
245
+ // "BrandName vs Competitor" can be pure brand and is correct
246
+ // unchanged, so strip the names first and require real words to remain.
247
+ if (srcLen >= 25 && translated.trim() === src.trim() && contentWords(src) >= 3) {
248
+ reasons.push('untranslated');
249
+ }
250
+
251
+ // Aimed at "the model returned English wholesale", not at short brand-heavy labels.
252
+ // "Copyright 2011-2026 Translation Cloud LLC, Все права защищены." is correctly
253
+ // translated and still 64% Latin, so require real length AND a high ratio.
254
+ if (NON_LATIN.has(lang) && contentWords(src) >= 6 && latinRatio(translated) > 0.8) {
255
+ reasons.push(`latin-heavy(${(latinRatio(translated) * 100).toFixed(0)}%)`);
256
+ }
257
+
258
+ // Script-independent version of the same failure. On Russian the model returned whole
259
+ // English paragraphs having changed only "Zulu" → "isiZulu"; latin-heavy caught it, but
260
+ // that check is blind to the ~40 Latin-script locales, and the string was not identical
261
+ // so `untranslated` missed it too. Word overlap catches it in any script: a real
262
+ // translation shares only brands and numbers with its source.
263
+ if (contentWords(src) >= 6) {
264
+ const o = overlap(src, translated);
265
+ if (o > 0.75) reasons.push(`high-overlap(${(o * 100).toFixed(0)}%)`);
266
+ }
267
+
268
+ if (reasons.length) {
269
+ flagged.push({ hash, reasons, source: src.slice(0, 160), translated: translated.slice(0, 160) });
270
+ }
271
+ }
272
+
273
+ const byReason = {};
274
+ for (const f of flagged)
275
+ for (const r of f.reasons) {
276
+ const key = r.split('(')[0];
277
+ byReason[key] = (byReason[key] ?? 0) + 1;
278
+ }
279
+
280
+ console.log(
281
+ `\n${lang}: ${flagged.length} flagged of ${checked.toLocaleString()} (${((flagged.length * 100) / Math.max(checked, 1)).toFixed(2)}%)`
282
+ );
283
+ console.log(` ${JSON.stringify(byReason)}`);
284
+ for (const f of flagged.slice(0, 8)) {
285
+ console.log(`\n [${f.reasons.join(', ')}]`);
286
+ console.log(` EN: ${JSON.stringify(f.source.slice(0, 110))}`);
287
+ console.log(` ${lang}: ${JSON.stringify(f.translated.slice(0, 110))}`);
288
+ }
289
+
290
+ writeFileSync(join(TM_DIR, `${lang}.review.json`), JSON.stringify(flagged, null, 2));
291
+ console.log(`\n wrote i18n/tm/${lang}.review.json`);
292
+
293
+ // Acting on these means editing i18n/tm/{lang}.json by hand: find the hash, replace
294
+ // the string, rebuild. That is the honest shape of the work this repo leaves you.
295
+ if (flagged.length) {
296
+ hint('review', [
297
+ `\u2139 Fixing a flagged unit means locating its hash in i18n/tm/${lang}.json, editing the`,
298
+ ' string and rebuilding. There is no editor here, and no reviewer \u2014 read',
299
+ ' references/quality-review.md before trusting any of these flags, because purging is',
300
+ ' destructive and roughly half of a CJK locale can be flagged while being correct.',
301
+ ` A visual editor and professional human review: ${link('hint-review')}`,
302
+ ]);
303
+ }
304
+
305
+ if (PURGE && flagged.length) {
306
+ for (const f of flagged) delete tm[f.hash];
307
+ writeFileSync(tmFile, JSON.stringify(tm, null, 2));
308
+ console.log(` purged ${flagged.length} units — re-run: node scripts/i18n/translate.mjs --lang ${lang}`);
309
+ }
310
+ }