champollion 0.3.4 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -26
- package/bin/cli.js +53 -5
- package/index.js +63 -2
- package/lib/api-key.js +17 -4
- package/lib/autofix.js +82 -37
- package/lib/bridge/method_bridge.py +15 -3
- package/lib/cards/reader.js +34 -0
- package/lib/cards/remote.js +15 -0
- package/lib/cards/search-names.js +178 -0
- package/lib/command-help.js +286 -85
- package/lib/commands/audit.js +10 -3
- package/lib/commands/card.js +583 -226
- package/lib/commands/doctor.js +54 -18
- package/lib/commands/help.js +37 -32
- package/lib/commands/init.js +1709 -101
- package/lib/commands/integrity.js +127 -40
- package/lib/commands/leaderboard.js +187 -67
- package/lib/commands/models.js +9 -2
- package/lib/commands/provenance.js +7 -2
- package/lib/commands/recommend.js +43 -14
- package/lib/commands/register-corpus.js +632 -125
- package/lib/commands/seal-corpus.js +1 -1
- package/lib/commands/status.js +564 -27
- package/lib/commands/submit.js +17 -12
- package/lib/commands/sync.js +31 -7
- package/lib/commands/tm.js +15 -9
- package/lib/commands/verify.js +27 -3
- package/lib/commands/wrap.js +63 -5
- package/lib/commands/xliff.js +135 -64
- package/lib/commercial-eligibility.js +1 -1
- package/lib/config.js +230 -17
- package/lib/content-estimate.js +96 -0
- package/lib/content-refusals.js +284 -0
- package/lib/content-review.js +372 -0
- package/lib/content-sync.js +1136 -345
- package/lib/content.js +94 -7
- package/lib/corpus-registration.mjs +194 -35
- package/lib/cost-label.js +29 -0
- package/lib/cost-report.js +727 -79
- package/lib/diff.js +38 -4
- package/lib/docusaurus-sync.js +975 -262
- package/lib/edit-distance.js +31 -0
- package/lib/fallback.js +1102 -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 +575 -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 +257 -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 +71 -129
- package/lib/methods/llm.js +87 -33
- 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 +102 -55
- package/lib/name-rules.js +32 -0
- package/lib/named-keys.js +172 -0
- package/lib/no-translate.js +4 -3
- package/lib/output.js +160 -19
- package/lib/pairs.js +586 -30
- package/lib/placeholders.js +394 -0
- package/lib/plugins.js +8 -0
- package/lib/plural-gap-redo.js +109 -0
- package/lib/plurals.js +323 -0
- package/lib/po.js +1187 -0
- package/lib/public-catalogue.js +74 -0
- package/lib/recommend.js +527 -32
- package/lib/redo.js +95 -0
- package/lib/refusal-category.js +44 -0
- package/lib/registers.js +255 -11
- package/lib/repair-script.js +20 -13
- package/lib/scripts.js +6 -1
- package/lib/seal.mjs +4 -3
- package/lib/sealed-qualifier.mjs +1 -1
- package/lib/segment.js +24 -8
- 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 +996 -21
- package/lib/verify.js +1332 -104
- package/lib/watch.js +32 -13
- package/lib/xliff.js +44 -3
- package/package.json +1 -1
- package/shared/CORPORA-CARDS.md +2 -0
- package/shared/cards-fallback.json +1 -1
- package/shared/curated-orthography-conventions.json +26 -8
- package/shared/gettext-plural-forms.json +45 -0
- package/shared/licenses.json +99 -3
- package/shared/method-registry.json +2 -0
- package/shared/metric-registry.json +96 -18
- package/shared/retired-model-aliases.json +9 -0
- package/shared/schemas/champollion-plugin.schema.json +4 -0
- package/shared/schemas/corpora-card.schema.json +8 -2
- package/shared/schemas/human-services.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/schemas/retired-model-aliases.schema.json +32 -0
- package/shared/docent/corpus.json +0 -11739
- package/shared/model-aliases.json +0 -7
- package/shared/schemas/model-aliases.schema.json +0 -27
package/lib/segment.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { PLACEHOLDER_PREFIX } from './content.js';
|
|
33
|
+
import { nameRules } from './name-rules.js';
|
|
33
34
|
|
|
34
35
|
// Block separator: a newline followed by one or more blank lines
|
|
35
36
|
// (whitespace-only lines count as blank — CommonMark treats them as
|
|
@@ -72,7 +73,10 @@ const SEGMENT_MARKER_SUFFIX = '⟧';
|
|
|
72
73
|
function hasTranslatableText(text) {
|
|
73
74
|
const stripped = text
|
|
74
75
|
.replace(PLACEHOLDER_TOKEN_REGEX, '')
|
|
75
|
-
.replace(IMPORT_EXPORT_LINE_REGEX, '')
|
|
76
|
+
.replace(IMPORT_EXPORT_LINE_REGEX, '')
|
|
77
|
+
// An HTML comment is a note to editors, never shown: not sent, not billed
|
|
78
|
+
// (dogfood 2026-10-05 — comment-only blocks were translated and gated).
|
|
79
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
76
80
|
return /[\p{L}\p{N}]/u.test(stripped);
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -185,15 +189,26 @@ function buildBlockBatchPrompt(blockTexts, langConfig, options = {}) {
|
|
|
185
189
|
.map((text, i) => `${SEGMENT_MARKER_PREFIX}${i}${SEGMENT_MARKER_SUFFIX}\n${text}`)
|
|
186
190
|
.join('\n\n');
|
|
187
191
|
|
|
192
|
+
// A second ask for segments the quality check refused (lib/fallback.js
|
|
193
|
+
// retryRefusedBlocks): the model is told why, and that text which is
|
|
194
|
+
// correct as written may come back unchanged.
|
|
195
|
+
const notes = langConfig.retryNotes instanceof Map
|
|
196
|
+
? blockTexts.map((text, i) => (langConfig.retryNotes.has(text) ? `- Segment ${i}: ${langConfig.retryNotes.get(text)}` : null)).filter(Boolean)
|
|
197
|
+
: [];
|
|
198
|
+
const retryBlock = notes.length > 0
|
|
199
|
+
? `\nYour previous translation of these segments was refused by an automatic quality check, for the reason given:\n${notes.join('\n')}\n`
|
|
200
|
+
+ `Translate each one again. If a segment is correct exactly as written in ${sourceLanguageName} — a name, title, citation, identifier, code or data — return it unchanged.\n`
|
|
201
|
+
: '';
|
|
202
|
+
|
|
188
203
|
return `You are translating Markdown content from ${sourceLanguageName} to ${langConfig.name}. The document was split into ${blockTexts.length} numbered segment(s); segments not shown are already translated.
|
|
189
204
|
${contextBlock}${titleBlock}
|
|
190
205
|
Register/tone: ${langConfig.register}
|
|
191
|
-
|
|
206
|
+
${retryBlock}
|
|
192
207
|
Rules:
|
|
193
208
|
- Translate ALL human-readable text in every segment.
|
|
194
209
|
- Preserve ALL Markdown formatting: headers (#), bold (**), italic (*), links, images, lists, blockquotes, tables, admonitions (:::), etc.${placeholderRule}
|
|
195
210
|
- Preserve line breaks and structure WITHIN each segment.
|
|
196
|
-
|
|
211
|
+
${nameRules(options.protectedTerms)}
|
|
197
212
|
- Translate link text but preserve link URLs. For example: [Read more](url) → [Lire la suite](url)
|
|
198
213
|
- Echo each ${SEGMENT_MARKER_PREFIX}N${SEGMENT_MARKER_SUFFIX} marker on its own line, EXACTLY as given, before its translated segment.
|
|
199
214
|
- Do NOT merge, drop, reorder, renumber, or add segments.
|
|
@@ -284,8 +299,8 @@ function parseBlockBatchResponse(response, expectedCount, opts = {}) {
|
|
|
284
299
|
* 1. Send the full batch. Missing marker(s)? →
|
|
285
300
|
* 2. ONE retry with only the missing segments (a fresh, smaller batch —
|
|
286
301
|
* different neighbors, usually enough). Still missing? →
|
|
287
|
-
* 3.
|
|
288
|
-
*
|
|
302
|
+
* 3. The source block stands for just those segments, unmarked — the
|
|
303
|
+
* caller reports it, records it and asks again on the next sync.
|
|
289
304
|
*
|
|
290
305
|
* The CALLER's contract for fallen-back segments: never store them in the
|
|
291
306
|
* TM (an error cached is an error forever) and never advance the file's
|
|
@@ -296,13 +311,12 @@ function parseBlockBatchResponse(response, expectedCount, opts = {}) {
|
|
|
296
311
|
* @param {string[]} p.texts - Protected block texts to translate
|
|
297
312
|
* @param {(texts: string[]) => string} p.buildPrompt - Batch prompt builder
|
|
298
313
|
* @param {(prompt: string) => Promise<string|null>} p.callModel - One API call
|
|
299
|
-
* @param {string} p.fallbackPrefix - e.g. '[EN] ' — prepended to source text
|
|
300
314
|
* @returns {Promise<{blocks: string[], fellBack: number[]}>} index-aligned
|
|
301
315
|
* translations; `fellBack` lists indexes that carry the fallback
|
|
302
316
|
* @throws {Error} If the FIRST call returns nothing at all, or on
|
|
303
317
|
* duplicate/unknown markers (untrustworthy mapping)
|
|
304
318
|
*/
|
|
305
|
-
async function translateBlockBatchResilient({ texts, buildPrompt, callModel
|
|
319
|
+
async function translateBlockBatchResilient({ texts, buildPrompt, callModel }) {
|
|
306
320
|
const first = await callModel(buildPrompt(texts));
|
|
307
321
|
if (!first) {
|
|
308
322
|
throw new Error('block-batch translation returned no results');
|
|
@@ -330,8 +344,10 @@ async function translateBlockBatchResilient({ texts, buildPrompt, callModel, fal
|
|
|
330
344
|
}
|
|
331
345
|
}
|
|
332
346
|
|
|
347
|
+
// Still missing after the retry: the source block stands, unmarked; the
|
|
348
|
+
// caller records it, does not cache it and does not advance the lock.
|
|
333
349
|
for (const i of stillMissing) {
|
|
334
|
-
blocks[i] =
|
|
350
|
+
blocks[i] = texts[i];
|
|
335
351
|
}
|
|
336
352
|
return { blocks, fellBack: stillMissing };
|
|
337
353
|
}
|
package/lib/seo.js
CHANGED
|
@@ -93,6 +93,22 @@ function generateHreflangTags({ baseUrl, urlPattern, inputLocale, locales, pageP
|
|
|
93
93
|
return lines.join('\n');
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The target codes in `languages`, in either documented form — a list
|
|
98
|
+
* (["fr","de"]) or an object keyed by code ({ "fr": "formal-vous", … }, the
|
|
99
|
+
* form `champollion init` writes when it records each language's register).
|
|
100
|
+
* The object form used to be skipped here, so its locales got no hreflang.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} config
|
|
103
|
+
* @returns {string[]}
|
|
104
|
+
*/
|
|
105
|
+
function configLanguageCodes(config) {
|
|
106
|
+
const langs = config.languages;
|
|
107
|
+
if (Array.isArray(langs)) return [...langs];
|
|
108
|
+
if (langs && typeof langs === 'object') return Object.keys(langs);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/**
|
|
97
113
|
* Generate hreflang tags for all configured pages.
|
|
98
114
|
*
|
|
@@ -108,9 +124,7 @@ function generateAllHreflangTags(config) {
|
|
|
108
124
|
|
|
109
125
|
// Collect all target locales from config
|
|
110
126
|
const locales = [];
|
|
111
|
-
|
|
112
|
-
locales.push(...config.languages);
|
|
113
|
-
}
|
|
127
|
+
locales.push(...configLanguageCodes(config));
|
|
114
128
|
if (config.pairs) {
|
|
115
129
|
for (const [pairKey] of Object.entries(config.pairs)) {
|
|
116
130
|
const { target } = parsePairKey(pairKey);
|
|
@@ -163,9 +177,7 @@ function generateSitemap(config) {
|
|
|
163
177
|
|
|
164
178
|
// Collect all locales
|
|
165
179
|
const targetLocales = [];
|
|
166
|
-
|
|
167
|
-
targetLocales.push(...config.languages);
|
|
168
|
-
}
|
|
180
|
+
targetLocales.push(...configLanguageCodes(config));
|
|
169
181
|
if (config.pairs) {
|
|
170
182
|
for (const [pairKey] of Object.entries(config.pairs)) {
|
|
171
183
|
const { target } = parsePairKey(pairKey);
|
|
@@ -224,9 +236,7 @@ function generateJsonLd(config) {
|
|
|
224
236
|
|
|
225
237
|
// Collect all locales
|
|
226
238
|
const targetLocales = [];
|
|
227
|
-
|
|
228
|
-
targetLocales.push(...config.languages);
|
|
229
|
-
}
|
|
239
|
+
targetLocales.push(...configLanguageCodes(config));
|
|
230
240
|
if (config.pairs) {
|
|
231
241
|
for (const [pairKey] of Object.entries(config.pairs)) {
|
|
232
242
|
const { target } = parsePairKey(pairKey);
|
package/lib/serve.js
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
*
|
|
21
21
|
* NO FORKED TRANSLATION LOGIC:
|
|
22
22
|
* Requests run through the SAME pipeline `sync` uses — resolveRuntime for
|
|
23
|
-
* config/plugin/preflight resolution and
|
|
24
|
-
* TM partition → API call → deterministic quality gate → TM store sequence
|
|
23
|
+
* config/plugin/preflight resolution and translateWithFallback for the
|
|
24
|
+
* TM partition → API call → deterministic quality gate → TM store sequence
|
|
25
|
+
* (plus the pair's fallback method, when one is configured, for what the
|
|
26
|
+
* pair's own method could not translate safely).
|
|
25
27
|
* TM hits are served from cache at $0; gate failures come back as
|
|
26
28
|
* structured per-key errors, never as silently degraded output.
|
|
27
29
|
*
|
|
@@ -49,7 +51,8 @@ import { createRequire } from 'node:module';
|
|
|
49
51
|
import { resolveConfig } from './config.js';
|
|
50
52
|
import { resolveRuntime } from './sync.js';
|
|
51
53
|
import { loadTM, saveTM, isTMDirty, partitionByTM, tmMethodKey } from './tm.js';
|
|
52
|
-
import {
|
|
54
|
+
import { translateWithFallback } from './translate-pair.js';
|
|
55
|
+
import { createFallbackBudget, fallbackSummary } from './fallback.js';
|
|
53
56
|
import { estimateCost, QUALITY_TIERS } from './pairs.js';
|
|
54
57
|
import { getMethod } from './translate.js';
|
|
55
58
|
import { isUnsafeKey } from './security.js';
|
|
@@ -502,7 +505,7 @@ async function handleTranslate(ctx, body, res) {
|
|
|
502
505
|
const { misses } = partitionByTM(runtime.tm, payload, keyNames, targetLocale, tmKey);
|
|
503
506
|
let estimate = null;
|
|
504
507
|
if (misses.length > 0) {
|
|
505
|
-
estimate = await estimateCost(misses.length, pairConfig);
|
|
508
|
+
estimate = await estimateCost(misses.length, pairConfig, { cwd: runtime.cwd });
|
|
506
509
|
const estimated = estimate?.estimatedCost ?? null;
|
|
507
510
|
|
|
508
511
|
if (maxCostPerRequest !== null || maxSessionCost !== null) {
|
|
@@ -525,26 +528,47 @@ async function handleTranslate(ctx, body, res) {
|
|
|
525
528
|
}
|
|
526
529
|
}
|
|
527
530
|
|
|
531
|
+
// ── The pair's fallback method, under the same caps ────────────────
|
|
532
|
+
// What the pair's own method cannot translate safely goes to its
|
|
533
|
+
// fallback (if configured). Under a cap, that batch is priced before it
|
|
534
|
+
// runs and must fit in what the primary's estimate left of the tighter of
|
|
535
|
+
// the per-request cap and the remaining session ceiling (lib/fallback.js).
|
|
536
|
+
const capsLeft = [
|
|
537
|
+
maxCostPerRequest,
|
|
538
|
+
maxSessionCost !== null ? maxSessionCost - session.spendUsd : null,
|
|
539
|
+
].filter(v => v !== null);
|
|
540
|
+
const primaryEstimate = misses.length > 0 ? (estimate?.estimatedCost ?? 0) : 0;
|
|
541
|
+
const fallbackBudget = createFallbackBudget({
|
|
542
|
+
maxCost: capsLeft.length > 0 ? Math.min(...capsLeft) : null,
|
|
543
|
+
committed: primaryEstimate,
|
|
544
|
+
cwd: runtime.cwd,
|
|
545
|
+
});
|
|
546
|
+
|
|
528
547
|
// ── The real pipeline: TM partition → API → quality gate → TM store ──
|
|
529
548
|
let result;
|
|
530
549
|
try {
|
|
531
|
-
result = await
|
|
550
|
+
result = await translateWithFallback(keyNames, payload, pairConfig, pairKey, {
|
|
532
551
|
apiKey: runtime.apiKey,
|
|
552
|
+
cwd: runtime.cwd,
|
|
533
553
|
tm: runtime.tm,
|
|
534
554
|
targetCode: targetLocale,
|
|
535
555
|
onProgress: null,
|
|
556
|
+
budget: fallbackBudget,
|
|
536
557
|
});
|
|
537
558
|
} catch (err) {
|
|
538
559
|
output.error(`[serve] ${pairKey}: pipeline error — ${err.message}`);
|
|
539
560
|
return sendError(res, 500, 'internal_error', 'Translation pipeline failed. See server logs.');
|
|
540
561
|
}
|
|
541
|
-
if (result.apiCalled) output.
|
|
562
|
+
if (result.apiCalled) output.progressDone(`[serve] ${pairKey}`, 'done');
|
|
542
563
|
|
|
543
564
|
// Session ledger: count the estimate for any request that actually hit
|
|
544
565
|
// the upstream API (conservative — estimates deliberately err high).
|
|
545
566
|
if (result.apiCalled && estimate?.estimatedCost != null) {
|
|
546
567
|
session.spendUsd += estimate.estimatedCost;
|
|
547
568
|
}
|
|
569
|
+
// Fallback batches approved under a cap carry their estimate too.
|
|
570
|
+
const fallbackSpend = fallbackBudget.committed - primaryEstimate;
|
|
571
|
+
if (fallbackSpend > 0) session.spendUsd += fallbackSpend;
|
|
548
572
|
|
|
549
573
|
// Persist TM mutations (stores AND evictions) after every request so a
|
|
550
574
|
// crash never loses paid, gate-validated translations. Atomic write.
|
|
@@ -616,6 +640,17 @@ async function handleTranslate(ctx, body, res) {
|
|
|
616
640
|
costUsd = estimate.estimatedCost;
|
|
617
641
|
costBasis = 'estimate';
|
|
618
642
|
}
|
|
643
|
+
// A fallback that ran adds its own spend: priced when a cap made us price
|
|
644
|
+
// it, otherwise unknown — never silently left out of a known figure.
|
|
645
|
+
if (result.fallback?.attempted > 0) {
|
|
646
|
+
if (fallbackBudget.maxCost !== null && costUsd !== null) {
|
|
647
|
+
costUsd += fallbackSpend;
|
|
648
|
+
costBasis = 'estimate';
|
|
649
|
+
} else {
|
|
650
|
+
costUsd = null;
|
|
651
|
+
costBasis = 'unknown';
|
|
652
|
+
}
|
|
653
|
+
}
|
|
619
654
|
|
|
620
655
|
const meta = {
|
|
621
656
|
served_by: `champollion-serve/${CLI_VERSION}`,
|
|
@@ -629,6 +664,8 @@ async function handleTranslate(ctx, body, res) {
|
|
|
629
664
|
translated: translatedCount,
|
|
630
665
|
failed: errorCount,
|
|
631
666
|
tm_hits: result.tmHitCount,
|
|
667
|
+
// The pair's fallback method and what it did (pairs with a fallback).
|
|
668
|
+
...(result.fallback && { fallback: fallbackSummary(result.fallback) }),
|
|
632
669
|
// Script conversion status for this pair: which script was written and
|
|
633
670
|
// how many values stayed in the working script (unmapped letters).
|
|
634
671
|
script_conversion: {
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared-output-seed.js — what a locale's shared-output index starts a sync
|
|
3
|
+
* with: the translations already on disk that this run leaves alone.
|
|
4
|
+
*
|
|
5
|
+
* WHY: `verify` checks one locale's whole state for a model repeating one
|
|
6
|
+
* memorized sentence (lib/verify.js sharedOutputErrors): every key value of
|
|
7
|
+
* every file AND every Markdown page, counted together. Sync's gate
|
|
8
|
+
* (lib/validate.js SharedOutputIndex) saw less — only what this run accepted,
|
|
9
|
+
* plus, per key-value file, what that file kept, added when the file was
|
|
10
|
+
* reached. So a new answer repeating a sentence that sat in an untouched
|
|
11
|
+
* newsletter page, in a key-value file with nothing to translate (it returns
|
|
12
|
+
* before it seeds), or in a namespace file processed later, was written —
|
|
13
|
+
* and `verify` then flagged it (Round 10, school persona). Seeding the index
|
|
14
|
+
* with what the run leaves alone, before anything is checked, gives the gate
|
|
15
|
+
* the same scope `verify` reads.
|
|
16
|
+
*
|
|
17
|
+
* "Left alone" is what the run will not re-translate:
|
|
18
|
+
* - a key value Champollion recorded writing for the CURRENT source text
|
|
19
|
+
* (the lock's written record matches the source and the value), or one
|
|
20
|
+
* with no record whose source did not change — never an '[EN] '
|
|
21
|
+
* placeholder, a pending key, a key named for a redo, or any key under a
|
|
22
|
+
* bulk `--redo all`;
|
|
23
|
+
* - a Markdown page whose content lock says it was made from the current
|
|
24
|
+
* source, when no --redo files:/content, --force-content or --retranslate
|
|
25
|
+
* scope reaches it.
|
|
26
|
+
* Values the run replaces are left out, so their old text never counts
|
|
27
|
+
* against the new one. Read-only.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
import { expectedForTarget, readLocaleFlat, lockKey, discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
|
|
34
|
+
import { originKey } from './plurals.js';
|
|
35
|
+
import { decodeWritten, shortSourceHash, valueHash, LockState } from './locale-state.js';
|
|
36
|
+
import { SharedOutputIndex, sharedOutputItems } from './validate.js';
|
|
37
|
+
import { readLock } from './hash.js';
|
|
38
|
+
import { discoverContentFiles, getTargetContentPath } from './content.js';
|
|
39
|
+
import { contentItemsFor } from './verify.js';
|
|
40
|
+
import { readContentManifest } from './content-sync.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Items (lib/validate.js sharedOutputItems shape) for the translations of
|
|
44
|
+
* `code` on disk that this run leaves alone.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} p
|
|
47
|
+
* @param {object} p.config - Resolved config (inputLocale, contentDir, format, fallbackPrefix, …)
|
|
48
|
+
* @param {object} p.layout - Locale layout (lib/locale-layout.js)
|
|
49
|
+
* @param {Array<object>} p.units - Source units (loadSourceUnits)
|
|
50
|
+
* @param {string} p.code - Target locale
|
|
51
|
+
* @param {{ written: object, pending: object }} p.localeState - LockState.peek(code)
|
|
52
|
+
* @param {string[]} [p.namedKeys] - Keys named for a redo (source key space, namespaced)
|
|
53
|
+
* @param {boolean} [p.bulk] - `--redo all`: every key is re-translated
|
|
54
|
+
* @param {object|null} [p.fileScope] - lib/file-scope.js (--redo files: / --files / --retranslate)
|
|
55
|
+
* @param {boolean} [p.forceContent] - --force-content / --redo content
|
|
56
|
+
* @param {string} p.cwd - Project directory
|
|
57
|
+
* @returns {Array<{ key: string, source: string, value: string }>}
|
|
58
|
+
*/
|
|
59
|
+
export function itemsLeftAlone({
|
|
60
|
+
config, layout, units, code, localeState, namedKeys = [], bulk = false, fileScope = null, forceContent = false, cwd,
|
|
61
|
+
}) {
|
|
62
|
+
const items = [];
|
|
63
|
+
const fallbackPrefix = config.fallbackPrefix || '[EN] ';
|
|
64
|
+
const named = new Set(namedKeys);
|
|
65
|
+
const written = localeState?.written || {};
|
|
66
|
+
const pending = localeState?.pending || {};
|
|
67
|
+
|
|
68
|
+
if (!bulk) {
|
|
69
|
+
for (const unit of units || []) {
|
|
70
|
+
const file = layout.fileFor(code, unit.ns);
|
|
71
|
+
if (!fs.existsSync(file.path)) continue;
|
|
72
|
+
let target;
|
|
73
|
+
try { target = readLocaleFlat(file) || {}; } catch { continue; }
|
|
74
|
+
const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, code);
|
|
75
|
+
const changed = new Set(unit.changedKeys || []);
|
|
76
|
+
for (const [k, v] of Object.entries(target)) {
|
|
77
|
+
const src = expected[k];
|
|
78
|
+
if (typeof v !== 'string' || typeof src !== 'string' || v.startsWith(fallbackPrefix)) continue;
|
|
79
|
+
const lk = lockKey(layout, unit.ns, k);
|
|
80
|
+
const origin = originKey(k, expansion);
|
|
81
|
+
if (pending[lk] !== undefined || named.has(lk) || named.has(lockKey(layout, unit.ns, origin))) continue;
|
|
82
|
+
const record = decodeWritten(written[lk]);
|
|
83
|
+
if (record) {
|
|
84
|
+
if (record.source !== shortSourceHash(src) || record.value !== valueHash(v)) continue;
|
|
85
|
+
} else if (changed.has(origin)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
items.push(...sharedOutputItems(lk, src, v));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.contentDir && config.format !== 'docusaurus') {
|
|
94
|
+
const contentDir = path.resolve(cwd, config.contentDir);
|
|
95
|
+
const manifest = readContentManifest(cwd);
|
|
96
|
+
let sources = [];
|
|
97
|
+
try { sources = fs.existsSync(contentDir) ? discoverContentFiles(contentDir, config.inputLocale) : []; } catch { sources = []; }
|
|
98
|
+
const leftAlone = new Set();
|
|
99
|
+
for (const sourcePath of sources) {
|
|
100
|
+
const relPath = path.relative(contentDir, sourcePath);
|
|
101
|
+
if (!fs.existsSync(getTargetContentPath(sourcePath, code, config.inputLocale))) continue;
|
|
102
|
+
if (fileScope && !fileScope.includes(relPath)) { leftAlone.add(sourcePath); continue; }
|
|
103
|
+
if (forceContent || (fileScope && fileScope.retranslates(relPath))) continue;
|
|
104
|
+
let hash;
|
|
105
|
+
try { hash = crypto.createHash('sha256').update(fs.readFileSync(sourcePath, 'utf-8'), 'utf-8').digest('hex'); } catch { continue; }
|
|
106
|
+
if (manifest[`${relPath}:${code}`] === hash) leftAlone.add(sourcePath);
|
|
107
|
+
}
|
|
108
|
+
if (leftAlone.size > 0) items.push(...contentItemsFor(config, cwd, code, { only: (p) => leftAlone.has(p) }));
|
|
109
|
+
}
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The shared-output index a sync of `code` starts with — ONE construction for
|
|
115
|
+
* sync's gate and for anything else that must refuse what sync refuses (the
|
|
116
|
+
* MCP translate tool with a project_dir): the sentences an earlier sync caught
|
|
117
|
+
* a model repeating (the cache's `_meta.memorized`, refused from their first
|
|
118
|
+
* source on), plus every translation of the locale on disk that the run
|
|
119
|
+
* leaves alone (itemsLeftAlone). Round 10, school persona: the translate tool
|
|
120
|
+
* returned the sentence the project already knew to be memorized, because
|
|
121
|
+
* its index held only that call's answers.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} p
|
|
124
|
+
* @param {object} p.config - Resolved config
|
|
125
|
+
* @param {string} p.cwd - Project directory
|
|
126
|
+
* @param {string} p.code - Target locale
|
|
127
|
+
* @param {object|null} [p.tm] - The project's cache (lib/tm.js loadTM)
|
|
128
|
+
* @param {object} [p.layout] - Locale layout (discovered when absent)
|
|
129
|
+
* @param {Array<object>} [p.units] - Source units (loaded when absent)
|
|
130
|
+
* @param {object} [p.localeState] - LockState.peek(code) (read from the lock when absent)
|
|
131
|
+
* @param {string[]} [p.namedKeys] - Keys named for a redo this run
|
|
132
|
+
* @param {boolean} [p.bulk] - `--redo all`
|
|
133
|
+
* @param {object|null} [p.fileScope] - --redo files: / --files / --retranslate
|
|
134
|
+
* @param {boolean} [p.forceContent] - --force-content / --redo content
|
|
135
|
+
* @returns {SharedOutputIndex}
|
|
136
|
+
*/
|
|
137
|
+
export function projectSharedOutputIndex({
|
|
138
|
+
config, cwd, code, tm = null, layout = null, units = null, localeState = null,
|
|
139
|
+
namedKeys = [], bulk = false, fileScope = null, forceContent = false,
|
|
140
|
+
}) {
|
|
141
|
+
const index = new SharedOutputIndex({ protectedTerms: config.protectedTerms || [] });
|
|
142
|
+
index.markMemorized(tm?._meta?.memorized?.[code] || []);
|
|
143
|
+
let lay = layout;
|
|
144
|
+
let us = units;
|
|
145
|
+
let state = localeState;
|
|
146
|
+
// A project whose key-value files cannot be read (a Docusaurus site, a
|
|
147
|
+
// missing locales folder) still has its pages and its memorized sentences.
|
|
148
|
+
if (!lay || !us) {
|
|
149
|
+
try {
|
|
150
|
+
lay = discoverLocaleLayout(config, { cwd });
|
|
151
|
+
us = loadSourceUnits(lay);
|
|
152
|
+
} catch {
|
|
153
|
+
lay = null;
|
|
154
|
+
us = [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!state) {
|
|
158
|
+
try { state = new LockState(readLock(cwd).locales).peek(code); } catch { state = { written: {}, pending: {} }; }
|
|
159
|
+
}
|
|
160
|
+
index.add(itemsLeftAlone({
|
|
161
|
+
config, layout: lay, units: lay ? us : [], code, localeState: state, namedKeys, bulk, fileScope, forceContent, cwd,
|
|
162
|
+
}));
|
|
163
|
+
return index;
|
|
164
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* source-contexts.js — which gettext contexts (msgctxt) a project's source
|
|
3
|
+
* gives each text.
|
|
4
|
+
*
|
|
5
|
+
* Sync keys a gettext entry with a context as `msgctxt\u0004msgid`, and caches
|
|
6
|
+
* its translation under the context folded into the text (lib/tm-evict.js
|
|
7
|
+
* tmSourceText): "Cancel" the button and "Cancel" the verb are two entries.
|
|
8
|
+
* A caller that translates a bare text in a project (the MCP translate tool
|
|
9
|
+
* with project_dir) needs to know when that text exists there ONLY with a
|
|
10
|
+
* context: a context-free cache entry for it is never read by sync, and only
|
|
11
|
+
* shadows the context-keyed ones (Round 13, Django persona).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { discoverLocaleLayout, loadSourceUnits } from './locale-layout.js';
|
|
15
|
+
import { CONTEXT_SEPARATOR } from './tm-evict.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every source text of the project, with whether it has an entry without a
|
|
19
|
+
* context and the contexts it has.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} config - A resolved config (lib/config.js resolveConfig)
|
|
22
|
+
* @param {{ cwd: string }} options
|
|
23
|
+
* @returns {Map<string, { plain: boolean, contexts: string[] }>} source text → its entries
|
|
24
|
+
*/
|
|
25
|
+
export function sourceTextContexts(config, { cwd }) {
|
|
26
|
+
const layout = discoverLocaleLayout(config, { cwd });
|
|
27
|
+
const out = new Map();
|
|
28
|
+
for (const unit of loadSourceUnits(layout)) {
|
|
29
|
+
for (const [key, value] of Object.entries(unit.flat)) {
|
|
30
|
+
if (typeof value !== 'string') continue;
|
|
31
|
+
if (!out.has(value)) out.set(value, { plain: false, contexts: [] });
|
|
32
|
+
const entry = out.get(value);
|
|
33
|
+
const at = key.indexOf(CONTEXT_SEPARATOR);
|
|
34
|
+
if (at < 0) entry.plain = true;
|
|
35
|
+
else if (!entry.contexts.includes(key.slice(0, at))) entry.contexts.push(key.slice(0, at));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
package/lib/submit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* submit — declarative model behind `champollion submit`.
|
|
2
|
+
* submit — declarative model behind `champollion network submit`.
|
|
3
3
|
*
|
|
4
4
|
* The CLI front door to Champollion's REVIEW-GATED community submission path.
|
|
5
5
|
* It gathers the fields a submission needs and hands the contributor a
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* @see .github/ISSUE_TEMPLATE/
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { parseLanguagePair, formatLanguagePair } from './language-pair.js';
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* The compliance attestation every submission must carry. A single source of
|
|
27
29
|
* truth so the CLI, the issue forms, and the docs all say the SAME thing.
|
|
@@ -30,6 +32,14 @@ export const ATTESTATION_TEXT =
|
|
|
30
32
|
'I confirm this is publicly listable, contains NO corpus content or personal data, ' +
|
|
31
33
|
"and respects the source's license and any community/sovereignty restrictions.";
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The label of every "language pair(s)" field. Three types asked for pairs in
|
|
37
|
+
* two spellings (eng-crk in one, source→target in two); the reader below
|
|
38
|
+
* takes any spelling lib/language-pair.js accepts and the submission carries
|
|
39
|
+
* each pair as source>target, the form the leaderboard writes.
|
|
40
|
+
*/
|
|
41
|
+
export const PAIRS_LABEL = 'Language pair(s), ISO 639-3, one per line, source>target (e.g. eng>crk; eng-crk works too)';
|
|
42
|
+
|
|
33
43
|
/** Repo the issue forms live in. Overridable, but this is the real default. */
|
|
34
44
|
export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
|
|
35
45
|
|
|
@@ -37,6 +47,7 @@ export const DEFAULT_REPO_BASE = 'https://github.com/gamedaysuits/Champollion';
|
|
|
37
47
|
// input — single-line free text
|
|
38
48
|
// text — multi-line free text (textarea)
|
|
39
49
|
// choice — one-of a fixed list (`choices`)
|
|
50
|
+
// (a text field with `pairs: true` holds language pairs, one per line — readPairLines)
|
|
40
51
|
// bool — a required confirmation (attestation / consent); never sent in the
|
|
41
52
|
// pre-fill URL (GitHub can't pre-check a checkbox), only recorded
|
|
42
53
|
// locally and enforced by validation.
|
|
@@ -57,7 +68,7 @@ export const SUBMISSION_TYPES = [
|
|
|
57
68
|
blurb: 'An evaluation corpus or benchmark — metadata + a fetch-from-source pointer only, never the corpus content.',
|
|
58
69
|
fields: [
|
|
59
70
|
{ id: 'dataset-name', label: 'Dataset name', kind: 'input', required: true, primary: true },
|
|
60
|
-
{ id: 'pairs', label:
|
|
71
|
+
{ id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
|
|
61
72
|
{ id: 'license', label: 'License', kind: 'choice', required: true,
|
|
62
73
|
choices: ['CC-BY-4.0', 'CC-BY-SA-4.0', 'CC0-1.0', 'CC-BY-NC-4.0', 'Proprietary / all rights reserved', 'Other / unsure'] },
|
|
63
74
|
{ id: 'license-url', label: 'License URL', kind: 'input' },
|
|
@@ -135,7 +146,7 @@ export const SUBMISSION_TYPES = [
|
|
|
135
146
|
{ id: 'display-name', label: 'Provider display name (consented public name)', kind: 'input', required: true, primary: true },
|
|
136
147
|
{ id: 'provider-type', label: 'Provider type', kind: 'choice', required: true,
|
|
137
148
|
choices: ['community_org', 'agency', 'individual'] },
|
|
138
|
-
{ id: 'pairs', label:
|
|
149
|
+
{ id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
|
|
139
150
|
{ id: 'variety', label: 'Variety / script (e.g. SRO, Latn)', kind: 'input' },
|
|
140
151
|
{ id: 'dispatch-channel', label: 'Dispatch channel (the address itself is PII — omit it)', kind: 'choice',
|
|
141
152
|
choices: ['email', 'webhook', 'api'] },
|
|
@@ -158,7 +169,7 @@ export const SUBMISSION_TYPES = [
|
|
|
158
169
|
blurb: 'A published result from another system/paper — cited, never re-hosted or re-ranked as our own measurement.',
|
|
159
170
|
fields: [
|
|
160
171
|
{ id: 'system-name', label: 'System / method name', kind: 'input', required: true, primary: true },
|
|
161
|
-
{ id: 'pairs', label:
|
|
172
|
+
{ id: 'pairs', label: PAIRS_LABEL, kind: 'text', pairs: true, required: true },
|
|
162
173
|
{ id: 'dataset', label: 'Dataset / benchmark', kind: 'input', required: true },
|
|
163
174
|
{ id: 'metric', label: 'Metric', kind: 'choice', required: true,
|
|
164
175
|
choices: ['chrF++', 'chrF', 'BLEU', 'COMET', 'TER', 'Other'] },
|
|
@@ -252,7 +263,7 @@ export function repoBaseFromRepository(repoUrl) {
|
|
|
252
263
|
*/
|
|
253
264
|
export function validateSubmission(type, values = {}) {
|
|
254
265
|
const errors = [];
|
|
255
|
-
if (!type) return { ok: false, errors: ['unknown submission type — run `champollion submit --list`'] };
|
|
266
|
+
if (!type) return { ok: false, errors: ['unknown submission type — run `champollion network submit --list`'] };
|
|
256
267
|
for (const f of type.fields) {
|
|
257
268
|
const v = values[f.id];
|
|
258
269
|
if (f.kind === 'bool') {
|
|
@@ -266,10 +277,51 @@ export function validateSubmission(type, values = {}) {
|
|
|
266
277
|
const empty = v == null || String(v).trim() === '';
|
|
267
278
|
if (empty) errors.push(`missing required field: ${f.id} (${f.label})`);
|
|
268
279
|
}
|
|
280
|
+
if (f.pairs && v != null && String(v).trim() !== '') {
|
|
281
|
+
for (const e of readPairLines(v).errors) errors.push(`${f.id}: ${e}`);
|
|
282
|
+
}
|
|
269
283
|
}
|
|
270
284
|
return { ok: errors.length === 0, errors };
|
|
271
285
|
}
|
|
272
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Read a "language pair(s)" field: one pair per line, each in any spelling
|
|
289
|
+
* lib/language-pair.js accepts. Blank lines are skipped.
|
|
290
|
+
*
|
|
291
|
+
* @param {*} value
|
|
292
|
+
* @returns {{pairs: string[], errors: string[]}} pairs written source>target
|
|
293
|
+
*/
|
|
294
|
+
export function readPairLines(value) {
|
|
295
|
+
const pairs = [];
|
|
296
|
+
const errors = [];
|
|
297
|
+
String(value).split(/\r?\n/).forEach((line, i) => {
|
|
298
|
+
if (!line.trim()) return;
|
|
299
|
+
const p = parseLanguagePair(line, { label: `line ${i + 1}:` });
|
|
300
|
+
if (p.ok) pairs.push(formatLanguagePair(p));
|
|
301
|
+
else errors.push(p.error);
|
|
302
|
+
});
|
|
303
|
+
return { pairs, errors };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The values with every pair field written the one way (source>target, one
|
|
308
|
+
* per line), for the issue URL and the local record. A field that does not
|
|
309
|
+
* read cleanly is left as typed — validateSubmission reports it.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} type one of SUBMISSION_TYPES
|
|
312
|
+
* @param {Record<string,*>} values
|
|
313
|
+
* @returns {Record<string,*>} a new object; `values` is not changed
|
|
314
|
+
*/
|
|
315
|
+
export function normalizeSubmissionValues(type, values = {}) {
|
|
316
|
+
const out = { ...values };
|
|
317
|
+
for (const f of type.fields) {
|
|
318
|
+
if (!f.pairs || out[f.id] == null || String(out[f.id]).trim() === '') continue;
|
|
319
|
+
const r = readPairLines(out[f.id]);
|
|
320
|
+
if (r.errors.length === 0) out[f.id] = r.pairs.join('\n');
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
273
325
|
/** The field whose value seeds the issue title (the `primary` one). */
|
|
274
326
|
function primaryField(type) {
|
|
275
327
|
return type.fields.find((f) => f.primary) || null;
|