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.
Files changed (138) hide show
  1. package/README.md +41 -26
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +82 -37
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +34 -0
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +286 -85
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1709 -101
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +632 -125
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +15 -9
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +230 -17
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +284 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1136 -345
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +194 -35
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +727 -79
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +975 -262
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +1102 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +575 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +257 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +71 -129
  70. package/lib/methods/llm.js +87 -33
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +102 -55
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +6 -1
  100. package/lib/seal.mjs +4 -3
  101. package/lib/sealed-qualifier.mjs +1 -1
  102. package/lib/segment.js +24 -8
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +996 -21
  117. package/lib/verify.js +1332 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +1 -1
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/cards-fallback.json +1 -1
  123. package/shared/curated-orthography-conventions.json +26 -8
  124. package/shared/gettext-plural-forms.json +45 -0
  125. package/shared/licenses.json +99 -3
  126. package/shared/method-registry.json +2 -0
  127. package/shared/metric-registry.json +96 -18
  128. package/shared/retired-model-aliases.json +9 -0
  129. package/shared/schemas/champollion-plugin.schema.json +4 -0
  130. package/shared/schemas/corpora-card.schema.json +8 -2
  131. package/shared/schemas/human-services.schema.json +1 -1
  132. package/shared/schemas/method-index-record.schema.json +67 -0
  133. package/shared/schemas/method-registry.schema.json +4 -0
  134. package/shared/schemas/metric-registry.schema.json +55 -1
  135. package/shared/schemas/retired-model-aliases.schema.json +32 -0
  136. package/shared/docent/corpus.json +0 -11739
  137. package/shared/model-aliases.json +0 -7
  138. 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
- const langConfig = {
274
- name: pairConfig.name,
275
- register: pairConfig.register,
276
- genderGuidance: pairConfig.genderGuidance || null,
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
- const { apiKey } = options;
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
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
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, structured, opts, pairConfig);
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
- const model = pairConfig.model || options.model || directMethod._getDefaultModel();
313
- await directMethod._validateModel(model, apiKey); // DX warnings only; never blocks
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
- coaching: structured, temperature, descriptions: options.descriptions || null,
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
- // Strip the target locale so a direct provider's translateContent does NOT
428
- // re-load and re-prepend the SAME structured coaching block (it keys
429
- // coaching loading off pairConfig.target; LLMMethod ignores target).
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
- checkReadiness(context) {
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, coaching, options, pairConfig = {}) {
476
+ async _callCoachedBatch(toTranslate, glossary, options, pairConfig = {}) {
493
477
  const { apiKey, model, batchNum, systemMessage } = options;
494
478
 
495
- // Build per-batch user message with dictionary hints specific to this batch's
496
- // values. `coaching` may be null when only a free-text coaching prompt is
497
- // configured (no structured .champollion/coaching/<locale>.json) — in that
498
- // case there is no dictionary to match against.
499
- const dictHints = (coaching && coaching.dictionary)
500
- ? findDictionaryMatches(toTranslate, coaching.dictionary)
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,
@@ -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
- const { apiKey } = options;
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
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
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
- const langConfig = {
97
- name: pairConfig.name,
98
- register: pairConfig.register,
99
- // Language-specific gender guidance from the card (e.g., écriture inclusive
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
- const model = pairConfig.model || options.model || DEFAULT_OPENROUTER_MODEL;
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-2.5-pro", "batchSize": 5, "maxRetries": 5 } }`);
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
- const genderRule = langConfig.genderGuidance
480
- ? `- Gender: ${langConfig.genderGuidance}`
481
- : '- When gender is ambiguous, prefer gender-neutral forms or the most inclusive option available in ' + langConfig.name + '.';
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
- - Proper nouns (product names, company names, place names) should NOT be translated.
503
- - Technical terms and role descriptions that are industry-standard should stay in English.
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: UI context hints for key names + the JSON payload.
514
- * This changes with every batch, so it's never cached.
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 };