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/translate-pair.js
CHANGED
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
* return byte-identical output)
|
|
14
14
|
* 4. TM store: cache only gate-validated API translations
|
|
15
15
|
*
|
|
16
|
+
* ICU MESSAGES get prompt guidance (keep the syntax; the target language's
|
|
17
|
+
* CLDR plural categories) and the gate's structure check (lib/validate.js).
|
|
18
|
+
* A TM hit that fails the gate is evicted where it actually lives — with
|
|
19
|
+
* model carry-over that can be another model's entry (lib/tm-evict.js).
|
|
20
|
+
*
|
|
21
|
+
* KEYS THE MODEL MUST NOT SEE. gettext keys are msgids — sentences, some
|
|
22
|
+
* with newlines, some carrying a msgctxt behind a U+0004. A model asked to
|
|
23
|
+
* "keep the keys exactly as-is" reliably mangles those, so such keys travel
|
|
24
|
+
* under short aliases and are mapped back; ordinary dotted keys are sent as
|
|
25
|
+
* they are (their names are prompt context).
|
|
26
|
+
*
|
|
16
27
|
* ORDER MATTERS: the TM must only ever hold gate-validated values. An
|
|
17
28
|
* earlier version stored API output before validation; one degenerate
|
|
18
29
|
* response (e.g. "for translating" → "吗") then poisoned the cache — every
|
|
@@ -23,10 +34,139 @@
|
|
|
23
34
|
* those details differ between sync paths.
|
|
24
35
|
*/
|
|
25
36
|
|
|
26
|
-
import { translateBatch } from './translate.js';
|
|
27
|
-
import { validateTranslations, logGateFailures } from './validate.js';
|
|
28
|
-
import { partitionByTM, storeTM,
|
|
37
|
+
import { translateBatch, getMethod } from './translate.js';
|
|
38
|
+
import { validateTranslations, logGateFailures, sharedOutputReason, sharedOutputItems } from './validate.js';
|
|
39
|
+
import { partitionByTM, storeTM, tmMethodKey, lookupTM, servingMethodKey } from './tm.js';
|
|
40
|
+
import { tmSourceText, createTMEvictor } from './tm-evict.js';
|
|
41
|
+
import { icuGuidance, pluralGaps, describeCategories } from './icu-structure.js';
|
|
29
42
|
import { output } from './output.js';
|
|
43
|
+
import { captureRequests } from './methods/request-capture.js';
|
|
44
|
+
import { refusalCategory, notePrimaryReason } from './refusal-category.js';
|
|
45
|
+
|
|
46
|
+
/** A key the model should not be shown: control characters, or a whole paragraph. */
|
|
47
|
+
function needsAlias(key) {
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
return /[\u0000-\u001f\u007f]/.test(key) || key.length > 120;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Accepted plural messages that lack CLDR categories the target uses
|
|
54
|
+
* (lib/icu-structure.js pluralGaps), keyed by key.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} values - key → accepted translation
|
|
57
|
+
* @param {object} sourceFlat
|
|
58
|
+
* @param {string} targetCode
|
|
59
|
+
* @returns {Object<string, { everyday: string[], rare: string[], type: string }>}
|
|
60
|
+
*/
|
|
61
|
+
function pluralGapsOf(values, sourceFlat, targetCode, slots = null) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const [k, v] of Object.entries(values)) {
|
|
64
|
+
if (typeof sourceFlat[k] !== 'string' || !sourceFlat[k].includes('{') || typeof v !== 'string') continue;
|
|
65
|
+
const gaps = pluralGaps(sourceFlat[k], v, targetCode, slots);
|
|
66
|
+
if (gaps.length === 0) continue;
|
|
67
|
+
out[k] = {
|
|
68
|
+
everyday: [...new Set(gaps.flatMap(g => g.everyday))],
|
|
69
|
+
rare: [...new Set(gaps.flatMap(g => g.rare))],
|
|
70
|
+
type: gaps[0].type,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* translateBatch, with unpromptable keys (see header) sent under aliases
|
|
78
|
+
* and mapped back. Descriptions follow their keys.
|
|
79
|
+
*/
|
|
80
|
+
async function translateBatchAliased(keys, sourceFlat, pairConfig, options) {
|
|
81
|
+
if (!keys.some(needsAlias)) return translateBatch(keys, sourceFlat, pairConfig, options);
|
|
82
|
+
const taken = new Set(keys);
|
|
83
|
+
const toAlias = new Map();
|
|
84
|
+
const fromAlias = new Map();
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
if (!needsAlias(key)) continue;
|
|
88
|
+
let alias;
|
|
89
|
+
do { alias = `msg_${++n}`; } while (taken.has(alias) || Object.prototype.hasOwnProperty.call(sourceFlat, alias));
|
|
90
|
+
toAlias.set(key, alias);
|
|
91
|
+
fromAlias.set(alias, key);
|
|
92
|
+
}
|
|
93
|
+
const aliasedKeys = keys.map(k => toAlias.get(k) ?? k);
|
|
94
|
+
const aliasedSource = { ...sourceFlat };
|
|
95
|
+
for (const [key, alias] of toAlias) aliasedSource[alias] = sourceFlat[key];
|
|
96
|
+
let descriptions = options.descriptions;
|
|
97
|
+
if (descriptions) {
|
|
98
|
+
descriptions = { ...descriptions };
|
|
99
|
+
for (const [key, alias] of toAlias) {
|
|
100
|
+
if (descriptions[key] !== undefined) descriptions[alias] = descriptions[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const result = await translateBatch(aliasedKeys, aliasedSource, pairConfig, { ...options, descriptions });
|
|
104
|
+
if (!result) return result;
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [k, v] of Object.entries(result)) out[fromAlias.get(k) ?? k] = v;
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The per-key notes the method is given: the caller's (Docusaurus
|
|
112
|
+
* descriptions, i18next plural forms, ARB descriptions, gettext msgctxt and
|
|
113
|
+
* `#.` comments) plus, for an ICU plural/select message, what is syntax and
|
|
114
|
+
* which plural categories the target uses. ONE builder for the real call and
|
|
115
|
+
* for `sync --dry --show-prompt`, so the preview is what is sent.
|
|
116
|
+
*
|
|
117
|
+
* @returns {object|null}
|
|
118
|
+
*/
|
|
119
|
+
function buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions) {
|
|
120
|
+
let promptDescriptions = descriptions || null;
|
|
121
|
+
let ownCopy = false;
|
|
122
|
+
for (const k of stringKeys) {
|
|
123
|
+
const guidance = icuGuidance(sourceFlat[k], targetCode, pairConfig.name || targetCode);
|
|
124
|
+
if (!guidance) continue;
|
|
125
|
+
if (!ownCopy) { promptDescriptions = { ...(descriptions || {}) }; ownCopy = true; }
|
|
126
|
+
promptDescriptions[k] = promptDescriptions[k] ? `${promptDescriptions[k]} — ${guidance}` : guidance;
|
|
127
|
+
}
|
|
128
|
+
return promptDescriptions;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The options translateBatch gets — shared by the real call and the preview.
|
|
133
|
+
* `cwd` is the PROJECT directory: methods read their key (.env, .env.local),
|
|
134
|
+
* endpoint, coaching and glossary from it. Without it they fell back to
|
|
135
|
+
* process.cwd(), which is the project only when the caller runs there (the
|
|
136
|
+
* MCP server had to swap process.cwd() for the call).
|
|
137
|
+
*/
|
|
138
|
+
function buildBatchOptions(pairConfig, apiKey, promptDescriptions, onProgress = null, cwd = null) {
|
|
139
|
+
return {
|
|
140
|
+
apiKey,
|
|
141
|
+
...(cwd && { cwd }),
|
|
142
|
+
model: pairConfig.model,
|
|
143
|
+
batchSize: pairConfig.batchSize,
|
|
144
|
+
onProgress,
|
|
145
|
+
// Docusaurus passes descriptions for disambiguation context
|
|
146
|
+
...(promptDescriptions && { descriptions: promptDescriptions }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The exact request(s) the pair's method would send for these keys — built
|
|
152
|
+
* by the method's own code and handed over at its transport, never sent
|
|
153
|
+
* (lib/methods/request-capture.js). Nothing is read from or written to the
|
|
154
|
+
* cache: this shows the request a cache miss produces.
|
|
155
|
+
*
|
|
156
|
+
* @param {string[]} stringKeys
|
|
157
|
+
* @param {object} sourceFlat
|
|
158
|
+
* @param {object} pairConfig
|
|
159
|
+
* @param {{ apiKey?: string|null, targetCode: string, descriptions?: object|null }} options
|
|
160
|
+
* @returns {Promise<{ supported: boolean, requests: Array<{ url: string, method: string, headers: object, body: unknown }> }>}
|
|
161
|
+
*/
|
|
162
|
+
export async function previewRequests(stringKeys, sourceFlat, pairConfig, { apiKey = null, targetCode, descriptions = null, cwd = null } = {}) {
|
|
163
|
+
const method = getMethod(pairConfig.method || 'llm', pairConfig);
|
|
164
|
+
if (!method.supportsRequestPreview) return { supported: false, requests: [] };
|
|
165
|
+
const promptDescriptions = buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions);
|
|
166
|
+
const batchOptions = buildBatchOptions(pairConfig, apiKey, promptDescriptions, null, cwd);
|
|
167
|
+
const requests = await captureRequests(() => translateBatchAliased(stringKeys, sourceFlat, pairConfig, batchOptions));
|
|
168
|
+
return { supported: true, requests };
|
|
169
|
+
}
|
|
30
170
|
|
|
31
171
|
/**
|
|
32
172
|
* Translate a set of string keys through the TM + API + quality gate pipeline.
|
|
@@ -40,7 +180,18 @@ import { output } from './output.js';
|
|
|
40
180
|
* @param {object} options.tm - Translation Memory object (mutable — entries are stored in-place)
|
|
41
181
|
* @param {string} options.targetCode - Target language code
|
|
42
182
|
* @param {object} [options.descriptions] - Optional key descriptions (Docusaurus format)
|
|
183
|
+
* @param {string} [options.cwd] - The project directory: where the method reads
|
|
184
|
+
* its key (.env / .env.local), endpoint, coaching and glossary. Omitted:
|
|
185
|
+
* process.cwd()
|
|
43
186
|
* @param {Function} [options.onProgress] - Progress callback: (completed, total) => void
|
|
187
|
+
* @param {Object<string, string>} [options.pluralForms] - Borrowed i18next
|
|
188
|
+
* plural forms (lib/plurals.js `borrowed`): key → form; each is cached
|
|
189
|
+
* under its own identity (lib/tm-evict.js tmSourceText)
|
|
190
|
+
* @param {Set<string>} [options.noSendKeys] - Keys the method must NOT be
|
|
191
|
+
* asked for (lib/locale-state.js: refused before). The cache still serves
|
|
192
|
+
* them; what it does not hold comes back in `heldKeys`, unsent and unbilled.
|
|
193
|
+
* @param {import('./validate.js').SharedOutputIndex} [options.sharedOutputs] -
|
|
194
|
+
* The locale's different-inputs-same-output index (accepted values are added)
|
|
44
195
|
* @returns {Promise<TranslateResult>}
|
|
45
196
|
*
|
|
46
197
|
* @typedef {object} TranslateResult
|
|
@@ -49,25 +200,55 @@ import { output } from './output.js';
|
|
|
49
200
|
* @property {Array} failures - Quality gate failures (for caller logging)
|
|
50
201
|
* @property {boolean} apiCalled - Whether the API was actually invoked
|
|
51
202
|
* @property {boolean} apiReturnedNull - Whether the API was called but returned null
|
|
203
|
+
* @property {number} sentCount - Keys sent to the method (TM misses) — what the
|
|
204
|
+
* run asks a model/API for, as opposed to tmHitCount served from the cache
|
|
205
|
+
* @property {number} retriedCount - Keys sent a second time with the gate's feedback
|
|
206
|
+
* @property {Object<string, { everyday: string[], rare: string[], type: string }>} pluralGaps -
|
|
207
|
+
* Accepted plural messages that lack CLDR categories the target uses
|
|
208
|
+
* (lib/icu-structure.js pluralGaps), keyed by key — the caller reports them
|
|
209
|
+
* @property {string[]} sentKeys - Keys sent to the method (TM misses not held back)
|
|
210
|
+
* @property {string[]} answeredKeys - Accepted keys whose value is the method's
|
|
211
|
+
* own answer this run (not served from the cache)
|
|
212
|
+
* @property {string[]} heldKeys - noSendKeys the cache did not hold: not sent, not translated
|
|
213
|
+
* @property {string[]} refusedKeys - Final failures the METHOD produced (it was
|
|
214
|
+
* asked and the gate refused its answer) — what a refusal record remembers
|
|
52
215
|
*/
|
|
53
216
|
export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, options) {
|
|
54
217
|
const { apiKey, tm, targetCode, descriptions } = options;
|
|
55
218
|
const method = pairConfig.method || 'llm';
|
|
219
|
+
const noSend = options.noSendKeys instanceof Set ? options.noSendKeys : new Set();
|
|
220
|
+
const sharedOutputs = options.sharedOutputs || null;
|
|
56
221
|
|
|
57
222
|
// TM entries are keyed on the FULL method key (method|model|register|coaching),
|
|
58
223
|
// not the bare method name: switching model, register, or coaching must be a
|
|
59
224
|
// cache miss, never a silent re-serve of old-style translations. See tm.js.
|
|
60
225
|
const tmKey = tmMethodKey(pairConfig);
|
|
61
226
|
|
|
227
|
+
// The text each key is cached under: its source text, with a gettext
|
|
228
|
+
// msgctxt folded in (lib/tm-evict.js) so two contexts never share an entry.
|
|
229
|
+
// A borrowed i18next plural form (options.pluralForms: key → form) is
|
|
230
|
+
// cached under its own text, apart from the form it borrows from.
|
|
231
|
+
const pluralForms = options.pluralForms || null;
|
|
232
|
+
const tmText = {};
|
|
233
|
+
for (const k of stringKeys) {
|
|
234
|
+
if (typeof sourceFlat[k] === 'string') tmText[k] = tmSourceText(k, sourceFlat[k], pluralForms?.[k] || null);
|
|
235
|
+
}
|
|
236
|
+
const evictor = createTMEvictor(tm);
|
|
237
|
+
|
|
238
|
+
const promptDescriptions = buildPromptDescriptions(stringKeys, sourceFlat, pairConfig, targetCode, descriptions);
|
|
239
|
+
|
|
62
240
|
// Step 1: TM partition — serve cached hits, identify API misses
|
|
63
241
|
const { hits: tmHits, misses: tmMisses } = partitionByTM(
|
|
64
|
-
tm,
|
|
242
|
+
tm, tmText, stringKeys, targetCode, tmKey
|
|
65
243
|
);
|
|
66
244
|
|
|
67
245
|
const tmHitCount = Object.keys(tmHits).length;
|
|
68
246
|
if (tmHitCount > 0) {
|
|
69
247
|
output.info(`[TM] ${tmHitCount} key(s) served from cache`);
|
|
70
248
|
}
|
|
249
|
+
// The entry each hit was served from (exact, or another model's).
|
|
250
|
+
const tmHitBy = {};
|
|
251
|
+
for (const k of Object.keys(tmHits)) tmHitBy[k] = servingMethodKey(tm, tmText[k], targetCode, tmKey) || tmKey;
|
|
71
252
|
|
|
72
253
|
// Start with TM hits as the base
|
|
73
254
|
const translated = { ...tmHits };
|
|
@@ -79,28 +260,26 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
79
260
|
// non-API (TM-served) failures need cache eviction.
|
|
80
261
|
const apiKeys = new Set();
|
|
81
262
|
|
|
82
|
-
const batchOptions =
|
|
83
|
-
apiKey,
|
|
84
|
-
model: pairConfig.model,
|
|
85
|
-
batchSize: pairConfig.batchSize,
|
|
86
|
-
onProgress: options.onProgress || null,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// Docusaurus passes descriptions for disambiguation context
|
|
90
|
-
if (descriptions) {
|
|
91
|
-
batchOptions.descriptions = descriptions;
|
|
92
|
-
}
|
|
263
|
+
const batchOptions = buildBatchOptions(pairConfig, apiKey, promptDescriptions, options.onProgress || null, options.cwd || null);
|
|
93
264
|
|
|
94
|
-
// Step 2: API call for misses
|
|
95
|
-
|
|
96
|
-
|
|
265
|
+
// Step 2: API call for misses — except keys held back (refused before by
|
|
266
|
+
// this method; lib/locale-state.js). They stay untranslated, unbilled.
|
|
267
|
+
const toSend = tmMisses.filter(k => !noSend.has(k));
|
|
268
|
+
const heldKeys = tmMisses.filter(k => noSend.has(k));
|
|
269
|
+
let sentCount = 0;
|
|
270
|
+
let retriedCount = 0;
|
|
271
|
+
// Keys whose current value the METHOD produced (initial call or retry).
|
|
272
|
+
const askedKeys = new Set();
|
|
273
|
+
if (toSend.length > 0) {
|
|
274
|
+
sentCount = toSend.length;
|
|
275
|
+
output.progress(` Translating ${toSend.length} key(s) to ${pairConfig.name} (${method})...`);
|
|
97
276
|
|
|
98
|
-
const apiResult = await
|
|
277
|
+
const apiResult = await translateBatchAliased(toSend, sourceFlat, pairConfig, batchOptions);
|
|
99
278
|
apiCalled = true;
|
|
100
279
|
|
|
101
280
|
if (apiResult) {
|
|
102
281
|
Object.assign(translated, apiResult);
|
|
103
|
-
for (const k of Object.keys(apiResult)) apiKeys.add(k);
|
|
282
|
+
for (const k of Object.keys(apiResult)) { apiKeys.add(k); askedKeys.add(k); }
|
|
104
283
|
} else {
|
|
105
284
|
apiReturnedNull = true;
|
|
106
285
|
}
|
|
@@ -115,14 +294,74 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
115
294
|
failures = result.failures;
|
|
116
295
|
}
|
|
117
296
|
|
|
297
|
+
// Different inputs, same output (lib/validate.js SharedOutputIndex): one
|
|
298
|
+
// text answering 3+ different source strings — a memorized sentence. A
|
|
299
|
+
// soft failure: the retry asks again, then the fallback; never accepted
|
|
300
|
+
// silently. Checked against what this locale accepted earlier in the run.
|
|
301
|
+
// Counted over EVERY answer the method gave (`pool`), not only the ones
|
|
302
|
+
// that passed the other checks: a third copy refused for a lost
|
|
303
|
+
// placeholder is still a third copy, and its two siblings must not pass
|
|
304
|
+
// (Round 5, hospital persona). ICU branches count one by one
|
|
305
|
+
// (validate.js sharedOutputItems). Only keys in `vals` are failed.
|
|
306
|
+
const sharedFailure = (vals, pool = vals) => {
|
|
307
|
+
if (!sharedOutputs) return [];
|
|
308
|
+
const items = Object.entries(pool)
|
|
309
|
+
.flatMap(([k, v]) => (typeof v === 'string' && typeof sourceFlat[k] === 'string' ? sharedOutputItems(k, sourceFlat[k], v) : []));
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const [k, group] of sharedOutputs.suspects(items)) {
|
|
312
|
+
if (!(k in vals)) continue;
|
|
313
|
+
out.push({ key: k, reason: sharedOutputReason(group), value: vals[k], sharedOutput: true });
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
};
|
|
317
|
+
for (const f of sharedFailure(validated, translated)) {
|
|
318
|
+
delete validated[f.key];
|
|
319
|
+
failures.push(f);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A TM-served short Latin-script value was already settled as a name by
|
|
323
|
+
// an earlier run (that is how it got into the TM). Re-asking would evict
|
|
324
|
+
// it and re-bill the same answer on every sync — accept it as confirmed.
|
|
325
|
+
// Same for a TM-served plural message without some plural forms: it was
|
|
326
|
+
// accepted (after one corrective ask) by the run that cached it. Serving
|
|
327
|
+
// it is free; re-asking would bill every sync. It is still reported
|
|
328
|
+
// (pluralGaps below), with the command that asks again.
|
|
329
|
+
failures = failures.filter((f) => {
|
|
330
|
+
if ((f.nameOrLabel || f.pluralGap) && f.key in tmHits && !apiKeys.has(f.key)) {
|
|
331
|
+
validated[f.key] = translated[f.key];
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
return true;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// A method that reads no per-key instructions (a machine translation
|
|
338
|
+
// engine) cannot be told which plural forms to add: asking again would
|
|
339
|
+
// bill the same answer. Accept the message as it is; it is reported.
|
|
340
|
+
const takesInstructions = (() => {
|
|
341
|
+
try { return getMethod(method, pairConfig).acceptsKeyInstructions === true; } catch { return false; }
|
|
342
|
+
})();
|
|
343
|
+
if (!takesInstructions) {
|
|
344
|
+
failures = failures.filter((f) => {
|
|
345
|
+
if (f.pluralGap) { validated[f.key] = translated[f.key]; return false; }
|
|
346
|
+
return true;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
118
350
|
// Step 3a: Evict poisoned TM entries. A TM-served value that fails the
|
|
119
351
|
// gate would otherwise be re-served (and re-fail) on every future sync
|
|
120
|
-
// without the API ever being consulted again.
|
|
352
|
+
// without the API ever being consulted again. Evicted where it LIVES: a
|
|
353
|
+
// model-carry-over hit is another model's entry (lib/tm-evict.js).
|
|
121
354
|
for (const f of failures) {
|
|
122
|
-
if (f.key in tmHits && !apiKeys.has(f.key) && typeof
|
|
123
|
-
|
|
355
|
+
if (f.key in tmHits && !apiKeys.has(f.key) && typeof tmText[f.key] === 'string') {
|
|
356
|
+
evictor.evictProducing(tmText[f.key], targetCode, tmHits[f.key], [tmKey]);
|
|
124
357
|
}
|
|
125
358
|
}
|
|
359
|
+
// A held-back key whose cached value the gate refused is not sent either:
|
|
360
|
+
// it is held, not retried (a paid call is exactly what holding back stops).
|
|
361
|
+
failures = failures.filter((f) => {
|
|
362
|
+
if (noSend.has(f.key) && !apiKeys.has(f.key)) { heldKeys.push(f.key); return false; }
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
126
365
|
|
|
127
366
|
// Step 3b: Feedback retry — one corrective round for gate failures.
|
|
128
367
|
// The rejection reason is injected as per-key context so the prompt
|
|
@@ -135,24 +374,84 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
135
374
|
// evicted by the gate (step 3a) was only re-billed on the NEXT sync — a
|
|
136
375
|
// two-pass heal nobody asked for. A method that genuinely cannot run
|
|
137
376
|
// returns null here and the failures simply stand.
|
|
377
|
+
// Keys whose ONLY problem was a missing plural form: if the retry does not
|
|
378
|
+
// improve on them (no answer, or one refused for something else), their
|
|
379
|
+
// first answer stands — see below.
|
|
380
|
+
const pluralGapKeys = new Set(failures.filter(f => f.pluralGap).map(f => f.key));
|
|
138
381
|
if (failures.length > 0) {
|
|
139
382
|
const retryKeys = failures
|
|
140
383
|
.map(f => f.key)
|
|
141
|
-
.filter(k => typeof sourceFlat[k] === 'string');
|
|
384
|
+
.filter(k => typeof sourceFlat[k] === 'string' && !noSend.has(k));
|
|
142
385
|
|
|
143
|
-
|
|
144
|
-
|
|
386
|
+
// What the second ask can carry. An LLM method reads the gate's
|
|
387
|
+
// feedback; a machine-translation engine or an `api` endpoint gets the
|
|
388
|
+
// same text again (the api contract carries the source text — and the
|
|
389
|
+
// feedback only when the endpoint declares "acceptsInstructions": true).
|
|
390
|
+
// An endpoint that declares it follows no instructions (a trained NMT
|
|
391
|
+
// model) would answer the same: it is not asked again — its first
|
|
392
|
+
// answers are judged as a second answer would be (a name kept as written
|
|
393
|
+
// is accepted), and the rest go to the fallback (Round 4 school persona).
|
|
394
|
+
const declaredDeaf = pairConfig.acceptsInstructions === false;
|
|
395
|
+
// Why each key was refused, under the line that says it was — the line
|
|
396
|
+
// alone said "rejected 1 key(s)" and nothing about why (Round 5, Django
|
|
397
|
+
// persona). A key that passes on the second ask is never in the final
|
|
398
|
+
// [GATE] block, so this is the only place its reason is printed.
|
|
399
|
+
const reasonLines = () => {
|
|
400
|
+
const byKey = new Map(failures.filter(f => retryKeys.includes(f.key)).map(f => [f.key, f.reason]));
|
|
401
|
+
const shown = [...byKey].slice(0, 10).map(([k, r]) => ` - ${k}: ${r}`);
|
|
402
|
+
if (byKey.size > 10) shown.push(` … and ${byKey.size - 10} more`);
|
|
403
|
+
for (const line of shown) output.progress(line);
|
|
404
|
+
};
|
|
405
|
+
if (retryKeys.length > 0 && declaredDeaf) {
|
|
406
|
+
output.progress(` ${pairKey}: quality gate rejected ${retryKeys.length} key(s) — not asked again: `
|
|
407
|
+
+ `${method === 'api' ? 'the endpoint' : method} declares it does not follow instructions, so it would return the same text`
|
|
408
|
+
+ `${pairConfig.fallback ? ' (the fallback gets them)' : ''}.`);
|
|
409
|
+
reasonLines();
|
|
410
|
+
const firstAnswers = {};
|
|
411
|
+
for (const k of retryKeys) if (typeof translated[k] === 'string') firstAnswers[k] = translated[k];
|
|
412
|
+
const second = validateTranslations(firstAnswers, sourceFlat, pairConfig, { acceptLatinNames: true, acceptPluralGaps: true });
|
|
413
|
+
const shared = new Set(failures.filter(f => f.sharedOutput).map(f => f.key));
|
|
414
|
+
for (const [k, v] of Object.entries(second.validated)) {
|
|
415
|
+
if (shared.has(k)) continue;
|
|
416
|
+
validated[k] = v;
|
|
417
|
+
}
|
|
418
|
+
const passed = new Set(Object.keys(validated));
|
|
419
|
+
failures = failures.filter(f => !passed.has(f.key));
|
|
420
|
+
} else if (retryKeys.length > 0) {
|
|
421
|
+
const how = takesInstructions
|
|
422
|
+
? 'retrying with feedback'
|
|
423
|
+
: method === 'api'
|
|
424
|
+
? 'asking once more (the endpoint may ignore feedback: the api request carries it only when the pair declares "acceptsInstructions": true)'
|
|
425
|
+
: `asking once more (${method} takes no instructions, so it gets the same text again)`;
|
|
426
|
+
output.progress(` ${pairKey}: quality gate rejected ${retryKeys.length} key(s) — ${how}...`);
|
|
427
|
+
reasonLines();
|
|
145
428
|
|
|
146
|
-
const feedbackDescriptions = { ...(
|
|
429
|
+
const feedbackDescriptions = { ...(promptDescriptions || {}) };
|
|
147
430
|
for (const f of failures) {
|
|
148
431
|
const rejected = String(f.value ?? '').slice(0, 60);
|
|
149
432
|
const base = feedbackDescriptions[f.key] ? `${feedbackDescriptions[f.key]} — ` : '';
|
|
150
|
-
feedbackDescriptions[f.key] =
|
|
151
|
-
`${base}RETRY: a previous attempt ("${rejected}")
|
|
152
|
-
|
|
433
|
+
feedbackDescriptions[f.key] = f.sharedOutput
|
|
434
|
+
? `${base}RETRY: a previous attempt returned the same text ("${rejected}") for several different source strings. `
|
|
435
|
+
+ `Translate THIS string into ${pairConfig.name} — its own meaning, not a sentence you used elsewhere.`
|
|
436
|
+
: f.nameOrLabel
|
|
437
|
+
// A short value kept in Latin script: maybe a name, maybe a label
|
|
438
|
+
// the model skipped. Ask once, plainly; if it answers the same
|
|
439
|
+
// again, the retry validation below accepts it as a name.
|
|
440
|
+
? `${base}RETRY: a previous attempt kept this in Latin script ("${rejected}"). ` +
|
|
441
|
+
`If it is a descriptive label or phrase, translate it into ${pairConfig.name}. ` +
|
|
442
|
+
'Only if it is a proper name (a person, company, product or brand) return it exactly as written.'
|
|
443
|
+
: f.pluralGap
|
|
444
|
+
// A plural message without forms the language needs: name them,
|
|
445
|
+
// with the counts that select each, and ask for the whole message.
|
|
446
|
+
? `${base}RETRY: the previous translation had no ${f.pluralGap.missing.map(c => `"${c}"`).join(', ')} `
|
|
447
|
+
+ `branch. ${pairConfig.name} uses ${describeCategories(targetCode, f.pluralGap.missing, f.pluralGap.type)}. `
|
|
448
|
+
+ 'Return the whole message again with a branch for each of these forms, keeping every other branch and "other".'
|
|
449
|
+
: `${base}RETRY: a previous attempt ("${rejected}") was rejected by the quality gate: ${f.reason}. ` +
|
|
450
|
+
`Provide a complete, self-contained ${pairConfig.name} translation of this exact string.`;
|
|
153
451
|
}
|
|
154
452
|
|
|
155
|
-
|
|
453
|
+
retriedCount = retryKeys.length;
|
|
454
|
+
const retryResult = await translateBatchAliased(retryKeys, sourceFlat, pairConfig, {
|
|
156
455
|
...batchOptions,
|
|
157
456
|
onProgress: null, // avoid double-counting progress
|
|
158
457
|
descriptions: feedbackDescriptions,
|
|
@@ -160,7 +459,21 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
160
459
|
apiCalled = true;
|
|
161
460
|
|
|
162
461
|
if (retryResult) {
|
|
163
|
-
|
|
462
|
+
// Only keys the method actually answered count as refused by it.
|
|
463
|
+
for (const k of Object.keys(retryResult)) askedKeys.add(k);
|
|
464
|
+
// acceptLatinNames: a value the model keeps in Latin script after
|
|
465
|
+
// being asked once is a name by its own account — accept and cache it.
|
|
466
|
+
// acceptPluralGaps: asked once for the missing forms; a second answer
|
|
467
|
+
// without them is accepted and reported, never asked a third time.
|
|
468
|
+
const retryValidation = validateTranslations(retryResult, sourceFlat, pairConfig,
|
|
469
|
+
{ acceptLatinNames: true, acceptPluralGaps: true });
|
|
470
|
+
// The second answer is held to the shared-output rule too (against
|
|
471
|
+
// what was accepted so far): the same memorized sentence again stays
|
|
472
|
+
// refused.
|
|
473
|
+
for (const f of sharedFailure({ ...retryValidation.validated }, retryResult)) {
|
|
474
|
+
delete retryValidation.validated[f.key];
|
|
475
|
+
retryValidation.failures.push(f);
|
|
476
|
+
}
|
|
164
477
|
Object.assign(validated, retryValidation.validated);
|
|
165
478
|
for (const k of Object.keys(retryValidation.validated)) apiKeys.add(k);
|
|
166
479
|
|
|
@@ -175,17 +488,45 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
175
488
|
}
|
|
176
489
|
}
|
|
177
490
|
|
|
491
|
+
// A plural message the retry did not improve on (no answer, or one the
|
|
492
|
+
// gate refused for another reason) keeps its first answer: it passed every
|
|
493
|
+
// check except the missing forms, and it was paid for. Reported below.
|
|
494
|
+
failures = failures.filter((f) => {
|
|
495
|
+
if (pluralGapKeys.has(f.key) && typeof translated[f.key] === 'string' && apiKeys.has(f.key)) {
|
|
496
|
+
validated[f.key] = translated[f.key];
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
return true;
|
|
500
|
+
});
|
|
501
|
+
|
|
178
502
|
if (failures.length > 0) {
|
|
179
503
|
logGateFailures(failures, pairKey);
|
|
180
504
|
}
|
|
181
505
|
|
|
506
|
+
// Every accepted plural message that lacks forms the target uses — the
|
|
507
|
+
// caller says so (and a gettext catalog marks the repeated forms).
|
|
508
|
+
const gapsByKey = pluralGapsOf(validated, sourceFlat, targetCode, pairConfig.pluralSlots || null);
|
|
509
|
+
|
|
182
510
|
// Step 4: TM store — only gate-validated values that came from the API.
|
|
183
511
|
// TM hits are already cached; unvalidated output must never enter the TM.
|
|
184
512
|
for (const [k, v] of Object.entries(validated)) {
|
|
185
|
-
if (apiKeys.has(k) && typeof v === 'string' && typeof
|
|
186
|
-
storeTM(tm,
|
|
513
|
+
if (apiKeys.has(k) && typeof v === 'string' && typeof tmText[k] === 'string') {
|
|
514
|
+
storeTM(tm, tmText[k], targetCode, tmKey, v);
|
|
187
515
|
}
|
|
188
516
|
}
|
|
517
|
+
// Which method key produced each accepted value: this pair's on a fresh
|
|
518
|
+
// answer, the serving entry's on a cache hit (another model's, for a
|
|
519
|
+
// model carry-over). The lock records it (lib/locale-state.js `by`), so
|
|
520
|
+
// `status` names the model that wrote a value instead of guessing from
|
|
521
|
+
// identical cache entries (Round 6, Next.js persona).
|
|
522
|
+
const producedBy = {};
|
|
523
|
+
for (const k of Object.keys(validated)) {
|
|
524
|
+
producedBy[k] = apiKeys.has(k) ? tmKey : (tmHitBy[k] || tmKey);
|
|
525
|
+
}
|
|
526
|
+
if (sharedOutputs) {
|
|
527
|
+
sharedOutputs.add(Object.entries(validated)
|
|
528
|
+
.flatMap(([k, v]) => (typeof v === 'string' && typeof sourceFlat[k] === 'string' ? sharedOutputItems(k, sourceFlat[k], v) : [])));
|
|
529
|
+
}
|
|
189
530
|
|
|
190
531
|
return {
|
|
191
532
|
translated: Object.keys(validated).length > 0 ? validated : null,
|
|
@@ -193,5 +534,269 @@ export async function translateAndValidate(stringKeys, sourceFlat, pairConfig, p
|
|
|
193
534
|
failures,
|
|
194
535
|
apiCalled,
|
|
195
536
|
apiReturnedNull,
|
|
537
|
+
sentCount,
|
|
538
|
+
retriedCount,
|
|
539
|
+
pluralGaps: gapsByKey,
|
|
540
|
+
producedBy,
|
|
541
|
+
// Accepted values that are the method's own answer THIS run (not a
|
|
542
|
+
// cache hit): with options.pluralForms, a borrowed plural form here was
|
|
543
|
+
// asked for under its own identity — the lock records that (sync).
|
|
544
|
+
answeredKeys: Object.keys(validated).filter(k => apiKeys.has(k)),
|
|
545
|
+
sentKeys: toSend,
|
|
546
|
+
heldKeys: [...new Set(heldKeys)].filter(k => !(k in validated)),
|
|
547
|
+
refusedKeys: failures.filter(f => askedKeys.has(f.key) && f.value !== undefined).map(f => f.key),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* translateAndValidate with the pair's FALLBACK method (lib/fallback.js).
|
|
553
|
+
*
|
|
554
|
+
* The ladder, per key:
|
|
555
|
+
* 1. the pair's own TM entry (served by translateAndValidate, gate-checked);
|
|
556
|
+
* 2. the fallback's TM entry — text the fallback translated on an earlier
|
|
557
|
+
* run is reused (gate-checked; a failing entry is evicted), so the
|
|
558
|
+
* primary is not re-asked, and re-billed, for it on every re-run;
|
|
559
|
+
* 3. the pair's own method (with its one corrective retry);
|
|
560
|
+
* 4. for every key 3 left untranslated — gate-refused, or missing from the
|
|
561
|
+
* response — ONE pass through the fallback's method, through the SAME
|
|
562
|
+
* quality gate (with its own corrective retry). Accepted values are
|
|
563
|
+
* cached under the fallback's own TM key.
|
|
564
|
+
*
|
|
565
|
+
* A key both methods fail stays failed exactly as without a fallback: the
|
|
566
|
+
* caller leaves it untranslated and keeps its lock entry, and verify lists it.
|
|
567
|
+
*
|
|
568
|
+
* A primary that returned nothing at all is exactly when a fallback helps:
|
|
569
|
+
* the fallback still runs, and that the primary failed wholesale is said
|
|
570
|
+
* out loud. `skipPrimary` sends everything the pair's own cache does not
|
|
571
|
+
* hold straight to the fallback (the primary already returned nothing for an
|
|
572
|
+
* earlier file of this locale; its cached translations are still good).
|
|
573
|
+
*
|
|
574
|
+
* --max-cost: the fallback batch is priced (its cache misses only) before it
|
|
575
|
+
* runs; `budget` refuses it when the run's committed spend plus this batch
|
|
576
|
+
* would pass the cap, or when the fallback has no price (lib/fallback.js
|
|
577
|
+
* createFallbackBudget). A refused batch leaves the keys failed.
|
|
578
|
+
*
|
|
579
|
+
* Without `pairConfig.fallback` this IS translateAndValidate (plus a null
|
|
580
|
+
* `fallback` report).
|
|
581
|
+
*
|
|
582
|
+
* @param {string[]} stringKeys
|
|
583
|
+
* @param {object} sourceFlat
|
|
584
|
+
* @param {object} pairConfig - Resolved pair (lib/pairs.js); `fallback` optional
|
|
585
|
+
* @param {string} pairKey
|
|
586
|
+
* @param {object} options - translateAndValidate options, plus:
|
|
587
|
+
* @param {boolean} [options.skipPrimary=false]
|
|
588
|
+
* @param {object|null} [options.budget=null] - createFallbackBudget()
|
|
589
|
+
* @param {Set<string>} [options.noSendPrimary] - Keys the pair's own method
|
|
590
|
+
* refused before (lib/locale-state.js): its cache is consulted, it is not
|
|
591
|
+
* asked; with a fallback, they go to the fallback.
|
|
592
|
+
* @param {Set<string>} [options.noSendFallback] - Keys the fallback refused before
|
|
593
|
+
* @returns {Promise<TranslateResult & { fallback: import('./fallback.js').FallbackReport|null,
|
|
594
|
+
* refusedBy: Object<string, string[]> }>} refusedBy: key → the method keys
|
|
595
|
+
* whose answer the gate refused this run
|
|
596
|
+
*/
|
|
597
|
+
export async function translateWithFallback(stringKeys, sourceFlat, pairConfig, pairKey, options) {
|
|
598
|
+
const {
|
|
599
|
+
skipPrimary = false, budget = null, noSendPrimary = new Set(), noSendFallback = new Set(), ...pipeline
|
|
600
|
+
} = options;
|
|
601
|
+
const fb = pairConfig.fallback || null;
|
|
602
|
+
if (!fb) {
|
|
603
|
+
const result = await translateAndValidate(stringKeys, sourceFlat, pairConfig, pairKey, { ...pipeline, noSendKeys: noSendPrimary });
|
|
604
|
+
const primaryKey = tmMethodKey(pairConfig);
|
|
605
|
+
const refusedBy = {};
|
|
606
|
+
for (const k of result.refusedKeys || []) refusedBy[k] = [primaryKey];
|
|
607
|
+
return { ...result, fallback: null, refusedBy };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const { tm, targetCode } = pipeline;
|
|
611
|
+
const sharedOutputs = pipeline.sharedOutputs || null;
|
|
612
|
+
const isString = (k) => typeof sourceFlat[k] === 'string';
|
|
613
|
+
const textOf = (k) => tmSourceText(k, sourceFlat[k], pipeline.pluralForms?.[k] || null);
|
|
614
|
+
const primaryKey = tmMethodKey(pairConfig);
|
|
615
|
+
const fallbackKey = tmMethodKey(fb);
|
|
616
|
+
const report = {
|
|
617
|
+
method: fb.method,
|
|
618
|
+
model: fb.model || null,
|
|
619
|
+
attempted: 0,
|
|
620
|
+
accepted: 0,
|
|
621
|
+
cached: 0,
|
|
622
|
+
primaryFailedWholesale: skipPrimary,
|
|
623
|
+
skipped: null,
|
|
624
|
+
// The primary's own accepted answers this run, and why the keys sent to
|
|
625
|
+
// the fallback were not its (lib/fallback.js warnFallbackMajority).
|
|
626
|
+
primaryAccepted: 0,
|
|
627
|
+
primaryReasons: {},
|
|
196
628
|
};
|
|
629
|
+
|
|
630
|
+
// A cache read outside translateAndValidate: entries for `keys` under
|
|
631
|
+
// `tmKey`, gate-checked against `cfg` like any TM hit (a short Latin value
|
|
632
|
+
// already settled as a name stays accepted); a failing entry is evicted.
|
|
633
|
+
const servedBy = {};
|
|
634
|
+
const serveCached = (keys, cfg, tmKey) => {
|
|
635
|
+
const candidates = {};
|
|
636
|
+
for (const k of keys) {
|
|
637
|
+
const cached = lookupTM(tm, textOf(k), targetCode, tmKey);
|
|
638
|
+
if (cached !== null) candidates[k] = cached;
|
|
639
|
+
}
|
|
640
|
+
const served = {};
|
|
641
|
+
for (const k of Object.keys(candidates)) servedBy[k] = servingMethodKey(tm, textOf(k), targetCode, tmKey) || tmKey;
|
|
642
|
+
if (Object.keys(candidates).length === 0) return served;
|
|
643
|
+
const { validated, failures } = validateTranslations(candidates, sourceFlat, cfg);
|
|
644
|
+
Object.assign(served, validated);
|
|
645
|
+
const evictor = createTMEvictor(tm);
|
|
646
|
+
for (const f of failures) {
|
|
647
|
+
if (f.nameOrLabel || f.pluralGap) { served[f.key] = candidates[f.key]; continue; }
|
|
648
|
+
evictor.evictProducing(textOf(f.key), targetCode, candidates[f.key], [tmKey]);
|
|
649
|
+
}
|
|
650
|
+
// A cache read is held to the shared-output rule too: entries another
|
|
651
|
+
// tool cached one text at a time (the MCP translate tool, an earlier
|
|
652
|
+
// run) used to be written straight from this cache, three different
|
|
653
|
+
// clinical prompts as one sentence (Round 5, hospital persona). A
|
|
654
|
+
// suspect entry is evicted, so the key goes on to the gated methods.
|
|
655
|
+
if (sharedOutputs) {
|
|
656
|
+
const items = Object.entries(candidates).flatMap(([k, v]) => sharedOutputItems(k, sourceFlat[k], v));
|
|
657
|
+
const suspects = sharedOutputs.suspects(items);
|
|
658
|
+
for (const k of suspects.keys()) {
|
|
659
|
+
if (!(k in served)) continue;
|
|
660
|
+
evictor.evictProducing(textOf(k), targetCode, served[k], [tmKey]);
|
|
661
|
+
delete served[k];
|
|
662
|
+
}
|
|
663
|
+
sharedOutputs.add(Object.entries(served).flatMap(([k, v]) => sharedOutputItems(k, sourceFlat[k], v)));
|
|
664
|
+
}
|
|
665
|
+
return served;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// Step 2 of the ladder: the fallback's cache, for keys the pair's own
|
|
669
|
+
// cache does not hold. (With the primary known to be down, its own cache
|
|
670
|
+
// is still good: those hits are served, and only the rest go on.)
|
|
671
|
+
const fromCache = {};
|
|
672
|
+
let primaryCacheHits = {};
|
|
673
|
+
if (skipPrimary) {
|
|
674
|
+
primaryCacheHits = serveCached(stringKeys.filter(isString), pairConfig, primaryKey);
|
|
675
|
+
} else {
|
|
676
|
+
const notInOwnCache = stringKeys.filter(k => isString(k) && lookupTM(tm, textOf(k), targetCode, primaryKey) === null);
|
|
677
|
+
// (A key the fallback refused before is still served from its cache
|
|
678
|
+
// here when it holds a valid entry — holding back stops calls, not reads.)
|
|
679
|
+
Object.assign(fromCache, serveCached(notInOwnCache, fb, fallbackKey));
|
|
680
|
+
report.cached = Object.keys(fromCache).length;
|
|
681
|
+
if (report.cached > 0) output.info(`[TM] ${report.cached} key(s) served from the fallback's cache (${fb.method})`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Step 3: the pair's own method, for everything else.
|
|
685
|
+
const primaryKeys = skipPrimary ? [] : stringKeys.filter(k => !(k in fromCache));
|
|
686
|
+
let primary = {
|
|
687
|
+
translated: null,
|
|
688
|
+
producedBy: {},
|
|
689
|
+
tmHitCount: Object.keys(primaryCacheHits).length,
|
|
690
|
+
failures: [],
|
|
691
|
+
apiCalled: false,
|
|
692
|
+
apiReturnedNull: false,
|
|
693
|
+
sentCount: 0,
|
|
694
|
+
retriedCount: 0,
|
|
695
|
+
pluralGaps: {},
|
|
696
|
+
heldKeys: [],
|
|
697
|
+
refusedKeys: [],
|
|
698
|
+
};
|
|
699
|
+
if (primaryKeys.length > 0) {
|
|
700
|
+
primary = await translateAndValidate(primaryKeys, sourceFlat, pairConfig, pairKey, { ...pipeline, noSendKeys: noSendPrimary });
|
|
701
|
+
}
|
|
702
|
+
const translated = { ...primaryCacheHits, ...fromCache, ...(primary.translated || {}) };
|
|
703
|
+
const notTranslated = (skipPrimary ? stringKeys : primaryKeys).filter(k => isString(k) && !(k in translated));
|
|
704
|
+
// Refused before by the fallback too: neither method is asked.
|
|
705
|
+
const heldBoth = notTranslated.filter(k => noSendFallback.has(k) && (skipPrimary || noSendPrimary.has(k)));
|
|
706
|
+
const toFallback = notTranslated.filter(k => !heldBoth.includes(k));
|
|
707
|
+
if (!skipPrimary && primary.apiReturnedNull && !primary.translated) report.primaryFailedWholesale = true;
|
|
708
|
+
|
|
709
|
+
const done = (fallbackResult = null) => {
|
|
710
|
+
const merged = { ...translated, ...(fallbackResult?.translated || {}) };
|
|
711
|
+
const fbFailures = (fallbackResult?.failures || []).filter(f => !(f.key in merged));
|
|
712
|
+
const fbFailed = new Set(fbFailures.map(f => f.key));
|
|
713
|
+
const failures = [
|
|
714
|
+
...primary.failures.filter(f => !(f.key in merged) && !fbFailed.has(f.key)),
|
|
715
|
+
...fbFailures,
|
|
716
|
+
];
|
|
717
|
+
// Who refused what this run (the lock remembers it per key).
|
|
718
|
+
const refusedBy = {};
|
|
719
|
+
for (const k of primary.refusedKeys || []) if (!(k in merged)) refusedBy[k] = [primaryKey];
|
|
720
|
+
for (const k of fallbackResult?.refusedKeys || []) {
|
|
721
|
+
if (!(k in merged)) refusedBy[k] = [...(refusedBy[k] || []), fallbackKey];
|
|
722
|
+
}
|
|
723
|
+
// Not sent anywhere: refused before by every method that could be asked.
|
|
724
|
+
const heldKeys = [...new Set([...heldBoth, ...(fallbackResult?.heldKeys || [])])].filter(k => !(k in merged));
|
|
725
|
+
// Which method key produced each value (the cache entry, or the method asked).
|
|
726
|
+
const producedBy = {};
|
|
727
|
+
for (const k of Object.keys(merged)) {
|
|
728
|
+
producedBy[k] = fallbackResult?.producedBy?.[k] || primary.producedBy?.[k] || servedBy[k] || primaryKey;
|
|
729
|
+
}
|
|
730
|
+
// The method's own answers this run (the fallback's replace the primary's).
|
|
731
|
+
const fbAnswered = new Set(fallbackResult?.answeredKeys || []);
|
|
732
|
+
const fbTranslated = fallbackResult?.translated || {};
|
|
733
|
+
// What the fallback produced (its answers and its cache): named in the
|
|
734
|
+
// [FALLBACK] line (lib/fallback.js printFallbackReport).
|
|
735
|
+
report.produced = Object.keys(merged).filter(k => producedBy[k] === fallbackKey);
|
|
736
|
+
// The pair's own method's answers that were kept (not its cache hits).
|
|
737
|
+
report.primaryAccepted = (primary.answeredKeys || []).filter(k => k in merged && !(k in fbTranslated)).length;
|
|
738
|
+
const answeredKeys = [
|
|
739
|
+
...(primary.answeredKeys || []).filter(k => k in merged && !(k in fbTranslated)),
|
|
740
|
+
...[...fbAnswered].filter(k => k in merged),
|
|
741
|
+
];
|
|
742
|
+
return {
|
|
743
|
+
producedBy,
|
|
744
|
+
answeredKeys,
|
|
745
|
+
translated: Object.keys(merged).length > 0 ? merged : null,
|
|
746
|
+
tmHitCount: primary.tmHitCount + report.cached + (fallbackResult?.tmHitCount || 0),
|
|
747
|
+
failures,
|
|
748
|
+
apiCalled: primary.apiCalled || !!fallbackResult?.apiCalled,
|
|
749
|
+
apiReturnedNull: skipPrimary || primary.apiReturnedNull,
|
|
750
|
+
sentCount: (primary.sentCount || 0) + (fallbackResult?.sentCount || 0),
|
|
751
|
+
retriedCount: (primary.retriedCount || 0) + (fallbackResult?.retriedCount || 0),
|
|
752
|
+
pluralGaps: pluralGapsOf(merged, sourceFlat, targetCode, pairConfig.pluralSlots || null),
|
|
753
|
+
fallbackReturnedNull: !!fallbackResult?.apiReturnedNull && !fallbackResult?.translated,
|
|
754
|
+
fallback: report,
|
|
755
|
+
sentKeys: [...(primary.sentKeys || []), ...(fallbackResult?.sentKeys || [])],
|
|
756
|
+
heldKeys,
|
|
757
|
+
refusedKeys: Object.keys(refusedBy),
|
|
758
|
+
refusedBy,
|
|
759
|
+
};
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
if (toFallback.length === 0) return done();
|
|
763
|
+
|
|
764
|
+
if (report.primaryFailedWholesale) {
|
|
765
|
+
output.warn(
|
|
766
|
+
`${pairKey}: the primary method (${pairConfig.method}) returned no results`
|
|
767
|
+
+ `${skipPrimary ? ' for an earlier file of this locale' : ''} — sending ${toFallback.length} key(s) `
|
|
768
|
+
+ `to the fallback (${fb.method}).`
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --max-cost: price what the fallback will actually send (its cache misses).
|
|
773
|
+
if (budget) {
|
|
774
|
+
const texts = {};
|
|
775
|
+
for (const k of toFallback) texts[k] = textOf(k);
|
|
776
|
+
const { misses } = partitionByTM(tm, texts, toFallback, targetCode, fallbackKey);
|
|
777
|
+
const verdict = await budget.approve(misses.length, fb);
|
|
778
|
+
if (!verdict.ok) {
|
|
779
|
+
report.skipped = { reason: verdict.reason, items: [...toFallback] };
|
|
780
|
+
return done();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
report.attempted = toFallback.length;
|
|
785
|
+
// Why each key goes to the fallback, counted: the gate's refusal of the
|
|
786
|
+
// primary's answer, no answer, or a refusal on an earlier sync.
|
|
787
|
+
const primaryFailure = new Map();
|
|
788
|
+
for (const f of primary.failures || []) if (!primaryFailure.has(f.key)) primaryFailure.set(f.key, f);
|
|
789
|
+
for (const k of toFallback) {
|
|
790
|
+
const f = primaryFailure.get(k);
|
|
791
|
+
notePrimaryReason(report, f
|
|
792
|
+
? refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, memorized: !!f.memorized })
|
|
793
|
+
: refusalCategory(null, { heldBefore: !skipPrimary && noSendPrimary.has(k), noAnswer: true }));
|
|
794
|
+
}
|
|
795
|
+
const fallbackResult = await translateAndValidate(
|
|
796
|
+
toFallback, sourceFlat, fb, `${pairKey} (fallback: ${fb.method})`,
|
|
797
|
+
{ ...pipeline, onProgress: null, noSendKeys: noSendFallback },
|
|
798
|
+
);
|
|
799
|
+
const result = done(fallbackResult);
|
|
800
|
+
report.accepted = toFallback.filter(k => result.translated && k in result.translated).length;
|
|
801
|
+
return result;
|
|
197
802
|
}
|