champollion 0.5.1 → 0.5.3
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/lib/command-help.js +2 -1
- package/lib/commands/models.js +50 -0
- package/lib/config.js +3 -1
- package/lib/content-refusals.js +54 -0
- package/lib/content-sync.js +18 -2
- package/lib/docusaurus-sync.js +21 -2
- package/lib/fallback.js +53 -21
- package/lib/methods/anthropic.js +2 -1
- package/lib/methods/gemini.js +2 -1
- package/lib/methods/openai.js +4 -3
- package/lib/model-defaults.js +113 -0
- package/lib/validate.js +65 -6
- package/package.json +1 -1
- package/shared/docent/corpus.json +13573 -0
- package/shared/model-defaults.json +47 -0
package/lib/command-help.js
CHANGED
|
@@ -711,7 +711,7 @@ const COMMAND_HELP = {
|
|
|
711
711
|
},
|
|
712
712
|
|
|
713
713
|
models: {
|
|
714
|
-
usage: 'champollion models --method <provider>',
|
|
714
|
+
usage: 'champollion models --method <provider> | champollion models check',
|
|
715
715
|
description: [
|
|
716
716
|
'Lists available models from a translation provider\'s API.',
|
|
717
717
|
'Queries the provider\'s live model endpoint and displays all',
|
|
@@ -719,6 +719,7 @@ const COMMAND_HELP = {
|
|
|
719
719
|
],
|
|
720
720
|
options: [
|
|
721
721
|
['--method <name>', 'Provider to query: gemini, openai, or anthropic (required)'],
|
|
722
|
+
['check', 'Check every default model (shared/model-defaults.json) against the providers\' live lists; exit 1 if one is gone. Free (list endpoints are not billed)'],
|
|
722
723
|
['--json', 'Machine-readable JSON output (single document)'],
|
|
723
724
|
],
|
|
724
725
|
examples: [
|
package/lib/commands/models.js
CHANGED
|
@@ -21,6 +21,8 @@ import { resolveConfig } from '../config.js';
|
|
|
21
21
|
import { fetchAvailableModels, resolveProviderApiKey, getProviderLabel, getProviderEnvVar, isListableProvider, getListableProviders } from '../models.js';
|
|
22
22
|
import { missingKeyAdvice } from '../missing-key.js';
|
|
23
23
|
import { output } from '../output.js';
|
|
24
|
+
import { checkModelDefaults } from '../model-defaults.js';
|
|
25
|
+
import { fetchModelPricing } from '../methods/openrouter-pricing.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
|
|
@@ -38,6 +40,8 @@ async function run(args, cwd) {
|
|
|
38
40
|
return 0;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
if (args._[1] === 'check') return runCheck(args, cwd, json);
|
|
44
|
+
|
|
41
45
|
const method = args.method;
|
|
42
46
|
if (!method) {
|
|
43
47
|
if (json) {
|
|
@@ -182,3 +186,49 @@ function showHelp() {
|
|
|
182
186
|
}
|
|
183
187
|
|
|
184
188
|
export { run };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `champollion models check` — every default model (shared/model-defaults.json)
|
|
192
|
+
* against the providers' live lists. Read-only and free: list endpoints are
|
|
193
|
+
* not billed. Exit 1 when a pinned default is no longer listed (a run would
|
|
194
|
+
* fail on it); a newer model matching a role's rule is reported, not a
|
|
195
|
+
* failure — moving a default is a reviewed change, never automatic.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} args
|
|
198
|
+
* @param {string} cwd
|
|
199
|
+
* @param {boolean} json
|
|
200
|
+
* @returns {Promise<number>}
|
|
201
|
+
*/
|
|
202
|
+
async function runCheck(args, cwd, json) {
|
|
203
|
+
const lists = { openrouter: null, openai: null, gemini: null, anthropic: null };
|
|
204
|
+
try {
|
|
205
|
+
const pricing = await fetchModelPricing();
|
|
206
|
+
if (pricing && pricing.size > 0) lists.openrouter = [...pricing.keys()];
|
|
207
|
+
} catch { /* unchecked */ }
|
|
208
|
+
for (const provider of ['openai', 'gemini', 'anthropic']) {
|
|
209
|
+
const key = resolveProviderApiKey(provider, cwd);
|
|
210
|
+
if (key) lists[provider] = await fetchAvailableModels(provider, key);
|
|
211
|
+
}
|
|
212
|
+
const rows = checkModelDefaults(lists);
|
|
213
|
+
const gone = rows.filter(r => r.status === 'gone');
|
|
214
|
+
if (json) {
|
|
215
|
+
console.log(JSON.stringify({ command: 'models check', ok: gone.length === 0, roles: rows }, null, 2));
|
|
216
|
+
return gone.length > 0 ? 1 : 0;
|
|
217
|
+
}
|
|
218
|
+
output.raw('\n Default models (shared/model-defaults.json) against the providers\' live lists:\n');
|
|
219
|
+
for (const r of rows) {
|
|
220
|
+
const mark = { ok: '[OK] ', newer: '[NEW] ', gone: '[GONE]', unchecked: '[--] ' }[r.status];
|
|
221
|
+
output.raw(` ${mark} ${r.role.padEnd(10)} ${r.model.padEnd(34)} ${r.note}`);
|
|
222
|
+
}
|
|
223
|
+
const unchecked = rows.filter(r => r.status === 'unchecked').map(r => r.provider);
|
|
224
|
+
if (unchecked.length > 0) {
|
|
225
|
+
output.raw(`\n Not checked (no key for ${[...new Set(unchecked)].map(p => getProviderEnvVar(p) || p).join(', ')}): set it to check that provider's list.`);
|
|
226
|
+
}
|
|
227
|
+
output.raw('');
|
|
228
|
+
if (gone.length > 0) {
|
|
229
|
+
output.error(`${gone.length} default model(s) are no longer listed by their provider — a run on them would fail. `
|
|
230
|
+
+ 'Update shared/model-defaults.json (in the repo: node cli/scripts/check-model-defaults.mjs --update) and release.');
|
|
231
|
+
return 1;
|
|
232
|
+
}
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* complex setups with custom registers, models, and batch sizes.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { defaultModel } from './model-defaults.js';
|
|
14
15
|
import fs from 'node:fs';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import { DEFAULT_REGISTERS, getLanguageCard, getRegister, resolveCode } from './registers.js';
|
|
@@ -25,7 +26,8 @@ const CONFIG_FILENAMES = ['champollion.config.json'];
|
|
|
25
26
|
|
|
26
27
|
// Canonical defaults — import these in any module that needs a fallback
|
|
27
28
|
// instead of hardcoding the string/number inline.
|
|
28
|
-
|
|
29
|
+
// shared/model-defaults.json, role "translate" — never written here (lib/model-defaults.js).
|
|
30
|
+
const DEFAULT_OPENROUTER_MODEL = defaultModel('translate');
|
|
29
31
|
const DEFAULT_BATCH_SIZE = 80;
|
|
30
32
|
// Max parallel API calls for JSON key-value translation. 50 is kind to
|
|
31
33
|
// free/low-tier keys on a zero-config first run (200 would hammer 429s).
|
package/lib/content-refusals.js
CHANGED
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
* paragraph written by hand — drops its record.
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
+
import fs from 'node:fs';
|
|
55
|
+
import path from 'node:path';
|
|
56
|
+
import { output } from './output.js';
|
|
54
57
|
import { tmMethodKey, lookupTM } from './tm.js';
|
|
55
58
|
import { GATE_VERSION } from './validate.js';
|
|
56
59
|
import { holdState, shortSourceHash } from './locale-state.js';
|
|
@@ -282,3 +285,54 @@ export function previewHeld({ tm, code, pairConfig, fields, body, segMode, holds
|
|
|
282
285
|
}
|
|
283
286
|
return { names, pageHeld: false };
|
|
284
287
|
}
|
|
288
|
+
|
|
289
|
+
// ── What a run left in the source language ───────────────────────────
|
|
290
|
+
|
|
291
|
+
/** Where refused answers are logged (the per-machine cache folder, never committed). */
|
|
292
|
+
export const REFUSAL_LOG = path.join('.champollion', 'refused.jsonl');
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Append one refused answer to `.champollion/refused.jsonl`: the source, what
|
|
296
|
+
* the model answered, and why the gate refused it. WHY: a refused answer was
|
|
297
|
+
* thrown away, so "why did it refuse that twice?" could only be answered by
|
|
298
|
+
* paying the model again (2026-10-06 — and the answer then showed the gate,
|
|
299
|
+
* not the model, was wrong). Never fails the run.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} cwd
|
|
302
|
+
* @param {object} rec - { pair, method, file, locale, paragraph, reason, source, answer }
|
|
303
|
+
*/
|
|
304
|
+
export function logRefusal(cwd, rec) {
|
|
305
|
+
try {
|
|
306
|
+
const file = path.join(cwd, REFUSAL_LOG);
|
|
307
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
308
|
+
fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...rec })}\n`, 'utf-8');
|
|
309
|
+
} catch { /* a diagnostic: never fails the run */ }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The run's closing report when anything was left in the source language —
|
|
314
|
+
* last, as an error, never under an [OK] (2026-10-06: a 1,082-page sync ended
|
|
315
|
+
* on "[OK] Created 1082 content file(s)" with seven untranslated blocks named
|
|
316
|
+
* only in warnings mid-stream).
|
|
317
|
+
*
|
|
318
|
+
* @param {Array<{ file: string, locale: string, pair: string, where: string, reason: string }>} items
|
|
319
|
+
*/
|
|
320
|
+
export function reportLeftInSource(items) {
|
|
321
|
+
if (!items || items.length === 0) return;
|
|
322
|
+
const pages = new Map();
|
|
323
|
+
for (const it of items) {
|
|
324
|
+
const k = `${it.file}\u0000${it.pair}`;
|
|
325
|
+
if (!pages.has(k)) pages.set(k, { ...it, wheres: [] });
|
|
326
|
+
pages.get(k).wheres.push(`${it.where}: ${it.reason}`);
|
|
327
|
+
}
|
|
328
|
+
output.raw('');
|
|
329
|
+
output.error(`Not finished: ${items.length} part(s) of ${pages.size} page translation(s) are still in the source language — `
|
|
330
|
+
+ 'the quality gate refused the model\'s answer, also when asked again with the reason:');
|
|
331
|
+
for (const p of pages.values()) {
|
|
332
|
+
output.raw(` ${p.file} → ${p.locale}: ${p.wheres.slice(0, 3).join('; ')}${p.wheres.length > 3 ? `; +${p.wheres.length - 3} more` : ''}`);
|
|
333
|
+
}
|
|
334
|
+
const first = [...pages.values()][0];
|
|
335
|
+
output.raw(` The rest of each page is written. What the model answered is in ${REFUSAL_LOG}. `
|
|
336
|
+
+ `Ask again: \`${contentRedoCommand(first.file, { pair: first.pair })}\`${pages.size > 1 ? ' (one per page)' : ''}, `
|
|
337
|
+
+ 'or add a "fallback" method to the pair.');
|
|
338
|
+
}
|
package/lib/content-sync.js
CHANGED
|
@@ -89,7 +89,7 @@ import {
|
|
|
89
89
|
} from './fallback.js';
|
|
90
90
|
import {
|
|
91
91
|
readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
|
|
92
|
-
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
|
|
92
|
+
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
|
|
93
93
|
} from './content-refusals.js';
|
|
94
94
|
import { contentRedoCommand } from './verify.js';
|
|
95
95
|
|
|
@@ -115,6 +115,8 @@ import { contentRedoCommand } from './verify.js';
|
|
|
115
115
|
* (null = no cap).
|
|
116
116
|
*/
|
|
117
117
|
async function runContentSync(options) {
|
|
118
|
+
// Parts this run left in the source language — reported at the end, as an error.
|
|
119
|
+
const leftInSource = [];
|
|
118
120
|
const {
|
|
119
121
|
contentDir,
|
|
120
122
|
sourceLocale,
|
|
@@ -700,6 +702,11 @@ async function runContentSync(options) {
|
|
|
700
702
|
// field keeps its source text; the page is still written.
|
|
701
703
|
fieldsLeftInSource = true;
|
|
702
704
|
for (const h of fm.hollowed) {
|
|
705
|
+
leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
|
|
706
|
+
logRefusal(cwd, {
|
|
707
|
+
pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
|
|
708
|
+
field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
|
|
709
|
+
});
|
|
703
710
|
const advice = h.sharedOutput
|
|
704
711
|
? ` ${pairConfig.method} answers it only with a sentence it gave for other strings — `
|
|
705
712
|
+ `${pairConfig.fallback ? 'and the fallback did not translate it either' : 'add a "fallback" method to the pair'}, or write it in ${targetRel} by hand (kept).`
|
|
@@ -990,6 +997,13 @@ async function runContentSync(options) {
|
|
|
990
997
|
// Blocks the quality gate refused — the key-value gate's checks
|
|
991
998
|
// (length inflation, echo, truncation, script), per block.
|
|
992
999
|
const refusedByGate = batchOutcome.refused || [];
|
|
1000
|
+
for (const r of refusedByGate) {
|
|
1001
|
+
leftInSource.push({ file: relPath, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
|
|
1002
|
+
logRefusal(cwd, {
|
|
1003
|
+
pair: pairKey, method: tmMethodKey(pairConfig), file: relPath, locale: code,
|
|
1004
|
+
paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
993
1007
|
if (refusedByGate.length > 0) {
|
|
994
1008
|
output.warn(
|
|
995
1009
|
`Content sync body for ${code}: ${refusedByGate.length} block(s) of ${relPath} refused by the quality gate, also when asked again with the reason — `
|
|
@@ -1210,9 +1224,11 @@ async function runContentSync(options) {
|
|
|
1210
1224
|
const heldBackNote = heldBackSeen > 0 ? `; ${heldBackSeen} block(s)/field(s) refused before would be held back (not sent)` : '';
|
|
1211
1225
|
output.info(`Would have created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${heldBackNote}.`);
|
|
1212
1226
|
} else {
|
|
1213
|
-
|
|
1227
|
+
const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
|
|
1228
|
+
(unfinished ? output.warn : output.ok).call(output, `Created ${totalCreated} content file(s)${retranslateNote}${keptNote}, ${skipped} unchanged${heldNote}${unfinished}.`);
|
|
1214
1229
|
}
|
|
1215
1230
|
}
|
|
1231
|
+
reportLeftInSource(leftInSource);
|
|
1216
1232
|
if (heldBack > 0) {
|
|
1217
1233
|
// Not sent, not billed — but not translated either (exit 2, as for a
|
|
1218
1234
|
// held-back key). Each page was named above with its own repair.
|
package/lib/docusaurus-sync.js
CHANGED
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
import { walkFiles } from './locale-layout.js';
|
|
75
75
|
import {
|
|
76
76
|
readRefusals, contentHolds, nextRefusals, storeRefusals, blockUnit, fieldUnit, describeHeld, describeNewHold,
|
|
77
|
-
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock,
|
|
77
|
+
describeFallenBack, previewHeld, PAGE_UNIT, PAGE_NAME, pendingLockValue, isPendingLock, logRefusal, reportLeftInSource,
|
|
78
78
|
} from './content-refusals.js';
|
|
79
79
|
import { applyNamedKeyRule, reportUnmatchedKeys, unmatchedKeysSummary, reportNamedFromCache } from './named-keys.js';
|
|
80
80
|
|
|
@@ -1209,6 +1209,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1209
1209
|
let contentHeldBack = 0;
|
|
1210
1210
|
const contentHeldBackItems = [];
|
|
1211
1211
|
let contentRefused = 0;
|
|
1212
|
+
// Parts this run left in the source language — said last, as an error.
|
|
1213
|
+
const leftInSource = [];
|
|
1212
1214
|
|
|
1213
1215
|
if (contentSources.length === 0) {
|
|
1214
1216
|
output.info('No docs/ or blog/ directories found — skipping content sync.');
|
|
@@ -1443,6 +1445,11 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1443
1445
|
// field keeps its source text; the page is still written.
|
|
1444
1446
|
fieldsLeftInSource = true;
|
|
1445
1447
|
for (const h of fm.hollowed) {
|
|
1448
|
+
leftInSource.push({ file: label, locale: code, pair: pairKey, where: `front matter "${h.field}"`, reason: h.reason });
|
|
1449
|
+
logRefusal(cwd, {
|
|
1450
|
+
pair: pairKey, method: tmMethodKey(pairConfig), file: label, locale: code,
|
|
1451
|
+
field: h.field, reason: h.reason, source: fieldsToTranslate[h.field], answer: h.value ?? null,
|
|
1452
|
+
});
|
|
1446
1453
|
output.warn(
|
|
1447
1454
|
`${label} → ${code}: front matter "${h.field}" kept in the source language — ${h.reason}`
|
|
1448
1455
|
+ `${h.fallbackReason ? `; the fallback (${pairConfig.fallback.method}): ${h.fallbackReason}` : ''}.`
|
|
@@ -1666,6 +1673,13 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1666
1673
|
'the model gave for other, different source strings (a memorized sentence, not a translation) — refused.'
|
|
1667
1674
|
);
|
|
1668
1675
|
}
|
|
1676
|
+
for (const r of outcome.refused || []) {
|
|
1677
|
+
leftInSource.push({ file: `${dirName}/${relPath}`, locale: code, pair: pairKey, where: `paragraph ${r.block}`, reason: r.reason });
|
|
1678
|
+
logRefusal(cwd, {
|
|
1679
|
+
pair: pairKey, method: tmMethodKey(pairConfig), file: `${dirName}/${relPath}`, locale: code,
|
|
1680
|
+
paragraph: r.block, reason: r.reason, source: missed[r.i].source, answer: r.answer,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1669
1683
|
if ((outcome.refused || []).length > 0) {
|
|
1670
1684
|
output.warn(
|
|
1671
1685
|
`Docusaurus body for ${code}: ${outcome.refused.length} block(s) of ${dirName}/${relPath} refused by the quality gate, also when asked again with the reason — `
|
|
@@ -1845,7 +1859,8 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1845
1859
|
if (totalContent > 0 || totalContentSkipped > 0 || totalContentRetranslated > 0) {
|
|
1846
1860
|
const action = dryRun ? 'Would create' : 'Created';
|
|
1847
1861
|
const retranslateNote = totalContentRetranslated > 0 ? ` (${totalContentRetranslated} re-translated)` : '';
|
|
1848
|
-
|
|
1862
|
+
const unfinished = leftInSource.length > 0 ? ` — ${leftInSource.length} part(s) left in the source language (below)` : '';
|
|
1863
|
+
(unfinished ? output.warn : output.ok).call(output, `${action} ${totalContent} content file(s)${retranslateNote}, ${totalContentSkipped} unchanged${unfinished}`);
|
|
1849
1864
|
}
|
|
1850
1865
|
|
|
1851
1866
|
contentTranslated = totalContent;
|
|
@@ -1891,6 +1906,10 @@ async function runDocusaurusSync(options, config, cwd, resolveRuntime) {
|
|
|
1891
1906
|
saveTM(cwd, tm);
|
|
1892
1907
|
output.info(`[TM] Saved ${describeTMChanges(tm)} this sync`);
|
|
1893
1908
|
}
|
|
1909
|
+
{
|
|
1910
|
+
// Last, so it is the run's closing word: what is still untranslated.
|
|
1911
|
+
reportLeftInSource(leftInSource);
|
|
1912
|
+
}
|
|
1894
1913
|
|
|
1895
1914
|
// Failures are reported, not thrown: a partly failed run still did real
|
|
1896
1915
|
// work, and the exit code says so (2 = partial, 1 = nothing succeeded).
|
package/lib/fallback.js
CHANGED
|
@@ -572,7 +572,8 @@ export async function translateFieldsWithFallback({
|
|
|
572
572
|
if (typeof value !== 'string') continue;
|
|
573
573
|
const f = failed.get(field);
|
|
574
574
|
const fault = contentGateFault(fields[field], value, pairConfig);
|
|
575
|
-
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason)
|
|
575
|
+
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(f.reason)
|
|
576
|
+
&& [f.value, fields[field]].some(v => sameAnswer(v, value));
|
|
576
577
|
if (!fault || insisted) againPassing.push([field, value]);
|
|
577
578
|
else f.reason = `${f.reason}; asked again with that reason: ${fault}`;
|
|
578
579
|
}
|
|
@@ -698,6 +699,17 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
698
699
|
return contentGateFault(restoredSource, restoredOut, pairConfig);
|
|
699
700
|
}
|
|
700
701
|
|
|
702
|
+
/**
|
|
703
|
+
* The same answer, give or take spacing and closing punctuation: a citation
|
|
704
|
+
* kept as written once with its final "." and once without was refused as a
|
|
705
|
+
* changed answer (dogfood 2026-10-06, a Thai reference) — the model had said
|
|
706
|
+
* "keep it" twice.
|
|
707
|
+
*/
|
|
708
|
+
function sameAnswer(a, b) {
|
|
709
|
+
const norm = (v) => String(v ?? '').replace(/\s+/g, ' ').trim().replace(/[.,;:!?。、]+$/u, '');
|
|
710
|
+
return norm(a) === norm(b);
|
|
711
|
+
}
|
|
712
|
+
|
|
701
713
|
/**
|
|
702
714
|
* Ask the pair's method once more for the blocks the gate refused, telling it
|
|
703
715
|
* why. WHY: a refusal used to be final for the run, and the block was left in
|
|
@@ -708,8 +720,9 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
708
720
|
* reason, the model either translates it or returns it unchanged again — and
|
|
709
721
|
* a keep-as-written refusal (isKeepAsWrittenFault) answered the same way
|
|
710
722
|
* twice is taken at its word, as the key-value lane takes a name. Every other
|
|
711
|
-
* fault must clear the gate outright. One ask,
|
|
712
|
-
* it is billed like any other
|
|
723
|
+
* fault must clear the gate outright. One ask per block, alone (not in the
|
|
724
|
+
* page's batch), never a loop; under --max-cost it is billed like any other
|
|
725
|
+
* call (budget.approve).
|
|
713
726
|
*
|
|
714
727
|
* @param {object} p
|
|
715
728
|
* @param {number[]} p.idx - Indices (into `missed`) the gate refused
|
|
@@ -724,7 +737,7 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
724
737
|
* @param {object|null} [p.budget]
|
|
725
738
|
* @returns {Promise<Map<number,string>>} index → accepted (restored) answer
|
|
726
739
|
*/
|
|
727
|
-
async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
|
|
740
|
+
async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, answers = null, budget = null }) {
|
|
728
741
|
const accepted = new Map();
|
|
729
742
|
if (idx.length === 0) return accepted;
|
|
730
743
|
// An endpoint that declares it follows no instructions (a trained NMT
|
|
@@ -740,20 +753,33 @@ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runB
|
|
|
740
753
|
if (!verdict.ok) return accepted;
|
|
741
754
|
}
|
|
742
755
|
const retryNotes = new Map(idx.map(i => [texts[i], reasons.get(i)]));
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
756
|
+
// Each block alone: away from the page's other segments, the model has one
|
|
757
|
+
// thing to do (and one reason to read). Refusals are rare, so the extra
|
|
758
|
+
// requests cost next to nothing; at most 4 at a time.
|
|
759
|
+
const answers1 = new Map();
|
|
760
|
+
let next = 0;
|
|
761
|
+
const worker = async () => {
|
|
762
|
+
while (next < idx.length) {
|
|
763
|
+
const i = idx[next++];
|
|
764
|
+
try {
|
|
765
|
+
const res = await runBatch([texts[i]], { ...pairConfig, retryNotes });
|
|
766
|
+
if (res && !(res.fellBack || []).includes(0) && typeof res.blocks[0] === 'string') answers1.set(i, res.blocks[0]);
|
|
767
|
+
} catch { /* no second answer for this block */ }
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
await Promise.all(Array.from({ length: Math.min(4, idx.length) }, worker));
|
|
771
|
+
idx.forEach((i) => {
|
|
772
|
+
if (!answers1.has(i)) return;
|
|
773
|
+
const protectedOut = answers1.get(i);
|
|
774
|
+
const restored = restoreBlocks(protectedOut, blocks);
|
|
775
|
+
const fault = blockFault(texts[i], protectedOut, restored, missed[i].source, pairConfig);
|
|
776
|
+
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i))
|
|
777
|
+
&& [firstOut.get(i), missed[i].source].some(v => sameAnswer(v, restored));
|
|
755
778
|
if (!fault || insisted) accepted.set(i, restored);
|
|
756
|
-
else
|
|
779
|
+
else {
|
|
780
|
+
reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
|
|
781
|
+
if (answers) answers.set(i, restored);
|
|
782
|
+
}
|
|
757
783
|
});
|
|
758
784
|
return accepted;
|
|
759
785
|
}
|
|
@@ -828,9 +854,13 @@ export async function translateBlocksWithFallback({
|
|
|
828
854
|
};
|
|
829
855
|
// Why the gate refused a block (index → reason), for the lane's warning.
|
|
830
856
|
const refusedReason = new Map();
|
|
857
|
+
// The last answer the gate refused, per block — kept for the refusal log
|
|
858
|
+
// (.champollion/refused.jsonl), so "why was this refused?" can be read off
|
|
859
|
+
// what the model actually said instead of asked again.
|
|
860
|
+
const refusedAnswer = new Map();
|
|
831
861
|
/** Blocks left as their source text because the gate refused them. */
|
|
832
862
|
const refusedList = (fellBack) => fellBack.filter(i => refusedReason.has(i))
|
|
833
|
-
.map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i) }));
|
|
863
|
+
.map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i), answer: refusedAnswer.get(i) ?? null }));
|
|
834
864
|
|
|
835
865
|
if (!fb) {
|
|
836
866
|
const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
|
|
@@ -841,14 +871,14 @@ export async function translateBlocksWithFallback({
|
|
|
841
871
|
for (let i = 0; i < outs.length; i++) {
|
|
842
872
|
if (fellSet.has(i)) continue;
|
|
843
873
|
const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
|
|
844
|
-
if (fault) refusedReason.set(i, fault);
|
|
874
|
+
if (fault) { refusedReason.set(i, fault); refusedAnswer.set(i, outs[i]); }
|
|
845
875
|
}
|
|
846
876
|
const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
|
|
847
877
|
for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
|
|
848
878
|
// Refused: asked once more, with the reason.
|
|
849
879
|
const again = await retryRefusedBlocks({
|
|
850
880
|
idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
|
|
851
|
-
firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
|
|
881
|
+
firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, answers: refusedAnswer, budget,
|
|
852
882
|
});
|
|
853
883
|
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
854
884
|
for (const [i, out] of again) {
|
|
@@ -914,6 +944,7 @@ export async function translateBlocksWithFallback({
|
|
|
914
944
|
const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
|
|
915
945
|
if (fault) {
|
|
916
946
|
refusedReason.set(i, fault);
|
|
947
|
+
refusedAnswer.set(i, restored);
|
|
917
948
|
primaryRefused.add(i);
|
|
918
949
|
failed.push(i);
|
|
919
950
|
continue;
|
|
@@ -934,7 +965,7 @@ export async function translateBlocksWithFallback({
|
|
|
934
965
|
if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
|
|
935
966
|
}
|
|
936
967
|
const again = await retryRefusedBlocks({
|
|
937
|
-
idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
|
|
968
|
+
idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, answers: refusedAnswer, budget,
|
|
938
969
|
});
|
|
939
970
|
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
940
971
|
const recovered = new Set();
|
|
@@ -1015,6 +1046,7 @@ export async function translateBlocksWithFallback({
|
|
|
1015
1046
|
if (!fbFault) fbGood.push({ i, out: restored });
|
|
1016
1047
|
else {
|
|
1017
1048
|
refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
|
|
1049
|
+
refusedAnswer.set(i, restored);
|
|
1018
1050
|
fallbackRefused.add(i);
|
|
1019
1051
|
}
|
|
1020
1052
|
}
|
package/lib/methods/anthropic.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* lives in DirectLLMMethod.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { defaultModel } from '../model-defaults.js';
|
|
16
17
|
import { DirectLLMMethod } from './direct-llm.js';
|
|
17
18
|
import { fetchAvailableModels } from '../models.js';
|
|
18
19
|
import { estimateLlmCost } from './provider-pricing.js';
|
|
@@ -21,7 +22,7 @@ import { estimateLlmCost } from './provider-pricing.js';
|
|
|
21
22
|
// _getDefaultModel() and again as an inline `pairConfig.model || '...'`
|
|
22
23
|
// fallback in estimateCost() — so the two could drift and price a run
|
|
23
24
|
// against a model it did not use.
|
|
24
|
-
const DEFAULT_MODEL = '
|
|
25
|
+
const DEFAULT_MODEL = defaultModel('anthropic'); // shared/model-defaults.json
|
|
25
26
|
|
|
26
27
|
class AnthropicMethod extends DirectLLMMethod {
|
|
27
28
|
constructor(options = {}) {
|
package/lib/methods/gemini.js
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
* lives in DirectLLMMethod.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { defaultModel } from '../model-defaults.js';
|
|
15
16
|
import { DirectLLMMethod } from './direct-llm.js';
|
|
16
17
|
import { fetchAvailableModels } from '../models.js';
|
|
17
18
|
import { estimateLlmCost } from './provider-pricing.js';
|
|
18
19
|
|
|
19
20
|
// The default model, named ONCE — it was previously written twice (here and
|
|
20
21
|
// as an inline fallback in estimateCost()) and the two could drift.
|
|
21
|
-
const DEFAULT_MODEL = 'gemini-
|
|
22
|
+
const DEFAULT_MODEL = defaultModel('gemini'); // shared/model-defaults.json
|
|
22
23
|
|
|
23
24
|
class GeminiMethod extends DirectLLMMethod {
|
|
24
25
|
constructor(options = {}) {
|
package/lib/methods/openai.js
CHANGED
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
* lives in DirectLLMMethod.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { defaultModel } from '../model-defaults.js';
|
|
16
17
|
import { DirectLLMMethod } from './direct-llm.js';
|
|
17
18
|
import { fetchAvailableModels } from '../models.js';
|
|
18
19
|
import { estimateLlmCost } from './provider-pricing.js';
|
|
19
20
|
|
|
20
21
|
// The default model, named ONCE — it was previously written twice (here and
|
|
21
22
|
// as an inline fallback in estimateCost()) and the two could drift.
|
|
22
|
-
//
|
|
23
|
-
// ruling 2026-10-05: exact model ids only
|
|
24
|
-
const DEFAULT_MODEL = '
|
|
23
|
+
// shared/model-defaults.json: a dated snapshot (plain 'gpt-4o' was an alias
|
|
24
|
+
// OpenAI repoints — founder ruling 2026-10-05: exact model ids only).
|
|
25
|
+
const DEFAULT_MODEL = defaultModel('openai');
|
|
25
26
|
|
|
26
27
|
class OpenAIMethod extends DirectLLMMethod {
|
|
27
28
|
constructor(options = {}) {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-defaults.js — the ONE place a default model comes from.
|
|
3
|
+
*
|
|
4
|
+
* WHY: the toolset's defaults were ten hand-written ids in seven files across
|
|
5
|
+
* five components (CLI, its three direct providers, the harness, forge, the
|
|
6
|
+
* MCP server, the site docent). Nothing kept them in step, so they drifted —
|
|
7
|
+
* the docent still named an undated `claude-haiku-4-5` after every other
|
|
8
|
+
* default had been made exact (founder, 2026-10-07: "we're gonna be getting
|
|
9
|
+
* our wires crossed no matter what doing it that way"). Every default now
|
|
10
|
+
* reads shared/model-defaults.json (bundled here as shared/model-defaults.json).
|
|
11
|
+
*
|
|
12
|
+
* A default is an exact id, fixed in that file — never resolved at run time:
|
|
13
|
+
* a run must say which model translated, and the translation memory is keyed
|
|
14
|
+
* by model, so a default that moved by itself would re-bill every cached
|
|
15
|
+
* string. What IS live is the check: `champollion models check` reads each
|
|
16
|
+
* provider's current list, fails when a pinned id is gone, and names the
|
|
17
|
+
* newest model each role's rule matches today.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const FILE = path.join(__dirname, '..', 'shared', 'model-defaults.json');
|
|
26
|
+
|
|
27
|
+
let _data = null;
|
|
28
|
+
|
|
29
|
+
/** The parsed file. Missing or malformed is a broken install: fail loud. */
|
|
30
|
+
export function modelDefaultsData() {
|
|
31
|
+
if (_data) return _data;
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = fs.readFileSync(FILE, 'utf-8');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`${FILE} is missing (${err.code || err.message}): it names every default model. Reinstall champollion.`);
|
|
37
|
+
}
|
|
38
|
+
const data = JSON.parse(raw);
|
|
39
|
+
if (!data || typeof data.roles !== 'object') throw new Error(`${FILE} has no "roles" — it is not a model-defaults file.`);
|
|
40
|
+
_data = data;
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The exact default model id for a role ("translate", "openai", "gemini",
|
|
46
|
+
* "anthropic", "harness", "docent"). Unknown role: a programming error.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} role
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function defaultModel(role) {
|
|
52
|
+
const r = modelDefaultsData().roles[role];
|
|
53
|
+
if (!r || typeof r.model !== 'string' || !r.model) throw new Error(`model-defaults.json has no "${role}" role.`);
|
|
54
|
+
return r.model;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Version string → comparable number array ("4-6" / "4.6" → [4, 6]). */
|
|
58
|
+
function versionKey(v) {
|
|
59
|
+
return String(v || '0').split(/[.-]/).map((n) => Number(n) || 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compareIds(a, b) {
|
|
63
|
+
const [va, vb] = [versionKey(a.v), versionKey(b.v)];
|
|
64
|
+
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
|
65
|
+
if ((va[i] || 0) !== (vb[i] || 0)) return (va[i] || 0) - (vb[i] || 0);
|
|
66
|
+
}
|
|
67
|
+
return String(a.date || '').localeCompare(String(b.date || ''));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The newest id in a provider's live list that a role's rule matches, or null.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} role - A roles[] entry ({ rule: { match } })
|
|
74
|
+
* @param {string[]} ids - The provider's live model ids
|
|
75
|
+
* @returns {string|null}
|
|
76
|
+
*/
|
|
77
|
+
export function ruleCandidate(role, ids) {
|
|
78
|
+
const re = new RegExp(role.rule.match);
|
|
79
|
+
const hits = [];
|
|
80
|
+
for (const id of ids || []) {
|
|
81
|
+
const m = re.exec(id);
|
|
82
|
+
if (m) hits.push({ id, v: m.groups?.v, date: m.groups?.date });
|
|
83
|
+
}
|
|
84
|
+
if (hits.length === 0) return null;
|
|
85
|
+
hits.sort(compareIds);
|
|
86
|
+
return hits[hits.length - 1].id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check every role against live lists.
|
|
91
|
+
*
|
|
92
|
+
* @param {Record<string, string[]|null>} lists - provider → live ids (null = could not be read)
|
|
93
|
+
* @returns {Array<{ role: string, provider: string, model: string, status: 'ok'|'newer'|'gone'|'unchecked', candidate: string|null, note: string }>}
|
|
94
|
+
*/
|
|
95
|
+
export function checkModelDefaults(lists) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [name, role] of Object.entries(modelDefaultsData().roles)) {
|
|
98
|
+
const ids = lists[role.provider];
|
|
99
|
+
if (!ids) {
|
|
100
|
+
out.push({ role: name, provider: role.provider, model: role.model, status: 'unchecked', candidate: null, note: `${role.provider}'s model list could not be read` });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const candidate = ruleCandidate(role, ids);
|
|
104
|
+
if (!ids.includes(role.model)) {
|
|
105
|
+
out.push({ role: name, provider: role.provider, model: role.model, status: 'gone', candidate, note: `${role.model} is no longer in ${role.provider}'s list${candidate ? ` — the rule's model today: ${candidate}` : ''}` });
|
|
106
|
+
} else if (candidate && candidate !== role.model) {
|
|
107
|
+
out.push({ role: name, provider: role.provider, model: role.model, status: 'newer', candidate, note: `a newer model matches the rule: ${candidate}` });
|
|
108
|
+
} else {
|
|
109
|
+
out.push({ role: name, provider: role.provider, model: role.model, status: 'ok', candidate, note: 'listed' });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|