champollion-mcp-server 0.1.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/LICENSE +133 -0
- package/README.md +245 -0
- package/bin/server.js +19 -0
- package/instructions.md +234 -0
- package/package.json +50 -0
- package/src/index.js +1106 -0
- package/src/tools/forge.js +140 -0
- package/src/tools/harness.js +727 -0
- package/src/tools/languages.js +329 -0
- package/src/tools/queue.js +313 -0
- package/src/tools/reliability.js +190 -0
- package/src/tools/results.js +346 -0
- package/src/tools/training.js +349 -0
- package/src/tools/translate.js +385 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language tools — search and browse Champollion language cards.
|
|
3
|
+
*
|
|
4
|
+
* Card data comes from ONE of three sources, tried in order:
|
|
5
|
+
* 1. CHAMPOLLION_CARDS_DIR — explicit override (same variable the CLI and
|
|
6
|
+
* the Python harness honor). Set-but-unusable is an immediate error:
|
|
7
|
+
* the user pointed somewhere specific, and silently reading a different
|
|
8
|
+
* corpus would be worse than failing.
|
|
9
|
+
* 2. The monorepo checkout's cli/shared/language-cards/ (full corpus).
|
|
10
|
+
* 3. The champollion package's bundled shared/cards-fallback.json — the
|
|
11
|
+
* same bundle the published CLI runs on: 1,157 full cards plus a
|
|
12
|
+
* manifest of every concrete code → name/aliases, so the FULL catalogue
|
|
13
|
+
* is searchable by name offline, with rich fields on the core set and
|
|
14
|
+
* honest absences elsewhere.
|
|
15
|
+
*
|
|
16
|
+
* NOTHING is hardcoded and nothing degrades silently. This file used to
|
|
17
|
+
* carry a hand-written 40-language FALLBACK_INDEX served with only a stderr
|
|
18
|
+
* note — an agent outside the repo was told "No languages found" for ~7,887
|
|
19
|
+
* real languages, indistinguishable from an authoritative answer (and the
|
|
20
|
+
* published install crashed before even reaching it, because the adapter
|
|
21
|
+
* import was a static repo-relative path). When no source resolves, the
|
|
22
|
+
* server now REFUSES TO START, listing every path tried and the fix — the
|
|
23
|
+
* same posture as translate.js's "refusing to start with an empty or
|
|
24
|
+
* hand-guessed method surface". With `champollion` a real dependency, a
|
|
25
|
+
* missing card surface always means a broken install, never a legitimate
|
|
26
|
+
* degraded mode.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { dirname, join, resolve } from 'node:path';
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
|
|
38
|
+
// Monorepo layout: mcp-server/src/tools/ → repo root is three up.
|
|
39
|
+
const REPO_CLI = resolve(__dirname, '../../../cli');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the ONE card adapter (cli/lib/cards/reader.js), monorepo checkout
|
|
43
|
+
* first so development never resolves a stale registry install, then the
|
|
44
|
+
* `champollion` dependency for published installs. A ninth private reader is
|
|
45
|
+
* how this server came to serve "[object Object]" — the adapter is not
|
|
46
|
+
* optional, so failing to find it is a startup error, not a fallback.
|
|
47
|
+
*
|
|
48
|
+
* @returns {Promise<{reader: object, packageRoot: string, source: string}>}
|
|
49
|
+
*/
|
|
50
|
+
async function resolveAdapter() {
|
|
51
|
+
const attempts = [];
|
|
52
|
+
|
|
53
|
+
const repoReader = resolve(REPO_CLI, 'lib/cards/reader.js');
|
|
54
|
+
if (existsSync(repoReader)) {
|
|
55
|
+
const reader = await import(pathToFileURL(repoReader).href);
|
|
56
|
+
return { reader, packageRoot: REPO_CLI, source: 'monorepo checkout' };
|
|
57
|
+
}
|
|
58
|
+
attempts.push(`${repoReader} (monorepo checkout — not present)`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const entry = require.resolve('champollion');
|
|
62
|
+
const reader = await import('champollion');
|
|
63
|
+
return {
|
|
64
|
+
reader,
|
|
65
|
+
packageRoot: dirname(entry),
|
|
66
|
+
source: 'champollion package',
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
attempts.push(`champollion package (${err.code ?? err.message})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Cannot resolve the champollion card adapter — refusing to start with no '
|
|
74
|
+
+ 'card surface. Tried:\n'
|
|
75
|
+
+ attempts.map((a) => ` - ${a}`).join('\n')
|
|
76
|
+
+ '\nFix: run inside the Champollion monorepo, or `npm install` in the '
|
|
77
|
+
+ 'MCP server directory so the `champollion` dependency is present.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One index entry from a full (normalized) card. */
|
|
82
|
+
function entryFromCard(card, { display, isDisputed }) {
|
|
83
|
+
// Speaker estimates: ONE claim answers as itself; several claims answer as
|
|
84
|
+
// the DISAGREEMENT, every value shown. Taking estimates[0] silently elected
|
|
85
|
+
// ELCat's British-Columbia-only count as Plains Cree's total — 'speakers:
|
|
86
|
+
// 10-99' on the flagship card, which is exactly the pick-a-winner move the
|
|
87
|
+
// card boundary forbids.
|
|
88
|
+
const claims = Array.isArray(card.speakerEstimates) ? card.speakerEstimates : [];
|
|
89
|
+
const counts = [...new Set(claims.map((e) => e?.count).filter((c) => c != null && c !== ''))];
|
|
90
|
+
const speakers = card.vitality?.speakerCount
|
|
91
|
+
|| (counts.length === 1 ? counts[0]
|
|
92
|
+
: counts.length > 1 ? `${counts.join(' / ')} (sources differ)` : '');
|
|
93
|
+
const fam = card.classification?.family;
|
|
94
|
+
return {
|
|
95
|
+
code: card.code || card.iso639_3 || card.bcp47 || '',
|
|
96
|
+
name: card.name || '',
|
|
97
|
+
endonym: card.nativeName || card.endonym || '',
|
|
98
|
+
// display() yields the agreed value and nothing on a real dispute —
|
|
99
|
+
// but 'disputed' and 'unknown' are different claims, so a genuine
|
|
100
|
+
// disagreement says so instead of reading as ignorance.
|
|
101
|
+
family: display(fam) || (isDisputed?.(fam) ? 'disputed' : ''),
|
|
102
|
+
speakers,
|
|
103
|
+
script: card.script || (Array.isArray(card.scripts) ? card.scripts[0] : '') || '',
|
|
104
|
+
region: card.macroarea || '',
|
|
105
|
+
typology: card.typologicalProfile?.verbSynthesis
|
|
106
|
+
|| card.typologicalProfile?.morphologicalSynthesis || '',
|
|
107
|
+
// Documented alternate names so a language is findable by ANY of its
|
|
108
|
+
// names — the same alias set the Atlas searches.
|
|
109
|
+
aliases: [
|
|
110
|
+
...(Array.isArray(card.alternateNames) ? card.alternateNames : []),
|
|
111
|
+
...(Array.isArray(card.aliases) ? card.aliases : []),
|
|
112
|
+
],
|
|
113
|
+
// Keep the full card for detailed lookups
|
|
114
|
+
_raw: card,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Build the index from a language-cards directory (full corpus). */
|
|
119
|
+
async function indexFromDir(dir, ctx) {
|
|
120
|
+
const { normalizeCard } = ctx;
|
|
121
|
+
const files = await readdir(dir);
|
|
122
|
+
const jsonFiles = files.filter((f) => f.endsWith('.json') && !f.startsWith('.'));
|
|
123
|
+
|
|
124
|
+
const index = [];
|
|
125
|
+
const cards = await Promise.allSettled(
|
|
126
|
+
jsonFiles.map(async (f) => {
|
|
127
|
+
const raw = await readFile(join(dir, f), 'utf-8');
|
|
128
|
+
// THROUGH THE ONE ADAPTER, like every other consumer.
|
|
129
|
+
return normalizeCard(JSON.parse(raw));
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
for (const result of cards) {
|
|
134
|
+
if (result.status !== 'fulfilled') continue;
|
|
135
|
+
const card = result.value;
|
|
136
|
+
// The cards dir also holds generated reference files (language-tree.json)
|
|
137
|
+
// that are not cards — a card always carries a code and a name.
|
|
138
|
+
if (!(card.code || card.iso639_3 || card.bcp47) || !card.name) continue;
|
|
139
|
+
// A LOCALE IS NOT A LANGUAGE: fra-CA carries French's name and facts;
|
|
140
|
+
// indexing the 8,675 locale cards would return one language a dozen
|
|
141
|
+
// times for one query.
|
|
142
|
+
if (card.locale?.language) continue;
|
|
143
|
+
index.push(entryFromCard(card, ctx));
|
|
144
|
+
}
|
|
145
|
+
return index;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the index from the CLI's bundled cards-fallback.json: full entries
|
|
150
|
+
* for the ~1,157 bundled core cards, lean name/alias entries for every other
|
|
151
|
+
* concrete language in the manifest. Lean entries answer "does Champollion
|
|
152
|
+
* know this language, and by what name" honestly — with empty strings, never
|
|
153
|
+
* invented facts — and the full card remains fetchable through the CLI.
|
|
154
|
+
*/
|
|
155
|
+
async function indexFromFallbackFile(file, ctx) {
|
|
156
|
+
const { normalizeCard } = ctx;
|
|
157
|
+
const bundle = JSON.parse(await readFile(file, 'utf-8'));
|
|
158
|
+
const index = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
|
|
161
|
+
for (const raw of Object.values(bundle.cards ?? {})) {
|
|
162
|
+
const card = normalizeCard(raw);
|
|
163
|
+
if (!(card.code || card.iso639_3 || card.bcp47) || !card.name) continue;
|
|
164
|
+
// The bundle deliberately carries locale-variant cards (fra-CA,
|
|
165
|
+
// cmn-Hant) for the CLI's resolution needs; a language index excludes
|
|
166
|
+
// them by locale block AND by dashed code — some variant cards predate
|
|
167
|
+
// the locale block.
|
|
168
|
+
if (card.locale?.language) continue;
|
|
169
|
+
const entry = entryFromCard(card, ctx);
|
|
170
|
+
if (entry.code.includes('-')) continue;
|
|
171
|
+
index.push(entry);
|
|
172
|
+
seen.add(entry.code);
|
|
173
|
+
}
|
|
174
|
+
const parents = new Set(Object.keys(bundle.parents ?? {}));
|
|
175
|
+
for (const [code, m] of Object.entries(bundle.manifest ?? {})) {
|
|
176
|
+
if (seen.has(code) || parents.has(code)) continue;
|
|
177
|
+
// Dashed codes are locale projections (fra-CA) — not languages. (This
|
|
178
|
+
// also skips the x-* constructed-script variants; their base cards are
|
|
179
|
+
// in the bundled core set.)
|
|
180
|
+
if (code.includes('-')) continue;
|
|
181
|
+
if (!m?.n) continue;
|
|
182
|
+
index.push({
|
|
183
|
+
code,
|
|
184
|
+
name: m.n,
|
|
185
|
+
endonym: '',
|
|
186
|
+
family: '',
|
|
187
|
+
speakers: '',
|
|
188
|
+
script: '',
|
|
189
|
+
region: '',
|
|
190
|
+
typology: '',
|
|
191
|
+
aliases: Array.isArray(m.a) ? m.a : [],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return index;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Load the language index. Resolution ladder in the module docstring; every
|
|
199
|
+
* miss is recorded and a total miss THROWS with the full list — startup is
|
|
200
|
+
* the right place to fail, because index.js awaits this before the MCP
|
|
201
|
+
* handshake and a server that starts without languages would answer
|
|
202
|
+
* "No languages found" as if it were a fact about the world.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} [opts] Test injection: `cardsDir` (strict, same semantics
|
|
205
|
+
* as CHAMPOLLION_CARDS_DIR), `fallbackFile`, `repoDir` (override the
|
|
206
|
+
* monorepo candidate).
|
|
207
|
+
* @returns {Promise<object[]>} Language index array
|
|
208
|
+
*/
|
|
209
|
+
export async function loadLanguageIndex(opts = {}) {
|
|
210
|
+
const { reader, packageRoot, source } = await resolveAdapter();
|
|
211
|
+
const { normalizeCard, display, isDisputed } = reader;
|
|
212
|
+
const adapterCtx = { normalizeCard, display, isDisputed };
|
|
213
|
+
const attempts = [];
|
|
214
|
+
|
|
215
|
+
// Tier 1 — explicit override: obey it or fail, never fall past it.
|
|
216
|
+
const explicit = opts.cardsDir ?? process.env.CHAMPOLLION_CARDS_DIR;
|
|
217
|
+
if (explicit) {
|
|
218
|
+
const label = opts.cardsDir ? 'cardsDir option' : 'CHAMPOLLION_CARDS_DIR';
|
|
219
|
+
let index;
|
|
220
|
+
try {
|
|
221
|
+
index = await indexFromDir(explicit, adapterCtx);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`${label} points at ${explicit}, which is not a readable card `
|
|
225
|
+
+ `directory (${err.code ?? err.message}). The override names a `
|
|
226
|
+
+ 'specific corpus; silently reading a different one would be worse '
|
|
227
|
+
+ 'than failing.',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (index.length === 0) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`${label} points at ${explicit}, which contains no language cards — `
|
|
233
|
+
+ 'a broken corpus, not a missing one.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
process.stderr.write(`Loaded ${index.length} language cards from ${explicit} (${label})\n`);
|
|
237
|
+
return index;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Tier 2 — the monorepo's full corpus.
|
|
241
|
+
const repoDir = opts.repoDir ?? resolve(REPO_CLI, 'shared/language-cards');
|
|
242
|
+
if (existsSync(repoDir)) {
|
|
243
|
+
const index = await indexFromDir(repoDir, adapterCtx);
|
|
244
|
+
if (index.length === 0) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`${repoDir} exists but contains no language cards — a broken corpus `
|
|
247
|
+
+ '(a half-applied cutover?), not a missing one. Rebuild the cards or '
|
|
248
|
+
+ 'set CHAMPOLLION_CARDS_DIR.',
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
process.stderr.write(`Loaded ${index.length} language cards from ${repoDir}\n`);
|
|
252
|
+
return index;
|
|
253
|
+
}
|
|
254
|
+
attempts.push(`${repoDir} (monorepo corpus — not present)`);
|
|
255
|
+
|
|
256
|
+
// Tier 3 — the champollion package's bundled fallback.
|
|
257
|
+
const fallbackFile = opts.fallbackFile
|
|
258
|
+
?? process.env.CHAMPOLLION_CARDS_FALLBACK
|
|
259
|
+
?? join(packageRoot, 'shared', 'cards-fallback.json');
|
|
260
|
+
if (existsSync(fallbackFile)) {
|
|
261
|
+
const index = await indexFromFallbackFile(fallbackFile, adapterCtx);
|
|
262
|
+
if (index.length === 0) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${fallbackFile} parsed but yielded no language entries — a broken `
|
|
265
|
+
+ 'bundle, not a missing one.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`Loaded ${index.length} languages from the bundled fallback ${fallbackFile}\n`,
|
|
270
|
+
);
|
|
271
|
+
return index;
|
|
272
|
+
}
|
|
273
|
+
attempts.push(`${fallbackFile} (bundled cards-fallback.json — not present)`);
|
|
274
|
+
|
|
275
|
+
throw new Error(
|
|
276
|
+
'No language-card source resolved — refusing to start with an empty or '
|
|
277
|
+
+ `hand-guessed language surface (adapter came from the ${source}). Tried:\n`
|
|
278
|
+
+ attempts.map((a) => ` - ${a}`).join('\n')
|
|
279
|
+
+ '\nFix: set CHAMPOLLION_CARDS_DIR to a language-cards directory, run '
|
|
280
|
+
+ 'inside the Champollion monorepo, or reinstall so the champollion '
|
|
281
|
+
+ "dependency's bundled cards ship intact.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Search the language index for matches against a query string.
|
|
287
|
+
*
|
|
288
|
+
* Matches against: code, name, endonym, family, region.
|
|
289
|
+
* Case-insensitive substring match, ranked: exact code match first, then
|
|
290
|
+
* exact name/endonym, then name-prefix, then everything else in file order.
|
|
291
|
+
*
|
|
292
|
+
* @param {object[]} index Language index from loadLanguageIndex()
|
|
293
|
+
* @param {string} query Search term
|
|
294
|
+
* @param {number} limit Max results
|
|
295
|
+
* @returns {object[]} Matching languages
|
|
296
|
+
*/
|
|
297
|
+
export function searchLanguages(index, query, limit = 10) {
|
|
298
|
+
const q = query.toLowerCase();
|
|
299
|
+
const tiers = [[], [], [], []];
|
|
300
|
+
|
|
301
|
+
for (const lang of index) {
|
|
302
|
+
const aliases = Array.isArray(lang.aliases) ? lang.aliases : [];
|
|
303
|
+
const searchable = [
|
|
304
|
+
lang.code, lang.name, lang.endonym, lang.family,
|
|
305
|
+
lang.region, lang.typology, ...aliases,
|
|
306
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
307
|
+
|
|
308
|
+
if (!searchable.includes(q)) continue;
|
|
309
|
+
|
|
310
|
+
if (lang.code && lang.code.toLowerCase() === q) {
|
|
311
|
+
tiers[0].push(lang);
|
|
312
|
+
} else if (
|
|
313
|
+
(lang.name && lang.name.toLowerCase() === q) ||
|
|
314
|
+
(lang.endonym && lang.endonym.toLowerCase() === q) ||
|
|
315
|
+
aliases.some((a) => a.toLowerCase() === q)
|
|
316
|
+
) {
|
|
317
|
+
tiers[1].push(lang);
|
|
318
|
+
} else if (lang.name && lang.name.toLowerCase().startsWith(q)) {
|
|
319
|
+
tiers[2].push(lang);
|
|
320
|
+
} else {
|
|
321
|
+
tiers[3].push(lang);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Enough candidates to fill the limit from the best tiers alone.
|
|
325
|
+
if (tiers[0].length >= limit) break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return tiers.flat().slice(0, limit);
|
|
329
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue tools — fetch, filter, and inspect the Champollion sweep queue.
|
|
3
|
+
*
|
|
4
|
+
* Data source (default): the live queue served from Postgres via the queue_top
|
|
5
|
+
* RPC — a ranked list of (corpus, model, condition) items, coverage-filtered
|
|
6
|
+
* against VERIFIED runs, so nothing stale or already-done is shown. Items are
|
|
7
|
+
* paged from the RPC; metadata comes from the small queue-preview.json. If the
|
|
8
|
+
* DB is unreachable, fetchQueue falls back to the full static queue.json blob,
|
|
9
|
+
* so the tools never break. Set CHAMPOLLION_QUEUE_SOURCE=blob to force the blob.
|
|
10
|
+
* An in-memory CACHE_TTL_MS cache avoids re-fetching on every tool call.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const QUEUE_URL = 'https://champollion.dev/queue.json';
|
|
14
|
+
// DB-as-queue (B1): the live queue is served from Postgres (the queue_top RPC),
|
|
15
|
+
// coverage-filtered against VERIFIED runs, so items are never stale. Metadata
|
|
16
|
+
// (open_items, models, priority_model, cost_basis, how_to_run) comes from the
|
|
17
|
+
// small queue-preview.json companion. The full static blob remains the FALLBACK
|
|
18
|
+
// so this tool never breaks if the DB is unreachable.
|
|
19
|
+
const PREVIEW_URL = 'https://champollion.dev/queue-preview.json';
|
|
20
|
+
const SUPABASE_URL = process.env.MT_EVAL_SUPABASE_URL
|
|
21
|
+
|| 'https://sjdomynysdljkbemupqa.supabase.co';
|
|
22
|
+
const SUPABASE_ANON_KEY = process.env.MT_EVAL_SUPABASE_ANON_KEY
|
|
23
|
+
|| 'sb_publishable_bV6CFNFnzxhQI0wlBx2J0A_5Vm5gFBp';
|
|
24
|
+
const QUEUE_TOP_PAGE = 500; // matches the RPC's hard page cap
|
|
25
|
+
// 'db' (default) serves live from queue_top with a blob fallback; 'blob' forces
|
|
26
|
+
// the legacy static file (used by the existing fetch tests).
|
|
27
|
+
const QUEUE_SOURCE = process.env.CHAMPOLLION_QUEUE_SOURCE || 'db';
|
|
28
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
29
|
+
|
|
30
|
+
// Served item fields (what queue.json publishes / consumers rely on). queue_top
|
|
31
|
+
// rows also carry rank_mode/map_value/diagnostics/generation_id/generated_at —
|
|
32
|
+
// projected away so DB-sourced items match the blob shape exactly.
|
|
33
|
+
const SERVED_FIELDS = [
|
|
34
|
+
'priority', 'id', 'language_pair', 'source_language', 'target_language',
|
|
35
|
+
'corpus_id', 'corpus_license', 'entry_count', 'contamination', 'domain',
|
|
36
|
+
'source_length', 'model', 'condition', 'est_cost_usd', 'est_basis',
|
|
37
|
+
'run_command',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let _cache = null;
|
|
41
|
+
let _cacheTime = 0;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch the queue, returning the cached version if still fresh.
|
|
45
|
+
*
|
|
46
|
+
* The static host can serve an HTML holding page with HTTP 200 (site gated,
|
|
47
|
+
* maintenance, CDN error page) — `resp.json()` would then surface a raw
|
|
48
|
+
* `SyntaxError: Unexpected token '<'` to every queue-backed tool (list_queue,
|
|
49
|
+
* get_queue_item, estimate_cost, get_project_info, and run_benchmark's item
|
|
50
|
+
* lookup all relay err.message verbatim). The body is therefore read as text
|
|
51
|
+
* and parsed here, so a non-JSON response becomes ONE clean, user-facing
|
|
52
|
+
* error at this shared choke point. Failures are never cached — the next
|
|
53
|
+
* call re-fetches.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [opts]
|
|
56
|
+
* @param {typeof fetch} [opts.fetchImpl] Injectable fetch (for tests).
|
|
57
|
+
* @returns {Promise<{ metadata: object, items: object[] }>}
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchQueue({ fetchImpl = fetch, source = QUEUE_SOURCE } = {}) {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
if (_cache && (now - _cacheTime) < CACHE_TTL_MS) {
|
|
62
|
+
return _cache;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let data;
|
|
66
|
+
if (source === 'blob') {
|
|
67
|
+
data = await fetchQueueFromBlob(fetchImpl);
|
|
68
|
+
} else {
|
|
69
|
+
// Live DB path with a graceful fallback: a DB/preview failure must never
|
|
70
|
+
// take the tool down when the static blob is still being served.
|
|
71
|
+
try {
|
|
72
|
+
data = await fetchQueueFromDb(fetchImpl);
|
|
73
|
+
} catch {
|
|
74
|
+
data = await fetchQueueFromBlob(fetchImpl);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_cache = data;
|
|
79
|
+
_cacheTime = now;
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Serve the live queue from Postgres: items from the queue_top RPC (paged,
|
|
84
|
+
* coverage-filtered against verified runs), metadata from queue-preview.json.
|
|
85
|
+
* Throws on any failure so fetchQueue can fall back to the static blob. */
|
|
86
|
+
async function fetchQueueFromDb(fetchImpl) {
|
|
87
|
+
// Metadata + preview from the companion file (a few hundred KB — ~292 KB
|
|
88
|
+
// as of 2026-08; it grows with the queue, so never assume it is tiny).
|
|
89
|
+
const pResp = await fetchImpl(PREVIEW_URL, {
|
|
90
|
+
headers: { 'Accept': 'application/json' },
|
|
91
|
+
signal: AbortSignal.timeout(30_000),
|
|
92
|
+
});
|
|
93
|
+
if (!pResp.ok) throw new Error(`preview HTTP ${pResp.status}`);
|
|
94
|
+
const preview = JSON.parse(await pResp.text());
|
|
95
|
+
const metadata = preview?.metadata;
|
|
96
|
+
if (metadata === null || typeof metadata !== 'object') {
|
|
97
|
+
throw new Error('queue-preview.json missing metadata');
|
|
98
|
+
}
|
|
99
|
+
const rankMode = metadata.rank_mode || 'map';
|
|
100
|
+
|
|
101
|
+
// Page through the RPC until a short page signals the end.
|
|
102
|
+
const items = [];
|
|
103
|
+
for (let offset = 0; ; offset += QUEUE_TOP_PAGE) {
|
|
104
|
+
const resp = await fetchImpl(`${SUPABASE_URL}/rest/v1/rpc/queue_top`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'apikey': SUPABASE_ANON_KEY,
|
|
108
|
+
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
'Accept': 'application/json',
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
p_rank_mode: rankMode, p_limit: QUEUE_TOP_PAGE, p_offset: offset,
|
|
114
|
+
}),
|
|
115
|
+
signal: AbortSignal.timeout(30_000),
|
|
116
|
+
});
|
|
117
|
+
if (!resp.ok) throw new Error(`queue_top HTTP ${resp.status}`);
|
|
118
|
+
const page = JSON.parse(await resp.text());
|
|
119
|
+
if (!Array.isArray(page)) throw new Error('queue_top did not return an array');
|
|
120
|
+
for (const row of page) {
|
|
121
|
+
const item = {};
|
|
122
|
+
for (const f of SERVED_FIELDS) if (row[f] !== undefined) item[f] = row[f];
|
|
123
|
+
// The restricted-corpus `transmission` stamp is a SERVED extra on
|
|
124
|
+
// queue.json but not a queue_items COLUMN — the ranker writes it into
|
|
125
|
+
// the diagnostics JSONB. Projecting columns alone dropped it from every
|
|
126
|
+
// DB-served item, so agents pulling work through MCP lost the no-train
|
|
127
|
+
// channel requirement the blob discloses. Lift it back.
|
|
128
|
+
const stamp = row.diagnostics?.transmission;
|
|
129
|
+
if (stamp && typeof stamp === 'object' && stamp.policy) item.transmission = stamp;
|
|
130
|
+
items.push(item);
|
|
131
|
+
}
|
|
132
|
+
if (page.length < QUEUE_TOP_PAGE) break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// open_items reflects the LIVE served count, not the last generation's stat.
|
|
136
|
+
return { metadata: { ...metadata, open_items: items.length }, items };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The legacy path: the full static queue.json blob. Also the DB-path fallback. */
|
|
140
|
+
async function fetchQueueFromBlob(fetchImpl) {
|
|
141
|
+
const resp = await fetchImpl(QUEUE_URL, {
|
|
142
|
+
headers: { 'Accept': 'application/json' },
|
|
143
|
+
signal: AbortSignal.timeout(30_000),
|
|
144
|
+
});
|
|
145
|
+
if (!resp.ok) {
|
|
146
|
+
throw new Error(`Queue fetch failed: HTTP ${resp.status}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const body = await resp.text();
|
|
150
|
+
let data;
|
|
151
|
+
try {
|
|
152
|
+
data = JSON.parse(body);
|
|
153
|
+
} catch {
|
|
154
|
+
const contentType = resp.headers?.get?.('content-type') || 'unknown content-type';
|
|
155
|
+
throw new Error(
|
|
156
|
+
'the Champollion queue endpoint is not serving JSON — the site may be '
|
|
157
|
+
+ `gated or down (got ${contentType} from ${QUEUE_URL}). `
|
|
158
|
+
+ 'Try again later, or check https://champollion.dev.',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
// Parsed, but not a queue: a gate/deploy step could serve a JSON error
|
|
162
|
+
// object with HTTP 200. Fail cleanly here rather than crashing downstream
|
|
163
|
+
// in filterQueue with a raw TypeError.
|
|
164
|
+
if (data === null || typeof data !== 'object'
|
|
165
|
+
|| !Array.isArray(data.items)
|
|
166
|
+
|| data.metadata === null || typeof data.metadata !== 'object') {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'the Champollion queue endpoint returned JSON without the expected '
|
|
169
|
+
+ '{ metadata, items } queue shape — the site may be gated or '
|
|
170
|
+
+ 'mid-deploy. Try again later, or check https://champollion.dev.',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Clear the in-memory queue cache. For test isolation. */
|
|
178
|
+
export function resetQueueCache() {
|
|
179
|
+
_cache = null;
|
|
180
|
+
_cacheTime = 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Filter queue items by language, model, budget, and condition.
|
|
185
|
+
*
|
|
186
|
+
* Core selection follows the same rules as queue_runner.py select_items:
|
|
187
|
+
* - Items are walked in queue order (the ranking IS the priority)
|
|
188
|
+
* - Budget mode: only items whose est_cost fits entirely within
|
|
189
|
+
* the remaining budget are selected
|
|
190
|
+
* - Items without cost estimates are skipped in budget mode
|
|
191
|
+
* - Coached items are included only when condition='coached'
|
|
192
|
+
*
|
|
193
|
+
* This function adds language/model/source_language filters on top
|
|
194
|
+
* of the core selection logic (those are MCP-only features).
|
|
195
|
+
*
|
|
196
|
+
* SSOT: Canonical implementation is arena/mt_eval_harness/queue_runner.py.
|
|
197
|
+
* Both are tested against shared/queue-selection-vectors.json — if you
|
|
198
|
+
* change core selection behavior here, update the vectors and run both
|
|
199
|
+
* test suites (npm test + pytest).
|
|
200
|
+
*
|
|
201
|
+
* @param {object[]} items All queue items (already sorted by priority)
|
|
202
|
+
* @param {object} filters Filter options
|
|
203
|
+
* @returns {object[]} Filtered items
|
|
204
|
+
*/
|
|
205
|
+
export function filterQueue(items, {
|
|
206
|
+
budget = null,
|
|
207
|
+
language = null,
|
|
208
|
+
source_language = null,
|
|
209
|
+
model = null,
|
|
210
|
+
condition = null,
|
|
211
|
+
limit = 20,
|
|
212
|
+
} = {}) {
|
|
213
|
+
const selected = [];
|
|
214
|
+
let spend = 0;
|
|
215
|
+
|
|
216
|
+
// Normalize filter strings for case-insensitive matching
|
|
217
|
+
const langLower = language?.toLowerCase();
|
|
218
|
+
const modelLower = model?.toLowerCase();
|
|
219
|
+
const srcLower = source_language?.toLowerCase();
|
|
220
|
+
|
|
221
|
+
for (const item of items) {
|
|
222
|
+
if (selected.length >= limit) break;
|
|
223
|
+
|
|
224
|
+
// --- Condition filter ---
|
|
225
|
+
// Skip coached items unless explicitly requested (mirrors queue_runner.py)
|
|
226
|
+
if (condition) {
|
|
227
|
+
if (item.condition !== condition) continue;
|
|
228
|
+
} else if (item.condition === 'coached') {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- Language filter ---
|
|
233
|
+
// Matches against target_language name or the target side of the pair code
|
|
234
|
+
if (langLower) {
|
|
235
|
+
const targetCode = item.language_pair?.split('>')[1] || '';
|
|
236
|
+
const targetName = (item.target_language || '').toLowerCase();
|
|
237
|
+
if (!targetName.includes(langLower) && !targetCode.includes(langLower)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- Source language filter ---
|
|
243
|
+
if (srcLower) {
|
|
244
|
+
const sourceCode = item.language_pair?.split('>')[0] || '';
|
|
245
|
+
if (sourceCode.toLowerCase() !== srcLower) continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Model filter ---
|
|
249
|
+
if (modelLower) {
|
|
250
|
+
const itemModel = (item.model || '').toLowerCase();
|
|
251
|
+
if (!itemModel.includes(modelLower)) continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- Budget filter ---
|
|
255
|
+
if (budget != null) {
|
|
256
|
+
const est = item.est_cost_usd;
|
|
257
|
+
if (est == null) continue; // unknown cost — skip in budget mode
|
|
258
|
+
if (spend + est > budget) continue; // would exceed budget
|
|
259
|
+
spend += est;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
selected.push(item);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return selected;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Look up a single queue item by ID or priority rank.
|
|
270
|
+
*
|
|
271
|
+
* @param {object[]} items All queue items
|
|
272
|
+
* @param {{ id?: string, priority?: number }} lookup
|
|
273
|
+
* @returns {object|null} The matching item, or null
|
|
274
|
+
*/
|
|
275
|
+
export function getQueueItem(items, { id, priority }) {
|
|
276
|
+
if (id) {
|
|
277
|
+
return items.find(it => it.id === id) || null;
|
|
278
|
+
}
|
|
279
|
+
if (priority != null) {
|
|
280
|
+
return items.find(it => it.priority === priority) || null;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Estimate cost for a set of queue items matching the given filters.
|
|
287
|
+
*
|
|
288
|
+
* Returns aggregate statistics without executing anything.
|
|
289
|
+
*
|
|
290
|
+
* @param {object[]} items All queue items
|
|
291
|
+
* @param {object} filters Same filters as filterQueue
|
|
292
|
+
* @returns {{ count, totalCost, cheapest, mostExpensive, languages }}
|
|
293
|
+
*/
|
|
294
|
+
export function estimateCost(items, filters) {
|
|
295
|
+
// Use a high limit to get all matching items for cost estimation
|
|
296
|
+
// Hard cap so a broad filter can't blow up the response; callers see
|
|
297
|
+
// `capped` and must not read `count` as the queue-wide total.
|
|
298
|
+
const ESTIMATE_CAP = 500;
|
|
299
|
+
const matched = filterQueue(items, { ...filters, limit: ESTIMATE_CAP });
|
|
300
|
+
const costs = matched
|
|
301
|
+
.map(it => it.est_cost_usd)
|
|
302
|
+
.filter(c => c != null);
|
|
303
|
+
const languages = [...new Set(matched.map(it => it.target_language))];
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
count: matched.length,
|
|
307
|
+
capped: matched.length >= ESTIMATE_CAP,
|
|
308
|
+
totalCost: costs.reduce((s, c) => s + c, 0),
|
|
309
|
+
cheapest: costs.length ? Math.min(...costs) : 0,
|
|
310
|
+
mostExpensive: costs.length ? Math.max(...costs) : 0,
|
|
311
|
+
languages,
|
|
312
|
+
};
|
|
313
|
+
}
|