champollion-mcp-server 0.1.0 → 0.2.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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Corpora tool — "what benchmarks exist for X→Y (or family F)?"
3
+ *
4
+ * The corpus registry (arena/datasets/registry.json, published at
5
+ * champollion.dev/registry.json) is the corpus SSOT: metadata cards only —
6
+ * id, pair, size, license, contamination grade, domain, a sha-pinned
7
+ * fetch-from-source recipe. Corpus CONTENT is never hosted and never returned
8
+ * here. This tool is the MCP twin of the harness's `mt-eval corpora` and
9
+ * `mt-eval list datasets` (arena/mt_eval_harness/corpora_browse.py):
10
+ * `normalizeRegistryEntry` mirrors `corpora_browse.normalize_entry`, including
11
+ * the availability vocabulary below. Keep the two in step.
12
+ *
13
+ * Availability (what the harness can actually DO with an entry):
14
+ * fetch rebuilt on demand from the pinned upstream (has a builder)
15
+ * gated fetch + an accept-terms step and an access token
16
+ * local an in-repo file present next to the registry
17
+ * local-missing declared in-repo but not found
18
+ * quarantined catalogued, never runnable — see quarantine_reason
19
+ * unbuildable no builder and no URL: catalogued but not materializable
20
+ *
21
+ * Source ladder (CHAMPOLLION_CORPORA_SOURCE forces one rung: registry | remote | db):
22
+ * 1. registry — the in-repo arena/datasets/registry.json (or
23
+ * CHAMPOLLION_REGISTRY_PATH) when this server runs inside a checkout;
24
+ * 2. remote — https://champollion.dev/registry.json (the same file,
25
+ * published at build time; guarded against an HTML holding page);
26
+ * 3. db — the prod `datasets` table over PostgREST (anon read). It is a
27
+ * MIRROR that lags the registry until the next sync, so it is last, and
28
+ * the answer says so.
29
+ * Every answer names the rung it came from. Entries are cached in memory for
30
+ * CACHE_TTL_MS; failures are never cached.
31
+ */
32
+
33
+ import { existsSync, readFileSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const __dirname = dirname(fileURLToPath(import.meta.url));
38
+
39
+ const REGISTRY_URL = 'https://champollion.dev/registry.json';
40
+ // Same project + publishable anon key the public leaderboard embeds; RLS makes
41
+ // `datasets` read-only for anon. CHAMPOLLION_-prefixed overrides (results.js
42
+ // convention) so an unrelated SUPABASE_URL in the environment cannot repoint us.
43
+ const SUPABASE_URL =
44
+ process.env.CHAMPOLLION_SUPABASE_URL ||
45
+ 'https://sjdomynysdljkbemupqa.supabase.co';
46
+ const SUPABASE_ANON_KEY =
47
+ process.env.CHAMPOLLION_SUPABASE_ANON_KEY ||
48
+ 'sb_publishable_bV6CFNFnzxhQI0wlBx2J0A_5Vm5gFBp';
49
+
50
+ const CACHE_TTL_MS = 5 * 60 * 1000;
51
+ const FETCH_TIMEOUT_MS = 30_000;
52
+ export const DEFAULT_LIMIT = 20;
53
+ export const MAX_LIMIT = 100;
54
+
55
+ export const AVAILABILITY_VALUES = Object.freeze([
56
+ 'fetch', 'gated', 'local', 'local-missing', 'quarantined', 'unbuildable',
57
+ ]);
58
+
59
+ /** The in-repo registry path: env override, else three levels up from src/tools/. */
60
+ export function inRepoRegistryPath() {
61
+ return process.env.CHAMPOLLION_REGISTRY_PATH
62
+ || resolve(__dirname, '..', '..', '..', 'arena', 'datasets', 'registry.json');
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Normalization — JS twin of corpora_browse.normalize_entry
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function availabilityOf(entry, { gated, builder, registryDir }) {
70
+ if (entry.quarantine) return 'quarantined';
71
+ const access = entry.access;
72
+ if (access === 'local') {
73
+ const rel = entry.local_path || entry.path;
74
+ const dataRoot = process.env.MT_EVAL_DATA_ROOT;
75
+ const bases = [registryDir, dataRoot].filter(Boolean);
76
+ if (rel && bases.some((b) => existsSync(join(b, rel)))) return 'local';
77
+ return 'local-missing';
78
+ }
79
+ if (access === 'fetch-from-source') {
80
+ if (!builder && !entry.url) return 'unbuildable';
81
+ return gated ? 'gated' : 'fetch';
82
+ }
83
+ if (entry.url) return 'fetch';
84
+ return 'unbuildable';
85
+ }
86
+
87
+ /**
88
+ * Project a raw registry entry to the stable CorpusInfo shape.
89
+ *
90
+ * @param {object} entry A registry `datasets[]` element.
91
+ * @param {object} [opts]
92
+ * @param {string} [opts.registryDir] Directory of the registry file (for
93
+ * `access: local` existence checks); omit when the entry is not in-repo.
94
+ */
95
+ export function normalizeRegistryEntry(entry, { registryDir } = {}) {
96
+ const pair = entry.language_pair || {};
97
+ const exp = entry.source_export || {};
98
+ const gated = Boolean(entry.gated || exp.gated);
99
+ const termsUrl = entry.terms_url || exp.terms_url || null;
100
+ const tokenEnv = entry.token_env || exp.token_env || null;
101
+ const builder = exp.builder || null;
102
+ const quarantined = Boolean(entry.quarantine);
103
+ return {
104
+ id: entry.id ?? null,
105
+ name: entry.name ?? '',
106
+ source: pair.source ?? null,
107
+ target: pair.target ?? null,
108
+ size: entry.size ?? null,
109
+ domain: entry.domain ?? null,
110
+ contamination: entry.contamination ?? null,
111
+ license: entry.license ?? null,
112
+ provider: entry.source ?? null,
113
+ attribution: entry.attribution ?? null,
114
+ access: entry.access ?? null,
115
+ do_not_train: entry.do_not_train ?? true,
116
+ segment: entry.segment ?? null,
117
+ builder,
118
+ gated,
119
+ terms_url: gated ? termsUrl : null,
120
+ token_env: gated ? (tokenEnv || 'HF_TOKEN') : null,
121
+ quarantine: quarantined,
122
+ quarantine_reason: quarantined ? (entry.quarantine_reason || null) : null,
123
+ family: entry.registry_source ?? null,
124
+ availability: availabilityOf(entry, { gated, builder, registryDir }),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Map a prod `datasets` row (the sync script's column shape) back to a
130
+ * registry-like entry so the same normalizer applies. `access` is not a table
131
+ * column: a row with a `source_export` recipe is fetch-from-source; anything
132
+ * else cannot be materialized from the mirror alone and reads `unbuildable`
133
+ * (every `access: local` entry in the registry today is also quarantined, so
134
+ * that label wins for them anyway).
135
+ */
136
+ export function mapDatasetRow(row) {
137
+ const [source, target] = String(row.language_pair || '').split('>');
138
+ const meta = (row.metadata && typeof row.metadata === 'object') ? row.metadata : {};
139
+ const exp = meta.source_export;
140
+ return {
141
+ id: row.id,
142
+ name: row.name,
143
+ language_pair: { source: source || null, target: target || null },
144
+ size: row.entry_count ?? null,
145
+ domain: row.domain ?? null,
146
+ license: row.license ?? null,
147
+ source: row.source ?? null,
148
+ segment: row.segment ?? null,
149
+ sha256: row.sha256 ?? null,
150
+ quarantine: Boolean(row.quarantined),
151
+ quarantine_reason: row.quarantine_reason ?? null,
152
+ contamination: meta.contamination ?? null,
153
+ attribution: meta.attribution ?? null,
154
+ registry_source: meta.registry_source ?? null,
155
+ path: meta.path ?? null,
156
+ access: exp ? 'fetch-from-source' : null,
157
+ source_export: exp || undefined,
158
+ gated: Boolean(exp && exp.gated),
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Filtering — pure
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Filter normalized entries. Quarantined entries are dropped unless
168
+ * `include_quarantined`, but always COUNTED in `hiddenQuarantined`.
169
+ *
170
+ * @returns {{ items: object[], hiddenQuarantined: number }}
171
+ */
172
+ export function filterCorpora(infos, {
173
+ source_language, target_language, family, include_quarantined = false,
174
+ } = {}) {
175
+ const src = source_language ? String(source_language).trim().toLowerCase() : null;
176
+ const tgt = target_language ? String(target_language).trim().toLowerCase() : null;
177
+ const fam = family ? String(family).trim().toLowerCase() : null;
178
+ const items = [];
179
+ let hiddenQuarantined = 0;
180
+ for (const info of infos) {
181
+ if (src && info.source !== src) continue;
182
+ if (tgt && info.target !== tgt) continue;
183
+ if (fam && (info.family || '').toLowerCase() !== fam) continue;
184
+ if (info.quarantine && !include_quarantined) { hiddenQuarantined += 1; continue; }
185
+ items.push(info);
186
+ }
187
+ items.sort(sortKey);
188
+ return { items, hiddenQuarantined };
189
+ }
190
+
191
+ const CONTAM_ORDER = { NONE: 0, LOW: 1, MEDIUM: 2, HIGH: 3 };
192
+
193
+ // Lowest contamination first, then larger corpora, then id — same order the
194
+ // harness's `mt-eval corpora` table uses.
195
+ function sortKey(a, b) {
196
+ const ca = CONTAM_ORDER[a.contamination] ?? 1;
197
+ const cb = CONTAM_ORDER[b.contamination] ?? 1;
198
+ if (ca !== cb) return ca - cb;
199
+ const sa = a.size || 0, sb = b.size || 0;
200
+ if (sa !== sb) return sb - sa;
201
+ return String(a.id).localeCompare(String(b.id));
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Loading — the source ladder
206
+ // ---------------------------------------------------------------------------
207
+
208
+ let _cache = null; // { source, infos, note }
209
+ let _cacheTime = 0;
210
+
211
+ /** Reset the in-memory cache (tests). */
212
+ export function _resetCorporaCache() { _cache = null; _cacheTime = 0; }
213
+
214
+ function fresh() {
215
+ return _cache && (Date.now() - _cacheTime) < CACHE_TTL_MS ? _cache : null;
216
+ }
217
+
218
+ function parseRegistryText(text, what) {
219
+ const trimmed = String(text).trimStart();
220
+ if (trimmed.startsWith('<')) {
221
+ throw new Error(`${what} returned an HTML page instead of JSON (gate or holding page)`);
222
+ }
223
+ const data = JSON.parse(trimmed);
224
+ const list = Array.isArray(data) ? data : data?.datasets;
225
+ if (!Array.isArray(list)) throw new Error(`${what} has no datasets[] array`);
226
+ return list;
227
+ }
228
+
229
+ function loadInRepo(registryPath) {
230
+ if (!existsSync(registryPath)) throw new Error(`no in-repo registry at ${registryPath}`);
231
+ const list = parseRegistryText(readFileSync(registryPath, 'utf-8'), registryPath);
232
+ const registryDir = dirname(registryPath);
233
+ return {
234
+ source: 'registry.json (in-repo)',
235
+ infos: list.map((e) => normalizeRegistryEntry(e, { registryDir })),
236
+ note: null,
237
+ };
238
+ }
239
+
240
+ async function loadRemote(fetchImpl) {
241
+ const resp = await fetchImpl(REGISTRY_URL, {
242
+ headers: { Accept: 'application/json' },
243
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
244
+ });
245
+ if (!resp.ok) throw new Error(`registry.json HTTP ${resp.status}`);
246
+ const list = parseRegistryText(await resp.text(), REGISTRY_URL);
247
+ return {
248
+ source: 'registry.json (remote)',
249
+ infos: list.map((e) => normalizeRegistryEntry(e)),
250
+ note: null,
251
+ };
252
+ }
253
+
254
+ const DB_SELECT = [
255
+ 'id', 'name', 'language_pair', 'entry_count', 'domain', 'license', 'segment',
256
+ 'source', 'sha256', 'quarantined', 'quarantine_reason', 'metadata',
257
+ ].join(',');
258
+
259
+ /** Build the PostgREST URL for the `datasets` mirror (exported for tests). */
260
+ export function buildDatasetsUrl({ source_language, target_language, family, quarantined } = {}) {
261
+ const url = new URL(`${SUPABASE_URL}/rest/v1/datasets`);
262
+ url.searchParams.set('select', DB_SELECT);
263
+ const src = source_language ? String(source_language).toLowerCase() : null;
264
+ const tgt = target_language ? String(target_language).toLowerCase() : null;
265
+ if (src && tgt) url.searchParams.set('language_pair', `eq.${src}>${tgt}`);
266
+ else if (src) url.searchParams.set('language_pair', `like.${src}>%`);
267
+ else if (tgt) url.searchParams.set('language_pair', `like.%>${tgt}`);
268
+ if (family) url.searchParams.set('metadata->>registry_source', `eq.${String(family).toLowerCase()}`);
269
+ if (quarantined === true) url.searchParams.set('quarantined', 'eq.true');
270
+ if (quarantined === false) url.searchParams.set('quarantined', 'eq.false');
271
+ url.searchParams.set('order', 'entry_count.desc.nullslast,id.asc');
272
+ return url.toString();
273
+ }
274
+
275
+ async function loadDb(fetchImpl, filters) {
276
+ // The mirror is filtered server-side (it is 5,000+ rows); we fetch the
277
+ // matching rows both ways so the quarantined count stays honest.
278
+ const headers = {
279
+ apikey: SUPABASE_ANON_KEY,
280
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
281
+ Accept: 'application/json',
282
+ Prefer: 'count=exact',
283
+ };
284
+ const resp = await fetchImpl(buildDatasetsUrl(filters), {
285
+ headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
286
+ });
287
+ if (!resp.ok) throw new Error(`datasets HTTP ${resp.status}`);
288
+ const rows = parseRegistryText(await resp.text(), 'datasets table');
289
+ return {
290
+ source: 'live datasets table',
291
+ infos: rows.map((r) => normalizeRegistryEntry(mapDatasetRow(r))),
292
+ note: 'served from the prod datasets MIRROR, which lags the registry until '
293
+ + 'the next sync (registry.json was unreachable)',
294
+ total: Number((resp.headers?.get?.('content-range') || '').split('/')[1]) || null,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Load normalized entries via the source ladder.
300
+ *
301
+ * @param {object} [opts]
302
+ * @param {Function} [opts.fetchImpl] fetch replacement (tests)
303
+ * @param {string} [opts.corporaSource] 'registry' | 'remote' | 'db' | 'auto'
304
+ * @param {string} [opts.registryPath] explicit in-repo registry path
305
+ * @param {object} [opts.filters] filters (used by the db rung only)
306
+ */
307
+ export async function loadCorpora({
308
+ fetchImpl = globalThis.fetch, corporaSource, registryPath, filters = {},
309
+ } = {}) {
310
+ const mode = corporaSource || process.env.CHAMPOLLION_CORPORA_SOURCE || 'auto';
311
+ if (mode !== 'db') {
312
+ const hit = fresh();
313
+ if (hit) return hit;
314
+ }
315
+ const path = registryPath || inRepoRegistryPath();
316
+ const attempts = [];
317
+ const rungs = mode === 'auto' ? ['registry', 'remote', 'db'] : [mode];
318
+ for (const rung of rungs) {
319
+ try {
320
+ let loaded;
321
+ if (rung === 'registry') loaded = loadInRepo(path);
322
+ else if (rung === 'remote') loaded = await loadRemote(fetchImpl);
323
+ else if (rung === 'db') loaded = await loadDb(fetchImpl, filters);
324
+ else throw new Error(`unknown CHAMPOLLION_CORPORA_SOURCE '${rung}'`);
325
+ if (rung !== 'db') { _cache = loaded; _cacheTime = Date.now(); }
326
+ return loaded;
327
+ } catch (err) {
328
+ attempts.push(`${rung}: ${err.message}`);
329
+ }
330
+ }
331
+ throw new Error(`could not load the corpus registry — ${attempts.join('; ')}`);
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // The tool entry point
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Select corpora for the given filters. At least one of source_language,
340
+ * target_language, family is required — the registry is thousands of rows and
341
+ * an unfiltered dump is never a useful answer to an agent.
342
+ *
343
+ * @returns {Promise<{ items: object[], total: number, hiddenQuarantined: number,
344
+ * source: string, note: string|null, filters: object, limit: number }>}
345
+ */
346
+ export async function selectCorpora({
347
+ source_language, target_language, family, include_quarantined = false,
348
+ limit = DEFAULT_LIMIT, fetchImpl, corporaSource, registryPath,
349
+ } = {}) {
350
+ const filters = { source_language, target_language, family, include_quarantined };
351
+ const lim = Math.max(1, Math.min(MAX_LIMIT, Number(limit) || DEFAULT_LIMIT));
352
+ if (!source_language && !target_language && !family) {
353
+ return { items: [], total: 0, hiddenQuarantined: 0, source: null, note: null,
354
+ filters, limit: lim, needsFilter: true };
355
+ }
356
+ const loaded = await loadCorpora({ fetchImpl, corporaSource, registryPath, filters });
357
+ const { items, hiddenQuarantined } = filterCorpora(loaded.infos, filters);
358
+ return {
359
+ items: items.slice(0, lim),
360
+ total: items.length,
361
+ hiddenQuarantined,
362
+ source: loaded.source,
363
+ note: loaded.note,
364
+ filters,
365
+ limit: lim,
366
+ };
367
+ }
368
+
369
+ const AVAIL_LABEL = {
370
+ fetch: 'fetch', gated: 'gated', local: 'local ✓', 'local-missing': 'local ✗',
371
+ quarantined: 'QUARANTINED', unbuildable: 'no builder',
372
+ };
373
+
374
+ /** Render a bounded, self-describing text answer. */
375
+ export function formatCorpora(result) {
376
+ const f = result.filters || {};
377
+ const scope = [
378
+ f.source_language ? `source ${String(f.source_language).toLowerCase()}` : null,
379
+ f.target_language ? `target ${String(f.target_language).toLowerCase()}` : null,
380
+ f.family ? `family ${f.family}` : null,
381
+ ].filter(Boolean).join(', ');
382
+
383
+ if (result.needsFilter) {
384
+ return 'list_corpora needs at least one filter: source_language, '
385
+ + 'target_language (ISO 639-3, e.g. "eng", "yor") or family (a benchmark '
386
+ + 'family such as "flores", "tatoeba", "wmt24pp", "in22"). The registry '
387
+ + 'holds thousands of corpora; an unfiltered dump is never the answer. '
388
+ + 'Try search_languages first if you only have a language name.';
389
+ }
390
+
391
+ const hidden = result.hiddenQuarantined
392
+ ? `${result.hiddenQuarantined} quarantined hidden — pass include_quarantined=true to list them`
393
+ : 'no quarantined entries hidden';
394
+ const lines = [
395
+ `Corpora for ${scope}: ${result.total} listed (${hidden}) · source: ${result.source}`,
396
+ ];
397
+ if (result.note) lines.push(` note: ${result.note}`);
398
+ if (result.total === 0) {
399
+ lines.push(result.hiddenQuarantined
400
+ ? ' (none runnable — every catalogued entry for this scope is quarantined; '
401
+ + 'quarantined corpora are catalogued but never runnable or rankable)'
402
+ : ' (none found — try a different pair, or search_languages to check the codes)');
403
+ return lines.join('\n');
404
+ }
405
+ for (const i of result.items) {
406
+ const pair = `${i.source ?? '?'}→${i.target ?? '?'}`;
407
+ const size = i.size != null ? `${i.size} rows` : '? rows';
408
+ const bits = [
409
+ i.id, pair, size, i.license || 'license ?',
410
+ `contamination ${i.contamination || '?'}`, i.domain || 'domain ?',
411
+ AVAIL_LABEL[i.availability] || i.availability,
412
+ ];
413
+ if (i.gated) bits.push(`[gated: accept terms at ${i.terms_url} then set ${i.token_env}]`);
414
+ if (i.quarantine && i.quarantine_reason) bits.push(`[why: ${i.quarantine_reason.slice(0, 140)}]`);
415
+ lines.push(' ' + bits.join(' '));
416
+ }
417
+ if (result.total > result.items.length) {
418
+ lines.push(` … showing ${result.items.length} of ${result.total} (raise limit, max ${MAX_LIMIT}, or narrow the filter)`);
419
+ }
420
+ lines.push('Run one: mt-eval run --corpus <id> --attest-no-training (or run_benchmark with its queue item). '
421
+ + 'Corpus content is fetched from its pinned upstream on demand — never hosted here.');
422
+ return lines.join('\n');
423
+ }