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/pairs.js
CHANGED
|
@@ -19,15 +19,23 @@
|
|
|
19
19
|
|
|
20
20
|
import { DEFAULT_REGISTERS, getLanguageCard, resolveCode, DEFAULT_REGISTER_FALLBACK } from './registers.js';
|
|
21
21
|
import { resolveTargetScript, validateScriptFallback, converterKeyForLocale } from './scripts.js';
|
|
22
|
-
import { getMethod } from './translate.js';
|
|
22
|
+
import { getMethod, METHOD_REGISTRY } from './translate.js';
|
|
23
|
+
import { COACHED_PROVIDERS, normalizeProvider } from './methods/llm-coached.js';
|
|
23
24
|
import { DEFAULT_OPENROUTER_MODEL, DEFAULT_BATCH_SIZE, DEFAULT_MAX_RETRIES } from './config.js';
|
|
25
|
+
import { resolveModel } from './models.js';
|
|
26
|
+
import { output } from './output.js';
|
|
27
|
+
import { tmMethodKey } from './tm.js';
|
|
28
|
+
import { PLAIN_LLM_METHODS } from './methods/prompt-methods.js';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
24
31
|
|
|
25
32
|
/**
|
|
26
|
-
* Quality tiers —
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
33
|
+
* Quality tiers — a label a pair's config (`pairs.<pair>.qualityTier`) or a
|
|
34
|
+
* served method's manifest declares about its output. Each description says
|
|
35
|
+
* what the label claims; nothing measures or enforces it — sync translates
|
|
36
|
+
* the same whatever the tier, `serve` advertises it, and `status` shows it
|
|
37
|
+
* only when someone set it (the default is no claim at all; Round 14,
|
|
38
|
+
* Next.js persona: "quality: Standard" on every pair explained nothing).
|
|
31
39
|
*/
|
|
32
40
|
const QUALITY_TIERS = {
|
|
33
41
|
standard: {
|
|
@@ -68,27 +76,423 @@ const PAIR_DEFAULTS = {
|
|
|
68
76
|
* class's _getDefaultModel() fires with the correct provider-specific slug.
|
|
69
77
|
*/
|
|
70
78
|
const DIRECT_PROVIDER_METHODS = new Set([
|
|
71
|
-
'gemini', 'openai', 'anthropic', 'deepl',
|
|
79
|
+
'gemini', 'openai', 'anthropic', 'local', 'deepl',
|
|
72
80
|
'google-translate', 'microsoft-translator', 'libretranslate',
|
|
73
81
|
]);
|
|
74
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Methods whose transport is chosen by `provider`. Every other method names
|
|
85
|
+
* its own engine, so a `provider` set on one of them is a config error.
|
|
86
|
+
*/
|
|
87
|
+
const PROVIDER_ROUTED_METHODS = new Set(['llm', 'llm-coached']);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve a pair's transport from its method and the `provider` settings.
|
|
91
|
+
*
|
|
92
|
+
* - `ownProvider` is set at the pair or language level; `globalProvider` at
|
|
93
|
+
* the top level. The more specific one wins.
|
|
94
|
+
* - Only llm / llm-coached are provider-routed. A top-level provider is a
|
|
95
|
+
* default for those and leaves other methods alone; a pair- or
|
|
96
|
+
* language-level provider on any other method is refused (unless it just
|
|
97
|
+
* restates the method, e.g. method "openai" + provider "openai").
|
|
98
|
+
* - Plain `llm` on a direct provider IS that provider's method (the direct
|
|
99
|
+
* methods build the same prompt), so it is rewritten to it. llm-coached
|
|
100
|
+
* keeps its method and dispatches on `provider` itself.
|
|
101
|
+
* - An unknown provider throws — it must never quietly ride OpenRouter.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} method - Method after method resolution
|
|
104
|
+
* @param {string|null|undefined} ownProvider - Pair/language-level provider
|
|
105
|
+
* @param {string|null|undefined} globalProvider - Top-level provider
|
|
106
|
+
* @returns {{ method: string, provider: string|null }}
|
|
107
|
+
*/
|
|
108
|
+
function resolveTransportForPair(method, ownProvider, globalProvider) {
|
|
109
|
+
const check = (raw) => {
|
|
110
|
+
if (raw == null) return null;
|
|
111
|
+
const p = normalizeProvider(raw);
|
|
112
|
+
if (!COACHED_PROVIDERS.includes(p)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Unknown provider "${raw}". Supported providers: ${COACHED_PROVIDERS.join(', ')}.`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return p;
|
|
118
|
+
};
|
|
119
|
+
const own = check(ownProvider);
|
|
120
|
+
const global = check(globalProvider);
|
|
121
|
+
|
|
122
|
+
if (!PROVIDER_ROUTED_METHODS.has(method)) {
|
|
123
|
+
if (own && own !== method) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`provider "${own}" cannot apply to method "${method}" — only llm and ` +
|
|
126
|
+
`llm-coached are routed by provider. Remove "provider", or use ` +
|
|
127
|
+
`method "${own === 'openrouter' ? 'llm' : own}".`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return { method, provider: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const provider = own ?? global ?? 'openrouter';
|
|
134
|
+
if (method === 'llm' && provider !== 'openrouter') {
|
|
135
|
+
return { method: provider, provider };
|
|
136
|
+
}
|
|
137
|
+
return { method, provider };
|
|
138
|
+
}
|
|
139
|
+
|
|
75
140
|
/**
|
|
76
141
|
* Resolve the model for a translation pair.
|
|
77
142
|
*
|
|
78
|
-
* - If the user explicitly set a model, use it
|
|
79
|
-
* - If the
|
|
80
|
-
*
|
|
81
|
-
*
|
|
143
|
+
* - If the user explicitly set a model for the pair/language, use it.
|
|
144
|
+
* - If the pair talks to a direct provider (a direct method, or llm-coached
|
|
145
|
+
* on a non-OpenRouter provider), the global default is an OpenRouter slug
|
|
146
|
+
* and would be wrong there. Use `providerModel` — the top-level `model`
|
|
147
|
+
* when it was written alongside a top-level `provider` for this very
|
|
148
|
+
* transport — else null so the method's _getDefaultModel() fires.
|
|
149
|
+
* - Otherwise (OpenRouter), use the global default.
|
|
82
150
|
*
|
|
83
151
|
* @param {string|null} explicitModel - Model from per-language or per-pair config
|
|
84
|
-
* @param {string} method - Translation method name
|
|
152
|
+
* @param {string} method - Translation method name (after transport resolution)
|
|
85
153
|
* @param {string} globalDefault - Global model from config (OpenRouter slug)
|
|
86
|
-
* @
|
|
154
|
+
* @param {string|null} [provider] - Resolved transport (null for non-routed methods)
|
|
155
|
+
* @param {string|null} [providerModel] - Top-level model that belongs to `provider`
|
|
156
|
+
* @returns {string|null} Resolved model, or null to use the method default
|
|
157
|
+
*/
|
|
158
|
+
function resolveModelForPair(explicitModel, method, globalDefault, provider = null, providerModel = null, from = null) {
|
|
159
|
+
if (explicitModel) return modelIdForTransport(explicitModel, method, provider, from);
|
|
160
|
+
if (DIRECT_PROVIDER_METHODS.has(method) || (provider && provider !== 'openrouter')) {
|
|
161
|
+
return modelIdForTransport(providerModel, method, provider, 'from the top-level "model"');
|
|
162
|
+
}
|
|
163
|
+
return modelIdForTransport(globalDefault, method, provider);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Transports that name models themselves (lib/methods/direct-llm.js resolveModelId). */
|
|
167
|
+
const MODEL_ID_TRANSPORTS = new Set(['openai', 'anthropic', 'gemini', 'local']);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The model id a pair's transport is sent, as written in the config or on
|
|
171
|
+
* --model:
|
|
172
|
+
* - OpenRouter (llm, llm-coached by default): an alias from
|
|
173
|
+
* shared/model-aliases.json becomes its full id ("gemini-flash" →
|
|
174
|
+
* "google/gemini-3.5-flash"). --model's alias used to reach OpenRouter
|
|
175
|
+
* unresolved, as "gemini-flash".
|
|
176
|
+
* - A direct provider (openai, anthropic, gemini — as a method or as the
|
|
177
|
+
* provider of llm-coached): its own name ("openai/gpt-5.5" → "gpt-5.5");
|
|
178
|
+
* an id it has no name for ("google/…" on openai) THROWS, naming a model
|
|
179
|
+
* that method can run. It used to be sent as is, and every request failed.
|
|
180
|
+
* - local, and a gateway set with OPENAI_API_BASE: as written.
|
|
181
|
+
* - Engines (deepl, api, …): as written (they run no model of ours).
|
|
182
|
+
*
|
|
183
|
+
* @param {string|null} model
|
|
184
|
+
* @param {string} method - Method after transport resolution
|
|
185
|
+
* @param {string|null} provider - Resolved transport (null for non-routed methods)
|
|
186
|
+
* @param {string|null} [from] - Where the id was set, for a refusal
|
|
187
|
+
* @returns {string|null}
|
|
188
|
+
* @throws {Error} code CHAMPOLLION_MODEL_ROUTE (the caller prefixes the pair)
|
|
189
|
+
*/
|
|
190
|
+
function modelIdForTransport(model, method, provider = null, from = null) {
|
|
191
|
+
if (!model) return model;
|
|
192
|
+
const transport = PROVIDER_ROUTED_METHODS.has(method) ? (provider || 'openrouter') : method;
|
|
193
|
+
if (transport === 'openrouter') return resolveModel(model);
|
|
194
|
+
if (MODEL_ID_TRANSPORTS.has(transport)) return new METHOD_REGISTRY[transport]().resolveModelId(model, { from });
|
|
195
|
+
return model;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* resolveModelForPair, with a refused model id naming the pair — the same
|
|
200
|
+
* shape as a refused provider. `from` says where an explicit id was set.
|
|
201
|
+
*/
|
|
202
|
+
function resolveModelNamed(where, from, explicitModel, method, globalDefault, provider, providerModel) {
|
|
203
|
+
try {
|
|
204
|
+
return resolveModelForPair(explicitModel, method, globalDefault, provider, providerModel, from);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.code === 'CHAMPOLLION_MODEL_ROUTE') err.message = `${where}: ${err.message}`;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Where a pair's explicit model id was written, for a refusal message:
|
|
213
|
+
* --model, else the first config field that set it (null = none set).
|
|
214
|
+
*
|
|
215
|
+
* @param {object} config
|
|
216
|
+
* @param {Array<[string, unknown]>} fields - [label, value] in precedence order
|
|
217
|
+
* @returns {string|null}
|
|
218
|
+
*/
|
|
219
|
+
function modelSource(config, fields) {
|
|
220
|
+
if (config._modelOverride) return 'from --model';
|
|
221
|
+
const set = fields.find(([, value]) => value);
|
|
222
|
+
return set ? `from ${set[0]}` : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Fields a pair's `fallback` may set. Everything else on a resolved fallback
|
|
227
|
+
* is inherited from its pair — the target language's name, register, script,
|
|
228
|
+
* coaching, prompt context — because those describe the LANGUAGE, and a
|
|
229
|
+
* fallback translates the same language for the same project.
|
|
230
|
+
*/
|
|
231
|
+
const FALLBACK_FIELDS = new Set([
|
|
232
|
+
'method', 'model', 'provider', 'endpoint', 'apiKey', 'methodPlugin', 'acceptsInstructions',
|
|
233
|
+
'register', 'temperature', 'coachingFile', 'coachingPrompt', 'promptContext',
|
|
234
|
+
'batchSize', 'maxRetries', 'qualityTier', 'contentSegmentation', 'name',
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/** Fields refused on a fallback, with the reason the error gives. */
|
|
238
|
+
const FALLBACK_REFUSED = {
|
|
239
|
+
fallback: 'a fallback cannot have its own fallback — one per pair',
|
|
240
|
+
script: 'the writing system belongs to the pair, not to one method — set "script" on the pair',
|
|
241
|
+
scriptFallback: 'transliteration rules belong to the pair — set "scriptFallback" on the pair',
|
|
242
|
+
source: 'a fallback translates its own pair — it has no source of its own',
|
|
243
|
+
target: 'a fallback translates its own pair — it has no target of its own',
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Fields whose value a fallback inherits from its pair unless it sets them. */
|
|
247
|
+
const FALLBACK_INHERITED = [
|
|
248
|
+
'register', 'temperature', 'coachingFile', 'coachingPrompt', 'promptContext',
|
|
249
|
+
'batchSize', 'maxRetries', 'qualityTier', 'contentSegmentation', 'name',
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Say a fallback note once per process: resolvePairs runs several times in
|
|
254
|
+
* one sync (the run, then verify), and the same warning twice is noise.
|
|
255
|
+
*/
|
|
256
|
+
const notedOnce = new Set();
|
|
257
|
+
function noteOnce(level, message) {
|
|
258
|
+
if (notedOnce.has(message)) return;
|
|
259
|
+
notedOnce.add(message);
|
|
260
|
+
output[level](message);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Throw, naming the pair, when `methodName` is not a method the CLI can run.
|
|
265
|
+
* getMethod() supplies the precise message (harness-only engine vs typo).
|
|
266
|
+
*/
|
|
267
|
+
function assertKnownMethod(methodName, where) {
|
|
268
|
+
if (METHOD_REGISTRY[methodName]) return;
|
|
269
|
+
try {
|
|
270
|
+
getMethod(methodName);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
throw new Error(`${where}: ${err.message}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read a coaching file a pair, a language or a fallback names for itself.
|
|
278
|
+
*
|
|
279
|
+
* WHY: only the TOP-LEVEL coachingFile was read (lib/config.js, into
|
|
280
|
+
* coachingPrompt). A language's, a pair's or a fallback's own coachingFile
|
|
281
|
+
* reached the plain LLM methods' prompt never — their prompt carries
|
|
282
|
+
* coachingPrompt only — and llm-coached only when no less specific level had
|
|
283
|
+
* coaching text (that text won). A `local` fallback given a coaching file
|
|
284
|
+
* ran uncoached, and its cache key did not change either (Round 10, school
|
|
285
|
+
* persona). The text read here is what the prompt carries and what the
|
|
286
|
+
* cache key fingerprints (lib/tm.js tmMethodKey), so an edit re-translates.
|
|
287
|
+
*
|
|
288
|
+
* Fails loud, naming where the file was named: a run never goes ahead
|
|
289
|
+
* uncoached when coaching was asked for (the llm-coached contract).
|
|
290
|
+
*
|
|
291
|
+
* @param {string} file - As written in the config
|
|
292
|
+
* @param {string} projectDir - What a relative path is relative to
|
|
293
|
+
* @param {string} where - e.g. 'en:crk: "fallback"'
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
function readOwnCoachingFile(file, projectDir, where) {
|
|
297
|
+
const resolved = path.isAbsolute(file) ? file : path.resolve(projectDir, file);
|
|
298
|
+
let text;
|
|
299
|
+
try {
|
|
300
|
+
text = fs.readFileSync(resolved, 'utf-8');
|
|
301
|
+
} catch (err) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`${where}: "coachingFile" ${JSON.stringify(file)} cannot be read (${err.code || err.message}; resolved to ${resolved}). `
|
|
304
|
+
+ 'Fix the path, or remove "coachingFile" — Champollion does not translate without the coaching a config asks for.'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
text = text.trim();
|
|
308
|
+
if (!text) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`${where}: "coachingFile" ${JSON.stringify(file)} (${resolved}) is empty. `
|
|
311
|
+
+ 'Write the coaching in it, or remove "coachingFile".'
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return text;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The coaching text the version before 2026-10 SENT for a pair (lib/tm.js
|
|
319
|
+
* legacyMethodKey reuses entries made with it only when it is the text sent
|
|
320
|
+
* now): the plain LLM methods sent `coachingPrompt` as it resolved then — a
|
|
321
|
+
* level's own coachingFile never; llm-coached that text, else its
|
|
322
|
+
* coachingFile's content.
|
|
323
|
+
*
|
|
324
|
+
* @param {object} pc - The pair (or fallback) with method, coachingFile, coachingPrompt
|
|
325
|
+
* @param {string|null} legacyPrompt - coachingPrompt as the old precedence resolved it
|
|
326
|
+
* @param {string|null} ownFile - The file this level names for itself (already in coachingPrompt)
|
|
327
|
+
* @param {string} projectDir
|
|
328
|
+
* @returns {string|null}
|
|
329
|
+
*/
|
|
330
|
+
function legacyCoachingSent(pc, legacyPrompt, ownFile, projectDir) {
|
|
331
|
+
if (PLAIN_LLM_METHODS.has(pc.method)) return legacyPrompt;
|
|
332
|
+
if (pc.method !== 'llm-coached') return pc.coachingPrompt ?? null;
|
|
333
|
+
if (typeof legacyPrompt === 'string' && legacyPrompt.trim()) return legacyPrompt;
|
|
334
|
+
if (typeof pc.coachingFile !== 'string' || !pc.coachingFile.trim()) return null;
|
|
335
|
+
if (ownFile && ownFile === pc.coachingFile) return pc.coachingPrompt ?? null;
|
|
336
|
+
try {
|
|
337
|
+
const resolved = path.isAbsolute(pc.coachingFile) ? pc.coachingFile : path.resolve(projectDir, pc.coachingFile);
|
|
338
|
+
return fs.readFileSync(resolved, 'utf-8').trim() || null;
|
|
339
|
+
} catch {
|
|
340
|
+
return null; // it failed then too (llm-coached refuses to run uncoached)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a pair's `fallback` into a full pair config.
|
|
346
|
+
*
|
|
347
|
+
* A fallback is the method that translates what the pair's own method could
|
|
348
|
+
* not translate safely: keys the quality gate refused or the method returned
|
|
349
|
+
* nothing for, Markdown blocks it dropped or damaged. It goes through the
|
|
350
|
+
* same machinery as a pair — resolveTransportForPair for `method`/`provider`,
|
|
351
|
+
* resolveModelForPair for `model` — so it is a complete pair config the sync
|
|
352
|
+
* pipeline can run unchanged: its own TM key, its own cost estimate, the
|
|
353
|
+
* same quality gate.
|
|
354
|
+
*
|
|
355
|
+
* `--method` / `--model` override the pair's OWN method only. The fallback
|
|
356
|
+
* resolves against what the config FILE set (config._fileModel,
|
|
357
|
+
* config._fileDefaultMethod, recorded by lib/config.js before the flags
|
|
358
|
+
* apply), so trying a different primary for one run never changes what
|
|
359
|
+
* catches its failures.
|
|
360
|
+
*
|
|
361
|
+
* @param {string} pairKey - e.g. "en:crk" (named in every error)
|
|
362
|
+
* @param {object} raw - The `fallback` object as written in the config
|
|
363
|
+
* @param {object} pair - The resolved pair it belongs to
|
|
364
|
+
* @param {object} config - Resolved config
|
|
365
|
+
* @param {string} [projectDir] - What a relative coachingFile is relative to
|
|
366
|
+
* @returns {object|null} Resolved fallback pair config, or null when a CLI
|
|
367
|
+
* override made the pair's own method identical to its fallback
|
|
368
|
+
* @throws {Error} On a malformed fallback — always naming the pair
|
|
369
|
+
*/
|
|
370
|
+
function resolveFallbackForPair(pairKey, raw, pair, config, projectDir = process.cwd()) {
|
|
371
|
+
const where = `${pairKey}: "fallback"`;
|
|
372
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${where} must be an object naming a method, e.g. "fallback": { "method": "llm-coached" } `
|
|
375
|
+
+ `(got ${JSON.stringify(raw)}).`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
for (const [field, why] of Object.entries(FALLBACK_REFUSED)) {
|
|
379
|
+
if (Object.prototype.hasOwnProperty.call(raw, field)) {
|
|
380
|
+
throw new Error(`${where} cannot set "${field}": ${why}.`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const unknown = Object.keys(raw).filter(k => !k.startsWith('_') && !FALLBACK_FIELDS.has(k));
|
|
384
|
+
if (unknown.length > 0) {
|
|
385
|
+
noteOnce('warn',
|
|
386
|
+
`${where}: field(s) ${unknown.map(k => `"${k}"`).join(', ')} have no effect on a fallback. `
|
|
387
|
+
+ `A fallback accepts: ${[...FALLBACK_FIELDS].join(', ')}.`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (typeof raw.method !== 'string' || raw.method.trim() === '') {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`${where} needs a "method" — the method that translates what the pair's own method `
|
|
393
|
+
+ `(${pair.method}) cannot, e.g. "fallback": { "method": "llm-coached" }.`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
assertKnownMethod(raw.method, where);
|
|
397
|
+
|
|
398
|
+
// The FILE's model and default method, not the --model/--method override.
|
|
399
|
+
const fileModel = (config._modelOverride ? config._fileModel : config.model) || DEFAULT_OPENROUTER_MODEL;
|
|
400
|
+
const fileModelExplicit = config._modelOverride ? !!config._fileModelExplicit : !!config._modelExplicit;
|
|
401
|
+
const fileDefaultMethod = config._methodOverride ? config._fileDefaultMethod : config.defaultMethod;
|
|
402
|
+
const globalProvider = config.provider == null ? null : normalizeProvider(config.provider);
|
|
403
|
+
|
|
404
|
+
let method;
|
|
405
|
+
let provider;
|
|
406
|
+
try {
|
|
407
|
+
({ method, provider } = resolveTransportForPair(raw.method, raw.provider, config.provider));
|
|
408
|
+
} catch (err) {
|
|
409
|
+
throw new Error(`${where}: ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
const providerModel = fileModelExplicit && (
|
|
412
|
+
(provider && provider !== 'openrouter' && provider === globalProvider)
|
|
413
|
+
|| (method === fileDefaultMethod && DIRECT_PROVIDER_METHODS.has(method))
|
|
414
|
+
) ? fileModel : null;
|
|
415
|
+
const model = resolveModelNamed(where, raw.model ? 'from the fallback\'s "model"' : 'from the top-level "model"', raw.model, method, fileModel, provider, providerModel);
|
|
416
|
+
|
|
417
|
+
// Start from the pair (the language's facts and settings), drop what
|
|
418
|
+
// belongs to the pair's own method, then apply the fallback's fields.
|
|
419
|
+
const fallback = { ...pair };
|
|
420
|
+
delete fallback.fallback;
|
|
421
|
+
const _defaults = new Set();
|
|
422
|
+
for (const field of FALLBACK_INHERITED) {
|
|
423
|
+
if (raw[field] != null) fallback[field] = raw[field];
|
|
424
|
+
else _defaults.add(field);
|
|
425
|
+
}
|
|
426
|
+
if (raw.model == null) _defaults.add('model');
|
|
427
|
+
if (raw.register != null) {
|
|
428
|
+
// Same preset-key rule as a pair-level register (see resolvePairs).
|
|
429
|
+
const card = getLanguageCard(pair.target);
|
|
430
|
+
fallback.registerPreset = card?.registers?.[raw.register] != null ? raw.register : null;
|
|
431
|
+
}
|
|
432
|
+
Object.assign(fallback, {
|
|
433
|
+
method,
|
|
434
|
+
provider,
|
|
435
|
+
model,
|
|
436
|
+
endpoint: raw.endpoint || null,
|
|
437
|
+
// Its own token, never the pair's: a fallback endpoint is another server.
|
|
438
|
+
apiKey: raw.apiKey || null,
|
|
439
|
+
methodPlugin: raw.methodPlugin || null,
|
|
440
|
+
// Its own declaration too (another endpoint): unknown unless stated.
|
|
441
|
+
acceptsInstructions: typeof raw.acceptsInstructions === 'boolean' ? raw.acceptsInstructions : null,
|
|
442
|
+
isFallback: true,
|
|
443
|
+
_defaults,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Its own coaching file: read, so its prompt and its cache key carry the
|
|
447
|
+
// text (readOwnCoachingFile). Its own coachingPrompt wins, as on a pair.
|
|
448
|
+
fallback._legacyCoachingPrompt = raw.coachingPrompt ?? pair._legacyCoachingPrompt ?? null;
|
|
449
|
+
if (raw.coachingFile != null && raw.coachingPrompt == null) {
|
|
450
|
+
fallback.coachingPrompt = readOwnCoachingFile(raw.coachingFile, projectDir, where);
|
|
451
|
+
}
|
|
452
|
+
fallback._legacyCoachingSent = legacyCoachingSent(
|
|
453
|
+
fallback, fallback._legacyCoachingPrompt, raw.coachingPrompt == null ? (raw.coachingFile ?? null) : null, projectDir,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// A fallback identical to its pair would only repeat the same translation.
|
|
457
|
+
// Identity is what the TM keys on (method, model — or an api pair's
|
|
458
|
+
// endpoint — register, coaching) plus the transport and temperature.
|
|
459
|
+
const sameAs = tmMethodKey(fallback) === tmMethodKey(pair)
|
|
460
|
+
&& ['provider', 'temperature'].every(f => (fallback[f] ?? null) === (pair[f] ?? null));
|
|
461
|
+
if (sameAs) {
|
|
462
|
+
if (config._methodOverride || config._modelOverride) {
|
|
463
|
+
noteOnce('info',
|
|
464
|
+
`${pairKey}: --method/--model made the pair's own method the same as its fallback `
|
|
465
|
+
+ `(${method}${model ? `, ${model}` : ''}) — no fallback this run.`
|
|
466
|
+
);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
throw new Error(
|
|
470
|
+
`${where} is the same method as the pair itself (${method}${model ? `, model ${model}` : ''}) — `
|
|
471
|
+
+ 'it would only repeat the same translation. Name a different method or model.'
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return fallback;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The gender guidance a pair's prompt carries, and where it comes from. The
|
|
479
|
+
* default is the catalogue's for the language (shared/catalogue/gender-
|
|
480
|
+
* guidance.json — French: écriture inclusive with the interpunct,
|
|
481
|
+
* "Connecté·e"); a config can replace it with its own instruction or turn it
|
|
482
|
+
* off with `false` — per pair, per language, or for every language. Never
|
|
483
|
+
* changed silently: absent means the catalogue's, as before (Round 8, Django
|
|
484
|
+
* persona: the French prompt asked for écriture inclusive and nothing showed it).
|
|
485
|
+
*
|
|
486
|
+
* @param {Array<*>} settings - The config values in precedence order (pair, language, global)
|
|
487
|
+
* @param {string|null} catalogue - The card's guidance (getLanguageCard().gender.inclusiveGuidance)
|
|
488
|
+
* @returns {{ genderGuidance: string|null, genderGuidanceSource: 'config'|'off'|'catalogue'|null }}
|
|
87
489
|
*/
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
490
|
+
function resolveGenderGuidance(settings, catalogue) {
|
|
491
|
+
for (const v of settings) {
|
|
492
|
+
if (v === false) return { genderGuidance: null, genderGuidanceSource: 'off' };
|
|
493
|
+
if (typeof v === 'string' && v.trim()) return { genderGuidance: v.trim(), genderGuidanceSource: 'config' };
|
|
494
|
+
}
|
|
495
|
+
return catalogue ? { genderGuidance: catalogue, genderGuidanceSource: 'catalogue' } : { genderGuidance: null, genderGuidanceSource: null };
|
|
92
496
|
}
|
|
93
497
|
|
|
94
498
|
/**
|
|
@@ -106,21 +510,52 @@ function resolveModelForPair(explicitModel, method, globalDefault) {
|
|
|
106
510
|
* - dir: text directionality ('ltr' or 'rtl')
|
|
107
511
|
* - scripts: available script conversions (if any)
|
|
108
512
|
* - endpoint: API endpoint URL for the bare "api" method (if set)
|
|
513
|
+
* - fallback: (only when configured) a full pair config for the second
|
|
514
|
+
* method that translates what this one cannot translate
|
|
515
|
+
* safely — see resolveFallbackForPair
|
|
109
516
|
*
|
|
110
517
|
* Pair keys use colon separator: "en:fr", "en:crk".
|
|
111
518
|
* Legacy arrow formats (en→fr, en->fr) in config.pairs are accepted
|
|
112
519
|
* by parsePairKey but stored internally in colon format.
|
|
113
520
|
*
|
|
114
521
|
* @param {import('./types.js').ChampollionConfig} config - Resolved config (post-migration, post-defaults)
|
|
522
|
+
* @param {{ cwd?: string }} [options] - cwd: the project directory a pair's,
|
|
523
|
+
* language's or fallback's own coachingFile is relative to (the directory
|
|
524
|
+
* resolveConfig read the config in; the CLI's working directory by default)
|
|
115
525
|
* @returns {Map<string, import('./types.js').PairConfig>} Pair graph
|
|
116
526
|
*/
|
|
117
|
-
function resolvePairs(config) {
|
|
527
|
+
function resolvePairs(config, { cwd = process.cwd() } = {}) {
|
|
118
528
|
const pairs = new Map();
|
|
119
529
|
const inputLocale = config.inputLocale;
|
|
120
530
|
const defaultModel = config.model || DEFAULT_OPENROUTER_MODEL;
|
|
121
531
|
const defaultBatchSize = config.batchSize || DEFAULT_BATCH_SIZE;
|
|
122
532
|
const defaultMethod = config.defaultMethod || PAIR_DEFAULTS.method;
|
|
123
533
|
|
|
534
|
+
// A top-level `model` written next to a top-level direct `provider` names a
|
|
535
|
+
// model ON that provider (the harness export-config shape). Without an
|
|
536
|
+
// explicit top-level model, config.model is the OpenRouter default slug and
|
|
537
|
+
// must never be sent to a direct provider.
|
|
538
|
+
const globalProvider = config.provider == null ? null : normalizeProvider(config.provider);
|
|
539
|
+
// An explicit global model belongs to the global transport: the global
|
|
540
|
+
// provider, or the default METHOD when that is a direct one. Without the
|
|
541
|
+
// second case, `defaultMethod: "local"` + `model: "stub-1"` ran every pair
|
|
542
|
+
// on the local method's fallback model (llama3.1) — and the cache key
|
|
543
|
+
// carried no model, so a model switch went unnoticed (synthetic review).
|
|
544
|
+
const providerModelFor = (provider, method = null) => (
|
|
545
|
+
config._modelExplicit && (
|
|
546
|
+
(provider && provider !== 'openrouter' && provider === globalProvider)
|
|
547
|
+
|| (method && method === config.defaultMethod && DIRECT_PROVIDER_METHODS.has(method))
|
|
548
|
+
)
|
|
549
|
+
? defaultModel
|
|
550
|
+
: null
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
// The UNRESOLVED method/provider/model each pair was configured with. Step 1
|
|
554
|
+
// may rewrite `llm` to a direct provider's method and fill the model from a
|
|
555
|
+
// default; a Step-2 override that changes the transport must re-derive from
|
|
556
|
+
// what the user wrote, not from those resolved values.
|
|
557
|
+
const rawSettings = new Map();
|
|
558
|
+
|
|
124
559
|
// Step 1: Build pairs from the `languages` array (simple mode)
|
|
125
560
|
const languages = config.resolvedLanguages || {};
|
|
126
561
|
for (const [code, langConfig] of Object.entries(languages)) {
|
|
@@ -138,13 +573,44 @@ function resolvePairs(config) {
|
|
|
138
573
|
// than explicitly set by the user. resolvePluginForPair uses this to
|
|
139
574
|
// let plugin config override defaults while respecting explicit settings.
|
|
140
575
|
const _defaults = new Set();
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
576
|
+
// A language's own model/provider belong to its own method: when
|
|
577
|
+
// --method overrides that method they go with it (--model still applies).
|
|
578
|
+
const langOverridden = Boolean(config._methodOverride && langConfig.method
|
|
579
|
+
&& langConfig.method !== config._methodOverride);
|
|
580
|
+
let method;
|
|
581
|
+
let provider;
|
|
582
|
+
try {
|
|
583
|
+
({ method, provider } = resolveTransportForPair(
|
|
584
|
+
config._methodOverride || langConfig.method || defaultMethod,
|
|
585
|
+
langOverridden ? null : langConfig.provider, config.provider,
|
|
586
|
+
));
|
|
587
|
+
} catch (err) {
|
|
588
|
+
err.message = `${pairKey}: ${err.message}`;
|
|
589
|
+
throw err;
|
|
590
|
+
}
|
|
591
|
+
const langModel = config._modelOverride || (langOverridden ? undefined : langConfig.model);
|
|
592
|
+
const model = resolveModelNamed(pairKey, modelSource(config, [[`languages.${code}.model`, langModel]]),
|
|
593
|
+
langModel, method, defaultModel, provider, providerModelFor(provider, method));
|
|
594
|
+
rawSettings.set(pairKey, {
|
|
595
|
+
method: config._methodOverride || langConfig.method || defaultMethod,
|
|
596
|
+
provider: langOverridden ? null : (langConfig.provider ?? null),
|
|
597
|
+
model: langModel || null,
|
|
598
|
+
// The language's `fallback`, resolved in Step 4 (after the script
|
|
599
|
+
// decision, which a fallback shares with its pair).
|
|
600
|
+
fallback: langConfig.fallback ?? null,
|
|
601
|
+
// The coaching this language names for itself (read in Step 3b).
|
|
602
|
+
coaching: langConfig.coachingFile != null || langConfig.coachingPrompt != null
|
|
603
|
+
? { file: langConfig.coachingFile ?? null, prompt: langConfig.coachingPrompt ?? null, where: `languages.${code}` }
|
|
604
|
+
: null,
|
|
605
|
+
});
|
|
606
|
+
if (!langModel) _defaults.add('model');
|
|
144
607
|
const batchSize = langConfig.batchSize || defaultBatchSize;
|
|
145
608
|
if (!langConfig.batchSize) _defaults.add('batchSize');
|
|
146
609
|
const register = langConfig.register || registerInfo.register || DEFAULT_REGISTER_FALLBACK;
|
|
147
610
|
if (!langConfig.register) _defaults.add('register');
|
|
611
|
+
// A language entry sets no quality tier: the pair's is the default
|
|
612
|
+
// label, not one anybody chose (status shows only a chosen one).
|
|
613
|
+
_defaults.add('qualityTier');
|
|
148
614
|
|
|
149
615
|
// Track fields from system defaults so plugins can override them.
|
|
150
616
|
// If the user didn't explicitly set these, they're defaults.
|
|
@@ -163,6 +629,9 @@ function resolvePairs(config) {
|
|
|
163
629
|
source: inputLocale,
|
|
164
630
|
target: code,
|
|
165
631
|
method,
|
|
632
|
+
// Transport for llm-coached (dispatches on it) and the record of which
|
|
633
|
+
// provider a rewritten `llm` pair runs on. null for engine methods.
|
|
634
|
+
provider,
|
|
166
635
|
model,
|
|
167
636
|
qualityTier: PAIR_DEFAULTS.qualityTier,
|
|
168
637
|
batchSize,
|
|
@@ -182,12 +651,21 @@ function resolvePairs(config) {
|
|
|
182
651
|
// APIMethod falls back to pairConfig.endpoint — dropping it during
|
|
183
652
|
// normalization made { method: "api", endpoint: … } configs unusable.
|
|
184
653
|
endpoint: langConfig.endpoint || null,
|
|
654
|
+
// The api endpoint's own token (lib/methods/api.js resolveApiMethodKey:
|
|
655
|
+
// "${VAR}" or a literal). Documented, but dropped here until 2026-10-03.
|
|
656
|
+
apiKey: langConfig.apiKey || null,
|
|
657
|
+
// Does the api endpoint follow per-key instructions? true / false as
|
|
658
|
+
// declared (pair or plugin manifest); null = unknown (lib/methods/api.js).
|
|
659
|
+
acceptsInstructions: typeof langConfig.acceptsInstructions === 'boolean' ? langConfig.acceptsInstructions : null,
|
|
185
660
|
// Structured formality info for method-specific behavior (e.g., DeepL)
|
|
186
661
|
formalitySystem: card?.formality?.system || null,
|
|
187
|
-
// Language-specific gender guidance for LLM prompts (e.g., écriture
|
|
188
|
-
|
|
662
|
+
// Language-specific gender guidance for LLM prompts (e.g., écriture
|
|
663
|
+
// inclusive for French) — the catalogue's, unless the config replaces
|
|
664
|
+
// or turns it off ("genderGuidance").
|
|
665
|
+
...resolveGenderGuidance([langConfig.genderGuidance, config.genderGuidance], card?.gender?.inclusiveGuidance || null),
|
|
189
666
|
// Global prompt context from config (e.g., "This is a developer tool README")
|
|
190
667
|
promptContext,
|
|
668
|
+
protectedTerms: config.protectedTerms || [],
|
|
191
669
|
// Temperature: per-language → global config → null (method picks its own default)
|
|
192
670
|
temperature,
|
|
193
671
|
// Coaching: coaching file path and resolved prompt text
|
|
@@ -233,19 +711,58 @@ function resolvePairs(config) {
|
|
|
233
711
|
const registerInfo = DEFAULT_REGISTERS[target] || {};
|
|
234
712
|
const existing = pairs.get(pairKey) || {};
|
|
235
713
|
const existingDefaults = existing._defaults || new Set();
|
|
714
|
+
const raw = rawSettings.get(pairKey) || {};
|
|
236
715
|
|
|
237
716
|
// _defaults: a field is "defaulted" if NEITHER the pairOverride NOR
|
|
238
717
|
// the existing pair set it explicitly. If pairOverride sets a field,
|
|
239
718
|
// it clears the default flag; if it falls through to existing, it
|
|
240
719
|
// inherits that pair's default tracking.
|
|
241
720
|
const _defaults = new Set();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
721
|
+
// Transport + model re-derive from the RAW settings (see rawSettings):
|
|
722
|
+
// re-resolving from existing.method/existing.model would treat a
|
|
723
|
+
// rewritten method or a defaulted model as if the user had written it.
|
|
724
|
+
// The pair's own model/provider belong to its own method: when
|
|
725
|
+
// --method overrides that method they go with it (--model still applies).
|
|
726
|
+
const pairOverridden = Boolean(config._methodOverride && pairOverride.method
|
|
727
|
+
&& pairOverride.method !== config._methodOverride);
|
|
728
|
+
let resolvedMethod;
|
|
729
|
+
let provider;
|
|
730
|
+
try {
|
|
731
|
+
({ method: resolvedMethod, provider } = resolveTransportForPair(
|
|
732
|
+
config._methodOverride || pairOverride.method || raw.method || defaultMethod,
|
|
733
|
+
pairOverridden ? null : (pairOverride.provider ?? raw.provider),
|
|
734
|
+
config.provider,
|
|
735
|
+
));
|
|
736
|
+
} catch (err) {
|
|
737
|
+
err.message = `${pairKey}: ${err.message}`;
|
|
738
|
+
throw err;
|
|
739
|
+
}
|
|
740
|
+
const pairModel = config._modelOverride
|
|
741
|
+
|| (pairOverridden ? undefined : (pairOverride.model || raw.model));
|
|
742
|
+
rawSettings.set(pairKey, {
|
|
743
|
+
method: config._methodOverride || pairOverride.method || raw.method || defaultMethod,
|
|
744
|
+
provider: pairOverridden ? null : (pairOverride.provider ?? raw.provider ?? null),
|
|
745
|
+
model: pairModel || null,
|
|
746
|
+
// A pair-level `fallback` replaces the language's; `null` removes it.
|
|
747
|
+
fallback: Object.prototype.hasOwnProperty.call(pairOverride, 'fallback')
|
|
748
|
+
? pairOverride.fallback
|
|
749
|
+
: (raw.fallback ?? null),
|
|
750
|
+
// The most specific level that names coaching (read in Step 3b).
|
|
751
|
+
coaching: pairOverride.coachingFile != null || pairOverride.coachingPrompt != null
|
|
752
|
+
? { file: pairOverride.coachingFile ?? null, prompt: pairOverride.coachingPrompt ?? null, where: `pairs["${rawPairKey}"]` }
|
|
753
|
+
: (raw.coaching ?? null),
|
|
754
|
+
});
|
|
755
|
+
const model = resolveModelNamed(
|
|
756
|
+
pairKey,
|
|
757
|
+
modelSource(config, [[`pairs["${rawPairKey}"].model`, pairModel && pairOverride.model], [`languages.${rawTarget}.model`, pairModel]]),
|
|
758
|
+
pairModel,
|
|
245
759
|
resolvedMethod,
|
|
246
|
-
defaultModel
|
|
760
|
+
defaultModel,
|
|
761
|
+
provider,
|
|
762
|
+
providerModelFor(provider, resolvedMethod),
|
|
247
763
|
);
|
|
248
764
|
if (!pairOverride.model && existingDefaults.has('model')) _defaults.add('model');
|
|
765
|
+
if (!pairOverride.qualityTier && (existingDefaults.has('qualityTier') || !existing.qualityTier)) _defaults.add('qualityTier');
|
|
249
766
|
if (!pairOverride.model && !existing.model) _defaults.add('model');
|
|
250
767
|
const batchSize = pairOverride.batchSize || existing.batchSize || defaultBatchSize;
|
|
251
768
|
if (!pairOverride.batchSize && existingDefaults.has('batchSize')) _defaults.add('batchSize');
|
|
@@ -285,6 +802,7 @@ function resolvePairs(config) {
|
|
|
285
802
|
source,
|
|
286
803
|
target,
|
|
287
804
|
method: resolvedMethod,
|
|
805
|
+
provider,
|
|
288
806
|
model,
|
|
289
807
|
qualityTier: pairOverride.qualityTier || existing.qualityTier || PAIR_DEFAULTS.qualityTier,
|
|
290
808
|
batchSize,
|
|
@@ -300,12 +818,21 @@ function resolvePairs(config) {
|
|
|
300
818
|
// Pair-level API endpoint for the bare "api" method (no plugin manifest).
|
|
301
819
|
// Preserved so APIMethod's documented pairConfig.endpoint fallback works.
|
|
302
820
|
endpoint: pairOverride.endpoint || existing.endpoint || null,
|
|
821
|
+
apiKey: pairOverride.apiKey || existing.apiKey || null,
|
|
822
|
+
acceptsInstructions: typeof pairOverride.acceptsInstructions === 'boolean'
|
|
823
|
+
? pairOverride.acceptsInstructions
|
|
824
|
+
: (typeof existing.acceptsInstructions === 'boolean' ? existing.acceptsInstructions : null),
|
|
303
825
|
// Plugin reference — the plugin loader will merge its config into this pair
|
|
304
826
|
methodPlugin: pairOverride.methodPlugin || null,
|
|
305
827
|
formalitySystem: card?.formality?.system || existing.formalitySystem || null,
|
|
306
|
-
genderGuidance
|
|
828
|
+
...(pairOverride.genderGuidance !== undefined && pairOverride.genderGuidance !== null
|
|
829
|
+
? resolveGenderGuidance([pairOverride.genderGuidance], card?.gender?.inclusiveGuidance || null)
|
|
830
|
+
: existing.genderGuidanceSource
|
|
831
|
+
? { genderGuidance: existing.genderGuidance || null, genderGuidanceSource: existing.genderGuidanceSource }
|
|
832
|
+
: resolveGenderGuidance([config.genderGuidance], card?.gender?.inclusiveGuidance || existing.genderGuidance || null)),
|
|
307
833
|
// Global prompt context flows from config into every pair
|
|
308
834
|
promptContext,
|
|
835
|
+
protectedTerms: config.protectedTerms || [],
|
|
309
836
|
temperature,
|
|
310
837
|
// Coaching: coaching file path and resolved prompt text
|
|
311
838
|
coachingFile,
|
|
@@ -343,6 +870,32 @@ function resolvePairs(config) {
|
|
|
343
870
|
}
|
|
344
871
|
}
|
|
345
872
|
|
|
873
|
+
// Step 3b: Coaching text — the most specific level that names coaching
|
|
874
|
+
// (pair, then language, then the top level) decides it: its inline
|
|
875
|
+
// coachingPrompt, else its own coachingFile, read here (readOwnCoachingFile).
|
|
876
|
+
// A language's or pair's coachingFile used to lose to a less specific
|
|
877
|
+
// level's text, and never reached a plain LLM method at all.
|
|
878
|
+
for (const [pairKey, pc] of pairs) {
|
|
879
|
+
const own = rawSettings.get(pairKey)?.coaching || null;
|
|
880
|
+
pc._legacyCoachingPrompt = pc.coachingPrompt ?? null;
|
|
881
|
+
const ownFile = own && own.prompt == null && own.file != null ? own.file : null;
|
|
882
|
+
if (ownFile) {
|
|
883
|
+
pc.coachingPrompt = readOwnCoachingFile(ownFile, cwd, `${pairKey}: ${own.where}`);
|
|
884
|
+
pc._defaults?.delete('coachingPrompt');
|
|
885
|
+
}
|
|
886
|
+
pc._legacyCoachingSent = legacyCoachingSent(pc, pc._legacyCoachingPrompt, ownFile, cwd);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Step 4: Fallbacks — the second method for what a pair's own method
|
|
890
|
+
// cannot translate safely. Resolved last so a fallback inherits the
|
|
891
|
+
// pair's final settings, script decision included. Errors name the pair.
|
|
892
|
+
for (const [pairKey, pc] of pairs) {
|
|
893
|
+
const rawFallback = rawSettings.get(pairKey)?.fallback;
|
|
894
|
+
if (rawFallback == null) continue;
|
|
895
|
+
const fallback = resolveFallbackForPair(pairKey, rawFallback, pc, config, cwd);
|
|
896
|
+
if (fallback) pc.fallback = fallback;
|
|
897
|
+
}
|
|
898
|
+
|
|
346
899
|
return pairs;
|
|
347
900
|
}
|
|
348
901
|
|
|
@@ -511,9 +1064,11 @@ function getPairForTarget(pairs, targetCode) {
|
|
|
511
1064
|
*
|
|
512
1065
|
* @param {number} keyCount - Number of keys to translate
|
|
513
1066
|
* @param {object} pairConfig - Pair config with method and model
|
|
1067
|
+
* @param {{ cwd?: string }} [context] - cwd: the project directory (a local
|
|
1068
|
+
* method's endpoint may be set in its .env)
|
|
514
1069
|
* @returns {{ estimatedCost: number|null, currency: string, source: string, note: string }}
|
|
515
1070
|
*/
|
|
516
|
-
async function estimateCost(keyCount, pairConfig) {
|
|
1071
|
+
async function estimateCost(keyCount, pairConfig, context = {}) {
|
|
517
1072
|
const methodName = pairConfig.method || 'llm';
|
|
518
1073
|
|
|
519
1074
|
// Delegate to the method's own cost estimate.
|
|
@@ -522,7 +1077,7 @@ async function estimateCost(keyCount, pairConfig) {
|
|
|
522
1077
|
// LLM varies by model, API is server-determined.
|
|
523
1078
|
try {
|
|
524
1079
|
const method = getMethod(methodName);
|
|
525
|
-
return await method.estimateCost(keyCount, pairConfig);
|
|
1080
|
+
return await method.estimateCost(keyCount, pairConfig, context);
|
|
526
1081
|
} catch {
|
|
527
1082
|
// If method resolution fails, return an honest "unknown"
|
|
528
1083
|
return {
|
|
@@ -536,6 +1091,7 @@ async function estimateCost(keyCount, pairConfig) {
|
|
|
536
1091
|
|
|
537
1092
|
export {
|
|
538
1093
|
resolvePairs,
|
|
1094
|
+
resolveFallbackForPair,
|
|
539
1095
|
parsePairKey,
|
|
540
1096
|
buildPairKey,
|
|
541
1097
|
filterPairGraph,
|