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.
- package/CHANGELOG.md +468 -0
- package/README.md +135 -82
- package/bin/server.js +4 -3
- package/instructions.md +188 -232
- package/package.json +9 -5
- package/src/index.js +1364 -297
- package/src/tools/args.js +90 -0
- package/src/tools/contests.js +474 -0
- package/src/tools/corpora.js +423 -0
- package/src/tools/forge.js +1076 -78
- package/src/tools/harness-fst.js +404 -0
- package/src/tools/harness.js +1918 -188
- package/src/tools/job-supervisor.js +96 -0
- package/src/tools/jobs.js +408 -0
- package/src/tools/language-card.js +697 -0
- package/src/tools/languages.js +1361 -42
- package/src/tools/metrics-plan.js +287 -0
- package/src/tools/output-trim.js +177 -0
- package/src/tools/overview.js +423 -0
- package/src/tools/plan-notes.js +203 -0
- package/src/tools/plural.js +14 -0
- package/src/tools/publish-preview.js +191 -0
- package/src/tools/publish-report.js +382 -0
- package/src/tools/queue.js +413 -82
- package/src/tools/register-corpus-hint.js +77 -0
- package/src/tools/releasable.js +80 -0
- package/src/tools/reliability.js +114 -21
- package/src/tools/results.js +187 -20
- package/src/tools/run-plan.js +608 -0
- package/src/tools/state.js +40 -0
- package/src/tools/training.js +76 -18
- package/src/tools/translate.js +1612 -77
package/src/index.js
CHANGED
|
@@ -1,38 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Champollion MCP Server — core implementation.
|
|
3
3
|
*
|
|
4
|
-
* Exposes
|
|
5
|
-
*
|
|
4
|
+
* Exposes Champollion to AI agents via the Model Context Protocol, built
|
|
5
|
+
* around one north-star flow: "let's build a <language> model for our
|
|
6
|
+
* school / clinic — how do we start?"
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
8
|
+
* discover search_languages, get_language, language_overview,
|
|
9
|
+
* list_corpora, get_results, get_run_card, get_metric_reliability
|
|
10
|
+
* protect (guidance in language_overview — your data never leaves your
|
|
11
|
+
* machine unless you choose; steward local-only marks are obeyed);
|
|
12
|
+
* if a model may be trained: forge_register_eval, forge_leak_audit,
|
|
13
|
+
* forge_prereg_template, forge_prereg — BEFORE any benchmark
|
|
14
|
+
* baseline run_benchmark (queue items OR any corpus, any model — including
|
|
15
|
+
* one on this machine), get_run_status, preview_publish (read-only),
|
|
16
|
+
* publish_report
|
|
17
|
+
* build get_training_guardrails, forge_* (nmt-forge)
|
|
18
|
+
* prove forge_export (score once + package), forge_evaluate,
|
|
19
|
+
* forge_compare, forge_prereg_verdict, list_contests, get_contest
|
|
20
|
+
* deploy `nmt-forge serve <export>/model` (a terminal step), translate
|
|
21
|
+
* contribute list_queue, get_queue_item, estimate_cost, get_project_info
|
|
18
22
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - champollion://queue-schema Queue item field definitions.
|
|
23
|
+
* The tool list is enumerated, and checked against README.md and
|
|
24
|
+
* instructions.md, by test/server-surface.test.js — never hand-count it here.
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* - explore_language "Tell me about [language] in Champollion."
|
|
26
|
+
* Resources: champollion://contributing-guide, champollion://queue-schema,
|
|
27
|
+
* champollion://network-data. Prompts: start_language_project,
|
|
28
|
+
* explore_language, contribute_compute, compete_for_prize.
|
|
27
29
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* the champollion.dev leaderboard uses
|
|
33
|
-
*
|
|
34
|
-
* The server is stateless between tool calls. Queue data is cached in
|
|
35
|
-
* memory with a 5-minute TTL to avoid hammering the endpoint.
|
|
30
|
+
* The server is stateless between tool calls apart from in-memory caches
|
|
31
|
+
* (queue 5 min, corpus registry 5 min) and its on-disk state under
|
|
32
|
+
* ~/.champollion-mcp (CHAMPOLLION_MCP_HOME): the benchmark job history, which
|
|
33
|
+
* survives a server restart, and the translate tool's own Translation Memory.
|
|
36
34
|
*/
|
|
37
35
|
|
|
38
36
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -42,14 +40,31 @@ import { readFile } from 'node:fs/promises';
|
|
|
42
40
|
import { resolve, dirname } from 'node:path';
|
|
43
41
|
import { fileURLToPath } from 'node:url';
|
|
44
42
|
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import {
|
|
49
|
-
|
|
50
|
-
|
|
43
|
+
import {
|
|
44
|
+
fetchQueueMeta, selectFromQueue, lookupQueueItem, estimateCost,
|
|
45
|
+
} from './tools/queue.js';
|
|
46
|
+
import {
|
|
47
|
+
findLanguages, loadLanguageIndex, materializeLean, formatSearchAnswer,
|
|
48
|
+
} from './tools/languages.js';
|
|
49
|
+
import { languageAlias, resolveLanguageArg, argError } from './tools/args.js';
|
|
50
|
+
import { count } from './tools/plural.js';
|
|
51
|
+
import { runBenchmark, getRunStatus, RUN_PROVIDERS, languageNameFor } from './tools/harness.js';
|
|
52
|
+
import { previewReport, publishReport } from './tools/publish-report.js';
|
|
53
|
+
import { getLanguage, formatLanguage } from './tools/language-card.js';
|
|
54
|
+
import { languageOverview, formatOverview } from './tools/overview.js';
|
|
55
|
+
import { listContests, getContest, formatContestList, formatContest } from './tools/contests.js';
|
|
56
|
+
import { fetchResults, formatResults, fetchRunCard, formatRunCard } from './tools/results.js';
|
|
57
|
+
import { selectCorpora, formatCorpora, MAX_LIMIT as CORPORA_MAX_LIMIT } from './tools/corpora.js';
|
|
58
|
+
import { metricReliability, formatReliability, cardFamilyClaims } from './tools/reliability.js';
|
|
59
|
+
import { translateTexts, formatTranslateResult, translateResultIsError, TRANSLATE_METHODS } from './tools/translate.js';
|
|
51
60
|
import { trainingGuardrails, formatTrainingGuardrails } from './tools/training.js';
|
|
52
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
forgeTool, forgeToolsIn, FORGE_SCORING_TIMEOUT_MS, lintNextHint, lintSeverityCounts,
|
|
63
|
+
compareSummary, compareNextHint, discoverCardCrossCheck, preflightNextHint,
|
|
64
|
+
splitNearTwinSummary, splitNextHint, initNextHint, initializedStatusHint,
|
|
65
|
+
statusNextHint, exportSummary, exportNextHint, registerEvalPathError,
|
|
66
|
+
statusTools, statusExportCaveats, lintSummary,
|
|
67
|
+
} from './tools/forge.js';
|
|
53
68
|
|
|
54
69
|
/**
|
|
55
70
|
* Read the agent behavioral guide from disk.
|
|
@@ -84,13 +99,20 @@ export async function createServer() {
|
|
|
84
99
|
// no instructions if the file is missing rather than failing startup.
|
|
85
100
|
const instructions = await loadInstructions(resolve(__dirname, '..', 'instructions.md'));
|
|
86
101
|
|
|
102
|
+
// Version from package.json — the SSOT — so the handshake can never claim
|
|
103
|
+
// a stale release (0.1.1 shipped while the handshake still said 0.1.0).
|
|
104
|
+
const { version } = JSON.parse(
|
|
105
|
+
await readFile(resolve(__dirname, '..', 'package.json'), 'utf-8'),
|
|
106
|
+
);
|
|
107
|
+
|
|
87
108
|
const server = new McpServer(
|
|
88
|
-
{ name: 'champollion', version
|
|
109
|
+
{ name: 'champollion', version },
|
|
89
110
|
instructions ? { instructions } : undefined,
|
|
90
111
|
);
|
|
91
112
|
|
|
92
113
|
// Pre-load language index (small, fast, stays in memory)
|
|
93
114
|
const languageIndex = await loadLanguageIndex();
|
|
115
|
+
const languageCount = languageIndex.length;
|
|
94
116
|
|
|
95
117
|
// ==================================================================
|
|
96
118
|
// Resources — read-only data agents can pull on demand
|
|
@@ -181,7 +203,7 @@ export async function createServer() {
|
|
|
181
203
|
'| Corpus registry | https://champollion.dev/registry.json | Every registered eval corpus: license lane, attribution, checksum, fetch-from-source notes (~10 MB). |',
|
|
182
204
|
'| Provider coverage | shared/catalogue/method-coverage.json (repo) | Each MT provider’s published language list, cited + as-of + `tier` (service vs open). The map’s green has two tiers by exact ISO-639-3 code: bright = a DEPLOYED service lists it (Google/Microsoft/DeepL/LibreTranslate); dim = only an OPEN research model lists it (NLLB/OPUS/M2M-100/MADLAD-400 — a model-card code, not a usable service). "Covered" is a published-list claim, never a quality claim. |',
|
|
183
205
|
'| Scored runs (REST) | https://sjdomynysdljkbemupqa.supabase.co/rest/v1/run_cards | Public leaderboard as PostgREST, read-only via RLS (anon key sb_publishable_bV6CFNFnzxhQI0wlBx2J0A_5Vm5gFBp — publishable by design). Aggregates only; per-entry test sentences are license-gated and never exposed. Prefer the get_results / get_run_card tools. |',
|
|
184
|
-
|
|
206
|
+
`| Language cards | cli/shared/language-cards/ (repo) + champollion.dev published card tables | ${languageCount.toLocaleString()} languages in this install's index, every value cited. Prefer search_languages / get_language. |`,
|
|
185
207
|
'| Queue ranking spec | https://champollion.dev/docs/network/specifications/queue-construction | Normative spec: lanes + map-value survey ordering + ECV. |',
|
|
186
208
|
'',
|
|
187
209
|
'License note: most queued corpora are evaluation sets with',
|
|
@@ -228,6 +250,38 @@ export async function createServer() {
|
|
|
228
250
|
}
|
|
229
251
|
);
|
|
230
252
|
|
|
253
|
+
// Prompt: start_language_project — the north-star flow
|
|
254
|
+
server.prompt(
|
|
255
|
+
'start_language_project',
|
|
256
|
+
'Start building translation for a language with a community — e.g. "a Cree '
|
|
257
|
+
+ 'model for our school" or "an Ayta phrasebook for the clinic". Walks '
|
|
258
|
+
+ 'discover → protect your data (and, if a model may be trained, register, screen and predict) → '
|
|
259
|
+
+ 'baseline → build → prove → deploy.',
|
|
260
|
+
{
|
|
261
|
+
language: z.string()
|
|
262
|
+
.describe('The language, by name or code (e.g. "Plains Cree", "crk", "Ayta").'),
|
|
263
|
+
purpose: z.string().optional()
|
|
264
|
+
.describe('Who it is for and what it must do (e.g. "school curriculum", "hospital intake phrases").'),
|
|
265
|
+
},
|
|
266
|
+
async ({ language, purpose }) => ({
|
|
267
|
+
messages: [{
|
|
268
|
+
role: 'user',
|
|
269
|
+
content: {
|
|
270
|
+
type: 'text',
|
|
271
|
+
text: [
|
|
272
|
+
`We want to build machine translation for ${language}${purpose ? ` — for ${purpose}` : ''}. How do we get started?`,
|
|
273
|
+
'',
|
|
274
|
+
'Please: find the language (search_languages), show what exists for it (language_overview),',
|
|
275
|
+
'tell us how to keep our own data private, how to measure a baseline before building',
|
|
276
|
+
'(if we may train a model, write our predictions down before any baseline on our test set),',
|
|
277
|
+
'and what building and proving a model would involve. Do not spend money or publish',
|
|
278
|
+
'anything without asking us first.',
|
|
279
|
+
].join('\n'),
|
|
280
|
+
},
|
|
281
|
+
}],
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
|
|
231
285
|
// Prompt: explore_language — "Tell me about [language]"
|
|
232
286
|
server.prompt(
|
|
233
287
|
'explore_language',
|
|
@@ -243,9 +297,9 @@ export async function createServer() {
|
|
|
243
297
|
content: {
|
|
244
298
|
type: 'text',
|
|
245
299
|
text: `Tell me about ${language} in the Champollion project. `
|
|
246
|
-
+ 'What
|
|
247
|
-
+ '
|
|
248
|
-
+ '
|
|
300
|
+
+ 'What does the index know about it (with sources)? Which benchmarks, '
|
|
301
|
+
+ 'published results, methods and tools exist? (search_languages, then '
|
|
302
|
+
+ 'get_language and language_overview.)',
|
|
249
303
|
},
|
|
250
304
|
}],
|
|
251
305
|
};
|
|
@@ -273,7 +327,7 @@ export async function createServer() {
|
|
|
273
327
|
`I'm interested in building a competitive translation method for ${lang} in the Champollion Arena.`,
|
|
274
328
|
'',
|
|
275
329
|
'Can you help me understand:',
|
|
276
|
-
'1. Are there any active prize pools right now? Check the prize spec
|
|
330
|
+
'1. Are there any active prize pools or contests right now? Check the prize spec and list_contests.',
|
|
277
331
|
'2. What does it take to build a strong method? (approaches, data, tools)',
|
|
278
332
|
'3. How does the anti-gaming architecture work? (secret test sets, etc.)',
|
|
279
333
|
'4. Even if there\'s no active prize, I might contribute compute or benchmark runs — what would that look like?',
|
|
@@ -307,10 +361,10 @@ export async function createServer() {
|
|
|
307
361
|
},
|
|
308
362
|
async ({ budget, language, source_language, model, condition, limit }) => {
|
|
309
363
|
try {
|
|
310
|
-
const
|
|
311
|
-
const items = filterQueue(queue.items, {
|
|
364
|
+
const sel = await selectFromQueue({
|
|
312
365
|
budget, language, source_language, model, condition, limit,
|
|
313
366
|
});
|
|
367
|
+
const items = sel.selected;
|
|
314
368
|
|
|
315
369
|
// Compute summary statistics for the filtered set
|
|
316
370
|
const totalCost = items.reduce((s, it) => s + (it.est_cost_usd || 0), 0);
|
|
@@ -324,11 +378,19 @@ export async function createServer() {
|
|
|
324
378
|
+ `[${it.condition}]`
|
|
325
379
|
);
|
|
326
380
|
|
|
381
|
+
// Honest truncation: the DB path only pages as deep as the selection
|
|
382
|
+
// needs (bounded); if the bound hit before `limit` matches were found,
|
|
383
|
+
// say how far the search went instead of implying the queue ran dry.
|
|
384
|
+
const depthNote = (!sel.complete && items.length < limit)
|
|
385
|
+
? `Searched the top ${sel.scannedRows.toLocaleString()} ranked items — deeper matches may exist. Narrow the filters, or raise CHAMPOLLION_QUEUE_MAX_PAGES to scan deeper.`
|
|
386
|
+
: '';
|
|
387
|
+
|
|
327
388
|
const summary = [
|
|
328
|
-
`Found ${items.length}
|
|
389
|
+
`Found ${count(items.length, 'item')} (of ${sel.metadata.open_items} total open).`,
|
|
329
390
|
`Estimated cost: $${totalCost.toFixed(2)}`,
|
|
330
391
|
`Languages: ${languages.join(', ')}`,
|
|
331
|
-
`Models in queue: ${
|
|
392
|
+
`Models in queue: ${sel.metadata.models.map(m => m.split('/').pop()).join(', ')}`,
|
|
393
|
+
...(depthNote ? [depthNote] : []),
|
|
332
394
|
'',
|
|
333
395
|
...lines,
|
|
334
396
|
].join('\n');
|
|
@@ -363,16 +425,21 @@ export async function createServer() {
|
|
|
363
425
|
};
|
|
364
426
|
}
|
|
365
427
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
428
|
+
// Single-item lookups go straight to the queue_items primary key (or
|
|
429
|
+
// the mode+priority index) — never the ranked paging path.
|
|
430
|
+
const { item, covered, truncatedNote } = await lookupQueueItem({ id, priority });
|
|
368
431
|
if (!item) {
|
|
432
|
+
const note = truncatedNote ? ` ${truncatedNote}` : '';
|
|
369
433
|
return {
|
|
370
|
-
content: [{ type: 'text', text: `No queue item found for ${id ? `id="${id}"` : `priority=${priority}`}
|
|
434
|
+
content: [{ type: 'text', text: `No queue item found for ${id ? `id="${id}"` : `priority=${priority}`}.${note}` }],
|
|
371
435
|
isError: true,
|
|
372
436
|
};
|
|
373
437
|
}
|
|
438
|
+
const coveredNote = covered === true
|
|
439
|
+
? '\n\nNote: this item is already covered by a VERIFIED run — it is no longer an open work item and will not appear in list_queue.'
|
|
440
|
+
: '';
|
|
374
441
|
return {
|
|
375
|
-
content: [{ type: 'text', text: JSON.stringify(item, null, 2) }],
|
|
442
|
+
content: [{ type: 'text', text: JSON.stringify(item, null, 2) + coveredNote }],
|
|
376
443
|
};
|
|
377
444
|
} catch (err) {
|
|
378
445
|
return {
|
|
@@ -405,10 +472,17 @@ export async function createServer() {
|
|
|
405
472
|
},
|
|
406
473
|
async ({ budget, language, source_language, model, condition }) => {
|
|
407
474
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
475
|
+
// Deepen the ranked prefix toward the estimate cap (500), then run
|
|
476
|
+
// the same pure aggregation as before over what was scanned.
|
|
477
|
+
const sel = await selectFromQueue({
|
|
478
|
+
budget, language, source_language, model, condition, limit: 500,
|
|
479
|
+
});
|
|
480
|
+
const result = estimateCost(sel.scanned, {
|
|
410
481
|
budget, language, source_language, model, condition,
|
|
411
482
|
});
|
|
483
|
+
const depthNote = (!sel.complete && !result.capped)
|
|
484
|
+
? `Scanned the top ${sel.scannedRows.toLocaleString()} ranked items — treat count/total as a lower bound; deeper matches may exist.`
|
|
485
|
+
: '';
|
|
412
486
|
return {
|
|
413
487
|
content: [{
|
|
414
488
|
type: 'text',
|
|
@@ -419,6 +493,7 @@ export async function createServer() {
|
|
|
419
493
|
`Most expensive item: $${result.mostExpensive.toFixed(4)}`,
|
|
420
494
|
`Languages covered: ${result.languages.join(', ')}`,
|
|
421
495
|
budget ? `Budget remaining: $${(budget - result.totalCost).toFixed(2)}` : '',
|
|
496
|
+
depthNote,
|
|
422
497
|
].filter(Boolean).join('\n'),
|
|
423
498
|
}],
|
|
424
499
|
};
|
|
@@ -436,24 +511,33 @@ export async function createServer() {
|
|
|
436
511
|
// ------------------------------------------------------------------
|
|
437
512
|
server.tool(
|
|
438
513
|
'search_languages',
|
|
439
|
-
'
|
|
440
|
-
+ 'or region
|
|
514
|
+
'Find a language\'s code by name, endonym, alternate name, ISO code, family '
|
|
515
|
+
+ 'or region — misspellings included: when nothing matches exactly it returns '
|
|
516
|
+
+ 'the closest names by edit distance ("Atya" → the Ayta languages; a query word may begin a longer word of the name: "North Sami" → Northern Sami, said on the line), and a query of several words is matched part by part ("Plains Cree nêhiyawêwin" → crk). Each '
|
|
517
|
+
+ 'result also says where the language is spoken (countries, Glottolog\'s '
|
|
518
|
+
+ 'point, macroarea) and its other names — only the facts its card cites, each with its source, so '
|
|
519
|
+
+ 'same-named languages can be told apart; a location the card cannot cite is not shown, '
|
|
520
|
+
+ 'and the line links the language\'s Glottolog record (by glottocode) instead. Cards filled in from '
|
|
521
|
+
+ 'champollion.dev\'s published tables (an npm install\'s name-only results) carry no per-field sources '
|
|
522
|
+
+ 'until the tables\' next upload, so their lines have that link, not a location. Use this first when the user names '
|
|
523
|
+
+ 'a language; then get_language / language_overview with the code.',
|
|
441
524
|
{
|
|
442
|
-
query: z.string()
|
|
443
|
-
.describe('
|
|
525
|
+
query: z.string().optional()
|
|
526
|
+
.describe('Language name, endonym, ISO 639-3 code, family, or region. Case- and accent-insensitive; '
|
|
527
|
+
+ 'tolerates typos. Required unless `language` is given.'),
|
|
528
|
+
language: languageAlias('query'),
|
|
444
529
|
limit: z.number().int().min(1).max(50).default(10)
|
|
445
530
|
.describe('Maximum results to return (default 10).'),
|
|
446
531
|
},
|
|
447
|
-
async ({ query, limit }) => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
// An agent's first stop
|
|
453
|
-
|
|
454
|
-
let results;
|
|
532
|
+
async ({ query: queryArg, language, limit }) => {
|
|
533
|
+
const q = resolveLanguageArg({ query: queryArg, language }, 'query', 'search_languages');
|
|
534
|
+
if (q.error) return argError(q.error);
|
|
535
|
+
const query = q.value;
|
|
536
|
+
// Guarded: a card-shape change once made this throw a raw TypeError at
|
|
537
|
+
// the SDK. An agent's first stop must never take the call down.
|
|
538
|
+
let found;
|
|
455
539
|
try {
|
|
456
|
-
|
|
540
|
+
found = findLanguages(languageIndex, query, limit);
|
|
457
541
|
} catch (err) {
|
|
458
542
|
return {
|
|
459
543
|
content: [{
|
|
@@ -466,25 +550,95 @@ export async function createServer() {
|
|
|
466
550
|
isError: true,
|
|
467
551
|
};
|
|
468
552
|
}
|
|
469
|
-
|
|
553
|
+
// Name-only results (an npm install's long tail) are filled in from
|
|
554
|
+
// their published cards, bounded — so "Atya" answers with six Ayta
|
|
555
|
+
// languages AND where each is spoken (cited), or a Glottolog link where
|
|
556
|
+
// the row carries no per-field source — not six bare names. A failure
|
|
557
|
+
// here leaves the names standing and says why on each line.
|
|
558
|
+
let filled = {};
|
|
559
|
+
if (found.match !== 'none') {
|
|
560
|
+
try {
|
|
561
|
+
filled = await materializeLean(found.results);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
const why = `filling in its card failed: ${err.message}`;
|
|
564
|
+
filled = { unresolved: new Map(found.results.filter((l) => l.lean).map((l) => [l.code, why])) };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return { content: [{ type: 'text', text: formatSearchAnswer(found, query, filled) }] };
|
|
568
|
+
}
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// ------------------------------------------------------------------
|
|
572
|
+
// Tool: get_language
|
|
573
|
+
// ------------------------------------------------------------------
|
|
574
|
+
server.tool(
|
|
575
|
+
'get_language',
|
|
576
|
+
'The full, CITED language card for one language: names and endonym, '
|
|
577
|
+
+ 'classification, speaker estimates (every source\'s claim, none elected), '
|
|
578
|
+
+ 'endangerment, scripts, resources (dictionaries, grammars, FSTs, corpora, '
|
|
579
|
+
+ 'keyboards) and which MT services/models list it. Resolved exactly as the '
|
|
580
|
+
+ 'champollion CLI resolves it (bundled card → per-user cache → published card '
|
|
581
|
+
+ 'tables). Absent fields are reported as absent, never filled in. Accepts a code '
|
|
582
|
+
+ '("crk", "fr") or an exact name; misspelled names return the closest matches.',
|
|
583
|
+
{
|
|
584
|
+
code: z.string().optional()
|
|
585
|
+
.describe('ISO 639-3/639-1 code or exact language name (e.g. "crk", "Plains Cree", "abp"). '
|
|
586
|
+
+ 'Required unless `language` is given.'),
|
|
587
|
+
language: languageAlias('code'),
|
|
588
|
+
format: z.enum(['text', 'json']).default('text')
|
|
589
|
+
.describe('"text" (default, agent-readable) or "json" (the structured summary).'),
|
|
590
|
+
},
|
|
591
|
+
async ({ code: codeArg, language, format }) => {
|
|
592
|
+
const lang = resolveLanguageArg({ code: codeArg, language }, 'code', 'get_language');
|
|
593
|
+
if (lang.error) return argError(lang.error);
|
|
594
|
+
const code = lang.value;
|
|
595
|
+
try {
|
|
596
|
+
const r = await getLanguage(code, { index: languageIndex });
|
|
597
|
+
if (format === 'json' && r.status === 'ok') {
|
|
598
|
+
return { content: [{ type: 'text', text: JSON.stringify(r, null, 2) }] };
|
|
599
|
+
}
|
|
470
600
|
return {
|
|
471
|
-
content: [{
|
|
472
|
-
|
|
473
|
-
text: `No languages found matching "${query}". Try a different name, code, or family.`,
|
|
474
|
-
}],
|
|
601
|
+
content: [{ type: 'text', text: formatLanguage(r) }],
|
|
602
|
+
isError: r.status !== 'ok',
|
|
475
603
|
};
|
|
604
|
+
} catch (err) {
|
|
605
|
+
return { content: [{ type: 'text', text: `Error resolving the language card: ${err.message}` }], isError: true };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// ------------------------------------------------------------------
|
|
611
|
+
// Tool: language_overview
|
|
612
|
+
// ------------------------------------------------------------------
|
|
613
|
+
server.tool(
|
|
614
|
+
'language_overview',
|
|
615
|
+
'START HERE for "we want to build a model for <language> — how do we begin?". '
|
|
616
|
+
+ 'One honest page: what the index knows, which benchmarks exist, published '
|
|
617
|
+
+ 'results, which methods can run (and with what evidence), tooling (FSTs — and whether the '
|
|
618
|
+
+ 'installed harness can use each — dictionaries), licence/consent constraints, and numbered NEXT STEPS that each '
|
|
619
|
+
+ 'name the exact tool or command, in the guide\'s order — protect your data → (if a model may be '
|
|
620
|
+
+ 'trained) register the test set, screen the corpus and write the predictions → baseline → build → '
|
|
621
|
+
+ 'prove → deploy. Read-only; spends nothing.',
|
|
622
|
+
{
|
|
623
|
+
code: z.string().optional()
|
|
624
|
+
.describe('ISO 639-3 code or exact language name of the language to build for (e.g. "crk"). '
|
|
625
|
+
+ 'Required unless `language` is given.'),
|
|
626
|
+
language: languageAlias('code'),
|
|
627
|
+
source: z.string().default('eng')
|
|
628
|
+
.describe('Source language code for pair-specific evidence (default "eng").'),
|
|
629
|
+
},
|
|
630
|
+
async ({ code: codeArg, language, source }) => {
|
|
631
|
+
const lang = resolveLanguageArg({ code: codeArg, language }, 'code', 'language_overview');
|
|
632
|
+
if (lang.error) return argError(lang.error);
|
|
633
|
+
const code = lang.value;
|
|
634
|
+
try {
|
|
635
|
+
const o = await languageOverview({ code, source }, { index: languageIndex });
|
|
636
|
+
// a private-use code (qaa–qtz) is an answer, not an error
|
|
637
|
+
return { content: [{ type: 'text', text: formatOverview(o) }],
|
|
638
|
+
isError: !['ok', 'private-use'].includes(o.status) };
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return { content: [{ type: 'text', text: `Error building the overview: ${err.message}` }], isError: true };
|
|
476
641
|
}
|
|
477
|
-
const lines = results.map(lang =>
|
|
478
|
-
`${lang.code} ${lang.name} ${lang.endonym || ''} `
|
|
479
|
-
+ `family: ${lang.family || 'unknown'} `
|
|
480
|
-
+ `speakers: ${lang.speakers || 'unknown'}`
|
|
481
|
-
);
|
|
482
|
-
return {
|
|
483
|
-
content: [{
|
|
484
|
-
type: 'text',
|
|
485
|
-
text: `Found ${results.length} language(s):\n\n${lines.join('\n')}`,
|
|
486
|
-
}],
|
|
487
|
-
};
|
|
488
642
|
}
|
|
489
643
|
);
|
|
490
644
|
|
|
@@ -498,33 +652,40 @@ export async function createServer() {
|
|
|
498
652
|
{},
|
|
499
653
|
async () => {
|
|
500
654
|
try {
|
|
501
|
-
|
|
502
|
-
|
|
655
|
+
// Stats only — metadata comes from the preview + one aggregate RPC;
|
|
656
|
+
// this tool never needs a single ranked item.
|
|
657
|
+
const { metadata: meta } = await fetchQueueMeta();
|
|
503
658
|
return {
|
|
504
659
|
content: [{
|
|
505
660
|
type: 'text',
|
|
506
661
|
text: [
|
|
507
662
|
'# Champollion — Open Translation Benchmarks',
|
|
508
663
|
'',
|
|
509
|
-
'Champollion is
|
|
510
|
-
|
|
664
|
+
'Champollion is open infrastructure for measuring — and building —',
|
|
665
|
+
`machine translation for any language (${languageCount.toLocaleString()} languages in this`,
|
|
666
|
+
'install\'s card index). The project maintains:',
|
|
511
667
|
'',
|
|
512
668
|
'- A public **benchmark queue** of untested (language pair, model, condition)',
|
|
513
669
|
' combinations ranked by expected improvement to the translation mesh',
|
|
514
670
|
'- An **evaluation harness** (mt-eval) that runs standardized benchmarks',
|
|
515
671
|
' and publishes results to a public leaderboard',
|
|
516
|
-
'- **Language cards** with metadata for
|
|
672
|
+
'- **Language cards** with cited metadata for every catalogued language',
|
|
673
|
+
'- **nmt-forge**, a guarded training suite for building your own model',
|
|
674
|
+
'',
|
|
675
|
+
'Building for a specific language? Call language_overview with its code —',
|
|
676
|
+
'it shows what exists and the exact next steps.',
|
|
517
677
|
'',
|
|
518
678
|
'## Contributing Compute',
|
|
519
679
|
'',
|
|
520
680
|
'The easiest way to help: donate some API tokens to run benchmarks',
|
|
521
681
|
'from the public queue. Anyone with an API key can contribute:',
|
|
522
682
|
'',
|
|
523
|
-
'1. Install the harness: `pipx install mt-eval`',
|
|
683
|
+
'1. Install the harness: `pipx install mt-eval-harness`',
|
|
524
684
|
'2. Set your API key: `export OPENROUTER_API_KEY=sk-or-...`',
|
|
525
|
-
'3. Run from the queue: `mt-eval queue --budget 5`',
|
|
685
|
+
'3. Run from the queue: run_benchmark { budget: 5 } (or `mt-eval queue --budget 5`)',
|
|
526
686
|
' (runs top items up to $5 estimated cost)',
|
|
527
|
-
'4.
|
|
687
|
+
'4. Publishing to the public leaderboard is opt-in: run_benchmark publishes',
|
|
688
|
+
' only with publish: true (the CLI `mt-eval queue` publishes unless --no-publish)',
|
|
528
689
|
'',
|
|
529
690
|
'## Prizes',
|
|
530
691
|
'',
|
|
@@ -537,7 +698,7 @@ export async function createServer() {
|
|
|
537
698
|
'',
|
|
538
699
|
'## Current Queue Stats',
|
|
539
700
|
'',
|
|
540
|
-
`- Open items: ${meta.open_items.toLocaleString()}`,
|
|
701
|
+
`- Open items: ${meta.open_items.toLocaleString()}${meta.open_items_basis === 'generation' ? ' (as of last queue generation)' : ''}`,
|
|
541
702
|
`- Corpora: ${meta.corpora}`,
|
|
542
703
|
`- Models: ${meta.models.map(m => m.split('/').pop()).join(', ')}`,
|
|
543
704
|
`- Conditions: ${meta.conditions.join(', ')}`,
|
|
@@ -570,8 +731,10 @@ export async function createServer() {
|
|
|
570
731
|
'get_results',
|
|
571
732
|
'Read scored benchmark results from the public Champollion leaderboard. '
|
|
572
733
|
+ 'This closes the loop after run_benchmark: see what you (or the '
|
|
573
|
-
+ 'community) scored. Returns ranked, scored runs
|
|
574
|
-
+ '
|
|
734
|
+
+ 'community) scored. Returns ranked, scored runs — chrF++ with its 95% CI '
|
|
735
|
+
+ 'as the headline (scoring standard/1, as in WMT and FLORES-200), BLEU, '
|
|
736
|
+
+ 'spBLEU, TER and COMET beside it, diagnostics apart, no quality labels — '
|
|
737
|
+
+ 'with trust level and attribution, never raw test '
|
|
575
738
|
+ 'sentences. Filter by language pair or model. Each row carries a '
|
|
576
739
|
+ 'contamination score_lane: rows marked "relative-only" (HIGH/MEDIUM '
|
|
577
740
|
+ 'contamination, FLORES, or unknown grade) are valid ONLY for comparing '
|
|
@@ -584,8 +747,10 @@ export async function createServer() {
|
|
|
584
747
|
.describe('Target language ISO 639-3 code (e.g. "zul", "yor"). Matches the target side of the pair.'),
|
|
585
748
|
model: z.string().optional()
|
|
586
749
|
.describe('Model name or substring (e.g. "haiku", "gpt-5.5"). Case-insensitive.'),
|
|
587
|
-
sort: z.enum(['
|
|
588
|
-
.describe('Sort metric. Score metrics sort best-first; ter/cost
|
|
750
|
+
sort: z.enum(['chrf', 'bleu', 'ter', 'comet', 'cost', 'date', 'composite']).default('chrf')
|
|
751
|
+
.describe('Sort metric (default chrf, the headline). Score metrics sort best-first; ter/cost '
|
|
752
|
+
+ 'lowest-first; date newest-first. composite = the retired legacy composite (old cards only; '
|
|
753
|
+
+ 'newer cards sort last) — kept for old calls, not a quality ranking.'),
|
|
589
754
|
limit: z.number().int().min(1).max(100).default(20)
|
|
590
755
|
.describe('Maximum number of results to return (default 20, max 100).'),
|
|
591
756
|
},
|
|
@@ -623,7 +788,11 @@ export async function createServer() {
|
|
|
623
788
|
isError: true,
|
|
624
789
|
};
|
|
625
790
|
}
|
|
626
|
-
|
|
791
|
+
const costNote = card.total_cost_usd == null
|
|
792
|
+
? '\n\nNote: total_cost_usd is null — the cost of this run is UNKNOWN (a local '
|
|
793
|
+
+ 'model, an MT engine, or an unpriced provider), not $0.'
|
|
794
|
+
: '';
|
|
795
|
+
return { content: [{ type: 'text', text: formatRunCard(card) + costNote }] };
|
|
627
796
|
} catch (err) {
|
|
628
797
|
return {
|
|
629
798
|
content: [{ type: 'text', text: `Error fetching run card: ${err.message}` }],
|
|
@@ -633,6 +802,44 @@ export async function createServer() {
|
|
|
633
802
|
}
|
|
634
803
|
);
|
|
635
804
|
|
|
805
|
+
// ------------------------------------------------------------------
|
|
806
|
+
// Tool: list_corpora
|
|
807
|
+
// ------------------------------------------------------------------
|
|
808
|
+
server.tool(
|
|
809
|
+
'list_corpora',
|
|
810
|
+
'List the registered evaluation corpora (benchmarks) for a language pair '
|
|
811
|
+
+ 'or benchmark family — the metadata cards behind the queue: size, '
|
|
812
|
+
+ 'license, contamination grade, domain, and what the harness can DO with '
|
|
813
|
+
+ 'each one (fetch on demand from its pinned upstream / gated behind an '
|
|
814
|
+
+ 'access token / quarantined). Corpus content is never hosted or returned. '
|
|
815
|
+
+ 'Give at least one of source_language, target_language, family. '
|
|
816
|
+
+ 'Quarantined entries are hidden by default but always COUNTED, so an '
|
|
817
|
+
+ 'empty pair explains itself instead of looking unsupported.',
|
|
818
|
+
{
|
|
819
|
+
source_language: z.string().optional()
|
|
820
|
+
.describe('Source language ISO 639-3 code (e.g. "eng").'),
|
|
821
|
+
target_language: z.string().optional()
|
|
822
|
+
.describe('Target language ISO 639-3 code (e.g. "yor", "crk").'),
|
|
823
|
+
family: z.string().optional()
|
|
824
|
+
.describe('Benchmark family (the registry\'s registry_source, e.g. "flores", "tatoeba", "wmt24pp", "in22", "smol").'),
|
|
825
|
+
include_quarantined: z.boolean().default(false)
|
|
826
|
+
.describe('Also list quarantined corpora (catalogued, never runnable) with their reasons.'),
|
|
827
|
+
limit: z.number().int().min(1).max(CORPORA_MAX_LIMIT).default(20)
|
|
828
|
+
.describe(`Maximum corpora to return (default 20, max ${CORPORA_MAX_LIMIT}).`),
|
|
829
|
+
},
|
|
830
|
+
async (args) => {
|
|
831
|
+
try {
|
|
832
|
+
const result = await selectCorpora(args);
|
|
833
|
+
return { content: [{ type: 'text', text: formatCorpora(result) }] };
|
|
834
|
+
} catch (err) {
|
|
835
|
+
return {
|
|
836
|
+
content: [{ type: 'text', text: `Error listing corpora: ${err.message}` }],
|
|
837
|
+
isError: true,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
);
|
|
842
|
+
|
|
636
843
|
// ------------------------------------------------------------------
|
|
637
844
|
// Tool: get_metric_reliability
|
|
638
845
|
// ------------------------------------------------------------------
|
|
@@ -650,15 +857,22 @@ export async function createServer() {
|
|
|
650
857
|
+ 'transfer carries a caveat. Research-lane evidence only (upstream data '
|
|
651
858
|
+ 'license pending review) — never cite it in commercial claims.',
|
|
652
859
|
{
|
|
653
|
-
target: z.string()
|
|
860
|
+
target: z.string().optional()
|
|
654
861
|
.describe('Target language as an ISO 639 code ("iu", "iku", "kk") or '
|
|
655
862
|
+ 'a language-family name ("Turkic", "Eskimo-Aleut"). This is the '
|
|
656
863
|
+ 'language being TRANSLATED INTO — metric reliability is about '
|
|
657
|
-
+ 'scoring output in that language.'),
|
|
864
|
+
+ 'scoring output in that language. Required unless `language` is given.'),
|
|
865
|
+
language: languageAlias('target'),
|
|
658
866
|
},
|
|
659
|
-
async ({ target }) => {
|
|
867
|
+
async ({ target: targetArg, language }) => {
|
|
868
|
+
const lang = resolveLanguageArg({ target: targetArg, language }, 'target', 'get_metric_reliability');
|
|
869
|
+
if (lang.error) return argError(lang.error);
|
|
870
|
+
const target = lang.value;
|
|
660
871
|
try {
|
|
661
|
-
|
|
872
|
+
// A language WMT never judged is looked up by its card's family.
|
|
873
|
+
const answer = await metricReliability(target, undefined, {
|
|
874
|
+
familyClaims: (code) => cardFamilyClaims(code, { index: languageIndex }),
|
|
875
|
+
});
|
|
662
876
|
return {
|
|
663
877
|
content: [{ type: 'text', text: formatReliability(answer) }],
|
|
664
878
|
isError: answer.status === 'index-unavailable',
|
|
@@ -675,48 +889,95 @@ export async function createServer() {
|
|
|
675
889
|
// ------------------------------------------------------------------
|
|
676
890
|
// Tool: translate
|
|
677
891
|
// ------------------------------------------------------------------
|
|
678
|
-
|
|
892
|
+
// STRICT schema: an argument this tool does not know is refused with its
|
|
893
|
+
// name, never stripped. A synthetic user passed an `endpoint` here before
|
|
894
|
+
// the tool had one; zod dropped it silently and the call ran on a different
|
|
895
|
+
// local model than the one they had just deployed.
|
|
896
|
+
server.registerTool(
|
|
679
897
|
'translate',
|
|
680
|
-
'Translate texts through the champollion pipeline — the tested, '
|
|
681
|
-
+ 'deterministic alternative to improvising your own translation prompt. '
|
|
682
|
-
+ 'You get: engine choice (LLM via OpenRouter, direct '
|
|
683
|
-
+ 'OpenAI/Anthropic/Gemini, DeepL, Google Translate, Microsoft, '
|
|
684
|
-
+ 'LibreTranslate), language-card register/formality conditioning, a '
|
|
685
|
-
+ 'persistent Translation Memory (repeated texts cost ZERO tokens — the '
|
|
686
|
-
+ 'response shows the savings), and a deterministic five-check quality '
|
|
687
|
-
+ 'gate (empty/source-echo/hallucination-loop/length-inflation/'
|
|
688
|
-
+ 'script-compliance) so a bad translation comes back as an explicit '
|
|
689
|
-
+ 'failure, never as silent garbage. Spends real API tokens only for '
|
|
690
|
-
+ 'texts the Translation Memory has not seen (cost estimate included in '
|
|
691
|
-
+ 'the response). This is production translation — benchmark evidence '
|
|
692
|
-
+ 'and quality claims still come from run_benchmark/get_results.',
|
|
693
898
|
{
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
899
|
+
description: 'Translate texts through the champollion pipeline — the tested, '
|
|
900
|
+
+ 'deterministic alternative to improvising your own translation prompt. '
|
|
901
|
+
+ 'You get: engine choice (LLM via OpenRouter, direct '
|
|
902
|
+
+ 'OpenAI/Anthropic/Gemini, DeepL, Google Translate, Microsoft, '
|
|
903
|
+
+ 'LibreTranslate; method "local" = an OpenAI-compatible server on this '
|
|
904
|
+
+ 'machine, no key — point it with base_url; method "api" = a champollion '
|
|
905
|
+
+ 'API endpoint — the model you deployed with `nmt-forge serve`), '
|
|
906
|
+
+ 'language-card register/formality conditioning, a '
|
|
907
|
+
+ 'persistent Translation Memory (repeated texts cost ZERO tokens), and a '
|
|
908
|
+
+ 'deterministic five-check quality gate, so a bad translation comes back '
|
|
909
|
+
+ 'as an explicit failure, never as silent garbage. The response names the '
|
|
910
|
+
+ 'engine that actually ran (with its model and endpoint where it has them) '
|
|
911
|
+
+ 'and the Translation Memory file used. Spends real API tokens only for texts the TM has '
|
|
912
|
+
+ 'not seen. Production translation — benchmark evidence and quality '
|
|
913
|
+
+ 'claims still come from run_benchmark/get_results.',
|
|
914
|
+
inputSchema: z.object({
|
|
915
|
+
texts: z.array(z.string()).min(1).max(50)
|
|
916
|
+
.describe('Source texts to translate (max 50 per call; the TM makes repeated calls cheap).'),
|
|
917
|
+
source_language: z.string()
|
|
918
|
+
.describe('Source language: a code (e.g. "en") or a name (e.g. "English"). With project_dir it is '
|
|
919
|
+
+ 'matched to the project\'s own locale code, so a name hits the cache sync filled.'),
|
|
920
|
+
target_language: z.string()
|
|
921
|
+
.describe('Target language: a code (e.g. "fr", "crk", "zul") or a name (e.g. "French"). A name two '
|
|
922
|
+
+ 'languages share, or one matching two project locales, is refused with the choices.'),
|
|
923
|
+
method: z.enum(TRANSLATE_METHODS).optional()
|
|
924
|
+
.describe('Translation engine. Default: with project_dir, the method that project\'s '
|
|
925
|
+
+ 'config uses for this pair (as `champollion sync` resolves it); otherwise "llm" '
|
|
926
|
+
+ '(OpenRouter). "local" = an '
|
|
927
|
+
+ 'OpenAI-compatible server on this machine (Ollama unless base_url / LOCAL_API_BASE '
|
|
928
|
+
+ 'says otherwise; no key). "api" = a champollion API endpoint (needs endpoint). '
|
|
929
|
+
+ 'Other engines need their API key in the server environment.'),
|
|
930
|
+
model: z.string().optional()
|
|
931
|
+
.describe('Model for LLM engines (e.g. "anthropic/claude-sonnet-5"; for "local", the name '
|
|
932
|
+
+ 'the server serves). Refused for machine-translation APIs, which have no model.'),
|
|
933
|
+
base_url: z.string().optional()
|
|
934
|
+
.describe('Method "local" (or "openai"): the OpenAI-compatible base URL to call — e.g. '
|
|
935
|
+
+ '"http://127.0.0.1:8378/v1", the /v1 URL `nmt-forge serve` prints. Overrides LOCAL_API_BASE.'),
|
|
936
|
+
endpoint: z.string().optional()
|
|
937
|
+
.describe('Method "api": the champollion API endpoint — e.g. "http://127.0.0.1:8378/translate" '
|
|
938
|
+
+ 'from `nmt-forge serve`. Bearer key from CHAMPOLLION_API_KEY; none needed for a loopback '
|
|
939
|
+
+ 'server started without a token.'),
|
|
940
|
+
register: z.string().optional()
|
|
941
|
+
.describe('Style/register instruction (e.g. "formal", "casual-tu", or free-text guidance). Defaults to the language card\'s register.'),
|
|
942
|
+
project_dir: z.string().optional()
|
|
943
|
+
.describe('Use this project\'s Translation Memory (<project_dir>/.champollion/tm.json, shared with '
|
|
944
|
+
+ '`champollion sync` there), its coaching/.env files, and its own pair for these languages '
|
|
945
|
+
+ '(method, model, register, script, and its "fallback" for texts the method cannot translate safely — as `champollion sync` resolves and runs it there; each text the fallback produced is marked), so '
|
|
946
|
+
+ 'strings sync already cached are free here and vice versa. Default: the '
|
|
947
|
+
+ 'server\'s own TM at ~/.champollion-mcp/.champollion/tm.json, separate from every project.'),
|
|
948
|
+
context: z.union([z.string(), z.array(z.string().nullable())]).optional()
|
|
949
|
+
.describe('gettext msgctxt: one context for every text, or an array with one per text (null for none). '
|
|
950
|
+
+ 'The cache is keyed with it exactly as `champollion sync` keys a catalog entry with that context, and the model is '
|
|
951
|
+
+ 'told it — so "Cancel" with context "button" hits the entry sync cached for msgctxt "button" in project_dir. '
|
|
952
|
+
+ 'Without it, a text the project has only with a context is translated without reading or writing the project\'s '
|
|
953
|
+
+ 'cache, and the answer says which context to pass.'),
|
|
954
|
+
script: z.string().optional()
|
|
955
|
+
.describe('Output writing system (ISO 15924) for a target with more than one real orthography — '
|
|
956
|
+
+ 'e.g. crk: "Latn" (Standard Roman Orthography) or "Cans" (Syllabics). Required for such a '
|
|
957
|
+
+ 'target unless the project_dir pair sets "script"; the tool refuses rather than pick one, '
|
|
958
|
+
+ 'exactly as `champollion sync` does.'),
|
|
959
|
+
use_tm: z.boolean().default(true)
|
|
960
|
+
.describe('Consult and populate the Translation Memory (default true — identical requests become free).'),
|
|
961
|
+
validate: z.boolean().default(true)
|
|
962
|
+
.describe('Run the deterministic quality gate on fresh translations (default true). A project pair\'s fallback always runs through the gate, as in `champollion sync`.'),
|
|
963
|
+
}).strict(),
|
|
710
964
|
},
|
|
711
|
-
async ({
|
|
965
|
+
async ({
|
|
966
|
+
texts, source_language, target_language, method, model, base_url, endpoint,
|
|
967
|
+
register, project_dir, script, context, use_tm, validate,
|
|
968
|
+
}) => {
|
|
712
969
|
try {
|
|
713
970
|
const result = await translateTexts({
|
|
714
971
|
texts, source: source_language, target: target_language,
|
|
715
|
-
method, model, register,
|
|
972
|
+
method, model, register, baseUrl: base_url, endpoint, projectDir: project_dir, script, context,
|
|
973
|
+
useTm: use_tm, validate,
|
|
716
974
|
});
|
|
717
975
|
return {
|
|
718
976
|
content: [{ type: 'text', text: formatTranslateResult(result) }],
|
|
719
|
-
|
|
977
|
+
// Every text failed (an unreachable engine, say) is an error, not
|
|
978
|
+
// a successful answer of "0 of 2"; a partial result is a normal
|
|
979
|
+
// answer that lists its failures.
|
|
980
|
+
isError: translateResultIsError(result),
|
|
720
981
|
};
|
|
721
982
|
} catch (err) {
|
|
722
983
|
return {
|
|
@@ -743,8 +1004,8 @@ export async function createServer() {
|
|
|
743
1004
|
+ 'training, coverage vs cited grammar checklists, per-kind sampling '
|
|
744
1005
|
+ 'caps, bootstrap CIs on every number, the eval-read ledger, and '
|
|
745
1006
|
+ 'preregistration before test scoring. Each rule names the mistake it '
|
|
746
|
-
+ 'kills and the enforcing
|
|
747
|
-
+ '
|
|
1007
|
+
+ 'kills and the enforcing nmt-forge command (`python3 -m pip install nmt-forge`; the '
|
|
1008
|
+
+ 'forge_* tools run them). Also the non-negotiables: datasets marked do_not_train '
|
|
748
1009
|
+ 'or quarantined in the mt-eval registry NEVER enter training mixes, '
|
|
749
1010
|
+ 'and test sets are REAL DATA ONLY (no synthetic rows).',
|
|
750
1011
|
{
|
|
@@ -777,150 +1038,488 @@ export async function createServer() {
|
|
|
777
1038
|
// command) → forge_preflight (will it refuse? every gate) → the suggested
|
|
778
1039
|
// command → repeat. Discovery/scaffolding: forge_discover, forge_init.
|
|
779
1040
|
// Data hygiene: forge_split, forge_leak_audit, forge_register_eval,
|
|
780
|
-
// forge_prereg. Results:
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
//
|
|
1041
|
+
// forge_prereg_template + forge_prereg. Results: forge_export (score the
|
|
1042
|
+
// test battery once + package the model), forge_evaluate (score only),
|
|
1043
|
+
// forge_compare (A/B, with each system's near-twin caveat),
|
|
1044
|
+
// forge_report/forge_lint (read the diagnosis). `nmt-forge run` (training)
|
|
1045
|
+
// and `nmt-forge serve` (a long-running HTTP server) are intentionally not
|
|
1046
|
+
// tools — forge_status returns their exact commands for a terminal.
|
|
1047
|
+
//
|
|
1048
|
+
// Every tool passes --json (nmt-forge ≥ 0.2.0) and returns
|
|
1049
|
+
// {result, summary?, next?}; a refusal comes back as a tool error with
|
|
1050
|
+
// forge's what/why/fix (src/tools/forge.js).
|
|
784
1051
|
// ------------------------------------------------------------------
|
|
785
1052
|
const forgeWs = z.string().optional()
|
|
786
|
-
.describe('Workspace directory (default .forge, or '
|
|
1053
|
+
.describe('Workspace directory (default .forge inside project_dir, or '
|
|
787
1054
|
+ 'CHAMPOLLION_FORGE_WORKSPACE). The project you are driving.');
|
|
1055
|
+
const forgeProject = z.string().optional()
|
|
1056
|
+
.describe('Project directory forge runs FROM — its own advice is '
|
|
1057
|
+
+ '`cd <project> && nmt-forge …`: config.json\'s paths, the default '
|
|
1058
|
+
+ '.forge workspace and relative path arguments resolve against it. Pass '
|
|
1059
|
+
+ 'the `project` forge_init returned. Default: this server\'s working '
|
|
1060
|
+
+ 'directory.');
|
|
1061
|
+
const forgeAt = (workspace, project_dir) => ({ workspace, projectDir: project_dir });
|
|
1062
|
+
// forge_split's near-twin advice and verdicts (splitNearTwinSummary /
|
|
1063
|
+
// splitNextHint) and the step order forge_init and forge_status relay
|
|
1064
|
+
// (initNextHint / initializedStatusHint) live in tools/forge.js.
|
|
1065
|
+
const FORGE_PREREG_ARG = 'The preregistration this run is judged against (an id from forge_status). '
|
|
1066
|
+
+ 'Needed when several preregs bind the test set for this run (e.g. a full model and a '
|
|
1067
|
+
+ 'twin-free model on one test set): forge refuses to guess. Without it: the only valid '
|
|
1068
|
+
+ 'prereg, else the one this run\'s first test read was bound to, else the one pinned to '
|
|
1069
|
+
+ 'its config hash.';
|
|
1070
|
+
// Round 9: this said to set LOCAL_API_BASE "in this server's environment",
|
|
1071
|
+
// while the translate tool takes the URL as an argument.
|
|
1072
|
+
const SERVE_IS_A_TERMINAL_STEP = '`nmt-forge serve <export dir>/model` is a long-running '
|
|
1073
|
+
+ 'HTTP server — run it in a terminal (not a tool). It binds 127.0.0.1 and '
|
|
1074
|
+
+ 'serves the champollion api method (POST /translate) and an '
|
|
1075
|
+
+ 'OpenAI-compatible /v1. From the champollion CLI: the `api` method config in '
|
|
1076
|
+
+ '<export dir>/model/DEPLOY.md, or `LOCAL_API_BASE=<the /v1 URL serve prints> '
|
|
1077
|
+
+ 'champollion sync --method local`. From this server: the translate tool with '
|
|
1078
|
+
+ 'method "local" and base_url set to that /v1 URL (or method "api" and endpoint set to '
|
|
1079
|
+
+ 'its /translate URL) — no environment variable needed. Deploy only model/ — '
|
|
1080
|
+
+ 'evaluation/ holds the test sentences.';
|
|
788
1081
|
|
|
789
1082
|
server.tool(
|
|
790
1083
|
'forge_status',
|
|
791
1084
|
'WHERE AM I in an nmt-forge project, and WHAT DO I RUN NEXT? Call this '
|
|
792
|
-
+ 'FIRST and after every step
|
|
793
|
-
+ '
|
|
794
|
-
+ 'returns a state
|
|
795
|
-
+ '
|
|
796
|
-
+ 'a
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
1085
|
+
+ 'FIRST and after every step (needs nmt-forge ≥ 0.2.0: `python3 -m pip install nmt-forge`). '
|
|
1086
|
+
+ 'It reads the actual workspace (registered sets, preregistrations, runs, exports, ledger) '
|
|
1087
|
+
+ 'and returns a state — initialized (split next), empty-workspace, missing-preregistration '
|
|
1088
|
+
+ '(a registered test set has no predictions: write them BEFORE any run_benchmark on it), '
|
|
1089
|
+
+ 'no-dev-set, ready-to-train, training (a run holds the lock: wait), ready-to-score, '
|
|
1090
|
+
+ 'exported, choose-export (several exported models: the USER picks; advice.exports lists '
|
|
1091
|
+
+ 'each with its scores and caveats), or serving (chosen model answering) '
|
|
1092
|
+
+ '— THE single next command with exact args, ready items, '
|
|
1093
|
+
+ 'blockers and warnings (a saturated dev set, a prereg written after scoring reads, a SEVERE '
|
|
1094
|
+
+ 'leak-audit verdict and the two-model decision, a twin-free model not trained yet, the dev '
|
|
1095
|
+
+ 'twin verdict); '
|
|
1096
|
+
+ 'summary.runs lists EVERY run; summary.tools names the forge_* tool for each step of the '
|
|
1097
|
+
+ 'command ("terminal:" for training and serving). Deterministic: same state → same advice.',
|
|
1098
|
+
{ workspace: forgeWs, project_dir: forgeProject },
|
|
1099
|
+
async ({ workspace, project_dir }) => forgeTool(['status'], {
|
|
1100
|
+
...forgeAt(workspace, project_dir),
|
|
1101
|
+
summarize: (r) => ({
|
|
1102
|
+
state: r?.advice?.state,
|
|
1103
|
+
// get_training_guardrails first in no-dev-set, where forge names it (Round 13)
|
|
1104
|
+
tools: statusTools(r),
|
|
1105
|
+
...(Array.isArray(r?.advice?.exports) ? { exports: r.advice.exports.length } : {}),
|
|
1106
|
+
// mt-eval's caveats on each export's score, verbatim (Round 13)
|
|
1107
|
+
...(statusExportCaveats(r) ? { export_caveats: statusExportCaveats(r) } : {}),
|
|
1108
|
+
// every trained run, not just the newest (Round 6 hospital persona)
|
|
1109
|
+
...(Array.isArray(r?.snapshot?.runs) && r.snapshot.runs.length ? {
|
|
1110
|
+
runs: r.snapshot.runs.map((x) => ({
|
|
1111
|
+
run: x.run,
|
|
1112
|
+
checkpoint: x.selected_checkpoint ?? null,
|
|
1113
|
+
dev: x.dev_score ?? null,
|
|
1114
|
+
dev_saturated: x.dev_saturated === true,
|
|
1115
|
+
exported: (x.exports || []).map((e) => e.model_dir || e.dir),
|
|
1116
|
+
})),
|
|
1117
|
+
} : {}),
|
|
1118
|
+
...(Array.isArray(r?.advice?.warnings) && r.advice.warnings.length
|
|
1119
|
+
? { warnings: r.advice.warnings } : {}),
|
|
1120
|
+
}),
|
|
1121
|
+
nextHint: (r) => (r?.advice?.state === 'initialized'
|
|
1122
|
+
? initializedStatusHint(r)
|
|
1123
|
+
: r?.advice?.state === 'choose-export'
|
|
1124
|
+
? 'several exported models and no recorded choice: show the user result.advice.exports '
|
|
1125
|
+
+ '(each with its score, its twin-free score and its caveat) — and beside each export\'s score its '
|
|
1126
|
+
+ 'advice.exports[].score_caveats, mt-eval\'s caveats in its words (summary.export_caveats; a MAJOR '
|
|
1127
|
+
+ 'one is never left out) — and ASK which one to deploy — '
|
|
1128
|
+
+ 'never pick the newest for them. Only the user\'s answer records the choice: '
|
|
1129
|
+
+ '`nmt-forge choose <model dir>` in a terminal (or `nmt-forge serve <model dir> --choose`). '
|
|
1130
|
+
+ 'Serving a model to try it records it as SERVED, never as chosen, so this state stays open '
|
|
1131
|
+
+ 'until then. ' + SERVE_IS_A_TERMINAL_STEP
|
|
1132
|
+
: r?.advice?.state === 'exported'
|
|
1133
|
+
? (statusExportCaveats(r)?.major
|
|
1134
|
+
? `First relay summary.export_caveats with the score of ${statusExportCaveats(r).major.join(', ')} — `
|
|
1135
|
+
+ 'mt-eval qualifies it; never quote it without them. '
|
|
1136
|
+
: '')
|
|
1137
|
+
+ SERVE_IS_A_TERMINAL_STEP
|
|
1138
|
+
+ (Array.isArray(r?.advice?.exports)
|
|
1139
|
+
? ' Other exports exist (result.advice.exports) — the one named is the user\'s recorded choice '
|
|
1140
|
+
+ '(`nmt-forge choose`).'
|
|
1141
|
+
: '')
|
|
1142
|
+
: r?.advice?.state === 'missing-preregistration'
|
|
1143
|
+
// Round 9: the guide put baselines before the prereg, and forge
|
|
1144
|
+
// refuses a prereg written after any scoring read of the set
|
|
1145
|
+
? 'write the predictions WITH the user now — forge_prereg_template, then forge_prereg — '
|
|
1146
|
+
+ 'BEFORE any run_benchmark (or mt-eval run) on the registered test file: a benchmark is a '
|
|
1147
|
+
+ 'scoring read, and forge refuses a preregistration written after one. One prereg per '
|
|
1148
|
+
+ 'model the user plans to train (the leak-audit verdict says whether that is one or two: '
|
|
1149
|
+
+ 'all data, and twin-free), named after it. allow_after_reads is ONLY for predictions that '
|
|
1150
|
+
+ 'were truly written before those reads; forge then says so beside every verdict.'
|
|
1151
|
+
: r?.advice?.state === 'training'
|
|
1152
|
+
// nmt-forge holds <workspace>/run.lock while a run trains: the only
|
|
1153
|
+
// safe move is to wait. Never suggest `nmt-forge run` here.
|
|
1154
|
+
? 'a training run is in progress — WAIT. Never start another run (forge refuses '
|
|
1155
|
+
+ 'while the run lock is held, and two runs fight for the same CPU/GPU and memory). '
|
|
1156
|
+
+ 'Call forge_status again once the training process has exited (wait on that '
|
|
1157
|
+
+ 'process or its log rather than polling on a timer); it will then name the next '
|
|
1158
|
+
+ 'step. Meanwhile you may write a missing preregistration (see result.advice.blockers).'
|
|
1159
|
+
// the step forge named, with only its own terminal steps (Round 12)
|
|
1160
|
+
: statusNextHint(r)),
|
|
803
1161
|
}),
|
|
804
1162
|
);
|
|
805
1163
|
|
|
806
1164
|
server.tool(
|
|
807
1165
|
'forge_preflight',
|
|
808
|
-
'WILL THIS COMMAND REFUSE?
|
|
809
|
-
+ '
|
|
810
|
-
+ '
|
|
811
|
-
+ 'leak-audit
|
|
812
|
-
+ '
|
|
1166
|
+
'WILL THIS COMMAND REFUSE? Every gate a forge command will hit, each with '
|
|
1167
|
+
+ 'ok and the fix, so you stop trial-and-erroring through refusals one at a '
|
|
1168
|
+
+ 'time. Targets: run, evaluate, export, serve, score, split, prereg, '
|
|
1169
|
+
+ 'leak-audit. run/evaluate/export read the run config (`config`, default '
|
|
1170
|
+
+ 'config.json in project_dir): it must parse, point at this workspace, name '
|
|
1171
|
+
+ 'a registered dev set and existing data files, and the backend\'s extra '
|
|
1172
|
+
+ 'must be installed; evaluate/export also check the battery, its '
|
|
1173
|
+
+ 'preregistration and (sealed) that it is unspent; serve checks the serving '
|
|
1174
|
+
+ 'extra and a recorded export. A failing gate is an ANSWER, not a tool '
|
|
1175
|
+
+ 'error: summary.passed is false and summary.failing names the gates.',
|
|
813
1176
|
{
|
|
814
|
-
target: z.enum(['run', 'score', 'split', 'prereg', 'leak-audit'])
|
|
1177
|
+
target: z.enum(['run', 'evaluate', 'export', 'serve', 'score', 'split', 'prereg', 'leak-audit'])
|
|
815
1178
|
.describe('The command to preflight.'),
|
|
1179
|
+
config: z.string().optional()
|
|
1180
|
+
.describe('Run config for run/evaluate/export (default config.json in project_dir).'),
|
|
816
1181
|
workspace: forgeWs,
|
|
1182
|
+
project_dir: forgeProject,
|
|
817
1183
|
},
|
|
818
|
-
async ({ target, workspace }) => forgeTool(
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1184
|
+
async ({ target, config, workspace, project_dir }) => forgeTool(
|
|
1185
|
+
['preflight', target, ...(config ? ['--config', config] : [])], {
|
|
1186
|
+
...forgeAt(workspace, project_dir),
|
|
1187
|
+
okExitCodes: [0, 2],
|
|
1188
|
+
summarize: (gates) => (Array.isArray(gates) ? {
|
|
1189
|
+
passed: gates.every((g) => g.ok),
|
|
1190
|
+
failing: gates.filter((g) => !g.ok).map((g) => g.gate),
|
|
1191
|
+
// a warning passes but must be read first (Round 10)
|
|
1192
|
+
...(gates.some((g) => g.ok && g.warning)
|
|
1193
|
+
? { warnings: gates.filter((g) => g.ok && g.warning).map((g) => g.gate) } : {}),
|
|
1194
|
+
} : null),
|
|
1195
|
+
nextHint: (gates) => preflightNextHint(target, config, gates),
|
|
1196
|
+
}),
|
|
822
1197
|
);
|
|
823
1198
|
|
|
824
1199
|
server.tool(
|
|
825
1200
|
'forge_discover',
|
|
826
|
-
'What does a language HAVE? Reads the
|
|
827
|
-
+ 'scripts, analyzers, dictionaries, corpora, eval datasets (with '
|
|
828
|
-
+ 'do_not_train/quarantine flags), LYSS referee plugins,
|
|
829
|
-
+ 'language sits on
|
|
830
|
-
+ '
|
|
1201
|
+
'What does a language HAVE, as forge sees it? Reads the language card and '
|
|
1202
|
+
+ 'reports scripts, analyzers, dictionaries, corpora, eval datasets (with '
|
|
1203
|
+
+ 'do_not_train/quarantine flags), LYSS referee plugins, metric trust and '
|
|
1204
|
+
+ 'where the language sits on forge\'s asset ladder. Absence means UNKNOWN, '
|
|
1205
|
+
+ 'never zero. Works from a plain pip install: forge finds the card in '
|
|
1206
|
+
+ '`cards_dir`, $MT_EVAL_CARDS_DIR / $CHAMPOLLION_CARDS_DIR, a checkout or '
|
|
1207
|
+
+ 'node_modules/champollion above project_dir, else the public card index '
|
|
1208
|
+
+ '(cached; reused offline). Call this before forge_init.',
|
|
831
1209
|
{
|
|
832
|
-
code: z.string().
|
|
1210
|
+
code: z.string().optional()
|
|
1211
|
+
.describe('ISO 639-3 code (e.g. crk, nav, arb). Required unless `language` is given.'),
|
|
1212
|
+
language: languageAlias('code'),
|
|
1213
|
+
cards_dir: z.string().optional()
|
|
1214
|
+
.describe('A directory of <code>.json cards (e.g. from `champollion network card <code> --json`).'),
|
|
833
1215
|
workspace: forgeWs,
|
|
1216
|
+
project_dir: forgeProject,
|
|
1217
|
+
},
|
|
1218
|
+
async ({ code: codeArg, language, cards_dir, workspace, project_dir }) => {
|
|
1219
|
+
const lang = resolveLanguageArg({ code: codeArg, language }, 'code', 'forge_discover');
|
|
1220
|
+
if (lang.error) return argError(lang.error);
|
|
1221
|
+
// The CLI's own card, through its adapter — what language_overview
|
|
1222
|
+
// lists — so a gap in forge's source is said, attributed (Round 10).
|
|
1223
|
+
let cli = null;
|
|
1224
|
+
if (!cards_dir) {
|
|
1225
|
+
try {
|
|
1226
|
+
const g = await getLanguage(lang.value);
|
|
1227
|
+
if (g?.status === 'ok') cli = g;
|
|
1228
|
+
} catch { /* no CLI card here: forge's answer stands alone */ }
|
|
1229
|
+
}
|
|
1230
|
+
return forgeTool(
|
|
1231
|
+
['discover', lang.value, ...(cards_dir ? ['--cards-dir', cards_dir] : [])], {
|
|
1232
|
+
...forgeAt(workspace, project_dir),
|
|
1233
|
+
summarize: (r) => {
|
|
1234
|
+
const x = discoverCardCrossCheck(r, cli);
|
|
1235
|
+
return x ? { card_sources_differ: x } : null;
|
|
1236
|
+
},
|
|
1237
|
+
nextHint: (r) => (discoverCardCrossCheck(r, cli)
|
|
1238
|
+
? 'tell the user the two card sources differ (summary.card_sources_differ — each '
|
|
1239
|
+
+ 'attributed, nothing invented); then scaffold with forge_init, then forge_status.'
|
|
1240
|
+
: 'scaffold the project with forge_init, then forge_status.'),
|
|
1241
|
+
});
|
|
834
1242
|
},
|
|
835
|
-
async ({ code, workspace }) => forgeTool(['discover', code, '--json'], {
|
|
836
|
-
workspace, nextHint: 'scaffold the project with forge_init, then '
|
|
837
|
-
+ 'forge_status.',
|
|
838
|
-
}),
|
|
839
1243
|
);
|
|
840
1244
|
|
|
841
1245
|
server.tool(
|
|
842
1246
|
'forge_init',
|
|
843
|
-
'Scaffold a forge project from a language card: workspace +
|
|
844
|
-
+ 'config
|
|
845
|
-
+ '
|
|
1247
|
+
'Scaffold a forge project from a language card: <dir>/.forge workspace + a '
|
|
1248
|
+
+ 'starter config.json (the model preset expanded into explicit numbers, '
|
|
1249
|
+
+ 'never hidden) + NEXT_STEPS.md. Run after forge_discover. model: cpu-tiny '
|
|
1250
|
+
+ '(default — a ~6M-parameter model trained from scratch, minutes on a laptop '
|
|
1251
|
+
+ 'CPU, no download, weak by design), cpu-finetune (fine-tune an opus-mt '
|
|
1252
|
+
+ '`base` for a RELATED pair, CPU), nllb-600m (GPU). A language the card index '
|
|
1253
|
+
+ 'does not have yet: no_card + name (every card fact is then unknown). '
|
|
1254
|
+
+ 'Returns `project` — pass it as project_dir to every later forge_* call.',
|
|
846
1255
|
{
|
|
847
|
-
code: z.string().
|
|
848
|
-
|
|
1256
|
+
code: z.string().optional()
|
|
1257
|
+
.describe('ISO 639-3 code of the TARGET language. Required unless `language` is given.'),
|
|
1258
|
+
language: languageAlias('code'),
|
|
1259
|
+
dir: z.string().optional()
|
|
1260
|
+
.describe('Project directory to create (default .; absolute recommended — a '
|
|
1261
|
+
+ 'relative path resolves against this server\'s working directory).'),
|
|
849
1262
|
pair: z.string().optional()
|
|
850
|
-
.describe('Language pair
|
|
851
|
-
|
|
1263
|
+
.describe('Language pair: eng-crk or eng>crk (both accepted; default eng-<code>). '
|
|
1264
|
+
+ 'In a printed shell command quote the > form (\'eng>crk\'): an unquoted > is a redirect.'),
|
|
1265
|
+
model: z.string().optional()
|
|
1266
|
+
.describe('Model preset: cpu-tiny (default), cpu-finetune, nllb-600m. forge '
|
|
1267
|
+
+ 'refuses an unknown preset and lists the valid ones.'),
|
|
1268
|
+
base: z.string().optional()
|
|
1269
|
+
.describe('Pretrained model for cpu-finetune (a Hugging Face id or local dir, '
|
|
1270
|
+
+ 'e.g. an opus-mt model for a related pair); optional override for nllb-600m.'),
|
|
1271
|
+
no_card: z.boolean().optional()
|
|
1272
|
+
.describe('Scaffold a language the card index does not have yet (pass name too).'),
|
|
1273
|
+
name: z.string().optional().describe('The language\'s name (with no_card).'),
|
|
1274
|
+
cards_dir: z.string().optional()
|
|
1275
|
+
.describe('A directory of <code>.json cards (see forge_discover).'),
|
|
1276
|
+
},
|
|
1277
|
+
async ({ code: codeArg, language, dir, pair, model, base, no_card, name, cards_dir }) => {
|
|
1278
|
+
const lang = resolveLanguageArg({ code: codeArg, language }, 'code', 'forge_init');
|
|
1279
|
+
if (lang.error) return argError(lang.error);
|
|
1280
|
+
return forgeTool(
|
|
1281
|
+
['init', lang.value, ...(dir ? ['--dir', dir] : []), ...(pair ? ['--pair', pair] : []),
|
|
1282
|
+
...(model ? ['--model', model] : []), ...(base ? ['--base', base] : []),
|
|
1283
|
+
...(no_card ? ['--no-card'] : []), ...(name ? ['--name', name] : []),
|
|
1284
|
+
...(cards_dir ? ['--cards-dir', cards_dir] : [])],
|
|
1285
|
+
{
|
|
1286
|
+
nextHint: (r) => initNextHint(r, dir),
|
|
1287
|
+
});
|
|
852
1288
|
},
|
|
853
|
-
async ({ code, dir, pair, workspace }) => forgeTool(
|
|
854
|
-
['init', code, ...(dir ? ['--dir', dir] : []), ...(pair ? ['--pair', pair] : [])],
|
|
855
|
-
{ workspace, nextHint: 'call forge_status — it will tell you to split a '
|
|
856
|
-
+ 'corpus and register a dev set next.' }),
|
|
857
1289
|
);
|
|
858
1290
|
|
|
859
1291
|
server.tool(
|
|
860
1292
|
'forge_split',
|
|
861
|
-
'Carve a parallel corpus
|
|
862
|
-
+ '
|
|
863
|
-
+ '
|
|
864
|
-
+ '
|
|
1293
|
+
'Carve a parallel corpus (.jsonl with source/target fields, or .tsv: '
|
|
1294
|
+
+ 'source<TAB>target) into GROUP-DISJOINT train/dev/test — pairs sharing a '
|
|
1295
|
+
+ 'canonical source OR target land on one side, so answer-sharing rows can '
|
|
1296
|
+
+ 'never straddle the split (the split-guard). `register` also registers '
|
|
1297
|
+
+ '<prefix>-dev / <prefix>-test in one step. test: 0 carves train/dev only — '
|
|
1298
|
+
+ 'for a community that keeps its own test set as a separate file (register '
|
|
1299
|
+
+ 'it with forge_register_eval role test); the fresh sides are then screened '
|
|
1300
|
+
+ 'against registered test/sealed sets on the spot. Near-twins: when forge\'s advice '
|
|
1301
|
+
+ 'recommends a near-duplicate carve (`--near-dupe 0.6`), pass near_dupe: 0.6 (with '
|
|
1302
|
+
+ 'allow_rotate when re-splitting registered sets). When it refuses a split whose sides come '
|
|
1303
|
+
+ 'out far larger than asked (SplitSizeRefused), take the route it names (--max-group → '
|
|
1304
|
+
+ 'max_group). When the templates chain, dev_near_twin.verdict.final says no split gives a '
|
|
1305
|
+
+ 'twin-free dev set: relay it once with its options — re-splitting will not change it.',
|
|
865
1306
|
{
|
|
866
|
-
corpus: z.string().describe('Path to the parallel corpus (.jsonl).'),
|
|
867
|
-
test: z.number().int().
|
|
868
|
-
|
|
1307
|
+
corpus: z.string().describe('Path to the parallel corpus (.jsonl or .tsv).'),
|
|
1308
|
+
test: z.number().int().nonnegative()
|
|
1309
|
+
.describe('Test rows to carve (0 when your test set is a separate, registered file).'),
|
|
1310
|
+
dev: z.number().int().nonnegative().optional()
|
|
1311
|
+
.describe('Dev rows to carve (checkpoint selection runs on these; training refuses without a dev set).'),
|
|
869
1312
|
seed: z.number().int().describe('Split seed (required, reproducible).'),
|
|
870
|
-
out: z.string().
|
|
871
|
-
|
|
872
|
-
|
|
1313
|
+
out: z.string().optional()
|
|
1314
|
+
.describe('Output directory for the split files (default data/split — the path the '
|
|
1315
|
+
+ 'config.json forge_init writes reads its train side from; another directory works, and '
|
|
1316
|
+
+ 'forge then says which config.json lines to change).'),
|
|
1317
|
+
register: z.union([z.string(), z.boolean()]).optional()
|
|
1318
|
+
.describe('Also register the carved sides: a name prefix (string) registers <prefix>-dev / '
|
|
1319
|
+
+ '<prefix>-test; true means "project" — project-dev / project-test, the names the '
|
|
1320
|
+
+ 'config.json forge_init writes expects; false or omitted registers nothing.'),
|
|
1321
|
+
allow_rotate: z.boolean().optional()
|
|
1322
|
+
.describe('With register: replace <prefix>-dev / <prefix>-test when they are already '
|
|
1323
|
+
+ 'registered with other content (e.g. re-splitting after forge_leak_audit clean_to). '
|
|
1324
|
+
+ 'Ledgered with what it replaced and how often that content was read. Without it a '
|
|
1325
|
+
+ 'conflicting split is refused before any file is written.'),
|
|
1326
|
+
near_dupe: z.number().gt(0).max(1).optional()
|
|
1327
|
+
.describe('Also keep NEAR-duplicates on one side (`--near-dupe`): a Jaccard threshold in '
|
|
1328
|
+
+ '(0, 1] — rows whose source or target words overlap at ≥ this are grouped, so '
|
|
1329
|
+
+ 'sentences built on one template never straddle train and test. 0.6 is the value '
|
|
1330
|
+
+ 'forge\'s near-twin check uses; pass it when forge\'s advice recommends the carve '
|
|
1331
|
+
+ '(on a corpus whose templates chain into one group, forge names other routes).'),
|
|
1332
|
+
max_group: z.number().int().min(2).optional()
|
|
1333
|
+
.describe('With near_dupe (`--max-group`): cap near-duplicate share-groups at this many '
|
|
1334
|
+
+ 'rows, so templates on a small corpus cannot chain into one giant group. '
|
|
1335
|
+
+ 'Exact-duplicate groups are never capped; near-duplicate links beyond the cap are '
|
|
1336
|
+
+ 'left uncut and reported. Needs nmt-forge with --max-group (forge refuses the flag '
|
|
1337
|
+
+ 'otherwise, by name).'),
|
|
873
1338
|
workspace: forgeWs,
|
|
1339
|
+
project_dir: forgeProject,
|
|
1340
|
+
},
|
|
1341
|
+
async ({ corpus, test, dev, seed, out = 'data/split', register: registerArg, allow_rotate,
|
|
1342
|
+
near_dupe, max_group, workspace, project_dir }) => {
|
|
1343
|
+
// Round 10 (researcher): `register: true` failed schema validation with
|
|
1344
|
+
// nothing in the instructions saying it is a prefix. true → "project".
|
|
1345
|
+
const register = registerArg === true ? 'project' : (registerArg || null);
|
|
1346
|
+
if (typeof register === 'string' && !/^[A-Za-z0-9][\w.-]*$/.test(register)) {
|
|
1347
|
+
return argError(`forge_split: register ${JSON.stringify(register)} is not a name prefix — use letters, `
|
|
1348
|
+
+ 'digits, ".", "_" or "-" (e.g. "project"), or true for "project".');
|
|
1349
|
+
}
|
|
1350
|
+
// max_group caps NEAR-duplicate groups, which exist only with near_dupe:
|
|
1351
|
+
// alone it would be a flag forge accepts and nothing uses.
|
|
1352
|
+
if (max_group != null && near_dupe == null) {
|
|
1353
|
+
return argError('forge_split: max_group caps near-duplicate groups, which only exist with '
|
|
1354
|
+
+ 'near_dupe — pass near_dupe too (e.g. near_dupe: 0.6, max_group: 50), or drop max_group.');
|
|
1355
|
+
}
|
|
1356
|
+
return forgeTool(
|
|
1357
|
+
['split', corpus, '--test', String(test), '--seed', String(seed),
|
|
1358
|
+
'--out', out, ...(dev ? ['--dev', String(dev)] : []),
|
|
1359
|
+
...(register ? ['--register', register] : []),
|
|
1360
|
+
...(allow_rotate ? ['--allow-rotate'] : []),
|
|
1361
|
+
...(near_dupe != null ? ['--near-dupe', String(near_dupe)] : []),
|
|
1362
|
+
...(max_group != null ? ['--max-group', String(max_group)] : [])],
|
|
1363
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1364
|
+
summarize: (r) => {
|
|
1365
|
+
const sum = splitNearTwinSummary(r);
|
|
1366
|
+
const cc = r?.config_check;
|
|
1367
|
+
return cc && cc.ok === false ? { ...(sum || {}), config_check: cc } : sum;
|
|
1368
|
+
},
|
|
1369
|
+
nextHint: splitNextHint });
|
|
874
1370
|
},
|
|
875
|
-
async ({ corpus, test, dev, seed, out, register, workspace }) => forgeTool(
|
|
876
|
-
['split', corpus, '--test', String(test), '--seed', String(seed),
|
|
877
|
-
'--out', out, ...(dev ? ['--dev', String(dev)] : []),
|
|
878
|
-
...(register ? ['--register', register] : [])],
|
|
879
|
-
{ workspace, nextHint: 'forge_status — you now have dev/test; next is '
|
|
880
|
-
+ 'usually prereg then run.' }),
|
|
881
1371
|
);
|
|
882
1372
|
|
|
883
1373
|
server.tool(
|
|
884
1374
|
'forge_leak_audit',
|
|
885
|
-
'Screen a corpus against every registered eval set BEFORE
|
|
886
|
-
+ '
|
|
887
|
-
+ '
|
|
888
|
-
+ '
|
|
889
|
-
+ '
|
|
1375
|
+
'Screen a corpus (.jsonl or .tsv) against every registered eval set BEFORE '
|
|
1376
|
+
+ 'training. A row is a LEAK (dropped; refused with strict) when its target '
|
|
1377
|
+
+ 'is an eval answer, contains one, is a fragment of one, or is ≥0.9 '
|
|
1378
|
+
+ 'identical (diacritics folded). Template siblings (a pure word substitution: '
|
|
1379
|
+
+ '"I see the dog" / "I see the cat") and source-only near-dupes are KEPT and '
|
|
1380
|
+
+ 'reported, with the eval rows that have them. Deterministic and content-free (counts, '
|
|
1381
|
+
+ 'row numbers — long lists as {count, first}; full_indices for all — never eval text). '
|
|
1382
|
+
+ 'READ result.verdict FIRST: one sentence (e.g. every test row has a near-twin in '
|
|
1383
|
+
+ 'training), the key numbers and the fix command. clean_to writes the survivors '
|
|
1384
|
+
+ 'plus an audit manifest beside them, named with clean_to\'s extension replaced by '
|
|
1385
|
+
+ '.audit.json (clean.jsonl → clean.audit.json). Audit BEFORE splitting; the twin-free '
|
|
1386
|
+
+ 'audit (drop_test_twins, its own clean_to: corpus.notwins.jsonl) comes after the split.',
|
|
890
1387
|
{
|
|
891
|
-
corpus: z.string().describe('Corpus to screen (.jsonl).'),
|
|
1388
|
+
corpus: z.string().describe('Corpus to screen (.jsonl or .tsv).'),
|
|
892
1389
|
strict: z.boolean().optional().describe('Hard-fail on test/sealed hits.'),
|
|
893
1390
|
clean_to: z.string().optional()
|
|
894
|
-
.describe('Write surviving rows here
|
|
1391
|
+
.describe('Write surviving rows here (plus an audit manifest next to it).'),
|
|
1392
|
+
drop_test_twins: z.boolean().optional()
|
|
1393
|
+
.describe('With clean_to: also drop training rows that are near-twins of the '
|
|
1394
|
+
+ 'registered test/sealed rows — the fix when a FIXED test set\'s rows all have '
|
|
1395
|
+
+ 'template twins in training (the score would measure recall, not translation). '
|
|
1396
|
+
+ 'Also writes the twin-free model\'s config (config-notwins.json beside config.json, '
|
|
1397
|
+
+ 'or companion_config) and names the command that trains it.'),
|
|
1398
|
+
companion_config: z.string().optional()
|
|
1399
|
+
.describe('With drop_test_twins (`--companion-config`): where to write the twin-free '
|
|
1400
|
+
+ 'model\'s config — config.json with its own run_name and data.gold / '
|
|
1401
|
+
+ 'eval.near_dupe_corpus set to clean_to. Default config-notwins.json beside config.json. '
|
|
1402
|
+
+ 'An existing file is never overwritten.'),
|
|
1403
|
+
overwrite: z.boolean().optional()
|
|
1404
|
+
.describe('(`--overwrite`) Let clean_to replace a file that is in use — one a project config '
|
|
1405
|
+
+ 'or a run trains on, the corpus a registered split was carved from, or another audit\'s '
|
|
1406
|
+
+ 'output (the all-data clean corpus vs the twin-free one). Refused without it: give the '
|
|
1407
|
+
+ 'twin-free corpus its own file (corpus.notwins.jsonl). Only when the user means to replace it.'),
|
|
1408
|
+
full_indices: z.boolean().optional()
|
|
1409
|
+
.describe('(`--full-indices`) Return every row-index list in full. By default each list longer '
|
|
1410
|
+
+ 'than 5 comes back as {count, first}; result.indices.full_lists names the audit file that '
|
|
1411
|
+
+ 'keeps them all.'),
|
|
895
1412
|
workspace: forgeWs,
|
|
1413
|
+
project_dir: forgeProject,
|
|
1414
|
+
},
|
|
1415
|
+
async ({ corpus, strict, clean_to, drop_test_twins, companion_config, overwrite, full_indices,
|
|
1416
|
+
workspace, project_dir }) => {
|
|
1417
|
+
if (companion_config && !drop_test_twins) {
|
|
1418
|
+
return argError('forge_leak_audit: companion_config names the twin-free model\'s config, '
|
|
1419
|
+
+ 'which only drop_test_twins writes — pass drop_test_twins: true and clean_to, or drop '
|
|
1420
|
+
+ 'companion_config.');
|
|
1421
|
+
}
|
|
1422
|
+
return forgeTool(
|
|
1423
|
+
['leak-audit', corpus, ...(strict ? ['--strict'] : []),
|
|
1424
|
+
...(clean_to ? ['--clean-to', clean_to] : []),
|
|
1425
|
+
...(drop_test_twins ? ['--drop-test-twins'] : []),
|
|
1426
|
+
...(companion_config ? ['--companion-config', companion_config] : []),
|
|
1427
|
+
...(overwrite ? ['--overwrite'] : []),
|
|
1428
|
+
...(full_indices ? ['--full-indices'] : [])],
|
|
1429
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1430
|
+
summarize: (r) => (r?.verdict ? {
|
|
1431
|
+
severity: r.verdict.severity, verdict: r.verdict.summary,
|
|
1432
|
+
...(r.companion_config ? { companion_config: r.companion_config } : {}),
|
|
1433
|
+
...(Array.isArray(r.preregistration_needed) && r.preregistration_needed.length
|
|
1434
|
+
? { preregistration_needed: r.preregistration_needed } : {}),
|
|
1435
|
+
} : null),
|
|
1436
|
+
nextHint: (r) => {
|
|
1437
|
+
const prereg = Array.isArray(r?.preregistration_needed) && r.preregistration_needed.length
|
|
1438
|
+
? ` Then write the predictions for ${r.preregistration_needed.join(', ')} with the user `
|
|
1439
|
+
+ '(forge_prereg_template → forge_prereg; one per model) BEFORE any run_benchmark on '
|
|
1440
|
+
+ 'the test file — this audit\'s reads never block a preregistration, a benchmark\'s do.'
|
|
1441
|
+
: '';
|
|
1442
|
+
const c = r?.companion_config;
|
|
1443
|
+
if (c && c.path) {
|
|
1444
|
+
return `relay result.verdict to the user. The twin-free model's config is ${c.path}`
|
|
1445
|
+
+ (c.written ? ' (written now)' : '') + `: ${c.note}`
|
|
1446
|
+
+ (c.next ? ` Train it in a terminal: ${c.next}.` : '') + prereg;
|
|
1447
|
+
}
|
|
1448
|
+
if (r?.verdict?.fix) {
|
|
1449
|
+
return `relay result.verdict to the user, then run its fix: ${r.verdict.fix}`
|
|
1450
|
+
+ (r.verdict.decision ? ' — and show them result.verdict.decision before choosing a model.' : '')
|
|
1451
|
+
+ prereg;
|
|
1452
|
+
}
|
|
1453
|
+
return 'train on the clean_to file if one was written (split it first if it is not split yet); '
|
|
1454
|
+
+ `then forge_status.${prereg}`;
|
|
1455
|
+
} });
|
|
896
1456
|
},
|
|
897
|
-
async ({ corpus, strict, clean_to, workspace }) => forgeTool(
|
|
898
|
-
['leak-audit', corpus, ...(strict ? ['--strict'] : []),
|
|
899
|
-
...(clean_to ? ['--clean-to', clean_to] : [])],
|
|
900
|
-
{ workspace, nextHint: 'if rows were removed, train on the --clean-to '
|
|
901
|
-
+ 'file; then forge_status.' }),
|
|
902
1457
|
);
|
|
903
1458
|
|
|
904
1459
|
server.tool(
|
|
905
1460
|
'forge_register_eval',
|
|
906
|
-
'Register an eval file
|
|
907
|
-
+ '
|
|
908
|
-
+ '(one-shot). Every downstream guard keys
|
|
909
|
-
+ 'must be REAL data (synthetic rows are refused).',
|
|
1461
|
+
'Register an eval file (.jsonl, or .tsv: source<TAB>target) in the '
|
|
1462
|
+
+ 'workspace with a role: dev (fenced checkpoint selection), test '
|
|
1463
|
+
+ '(prereg-gated scoring), or sealed (one-shot). Every downstream guard keys '
|
|
1464
|
+
+ 'off these roles. Test sets must be REAL data (synthetic rows are refused).',
|
|
910
1465
|
{
|
|
911
1466
|
name: z.string().describe('Registry name for the set.'),
|
|
912
|
-
path: z.string().describe('Path to the eval file (.jsonl
|
|
1467
|
+
path: z.string().describe('Path to the eval file (.jsonl or .tsv): absolute, or relative to '
|
|
1468
|
+
+ 'project_dir — forge runs there, like every forge path (a file in a data/ folder beside the '
|
|
1469
|
+
+ 'project folder is "../data/test.tsv"). A file not found there but found from this server\'s '
|
|
1470
|
+
+ 'working directory is refused with the exact path to pass.'),
|
|
913
1471
|
role: z.enum(['dev', 'test', 'sealed']).describe('The set\'s role.'),
|
|
914
|
-
source_field: z.string().optional().describe('Source field (default source).'),
|
|
915
|
-
target_field: z.string().optional().describe('Target/reference field.'),
|
|
1472
|
+
source_field: z.string().optional().describe('Source field (default source; .jsonl only).'),
|
|
1473
|
+
target_field: z.string().optional().describe('Target/reference field (.jsonl only).'),
|
|
1474
|
+
allow_rotate: z.boolean().optional()
|
|
1475
|
+
.describe('Replace a set already registered under `name` with other content/role — '
|
|
1476
|
+
+ 'ledgered with what it replaced and how often that content was read.'),
|
|
916
1477
|
workspace: forgeWs,
|
|
1478
|
+
project_dir: forgeProject,
|
|
917
1479
|
},
|
|
918
|
-
async ({ name, path, role, source_field, target_field, workspace }) =>
|
|
1480
|
+
async ({ name, path, role, source_field, target_field, allow_rotate, workspace, project_dir }) => {
|
|
1481
|
+
// Round 12: a path written from the agent's directory, read inside
|
|
1482
|
+
// project_dir — say the exact path to pass (forge names its own miss)
|
|
1483
|
+
const pathError = registerEvalPathError({ path, projectDir: project_dir });
|
|
1484
|
+
if (pathError) return argError(pathError);
|
|
1485
|
+
return forgeTool(
|
|
919
1486
|
['registry', 'add', name, path, '--role', role,
|
|
920
1487
|
...(source_field ? ['--source-field', source_field] : []),
|
|
921
|
-
...(target_field ? ['--target-field', target_field] : [])
|
|
922
|
-
|
|
923
|
-
|
|
1488
|
+
...(target_field ? ['--target-field', target_field] : []),
|
|
1489
|
+
...(allow_rotate ? ['--allow-rotate'] : [])],
|
|
1490
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1491
|
+
nextHint: role === 'dev'
|
|
1492
|
+
? 'forge_status.'
|
|
1493
|
+
// Round 9: a benchmark before the prereg makes forge refuse it
|
|
1494
|
+
: 'the predictions come NEXT, before any run_benchmark (or mt-eval run) on this file: '
|
|
1495
|
+
+ 'screen the training corpus first (forge_leak_audit — its reads are audit reads, never '
|
|
1496
|
+
+ 'scoring ones; its verdict says one model or two), then forge_prereg_template → '
|
|
1497
|
+
+ 'forge_prereg with the user, one per model. A benchmark is a scoring read, and forge '
|
|
1498
|
+
+ 'refuses a preregistration written after one. Then forge_status.' });
|
|
1499
|
+
},
|
|
1500
|
+
);
|
|
1501
|
+
|
|
1502
|
+
server.tool(
|
|
1503
|
+
'forge_prereg_template',
|
|
1504
|
+
'The ONE valid predictions-file format for forge_prereg, and a template to '
|
|
1505
|
+
+ 'edit. With `out`, writes the template there (refuses to overwrite '
|
|
1506
|
+
+ 'without force); without it, only returns the format and template. The '
|
|
1507
|
+
+ 'template\'s REPLACE placeholders are refused by forge_prereg until they '
|
|
1508
|
+
+ 'are replaced by real expectations — written with the user BEFORE any test '
|
|
1509
|
+
+ 'score exists: a structured prediction (metric, direction, baseline_score, '
|
|
1510
|
+
+ 'margin, rationale — auto-verdicted) or a free-text one (metric, expect, '
|
|
1511
|
+
+ 'rationale — verdicted by a human).',
|
|
1512
|
+
{
|
|
1513
|
+
out: z.string().optional().describe('Write the template here (e.g. predictions.json).'),
|
|
1514
|
+
force: z.boolean().optional().describe('Overwrite an existing `out`.'),
|
|
1515
|
+
project_dir: forgeProject,
|
|
1516
|
+
},
|
|
1517
|
+
async ({ out, force, project_dir }) => forgeTool(
|
|
1518
|
+
['prereg', 'template', ...(out ? ['--out', out] : []), ...(force ? ['--force'] : [])],
|
|
1519
|
+
{ projectDir: project_dir,
|
|
1520
|
+
nextHint: (r) => `edit ${r?.path ? JSON.stringify(r.path) : 'a copy of result.template'}: `
|
|
1521
|
+
+ 'replace every REPLACE placeholder with what you and the user expect, then '
|
|
1522
|
+
+ 'forge_prereg { id, eval_set: <the test set>, predictions: <that file> }.' }),
|
|
924
1523
|
);
|
|
925
1524
|
|
|
926
1525
|
server.tool(
|
|
@@ -928,130 +1527,498 @@ export async function createServer() {
|
|
|
928
1527
|
'Preregister falsifiable predictions for a test/sealed set BEFORE '
|
|
929
1528
|
+ 'scoring it. Scoring a test set is refused without a prereg that '
|
|
930
1529
|
+ 'predates the first scoring read — this is what makes a result honest '
|
|
931
|
-
+ 'rather than results-first storytelling.
|
|
932
|
-
+ '
|
|
1530
|
+
+ 'rather than results-first storytelling. predictions: a .json file holding '
|
|
1531
|
+
+ 'a JSON ARRAY of prediction objects (no Markdown, no wrapper object) — get '
|
|
1532
|
+
+ 'a valid one to edit from forge_prereg_template; an invalid file is refused '
|
|
1533
|
+
+ 'with the format and the fix.',
|
|
933
1534
|
{
|
|
934
1535
|
id: z.string().describe('Preregistration id.'),
|
|
935
1536
|
eval_set: z.string().describe('The registered test/sealed set name.'),
|
|
936
1537
|
predictions: z.string()
|
|
937
|
-
.describe('Path to a JSON
|
|
1538
|
+
.describe('Path to the predictions .json (a JSON array of prediction objects).'),
|
|
938
1539
|
author: z.string().optional(),
|
|
1540
|
+
config_hash: z.string().optional()
|
|
1541
|
+
.describe('Pin this prereg to ONE run (`--config-hash`): the full config hash of the run it '
|
|
1542
|
+
+ 'predicts (a run manifest\'s config_hash). With two models on one test set, a pinned '
|
|
1543
|
+
+ 'prereg judges only its run, so export needs no `prereg` argument. Any edit to the config '
|
|
1544
|
+
+ 'changes its hash — unpinned, name the prereg after its model and pass `prereg` to '
|
|
1545
|
+
+ 'forge_export instead.'),
|
|
1546
|
+
allow_after_reads: z.boolean().optional()
|
|
1547
|
+
.describe('ONLY for predictions that were truly written down BEFORE the set\'s scored reads '
|
|
1548
|
+
+ '(e.g. on paper) — forge refuses a preregistration written after a scoring read (a '
|
|
1549
|
+
+ 'benchmark included). The override is ledgered, and every report, export, DEPLOY.md '
|
|
1550
|
+
+ 'and forge_status says the predictions came after N reads.'),
|
|
939
1551
|
workspace: forgeWs,
|
|
1552
|
+
project_dir: forgeProject,
|
|
940
1553
|
},
|
|
941
|
-
async ({ id, eval_set, predictions, author, workspace }) => forgeTool(
|
|
1554
|
+
async ({ id, eval_set, predictions, author, config_hash, allow_after_reads, workspace, project_dir }) => forgeTool(
|
|
942
1555
|
['prereg', 'new', id, '--eval-set', eval_set, '--predictions', predictions,
|
|
943
|
-
...(author ? ['--author', author] : [])
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1556
|
+
...(author ? ['--author', author] : []),
|
|
1557
|
+
...(config_hash ? ['--config-hash', config_hash] : []),
|
|
1558
|
+
...(allow_after_reads ? ['--allow-after-reads'] : [])],
|
|
1559
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1560
|
+
summarize: (r) => {
|
|
1561
|
+
const sum = {
|
|
1562
|
+
...(r?.after_reads ? { after_reads: r.after_reads.text } : {}),
|
|
1563
|
+
// Round 10: which model this prediction judges, said when it
|
|
1564
|
+
// is written — not after training
|
|
1565
|
+
...(r?.export_with ? { export_with: r.export_with } : {}),
|
|
1566
|
+
...(Array.isArray(r?.binding_preregs) && r.binding_preregs.length > 1
|
|
1567
|
+
? { binding_preregs: r.binding_preregs } : {}),
|
|
1568
|
+
};
|
|
1569
|
+
return Object.keys(sum).length ? sum : null;
|
|
1570
|
+
},
|
|
1571
|
+
nextHint: (r) => {
|
|
1572
|
+
const model = r?.model_note ? `${r.model_note}. ` : '';
|
|
1573
|
+
return r?.after_reads
|
|
1574
|
+
? `recorded under the override: ${r.after_reads.text}. Tell the user — every verdict on it `
|
|
1575
|
+
+ `will say so. ${model}Then forge_status.`
|
|
1576
|
+
: `${model}One preregistration per planned model (all data, and twin-free after a SEVERE `
|
|
1577
|
+
+ 'leak-audit), each before any benchmark. Then forge_status — next is the baselines '
|
|
1578
|
+
+ '(run_benchmark on the test file) once every planned model has its prediction, or the '
|
|
1579
|
+
+ 'split / training.';
|
|
1580
|
+
} }),
|
|
1581
|
+
);
|
|
1582
|
+
|
|
1583
|
+
server.tool(
|
|
1584
|
+
'forge_prereg_verdict',
|
|
1585
|
+
'Record a PERSON\'s verdict on a preregistered prediction forge cannot verdict itself (a '
|
|
1586
|
+
+ 'free-text "expect", or a structured one without baseline_score) — once the test score '
|
|
1587
|
+
+ 'exists (forge_export judged against that prereg). Ledgered with who (by), when and a note; '
|
|
1588
|
+
+ '`nmt-forge prereg check`, forge_report, forge_status and DEPLOY.md show it as a HUMAN '
|
|
1589
|
+
+ 'verdict, never as a computed one. The verdict is the USER\'s: show them the prediction and '
|
|
1590
|
+
+ 'the observed score (forge_export\'s prereg lines), ask held or missed, and record exactly '
|
|
1591
|
+
+ 'what they decide — never judge it yourself. Refused for a computed prediction, before any '
|
|
1592
|
+
+ 'scored result, and (without revise) when one is already recorded.',
|
|
1593
|
+
{
|
|
1594
|
+
id: z.string().describe('Preregistration id.'),
|
|
1595
|
+
prediction: z.union([z.number().int().positive(), z.string()])
|
|
1596
|
+
.describe('The prediction\'s number as the prereg verdict lines print it (#1 → 1), or its own "id".'),
|
|
1597
|
+
verdict: z.enum(['held', 'missed']).describe('What the USER decided.'),
|
|
1598
|
+
by: z.string().min(1).describe('Who judged — the person\'s name or role (recorded).'),
|
|
1599
|
+
note: z.string().optional().describe('Why (e.g. "predicted 15-60, observed 100.00").'),
|
|
1600
|
+
revise: z.boolean().optional()
|
|
1601
|
+
.describe('Replace an earlier verdict on this prediction (both stay in the ledger).'),
|
|
1602
|
+
workspace: forgeWs,
|
|
1603
|
+
project_dir: forgeProject,
|
|
1604
|
+
},
|
|
1605
|
+
async ({ id, prediction, verdict, by, note, revise, workspace, project_dir }) => forgeTool(
|
|
1606
|
+
['prereg', 'verdict', id, '--prediction', String(prediction),
|
|
1607
|
+
verdict === 'held' ? '--held' : '--missed', '--by', by,
|
|
1608
|
+
...(note ? ['--note', note] : []), ...(revise ? ['--revise'] : [])],
|
|
1609
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1610
|
+
summarize: (r) => (r?.verdict ? {
|
|
1611
|
+
prediction: r.prediction, verdict: r.verdict, by: r.by, deploy_updated: r.deploy_updated,
|
|
1612
|
+
} : null),
|
|
1613
|
+
nextHint: 'tell the user the verdict is recorded as theirs (who, when, note) — DEPLOY.md and '
|
|
1614
|
+
+ 'forge_report now show it as a human verdict; `nmt-forge prereg check <id>` lists every '
|
|
1615
|
+
+ 'prediction with its verdict.' }),
|
|
947
1616
|
);
|
|
948
1617
|
|
|
949
1618
|
server.tool(
|
|
950
1619
|
'forge_evaluate',
|
|
951
|
-
'
|
|
952
|
-
+ '
|
|
953
|
-
+ '
|
|
954
|
-
+ '
|
|
955
|
-
+ 'it
|
|
1620
|
+
'SCORE ONLY, after a training run: decode the config\'s battery with the '
|
|
1621
|
+
+ 'run\'s SELECTED checkpoint, score it (CIs, prereg-gated, ledgered) and '
|
|
1622
|
+
+ 'diagnose. The score-only half of forge_export — prefer forge_export, which '
|
|
1623
|
+
+ 'scores AND packages; a sealed battery is one-shot, so scoring it here '
|
|
1624
|
+
+ 'spends it and forge_export then needs no_eval. Call it on the '
|
|
1625
|
+
+ 'run-manifest.json that `nmt-forge run` wrote. harness_out also writes an '
|
|
1626
|
+
+ 'mt-eval RunLog + TestReport (they contain the eval set\'s text). Bounded '
|
|
1627
|
+
+ 'at 10 minutes; past that it names the terminal command.',
|
|
956
1628
|
{
|
|
957
1629
|
run_manifest: z.string().describe('Path to run-manifest.json.'),
|
|
958
1630
|
config: z.string().optional()
|
|
959
1631
|
.describe('Config path (defaults to the config embedded in the '
|
|
960
1632
|
+ 'manifest); must carry an eval block.'),
|
|
961
1633
|
out_hyps: z.string().optional().describe('Where to write decoded hyps.'),
|
|
1634
|
+
harness_out: z.string().optional()
|
|
1635
|
+
.describe('Also write an mt-eval RunLog + TestReport into this directory.'),
|
|
1636
|
+
glossary: z.string().optional()
|
|
1637
|
+
.describe('Evaluation glossary (JSON) for the terminology score — scoring only.'),
|
|
1638
|
+
prereg: z.string().optional()
|
|
1639
|
+
.describe(FORGE_PREREG_ARG),
|
|
962
1640
|
workspace: forgeWs,
|
|
1641
|
+
project_dir: forgeProject,
|
|
963
1642
|
},
|
|
964
|
-
async ({ run_manifest, config, out_hyps, workspace }) => forgeTool(
|
|
1643
|
+
async ({ run_manifest, config, out_hyps, harness_out, glossary, prereg, workspace, project_dir }) => forgeTool(
|
|
965
1644
|
['evaluate', run_manifest, ...(config ? ['--config', config] : []),
|
|
966
|
-
...(out_hyps ? ['--out-hyps', out_hyps] : [])
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1645
|
+
...(out_hyps ? ['--out-hyps', out_hyps] : []),
|
|
1646
|
+
...(harness_out ? ['--harness-out', harness_out] : []),
|
|
1647
|
+
...(glossary ? ['--glossary', glossary] : []),
|
|
1648
|
+
...(prereg ? ['--prereg', prereg] : [])],
|
|
1649
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1650
|
+
timeout: FORGE_SCORING_TIMEOUT_MS,
|
|
1651
|
+
nextHint: 'read result.battery (per-group scores with CIs); forge_report on '
|
|
1652
|
+
+ 'result.paths.manifest renders the Diagnosis, forge_lint on it names the '
|
|
1653
|
+
+ 'lever to pull next.' }),
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
server.tool(
|
|
1657
|
+
'forge_export',
|
|
1658
|
+
'PROVE AND PACKAGE a trained run in one step: decode the test battery with '
|
|
1659
|
+
+ 'the dev-selected checkpoint and score it ONCE (prereg-gated, ledgered, '
|
|
1660
|
+
+ 'CIs), write an mt-eval RunLog + TestReport, and package a self-contained '
|
|
1661
|
+
+ 'model (no optimizer state, tokenizer included), forge-model.json, a '
|
|
1662
|
+
+ 'champollion method.json and DEPLOY.md into `out`/model (the only folder to '
|
|
1663
|
+
+ 'deploy — no test sentence) and the evaluation into `out`/evaluation (never '
|
|
1664
|
+
+ 'copied with the model). All-or-nothing: a failed '
|
|
1665
|
+
+ 'export leaves no half-written directory. A sealed set is one-shot — '
|
|
1666
|
+
+ 'evaluating it here spends it; no_eval packages without scoring. Bounded '
|
|
1667
|
+
+ 'at 10 minutes. Then serve it: `nmt-forge serve <out>/model` in a TERMINAL (a '
|
|
1668
|
+
+ 'long-running HTTP server, not a tool), and use it from the champollion '
|
|
1669
|
+
+ 'CLI or translate.',
|
|
1670
|
+
{
|
|
1671
|
+
run_manifest: z.string().describe('Path to the run-manifest.json `nmt-forge run` wrote.'),
|
|
1672
|
+
out: z.string().describe('Export directory (new or empty).'),
|
|
1673
|
+
config: z.string().optional()
|
|
1674
|
+
.describe('Run config (defaults to the config embedded in the manifest).'),
|
|
1675
|
+
no_eval: z.boolean().optional()
|
|
1676
|
+
.describe('Package without scoring the test battery (e.g. a sealed set already spent).'),
|
|
1677
|
+
no_model: z.boolean().optional()
|
|
1678
|
+
.describe('Write the evaluation + mt-eval report only (no model to serve).'),
|
|
1679
|
+
glossary: z.string().optional()
|
|
1680
|
+
.describe('Evaluation glossary (JSON {"term": "translation"}) for the terminology '
|
|
1681
|
+
+ 'score — scoring only, the model never sees it.'),
|
|
1682
|
+
endpoint: z.string().optional()
|
|
1683
|
+
.describe('URL the champollion method.json points at (default http://127.0.0.1:<port>/translate).'),
|
|
1684
|
+
port: z.number().int().positive().optional().describe('Serve port recorded in the export (default 8378).'),
|
|
1685
|
+
name: z.string().optional().describe('Plugin/model name, kebab-case (default nmt-forge-<run>).'),
|
|
1686
|
+
force: z.boolean().optional().describe('Replace a non-empty `out` directory.'),
|
|
1687
|
+
prereg: z.string().optional()
|
|
1688
|
+
.describe(FORGE_PREREG_ARG),
|
|
1689
|
+
workspace: forgeWs,
|
|
1690
|
+
project_dir: forgeProject,
|
|
1691
|
+
},
|
|
1692
|
+
async ({ run_manifest, out, config, no_eval, no_model, glossary, endpoint, port, name, force,
|
|
1693
|
+
prereg, workspace, project_dir }) => forgeTool(
|
|
1694
|
+
['export', run_manifest, '--out', out, ...(config ? ['--config', config] : []),
|
|
1695
|
+
...(no_eval ? ['--no-eval'] : []), ...(no_model ? ['--no-model'] : []),
|
|
1696
|
+
...(glossary ? ['--glossary', glossary] : []),
|
|
1697
|
+
...(endpoint ? ['--endpoint', endpoint] : []), ...(port ? ['--port', String(port)] : []),
|
|
1698
|
+
...(name ? ['--name', name] : []), ...(force ? ['--force'] : []),
|
|
1699
|
+
...(prereg ? ['--prereg', prereg] : [])],
|
|
1700
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1701
|
+
timeout: FORGE_SCORING_TIMEOUT_MS,
|
|
1702
|
+
// a summary like every forge tool's (Round 12: it was null here)
|
|
1703
|
+
summarize: exportSummary,
|
|
1704
|
+
nextHint: (r) => exportNextHint(r, SERVE_IS_A_TERMINAL_STEP) }),
|
|
970
1705
|
);
|
|
971
1706
|
|
|
972
1707
|
server.tool(
|
|
973
1708
|
'forge_lint',
|
|
974
|
-
'Diagnose a
|
|
975
|
-
+ 'cause given the co-occurring signals, and the exact LEVER to pull next '
|
|
1709
|
+
'Diagnose a BATTERY manifest (a scored test read): which registers are weak, the '
|
|
1710
|
+
+ 'likeliest cause given the co-occurring signals, and the exact LEVER to pull next '
|
|
976
1711
|
+ '(VOCABULARY / STRUCTURE / ORTHOGRAPHY / REAL-DATA / MEASUREMENT / '
|
|
977
1712
|
+ 'REFEREE). Returns rule-id\'d findings with evidence — recommendations '
|
|
978
|
-
+ 'are explainable, not vibes
|
|
979
|
-
+ '
|
|
1713
|
+
+ 'are explainable, not vibes; every caveat mt-eval wrote on the score is an '
|
|
1714
|
+
+ 'R9 finding beside the headline (corpus chrF++ with its 95% CI). `manifest` is '
|
|
1715
|
+
+ 'the battery manifest: an export\'s is `<export>/evaluation/battery-hyps-battery.json`, '
|
|
1716
|
+
+ 'forge_evaluate\'s is result.paths.manifest. A run-manifest.json passed as `manifest` '
|
|
1717
|
+
+ 'lints the battery of every scored export of that run, and is refused with the exact '
|
|
1718
|
+
+ '`nmt-forge export` command when the run has none. `run_manifest` is optional and only '
|
|
1719
|
+
+ 'adds the transfer-plateau signal.',
|
|
980
1720
|
{
|
|
981
|
-
manifest: z.string().describe('Battery manifest JSON path
|
|
1721
|
+
manifest: z.string().describe('Battery manifest JSON path '
|
|
1722
|
+
+ '(<export>/evaluation/battery-hyps-battery.json); a run-manifest.json lints its '
|
|
1723
|
+
+ 'scored exports.'),
|
|
982
1724
|
run_manifest: z.string().optional()
|
|
983
|
-
.describe('Run manifest for schedule/transfer-plateau signals.'),
|
|
1725
|
+
.describe('Run manifest for schedule/transfer-plateau signals (optional).'),
|
|
984
1726
|
workspace: forgeWs,
|
|
1727
|
+
project_dir: forgeProject,
|
|
985
1728
|
},
|
|
986
|
-
async ({ manifest, run_manifest, workspace }) => forgeTool(
|
|
987
|
-
['lint', manifest, '--
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1729
|
+
async ({ manifest, run_manifest, workspace, project_dir }) => forgeTool(
|
|
1730
|
+
['lint', manifest, ...(run_manifest ? ['--run-manifest', run_manifest] : [])],
|
|
1731
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1732
|
+
// R9-harness-score-caveat (mt-eval's caveat on the scores) relayed in its words (Round 13)
|
|
1733
|
+
summarize: lintSummary,
|
|
1734
|
+
nextHint: lintNextHint }),
|
|
991
1735
|
);
|
|
992
1736
|
|
|
993
1737
|
server.tool(
|
|
994
1738
|
'forge_report',
|
|
995
1739
|
'Re-render the plain-language report (with the Diagnosis section) from a '
|
|
996
1740
|
+ 'run manifest or a battery manifest. Read-only pretty-printer for a '
|
|
997
|
-
+ 'result you already produced.'
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1741
|
+
+ 'result you already produced: result.markdown is the report. For a run manifest it '
|
|
1742
|
+
+ 'includes a saturated-dev warning and, once the run is exported, its TEST result: score '
|
|
1743
|
+
+ 'and CI, the twin-free strict subset, the near-twin caveat, the twin-free model to quote, '
|
|
1744
|
+
+ 'and the prereg verdicts (computed and human apart).',
|
|
1745
|
+
{
|
|
1746
|
+
manifest: z.string().describe('Run or battery manifest path.'),
|
|
1747
|
+
workspace: forgeWs,
|
|
1748
|
+
project_dir: forgeProject,
|
|
1749
|
+
},
|
|
1750
|
+
async ({ manifest, workspace, project_dir }) => forgeTool(['report', manifest],
|
|
1751
|
+
forgeAt(workspace, project_dir)),
|
|
1752
|
+
);
|
|
1753
|
+
|
|
1754
|
+
server.tool(
|
|
1755
|
+
'forge_compare',
|
|
1756
|
+
'A/B two systems\' hypotheses on a registered eval set (`nmt-forge compare`): paired '
|
|
1757
|
+
+ 'approximate randomization per metric — Δ with its CI, p, and the winner only when '
|
|
1758
|
+
+ 'significant. Prereg-gated and ledgered like every test read (a sealed set is spent by '
|
|
1759
|
+
+ 'it). result.caveats travel WITH the result: for each system, how many test rows have a '
|
|
1760
|
+
+ 'near-twin in its training data (from run_a / run_b — that model\'s run manifest — or the '
|
|
1761
|
+
+ 'export whose evaluation wrote the hypotheses). A winner whose rows are mostly twinned won '
|
|
1762
|
+
+ 'on recall of training phrases, not translation, and the caveats say so — relay them with '
|
|
1763
|
+
+ 'the winner, never the winner alone. Hypotheses: a .jsonl with a uniform "predicted" (or '
|
|
1764
|
+
+ '"hypothesis") field — e.g. <export>/evaluation/battery-hyps.jsonl — or plain text, one '
|
|
1765
|
+
+ 'line per test row. Bounded at 10 minutes.',
|
|
1766
|
+
{
|
|
1767
|
+
eval_set: z.string().describe('The registered eval set both systems were decoded on.'),
|
|
1768
|
+
hyps_a: z.string().describe('System A\'s hypotheses (one per test row, in the set\'s order).'),
|
|
1769
|
+
hyps_b: z.string().describe('System B\'s hypotheses.'),
|
|
1770
|
+
label_a: z.string().optional().describe('Name for system A in the result (default "A").'),
|
|
1771
|
+
label_b: z.string().optional().describe('Name for system B (default "B").'),
|
|
1772
|
+
run_a: z.string().optional()
|
|
1773
|
+
.describe('System A\'s run-manifest.json: its training files are checked for near-twins of '
|
|
1774
|
+
+ 'the eval set (the measure export reports). Not needed for hypotheses an export wrote.'),
|
|
1775
|
+
run_b: z.string().optional().describe('System B\'s run-manifest.json (see run_a).'),
|
|
1776
|
+
metric: z.array(z.string()).optional()
|
|
1777
|
+
.describe('Lanes to compare (default ["chrf++"]): chrf++, bleu, exact_match, comet, '
|
|
1778
|
+
+ 'comet-qe, metricx.'),
|
|
1779
|
+
target_lang: z.string().optional()
|
|
1780
|
+
.describe('ISO 639-3 target code — picks the neural metric model and its low-resource warning.'),
|
|
1781
|
+
config_hash: z.string().optional()
|
|
1782
|
+
.describe('The run config hash the read is recorded under (prereg binding).'),
|
|
1783
|
+
prereg: z.string().optional().describe(FORGE_PREREG_ARG),
|
|
1784
|
+
override_respend: z.string().optional()
|
|
1785
|
+
.describe('Re-spend a SEALED set that was already scored, with the reason (ledgered and '
|
|
1786
|
+
+ 'visible forever). The USER\'s decision, never yours — a sealed set answers one '
|
|
1787
|
+
+ 'question once.'),
|
|
1788
|
+
workspace: forgeWs,
|
|
1789
|
+
project_dir: forgeProject,
|
|
1790
|
+
},
|
|
1791
|
+
async ({ eval_set, hyps_a, hyps_b, label_a, label_b, run_a, run_b, metric, target_lang,
|
|
1792
|
+
config_hash, prereg, override_respend, workspace, project_dir }) => forgeTool(
|
|
1793
|
+
['compare', '--eval-set', eval_set, '--hyps-a', hyps_a, '--hyps-b', hyps_b,
|
|
1794
|
+
...(label_a ? ['--label-a', label_a] : []), ...(label_b ? ['--label-b', label_b] : []),
|
|
1795
|
+
...(run_a ? ['--run-a', run_a] : []), ...(run_b ? ['--run-b', run_b] : []),
|
|
1796
|
+
...(metric || []).flatMap((m) => ['--metric', m]),
|
|
1797
|
+
...(target_lang ? ['--target-lang', target_lang] : []),
|
|
1798
|
+
...(config_hash ? ['--config-hash', config_hash] : []),
|
|
1799
|
+
...(prereg ? ['--prereg', prereg] : []),
|
|
1800
|
+
...(override_respend ? ['--override-respend', override_respend] : [])],
|
|
1801
|
+
{ ...forgeAt(workspace, project_dir),
|
|
1802
|
+
timeout: FORGE_SCORING_TIMEOUT_MS,
|
|
1803
|
+
summarize: compareSummary,
|
|
1804
|
+
nextHint: compareNextHint }),
|
|
1001
1805
|
);
|
|
1002
1806
|
|
|
1003
1807
|
// ------------------------------------------------------------------
|
|
1004
|
-
//
|
|
1808
|
+
// Tools: list_contests / get_contest — READ-ONLY. Entering a contest is a
|
|
1809
|
+
// human-authorized CLI flow (sovereign hosting); there is deliberately no
|
|
1810
|
+
// MCP tool that enters, submits, or ranks.
|
|
1005
1811
|
// ------------------------------------------------------------------
|
|
1006
1812
|
server.tool(
|
|
1813
|
+
'list_contests',
|
|
1814
|
+
'List Champollion contests (and shared-task editions) visible to an anonymous '
|
|
1815
|
+
+ 'reader — status, language pair, lane, ranking metric, whether results are '
|
|
1816
|
+
+ 'hidden until close. Read-only. Entering a contest is a human-authorized CLI '
|
|
1817
|
+
+ 'flow (`mt-eval contest qualify` → submit-model/submit-method), never a tool.',
|
|
1818
|
+
{
|
|
1819
|
+
status: z.enum(['open', 'closed', 'archived', 'all']).default('all')
|
|
1820
|
+
.describe('Filter by status (default all).'),
|
|
1821
|
+
language: z.string().optional()
|
|
1822
|
+
.describe('ISO 639-3 code on either side of the contest\'s pair (e.g. "crk").'),
|
|
1823
|
+
limit: z.number().int().min(1).max(100).default(20)
|
|
1824
|
+
.describe('Maximum contests to list (default 20).'),
|
|
1825
|
+
},
|
|
1826
|
+
async ({ status, language, limit }) => {
|
|
1827
|
+
try {
|
|
1828
|
+
const r = await listContests({ status, language, limit });
|
|
1829
|
+
return { content: [{ type: 'text', text: formatContestList(r) }] };
|
|
1830
|
+
} catch (err) {
|
|
1831
|
+
return { content: [{ type: 'text', text: `Could not list contests: ${err.message}` }], isError: true };
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
server.tool(
|
|
1837
|
+
'get_contest',
|
|
1838
|
+
'One contest in full: phases (with the active window), the DECLARED TERMS the '
|
|
1839
|
+
+ 'organizer promised (frozen once entries exist) with a digest to detect '
|
|
1840
|
+
+ 'changes, results visibility, and the public ranking when one is visible — the '
|
|
1841
|
+
+ 'frozen final ranking after close, or interim published scores when results '
|
|
1842
|
+
+ 'are immediate. Says plainly what an anonymous reader cannot see. Read-only.',
|
|
1843
|
+
{
|
|
1844
|
+
id: z.string().describe('Contest id (slug) from list_contests.'),
|
|
1845
|
+
},
|
|
1846
|
+
async ({ id }) => {
|
|
1847
|
+
try {
|
|
1848
|
+
const r = await getContest(id);
|
|
1849
|
+
return { content: [{ type: 'text', text: formatContest(r) }], isError: r.status !== 'ok' };
|
|
1850
|
+
} catch (err) {
|
|
1851
|
+
return { content: [{ type: 'text', text: `Could not read contest "${id}": ${err.message}` }], isError: true };
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
// ------------------------------------------------------------------
|
|
1857
|
+
// Tool: run_benchmark
|
|
1858
|
+
// ------------------------------------------------------------------
|
|
1859
|
+
// STRICT schema, like translate: an unknown argument (an `endpoint`, an
|
|
1860
|
+
// `output_dir`, a misspelled flag) is refused by name — never stripped
|
|
1861
|
+
// while the run goes somewhere the agent did not ask for.
|
|
1862
|
+
server.registerTool(
|
|
1007
1863
|
'run_benchmark',
|
|
1008
|
-
'Start one or more benchmark items from the queue using mt-eval. '
|
|
1009
|
-
+ 'This executes the mt-eval harness on the user\'s machine and SPENDS '
|
|
1010
|
-
+ 'real API tokens. Requires mt-eval to be installed and an API key set. '
|
|
1011
|
-
+ 'Spending is gated: you MUST confirm with the user first and then pass '
|
|
1012
|
-
+ 'confirm:true. Without confirm:true (or with dry_run:true) the tool '
|
|
1013
|
-
+ 'returns a plan and spends nothing. '
|
|
1014
|
-
+ 'A confirmed run does NOT block: the benchmark is launched in the '
|
|
1015
|
-
+ 'background and this tool returns a JOB ID immediately (a real run takes '
|
|
1016
|
-
+ 'minutes — far longer than a default 60s MCP client timeout). After it '
|
|
1017
|
-
+ 'returns, poll get_run_status with that job id until the job reports '
|
|
1018
|
-
+ 'COMPLETED or FAILED, then call get_results. '
|
|
1019
|
-
+ 'Budget/top runs execute in deterministic top-of-queue order — the same '
|
|
1020
|
-
+ 'order estimate_cost and list_queue preview — and auto-publish each result '
|
|
1021
|
-
+ 'to the public leaderboard unless publish:false is passed. '
|
|
1022
|
-
+ 'SCOPE IS MANDATORY: every real run must name exactly one bound — budget '
|
|
1023
|
-
+ '(USD ceiling), top (item count), or item_id (one item). A call with none '
|
|
1024
|
-
+ 'of them is REFUSED rather than run over the whole queue.',
|
|
1025
1864
|
{
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
.
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1865
|
+
description: 'Run a benchmark with the mt-eval harness on this machine. One mode: '
|
|
1866
|
+
+ 'budget/top = top queue items; item_id = one queue item; corpus = ANY corpus (a '
|
|
1867
|
+
+ 'list_corpora id, or the user\'s own test file) on any model, including '
|
|
1868
|
+
+ 'one here (provider "local"; method "local-model" + a Hugging Face id, in-process, no '
|
|
1869
|
+
+ 'attestation) or a plugin directory (method_dir). An MT engine or a '
|
|
1870
|
+
+ 'plugin is local only on the USER\'s attest_local_transport. Without confirm:true (or '
|
|
1871
|
+
+ 'with dry_run) it returns the plan — the licence and do_not_train terms the run accepts, '
|
|
1872
|
+
+ 'the EVAL PACK status (a missing FST never stops it; other missing pieces do), the '
|
|
1873
|
+
+ 'metrics and where results land — and runs nothing; CONFIRM WITH THE USER FIRST (remote providers '
|
|
1874
|
+
+ 'spend real tokens). NOTHING IS PUBLISHED unless publish:true: the plan lists what goes '
|
|
1875
|
+
+ 'public; a real publish needs publish_ack in its exact words (a finished report: '
|
|
1876
|
+
+ 'preview_publish, then publish_report). A confirmed run returns a JOB ID — poll '
|
|
1877
|
+
+ 'get_run_status. Refusals (local-only marks, licences) come back as REFUSED '
|
|
1878
|
+
+ 'with what is allowed: relay them, never route around them.',
|
|
1879
|
+
inputSchema: z.object({
|
|
1880
|
+
budget: z.number().positive().optional()
|
|
1881
|
+
.describe('Queue mode: run the top queue items up to this USD budget.'),
|
|
1882
|
+
top: z.number().int().positive().optional()
|
|
1883
|
+
.describe('Queue mode: run the top N queue items.'),
|
|
1884
|
+
item_id: z.string().optional()
|
|
1885
|
+
.describe('Item mode: run one queue item by id.'),
|
|
1886
|
+
corpus: z.string().optional()
|
|
1887
|
+
.describe('Corpus mode: a registry corpus id (see list_corpora) or the path to a '
|
|
1888
|
+
+ '.json/.jsonl/.tsv test file the user holds (fields "source"/"reference" by default).'),
|
|
1889
|
+
model: z.string().optional()
|
|
1890
|
+
.describe('Corpus mode: the model — an OpenRouter slug ("openai/gpt-5.5"), a '
|
|
1891
|
+
+ 'direct-provider name, a local server\'s model ("llama3.1", "qwen2.5:7b"), '
|
|
1892
|
+
+ 'with method "local-model" a Hugging Face id or local model directory, or with '
|
|
1893
|
+
+ 'method_dir the model the PLUGIN loads, in its own naming (passed as -m; recorded on '
|
|
1894
|
+
+ 'the run card). Required for provider "local" and for method "local-model" (it has no '
|
|
1895
|
+
+ 'default model: the harness refuses a run without one).'),
|
|
1896
|
+
method: z.string().optional()
|
|
1897
|
+
.describe('Corpus mode: a self-contained MT method instead of an LLM — '
|
|
1898
|
+
+ '"local-model" (NLLB/OPUS-MT/MADLAD on this machine) or an MT API name '
|
|
1899
|
+
+ '(google-translate, deepl, apertium, …) from the method registry. An MT API '
|
|
1900
|
+
+ 'engine sends the text to its service (apertium: the public apertium.org '
|
|
1901
|
+
+ 'unless APERTIUM_API_URL points elsewhere).'),
|
|
1902
|
+
method_dir: z.string().optional()
|
|
1903
|
+
.describe('Corpus mode: an mt-eval method plugin DIRECTORY the user holds (method.json '
|
|
1904
|
+
+ 'with name + entry_point "module:ClassName", and that Python module), run as '
|
|
1905
|
+
+ '`mt-eval run --method <dir>` (with model: `-m <model>`, handed to the plugin). Not with '
|
|
1906
|
+
+ 'method, provider or coaching_file. '
|
|
1907
|
+
+ '(A champollion plugin manifest, e.g. an nmt-forge export\'s, is not one: use forge_export.)'),
|
|
1908
|
+
allow_model_pair_mismatch: z.boolean().optional()
|
|
1909
|
+
.describe('With method "local-model" (`--allow-model-pair-mismatch`): run an OPUS-MT pair model '
|
|
1910
|
+
+ 'whose id names another language pair than the corpus (opus-mt-en-fi on eng>sme, as a '
|
|
1911
|
+
+ 'related-language baseline). The harness refuses that without it, because a pair model '
|
|
1912
|
+
+ 'writes its own target language; the run card records the mismatch.'),
|
|
1913
|
+
attest_local_transport: z.boolean().default(false)
|
|
1914
|
+
.describe('Corpus mode, an MT engine (method) or a method_dir plugin: the USER attests its '
|
|
1915
|
+
+ 'transport is fully local (nothing leaves this machine) — recorded in the RunLog. '
|
|
1916
|
+
+ 'Required for an engine or plugin on a local-only corpus. Ask them; never assume. NOT '
|
|
1917
|
+
+ 'for method "local-model": the harness runs it in this process, so it needs no '
|
|
1918
|
+
+ 'attestation (passing one is refused). Refused for an engine whose configured '
|
|
1919
|
+
+ 'endpoint is not this machine.'),
|
|
1920
|
+
provider: z.enum(RUN_PROVIDERS).optional()
|
|
1921
|
+
.describe('LLM provider: openrouter (default), openai, anthropic, gemini, or '
|
|
1922
|
+
+ 'local (an OpenAI-compatible server on this machine — Ollama by default; '
|
|
1923
|
+
+ 'corpus mode only). Not with method / method_dir.'),
|
|
1924
|
+
base_url: z.string().optional()
|
|
1925
|
+
.describe('Corpus mode, provider local/openai: the endpoint (else LOCAL_API_BASE / '
|
|
1926
|
+
+ 'OPENAI_API_BASE / the Ollama default). A non-loopback URL is NOT local.'),
|
|
1927
|
+
target_language: z.string().optional()
|
|
1928
|
+
.describe('Corpus mode: the target language NAME used in the prompt (e.g. "Plains Cree"), or its '
|
|
1929
|
+
+ 'code: a code ("sme") is named from its language card before it reaches the prompt (passed as '
|
|
1930
|
+
+ '--target-lang "Northern Sami" --target-lang-code sme; the plan shows the prompt). A code no '
|
|
1931
|
+
+ 'card names (a private-use "qaa") stays a code, and the plan says so — pass the name then. The '
|
|
1932
|
+
+ 'target CODE is passed too when the file\'s steward sidecar or corpus card states it.'),
|
|
1933
|
+
script: z.string().optional()
|
|
1934
|
+
.describe('Corpus mode, LLM runs: the ISO 15924 script the translations must be written in '
|
|
1935
|
+
+ '(e.g. "Latn", "Cans") — passed as --target-script; the harness\'s prompt asks for it. For a '
|
|
1936
|
+
+ 'language written in more than one script (Plains Cree: "Cans" syllabics or "Latn" SRO) ASK '
|
|
1937
|
+
+ 'the user which script the test set\'s references use — the plan says when the card lists '
|
|
1938
|
+
+ 'several and none was given. Not for method / method_dir (they get no prompt).'),
|
|
1939
|
+
source_language: z.string().optional()
|
|
1940
|
+
.describe('Corpus mode: the source language NAME (e.g. "English") — for the prompt and the run '
|
|
1941
|
+
+ 'card. When the file\'s steward sidecar or the corpus card it names states the pair, '
|
|
1942
|
+
+ 'that source code is passed too and the harness names the language itself (the plan says '
|
|
1943
|
+
+ 'which); without either, the run card leaves the source language blank.'),
|
|
1944
|
+
source_field: z.string().optional()
|
|
1945
|
+
.describe('Corpus mode: source field name in your file (default "source").'),
|
|
1946
|
+
target_field: z.string().optional()
|
|
1947
|
+
.describe('Corpus mode: reference field name in your file (default "reference").'),
|
|
1948
|
+
max_cost: z.number().positive().optional()
|
|
1949
|
+
.describe('Corpus mode: abort before translating if the estimated cost exceeds this '
|
|
1950
|
+
+ 'USD cap (an unknown estimate also aborts — omit it for local runs).'),
|
|
1951
|
+
coaching_file: z.string().optional()
|
|
1952
|
+
.describe('Corpus mode, LLM runs: a coaching file the user holds (Markdown, text or '
|
|
1953
|
+
+ 'JSON) — its full text is sent to the model as its instructions; the run is '
|
|
1954
|
+
+ 'labelled "coached". Never a protected test file.'),
|
|
1955
|
+
glossary: z.string().optional()
|
|
1956
|
+
.describe('Corpus mode: an evaluation glossary (JSON {"term": "translation"}) for the '
|
|
1957
|
+
+ 'terminology score. Used only for scoring, never sent to the model; give every '
|
|
1958
|
+
+ 'run you compare the same file.'),
|
|
1959
|
+
attest_no_training: z.boolean().default(false)
|
|
1960
|
+
.describe('Corpus mode: the USER attests the model was not trained on this corpus. '
|
|
1961
|
+
+ 'Ask them; never assume.'),
|
|
1962
|
+
accept_nc_terms: z.boolean().default(false)
|
|
1963
|
+
.describe('Corpus mode: the USER accepts a non-commercial corpus\'s terms. Only '
|
|
1964
|
+
+ 'after they agree.'),
|
|
1965
|
+
skip_fst: z.boolean().default(false)
|
|
1966
|
+
.describe('Item/corpus mode: score WITHOUT FST acceptance (`--skip-fst`), even when the target '
|
|
1967
|
+
+ 'language has an FST — the run card marks it not computed. For when the plan says the '
|
|
1968
|
+
+ 'FST or its runtime is missing and the user chooses to score without it. Not for queue runs.'),
|
|
1969
|
+
skip_eval_standard: z.boolean().default(false)
|
|
1970
|
+
.describe('Item/corpus mode: score WITHOUT the language card\'s eval-standard metrics '
|
|
1971
|
+
+ '(`--skip-eval-standard`; an external package, e.g. the Plains Cree standard) — the run '
|
|
1972
|
+
+ 'card marks them not computed. Not for queue runs.'),
|
|
1973
|
+
comet: z.boolean().default(false)
|
|
1974
|
+
.describe('Item/corpus mode: REQUIRE COMET. The harness computes COMET on every run whose Python has '
|
|
1975
|
+
+ 'unbabel-comet (`mt-eval setup --comet`: about 300 MB, plus about 2.3 GB of model on first use) — '
|
|
1976
|
+
+ 'there is no run flag. The plan always says whether it will be computed; with comet: true a '
|
|
1977
|
+
+ 'confirmed run is REFUSED while it is not available, instead of scoring without it. Not for queue runs.'),
|
|
1978
|
+
metricx: z.boolean().default(false)
|
|
1979
|
+
.describe('Item/corpus mode: also compute MetricX-24 (`--metricx`; lower is better, 0–25, reported beside '
|
|
1980
|
+
+ 'the chrF++ headline, never blended into it; runs on this machine). Needs `python3 -m pip install \'mt-eval-harness[metricx]\'` plus '
|
|
1981
|
+
+ 'Google\'s model code (`python3 -m pip install git+https://github.com/google-research/metricx`) in the '
|
|
1982
|
+
+ 'harness\'s Python, and downloads several GB on first use. The plan says whether it is installed; a '
|
|
1983
|
+
+ 'confirmed run is REFUSED while it is not. Not for queue runs.'),
|
|
1984
|
+
metricx_model: z.string().optional()
|
|
1985
|
+
.describe('With metricx: true — another MetricX checkpoint (`--metricx-model`, e.g. an xl/xxl or a '
|
|
1986
|
+
+ 'google/metricx-25-* one); default google/metricx-24-hybrid-large-v2p6.'),
|
|
1987
|
+
fuse: z.boolean().default(false)
|
|
1988
|
+
.describe('Item/corpus mode: also compute the FUSE-style comparator (`--fuse`; untrained, reported beside '
|
|
1989
|
+
+ 'the scores, never blended into the chrF++ headline). Needs `python3 -m pip install \'mt-eval-harness[fuse]\'`; LaBSE '
|
|
1990
|
+
+ 'downloads about 1.8 GB on first use. Refused at confirm while it is not installed. Not for queue runs.'),
|
|
1991
|
+
dry_run: z.boolean().default(false)
|
|
1992
|
+
.describe('Preview without running. Item/corpus dry runs answer inline; a queue '
|
|
1993
|
+
+ 'dry run is a background job (poll get_run_status for the plan).'),
|
|
1994
|
+
confirm: z.boolean().default(false)
|
|
1995
|
+
.describe('Must be true to actually run. Confirm with the user first.'),
|
|
1996
|
+
publish: z.boolean().default(false)
|
|
1997
|
+
.describe('Publish results to the leaderboard. DEFAULT FALSE — results stay local. '
|
|
1998
|
+
+ 'True writes to the target named in the response (normally the PRODUCTION '
|
|
1999
|
+
+ 'public board) — only with the user\'s explicit consent. The plan (dry_run, or no '
|
|
2000
|
+
+ 'confirm) lists what goes public: every row WITH its sentence text or scores only, '
|
|
2001
|
+
+ 'and whether the prompt is published or redacted.'),
|
|
2002
|
+
publish_ack: z.string().optional()
|
|
2003
|
+
.describe('With publish:true and confirm:true: the user\'s acknowledgement of what goes '
|
|
2004
|
+
+ 'public, in the EXACT words the plan prints (e.g. "publish to production: scores only, '
|
|
2005
|
+
+ 'prompt published"). Missing or different → REFUSED, nothing runs. Never write it '
|
|
2006
|
+
+ 'without showing the user the plan\'s "WHAT GETS PUBLISHED" lines first.'),
|
|
2007
|
+
anonymous: z.boolean().default(false)
|
|
2008
|
+
.describe('With publish:true: publish without signing in (shown as "anonymous").'),
|
|
2009
|
+
}).strict(),
|
|
1047
2010
|
},
|
|
1048
|
-
async (
|
|
2011
|
+
async (params) => {
|
|
1049
2012
|
try {
|
|
1050
|
-
|
|
1051
|
-
|
|
2013
|
+
// A code given as target_language ("sme") is named from the card
|
|
2014
|
+
// index before it reaches the prompt (languageNameFor).
|
|
2015
|
+
const result = await runBenchmark(params, {
|
|
2016
|
+
languageName: (code) => languageNameFor(languageIndex, code),
|
|
1052
2017
|
});
|
|
2018
|
+
const refused = /^(REFUSED|Cannot run|mt-eval is not installed)/.test(result);
|
|
1053
2019
|
return {
|
|
1054
2020
|
content: [{ type: 'text', text: result }],
|
|
2021
|
+
...(refused ? { isError: true } : {}),
|
|
1055
2022
|
};
|
|
1056
2023
|
} catch (err) {
|
|
1057
2024
|
return {
|
|
@@ -1073,11 +2040,13 @@ export async function createServer() {
|
|
|
1073
2040
|
+ 'with that job id every ~15-30s until it reports COMPLETED or FAILED. '
|
|
1074
2041
|
+ 'Each poll returns instantly. On completion it returns the run output '
|
|
1075
2042
|
+ '(local score for an item run; per-item lines for a queue run) — then use '
|
|
1076
|
-
+ 'get_results to see the published leaderboard entry.
|
|
1077
|
-
+ '
|
|
2043
|
+
+ 'get_results to see the published leaderboard entry. Jobs are kept on disk '
|
|
2044
|
+
+ '(the newest 50): a job started before this server restarted is still found — '
|
|
2045
|
+
+ 'RUNNING, COMPLETED with the results the harness wrote, or INTERRUPTED with its '
|
|
2046
|
+
+ 'log tail. Call with no job_id to list them.',
|
|
1078
2047
|
{
|
|
1079
2048
|
job_id: z.string().optional()
|
|
1080
|
-
.describe('The job id returned by run_benchmark (e.g. "run-
|
|
2049
|
+
.describe('The job id returned by run_benchmark (e.g. "run-3f9c2a7b1e04"). Omit to list the job history.'),
|
|
1081
2050
|
},
|
|
1082
2051
|
async ({ job_id }) => {
|
|
1083
2052
|
try {
|
|
@@ -1091,10 +2060,108 @@ export async function createServer() {
|
|
|
1091
2060
|
}
|
|
1092
2061
|
);
|
|
1093
2062
|
|
|
2063
|
+
// ------------------------------------------------------------------
|
|
2064
|
+
// Tools: preview_publish (read-only) and publish_report (writes)
|
|
2065
|
+
// ------------------------------------------------------------------
|
|
2066
|
+
// Two tools, so an agent host can allow the PREVIEW on its own and keep the
|
|
2067
|
+
// real publish behind approval (Round 13 researcher: the host refused
|
|
2068
|
+
// publish_report's preview as a production deploy, because one tool did
|
|
2069
|
+
// both). The MCP tool annotations say which is which.
|
|
2070
|
+
// STRICT schemas: on a publishing tool a misspelled `scores_only` must be
|
|
2071
|
+
// refused by name — stripped, it would publish the sentence text; on the
|
|
2072
|
+
// preview a `confirm` or `publish_ack` is refused, never ignored.
|
|
2073
|
+
const reportArgs = {
|
|
2074
|
+
report: z.string()
|
|
2075
|
+
.describe('Path to the run\'s TestReport (*_report.json) — get_run_status prints it under "Results".'),
|
|
2076
|
+
scores_only: z.boolean().default(false)
|
|
2077
|
+
.describe('Publish ONLY the aggregate scores + the corpus sha256/size (`--scores-only`) — never the '
|
|
2078
|
+
+ 'sentence text. For a private or confidential corpus; the preview then says "scores only".'),
|
|
2079
|
+
redact_coaching: z.boolean().default(false)
|
|
2080
|
+
.describe('Publish the system/coaching prompt as its sha256 only (`--redact-coaching`); its text stays '
|
|
2081
|
+
+ 'in the local run log.'),
|
|
2082
|
+
anonymous: z.boolean().default(false)
|
|
2083
|
+
.describe('Publish without signing in (`--anonymous`): shown as "anonymous", rate-limited per IP.'),
|
|
2084
|
+
};
|
|
2085
|
+
server.registerTool(
|
|
2086
|
+
'preview_publish',
|
|
2087
|
+
{
|
|
2088
|
+
title: 'Preview a publish (read-only)',
|
|
2089
|
+
description: 'READ-ONLY — cannot publish. What publishing a FINISHED run\'s report (the *_report.json '
|
|
2090
|
+
+ 'get_run_status names under "Results") would put on the leaderboard, and the exact publish_report '
|
|
2091
|
+
+ 'call that would publish it. Runs only the harness\'s own `mt-eval publish <report> --dry-run` (no '
|
|
2092
|
+
+ 'network, no sign-in, nothing written) and has no confirm or publish_ack, so a host can allow this '
|
|
2093
|
+
+ 'tool on its own and keep publish_report behind approval. Returns WHAT GETS PUBLISHED — every row '
|
|
2094
|
+
+ 'WITH its sentence text, or scores only; the prompt, or only its sha256; where (normally the '
|
|
2095
|
+
+ 'PRODUCTION public board) — and the publish_ack words. scores_only / redact_coaching / anonymous '
|
|
2096
|
+
+ 'preview those variants; the next call carries the same flags.',
|
|
2097
|
+
inputSchema: z.object(reportArgs).strict(),
|
|
2098
|
+
annotations: {
|
|
2099
|
+
title: 'Preview a publish (read-only)',
|
|
2100
|
+
readOnlyHint: true,
|
|
2101
|
+
destructiveHint: false,
|
|
2102
|
+
idempotentHint: true,
|
|
2103
|
+
openWorldHint: false,
|
|
2104
|
+
},
|
|
2105
|
+
},
|
|
2106
|
+
async (params) => {
|
|
2107
|
+
try {
|
|
2108
|
+
const r = await previewReport(params);
|
|
2109
|
+
return { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) };
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
return { content: [{ type: 'text', text: `Error previewing the publish: ${err.message}` }], isError: true };
|
|
2112
|
+
}
|
|
2113
|
+
},
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
server.registerTool(
|
|
2117
|
+
'publish_report',
|
|
2118
|
+
{
|
|
2119
|
+
title: 'Publish a report to the leaderboard',
|
|
2120
|
+
description: 'WRITES to the public leaderboard: publishes a FINISHED run\'s report (the *_report.json '
|
|
2121
|
+
+ 'get_run_status names under "Results") — the MCP twin of `mt-eval publish <report>`, for a run made '
|
|
2122
|
+
+ 'without publish: true. To only SEE what would go public, call preview_publish (read-only; a host can '
|
|
2123
|
+
+ 'allow it alone). Only confirm: true WITH the exact publish_ack the preview prints publishes (to '
|
|
2124
|
+
+ 'normally the PRODUCTION board); anything else is refused and nothing is written. Without confirm it '
|
|
2125
|
+
+ 'returns the same preview and writes nothing (kept for older callers). scores_only withholds the '
|
|
2126
|
+
+ 'sentence text; redact_coaching publishes only the prompt\'s sha256. Show the user the preview first '
|
|
2127
|
+
+ 'and publish only with their agreement.',
|
|
2128
|
+
inputSchema: z.object({
|
|
2129
|
+
...reportArgs,
|
|
2130
|
+
confirm: z.boolean().default(false)
|
|
2131
|
+
.describe('Must be true to publish — and only with publish_ack. Without it: the preview, nothing written.'),
|
|
2132
|
+
publish_ack: z.string().optional()
|
|
2133
|
+
.describe('With confirm: true — the user\'s acknowledgement of what goes public, in the EXACT words the '
|
|
2134
|
+
+ 'preview prints (e.g. "publish to production: scores only, prompt published"). Missing or '
|
|
2135
|
+
+ 'different → REFUSED, nothing is written. Never write it before the user has seen the preview.'),
|
|
2136
|
+
}).strict(),
|
|
2137
|
+
// It writes to a public board (normally production) and a published
|
|
2138
|
+
// row cannot be taken back from this tool: hosts should ask first.
|
|
2139
|
+
annotations: {
|
|
2140
|
+
title: 'Publish a report to the leaderboard',
|
|
2141
|
+
readOnlyHint: false,
|
|
2142
|
+
destructiveHint: true,
|
|
2143
|
+
idempotentHint: false,
|
|
2144
|
+
openWorldHint: true,
|
|
2145
|
+
},
|
|
2146
|
+
},
|
|
2147
|
+
async (params) => {
|
|
2148
|
+
try {
|
|
2149
|
+
const r = await publishReport(params);
|
|
2150
|
+
return { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) };
|
|
2151
|
+
} catch (err) {
|
|
2152
|
+
return { content: [{ type: 'text', text: `Error publishing the report: ${err.message}` }], isError: true };
|
|
2153
|
+
}
|
|
2154
|
+
},
|
|
2155
|
+
);
|
|
2156
|
+
|
|
1094
2157
|
// ------------------------------------------------------------------
|
|
1095
2158
|
// Transport setup
|
|
1096
2159
|
// ------------------------------------------------------------------
|
|
1097
2160
|
return {
|
|
2161
|
+
/** Connect to any MCP transport (tests use the SDK's in-memory pair). */
|
|
2162
|
+
async connect(transport) {
|
|
2163
|
+
await server.connect(transport);
|
|
2164
|
+
},
|
|
1098
2165
|
/** Start the server on stdio transport. */
|
|
1099
2166
|
async start() {
|
|
1100
2167
|
const transport = new StdioServerTransport();
|