champollion 0.5.0 → 0.5.2
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 +4 -4
- package/lib/config.js +1 -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 +30 -8
- package/lib/methods/gemini.js +1 -1
- package/lib/methods/openai.js +3 -1
- package/lib/methods/provider-pricing.js +4 -0
- package/lib/validate.js +65 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -257,7 +257,7 @@ Full reference: [the reference docs](https://champollion.dev/docs/reference/cli)
|
|
|
257
257
|
|
|
258
258
|
```bash
|
|
259
259
|
mkdir -p .githooks
|
|
260
|
-
printf '#!/bin/sh\nnpx --yes champollion@0.
|
|
260
|
+
printf '#!/bin/sh\nnpx --yes champollion@0.5 lint\n' > .githooks/pre-commit # pinned: a new release never changes what the hook runs
|
|
261
261
|
chmod +x .githooks/pre-commit
|
|
262
262
|
git config core.hooksPath .githooks # once per clone
|
|
263
263
|
```
|
|
@@ -283,7 +283,7 @@ Create `champollion.config.json` or run `champollion init`:
|
|
|
283
283
|
"version": 3,
|
|
284
284
|
"inputLocale": "en",
|
|
285
285
|
"localesDir": "./locales",
|
|
286
|
-
"model": "google/gemini-3.
|
|
286
|
+
"model": "google/gemini-3.8-flash",
|
|
287
287
|
"pairs": {
|
|
288
288
|
"en:fr": { "qualityTier": "high" },
|
|
289
289
|
"en:ja": { "method": "google-translate" }
|
|
@@ -298,7 +298,7 @@ Create `champollion.config.json` or run `champollion init`:
|
|
|
298
298
|
| `localesPattern` | `null` | Any other layout, e.g. `"src/i18n/{ns}/{lang}.json"` (replaces `localesDir`) |
|
|
299
299
|
| `contentDir` | `null` | Folder of Markdown/MDX to translate (a Hugo `content/` or any folder); each translation is written beside its source as `<name>.<locale>.md` |
|
|
300
300
|
| `format` | `"auto"` | File format: `json`, `toml`, `yaml`, or `auto` |
|
|
301
|
-
| `model` | `"google/gemini-3.
|
|
301
|
+
| `model` | `"google/gemini-3.8-flash"` | Default model (OpenRouter slug). Direct providers resolve their own default at runtime. Run `champollion models --method gemini` to discover available models. |
|
|
302
302
|
| `defaultMethod` | `"llm"` | Default translation method (overridden by `--method` flag) |
|
|
303
303
|
| `batchSize` | `80` | Keys per translation batch |
|
|
304
304
|
| `pairs` | `{}` | Per-pair method, model, and quality overrides |
|
|
@@ -348,7 +348,7 @@ Estimated translation cost:
|
|
|
348
348
|
en:it llm 2847 0 ~$0.3843
|
|
349
349
|
|
|
350
350
|
Total: ~$2.5480
|
|
351
|
-
Rates: google/gemini-3.
|
|
351
|
+
Rates: google/gemini-3.8-flash $0.30 input / $2.50 output per 1M tokens — OpenRouter's price list, read 2026-10-04 14:02 UTC; deepl $25.00 per 1M characters — the provider's published price, as checked 2026-06-08. An estimate: ~200 input + ~30 output tokens or ~25 characters per key assumed — the bill depends on the real lengths (--json has the detail).
|
|
352
352
|
|
|
353
353
|
[INFO] Translating 3 locale(s) with concurrency 50
|
|
354
354
|
[INFO] es-MX.json — 2847 missing
|
package/lib/config.js
CHANGED
|
@@ -25,7 +25,7 @@ const CONFIG_FILENAMES = ['champollion.config.json'];
|
|
|
25
25
|
|
|
26
26
|
// Canonical defaults — import these in any module that needs a fallback
|
|
27
27
|
// instead of hardcoding the string/number inline.
|
|
28
|
-
const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.
|
|
28
|
+
const DEFAULT_OPENROUTER_MODEL = 'google/gemini-3.8-flash';
|
|
29
29
|
const DEFAULT_BATCH_SIZE = 80;
|
|
30
30
|
// Max parallel API calls for JSON key-value translation. 50 is kind to
|
|
31
31
|
// 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
|
|
@@ -724,7 +736,7 @@ export function blockFault(protectedSource, protectedOut, restoredOut, restoredS
|
|
|
724
736
|
* @param {object|null} [p.budget]
|
|
725
737
|
* @returns {Promise<Map<number,string>>} index → accepted (restored) answer
|
|
726
738
|
*/
|
|
727
|
-
async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, budget = null }) {
|
|
739
|
+
async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runBatch, firstOut, reasons, answers = null, budget = null }) {
|
|
728
740
|
const accepted = new Map();
|
|
729
741
|
if (idx.length === 0) return accepted;
|
|
730
742
|
// An endpoint that declares it follows no instructions (a trained NMT
|
|
@@ -751,9 +763,13 @@ async function retryRefusedBlocks({ idx, texts, missed, blocks, pairConfig, runB
|
|
|
751
763
|
if (!res || fell.has(j) || typeof res.blocks[j] !== 'string') return;
|
|
752
764
|
const restored = restoreBlocks(res.blocks[j], blocks);
|
|
753
765
|
const fault = blockFault(texts[i], res.blocks[j], restored, missed[i].source, pairConfig);
|
|
754
|
-
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i))
|
|
766
|
+
const insisted = isKeepAsWrittenFault(fault) && isKeepAsWrittenFault(reasons.get(i))
|
|
767
|
+
&& [firstOut.get(i), missed[i].source].some(v => sameAnswer(v, restored));
|
|
755
768
|
if (!fault || insisted) accepted.set(i, restored);
|
|
756
|
-
else
|
|
769
|
+
else {
|
|
770
|
+
reasons.set(i, `${reasons.get(i)}; asked again with that reason: ${fault}`);
|
|
771
|
+
if (answers) answers.set(i, restored);
|
|
772
|
+
}
|
|
757
773
|
});
|
|
758
774
|
return accepted;
|
|
759
775
|
}
|
|
@@ -828,9 +844,13 @@ export async function translateBlocksWithFallback({
|
|
|
828
844
|
};
|
|
829
845
|
// Why the gate refused a block (index → reason), for the lane's warning.
|
|
830
846
|
const refusedReason = new Map();
|
|
847
|
+
// The last answer the gate refused, per block — kept for the refusal log
|
|
848
|
+
// (.champollion/refused.jsonl), so "why was this refused?" can be read off
|
|
849
|
+
// what the model actually said instead of asked again.
|
|
850
|
+
const refusedAnswer = new Map();
|
|
831
851
|
/** Blocks left as their source text because the gate refused them. */
|
|
832
852
|
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) }));
|
|
853
|
+
.map(i => ({ i, block: (Number.isInteger(missed[i].pos) ? missed[i].pos : i) + 1, reason: refusedReason.get(i), answer: refusedAnswer.get(i) ?? null }));
|
|
834
854
|
|
|
835
855
|
if (!fb) {
|
|
836
856
|
const { blocks: translatedBlocks, fellBack } = await runBatch(texts, pairConfig);
|
|
@@ -841,14 +861,14 @@ export async function translateBlocksWithFallback({
|
|
|
841
861
|
for (let i = 0; i < outs.length; i++) {
|
|
842
862
|
if (fellSet.has(i)) continue;
|
|
843
863
|
const fault = contentGateFault(missed[i].source, outs[i], pairConfig);
|
|
844
|
-
if (fault) refusedReason.set(i, fault);
|
|
864
|
+
if (fault) { refusedReason.set(i, fault); refusedAnswer.set(i, outs[i]); }
|
|
845
865
|
}
|
|
846
866
|
const shared = sharedFault(outs.map((out, i) => ({ i, out })).filter(({ i }) => !fellSet.has(i) && !refusedReason.has(i)));
|
|
847
867
|
for (const i of shared) refusedReason.set(i, 'the same text the model gave for other, different source strings');
|
|
848
868
|
// Refused: asked once more, with the reason.
|
|
849
869
|
const again = await retryRefusedBlocks({
|
|
850
870
|
idx: [...refusedReason.keys()], texts, missed, blocks, pairConfig, runBatch,
|
|
851
|
-
firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, budget,
|
|
871
|
+
firstOut: new Map([...refusedReason.keys()].map(i => [i, outs[i]])), reasons: refusedReason, answers: refusedAnswer, budget,
|
|
852
872
|
});
|
|
853
873
|
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
854
874
|
for (const [i, out] of again) {
|
|
@@ -914,6 +934,7 @@ export async function translateBlocksWithFallback({
|
|
|
914
934
|
const fault = blockFault(texts[i], primary.blocks[i], restored, missed[i].source, pairConfig);
|
|
915
935
|
if (fault) {
|
|
916
936
|
refusedReason.set(i, fault);
|
|
937
|
+
refusedAnswer.set(i, restored);
|
|
917
938
|
primaryRefused.add(i);
|
|
918
939
|
failed.push(i);
|
|
919
940
|
continue;
|
|
@@ -934,7 +955,7 @@ export async function translateBlocksWithFallback({
|
|
|
934
955
|
if (refusedReason.has(i)) firstOut.set(i, restoreBlocks(primary.blocks[i], blocks));
|
|
935
956
|
}
|
|
936
957
|
const again = await retryRefusedBlocks({
|
|
937
|
-
idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, budget,
|
|
958
|
+
idx: [...firstOut.keys()], texts, missed, blocks, pairConfig, runBatch, firstOut, reasons: refusedReason, answers: refusedAnswer, budget,
|
|
938
959
|
});
|
|
939
960
|
const againShared = sharedFault([...again].map(([i, out]) => ({ i, out })));
|
|
940
961
|
const recovered = new Set();
|
|
@@ -1015,6 +1036,7 @@ export async function translateBlocksWithFallback({
|
|
|
1015
1036
|
if (!fbFault) fbGood.push({ i, out: restored });
|
|
1016
1037
|
else {
|
|
1017
1038
|
refusedReason.set(i, refusedReason.has(i) ? `${refusedReason.get(i)}; the fallback's answer: ${fbFault}` : `the fallback's answer: ${fbFault}`);
|
|
1039
|
+
refusedAnswer.set(i, restored);
|
|
1018
1040
|
fallbackRefused.add(i);
|
|
1019
1041
|
}
|
|
1020
1042
|
}
|
package/lib/methods/gemini.js
CHANGED
|
@@ -18,7 +18,7 @@ import { estimateLlmCost } from './provider-pricing.js';
|
|
|
18
18
|
|
|
19
19
|
// The default model, named ONCE — it was previously written twice (here and
|
|
20
20
|
// as an inline fallback in estimateCost()) and the two could drift.
|
|
21
|
-
const DEFAULT_MODEL = 'gemini-
|
|
21
|
+
const DEFAULT_MODEL = 'gemini-3.8-flash';
|
|
22
22
|
|
|
23
23
|
class GeminiMethod extends DirectLLMMethod {
|
|
24
24
|
constructor(options = {}) {
|
package/lib/methods/openai.js
CHANGED
|
@@ -19,7 +19,9 @@ import { estimateLlmCost } from './provider-pricing.js';
|
|
|
19
19
|
|
|
20
20
|
// The default model, named ONCE — it was previously written twice (here and
|
|
21
21
|
// as an inline fallback in estimateCost()) and the two could drift.
|
|
22
|
-
|
|
22
|
+
// A dated snapshot: plain 'gpt-4o' was an alias OpenAI repoints (founder
|
|
23
|
+
// ruling 2026-10-05: exact model ids only, nothing floating).
|
|
24
|
+
const DEFAULT_MODEL = 'gpt-5.4-mini-2026-03-17';
|
|
23
25
|
|
|
24
26
|
class OpenAIMethod extends DirectLLMMethod {
|
|
25
27
|
constructor(options = {}) {
|
|
@@ -126,6 +126,9 @@ export const LLM_RATES = {
|
|
|
126
126
|
// twice the real input rate. Corrected here.
|
|
127
127
|
'gpt-4o': { provider: 'openai', input: 2.50, output: 10.00, verified: '2026-08-01' },
|
|
128
128
|
'gpt-4o-mini': { provider: 'openai', input: 0.15, output: 0.60, verified: '2026-08-01' },
|
|
129
|
+
// The openai default (a dated snapshot, which OpenRouter lists only
|
|
130
|
+
// undated as openai/gpt-5.4-mini — so this row is what prices it).
|
|
131
|
+
'gpt-5.4-mini-2026-03-17': { provider: 'openai', input: 0.75, output: 4.50, verified: '2026-10-06' },
|
|
129
132
|
|
|
130
133
|
// ── Google Gemini ──────────────────────────────────────────────────
|
|
131
134
|
// LAST VERIFIED: 2026-08-01 against the live OpenRouter draw. The values
|
|
@@ -135,6 +138,7 @@ export const LLM_RATES = {
|
|
|
135
138
|
// should have failed. Corrected here.
|
|
136
139
|
'gemini-2.5-flash': { provider: 'gemini', input: 0.30, output: 2.50, verified: '2026-08-01' },
|
|
137
140
|
'gemini-2.5-pro': { provider: 'gemini', input: 1.25, output: 10.00, verified: '2026-08-01' },
|
|
141
|
+
'gemini-3.8-flash': { provider: 'gemini', input: 0.75, output: 3.75, verified: '2026-10-06' },
|
|
138
142
|
};
|
|
139
143
|
|
|
140
144
|
/**
|
package/lib/validate.js
CHANGED
|
@@ -451,8 +451,11 @@ function isProtectedTermValue(value, protectedTerms = []) {
|
|
|
451
451
|
* until someone names it for a redo. Bump it whenever a check is loosened.
|
|
452
452
|
* 2 — 2026-10-05: names with citations/anchors, reference entries, tables,
|
|
453
453
|
* short titles and source-shown fullwidth letters stopped being refused.
|
|
454
|
+
* 3 — 2026-10-06: inline code is not prose (an ICU sample in backticks no
|
|
455
|
+
* longer makes a translated paragraph "ASCII-only"); a phrase said
|
|
456
|
+
* twice is not a loop; "keep it" twice is accepted give or take a stop.
|
|
454
457
|
*/
|
|
455
|
-
const GATE_VERSION =
|
|
458
|
+
const GATE_VERSION = 3;
|
|
456
459
|
|
|
457
460
|
/**
|
|
458
461
|
* Refusals that may be the model keeping text that is correct as written —
|
|
@@ -519,7 +522,7 @@ function isBibliographicEntry(text) {
|
|
|
519
522
|
const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
|
|
520
523
|
return lines.length > 1 && lines.every(l => !l.includes('\n') && isBibliographicEntry(l));
|
|
521
524
|
}
|
|
522
|
-
return /^(?:\[?[A-Z]?\d{1,3}
|
|
525
|
+
return /^(?:\[?[A-Z]?\d{1,3}[a-z]?\]?[.)]?|[-*+])\s+\S/.test(t)
|
|
523
526
|
&& (/\(\d{4}[a-z]?\)/.test(t) || /\]\(https?:\/\//.test(t))
|
|
524
527
|
&& (/"[^"]{8,}"|“[^”]{8,}”/.test(t) || /\*[^*]{8,}\*/.test(t));
|
|
525
528
|
}
|
|
@@ -570,6 +573,43 @@ function contentGateFault(source, value, pairConfig = {}) {
|
|
|
570
573
|
return failures.length > 0 ? failures[0].reason : null;
|
|
571
574
|
}
|
|
572
575
|
|
|
576
|
+
/**
|
|
577
|
+
* Does `translated` show the shape of a degeneration loop, beyond a high
|
|
578
|
+
* repeated-n-gram rate? A phrase a language says twice where English
|
|
579
|
+
* elides it is not a loop: "From most to least trustworthy:" is correctly
|
|
580
|
+
* "Từ đáng tin cậy nhất đến ít đáng tin cậy nhất:" in Vietnamese, and was
|
|
581
|
+
* refused as a repetition hallucination (dogfood 2026-10-06). A loop does
|
|
582
|
+
* one of three things: repeats some span three times or more, repeats a whole
|
|
583
|
+
* line, or hands the source back verbatim beside a translation of it (the
|
|
584
|
+
* doubled Arabic headings, "### METEOR (…)\n### METEOR (…)").
|
|
585
|
+
*
|
|
586
|
+
* @param {string} translated
|
|
587
|
+
* @param {string} source
|
|
588
|
+
* @returns {boolean}
|
|
589
|
+
*/
|
|
590
|
+
function hasLoopEvidence(translated, source) {
|
|
591
|
+
const t = String(translated);
|
|
592
|
+
const grams = new Map();
|
|
593
|
+
for (let i = 0; i + REPETITION_LONG_N <= t.length; i++) {
|
|
594
|
+
const g = t.slice(i, i + REPETITION_LONG_N);
|
|
595
|
+
const n = (grams.get(g) || 0) + 1;
|
|
596
|
+
if (n >= 3 && g.trim().length >= 4) return true;
|
|
597
|
+
grams.set(g, n);
|
|
598
|
+
}
|
|
599
|
+
// A word said three times or more ("Qo' Qo' Qo' Qo'" — too short for an
|
|
600
|
+
// 8-character span to recur three times).
|
|
601
|
+
const words = new Map();
|
|
602
|
+
for (const w of t.toLowerCase().split(/\s+/u).filter(x => /\p{L}/u.test(x))) {
|
|
603
|
+
const n = (words.get(w) || 0) + 1;
|
|
604
|
+
if (n >= 3) return true;
|
|
605
|
+
words.set(w, n);
|
|
606
|
+
}
|
|
607
|
+
const lines = t.split('\n').map(l => l.trim()).filter(l => l.length >= 8);
|
|
608
|
+
if (new Set(lines).size < lines.length) return true;
|
|
609
|
+
const src = String(source).trim();
|
|
610
|
+
return src.length >= 8 && t.trim() !== src && t.includes(src);
|
|
611
|
+
}
|
|
612
|
+
|
|
573
613
|
/**
|
|
574
614
|
* Validate a batch of translations and return only passing keys.
|
|
575
615
|
*
|
|
@@ -775,7 +815,7 @@ function validateTranslations(translations, sourceFlat, pairConfig, options = {}
|
|
|
775
815
|
longGramRate: measureRepetition(seg, REPETITION_LONG_N),
|
|
776
816
|
}))
|
|
777
817
|
.find(m => m.trigramRate > trigramCap && m.longGramRate > longGramCap);
|
|
778
|
-
if (degenerateSegment) {
|
|
818
|
+
if (degenerateSegment && hasLoopEvidence(translated, source)) {
|
|
779
819
|
failures.push({
|
|
780
820
|
key,
|
|
781
821
|
reason: `repetition hallucination (${(degenerateSegment.trigramRate * 100).toFixed(0)}% repeated trigrams, ${(degenerateSegment.longGramRate * 100).toFixed(0)}% repeated ${REPETITION_LONG_N}-grams)`,
|
|
@@ -1300,12 +1340,30 @@ const FULLWIDTH_LATIN = /[A-Za-z]/u;
|
|
|
1300
1340
|
* URL value is a wrong-script answer unless the key is declared
|
|
1301
1341
|
* no-translate — which never reaches this check.)
|
|
1302
1342
|
*/
|
|
1343
|
+
/** `text` with every balanced {…} group removed (nested groups included). */
|
|
1344
|
+
function withoutBraceGroups(text) {
|
|
1345
|
+
let out = '';
|
|
1346
|
+
let depth = 0;
|
|
1347
|
+
for (const ch of text) {
|
|
1348
|
+
if (ch === '{') { depth++; continue; }
|
|
1349
|
+
if (ch === '}' && depth > 0) { depth--; continue; }
|
|
1350
|
+
if (depth === 0) out += ch;
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1303
1355
|
function proseOf(text) {
|
|
1356
|
+
// Inline code is never prose: a Markdown paragraph quoting an ICU message
|
|
1357
|
+
// in backticks (`{n, plural, one {One file} other {…}}`) was read as that
|
|
1358
|
+
// message, its English branches were taken for the whole value, and 470
|
|
1359
|
+
// characters of correct Japanese were refused as "ASCII-only" — twice, in
|
|
1360
|
+
// five languages (dogfood 2026-10-06, docs/getting-started/configuration.md).
|
|
1361
|
+
const str = String(text).replace(/(`+)[^`]*?\1/g, ' ');
|
|
1304
1362
|
// A plural/select message: its prose is the text of its branches (the
|
|
1305
|
-
// keywords and selectors — "plural", "one", "other" — are code)
|
|
1306
|
-
|
|
1363
|
+
// keywords and selectors — "plural", "one", "other" — are code) PLUS the
|
|
1364
|
+
// text around it ("You have {count, plural, …}" — "You have" is prose).
|
|
1307
1365
|
const branches = str.includes('{') ? pluralBranchTexts(str) : null;
|
|
1308
|
-
return (branches ? branches.join(' ') : str)
|
|
1366
|
+
return (branches ? `${branches.join(' ')} ${withoutBraceGroups(str)}` : str)
|
|
1309
1367
|
// Simple placeholders only: {name}, {count, number}. Branch text such as
|
|
1310
1368
|
// "{Один файл}" is prose, never stripped.
|
|
1311
1369
|
.replace(/\{\s*[\w.$-]+\s*(?:,[^{}]*)?\}/g, ' ')
|
|
@@ -1467,6 +1525,7 @@ export {
|
|
|
1467
1525
|
nameCore,
|
|
1468
1526
|
GATE_VERSION,
|
|
1469
1527
|
isKeepAsWrittenFault,
|
|
1528
|
+
hasLoopEvidence,
|
|
1470
1529
|
isBibliographicEntry,
|
|
1471
1530
|
tableProse,
|
|
1472
1531
|
SHORT_SOURCE_LENGTH_SLACK,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "champollion",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Research-grade translation engine for i18n projects. Pluggable methods, per-pair quality tiers, and deterministic script converters. Supports JSON (next-intl, i18next), TOML, and YAML (Hugo).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|