champollion 0.3.3 → 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 +52 -37
- 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 +51 -3
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +289 -88
- 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 +649 -130
- 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 +16 -10
- 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 +197 -38
- 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 +193 -106
- package/lib/seal.mjs +6 -5
- package/lib/sealed-qualifier.mjs +2 -2
- 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 +3 -2
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/DATA-SOVEREIGNTY.md +19 -20
- package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
- package/shared/cards-fallback.json +1 -1
- package/shared/catalogue/card-config.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/docent/faq.en.json +14 -16
- package/shared/docent/system-prompt.md +17 -19
- package/shared/explainers/tc-features.json +15 -15
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/human-services.json +1 -1
- 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 +20 -10
- package/shared/schemas/human-services.schema.json +2 -2
- package/shared/schemas/language-card.schema.json +1 -1
- package/shared/schemas/method-card.schema.json +1 -1
- package/shared/schemas/method-index-record.schema.json +67 -0
- package/shared/schemas/method-registry.schema.json +4 -0
- package/shared/schemas/metric-registry.schema.json +55 -1
- package/shared/docent/corpus.json +0 -11333
package/lib/fallback.js
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fallback.js — the per-pair fallback method: shared machinery for every lane.
|
|
3
|
+
*
|
|
4
|
+
* A pair may name a second method (`"fallback": { "method": … }`, resolved by
|
|
5
|
+
* lib/pairs.js resolveFallbackForPair into a full pair config). The pair's own
|
|
6
|
+
* method runs first; what it could not translate safely — keys the quality
|
|
7
|
+
* gate refused or that came back empty, Markdown blocks it dropped or damaged,
|
|
8
|
+
* front-matter fields it hollowed — goes to the fallback ONCE, through the
|
|
9
|
+
* same gate. What the fallback translates is cached under its own TM key, so
|
|
10
|
+
* the cache always says which method produced a value. What both fail stays
|
|
11
|
+
* failed exactly as without a fallback ('[EN] ' remains the content lanes'
|
|
12
|
+
* last resort).
|
|
13
|
+
*
|
|
14
|
+
* The TM ladder, everywhere: the pair's cache → the fallback's cache → the
|
|
15
|
+
* pair's method → the fallback's method. Text the fallback already paid for
|
|
16
|
+
* is reused, not re-sent to the primary on every re-run (re-ask the primary
|
|
17
|
+
* with --fresh or --retranslate).
|
|
18
|
+
*
|
|
19
|
+
* This module holds what the key-value, Hugo-content and Docusaurus lanes
|
|
20
|
+
* share: the --max-cost guard for fallback batches, the per-pair tally and its
|
|
21
|
+
* [FALLBACK] report line, and the content-lane helpers (front-matter fields,
|
|
22
|
+
* Markdown blocks, whole pages). The key-value pipeline itself is
|
|
23
|
+
* translateWithFallback in lib/translate-pair.js.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { estimateCost } from './pairs.js';
|
|
27
|
+
import { lookupTM, lookupTMValidated, tmMethodKey } from './tm.js';
|
|
28
|
+
import { createTMEvictor } from './tm-evict.js';
|
|
29
|
+
import { translatableBlockSources } from './content-estimate.js';
|
|
30
|
+
import { checkContentPreservation, contentGateFault, sharedOutputReason } from './validate.js';
|
|
31
|
+
import { restoreBlocks, hasOrphanedPlaceholders, PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX } from './content.js';
|
|
32
|
+
import { EST_CHARS_PER_KEY } from './config.js';
|
|
33
|
+
import { output } from './output.js';
|
|
34
|
+
import { refusalCategory, notePrimaryReason } from './refusal-category.js';
|
|
35
|
+
|
|
36
|
+
export { refusalCategory, notePrimaryReason };
|
|
37
|
+
|
|
38
|
+
// ── TM keys ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The TM method keys whose entries a pair's output may have come from: its
|
|
42
|
+
* own, then its fallback's. Echo confirmation (lib/diff.js) and verify must
|
|
43
|
+
* consult both — a value the fallback produced is cached under the fallback.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} pairConfig
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
export function tmKeysForPair(pairConfig) {
|
|
49
|
+
const keys = [tmMethodKey(pairConfig)];
|
|
50
|
+
if (pairConfig?.fallback) keys.push(tmMethodKey(pairConfig.fallback));
|
|
51
|
+
return keys;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when the TM holds `value` for `text` under any of `tmKeys` — the
|
|
56
|
+
* "the pipeline produced this" proof the echo checks need.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} tm
|
|
59
|
+
* @param {string} text - Source text as cached (tmSourceText)
|
|
60
|
+
* @param {string} locale
|
|
61
|
+
* @param {string[]} tmKeys - From tmKeysForPair
|
|
62
|
+
* @param {string} value
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function tmHoldsValue(tm, text, locale, tmKeys, value) {
|
|
66
|
+
return tmKeys.some(k => lookupTM(tm, text, locale, k) === value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Cost guard ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The --max-cost guard for fallback batches.
|
|
73
|
+
*
|
|
74
|
+
* The pre-run estimate cannot know which keys or blocks the primary will
|
|
75
|
+
* fail, so fallback spend is not in it. Each fallback batch is priced just
|
|
76
|
+
* before it runs, with the same estimator the run's estimate used
|
|
77
|
+
* (pairs.js estimateCost → the method's own estimateCost). Under a cap, a
|
|
78
|
+
* batch runs only if the spend already committed — the pre-run estimate the
|
|
79
|
+
* cap admitted, plus every fallback batch approved so far — plus this one
|
|
80
|
+
* stays within the cap. An unpriced fallback is refused: unknown is not
|
|
81
|
+
* free, the same rule the pre-run gate applies to the primary.
|
|
82
|
+
*
|
|
83
|
+
* Concurrency: approvals from parallel locales await the estimate, then
|
|
84
|
+
* check-and-commit synchronously, so two batches cannot both slip under the
|
|
85
|
+
* cap.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ maxCost?: number|null, committed?: number }} [opts]
|
|
88
|
+
* @returns {{ maxCost: number|null, readonly committed: number,
|
|
89
|
+
* approve: (units: number, fallbackConfig: object) => Promise<{ ok: boolean, estimate: number|null, reason?: string }> }}
|
|
90
|
+
*/
|
|
91
|
+
export function createFallbackBudget({ maxCost = null, committed = 0, cwd = null } = {}) {
|
|
92
|
+
let spent = Number.isFinite(committed) ? committed : 0;
|
|
93
|
+
return {
|
|
94
|
+
maxCost,
|
|
95
|
+
get committed() { return spent; },
|
|
96
|
+
async approve(units, fallbackConfig) {
|
|
97
|
+
if (maxCost === null || units <= 0) return { ok: true, estimate: null };
|
|
98
|
+
// cwd: the project, where a local fallback's endpoint may be set (.env).
|
|
99
|
+
const estimate = await estimateCost(units, fallbackConfig, cwd ? { cwd } : {});
|
|
100
|
+
const cost = estimate?.estimatedCost ?? null;
|
|
101
|
+
if (cost === null) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
estimate: null,
|
|
105
|
+
reason: `its cost cannot be estimated (${estimate?.source || 'unknown pricing'}) and --max-cost is set — unknown is not free`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (spent + cost > maxCost) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
estimate: cost,
|
|
112
|
+
reason: `~$${cost.toFixed(4)} on top of the ~$${spent.toFixed(4)} already committed would pass --max-cost $${maxCost.toFixed(4)}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
spent += cost;
|
|
116
|
+
return { ok: true, estimate: cost };
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Key-equivalents for a run of source characters — the unit the content
|
|
123
|
+
* lanes' estimates price (lib/cost-report.js priceContentChars).
|
|
124
|
+
*
|
|
125
|
+
* @param {number} chars
|
|
126
|
+
* @returns {number}
|
|
127
|
+
*/
|
|
128
|
+
export function charsToKeyUnits(chars) {
|
|
129
|
+
return Math.ceil(chars / EST_CHARS_PER_KEY);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Reporting ────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A per-call fallback report (one file, one batch). Callers add these into
|
|
136
|
+
* a per-pair tally (addToTally) and print one [FALLBACK] line per pair.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} fallbackConfig
|
|
139
|
+
* @returns {FallbackReport}
|
|
140
|
+
*
|
|
141
|
+
* @typedef {object} FallbackReport
|
|
142
|
+
* @property {string} method - The fallback's method
|
|
143
|
+
* @property {string|null} model
|
|
144
|
+
* @property {number} attempted - Units the primary failed and the fallback was asked to translate
|
|
145
|
+
* @property {number} accepted - Of those, how many the fallback translated and the gate accepted
|
|
146
|
+
* @property {number} cached - Units served from the fallback's cache (the primary was not re-asked)
|
|
147
|
+
* @property {boolean} primaryFailedWholesale - The primary returned nothing at all
|
|
148
|
+
* @property {{ reason: string, items: string[] }|null} skipped - Set when the
|
|
149
|
+
* fallback did not run (--max-cost); items are the keys/segments left failing
|
|
150
|
+
* @property {number} primaryAccepted - Units the pair's OWN method answered
|
|
151
|
+
* this run and the gate accepted (cache hits not counted) — with
|
|
152
|
+
* `accepted`, the run's fresh translations for the pair
|
|
153
|
+
* @property {Object<string, number>} primaryReasons - Why the units sent to
|
|
154
|
+
* the fallback were not the primary's: a refusal in a few words
|
|
155
|
+
* (refusalCategory) → how many
|
|
156
|
+
*/
|
|
157
|
+
export function newFallbackReport(fallbackConfig) {
|
|
158
|
+
return {
|
|
159
|
+
method: fallbackConfig.method,
|
|
160
|
+
model: fallbackConfig.model || null,
|
|
161
|
+
attempted: 0,
|
|
162
|
+
accepted: 0,
|
|
163
|
+
cached: 0,
|
|
164
|
+
primaryFailedWholesale: false,
|
|
165
|
+
skipped: null,
|
|
166
|
+
// What the fallback produced (keys, or pages for the content lanes) —
|
|
167
|
+
// named in the [FALLBACK] line, so its output is findable in the files.
|
|
168
|
+
produced: [],
|
|
169
|
+
primaryAccepted: 0,
|
|
170
|
+
primaryReasons: {},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Above this share of a run's fresh translations for a pair, the fallback
|
|
176
|
+
* wrote most of it, and sync says so in one warning (warnFallbackMajority) —
|
|
177
|
+
* and `status` beside the share of the files it holds. A fallback is the
|
|
178
|
+
* second opinion for what the pair's method cannot translate safely; when it
|
|
179
|
+
* writes most of the output, what ships is the fallback's work (Round 11,
|
|
180
|
+
* school persona: every app string and 5 of 7 newsletter segments came from
|
|
181
|
+
* the weakest method measured, and nothing said so in context).
|
|
182
|
+
*/
|
|
183
|
+
export const FALLBACK_MAJORITY_SHARE = 0.5;
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fold one call's report into a pair's running tally (mutates `tally`).
|
|
188
|
+
*
|
|
189
|
+
* @param {FallbackReport} tally
|
|
190
|
+
* @param {FallbackReport|null} report
|
|
191
|
+
* @returns {FallbackReport}
|
|
192
|
+
*/
|
|
193
|
+
export function addToTally(tally, report) {
|
|
194
|
+
if (!report) return tally;
|
|
195
|
+
tally.attempted += report.attempted;
|
|
196
|
+
tally.accepted += report.accepted;
|
|
197
|
+
tally.cached += report.cached;
|
|
198
|
+
tally.primaryAccepted = (tally.primaryAccepted || 0) + (report.primaryAccepted || 0);
|
|
199
|
+
for (const [why, n] of Object.entries(report.primaryReasons || {})) {
|
|
200
|
+
tally.primaryReasons = tally.primaryReasons || {};
|
|
201
|
+
tally.primaryReasons[why] = (tally.primaryReasons[why] || 0) + n;
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(report.produced) && report.produced.length > 0) {
|
|
204
|
+
tally.produced = tally.produced || [];
|
|
205
|
+
for (const k of report.produced) if (!tally.produced.includes(k)) tally.produced.push(k);
|
|
206
|
+
}
|
|
207
|
+
if (report.primaryFailedWholesale) tally.primaryFailedWholesale = true;
|
|
208
|
+
if (report.skipped) {
|
|
209
|
+
tally.skipped = tally.skipped || { reason: report.skipped.reason, items: [] };
|
|
210
|
+
tally.skipped.items.push(...report.skipped.items);
|
|
211
|
+
}
|
|
212
|
+
return tally;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The machine-readable form of a tally (the --json summary's per-locale
|
|
217
|
+
* `fallback` object).
|
|
218
|
+
*
|
|
219
|
+
* @param {FallbackReport} tally
|
|
220
|
+
* @returns {object}
|
|
221
|
+
*/
|
|
222
|
+
export function fallbackSummary(tally) {
|
|
223
|
+
return {
|
|
224
|
+
method: tally.method,
|
|
225
|
+
model: tally.model,
|
|
226
|
+
attempted: tally.attempted,
|
|
227
|
+
accepted: tally.accepted,
|
|
228
|
+
failed: tally.attempted - tally.accepted,
|
|
229
|
+
cached: tally.cached,
|
|
230
|
+
// The pair's own method's accepted answers this run (with `accepted`:
|
|
231
|
+
// this run's fresh translations), and why the rest went to the fallback.
|
|
232
|
+
primaryAccepted: tally.primaryAccepted || 0,
|
|
233
|
+
...(tally.primaryReasons && Object.keys(tally.primaryReasons).length > 0 && { primaryReasons: { ...tally.primaryReasons } }),
|
|
234
|
+
primaryFailedWholesale: tally.primaryFailedWholesale,
|
|
235
|
+
// Keys (or pages) whose text the fallback produced this run.
|
|
236
|
+
...(Array.isArray(tally.produced) && tally.produced.length > 0 && { produced: [...tally.produced] }),
|
|
237
|
+
...(tally.skipped && { skipped: { reason: tally.skipped.reason, items: [...tally.skipped.items] } }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Print a pair's [FALLBACK] line(s). Silent when the fallback had nothing
|
|
243
|
+
* to do — a pair whose own method translated everything says nothing here.
|
|
244
|
+
*
|
|
245
|
+
* [FALLBACK] en:crk — 6 key(s) the primary (api) could not translate
|
|
246
|
+
* safely → translated by llm-coached (4 accepted, 2 still failing)
|
|
247
|
+
*
|
|
248
|
+
* @param {string} pairKey
|
|
249
|
+
* @param {string} primaryMethod
|
|
250
|
+
* @param {FallbackReport} tally
|
|
251
|
+
* @param {{ unit?: string }} [opts] - "key(s)" or "content segment(s)"
|
|
252
|
+
*/
|
|
253
|
+
export function printFallbackReport(pairKey, primaryMethod, tally, { unit = 'key(s)', producedLabel = 'keys' } = {}) {
|
|
254
|
+
if (!tally) return;
|
|
255
|
+
const still = tally.attempted - tally.accepted;
|
|
256
|
+
// Which ones: the files do not say what the fallback wrote (Round 8 school
|
|
257
|
+
// + hospital personas), so the line names them, and status counts them.
|
|
258
|
+
const produced = Array.isArray(tally.produced) ? tally.produced : [];
|
|
259
|
+
const named = produced.length > 0
|
|
260
|
+
? ` — ${producedLabel}: ${produced.slice(0, 8).join(', ')}${produced.length > 8 ? `, +${produced.length - 8} more` : ''}`
|
|
261
|
+
+ ' (`champollion status` counts what each locale holds from the fallback)'
|
|
262
|
+
: '';
|
|
263
|
+
if (tally.skipped) {
|
|
264
|
+
const items = tally.skipped.items;
|
|
265
|
+
const shown = items.slice(0, 10).join(', ') + (items.length > 10 ? `, +${items.length - 10} more` : '');
|
|
266
|
+
output.warn(
|
|
267
|
+
`[FALLBACK] ${pairKey} — ${items.length} ${unit} the primary (${primaryMethod}) could not translate safely; `
|
|
268
|
+
+ `the fallback (${tally.method}) was skipped: ${tally.skipped.reason}. Still untranslated: ${shown}`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (tally.attempted > 0) {
|
|
272
|
+
const line = `[FALLBACK] ${pairKey} — ${tally.attempted} ${unit} the primary (${primaryMethod}) could not `
|
|
273
|
+
+ `translate safely → translated by ${tally.method} (${tally.accepted} accepted, ${still} still failing)`
|
|
274
|
+
+ (tally.cached > 0 ? `; ${tally.cached} more served from its cache` : '')
|
|
275
|
+
+ named;
|
|
276
|
+
if (still > 0) output.warn(line);
|
|
277
|
+
else output.info(line);
|
|
278
|
+
} else if (tally.cached > 0) {
|
|
279
|
+
output.info(
|
|
280
|
+
`[FALLBACK] ${pairKey} — ${tally.cached} ${unit} served from the fallback's cache (${tally.method}); `
|
|
281
|
+
+ `the primary (${primaryMethod}) was not asked again${named}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* When the fallback wrote most of a run's fresh translations for a pair
|
|
288
|
+
* (more than FALLBACK_MAJORITY_SHARE of them — the primary's accepted
|
|
289
|
+
* answers plus the fallback's), say it once, plainly: how many of how many,
|
|
290
|
+
* by which method and model, why the primary's answers were not used
|
|
291
|
+
* (counted), and what to consider. Silent otherwise, and for a run that
|
|
292
|
+
* translated nothing fresh (everything from the cache: `status` carries the
|
|
293
|
+
* standing share of the files).
|
|
294
|
+
*
|
|
295
|
+
* @param {string} pairKey
|
|
296
|
+
* @param {string} primaryMethod
|
|
297
|
+
* @param {FallbackReport} tally
|
|
298
|
+
* @param {{ unit?: string }} [opts] - "key(s)" or "content segment(s)"
|
|
299
|
+
* @returns {{ fromFallback: number, translated: number, share: number }|null} what was said (null: nothing)
|
|
300
|
+
*/
|
|
301
|
+
export function warnFallbackMajority(pairKey, primaryMethod, tally, { unit = 'key(s)' } = {}) {
|
|
302
|
+
if (!tally) return null;
|
|
303
|
+
const fromFallback = tally.accepted || 0;
|
|
304
|
+
const translated = (tally.primaryAccepted || 0) + fromFallback;
|
|
305
|
+
if (translated === 0 || fromFallback / translated <= FALLBACK_MAJORITY_SHARE) return null;
|
|
306
|
+
const who = `${tally.method}${tally.model ? `, model ${tally.model}` : ''}`;
|
|
307
|
+
const reasons = Object.entries(tally.primaryReasons || {}).sort((a, b) => b[1] - a[1])
|
|
308
|
+
.map(([why, n]) => `${why} (${n})`).join('; ');
|
|
309
|
+
output.warn(
|
|
310
|
+
`[FALLBACK] ${pairKey} — most of this run's translations came from the fallback: ${fromFallback} of ${translated} ${unit} `
|
|
311
|
+
+ `were written by ${who}, not by the pair's method (${primaryMethod})`
|
|
312
|
+
+ (reasons ? `. Why ${primaryMethod}'s answers were not used, for the ${tally.attempted} ${unit} sent to the fallback: ${reasons}` : '')
|
|
313
|
+
+ `. What ships is the fallback's work. Consider: ${primaryMethod} may not suit these strings; check what was written `
|
|
314
|
+
+ '(`champollion verify` checks structure — a speaker checks meaning); or a stronger fallback ("fallback" in the pair\'s "pairs" entry).',
|
|
315
|
+
);
|
|
316
|
+
return { fromFallback, translated, share: fromFallback / translated };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Content lanes ────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Read-time validation shared by every content TM read (see lookupTMValidated):
|
|
323
|
+
* the gate a fresh answer passes (lib/validate.js contentGateFault), so a
|
|
324
|
+
* cached block the gate refuses today — a heading turned into a sentence,
|
|
325
|
+
* cached before the lane checked length — is evicted, not served.
|
|
326
|
+
*/
|
|
327
|
+
const passesGate = (pairConfig) => (src, cached) => !contentGateFault(src, cached, pairConfig);
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* TM ladder for one content segment (a block, a whole body): the pair's own
|
|
331
|
+
* cache, then its fallback's. Each read is validated; a failing entry is
|
|
332
|
+
* evicted and reported as a miss.
|
|
333
|
+
*
|
|
334
|
+
* @returns {{ text: string, fromFallback: boolean }|null}
|
|
335
|
+
*/
|
|
336
|
+
export function lookupContentTM(tm, source, code, pairConfig, { countCarry = true, gate = true } = {}) {
|
|
337
|
+
// gate false: a look for the repeat check, which names what it refuses —
|
|
338
|
+
// the gate still applies when the entry is served.
|
|
339
|
+
const check = (cfg) => (gate ? passesGate(cfg) : () => true);
|
|
340
|
+
const own = lookupTMValidated(tm, source, code, tmMethodKey(pairConfig), check(pairConfig), { countCarry });
|
|
341
|
+
if (own !== null) return { text: own, fromFallback: false };
|
|
342
|
+
if (!pairConfig.fallback) return null;
|
|
343
|
+
const fb = lookupTMValidated(tm, source, code, tmMethodKey(pairConfig.fallback), check(pairConfig.fallback), { countCarry });
|
|
344
|
+
return fb !== null ? { text: fb, fromFallback: true } : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Cached content and the repeat check ──────────────────────────────
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Items through the locale's different-inputs-same-output index; a suspect
|
|
351
|
+
* is evicted where it lives — every entry, under the pair's or its
|
|
352
|
+
* fallback's key, that holds that text for that source (lib/tm-evict.js).
|
|
353
|
+
*
|
|
354
|
+
* @param {Array<{ key: string, source: string, value: string }>} items
|
|
355
|
+
* @returns {Set<string>} Keys refused (and evicted)
|
|
356
|
+
*/
|
|
357
|
+
function refuseRepeatedCached(items, { sharedOutputs, tm, code, pairConfig }) {
|
|
358
|
+
const refused = new Set();
|
|
359
|
+
if (items.length === 0) return refused;
|
|
360
|
+
const suspects = sharedOutputs.suspects(items);
|
|
361
|
+
if (suspects.size === 0) return refused;
|
|
362
|
+
const evictor = createTMEvictor(tm);
|
|
363
|
+
const keys = tmKeysForPair(pairConfig);
|
|
364
|
+
for (const it of items) {
|
|
365
|
+
if (!suspects.has(it.key)) continue;
|
|
366
|
+
refused.add(it.key);
|
|
367
|
+
evictor.evictProducing(it.source, code, it.value, keys);
|
|
368
|
+
}
|
|
369
|
+
return refused;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A page's cached content — its front-matter fields, its blocks, its whole
|
|
374
|
+
* body — through the repeat check (lib/validate.js SharedOutputIndex) in ONE
|
|
375
|
+
* batch, before the lane serves any of it: the way a key-value file's cached
|
|
376
|
+
* keys are checked together (lib/translate-pair.js). Cache hits used to skip
|
|
377
|
+
* the check: one memorized sentence cached for three different paragraphs
|
|
378
|
+
* was re-served by every sync, and only `verify` caught it on disk.
|
|
379
|
+
*
|
|
380
|
+
* A repeat is evicted where it lives, so the lane's own cache lookups miss
|
|
381
|
+
* it and it is translated again with the misses — through the same check.
|
|
382
|
+
* A whole-body entry holding a repeated block is evicted too (the body is
|
|
383
|
+
* then translated block by block, where only the repeats are asked again).
|
|
384
|
+
* What passes is recorded in the index, so later answers are compared with
|
|
385
|
+
* it. Keys are named as `verify` and the fresh path name them:
|
|
386
|
+
* "<label> front matter:<field>" and "<label>#<n>" (n = place among the
|
|
387
|
+
* page's translatable blocks). A whole body whose block count differs from
|
|
388
|
+
* its source's cannot be compared block by block (verify skips it too).
|
|
389
|
+
*
|
|
390
|
+
* @param {object} p
|
|
391
|
+
* @param {string} p.label - "content:<file>"
|
|
392
|
+
* @param {Record<string, string>} p.fields - field → source text: the fields the lane looks up
|
|
393
|
+
* @param {string} p.body - Source body ('' = none)
|
|
394
|
+
* @param {Set<number>} [p.skipBlocks] - Block places the lane does not serve from the cache (kept by hand)
|
|
395
|
+
* @param {boolean} [p.wholeBody=true] - false when the lane does not serve a whole-body hit
|
|
396
|
+
* @param {object} p.tm
|
|
397
|
+
* @param {string} p.code - Target locale
|
|
398
|
+
* @param {object} p.pairConfig
|
|
399
|
+
* @param {import('./validate.js').SharedOutputIndex|null} p.sharedOutputs
|
|
400
|
+
* @returns {string[]} What was refused, for the lane's warning ("title", "paragraph 2")
|
|
401
|
+
*/
|
|
402
|
+
export function refuseRepeatedCachedPage({
|
|
403
|
+
label, fields = {}, body = '', skipBlocks = new Set(), wholeBody = true, tm, code, pairConfig, sharedOutputs,
|
|
404
|
+
}) {
|
|
405
|
+
if (!sharedOutputs || !tm) return [];
|
|
406
|
+
// A look, not a serve: a carried (other-model) hit is counted when served.
|
|
407
|
+
const peek = (source) => lookupContentTM(tm, source, code, pairConfig, { countCarry: false, gate: false });
|
|
408
|
+
const items = [];
|
|
409
|
+
for (const [field, source] of Object.entries(fields)) {
|
|
410
|
+
if (typeof source !== 'string') continue;
|
|
411
|
+
const hit = peek(source);
|
|
412
|
+
if (hit) items.push({ key: `${label} front matter:${field}`, source, value: hit.text, name: field });
|
|
413
|
+
}
|
|
414
|
+
const sources = typeof body === 'string' && body.trim() ? translatableBlockSources(body) : [];
|
|
415
|
+
const blockKeys = new Set();
|
|
416
|
+
sources.forEach((source, i) => {
|
|
417
|
+
if (skipBlocks.has(i)) return;
|
|
418
|
+
const hit = peek(source);
|
|
419
|
+
if (!hit) return;
|
|
420
|
+
items.push({ key: `${label}#${i + 1}`, source, value: hit.text, name: `paragraph ${i + 1}` });
|
|
421
|
+
blockKeys.add(`${label}#${i + 1}`);
|
|
422
|
+
});
|
|
423
|
+
let whole = null;
|
|
424
|
+
const wholeKeys = [];
|
|
425
|
+
if (wholeBody && sources.length > 0) {
|
|
426
|
+
const hit = peek(body);
|
|
427
|
+
const outs = hit ? translatableBlockSources(hit.text) : [];
|
|
428
|
+
if (hit && outs.length === sources.length) {
|
|
429
|
+
whole = hit;
|
|
430
|
+
sources.forEach((source, i) => {
|
|
431
|
+
const key = `${label}#${i + 1}`;
|
|
432
|
+
wholeKeys.push(key);
|
|
433
|
+
// The block's own entry, when there is one, speaks for it.
|
|
434
|
+
if (!blockKeys.has(key)) items.push({ key, source, value: outs[i], name: `paragraph ${i + 1}` });
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const ctx = { sharedOutputs, tm, code, pairConfig };
|
|
439
|
+
const refused = refuseRepeatedCached(items, ctx);
|
|
440
|
+
if (whole && wholeKeys.some(k => refused.has(k))) {
|
|
441
|
+
createTMEvictor(tm).evictProducing(body, code, whole.text, tmKeysForPair(pairConfig));
|
|
442
|
+
}
|
|
443
|
+
sharedOutputs.add(items.filter(it => !refused.has(it.key)));
|
|
444
|
+
return items.filter(it => refused.has(it.key)).map(it => it.name);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Serve front-matter fields the pair's own cache missed from its fallback's
|
|
449
|
+
* cache (validated). No-op without a fallback.
|
|
450
|
+
*
|
|
451
|
+
* @param {object} tm
|
|
452
|
+
* @param {Record<string,string>} fields - field → source text
|
|
453
|
+
* @param {string[]} misses - Fields the pair's own cache did not hold
|
|
454
|
+
* @param {string} code
|
|
455
|
+
* @param {object} pairConfig
|
|
456
|
+
* @returns {{ hits: Record<string,string>, misses: string[] }}
|
|
457
|
+
*/
|
|
458
|
+
export function serveFieldsFromFallbackCache(tm, fields, misses, code, pairConfig) {
|
|
459
|
+
if (!pairConfig.fallback || misses.length === 0) return { hits: {}, misses };
|
|
460
|
+
const fbKey = tmMethodKey(pairConfig.fallback);
|
|
461
|
+
const hits = {};
|
|
462
|
+
const rest = [];
|
|
463
|
+
for (const field of misses) {
|
|
464
|
+
const cached = lookupTMValidated(tm, fields[field], code, fbKey, passesGate(pairConfig.fallback));
|
|
465
|
+
if (cached !== null) hits[field] = cached;
|
|
466
|
+
else rest.push(field);
|
|
467
|
+
}
|
|
468
|
+
return { hits, misses: rest };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Translate the front-matter fields the TM did not hold: the pair's method,
|
|
473
|
+
* then — for every field it returned nothing for or hollowed — its fallback.
|
|
474
|
+
*
|
|
475
|
+
* NOTHING IS CACHED HERE. The lanes promise "nothing was written or cached"
|
|
476
|
+
* when a field fails, so the TM stores come back in `stores` and the caller
|
|
477
|
+
* commits them only once no field is left failing.
|
|
478
|
+
*
|
|
479
|
+
* Without a fallback this behaves exactly as the lanes always did: a null
|
|
480
|
+
* response is `noResults`, the first hollowed field is reported, a field
|
|
481
|
+
* missing from the response is left as it was.
|
|
482
|
+
*
|
|
483
|
+
* @param {object} p
|
|
484
|
+
* @param {Record<string,string>} p.fields - field → source text
|
|
485
|
+
* @param {string[]} p.misses - Fields to translate
|
|
486
|
+
* @param {object} p.pairConfig
|
|
487
|
+
* @param {(fields: string[], config: object) => Promise<object|null>} p.translate
|
|
488
|
+
* - The lane's translateBatch call for a given pair config
|
|
489
|
+
* @param {object|null} [p.budget] - createFallbackBudget(); null = no cap
|
|
490
|
+
* @param {Set<string>} [p.fallbackOnly] - Fields the pair's method refused
|
|
491
|
+
* before (lib/content-refusals.js): not sent to it, only to the fallback
|
|
492
|
+
* @returns {Promise<{ translated: Record<string,string>, stores: Array<{text: string, value: string, tmKey: string}>,
|
|
493
|
+
* hollowed: Array<{field: string, reason: string, value: string, fallbackReason?: string}>,
|
|
494
|
+
* noResults: boolean, report: FallbackReport|null, refusedBy: Record<string, string[]> }>}
|
|
495
|
+
* refusedBy: field → the method keys whose answer the gate refused, for the
|
|
496
|
+
* fields left untranslated (the lane remembers them)
|
|
497
|
+
*/
|
|
498
|
+
export async function translateFieldsWithFallback({
|
|
499
|
+
fields, misses, pairConfig, translate, budget = null, sharedOutputs = null, label = 'front matter',
|
|
500
|
+
fallbackOnly = new Set(),
|
|
501
|
+
}) {
|
|
502
|
+
const fb = pairConfig.fallback || null;
|
|
503
|
+
const primaryKey = tmMethodKey(pairConfig);
|
|
504
|
+
const translated = {};
|
|
505
|
+
const stores = [];
|
|
506
|
+
/** @type {Map<string, {reason: string|null, value?: string}>} field → why it failed */
|
|
507
|
+
const failed = new Map();
|
|
508
|
+
const fieldKey = (field) => `${label}:${field}`;
|
|
509
|
+
// A field answered with the text the model gave for other source strings
|
|
510
|
+
// (lib/validate.js SharedOutputIndex) is refused like a hollowed one.
|
|
511
|
+
const sharedCheck = (candidates) => {
|
|
512
|
+
if (!sharedOutputs || candidates.length === 0) return new Map();
|
|
513
|
+
const suspects = sharedOutputs.suspects(candidates.map(([field, value]) => ({ key: fieldKey(field), source: fields[field], value })));
|
|
514
|
+
return new Map(candidates.filter(([field]) => suspects.has(fieldKey(field)))
|
|
515
|
+
.map(([field]) => [field, sharedOutputReason(suspects.get(fieldKey(field)))]));
|
|
516
|
+
};
|
|
517
|
+
const remember = (pairs) => {
|
|
518
|
+
if (sharedOutputs) sharedOutputs.add(pairs.map(([field, value]) => ({ key: fieldKey(field), source: fields[field], value })));
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Fields the pair's method refused before go to the fallback only.
|
|
522
|
+
const primaryAsked = fb ? misses.filter(f => !fallbackOnly.has(f)) : misses;
|
|
523
|
+
const out = primaryAsked.length > 0 ? await translate(primaryAsked, pairConfig) : {};
|
|
524
|
+
for (const field of misses) if (!primaryAsked.includes(field)) failed.set(field, { reason: null, fallbackOnly: true });
|
|
525
|
+
if (out) {
|
|
526
|
+
const passing = [];
|
|
527
|
+
for (const field of primaryAsked) {
|
|
528
|
+
const value = out[field];
|
|
529
|
+
if (typeof value !== 'string') {
|
|
530
|
+
// Missing from the response: left untranslated, as always — unless
|
|
531
|
+
// there is a fallback to ask.
|
|
532
|
+
if (fb) failed.set(field, { reason: null });
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const refused = contentGateFault(fields[field], value, pairConfig);
|
|
536
|
+
if (refused) {
|
|
537
|
+
failed.set(field, { reason: refused, value });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
passing.push([field, value]);
|
|
541
|
+
}
|
|
542
|
+
const shared = sharedCheck(passing);
|
|
543
|
+
for (const [field, value] of passing) {
|
|
544
|
+
if (shared.has(field)) { failed.set(field, { reason: shared.get(field), value, sharedOutput: true }); continue; }
|
|
545
|
+
translated[field] = value;
|
|
546
|
+
stores.push({ text: fields[field], value, tmKey: primaryKey });
|
|
547
|
+
}
|
|
548
|
+
remember(passing.filter(([field]) => !shared.has(field)));
|
|
549
|
+
} else if (fb) {
|
|
550
|
+
for (const field of primaryAsked) failed.set(field, { reason: null });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// With a fallback, a report even when nothing goes to it: the primary's
|
|
554
|
+
// accepted fields count toward the run's share (warnFallbackMajority).
|
|
555
|
+
let report = fb ? newFallbackReport(fb) : null;
|
|
556
|
+
if (report) report.primaryAccepted = Object.keys(translated).length;
|
|
557
|
+
let fallbackOut = null;
|
|
558
|
+
if (fb && failed.size > 0) {
|
|
559
|
+
report.primaryFailedWholesale = !out;
|
|
560
|
+
const asked = [...failed.keys()];
|
|
561
|
+
for (const field of asked) {
|
|
562
|
+
const f = failed.get(field);
|
|
563
|
+
notePrimaryReason(report, refusalCategory(f.reason, { sharedOutput: !!f.sharedOutput, heldBefore: !!f.fallbackOnly, noAnswer: !f.reason }));
|
|
564
|
+
}
|
|
565
|
+
const chars = asked.reduce((n, f) => n + fields[f].length, 0);
|
|
566
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
|
|
567
|
+
if (!verdict.ok) {
|
|
568
|
+
report.skipped = { reason: verdict.reason, items: asked.map(f => `front matter "${f}"`) };
|
|
569
|
+
} else {
|
|
570
|
+
report.attempted = asked.length;
|
|
571
|
+
fallbackOut = await translate(asked, fb);
|
|
572
|
+
const fbKey = tmMethodKey(fb);
|
|
573
|
+
const fbPassing = [];
|
|
574
|
+
for (const field of asked) {
|
|
575
|
+
const value = fallbackOut?.[field];
|
|
576
|
+
if (typeof value !== 'string') continue;
|
|
577
|
+
const refused = contentGateFault(fields[field], value, fb);
|
|
578
|
+
if (refused) {
|
|
579
|
+
failed.get(field).fallbackReason = refused;
|
|
580
|
+
if (failed.get(field).fallbackOnly) failed.get(field).value = value;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
fbPassing.push([field, value]);
|
|
584
|
+
}
|
|
585
|
+
const fbShared = sharedCheck(fbPassing);
|
|
586
|
+
for (const [field, value] of fbPassing) {
|
|
587
|
+
if (fbShared.has(field)) {
|
|
588
|
+
failed.get(field).fallbackReason = fbShared.get(field);
|
|
589
|
+
if (failed.get(field).fallbackOnly) failed.get(field).value = value;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
translated[field] = value;
|
|
593
|
+
stores.push({ text: fields[field], value, tmKey: fbKey });
|
|
594
|
+
failed.delete(field);
|
|
595
|
+
report.accepted++;
|
|
596
|
+
}
|
|
597
|
+
remember(fbPassing.filter(([field]) => !fbShared.has(field)));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// A field still hollowed (by the primary, and by the fallback when there
|
|
602
|
+
// is one) fails the file, as always; the caller names the first. A field
|
|
603
|
+
// only the fallback was asked for fails it when the fallback's answer is
|
|
604
|
+
// refused too.
|
|
605
|
+
const hollowed = [];
|
|
606
|
+
const refusedBy = {};
|
|
607
|
+
const fbKey = fb ? tmMethodKey(fb) : null;
|
|
608
|
+
for (const [field, f] of failed) {
|
|
609
|
+
const methods = [...(f.reason ? [primaryKey] : []), ...(f.fallbackReason ? [fbKey] : [])];
|
|
610
|
+
if (methods.length > 0) refusedBy[field] = methods;
|
|
611
|
+
if (f.reason || (f.fallbackOnly && f.fallbackReason)) {
|
|
612
|
+
hollowed.push({
|
|
613
|
+
field, reason: f.reason || `refused before: the gate refused ${pairConfig.method}'s translation of it on an earlier sync`, value: f.value,
|
|
614
|
+
...(f.sharedOutput && { sharedOutput: true }),
|
|
615
|
+
...(f.fallbackReason && { fallbackReason: f.fallbackReason }),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
translated,
|
|
622
|
+
stores,
|
|
623
|
+
hollowed,
|
|
624
|
+
noResults: !out && Object.keys(translated).length === 0,
|
|
625
|
+
report,
|
|
626
|
+
refusedBy,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** ⟦PROTECTED_N⟧ tokens in a protected text. */
|
|
631
|
+
function protectedTokens(text) {
|
|
632
|
+
const re = new RegExp(`${PLACEHOLDER_PREFIX}\\d+${PLACEHOLDER_SUFFIX}`, 'g');
|
|
633
|
+
return text.match(re) || [];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Why a translated Markdown block cannot be used, or null. The same gates
|
|
638
|
+
* the lanes apply to a whole body — orphaned placeholders, lost content —
|
|
639
|
+
* applied per block so the failing block (not the whole file) goes to the
|
|
640
|
+
* fallback, plus a check the whole-body gate cannot make: every protected
|
|
641
|
+
* element of the source block (code, link target, shortcode, HTML) must
|
|
642
|
+
* still be there.
|
|
643
|
+
*
|
|
644
|
+
* Used only for pairs with a fallback: without one there is no second
|
|
645
|
+
* method to route a block to, and the whole-body gates decide as before.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} protectedSource - The block as sent (placeholders in)
|
|
648
|
+
* @param {string} protectedOut - The model's block (placeholders in)
|
|
649
|
+
* @param {string} restoredOut - protectedOut with placeholders restored
|
|
650
|
+
* @param {string} restoredSource - The block's source text, restored
|
|
651
|
+
* @returns {string|null}
|
|
652
|
+
*/
|
|
653
|
+
export function blockFault(protectedSource, protectedOut, restoredOut, restoredSource, pairConfig = {}) {
|
|
654
|
+
if (hasOrphanedPlaceholders(restoredOut)) return 'a protected element was damaged';
|
|
655
|
+
const dropped = protectedTokens(protectedSource).filter(t => !protectedOut.includes(t));
|
|
656
|
+
if (dropped.length > 0) return `${dropped.length} protected element(s) (code, link, markup) missing`;
|
|
657
|
+
// The key-value gate's checks, per block (lib/validate.js contentGateFault):
|
|
658
|
+
// hollowing, echo, length inflation, truncation, repetition, script.
|
|
659
|
+
return contentGateFault(restoredSource, restoredOut, pairConfig);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Translate the Markdown blocks the TM ladder did not hold: one batch through
|
|
664
|
+
* the pair's method, then one batch through its fallback for every block the
|
|
665
|
+
* primary dropped or damaged. A block both fail is written as
|
|
666
|
+
* `fallbackPrefix` + its source — the honest '[EN] ' last resort (never
|
|
667
|
+
* cached; the caller does not advance the file's lock).
|
|
668
|
+
*
|
|
669
|
+
* Without a fallback this is exactly the lanes' old block path: the primary's
|
|
670
|
+
* output stands, its dropped blocks carry the prefix, and a structural
|
|
671
|
+
* failure (no response, duplicate markers) throws.
|
|
672
|
+
*
|
|
673
|
+
* With a fallback, a structural primary failure sends every block to the
|
|
674
|
+
* fallback; only when the fallback ALSO produces nothing does the primary's
|
|
675
|
+
* error fail the file. When --max-cost skips the fallback, the primary's
|
|
676
|
+
* output stands exactly as it would without one.
|
|
677
|
+
*
|
|
678
|
+
* @param {object} p
|
|
679
|
+
* @param {Array<{seg: {text: string}, source: string}>} p.missed - Blocks to translate
|
|
680
|
+
* @param {Map<string,string>} p.blocks - protectBlocks() placeholder map
|
|
681
|
+
* @param {object} p.pairConfig
|
|
682
|
+
* @param {(texts: string[], config: object) => Promise<{blocks: string[], fellBack: number[]}>} p.runBatch
|
|
683
|
+
* - The lane's translateBlockBatchResilient call for a given pair config
|
|
684
|
+
* @param {string} p.fallbackPrefix
|
|
685
|
+
* @param {object|null} [p.budget]
|
|
686
|
+
* @param {import('./validate.js').SharedOutputIndex|null} [p.sharedOutputs] - The
|
|
687
|
+
* locale's different-inputs-same-output index: a block answered with the
|
|
688
|
+
* text the model gave for other source strings is refused like a damaged
|
|
689
|
+
* one (the fallback gets it; without one, the honest '[EN] ' last resort)
|
|
690
|
+
* @param {string} [p.label] - Names the blocks in that index ("posts/a.md")
|
|
691
|
+
* @param {Set<number>} [p.fallbackOnly] - Indices (into `missed`) of blocks the
|
|
692
|
+
* pair's method refused before (lib/content-refusals.js): not sent to it,
|
|
693
|
+
* only to the fallback ('[EN] ' when the fallback does not translate them)
|
|
694
|
+
* @returns {Promise<{ outs: string[], stores: Array<{source: string, translation: string, tmKey: string}>,
|
|
695
|
+
* fellBack: number[], fromFallback: number, report: FallbackReport|null, sharedOutput: number[],
|
|
696
|
+
* refused: Array<{ i: number, block: number, reason: string }>, refusedBy: string[][] }>} refused: blocks written as the
|
|
697
|
+
* '[EN] ' last resort because the gate refused them (block = place among the page's blocks, from 1);
|
|
698
|
+
* refusedBy[i]: the method keys whose answer for block i the gate refused, for blocks left as '[EN] '
|
|
699
|
+
* (empty for the rest) — the lane remembers them
|
|
700
|
+
*/
|
|
701
|
+
export async function translateBlocksWithFallback({
|
|
702
|
+
missed, blocks, pairConfig, runBatch, fallbackPrefix, budget = null, sharedOutputs = null, label = 'block',
|
|
703
|
+
fallbackOnly = new Set(),
|
|
704
|
+
}) {
|
|
705
|
+
const texts = missed.map(r => r.seg.text);
|
|
706
|
+
const primaryKey = tmMethodKey(pairConfig);
|
|
707
|
+
const fb = pairConfig.fallback || null;
|
|
708
|
+
// Whose answers the gate refused, per block (the primary's, the fallback's).
|
|
709
|
+
const primaryRefused = new Set();
|
|
710
|
+
const fallbackRefused = new Set();
|
|
711
|
+
const refusedByOf = (fellBack) => {
|
|
712
|
+
const fell = new Set(fellBack);
|
|
713
|
+
return texts.map((_, i) => (fell.has(i)
|
|
714
|
+
? [...(primaryRefused.has(i) ? [primaryKey] : []), ...(fallbackRefused.has(i) ? [tmMethodKey(fb)] : [])]
|
|
715
|
+
: []));
|
|
716
|
+
};
|
|
717
|
+
// Named by the block's place among the page's translatable blocks (as
|
|
718
|
+
// verify and the cached-block check name it), not its place in `missed`.
|
|
719
|
+
const blockKey = (i) => `${label}#${(Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1}`;
|
|
720
|
+
// Blocks whose output repeats what the model answered for other sources.
|
|
721
|
+
const sharedFault = (candidates) => {
|
|
722
|
+
if (!sharedOutputs) return new Set();
|
|
723
|
+
const suspects = sharedOutputs.suspects(candidates.map(({ i, out }) => ({ key: blockKey(i), source: missed[i].source, value: out })));
|
|
724
|
+
return new Set(candidates.filter(({ i }) => suspects.has(blockKey(i))).map(({ i }) => i));
|
|
725
|
+
};
|
|
726
|
+
const remember = (accepted) => {
|
|
727
|
+
if (sharedOutputs) sharedOutputs.add(accepted.map(({ i, out }) => ({ key: blockKey(i), source: missed[i].source, value: out })));
|
|
728
|
+
};
|
|
729
|
+
// Why the gate refused a block (index → reason), for the lane's warning.
|
|
730
|
+
const refusedReason = new Map();
|
|
731
|
+
/** Blocks written as the '[EN] ' last resort because the gate refused them. */
|
|
732
|
+
const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
|
|
733
|
+
.map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
|
|
734
|
+
|
|
735
|
+
if (!fb) {
|
|
736
|
+
const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
|
|
737
|
+
const fellSet = new Set(fellBack);
|
|
738
|
+
const outs = translatedBlocks.map(t => restoreBlocks(t, blocks));
|
|
739
|
+
// The key-value gate's checks, per block: a short heading turned into a
|
|
740
|
+
// sentence is refused here as an app key is (Round 7, school persona).
|
|
741
|
+
for (let i = 0; i < outs.length; i++) {
|
|
742
|
+
if (fellSet.has(i)) continue;
|
|
743
|
+
const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
|
|
744
|
+
if (fault) refusedReason.set(i, fault);
|
|
745
|
+
}
|
|
746
|
+
const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
|
|
747
|
+
const stores = [];
|
|
748
|
+
const accepted = [];
|
|
749
|
+
outs.forEach((out, i) => {
|
|
750
|
+
if (shared.has(i) || refusedReason.has(i)) {
|
|
751
|
+
// Refused: the honest last resort, never cached.
|
|
752
|
+
outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
|
|
753
|
+
fellBack.push(i);
|
|
754
|
+
primaryRefused.add(i);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (!fellSet.has(i)) {
|
|
758
|
+
stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
|
|
759
|
+
accepted.push({ i, out });
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
remember(accepted);
|
|
763
|
+
return {
|
|
764
|
+
outs, stores, fellBack, fromFallback: 0, report: null, sharedOutput: [...shared], refused: refusedList(fellBack),
|
|
765
|
+
refusedBy: refusedByOf(fellBack),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Blocks the pair's method refused before are not sent to it again.
|
|
770
|
+
const primaryIdx = texts.map((_, i) => i).filter(i => !fallbackOnly.has(i));
|
|
771
|
+
let primary = null;
|
|
772
|
+
let primaryError = null;
|
|
773
|
+
try {
|
|
774
|
+
if (primaryIdx.length === texts.length) {
|
|
775
|
+
primary = await runBatch(texts, pairConfig);
|
|
776
|
+
} else if (primaryIdx.length > 0) {
|
|
777
|
+
// Back to the page's own block indices.
|
|
778
|
+
const res = await runBatch(primaryIdx.map(i => texts[i]), pairConfig);
|
|
779
|
+
const all = new Array(texts.length);
|
|
780
|
+
primaryIdx.forEach((i, j) => { all[i] = res.blocks[j]; });
|
|
781
|
+
primary = { blocks: all, fellBack: res.fellBack.map(j => primaryIdx[j]) };
|
|
782
|
+
} else {
|
|
783
|
+
primary = { blocks: new Array(texts.length), fellBack: [] };
|
|
784
|
+
}
|
|
785
|
+
} catch (err) {
|
|
786
|
+
primaryError = err;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const outs = new Array(texts.length);
|
|
790
|
+
const stores = [];
|
|
791
|
+
const failed = [];
|
|
792
|
+
const primaryFell = new Set(primary ? primary.fellBack : []);
|
|
793
|
+
const sharedIdx = new Set();
|
|
794
|
+
if (primary) {
|
|
795
|
+
const good = [];
|
|
796
|
+
for (let i = 0; i < texts.length; i++) {
|
|
797
|
+
if (primaryFell.has(i) || fallbackOnly.has(i)) { failed.push(i); continue; }
|
|
798
|
+
const restored = restoreBlocks(primary.blocks[i], blocks);
|
|
799
|
+
const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
|
|
800
|
+
if (fault) {
|
|
801
|
+
refusedReason.set(i, fault);
|
|
802
|
+
primaryRefused.add(i);
|
|
803
|
+
failed.push(i);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
good.push({ i, out: restored });
|
|
807
|
+
}
|
|
808
|
+
const shared = sharedFault(good);
|
|
809
|
+
for (const { i, out } of good) {
|
|
810
|
+
if (shared.has(i)) { failed.push(i); sharedIdx.add(i); primaryRefused.add(i); continue; }
|
|
811
|
+
outs[i] = out;
|
|
812
|
+
stores.push({ source: missed[i].source, translation: out, tmKey: primaryKey });
|
|
813
|
+
}
|
|
814
|
+
remember(good.filter(({ i }) => !shared.has(i)));
|
|
815
|
+
} else {
|
|
816
|
+
for (let i = 0; i < texts.length; i++) failed.push(i);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const report = newFallbackReport(fb);
|
|
820
|
+
report.primaryFailedWholesale = !primary;
|
|
821
|
+
// The primary's accepted blocks, and why the rest go to the fallback
|
|
822
|
+
// (lib/fallback.js warnFallbackMajority).
|
|
823
|
+
report.primaryAccepted = outs.filter(o => o !== undefined).length;
|
|
824
|
+
for (const i of failed) {
|
|
825
|
+
notePrimaryReason(report, refusalCategory(refusedReason.get(i) || null, {
|
|
826
|
+
sharedOutput: sharedIdx.has(i), heldBefore: fallbackOnly.has(i), noAnswer: !refusedReason.has(i) && !sharedIdx.has(i),
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
const fellBack = [];
|
|
830
|
+
failed.sort((a, b) => a - b);
|
|
831
|
+
if (failed.length === 0) return { outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [], refused: [], refusedBy: refusedByOf([]) };
|
|
832
|
+
|
|
833
|
+
const chars = failed.reduce((n, i) => n + missed[i].source.length, 0);
|
|
834
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(chars), fb) : { ok: true };
|
|
835
|
+
if (!verdict.ok) {
|
|
836
|
+
report.skipped = { reason: verdict.reason, items: failed.map(i => `block ${i + 1} of ${texts.length}`) };
|
|
837
|
+
if (primaryError) throw primaryError;
|
|
838
|
+
// The primary's own output stands, exactly as without a fallback —
|
|
839
|
+
// except a block refused as a repeated (memorized) answer, and a block
|
|
840
|
+
// only the fallback was to be asked for (the primary had no answer).
|
|
841
|
+
for (const i of failed) {
|
|
842
|
+
if (sharedIdx.has(i) || primaryFell.has(i) || refusedReason.has(i) || fallbackOnly.has(i)) {
|
|
843
|
+
outs[i] = sharedIdx.has(i) || refusedReason.has(i) || fallbackOnly.has(i)
|
|
844
|
+
? restoreBlocks(fallbackPrefix + texts[i], blocks) : restoreBlocks(primary.blocks[i], blocks);
|
|
845
|
+
fellBack.push(i);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
outs[i] = restoreBlocks(primary.blocks[i], blocks);
|
|
849
|
+
stores.push({ source: missed[i].source, translation: outs[i], tmKey: primaryKey });
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
outs, stores, fellBack, fromFallback: 0, report, sharedOutput: [...sharedIdx], refused: refusedList(fellBack),
|
|
853
|
+
refusedBy: refusedByOf(fellBack),
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
report.attempted = failed.length;
|
|
858
|
+
let second = null;
|
|
859
|
+
try {
|
|
860
|
+
second = await runBatch(failed.map(i => texts[i]), fb);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
if (primaryError) {
|
|
863
|
+
primaryError.message += ` (the fallback, ${fb.method}, failed too: ${err.message})`;
|
|
864
|
+
throw primaryError;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!second && primaryError) throw primaryError;
|
|
868
|
+
|
|
869
|
+
const fbKey = tmMethodKey(fb);
|
|
870
|
+
const secondFell = new Set(second ? second.fellBack : []);
|
|
871
|
+
let fromFallback = 0;
|
|
872
|
+
const fbGood = [];
|
|
873
|
+
failed.forEach((i, j) => {
|
|
874
|
+
if (second && !secondFell.has(j)) {
|
|
875
|
+
const restored = restoreBlocks(second.blocks[j], blocks);
|
|
876
|
+
const fbFault = blockFault(texts[i], second.blocks[j], restored, missed[i].source, fb);
|
|
877
|
+
if (!fbFault) fbGood.push({ i, out: restored });
|
|
878
|
+
else {
|
|
879
|
+
refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
|
|
880
|
+
fallbackRefused.add(i);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
const fbShared = sharedFault(fbGood);
|
|
885
|
+
for (const i of fbShared) fallbackRefused.add(i);
|
|
886
|
+
const fbAccepted = new Map(fbGood.filter(({ i }) => !fbShared.has(i)).map(({ i, out }) => [i, out]));
|
|
887
|
+
remember([...fbAccepted].map(([i, out]) => ({ i, out })));
|
|
888
|
+
for (const i of failed) {
|
|
889
|
+
if (fbAccepted.has(i)) {
|
|
890
|
+
outs[i] = fbAccepted.get(i);
|
|
891
|
+
stores.push({ source: missed[i].source, translation: outs[i], tmKey: fbKey });
|
|
892
|
+
report.accepted++;
|
|
893
|
+
fromFallback++;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
// Both methods failed this block: the honest last resort.
|
|
897
|
+
outs[i] = restoreBlocks(fallbackPrefix + texts[i], blocks);
|
|
898
|
+
fellBack.push(i);
|
|
899
|
+
}
|
|
900
|
+
return {
|
|
901
|
+
outs, stores, fellBack, fromFallback, report, sharedOutput: [...sharedIdx].filter(i => !fbAccepted.has(i)),
|
|
902
|
+
refused: refusedList(fellBack),
|
|
903
|
+
refusedBy: refusedByOf(fellBack),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Whole-page ('page' segmentation) translation with a fallback. The pair's
|
|
909
|
+
* method first; when it returns nothing, damages a placeholder or hollows
|
|
910
|
+
* the body, the fallback translates the whole page once. Returns null for
|
|
911
|
+
* `body` when both fail — the caller then fails the file exactly as it does
|
|
912
|
+
* without a fallback (its whole-body checks name the reason).
|
|
913
|
+
*
|
|
914
|
+
* Only called for pairs WITH a fallback; the lanes keep their own page path
|
|
915
|
+
* otherwise.
|
|
916
|
+
*
|
|
917
|
+
* @param {object} p
|
|
918
|
+
* @param {string} p.body - Source body (restored)
|
|
919
|
+
* @param {Map<string,string>} p.blocks - protectBlocks() map
|
|
920
|
+
* @param {object} p.pairConfig
|
|
921
|
+
* @param {(config: object) => Promise<string|null>} p.runPage - One page call for a config
|
|
922
|
+
* @param {object|null} [p.budget]
|
|
923
|
+
* @param {boolean} [p.fallbackOnly] - The pair's method refused this page
|
|
924
|
+
* before (lib/content-refusals.js): it is not asked, only the fallback is
|
|
925
|
+
* @returns {Promise<{ body: string|null, primaryBody: string|null, tmKey: string|null, report: FallbackReport,
|
|
926
|
+
* refusedBy: string[] }>} refusedBy: the method keys whose page the whole-body checks refused this run
|
|
927
|
+
*/
|
|
928
|
+
export async function translatePageWithFallback({ body, blocks, pairConfig, runPage, budget = null, fallbackOnly = false }) {
|
|
929
|
+
const fb = pairConfig.fallback;
|
|
930
|
+
const report = newFallbackReport(fb);
|
|
931
|
+
const fault = (restored) => hasOrphanedPlaceholders(restored) || !!checkContentPreservation(body, restored);
|
|
932
|
+
const refusedBy = [];
|
|
933
|
+
|
|
934
|
+
let primaryBody = null;
|
|
935
|
+
if (!fallbackOnly) {
|
|
936
|
+
const first = await runPage(pairConfig);
|
|
937
|
+
primaryBody = first ? restoreBlocks(first, blocks) : null;
|
|
938
|
+
if (primaryBody !== null && !fault(primaryBody)) {
|
|
939
|
+
report.primaryAccepted = 1;
|
|
940
|
+
return { body: primaryBody, primaryBody, tmKey: tmMethodKey(pairConfig), report, refusedBy };
|
|
941
|
+
}
|
|
942
|
+
if (primaryBody !== null) refusedBy.push(tmMethodKey(pairConfig));
|
|
943
|
+
report.primaryFailedWholesale = first === null;
|
|
944
|
+
}
|
|
945
|
+
// Why the page goes to the fallback (warnFallbackMajority).
|
|
946
|
+
notePrimaryReason(report, fallbackOnly
|
|
947
|
+
? refusalCategory(null, { heldBefore: true })
|
|
948
|
+
: refusalCategory(primaryBody === null ? null : 'content lost or changed', { noAnswer: primaryBody === null }));
|
|
949
|
+
|
|
950
|
+
const verdict = budget ? await budget.approve(charsToKeyUnits(body.length), fb) : { ok: true };
|
|
951
|
+
if (!verdict.ok) {
|
|
952
|
+
report.skipped = { reason: verdict.reason, items: ['the page body'] };
|
|
953
|
+
return { body: null, primaryBody, tmKey: null, report, refusedBy };
|
|
954
|
+
}
|
|
955
|
+
report.attempted = 1;
|
|
956
|
+
const second = await runPage(fb);
|
|
957
|
+
const fallbackBody = second ? restoreBlocks(second, blocks) : null;
|
|
958
|
+
if (fallbackBody !== null && !fault(fallbackBody)) {
|
|
959
|
+
report.accepted = 1;
|
|
960
|
+
return { body: fallbackBody, primaryBody, tmKey: tmMethodKey(fb), report, refusedBy };
|
|
961
|
+
}
|
|
962
|
+
if (fallbackBody !== null) refusedBy.push(tmMethodKey(fb));
|
|
963
|
+
return { body: null, primaryBody, tmKey: null, report, refusedBy };
|
|
964
|
+
}
|