champollion 0.3.4 → 0.5.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/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +82 -37
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1709 -101
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +230 -17
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +284 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1136 -345
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +727 -79
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +975 -262
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +1102 -0
- package/lib/file-scope.js +106 -0
- package/lib/flatten.js +80 -3
- package/lib/flutter-locales.js +124 -0
- package/lib/format.js +266 -12
- package/lib/hash.js +146 -21
- package/lib/icu-structure.js +929 -0
- package/lib/integrity.js +223 -75
- package/lib/language-pair.js +157 -0
- package/lib/lint.js +78 -16
- package/lib/local-only-marks.js +106 -0
- package/lib/locale-layout.js +1103 -0
- package/lib/locale-state.js +575 -0
- package/lib/methods/anthropic.js +5 -0
- package/lib/methods/apertium.js +6 -3
- package/lib/methods/api.js +138 -25
- package/lib/methods/base.js +17 -0
- package/lib/methods/coaching-data.js +153 -0
- package/lib/methods/content-separator.js +43 -0
- package/lib/methods/deepl.js +1 -1
- package/lib/methods/direct-llm.js +257 -103
- package/lib/methods/external.js +146 -63
- package/lib/methods/gemini.js +1 -0
- package/lib/methods/google-translate.js +1 -0
- package/lib/methods/http-utils.js +41 -0
- package/lib/methods/libretranslate.js +7 -2
- package/lib/methods/llm-coached.js +71 -129
- package/lib/methods/llm.js +87 -33
- package/lib/methods/local.js +93 -10
- package/lib/methods/microsoft-translator.js +1 -2
- package/lib/methods/openai.js +4 -2
- package/lib/methods/openrouter-client.js +20 -19
- package/lib/methods/openrouter-pricing.js +150 -13
- package/lib/methods/prompt-methods.js +20 -0
- package/lib/methods/provider-pricing.js +42 -1
- package/lib/methods/request-capture.js +104 -0
- package/lib/methods/tilde.js +1 -1
- package/lib/methods/translated.js +1 -2
- package/lib/missing-key.js +93 -0
- package/lib/models.js +102 -55
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +24 -8
- package/lib/seo.js +19 -9
- package/lib/serve.js +43 -6
- package/lib/shared-output-seed.js +164 -0
- package/lib/source-contexts.js +39 -0
- package/lib/submit.mjs +57 -5
- package/lib/sync.js +2923 -474
- package/lib/terminology.js +13 -4
- package/lib/tm-evict.js +179 -0
- package/lib/tm-seed.js +5 -2
- package/lib/tm.js +818 -36
- package/lib/translate-pair.js +639 -34
- package/lib/translate.js +78 -5
- package/lib/types.js +22 -3
- package/lib/validate.js +996 -21
- package/lib/verify.js +1332 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/licenses.json +99 -3
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/human-services.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/docent/corpus.json +0 -11739
- package/shared/model-aliases.json +0 -7
- package/shared/schemas/model-aliases.schema.json +0 -27
|
@@ -56,20 +56,19 @@ import path from 'node:path';
|
|
|
56
56
|
import fs from 'node:fs';
|
|
57
57
|
import { TranslationMethod } from './base.js';
|
|
58
58
|
import { callOpenRouterJSON } from './openrouter-client.js';
|
|
59
|
+
import { isCapturing, PREVIEW_KEY } from './request-capture.js';
|
|
59
60
|
import { estimateOpenRouterCost } from './openrouter-pricing.js';
|
|
60
61
|
|
|
61
62
|
// Re-use the LLM method's infrastructure (prompt building, key validation, cascade)
|
|
62
|
-
import { LLMMethod, inferKeyTypes, isUnsafeKey, buildSystemMessage } from './llm.js';
|
|
63
|
+
import { LLMMethod, inferKeyTypes, isUnsafeKey, buildSystemMessage, buildUserMessage, promptSettingsFor } from './llm.js';
|
|
64
|
+
// The coaching file + glossary helpers live in coaching-data.js (llm.js uses
|
|
65
|
+
// them too); re-exported below under their old names.
|
|
66
|
+
import { DEFAULT_COACHING_DIR, loadCoachingData, findDictionaryMatches, projectGlossary } from './coaching-data.js';
|
|
63
67
|
import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_COACHED_TEMPERATURE, DEFAULT_MAX_RETRIES, DEFAULT_METHOD_CONCURRENCY } from '../config.js';
|
|
64
68
|
import { pMap } from '../concurrent.js';
|
|
69
|
+
import { requireExactModelId } from '../models.js';
|
|
65
70
|
import { output } from '../output.js';
|
|
66
71
|
|
|
67
|
-
/**
|
|
68
|
-
* Default coaching data directory, relative to project root.
|
|
69
|
-
* Users can override via config: coaching.dir
|
|
70
|
-
*/
|
|
71
|
-
const DEFAULT_COACHING_DIR = '.champollion/coaching';
|
|
72
|
-
|
|
73
72
|
/**
|
|
74
73
|
* Direct-provider methods a coached run can dispatch to (in addition to the
|
|
75
74
|
* default OpenRouter path). Imported lazily inside _getProviderMethod() because
|
|
@@ -83,6 +82,13 @@ const DIRECT_PROVIDER_MODULES = {
|
|
|
83
82
|
local: { module: './local.js', export: 'LocalMethod' },
|
|
84
83
|
};
|
|
85
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Every transport a coached (or plain llm) pair can name in `provider`.
|
|
87
|
+
* pairs.js validates config against this list, so an unknown provider fails at
|
|
88
|
+
* pair-graph build time instead of silently riding OpenRouter.
|
|
89
|
+
*/
|
|
90
|
+
const COACHED_PROVIDERS = ['openrouter', ...Object.keys(DIRECT_PROVIDER_MODULES)];
|
|
91
|
+
|
|
86
92
|
/**
|
|
87
93
|
* Normalize a pair config's `provider` to a lowercase key. Absent/blank → the
|
|
88
94
|
* default OpenRouter path, matching config_exporter.py (which only emits
|
|
@@ -158,59 +164,16 @@ function resolveCoachingPrompt(pairConfig, options) {
|
|
|
158
164
|
return { configured: false, text: null };
|
|
159
165
|
}
|
|
160
166
|
|
|
161
|
-
/**
|
|
162
|
-
* Load coaching data for a locale from a JSON file, with caching.
|
|
163
|
-
*
|
|
164
|
-
* This is a standalone function so that any translation method (LLMCoached,
|
|
165
|
-
* OpenAI, Anthropic, Gemini) can load coaching data without instantiating
|
|
166
|
-
* the LLMCoachedMethod class.
|
|
167
|
-
*
|
|
168
|
-
* @param {string} coachingDir - Path to coaching data directory
|
|
169
|
-
* @param {string} locale - Target locale code (e.g., 'fr', 'crk')
|
|
170
|
-
* @param {Map} cache - Cache map to store loaded data (avoids re-reading files)
|
|
171
|
-
* @returns {object|null} Coaching data { grammar_rules, dictionary, style_notes }, or null
|
|
172
|
-
*/
|
|
173
|
-
function loadCoachingData(coachingDir, locale, cache) {
|
|
174
|
-
if (!locale) return null;
|
|
175
|
-
|
|
176
|
-
// Check cache first
|
|
177
|
-
const cacheKey = `${coachingDir}:${locale}`;
|
|
178
|
-
if (cache.has(cacheKey)) {
|
|
179
|
-
return cache.get(cacheKey);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const filePath = path.join(coachingDir, `${locale}.json`);
|
|
183
|
-
|
|
184
|
-
if (!fs.existsSync(filePath)) {
|
|
185
|
-
cache.set(cacheKey, null);
|
|
186
|
-
return null;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
191
|
-
const data = JSON.parse(raw);
|
|
192
|
-
|
|
193
|
-
// Validate required structure — normalize missing fields to safe defaults
|
|
194
|
-
const coaching = {
|
|
195
|
-
grammar_rules: Array.isArray(data.grammar_rules) ? data.grammar_rules : [],
|
|
196
|
-
dictionary: (data.dictionary && typeof data.dictionary === 'object') ? data.dictionary : {},
|
|
197
|
-
style_notes: typeof data.style_notes === 'string' ? data.style_notes : '',
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
cache.set(cacheKey, coaching);
|
|
201
|
-
return coaching;
|
|
202
|
-
} catch (err) {
|
|
203
|
-
output.warn(`Failed to load coaching data: ${filePath}`);
|
|
204
|
-
output.warn(err.message);
|
|
205
|
-
cache.set(cacheKey, null);
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
167
|
class LLMCoachedMethod extends TranslationMethod {
|
|
211
168
|
constructor(options = {}) {
|
|
212
169
|
super('llm-coached', options);
|
|
170
|
+
this.acceptsKeyInstructions = true; // prompt "UI context" lines (lib/methods/base.js)
|
|
171
|
+
this.supportsRequestPreview = true; // its transport reports to request-capture.js
|
|
213
172
|
this._coachingCache = new Map();
|
|
173
|
+
// The pair's transport, known at construction (getMethod passes the pair
|
|
174
|
+
// config) so preflight and cost estimation — which receive no pairConfig
|
|
175
|
+
// of their own — check the provider the run will actually call.
|
|
176
|
+
this._provider = normalizeProvider(options.provider);
|
|
214
177
|
}
|
|
215
178
|
|
|
216
179
|
/**
|
|
@@ -270,32 +233,36 @@ class LLMCoachedMethod extends TranslationMethod {
|
|
|
270
233
|
// langConfig.coachingPrompt (exactly as the plain llm.js method injects
|
|
271
234
|
// it), so it is part of EVERY batch and can never be silently dropped —
|
|
272
235
|
// including for the openai/anthropic/gemini/local providers below.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
promptContext: pairConfig.promptContext || null,
|
|
278
|
-
coachingPrompt: coachingPromptInfo.text,
|
|
279
|
-
};
|
|
236
|
+
// The same reading of the pair as every LLM method (llm.js
|
|
237
|
+
// promptSettingsFor) — it used to leave out the protected terms — with
|
|
238
|
+
// the coaching prompt this method resolved itself.
|
|
239
|
+
const langConfig = promptSettingsFor(pairConfig, { coachingPrompt: coachingPromptInfo.text });
|
|
280
240
|
const systemMessage = structured
|
|
281
241
|
? buildCoachedSystemMessage(langConfig, structured)
|
|
282
242
|
: buildSystemMessage(langConfig);
|
|
243
|
+
// The glossary each batch is told about: the coaching file's dictionary,
|
|
244
|
+
// else the project's (the same file, unless a plugin brings its own).
|
|
245
|
+
const glossary = (structured && Object.keys(structured.dictionary).length > 0)
|
|
246
|
+
? structured.dictionary
|
|
247
|
+
: projectGlossary(pairConfig, options, this._coachingCache);
|
|
283
248
|
|
|
284
249
|
const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
|
|
285
250
|
const maxRetries = pairConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
286
251
|
|
|
287
252
|
// ── Provider dispatch ──────────────────────────────────────────────
|
|
288
253
|
if (provider === 'openrouter') {
|
|
289
|
-
|
|
254
|
+
// A request preview needs no key: it is shown, never sent.
|
|
255
|
+
const apiKey = options.apiKey || (isCapturing() ? PREVIEW_KEY : null);
|
|
290
256
|
if (!apiKey) {
|
|
291
257
|
output.warn('LLM-Coached translate: no API key provided — skipping batch.');
|
|
292
258
|
return null;
|
|
293
259
|
}
|
|
294
|
-
|
|
260
|
+
// Exact slugs only (founder ruling 2026-10-05): refused before anything is sent.
|
|
261
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
295
262
|
const llm = new LLMMethod();
|
|
296
|
-
const batchFn = (batch, opts) => this._callCoachedBatch(batch,
|
|
263
|
+
const batchFn = (batch, opts) => this._callCoachedBatch(batch, glossary, opts, pairConfig);
|
|
297
264
|
return this._runCoachedBatches(llm, keys, sourceFlat, langConfig, {
|
|
298
|
-
apiKey, model, maxRetries, systemMessage, batchSize,
|
|
265
|
+
apiKey, model, maxRetries, systemMessage, batchSize, descriptions: options.descriptions || null,
|
|
299
266
|
}, batchFn);
|
|
300
267
|
}
|
|
301
268
|
|
|
@@ -304,18 +271,20 @@ class LLMCoachedMethod extends TranslationMethod {
|
|
|
304
271
|
// (_translateWithCascade), but with the coached system message WE built —
|
|
305
272
|
// so the coaching prompt is honored, not the default uncoached system.
|
|
306
273
|
const directMethod = await this._getProviderMethod(provider);
|
|
307
|
-
const apiKey = directMethod._resolveApiKey(options);
|
|
274
|
+
const apiKey = directMethod._resolveApiKey(options) || (isCapturing() ? PREVIEW_KEY : null);
|
|
308
275
|
if (!apiKey) {
|
|
309
276
|
output.warn(`LLM-Coached translate (${provider}): no API key — set ${directMethod._getApiKeyEnvVar()}. Skipping batch.`);
|
|
310
277
|
return null;
|
|
311
278
|
}
|
|
312
|
-
|
|
313
|
-
|
|
279
|
+
// The provider's own name for the model; an OpenRouter slug is mapped or
|
|
280
|
+
// refused (direct-llm.js resolveModelId), never sent to it as is.
|
|
281
|
+
const model = directMethod.resolveModelId(pairConfig.model || options.model || directMethod._getDefaultModel(), { cwd });
|
|
282
|
+
if (!isCapturing()) await directMethod._validateModel(model, apiKey, cwd); // DX warnings only; never blocks
|
|
314
283
|
const temperature = pairConfig.temperature ?? DEFAULT_COACHED_TEMPERATURE;
|
|
315
284
|
const batchFn = (batch, opts) => directMethod._callProviderBatch(batch, opts);
|
|
316
285
|
return this._runCoachedBatches(directMethod, keys, sourceFlat, langConfig, {
|
|
317
286
|
apiKey, model, maxRetries, systemMessage, batchSize,
|
|
318
|
-
|
|
287
|
+
glossary, temperature, descriptions: options.descriptions || null, cwd,
|
|
319
288
|
}, batchFn);
|
|
320
289
|
}
|
|
321
290
|
|
|
@@ -424,26 +393,41 @@ class LLMCoachedMethod extends TranslationMethod {
|
|
|
424
393
|
const coachingBlock = blocks.join('\n\n');
|
|
425
394
|
const augmentedPrompt = coachingBlock ? `${coachingBlock}\n\n${prompt}` : prompt;
|
|
426
395
|
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
const safePairConfig = { ...pairConfig, target: undefined, locale: undefined };
|
|
431
|
-
return targetMethod.translateContent(augmentedPrompt, safePairConfig, options);
|
|
396
|
+
// The plain methods send a content prompt as they get it, so the
|
|
397
|
+
// coaching is prepended once, here, whichever provider carries it.
|
|
398
|
+
return targetMethod.translateContent(augmentedPrompt, pairConfig, options);
|
|
432
399
|
}
|
|
433
400
|
|
|
434
401
|
/**
|
|
435
402
|
* Cost estimation — same as LLM but with coached:true flag
|
|
436
403
|
* for the 2.5x input token multiplier (grammar/dictionary injection).
|
|
404
|
+
* A direct provider prices through its own method (and `local` honestly
|
|
405
|
+
* returns null) — never through OpenRouter's rate for a model it isn't
|
|
406
|
+
* sending to OpenRouter.
|
|
437
407
|
*
|
|
438
408
|
* @param {number} keyCount - Number of keys to translate
|
|
439
409
|
* @param {object} [pairConfig] - Pair config containing the model ID
|
|
440
410
|
*/
|
|
441
|
-
async estimateCost(keyCount, pairConfig = {}) {
|
|
411
|
+
async estimateCost(keyCount, pairConfig = {}, context = {}) {
|
|
412
|
+
const provider = normalizeProvider(pairConfig.provider ?? this._provider);
|
|
413
|
+
if (provider !== 'openrouter') {
|
|
414
|
+
const directMethod = await this._getProviderMethod(provider);
|
|
415
|
+
return directMethod.estimateCost(keyCount, pairConfig, context);
|
|
416
|
+
}
|
|
442
417
|
const model = pairConfig.model || DEFAULT_OPENROUTER_MODEL;
|
|
443
418
|
return estimateOpenRouterCost(keyCount, model, { coached: true });
|
|
444
419
|
}
|
|
445
420
|
|
|
446
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Preflight: the credential the run needs is the chosen provider's, not
|
|
423
|
+
* OpenRouter's. A coached openai pair must pass with only OPENAI_API_KEY set,
|
|
424
|
+
* and must fail (naming OPENAI_API_KEY) without it.
|
|
425
|
+
*/
|
|
426
|
+
async checkReadiness(context) {
|
|
427
|
+
if (this._provider !== 'openrouter') {
|
|
428
|
+
const directMethod = await this._getProviderMethod(this._provider);
|
|
429
|
+
return directMethod.checkReadiness(context);
|
|
430
|
+
}
|
|
447
431
|
if (!context.apiKey) {
|
|
448
432
|
return { ready: false, reason: 'No OpenRouter API key (OPENROUTER_API_KEY).' };
|
|
449
433
|
}
|
|
@@ -489,28 +473,15 @@ class LLMCoachedMethod extends TranslationMethod {
|
|
|
489
473
|
* Make a single coached API call via the shared OpenRouter client.
|
|
490
474
|
* Builds per-batch user message with dictionary hints, uses shared system message.
|
|
491
475
|
*/
|
|
492
|
-
async _callCoachedBatch(toTranslate,
|
|
476
|
+
async _callCoachedBatch(toTranslate, glossary, options, pairConfig = {}) {
|
|
493
477
|
const { apiKey, model, batchNum, systemMessage } = options;
|
|
494
478
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// configured
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
: [];
|
|
502
|
-
const typeHints = inferKeyTypes(toTranslate);
|
|
503
|
-
|
|
504
|
-
let userMessage = '';
|
|
505
|
-
if (dictHints.length > 0) {
|
|
506
|
-
userMessage += 'REQUIRED TERMINOLOGY (use these exact translations):\n';
|
|
507
|
-
userMessage += dictHints.map(h => ` • "${h.term}" → "${h.translation}"`).join('\n');
|
|
508
|
-
userMessage += '\n\n';
|
|
509
|
-
}
|
|
510
|
-
if (typeHints.length > 0) {
|
|
511
|
-
userMessage += `UI context for these keys:\n${typeHints.join('\n')}\n\n`;
|
|
512
|
-
}
|
|
513
|
-
userMessage += JSON.stringify(toTranslate, null, 2);
|
|
479
|
+
// The user message every LLM method sends (llm.js buildUserMessage): the
|
|
480
|
+
// glossary terms this batch contains (none when only a free-text
|
|
481
|
+
// coaching prompt is configured and the project has no glossary), the
|
|
482
|
+
// per-key instructions (plural forms, ICU categories, gettext context,
|
|
483
|
+
// gate feedback), then the batch.
|
|
484
|
+
const userMessage = buildUserMessage(toTranslate, options.descriptions || undefined, glossary || null);
|
|
514
485
|
|
|
515
486
|
return callOpenRouterJSON({
|
|
516
487
|
prompt: userMessage,
|
|
@@ -628,39 +599,10 @@ function buildContentCoachingBlock(coaching) {
|
|
|
628
599
|
return parts.length > 0 ? parts.join('\n') : '';
|
|
629
600
|
}
|
|
630
601
|
|
|
631
|
-
/**
|
|
632
|
-
* Scan source values for dictionary term matches.
|
|
633
|
-
*
|
|
634
|
-
* Uses case-insensitive word-boundary matching to find terms from the
|
|
635
|
-
* coaching dictionary that appear in the current batch's source values.
|
|
636
|
-
*
|
|
637
|
-
* @param {object} toTranslate - Key-value map to scan
|
|
638
|
-
* @param {object} dictionary - Term → translation map
|
|
639
|
-
* @returns {Array<{ term: string, translation: string }>} Matched hints
|
|
640
|
-
*/
|
|
641
|
-
function findDictionaryMatches(toTranslate, dictionary) {
|
|
642
|
-
if (!dictionary || Object.keys(dictionary).length === 0) return [];
|
|
643
|
-
|
|
644
|
-
const matches = [];
|
|
645
|
-
const seen = new Set();
|
|
646
|
-
const values = Object.values(toTranslate).join(' ').toLowerCase();
|
|
647
|
-
|
|
648
|
-
for (const [term, translation] of Object.entries(dictionary)) {
|
|
649
|
-
if (seen.has(term)) continue;
|
|
650
|
-
|
|
651
|
-
// Case-insensitive word-boundary check
|
|
652
|
-
// Use a simple indexOf for performance — the dictionary is usually small
|
|
653
|
-
if (values.includes(term.toLowerCase())) {
|
|
654
|
-
matches.push({ term, translation });
|
|
655
|
-
seen.add(term);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
return matches;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
602
|
export {
|
|
663
603
|
LLMCoachedMethod,
|
|
604
|
+
COACHED_PROVIDERS,
|
|
605
|
+
normalizeProvider,
|
|
664
606
|
loadCoachingData,
|
|
665
607
|
buildCoachedPrompt,
|
|
666
608
|
buildCoachedSystemMessage,
|
package/lib/methods/llm.js
CHANGED
|
@@ -44,7 +44,14 @@ import { isUnsafeKey } from '../security.js';
|
|
|
44
44
|
import { pMap } from '../concurrent.js';
|
|
45
45
|
import { output } from '../output.js';
|
|
46
46
|
import { getEnvOrFileVar } from '../api-key.js';
|
|
47
|
-
|
|
47
|
+
import { requireExactModelId } from '../models.js';
|
|
48
|
+
import { nameRules } from '../name-rules.js';
|
|
49
|
+
import { isCapturing, PREVIEW_KEY } from './request-capture.js';
|
|
50
|
+
import { projectGlossary, terminologyBlock } from './coaching-data.js';
|
|
51
|
+
// The plain LLM methods: one prompt (promptSettingsFor + buildSystemMessage
|
|
52
|
+
// + buildUserMessage), different transports. llm-coached adds its coaching.
|
|
53
|
+
// Defined beside the set the cache key reads (lib/tm.js tmMethodKey).
|
|
54
|
+
import { PLAIN_LLM_METHODS } from './prompt-methods.js';
|
|
48
55
|
|
|
49
56
|
/** Default per-cascade cap on individual-key fallback calls (see below). */
|
|
50
57
|
const DEFAULT_MAX_KEY_FANOUT = 16;
|
|
@@ -72,6 +79,10 @@ function maxKeyFanout() {
|
|
|
72
79
|
class LLMMethod extends TranslationMethod {
|
|
73
80
|
constructor(options = {}) {
|
|
74
81
|
super('llm', options);
|
|
82
|
+
this.acceptsKeyInstructions = true; // prompt "UI context" lines (lib/methods/base.js)
|
|
83
|
+
this.supportsRequestPreview = true; // its transport reports to request-capture.js
|
|
84
|
+
// Coaching-file cache (the project glossary, lib/methods/coaching-data.js)
|
|
85
|
+
this._coachingCache = new Map();
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
/**
|
|
@@ -84,33 +95,28 @@ class LLMMethod extends TranslationMethod {
|
|
|
84
95
|
* @returns {object|null} Map of key → translated value, or null if all failed
|
|
85
96
|
*/
|
|
86
97
|
async translate(keys, sourceFlat, pairConfig, options) {
|
|
87
|
-
|
|
98
|
+
// A request preview needs no key: it is shown, never sent.
|
|
99
|
+
const apiKey = options.apiKey || (isCapturing() ? PREVIEW_KEY : null);
|
|
88
100
|
const batchSize = pairConfig.batchSize || options.batchSize || DEFAULT_BATCH_SIZE;
|
|
89
|
-
|
|
101
|
+
// Exact slugs only (founder ruling 2026-10-05) — checked here too, since
|
|
102
|
+
// callers outside the pair graph (the MCP translate tool) reach this directly.
|
|
103
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
90
104
|
const maxRetries = pairConfig.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
91
105
|
if (!apiKey) {
|
|
92
106
|
output.warn('LLM translate: no API key provided — skipping batch.');
|
|
93
107
|
return null;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// for French, Doppelpunkt notation for German). Falls back to a generic
|
|
101
|
-
// rule in buildSystemMessage when null.
|
|
102
|
-
genderGuidance: pairConfig.genderGuidance || null,
|
|
103
|
-
// User-provided global context (e.g., "This is a developer tool README").
|
|
104
|
-
// Injected into the system message to give the LLM domain awareness.
|
|
105
|
-
promptContext: pairConfig.promptContext || null,
|
|
106
|
-
// Coaching prompt text — free-text instructions read from a coaching file.
|
|
107
|
-
// Injected between register and rules in the system message.
|
|
108
|
-
coachingPrompt: pairConfig.coachingPrompt || null,
|
|
109
|
-
};
|
|
110
|
+
// What the pair tells its model — the same reading every LLM method
|
|
111
|
+
// uses (promptSettingsFor), so openai/anthropic/gemini/local send this
|
|
112
|
+
// exact system message too.
|
|
113
|
+
const langConfig = promptSettingsFor(pairConfig);
|
|
110
114
|
|
|
111
115
|
// Build the system message once — identical across all batches for this locale.
|
|
112
116
|
// This enables provider-level prompt caching (Anthropic, Gemini).
|
|
113
117
|
const systemMessage = buildSystemMessage(langConfig);
|
|
118
|
+
// The project glossary: each batch is told the terms it contains.
|
|
119
|
+
const glossary = projectGlossary(pairConfig, options, this._coachingCache);
|
|
114
120
|
|
|
115
121
|
const allTranslated = {};
|
|
116
122
|
|
|
@@ -140,6 +146,7 @@ class LLMMethod extends TranslationMethod {
|
|
|
140
146
|
systemMessage,
|
|
141
147
|
temperature: pairConfig.temperature ?? DEFAULT_TEMPERATURE,
|
|
142
148
|
descriptions: options.descriptions || null,
|
|
149
|
+
glossary,
|
|
143
150
|
}, this._callOpenRouterBatch.bind(this));
|
|
144
151
|
|
|
145
152
|
if (result) {
|
|
@@ -177,7 +184,9 @@ class LLMMethod extends TranslationMethod {
|
|
|
177
184
|
*/
|
|
178
185
|
async translateContent(prompt, pairConfig, options) {
|
|
179
186
|
const { apiKey } = options;
|
|
180
|
-
|
|
187
|
+
// Exact slugs only (founder ruling 2026-10-05) — checked here too, since
|
|
188
|
+
// callers outside the pair graph (the MCP translate tool) reach this directly.
|
|
189
|
+
const model = requireExactModelId(pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL);
|
|
181
190
|
if (!apiKey) {
|
|
182
191
|
output.warn('LLM translateContent: no API key provided — skipping.');
|
|
183
192
|
return null;
|
|
@@ -418,7 +427,7 @@ class LLMMethod extends TranslationMethod {
|
|
|
418
427
|
*/
|
|
419
428
|
async _callOpenRouterBatch(toTranslate, options) {
|
|
420
429
|
const { apiKey, model, batchNum, systemMessage, temperature } = options;
|
|
421
|
-
const userMessage = buildUserMessage(toTranslate, options.descriptions || undefined);
|
|
430
|
+
const userMessage = buildUserMessage(toTranslate, options.descriptions || undefined, options.glossary || null);
|
|
422
431
|
|
|
423
432
|
return callOpenRouterJSON({
|
|
424
433
|
prompt: userMessage,
|
|
@@ -445,7 +454,7 @@ class LLMMethod extends TranslationMethod {
|
|
|
445
454
|
output.error(`${keys.length} key(s) could not be translated.`);
|
|
446
455
|
if (!label) {
|
|
447
456
|
output.error(`Consider increasing maxRetries or using a more capable model:`);
|
|
448
|
-
output.error(`"languages": { "<code>": { "model": "google/gemini-
|
|
457
|
+
output.error(`"languages": { "<code>": { "model": "google/gemini-3.1-pro-preview", "batchSize": 5, "maxRetries": 5 } }`);
|
|
449
458
|
}
|
|
450
459
|
}
|
|
451
460
|
}
|
|
@@ -454,6 +463,46 @@ class LLMMethod extends TranslationMethod {
|
|
|
454
463
|
// Prompt building — split into system (cached) + user (per-batch)
|
|
455
464
|
// -----------------------------------------------------------------
|
|
456
465
|
|
|
466
|
+
/**
|
|
467
|
+
* What a pair tells its model, read from the pair config — the ONE reading
|
|
468
|
+
* every LLM-backed method uses (llm, openai, anthropic, gemini, local and
|
|
469
|
+
* llm-coached on any provider). Only the transport differs between them.
|
|
470
|
+
*
|
|
471
|
+
* WHY: the direct-provider methods used to build their own, shorter
|
|
472
|
+
* settings ({ name, register }), so a project's protected terms, its prompt
|
|
473
|
+
* context and the language's gender guidance never reached a model run
|
|
474
|
+
* through `local`, `openai`, `anthropic` or `gemini` — a brand name could
|
|
475
|
+
* be translated there and not through `llm` (found 2026-10-03).
|
|
476
|
+
*
|
|
477
|
+
* @param {object} pairConfig - Resolved pair config (lib/pairs.js)
|
|
478
|
+
* @param {object} [overrides] - Fields a method resolves itself (llm-coached:
|
|
479
|
+
* the coaching prompt it read from coachingFile)
|
|
480
|
+
* @returns {{ name: string, register: string, genderGuidance: string|null,
|
|
481
|
+
* promptContext: string|null, protectedTerms: string[], coachingPrompt: string|null }}
|
|
482
|
+
*/
|
|
483
|
+
function promptSettingsFor(pairConfig = {}, overrides = {}) {
|
|
484
|
+
return {
|
|
485
|
+
name: pairConfig.name,
|
|
486
|
+
register: pairConfig.register,
|
|
487
|
+
// Language-specific gender guidance from the card (e.g., écriture inclusive
|
|
488
|
+
// for French, Doppelpunkt notation for German). Falls back to a generic
|
|
489
|
+
// rule in buildSystemMessage when null.
|
|
490
|
+
genderGuidance: pairConfig.genderGuidance || null,
|
|
491
|
+
// "genderGuidance": false in the config — no gender instruction at all
|
|
492
|
+
// (not even the generic one).
|
|
493
|
+
genderOff: pairConfig.genderGuidanceSource === 'off',
|
|
494
|
+
// User-provided global context (e.g., "This is a developer tool README").
|
|
495
|
+
// Injected into the system message to give the LLM domain awareness.
|
|
496
|
+
promptContext: pairConfig.promptContext || null,
|
|
497
|
+
// Names the project keeps exactly as written (config.protectedTerms).
|
|
498
|
+
protectedTerms: pairConfig.protectedTerms || [],
|
|
499
|
+
// Coaching prompt text — free-text instructions read from a coaching file.
|
|
500
|
+
// Injected between register and rules in the system message.
|
|
501
|
+
coachingPrompt: pairConfig.coachingPrompt || null,
|
|
502
|
+
...overrides,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
457
506
|
/**
|
|
458
507
|
* Build the system message preamble (cached across batches).
|
|
459
508
|
*
|
|
@@ -470,15 +519,18 @@ class LLMMethod extends TranslationMethod {
|
|
|
470
519
|
* being translated (e.g., "This is a developer tool for i18n"). Injected
|
|
471
520
|
* after the role line to give the LLM domain awareness.
|
|
472
521
|
*
|
|
473
|
-
* @param {object} langConfig - { name, register, genderGuidance?, promptContext? }
|
|
522
|
+
* @param {object} langConfig - { name, register, genderGuidance?, promptContext?, protectedTerms? }
|
|
474
523
|
* @returns {string} System message
|
|
475
524
|
*/
|
|
476
525
|
function buildSystemMessage(langConfig) {
|
|
477
526
|
// Use language-specific gender guidance when available from the card,
|
|
478
527
|
// otherwise fall back to a generic rule for unknown languages.
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
528
|
+
// "genderGuidance": false — the config asked for none.
|
|
529
|
+
const genderRule = langConfig.genderOff
|
|
530
|
+
? null
|
|
531
|
+
: langConfig.genderGuidance
|
|
532
|
+
? `- Gender: ${langConfig.genderGuidance}`
|
|
533
|
+
: '- When gender is ambiguous, prefer gender-neutral forms or the most inclusive option available in ' + langConfig.name + '.';
|
|
482
534
|
|
|
483
535
|
// Inject user-provided promptContext to give the LLM domain awareness.
|
|
484
536
|
// This is set in the top-level config and flows through the pair graph.
|
|
@@ -499,10 +551,8 @@ Register/tone: ${langConfig.register}
|
|
|
499
551
|
${coachingBlock}
|
|
500
552
|
Rules:
|
|
501
553
|
- Translate ONLY the values, keep the keys exactly as-is.
|
|
502
|
-
|
|
503
|
-
-
|
|
504
|
-
${genderRule}
|
|
505
|
-
- Respect the UI element type: button labels should be concise, descriptions can be natural-length, error messages should be clear and direct.
|
|
554
|
+
${nameRules(langConfig.protectedTerms)}
|
|
555
|
+
${genderRule ? `${genderRule}\n` : ''}- Respect the UI element type: button labels should be concise, descriptions can be natural-length, error messages should be clear and direct.
|
|
506
556
|
- Quotation marks inside a translated value: use the target language's typographic quotation marks (e.g. “…”, «…», 「…」); if you must use ASCII double quotes, escape them as \\" so the JSON stays valid.
|
|
507
557
|
- Return ONLY valid JSON, no markdown fences, no explanation.`;
|
|
508
558
|
}
|
|
@@ -510,8 +560,9 @@ ${genderRule}
|
|
|
510
560
|
/**
|
|
511
561
|
* Build the user message (varies per batch).
|
|
512
562
|
*
|
|
513
|
-
* Contains:
|
|
514
|
-
*
|
|
563
|
+
* Contains: the glossary terms this batch contains (REQUIRED TERMINOLOGY),
|
|
564
|
+
* UI context hints for key names + the JSON payload. This changes with
|
|
565
|
+
* every batch, so it's never cached.
|
|
515
566
|
*
|
|
516
567
|
* Descriptions: When Docusaurus {message, description} files provide
|
|
517
568
|
* developer-written descriptions (e.g., "The title of the blog page"),
|
|
@@ -520,9 +571,12 @@ ${genderRule}
|
|
|
520
571
|
*
|
|
521
572
|
* @param {object} toTranslate - Key-value map to translate
|
|
522
573
|
* @param {object} [descriptions] - Optional key→description map from Docusaurus
|
|
574
|
+
* @param {Object<string, string>|null} [glossary] - The project glossary
|
|
575
|
+
* (lib/methods/coaching-data.js projectGlossary); only the terms this
|
|
576
|
+
* batch contains are listed
|
|
523
577
|
* @returns {string} User message
|
|
524
578
|
*/
|
|
525
|
-
function buildUserMessage(toTranslate, descriptions) {
|
|
579
|
+
function buildUserMessage(toTranslate, descriptions, glossary = null) {
|
|
526
580
|
const typeHints = inferKeyTypes(toTranslate);
|
|
527
581
|
|
|
528
582
|
// Merge auto-inferred type hints with Docusaurus descriptions.
|
|
@@ -547,7 +601,7 @@ function buildUserMessage(toTranslate, descriptions) {
|
|
|
547
601
|
? `UI context for these keys:\n${typeHints.join('\n')}\n\n`
|
|
548
602
|
: '';
|
|
549
603
|
|
|
550
|
-
return `${hintsBlock}${JSON.stringify(toTranslate, null, 2)}`;
|
|
604
|
+
return `${terminologyBlock(toTranslate, glossary)}${hintsBlock}${JSON.stringify(toTranslate, null, 2)}`;
|
|
551
605
|
}
|
|
552
606
|
|
|
553
607
|
/**
|
|
@@ -589,4 +643,4 @@ function inferKeyTypes(toTranslate) {
|
|
|
589
643
|
return hints;
|
|
590
644
|
}
|
|
591
645
|
|
|
592
|
-
export { LLMMethod, buildPrompt, buildSystemMessage, buildUserMessage, isUnsafeKey, inferKeyTypes };
|
|
646
|
+
export { LLMMethod, PLAIN_LLM_METHODS, buildPrompt, buildSystemMessage, buildUserMessage, promptSettingsFor, isUnsafeKey, inferKeyTypes };
|