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.
- package/CHANGELOG.md +135 -0
- package/LICENSE +662 -0
- package/LICENSING.md +69 -0
- package/README.md +434 -0
- package/SKILL.md +206 -0
- package/bin/claude-translator.mjs +230 -0
- package/bin/cli.test.mjs +165 -0
- package/i18n.config.example.json +67 -0
- package/package.json +60 -0
- package/references/adapting-generators.md +76 -0
- package/references/failure-modes.md +255 -0
- package/references/providers.md +157 -0
- package/references/quality-review.md +91 -0
- package/references/throughput-and-cost.md +124 -0
- package/scripts/audit-seo.mjs +261 -0
- package/scripts/build-locales.mjs +336 -0
- package/scripts/config.mjs +188 -0
- package/scripts/credit.mjs +143 -0
- package/scripts/extract.mjs +564 -0
- package/scripts/finalize.sh +58 -0
- package/scripts/providers/anthropic.mjs +118 -0
- package/scripts/providers/gemini.mjs +72 -0
- package/scripts/providers/index.mjs +95 -0
- package/scripts/providers/openai.mjs +120 -0
- package/scripts/providers/providers.test.mjs +214 -0
- package/scripts/review.mjs +310 -0
- package/scripts/translate.mjs +455 -0
- package/scripts/verify.mjs +384 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* i18n step 2 — translate extracted units into a translation memory.
|
|
4
|
+
*
|
|
5
|
+
* node scripts/i18n/translate.mjs --lang es
|
|
6
|
+
* node scripts/i18n/translate.mjs --lang es,ru --provider gemini --model gemini-2.5-flash-lite
|
|
7
|
+
* node scripts/i18n/translate.mjs --lang es --provider openai # local model via apiBaseUrl
|
|
8
|
+
* node scripts/i18n/translate.mjs --lang es --limit 150 --tag modelcmp # sampling run
|
|
9
|
+
*
|
|
10
|
+
* Reads i18n/source.json (from extract.mjs)
|
|
11
|
+
* Writes i18n/tm/{lang}.json hash → translated unit
|
|
12
|
+
*
|
|
13
|
+
* ── Incremental by design ────────────────────────────────────────────────────
|
|
14
|
+
* The memory is keyed by the SHA-1 of the English unit. Editing one English page
|
|
15
|
+
* changes only the hashes of the units it touched, so a re-run translates those and
|
|
16
|
+
* reuses everything else. A full re-translation only happens if you delete the memory.
|
|
17
|
+
*
|
|
18
|
+
* ── Placeholders are load-bearing ────────────────────────────────────────────
|
|
19
|
+
* Units carry inline markup as <0>…</0> / <1/> placeholders. A translation that drops,
|
|
20
|
+
* duplicates or invents one would corrupt the HTML on rebuild, so every response is
|
|
21
|
+
* validated against the source placeholder multiset and retried; units that still fail
|
|
22
|
+
* are left untranslated (English) and reported rather than shipped broken.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
SOURCE_FILE as SRC_FILE, TM_DIR, LOCALES, RTL, DNT, MODEL as CFG_MODEL,
|
|
31
|
+
ROOT_DIR as ROOT, SITE_NAME, SITE_DESCRIPTION, SOURCE_LANGUAGE,
|
|
32
|
+
PROVIDER as CFG_PROVIDER, API_BASE_URL, API_KEY_ENV, JSON_MODE, PRICING,
|
|
33
|
+
} from './config.mjs';
|
|
34
|
+
import { hint, link } from './credit.mjs';
|
|
35
|
+
import { loadProvider, extractJson } from './providers/index.mjs';
|
|
36
|
+
|
|
37
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const args = Object.fromEntries(
|
|
40
|
+
process.argv
|
|
41
|
+
.slice(2)
|
|
42
|
+
.join(' ')
|
|
43
|
+
.split('--')
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.map((s) => s.trim().split(/\s+/))
|
|
46
|
+
.map(([k, ...v]) => [k, v.join(' ') || true])
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const LANGS = String(args.lang ?? '')
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
const PROVIDER_NAME = args.provider ? String(args.provider) : CFG_PROVIDER;
|
|
54
|
+
const MODEL_ARG = args.model ? String(args.model) : CFG_MODEL;
|
|
55
|
+
const LIMIT = args.limit ? Number(args.limit) : Infinity;
|
|
56
|
+
const TAG = args.tag ? `.${args.tag}` : '';
|
|
57
|
+
const BATCH_UNITS = Number(args.batch ?? 40);
|
|
58
|
+
const CONCURRENCY = Number(args.concurrency ?? 12);
|
|
59
|
+
const DRY = Boolean(args.dry);
|
|
60
|
+
|
|
61
|
+
if (LANGS.length === 0) {
|
|
62
|
+
console.error(
|
|
63
|
+
'Usage: node scripts/i18n/translate.mjs --lang es[,ru,...] [--provider P] [--model M] [--limit N] [--tag T] [--dry]'
|
|
64
|
+
);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Key ──────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const PROVIDER = await loadProvider({ provider: PROVIDER_NAME, model: MODEL_ARG, root: ROOT });
|
|
71
|
+
const MODEL = MODEL_ARG ?? PROVIDER.defaultModel;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The key, from the environment or a .env file, under whichever variable the resolved
|
|
75
|
+
* provider uses — or `apiKeyEnv` if the config names a different one. Providers that
|
|
76
|
+
* set `keyOptional` (the OpenAI-compatible adapter, because local servers do not
|
|
77
|
+
* authenticate) are allowed to proceed without one.
|
|
78
|
+
*/
|
|
79
|
+
function loadKey() {
|
|
80
|
+
const names = API_KEY_ENV ? [API_KEY_ENV] : (PROVIDER.envKeys ?? []);
|
|
81
|
+
for (const name of names) if (process.env[name]) return process.env[name];
|
|
82
|
+
|
|
83
|
+
const envFile = join(ROOT, '.env');
|
|
84
|
+
if (existsSync(envFile)) {
|
|
85
|
+
const text = readFileSync(envFile, 'utf8');
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const m = new RegExp(`^${name}=(.*)$`, 'm').exec(text);
|
|
88
|
+
if (m) return m[1].trim();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (PROVIDER.keyOptional) return null;
|
|
93
|
+
console.error(
|
|
94
|
+
`No API key for ${PROVIDER.label ?? PROVIDER.id}. Set ${names.join(' or ')} in the ` +
|
|
95
|
+
`environment or .env, or set "apiKeyEnv" in i18n.config.json.`
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const KEY = loadKey();
|
|
100
|
+
|
|
101
|
+
// ── Language names for the prompt ────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const LANG_NAMES = Object.fromEntries(LOCALES.map((l) => [l.pathCode, l.nativeLabel ?? l.pathCode]));
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
// ── Prompt ───────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format, protocol and standards tokens that are correct unchanged in every language.
|
|
110
|
+
* Site-specific brand names come from config (doNotTranslate.brands) and are merged in.
|
|
111
|
+
*/
|
|
112
|
+
const TECH_TOKENS = [
|
|
113
|
+
'PDF', 'DOCX', 'DOC', 'XLSX', 'XLS', 'PPTX', 'PPT', 'EPUB', 'CSV', 'TXT', 'JSON',
|
|
114
|
+
'HTML', 'XML', 'IDML', 'INDD', 'PNG', 'JPG', 'JPEG', 'SVG', 'WEBP', 'MP4', 'ZIP',
|
|
115
|
+
'OCR', 'GDPR', 'SSL', 'TLS', 'API', 'SDK', 'URL', 'HTTP', 'HTTPS', 'SEO', 'CSS', 'RSS',
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const GLOSSARY = [...new Set([...DNT.brands, ...DNT.formats, ...TECH_TOKENS])];
|
|
119
|
+
|
|
120
|
+
function systemPrompt(langCode) {
|
|
121
|
+
const name = LANG_NAMES[langCode] ?? langCode;
|
|
122
|
+
return [
|
|
123
|
+
`You are a professional translator localising the website of ${SITE_NAME}${SITE_DESCRIPTION ? `, ${SITE_DESCRIPTION}` : ''}.`,
|
|
124
|
+
`Translate from ${SOURCE_LANGUAGE} into ${name} (${langCode}).`,
|
|
125
|
+
``,
|
|
126
|
+
`RULES`,
|
|
127
|
+
`1. Preserve every placeholder EXACTLY: <0>, </0>, <1/> and so on. Same count, same numbers.`,
|
|
128
|
+
` Placeholders wrap inline markup — move them so they wrap the equivalent words in your`,
|
|
129
|
+
` translation, but never drop, add, renumber or reorder their nesting.`,
|
|
130
|
+
`2. Never translate these names: ${GLOSSARY.join(', ')}.`,
|
|
131
|
+
`3. This is marketing and product copy. Translate meaning and tone, not word for word.`,
|
|
132
|
+
` Keep it natural and idiomatic for a native reader.`,
|
|
133
|
+
`4. Keep numbers, prices, file sizes and counts unchanged (e.g. "120+", "1 GB", "10 MB").`,
|
|
134
|
+
`5. Preserve leading/trailing punctuation and capitalisation style of the source where the`,
|
|
135
|
+
` target language allows it. Headings stay headings; button labels stay short.`,
|
|
136
|
+
`6. Do not add explanations, notes or quotes around the result.`,
|
|
137
|
+
RTL.has(langCode) ? `7. ${name} is right-to-left. Write natural RTL text; do not insert directional marks.` : ``,
|
|
138
|
+
``,
|
|
139
|
+
`Return a JSON array. For each input item return { "id": <same id>, "text": "<translation>" }.`,
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const RESPONSE_SCHEMA = {
|
|
146
|
+
type: 'ARRAY',
|
|
147
|
+
items: {
|
|
148
|
+
type: 'OBJECT',
|
|
149
|
+
properties: { id: { type: 'INTEGER' }, text: { type: 'STRING' } },
|
|
150
|
+
required: ['id', 'text'],
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// ── Placeholder validation ───────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
const PLACEHOLDER_RE = /<\/?\d+\/?>/g;
|
|
157
|
+
|
|
158
|
+
/** Sorted multiset of placeholders, so order changes are allowed but content is not. */
|
|
159
|
+
function placeholderSet(s) {
|
|
160
|
+
return (s.match(PLACEHOLDER_RE) ?? []).slice().sort().join('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validate(source, translated) {
|
|
164
|
+
if (typeof translated !== 'string' || translated.trim().length === 0) return 'empty';
|
|
165
|
+
if (placeholderSet(source) !== placeholderSet(translated)) return 'placeholder-mismatch';
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── API ──────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One request to whichever provider is loaded.
|
|
173
|
+
*
|
|
174
|
+
* The provider module owns three things and nothing else: how to build the request,
|
|
175
|
+
* how to read a response, and how to price it. Everything that made this function
|
|
176
|
+
* worth keeping — network-error retry, HTTP backoff, splitting a batch that was
|
|
177
|
+
* refused or truncated — is provider-agnostic and lives here, unchanged from the
|
|
178
|
+
* version that ran ten thousand units through Gemini.
|
|
179
|
+
*
|
|
180
|
+
* `usage` is normalised to { inTok, outTok } so the caller never sees a provider's
|
|
181
|
+
* own field names.
|
|
182
|
+
*/
|
|
183
|
+
async function callModel(langCode, items, attempt = 1, jsonMode = JSON_MODE) {
|
|
184
|
+
const { url, headers, body } = PROVIDER.request({
|
|
185
|
+
model: MODEL,
|
|
186
|
+
system: systemPrompt(langCode),
|
|
187
|
+
items,
|
|
188
|
+
temperature: attempt === 1 ? 0.2 : 0.4,
|
|
189
|
+
key: KEY,
|
|
190
|
+
baseUrl: API_BASE_URL,
|
|
191
|
+
jsonMode,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const split = async (why, mode = jsonMode) => {
|
|
195
|
+
const mid = Math.ceil(items.length / 2);
|
|
196
|
+
process.stderr.write(` ${why} on ${items.length} units — splitting\n`);
|
|
197
|
+
const [a, b] = await Promise.all([
|
|
198
|
+
callModel(langCode, items.slice(0, mid), 1, mode),
|
|
199
|
+
callModel(langCode, items.slice(mid), 1, mode),
|
|
200
|
+
]);
|
|
201
|
+
return {
|
|
202
|
+
rows: [...a.rows, ...b.rows],
|
|
203
|
+
usage: { inTok: a.usage.inTok + b.usage.inTok, outTok: a.usage.outTok + b.usage.outTok },
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Network-level failures (`TypeError: fetch failed` — DNS, reset connection, socket
|
|
208
|
+
// timeout) throw before there is any response, so they never reached the HTTP-status
|
|
209
|
+
// retry below. On the first full run that silently cost 481 of 10,166 Spanish units:
|
|
210
|
+
// 12 whole batches lost to transient connection errors while 12 requests ran in
|
|
211
|
+
// parallel. Retry them on the same backoff as 429/5xx.
|
|
212
|
+
let res;
|
|
213
|
+
try {
|
|
214
|
+
res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (attempt <= 5) {
|
|
217
|
+
const wait = Math.min(2 ** attempt * 1000, 30000);
|
|
218
|
+
process.stderr.write(
|
|
219
|
+
` network error (${String(err.message ?? err).slice(0, 60)}), retry ${attempt} in ${wait}ms\n`
|
|
220
|
+
);
|
|
221
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
222
|
+
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
223
|
+
}
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
|
|
230
|
+
// An OpenAI-compatible server that does not implement the structured-output form we
|
|
231
|
+
// asked for rejects the whole request. That is a capability gap, not a broken run:
|
|
232
|
+
// drop one rung of the ladder and retry. Placeholder validation still guards output.
|
|
233
|
+
if (PROVIDER.unsupportedJsonMode?.(res.status, text)) {
|
|
234
|
+
const next = (jsonMode ?? 'schema') === 'schema' ? 'object' : 'none';
|
|
235
|
+
if ((jsonMode ?? 'schema') !== 'none') {
|
|
236
|
+
process.stderr.write(
|
|
237
|
+
` server rejected JSON mode "${jsonMode ?? 'schema'}" — retrying with "${next}"\n`
|
|
238
|
+
);
|
|
239
|
+
return callModel(langCode, items, attempt, next);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const retryable = res.status === 429 || res.status >= 500;
|
|
244
|
+
if (retryable && attempt <= 5) {
|
|
245
|
+
const wait = Math.min(2 ** attempt * 1000, 30000);
|
|
246
|
+
process.stderr.write(` HTTP ${res.status}, retry ${attempt} in ${wait}ms\n`);
|
|
247
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
248
|
+
return callModel(langCode, items, attempt + 1, jsonMode);
|
|
249
|
+
}
|
|
250
|
+
throw new Error(`${PROVIDER.label ?? PROVIDER.id} HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
const { text: part, usage, retryable, detail } = PROVIDER.parse(data);
|
|
255
|
+
|
|
256
|
+
// A safety filter rejects the WHOLE request, so one string it dislikes takes the other
|
|
257
|
+
// 39 in the batch with it (seen on Russian: PROHIBITED_CONTENT on a batch that
|
|
258
|
+
// translated fine once split). Halve and recurse so the blast radius is the offending
|
|
259
|
+
// unit alone, not the batch.
|
|
260
|
+
if (retryable === 'safety' && items.length > 1) return split(detail ?? 'safety block');
|
|
261
|
+
|
|
262
|
+
if (!part) {
|
|
263
|
+
if (retryable === 'safety') throw new Error(`Safety block on a single unit (${detail})`);
|
|
264
|
+
throw new Error(`No content in response: ${JSON.stringify(data).slice(0, 300)}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// A batch whose combined translation exceeds the output limit comes back as TRUNCATED
|
|
268
|
+
// JSON ("Unterminated string at position 68365"), taking all 40 units with it — that is
|
|
269
|
+
// how Urdu lost a whole batch. Halve and recurse: the same content in two requests fits,
|
|
270
|
+
// and only a single oversized unit can end up unrecoverable. Some providers say so via
|
|
271
|
+
// a stop reason; the rest are caught by the parse failing.
|
|
272
|
+
if (retryable === 'truncated' && items.length > 1) {
|
|
273
|
+
const half = await split('output limit reached');
|
|
274
|
+
return { rows: half.rows, usage: { inTok: usage.inTok + half.usage.inTok, outTok: usage.outTok + half.usage.outTok } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(extractJson(part));
|
|
279
|
+
const rows = PROVIDER.unwrap ? PROVIDER.unwrap(parsed) : parsed;
|
|
280
|
+
if (!Array.isArray(rows)) throw new Error('response was not an array of units');
|
|
281
|
+
return { rows, usage };
|
|
282
|
+
} catch (err) {
|
|
283
|
+
if (items.length > 1) {
|
|
284
|
+
const half = await split('truncated JSON');
|
|
285
|
+
return {
|
|
286
|
+
rows: half.rows,
|
|
287
|
+
usage: { inTok: usage.inTok + half.usage.inTok, outTok: usage.outTok + half.usage.outTok },
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Worker ───────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
async function translateLang(langCode, units) {
|
|
297
|
+
const tmFile = join(TM_DIR, `${langCode}${TAG}.json`);
|
|
298
|
+
const tm = existsSync(tmFile) ? JSON.parse(readFileSync(tmFile, 'utf8')) : {};
|
|
299
|
+
|
|
300
|
+
const pending = units.filter(([hash]) => !(hash in tm));
|
|
301
|
+
|
|
302
|
+
// Re-run churn. The memory is keyed by source hash, so on an existing locale the
|
|
303
|
+
// pending share IS the share of the site that changed since last time. A site that
|
|
304
|
+
// turns over a large fraction of its copy every release is one this pipeline will
|
|
305
|
+
// keep charging for — worth knowing before the third re-run, not after.
|
|
306
|
+
const known = Object.keys(tm).length;
|
|
307
|
+
if (known > 0 && units.length > 0) {
|
|
308
|
+
const churn = (pending.length * 100) / units.length;
|
|
309
|
+
if (churn >= 15) {
|
|
310
|
+
hint('churn', [
|
|
311
|
+
`\u2139 ${churn.toFixed(0)}% of source units changed since the last run ` +
|
|
312
|
+
`(${pending.length.toLocaleString()} of ${units.length.toLocaleString()}).`,
|
|
313
|
+
' Static substitution re-translates and rebuilds on every content change. At this',
|
|
314
|
+
' rate that is a recurring cost; a runtime layer translates on demand instead:',
|
|
315
|
+
` ${link('hint-churn')}`,
|
|
316
|
+
]);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (pending.length === 0) {
|
|
321
|
+
console.log(`${langCode}: nothing to do (${Object.keys(tm).length} in memory)`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const batches = [];
|
|
326
|
+
for (let i = 0; i < pending.length; i += BATCH_UNITS) batches.push(pending.slice(i, i + BATCH_UNITS));
|
|
327
|
+
|
|
328
|
+
console.log(`${langCode}: ${pending.length} units to translate in ${batches.length} batches`);
|
|
329
|
+
if (DRY) return;
|
|
330
|
+
|
|
331
|
+
let done = 0;
|
|
332
|
+
let failed = 0;
|
|
333
|
+
let inTok = 0;
|
|
334
|
+
let outTok = 0;
|
|
335
|
+
const failures = [];
|
|
336
|
+
|
|
337
|
+
// Checkpoint the memory as we go. A full locale is ~255 batches over tens of minutes;
|
|
338
|
+
// writing only at the end means any interruption — rate limit, network, Ctrl-C —
|
|
339
|
+
// throws away every unit translated so far and re-spends on the retry.
|
|
340
|
+
mkdirSync(TM_DIR, { recursive: true });
|
|
341
|
+
let sinceFlush = 0;
|
|
342
|
+
const flush = () => {
|
|
343
|
+
writeFileSync(tmFile, JSON.stringify(tm, null, 2));
|
|
344
|
+
sinceFlush = 0;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
let cursor = 0;
|
|
348
|
+
const runOne = async () => {
|
|
349
|
+
for (;;) {
|
|
350
|
+
const myIndex = cursor++;
|
|
351
|
+
if (myIndex >= batches.length) return;
|
|
352
|
+
const batch = batches[myIndex];
|
|
353
|
+
|
|
354
|
+
const items = batch.map(([, unit], i) => ({ id: i, text: unit.text }));
|
|
355
|
+
|
|
356
|
+
let rows;
|
|
357
|
+
let usage;
|
|
358
|
+
try {
|
|
359
|
+
({ rows, usage } = await callModel(langCode, items));
|
|
360
|
+
} catch (err) {
|
|
361
|
+
failed += batch.length;
|
|
362
|
+
failures.push({ batch: myIndex, error: String(err).slice(0, 200) });
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
inTok += usage.inTok;
|
|
366
|
+
outTok += usage.outTok;
|
|
367
|
+
|
|
368
|
+
const byId = new Map(rows.map((r) => [r.id, r.text]));
|
|
369
|
+
|
|
370
|
+
// Units whose placeholders came back wrong get one solo retry — a single unit in
|
|
371
|
+
// isolation is far more reliable than the same unit inside a 40-item batch.
|
|
372
|
+
const retry = [];
|
|
373
|
+
for (let i = 0; i < batch.length; i++) {
|
|
374
|
+
const [hash, unit] = batch[i];
|
|
375
|
+
const out = byId.get(i);
|
|
376
|
+
const problem = validate(unit.text, out);
|
|
377
|
+
if (problem) retry.push([hash, unit, problem]);
|
|
378
|
+
else tm[hash] = out;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const [hash, unit, problem] of retry) {
|
|
382
|
+
try {
|
|
383
|
+
const solo = await callModel(langCode, [{ id: 0, text: unit.text }]);
|
|
384
|
+
const out = solo.rows.find((r) => r.id === 0)?.text;
|
|
385
|
+
inTok += solo.usage.inTok;
|
|
386
|
+
outTok += solo.usage.outTok;
|
|
387
|
+
if (!validate(unit.text, out)) {
|
|
388
|
+
tm[hash] = out;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
/* fall through to the failure path */
|
|
393
|
+
}
|
|
394
|
+
failed++;
|
|
395
|
+
failures.push({ hash, problem, source: unit.text.slice(0, 120) });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
done += batch.length;
|
|
399
|
+
sinceFlush++;
|
|
400
|
+
if (sinceFlush >= 10) flush();
|
|
401
|
+
process.stdout.write(`\r ${langCode}: ${done}/${pending.length} units`);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, batches.length) }, runOne));
|
|
406
|
+
process.stdout.write('\n');
|
|
407
|
+
|
|
408
|
+
flush();
|
|
409
|
+
|
|
410
|
+
// Token counts are always printed because they are measured. A cost is printed only
|
|
411
|
+
// when a rate is actually known — from the config, or the provider's own table. A
|
|
412
|
+
// hardcoded guess for somebody else's price list goes stale and misleads; the
|
|
413
|
+
// OpenAI-compatible adapter deliberately reports no rate at all, because it points at
|
|
414
|
+
// dozens of providers and some of them are a local model that costs nothing.
|
|
415
|
+
const rate = PRICING ?? PROVIDER.pricing?.(MODEL) ?? null;
|
|
416
|
+
|
|
417
|
+
console.log(` ${langCode}: ${Object.keys(tm).length} in memory, ${failed} failed`);
|
|
418
|
+
const tokens = ` tokens in/out: ${inTok.toLocaleString()} / ${outTok.toLocaleString()}`;
|
|
419
|
+
if (rate) {
|
|
420
|
+
const cost = (inTok / 1e6) * rate[0] + (outTok / 1e6) * rate[1];
|
|
421
|
+
console.log(`${tokens} ≈ $${cost.toFixed(3)} at $${rate[0]}/$${rate[1]} per Mtok`);
|
|
422
|
+
} else {
|
|
423
|
+
console.log(`${tokens} (no rate known for this model — set "pricing" in i18n.config.json)`);
|
|
424
|
+
}
|
|
425
|
+
if (failures.length) {
|
|
426
|
+
writeFileSync(join(TM_DIR, `${langCode}${TAG}.failures.json`), JSON.stringify(failures, null, 2));
|
|
427
|
+
console.log(` wrote ${failures.length} failures → i18n/tm/${langCode}${TAG}.failures.json`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
if (!existsSync(SRC_FILE)) {
|
|
434
|
+
console.error('i18n/source.json missing. Run: node scripts/i18n/extract.mjs');
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const source = JSON.parse(readFileSync(SRC_FILE, 'utf8'));
|
|
439
|
+
// Most-repeated units first, so a --limit sample covers the highest-impact copy.
|
|
440
|
+
const units = Object.entries(source).slice(0, LIMIT === Infinity ? undefined : LIMIT);
|
|
441
|
+
|
|
442
|
+
console.log(
|
|
443
|
+
`provider: ${PROVIDER.label ?? PROVIDER.id} · model: ${MODEL}` +
|
|
444
|
+
(PROVIDER_NAME ? '' : ' (inferred — set "provider" in i18n.config.json to pin it)') +
|
|
445
|
+
(API_BASE_URL ? ` · host: ${API_BASE_URL}` : '')
|
|
446
|
+
);
|
|
447
|
+
console.log(`source units: ${Object.keys(source).length.toLocaleString()}, selected: ${units.length.toLocaleString()}`);
|
|
448
|
+
|
|
449
|
+
for (const lang of LANGS) {
|
|
450
|
+
if (!(lang in LANG_NAMES)) {
|
|
451
|
+
console.error(`Unknown locale "${lang}" — not in the locales config`);
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
await translateLang(lang, units);
|
|
455
|
+
}
|