champollion 0.3.4 → 0.4.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 +83 -36
- 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 +1689 -87
- 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 +196 -14
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +270 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1127 -344
- 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 +726 -78
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +965 -253
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +964 -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 +571 -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 +252 -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 +68 -128
- package/lib/methods/llm.js +80 -31
- 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 +11 -0
- 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 +2 -1
- 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 +880 -17
- package/lib/verify.js +1296 -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/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- 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/docent/corpus.json +0 -11739
package/lib/methods/api.js
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
* source_locale: "en",
|
|
24
24
|
* target_locale: "crk",
|
|
25
25
|
* method: "crk-coached-v1",
|
|
26
|
-
* keys: { "hero.title": "Welcome", ... }
|
|
26
|
+
* keys: { "hero.title": "Welcome", ... },
|
|
27
|
+
* instructions: { "hero.title": "…" } // only when the endpoint declares
|
|
28
|
+
* // "acceptsInstructions": true
|
|
27
29
|
* }
|
|
28
30
|
*
|
|
29
31
|
* RESPONSE FORMAT (what the API returns):
|
|
@@ -32,6 +34,12 @@
|
|
|
32
34
|
* meta: { model, cost_usd, quality_tier, ... }
|
|
33
35
|
* }
|
|
34
36
|
*
|
|
37
|
+
* CONTENT (Markdown bodies) uses the SAME contract: the body pieces go out as
|
|
38
|
+
* keys ("segment.<N>" per block in block mode, "body" in page mode) with the
|
|
39
|
+
* optional field text_format: "markdown" so a server can tell document text
|
|
40
|
+
* from app strings (servers that do not know the field ignore it). Only the
|
|
41
|
+
* Markdown leaves — never the LLM instruction prompt the CLI wraps it in.
|
|
42
|
+
*
|
|
35
43
|
* COST PROFILE: Varies by method — determined server-side
|
|
36
44
|
* QUALITY TIER: Varies by method — read from plugin manifest
|
|
37
45
|
*/
|
|
@@ -44,10 +52,19 @@ import {
|
|
|
44
52
|
import { pMap } from '../concurrent.js';
|
|
45
53
|
import { DEFAULT_METHOD_CONCURRENCY } from '../config.js';
|
|
46
54
|
import { output } from '../output.js';
|
|
55
|
+
import { SEGMENT_MARKER_PREFIX, SEGMENT_MARKER_SUFFIX } from '../segment.js';
|
|
56
|
+
import { parseContentPrompt } from './content-separator.js';
|
|
57
|
+
import { getEnvOrFileVar } from '../api-key.js';
|
|
58
|
+
import { isLoopbackEndpoint, localMachineCost } from './http-utils.js';
|
|
59
|
+
import { captureRequest, isCapturing, PREVIEW_KEY } from './request-capture.js';
|
|
47
60
|
|
|
48
61
|
// Maximum keys per API request (server-side limit)
|
|
49
62
|
const MAX_KEYS_PER_REQUEST = 100;
|
|
50
63
|
|
|
64
|
+
// The block/page parser lives in content-separator.js (shared with the
|
|
65
|
+
// raw-text engines); api sends the blocks as keys.
|
|
66
|
+
const contentRequest = parseContentPrompt;
|
|
67
|
+
|
|
51
68
|
class APIMethod extends TranslationMethod {
|
|
52
69
|
constructor(options = {}) {
|
|
53
70
|
super('api', options);
|
|
@@ -58,6 +75,14 @@ class APIMethod extends TranslationMethod {
|
|
|
58
75
|
this.methodVersion = options.methodVersion || null;
|
|
59
76
|
this.qualityTier = options.qualityTier || 'standard';
|
|
60
77
|
this.pluginProvenance = options.provenance || null;
|
|
78
|
+
// Per-key instructions (plural forms, a quality-gate retry's feedback)
|
|
79
|
+
// reach the endpoint only when it declares it follows them
|
|
80
|
+
// ("acceptsInstructions": true on the pair or in the plugin manifest) —
|
|
81
|
+
// they travel as an "instructions" object beside "keys". false (a trained
|
|
82
|
+
// NMT model, e.g. nmt-forge serve) and unknown send the text alone.
|
|
83
|
+
this.declaredInstructions = typeof options.acceptsInstructions === 'boolean' ? options.acceptsInstructions : null;
|
|
84
|
+
this.acceptsKeyInstructions = this.declaredInstructions === true;
|
|
85
|
+
this.supportsRequestPreview = true; // its transport reports to request-capture.js
|
|
61
86
|
}
|
|
62
87
|
|
|
63
88
|
/**
|
|
@@ -70,12 +95,13 @@ class APIMethod extends TranslationMethod {
|
|
|
70
95
|
* @returns {object|null} Map of key → translated value, or null
|
|
71
96
|
*/
|
|
72
97
|
async translate(keys, sourceFlat, pairConfig, options) {
|
|
73
|
-
const
|
|
74
|
-
|
|
98
|
+
const endpointForKey = this.endpoint || pairConfig.endpoint || options.endpoint;
|
|
99
|
+
// A request preview needs no key: it is shown, never sent.
|
|
100
|
+
const apiKey = resolveApiMethodKey(pairConfig, endpointForKey, options.cwd) || (isCapturing() ? PREVIEW_KEY : null);
|
|
75
101
|
|
|
76
102
|
if (!apiKey) {
|
|
77
|
-
output.error(
|
|
78
|
-
output.error('Set CHAMPOLLION_API_KEY
|
|
103
|
+
output.error(`API method: no key for ${endpointForKey || 'this endpoint'}.`);
|
|
104
|
+
output.error('Set CHAMPOLLION_API_KEY, or "apiKey": "${YOUR_VAR}" on the pair.');
|
|
79
105
|
return null;
|
|
80
106
|
}
|
|
81
107
|
|
|
@@ -93,6 +119,9 @@ class APIMethod extends TranslationMethod {
|
|
|
93
119
|
const sourceLocale = pairConfig.source || 'en';
|
|
94
120
|
const targetLocale = pairConfig.target;
|
|
95
121
|
const method = this.methodName || pairConfig.methodPlugin || 'default';
|
|
122
|
+
// 'markdown' for content-body pieces (translateContent below); unset for
|
|
123
|
+
// app strings, so key-value requests are unchanged.
|
|
124
|
+
const textFormat = options.textFormat || null;
|
|
96
125
|
|
|
97
126
|
const allTranslated = {};
|
|
98
127
|
|
|
@@ -113,6 +142,17 @@ class APIMethod extends TranslationMethod {
|
|
|
113
142
|
|
|
114
143
|
if (Object.keys(keysPayload).length === 0) return;
|
|
115
144
|
|
|
145
|
+
// Declared instruction-following endpoints get the per-key notes.
|
|
146
|
+
let instructions = null;
|
|
147
|
+
if (this.acceptsKeyInstructions && options.descriptions) {
|
|
148
|
+
for (const key of Object.keys(keysPayload)) {
|
|
149
|
+
if (typeof options.descriptions[key] === 'string' && options.descriptions[key]) {
|
|
150
|
+
instructions = instructions || {};
|
|
151
|
+
instructions[key] = options.descriptions[key];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
const result = await this._translateBatchWithRetry(
|
|
117
157
|
keysPayload,
|
|
118
158
|
sourceLocale,
|
|
@@ -121,6 +161,8 @@ class APIMethod extends TranslationMethod {
|
|
|
121
161
|
endpoint,
|
|
122
162
|
apiKey,
|
|
123
163
|
idx + 1,
|
|
164
|
+
textFormat,
|
|
165
|
+
instructions,
|
|
124
166
|
);
|
|
125
167
|
|
|
126
168
|
if (result) {
|
|
@@ -132,14 +174,39 @@ class APIMethod extends TranslationMethod {
|
|
|
132
174
|
}
|
|
133
175
|
|
|
134
176
|
/**
|
|
135
|
-
*
|
|
177
|
+
* Content (Markdown body) translation via the API.
|
|
136
178
|
*
|
|
137
|
-
* The
|
|
138
|
-
*
|
|
139
|
-
*
|
|
179
|
+
* The content lane passes the prompt it built for an LLM (see
|
|
180
|
+
* contentRequest above). The Markdown inside it is sent over the same
|
|
181
|
+
* key → string contract with text_format "markdown", and the answer is
|
|
182
|
+
* rebuilt in the shape the content lane parses: one ⟦SEG_N⟧ marker per
|
|
183
|
+
* block (a block the server did not return is LEFT OUT, so the lane's
|
|
184
|
+
* self-repair ladder retries it and then marks it '[EN]' — visible, never
|
|
185
|
+
* silent), or the translated body in page mode.
|
|
186
|
+
*
|
|
187
|
+
* (This used to return null unconditionally "so the orchestrator falls
|
|
188
|
+
* back to the local LLM method" — no such fallback exists: every content
|
|
189
|
+
* file failed with "block-batch translation returned no results" and no
|
|
190
|
+
* reason. Synthetic users, 2026-10.)
|
|
140
191
|
*/
|
|
141
|
-
async translateContent(
|
|
142
|
-
|
|
192
|
+
async translateContent(prompt, pairConfig, options = {}) {
|
|
193
|
+
const request = contentRequest(prompt);
|
|
194
|
+
if (!request) {
|
|
195
|
+
output.error('API method: this content prompt carries neither ⟦SEG_N⟧ blocks nor a "---" body separator — nothing to send.');
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const names = Object.keys(request.keys).filter((k) => request.keys[k].trim());
|
|
199
|
+
if (names.length === 0) return null;
|
|
200
|
+
const result = await this.translate(names, request.keys, pairConfig,
|
|
201
|
+
{ ...options, textFormat: 'markdown' });
|
|
202
|
+
if (!result) return null;
|
|
203
|
+
if (request.mode === 'page') {
|
|
204
|
+
return typeof result.body === 'string' ? result.body : null;
|
|
205
|
+
}
|
|
206
|
+
const parts = request.ids
|
|
207
|
+
.filter((id) => typeof result[`segment.${id}`] === 'string')
|
|
208
|
+
.map((id) => `${SEGMENT_MARKER_PREFIX}${id}${SEGMENT_MARKER_SUFFIX}\n${result[`segment.${id}`]}`);
|
|
209
|
+
return parts.length > 0 ? parts.join('\n\n') : null;
|
|
143
210
|
}
|
|
144
211
|
|
|
145
212
|
/**
|
|
@@ -152,10 +219,26 @@ class APIMethod extends TranslationMethod {
|
|
|
152
219
|
* @param {string} endpoint - API endpoint URL
|
|
153
220
|
* @param {string} apiKey - Remote API key
|
|
154
221
|
* @param {number} batchNum - Batch number for logging
|
|
222
|
+
* @param {string|null} [textFormat] - 'markdown' for content-body pieces
|
|
155
223
|
* @returns {object|null} Map of key → translated value
|
|
156
224
|
*/
|
|
157
|
-
async _translateBatchWithRetry(keysPayload, sourceLocale, targetLocale, method, endpoint, apiKey, batchNum) {
|
|
225
|
+
async _translateBatchWithRetry(keysPayload, sourceLocale, targetLocale, method, endpoint, apiKey, batchNum, textFormat = null, instructions = null) {
|
|
158
226
|
const keyCount = Object.keys(keysPayload).length;
|
|
227
|
+
const headers = {
|
|
228
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
'User-Agent': 'champollion',
|
|
231
|
+
};
|
|
232
|
+
const body = {
|
|
233
|
+
source_locale: sourceLocale,
|
|
234
|
+
target_locale: targetLocale,
|
|
235
|
+
method,
|
|
236
|
+
keys: keysPayload,
|
|
237
|
+
...(textFormat ? { text_format: textFormat } : {}),
|
|
238
|
+
...(instructions ? { instructions } : {}),
|
|
239
|
+
};
|
|
240
|
+
// `sync --dry --show-prompt`: hand over the exact request, send nothing.
|
|
241
|
+
if (captureRequest({ url: endpoint, headers, body })) return null;
|
|
159
242
|
|
|
160
243
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
161
244
|
try {
|
|
@@ -164,17 +247,8 @@ class APIMethod extends TranslationMethod {
|
|
|
164
247
|
|
|
165
248
|
const response = await fetch(endpoint, {
|
|
166
249
|
method: 'POST',
|
|
167
|
-
headers
|
|
168
|
-
|
|
169
|
-
'Content-Type': 'application/json',
|
|
170
|
-
'User-Agent': 'champollion',
|
|
171
|
-
},
|
|
172
|
-
body: JSON.stringify({
|
|
173
|
-
source_locale: sourceLocale,
|
|
174
|
-
target_locale: targetLocale,
|
|
175
|
-
method,
|
|
176
|
-
keys: keysPayload,
|
|
177
|
-
}),
|
|
250
|
+
headers,
|
|
251
|
+
body: JSON.stringify(body),
|
|
178
252
|
signal: controller.signal,
|
|
179
253
|
});
|
|
180
254
|
|
|
@@ -286,7 +360,12 @@ class APIMethod extends TranslationMethod {
|
|
|
286
360
|
* Cost estimation — API method pricing is determined by the remote server.
|
|
287
361
|
* We cannot estimate cost without querying the endpoint.
|
|
288
362
|
*/
|
|
289
|
-
estimateCost(keyCount) {
|
|
363
|
+
estimateCost(keyCount, pairConfig = {}) {
|
|
364
|
+
// An endpoint on this machine (nmt-forge serve, champollion serve on
|
|
365
|
+
// 127.0.0.1) has no API bill — say $0, and why. Anything else is priced
|
|
366
|
+
// by its server: unknown here, never $0.
|
|
367
|
+
const endpoint = pairConfig?.endpoint || this.endpoint;
|
|
368
|
+
if (isLoopbackEndpoint(endpoint)) return localMachineCost(endpoint);
|
|
290
369
|
return {
|
|
291
370
|
estimatedCost: null,
|
|
292
371
|
currency: 'USD',
|
|
@@ -313,4 +392,38 @@ class APIMethod extends TranslationMethod {
|
|
|
313
392
|
}
|
|
314
393
|
}
|
|
315
394
|
|
|
316
|
-
|
|
395
|
+
|
|
396
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The bearer token for an `api` endpoint — and ONLY a token meant for it.
|
|
400
|
+
*
|
|
401
|
+
* 1. the pair's own "apiKey": "${VAR}" (read from the environment or
|
|
402
|
+
* .env.local/.env), or a literal value;
|
|
403
|
+
* 2. CHAMPOLLION_API_KEY;
|
|
404
|
+
* 3. for a loopback endpoint (nmt-forge serve, champollion serve) with no
|
|
405
|
+
* token configured, a placeholder: those servers need none.
|
|
406
|
+
*
|
|
407
|
+
* It used to fall back to `options.apiKey` — the generic provider key sync
|
|
408
|
+
* resolves for the llm method — so a user's OPENROUTER_API_KEY was sent as
|
|
409
|
+
* the Bearer token to whatever endpoint a pair named (found 2026-10-03). The
|
|
410
|
+
* documented per-pair "apiKey" was never read at all.
|
|
411
|
+
*
|
|
412
|
+
* @returns {string|null}
|
|
413
|
+
*/
|
|
414
|
+
function resolveApiMethodKey(pairConfig, endpoint, cwd) {
|
|
415
|
+
const own = pairConfig && typeof pairConfig.apiKey === 'string' ? pairConfig.apiKey.trim() : '';
|
|
416
|
+
if (own) {
|
|
417
|
+
const ref = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(own);
|
|
418
|
+
if (!ref) return own;
|
|
419
|
+
const v = getEnvOrFileVar(ref[1], cwd);
|
|
420
|
+
if (v) return v;
|
|
421
|
+
}
|
|
422
|
+
const shared = getEnvOrFileVar('CHAMPOLLION_API_KEY', cwd);
|
|
423
|
+
if (shared) return shared;
|
|
424
|
+
let host = '';
|
|
425
|
+
try { host = new URL(endpoint).hostname; } catch { /* no endpoint */ }
|
|
426
|
+
return LOOPBACK.has(host) ? 'local-no-token' : null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export { APIMethod, resolveApiMethodKey };
|
package/lib/methods/base.js
CHANGED
|
@@ -33,6 +33,23 @@ class TranslationMethod {
|
|
|
33
33
|
constructor(name, options = {}) {
|
|
34
34
|
this.name = name;
|
|
35
35
|
this.options = options;
|
|
36
|
+
// True for machine-translation engines that translate raw text and do
|
|
37
|
+
// not understand an LLM instruction prompt (Google, DeepL, Microsoft,
|
|
38
|
+
// LibreTranslate, Apertium, Tilde, Translated). translateRawContent sends
|
|
39
|
+
// them one Markdown block at a time instead of the block-batch prompt.
|
|
40
|
+
this.translatesRawText = false;
|
|
41
|
+
// True for methods that read per-key instructions (the "UI context"
|
|
42
|
+
// lines of the prompt): which plural form a generated i18next key needs,
|
|
43
|
+
// which CLDR categories an ICU plural must cover, a gettext msgctxt, a
|
|
44
|
+
// quality-gate retry's feedback. Only LLM methods do; a machine
|
|
45
|
+
// translation engine translates the text and nothing else, so sync must
|
|
46
|
+
// not claim it was asked for, e.g., French's "many" form.
|
|
47
|
+
this.acceptsKeyInstructions = false;
|
|
48
|
+
// True for methods whose transport hands its finished request to
|
|
49
|
+
// lib/methods/request-capture.js instead of sending it while a capture
|
|
50
|
+
// is on (`sync --dry --show-prompt`). Only those are previewed: running
|
|
51
|
+
// any other method's translate() under a preview could reach its API.
|
|
52
|
+
this.supportsRequestPreview = false;
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
/**
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's coaching file (.champollion/coaching/<locale>.json) and its
|
|
3
|
+
* glossary — read in ONE place for every method that uses them.
|
|
4
|
+
*
|
|
5
|
+
* THE FILE:
|
|
6
|
+
* {
|
|
7
|
+
* "grammar_rules": ["French adjectives agree in gender and number…"],
|
|
8
|
+
* "dictionary": { "dashboard": "tableau de bord" },
|
|
9
|
+
* "style_notes": "Prefer active voice."
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* WHO READS WHAT:
|
|
13
|
+
* - "dictionary" is the project's GLOSSARY. Every LLM method is told the
|
|
14
|
+
* glossary terms a batch contains (lib/methods/llm.js buildUserMessage),
|
|
15
|
+
* DeepL sends it as a glossary, and sync checks every method's output
|
|
16
|
+
* against it.
|
|
17
|
+
* - "grammar_rules" and "style_notes" are COACHING, read by the
|
|
18
|
+
* llm-coached method only (on any provider). The plain LLM methods
|
|
19
|
+
* (llm, openai, anthropic, gemini, local) build the same prompt as each
|
|
20
|
+
* other, so a pair gets the same instructions whichever of them runs it.
|
|
21
|
+
*
|
|
22
|
+
* WHY A MODULE OF ITS OWN: lib/methods/llm.js needs the glossary helpers and
|
|
23
|
+
* lib/methods/llm-coached.js imports llm.js — keeping these here avoids an
|
|
24
|
+
* import cycle. llm-coached.js re-exports them (the public names are unchanged).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { output } from '../output.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default coaching data directory, relative to project root.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_COACHING_DIR = '.champollion/coaching';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load coaching data for a locale from a JSON file, with caching.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} coachingDir - Path to coaching data directory
|
|
40
|
+
* @param {string} locale - Target locale code (e.g., 'fr', 'crk')
|
|
41
|
+
* @param {Map} cache - Cache map to store loaded data (avoids re-reading files)
|
|
42
|
+
* @returns {object|null} Coaching data { grammar_rules, dictionary, style_notes }, or null
|
|
43
|
+
*/
|
|
44
|
+
function loadCoachingData(coachingDir, locale, cache) {
|
|
45
|
+
if (!locale) return null;
|
|
46
|
+
|
|
47
|
+
const cacheKey = `${coachingDir}:${locale}`;
|
|
48
|
+
if (cache.has(cacheKey)) {
|
|
49
|
+
return cache.get(cacheKey);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const filePath = path.join(coachingDir, `${locale}.json`);
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(filePath)) {
|
|
55
|
+
cache.set(cacheKey, null);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const data = JSON.parse(raw);
|
|
62
|
+
|
|
63
|
+
// Validate required structure — normalize missing fields to safe defaults
|
|
64
|
+
const coaching = {
|
|
65
|
+
grammar_rules: Array.isArray(data.grammar_rules) ? data.grammar_rules : [],
|
|
66
|
+
dictionary: (data.dictionary && typeof data.dictionary === 'object') ? data.dictionary : {},
|
|
67
|
+
style_notes: typeof data.style_notes === 'string' ? data.style_notes : '',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
cache.set(cacheKey, coaching);
|
|
71
|
+
return coaching;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
output.warn(`Failed to load coaching data: ${filePath}`);
|
|
74
|
+
output.warn(err.message);
|
|
75
|
+
cache.set(cacheKey, null);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The project glossary for a pair: the term → translation map the pair is
|
|
82
|
+
* told about and checked against. sync loads it once for every pair
|
|
83
|
+
* (pairConfig.glossary, null when there is none); a caller that did not (a
|
|
84
|
+
* library user, `serve`) gets it from the coaching file in `cwd`.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} pairConfig
|
|
87
|
+
* @param {{ cwd?: string }} [options]
|
|
88
|
+
* @param {Map} [cache] - loadCoachingData cache
|
|
89
|
+
* @returns {Object<string, string>|null}
|
|
90
|
+
*/
|
|
91
|
+
function projectGlossary(pairConfig, options = {}, cache = new Map()) {
|
|
92
|
+
// Set by sync (null = the project has none for this locale).
|
|
93
|
+
if (pairConfig && Object.prototype.hasOwnProperty.call(pairConfig, 'glossary')) {
|
|
94
|
+
const g = pairConfig.glossary;
|
|
95
|
+
return g && typeof g === 'object' && Object.keys(g).length > 0 ? g : null;
|
|
96
|
+
}
|
|
97
|
+
const target = pairConfig && (pairConfig.target || pairConfig.locale);
|
|
98
|
+
if (!target) return null;
|
|
99
|
+
const data = loadCoachingData(path.join(options.cwd || process.cwd(), DEFAULT_COACHING_DIR), target, cache);
|
|
100
|
+
return data && Object.keys(data.dictionary).length > 0 ? data.dictionary : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Scan source values for dictionary term matches.
|
|
105
|
+
*
|
|
106
|
+
* Case-insensitive substring matching of each term against the batch's
|
|
107
|
+
* source values (the dictionary is usually small).
|
|
108
|
+
*
|
|
109
|
+
* @param {object} toTranslate - Key-value map to scan
|
|
110
|
+
* @param {object} dictionary - Term → translation map
|
|
111
|
+
* @returns {Array<{ term: string, translation: string }>} Matched hints
|
|
112
|
+
*/
|
|
113
|
+
function findDictionaryMatches(toTranslate, dictionary) {
|
|
114
|
+
if (!dictionary || Object.keys(dictionary).length === 0) return [];
|
|
115
|
+
|
|
116
|
+
const matches = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const values = Object.values(toTranslate).join(' ').toLowerCase();
|
|
119
|
+
|
|
120
|
+
for (const [term, translation] of Object.entries(dictionary)) {
|
|
121
|
+
if (seen.has(term)) continue;
|
|
122
|
+
if (values.includes(term.toLowerCase())) {
|
|
123
|
+
matches.push({ term, translation });
|
|
124
|
+
seen.add(term);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return matches;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The REQUIRED TERMINOLOGY block for a batch: the glossary terms its source
|
|
133
|
+
* values contain ('' when none).
|
|
134
|
+
*
|
|
135
|
+
* @param {object} toTranslate - Key-value map of the batch
|
|
136
|
+
* @param {Object<string, string>|null} glossary
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
function terminologyBlock(toTranslate, glossary) {
|
|
140
|
+
const hints = findDictionaryMatches(toTranslate, glossary);
|
|
141
|
+
if (hints.length === 0) return '';
|
|
142
|
+
return 'REQUIRED TERMINOLOGY (use these exact translations):\n'
|
|
143
|
+
+ hints.map(h => ` • "${h.term}" → "${h.translation}"`).join('\n')
|
|
144
|
+
+ '\n\n';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
DEFAULT_COACHING_DIR,
|
|
149
|
+
loadCoachingData,
|
|
150
|
+
projectGlossary,
|
|
151
|
+
findDictionaryMatches,
|
|
152
|
+
terminologyBlock,
|
|
153
|
+
};
|
|
@@ -20,6 +20,49 @@
|
|
|
20
20
|
*/
|
|
21
21
|
export const CONTENT_SEPARATOR = '\n---\n';
|
|
22
22
|
|
|
23
|
+
import { SEGMENT_MARKER_PREFIX, SEGMENT_MARKER_SUFFIX } from '../segment.js';
|
|
24
|
+
|
|
25
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
|
|
27
|
+
// One block-batch segment marker on its own line (segment.js
|
|
28
|
+
// buildBlockBatchPrompt writes "⟦SEG_N⟧\n<block>" separated by blank lines).
|
|
29
|
+
const SEGMENT_LINE = new RegExp(
|
|
30
|
+
`^${escapeRegExp(SEGMENT_MARKER_PREFIX)}(\\d+)${escapeRegExp(SEGMENT_MARKER_SUFFIX)}[ \\t]*$`,
|
|
31
|
+
'gm',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The Markdown a content prompt carries, without the LLM instructions.
|
|
36
|
+
*
|
|
37
|
+
* The content lane builds prompts for an LLM: the block-batch prompt
|
|
38
|
+
* (instructions, then one ⟦SEG_N⟧ marker line per block) or the page prompt
|
|
39
|
+
* (instructions, "---", the body). A method that is not an LLM must send
|
|
40
|
+
* only the Markdown — never the instructions.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} prompt
|
|
43
|
+
* @returns {{ mode: 'segments', keys: object, ids: string[] } | { mode: 'page', keys: object } | null}
|
|
44
|
+
*/
|
|
45
|
+
export function parseContentPrompt(prompt) {
|
|
46
|
+
const text = String(prompt);
|
|
47
|
+
const marks = [...text.matchAll(SEGMENT_LINE)];
|
|
48
|
+
if (marks.length > 0) {
|
|
49
|
+
const keys = {};
|
|
50
|
+
marks.forEach((m, i) => {
|
|
51
|
+
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
|
|
52
|
+
keys[`segment.${m[1]}`] = text
|
|
53
|
+
.slice(m.index + m[0].length, end)
|
|
54
|
+
.replace(/^\r?\n/, '')
|
|
55
|
+
.replace(/[\r\n]+\s*$/, '');
|
|
56
|
+
});
|
|
57
|
+
return { mode: 'segments', keys, ids: marks.map((m) => m[1]) };
|
|
58
|
+
}
|
|
59
|
+
const sep = text.indexOf(CONTENT_SEPARATOR);
|
|
60
|
+
if (sep !== -1) {
|
|
61
|
+
return { mode: 'page', keys: { body: text.slice(sep + CONTENT_SEPARATOR.length) } };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
/**
|
|
24
67
|
* Extract the Markdown body from a content prompt.
|
|
25
68
|
*
|
package/lib/methods/deepl.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEEPL_REQUEST_TIMEOUT_MS = 15000;
|
|
|
19
19
|
class DeepLMethod extends TranslationMethod {
|
|
20
20
|
constructor(options = {}) {
|
|
21
21
|
super('deepl', options);
|
|
22
|
+
this.translatesRawText = true; // see base.js
|
|
22
23
|
this._coachingCache = new Map();
|
|
23
24
|
}
|
|
24
25
|
|
|
@@ -31,7 +32,6 @@ class DeepLMethod extends TranslationMethod {
|
|
|
31
32
|
*/
|
|
32
33
|
_resolveApiKey(options) {
|
|
33
34
|
return options.deeplApiKey
|
|
34
|
-
|| getEnvOrFileVar('DEEPL_API_KEY')
|
|
35
35
|
|| getEnvOrFileVar('DEEPL_API_KEY', options.cwd);
|
|
36
36
|
}
|
|
37
37
|
|