Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/integrations/HERMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,40 @@ Aucun accès réseau ; les modules utilisés sont ceux de l’application (Studi
| `elos_today` | Quoi faire aujourd’hui : mission Daily English, mots et cartes dus, difficultés, examen proche |
| `elos_progress` | Série de jours, mots vus / maîtrisés, charge des 7 prochains jours, comparaison FSRS sur ton historique |
| `elos_exam_plan` | Compte à rebours des examens et séances de révision espacées (J-14, J-7, J-3, J-1) |
| `elos_course_map` | Programme (matières, chapitres) d’une base pédagogique : objectifs, prérequis, exemple, analogie, erreurs fréquentes, contrôle qualité |
| `elos_course_exercise` | Par défaut, QCM construit depuis tes propres cours du chapitre (citation page, mauvaises réponses tirées de la même matière, questions ratées en priorité) ; sinon exercice de la base. Correction seulement après ta réponse ; journal d’entraînement hors du dossier partagé, jamais compté comme maîtrise |

## Installation

```bash
REPO=~/Desktop/Developer/daily-english-mission
hermes mcp add elos --command "$(command -v node)" --args \
--experimental-strip-types --no-warnings --import "$REPO/tests/register-loader.mjs" "$REPO/mcp/elos-server.ts"
hermes mcp test elos # connexion + 6 outils
hermes mcp test elos # connexion + 11 outils
```

Dans une session Hermes déjà ouverte : `/reload-mcp`.

## Base pédagogique (programme)

`ELOS_BASE_DIR` désigne un dossier contenant une base extraite par sous-dossier
(`courses.json`, `exercises.json`, `flashcards.json`, schéma 1.0). Elle est traitée comme matériel dérivé :
synthèse générée, jamais source officielle ni preuve de maîtrise. À l’import, un contrôle qualité signale
et écarte les contenus inutilisables pour s’entraîner : QCM dont la bonne réponse est toujours la plus longue,
mauvaises réponses génériques réutilisées, cartes au verso identique, définitions recopiées, exercices sans source.
Les corrigés sortent du dossier partagé avec `npm run mcp:split-base -- <dossier de la base> <dossier privé>` :
`exercises.json` ne garde que les questions et le serveur lit les corrigés dans `ELOS_ANSWERS_DIR/<base>.json`
(droits 600). Hermes refuse de lire `config.yaml` avec son outil de fichiers, ce qui cache ce chemin, mais son
terminal tourne avec ton compte : c’est une protection contre la lecture accidentelle, pas une frontière de sécurité.

Questions citées : pour chaque chapitre, les notions du programme sont cherchées dans les définitions de tes
cours de la même matière (dossier dont le nom partage un mot avec la matière). Les intitulés parasites
(puces, débuts de proposition comme « Si… ») sont écartés. Les réponses sont journalisées dans
`<cache>/practice-log.jsonl` (`DERIVED_PRACTICE`), hors du dossier partagé.

Le Studio propose le même import dans **Base pédagogique (programme)** ; seuls la carte du programme et le
rapport qualité y sont conservés, sur l’appareil.

## Ta progression (lecture seule)

Les trois derniers outils lisent un résumé écrit par l’application, jamais ta base :
Expand Down
141 changes: 140 additions & 1 deletion mcp/elos-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Run: node --experimental-strip-types --import ./tests/register-loader.mjs mcp/elos-server.ts
import { execFile, execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { promisify } from "node:util";
import path from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
Expand All @@ -19,6 +19,8 @@ import { answerFromSources, deriveFlashcards, deriveQuiz, revisionSheet, socrati
import { SYNTHETIC_DOCUMENTS } from "../src/modules/learning-studio/fixtures.ts";
import { buildImportedDocument, pagesNeedingOcr, textToSections, type RawPage } from "../src/modules/learning-studio/import.ts";
import { buildAudioOverview } from "../src/modules/learning-studio/overview.ts";
import { chapterSourceQuiz, isCorrectChoice } from "../src/modules/learning-studio/chapter-practice.ts";
import { auditPedagogicalBase, parsePedagogicalBase, practiceExercises, type PedagogicalBase, type QualityReport } from "../src/modules/learning-studio/pedagogical-base.ts";
import { derivePrerequisites, remediationPath } from "../src/modules/learning-studio/prerequisites.ts";
import type { StudioDocument } from "../src/modules/learning-studio/types.ts";
import { analyzeProduction } from "../src/modules/daily-english/local-mission.ts";
Expand Down Expand Up @@ -195,6 +197,52 @@ const cite = (documents: readonly StudioDocument[], citation: { documentId: stri
return { source: document?.title ?? citation.documentId, [document?.unit === "section" ? "section" : "page"]: citation.pageNumber, quote: citation.quote, ...(ocr ? { ocr: "text recognised from a scan: verify against the page" } : {}) };
};

/**
* Pedagogical bases (ELOS_BASE_DIR): one folder per extracted export holding courses.json,
* exercises.json and flashcards.json. Generated material: audited, never mastery.
* Answer keys live in ELOS_ANSWERS_DIR/<base>.json (see `npm run mcp:split-base`), outside the
* folder the agent browses; a key still inside exercises.json is used but reported.
*/
export function loadBases(env: NodeJS.ProcessEnv = process.env): { name: string; base: PedagogicalBase; report: QualityReport; answersInSharedFolder: boolean }[] {
const dir = env.ELOS_BASE_DIR?.trim();
if (!dir) return [];
const root = path.resolve(dir);
return readdirSync(root).sort().flatMap((name) => {
const folder = path.join(root, name);
if (name.startsWith(".") || !statSync(folder).isDirectory() || !existsSync(path.join(folder, "courses.json"))) return [];
const read = (file: string) => (existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) as unknown : undefined);
const answersDir = env.ELOS_ANSWERS_DIR?.trim();
try {
const exercises = read(path.join(folder, "exercises.json"));
const answerKey = answersDir ? read(path.join(path.resolve(answersDir), `${name}.json`)) : undefined;
const base = parsePedagogicalBase({ courses: read(path.join(folder, "courses.json")), exercises, flashcards: read(path.join(folder, "flashcards.json")), answerKey });
const answersInSharedFolder = Array.isArray((exercises as { answer_key?: unknown } | undefined)?.answer_key);
return [{ name, base, report: auditPedagogicalBase(base), answersInSharedFolder }];
} catch { return []; }
});
}

// Practice log of course questions answered through the agent: outside the shared folder,
// used to ask missed questions again. DERIVED_PRACTICE only: it never grants mastery.
export type PracticeEntry = Readonly<{ at: string; chapterId: string; questionId: string; correct: boolean }>;

export function practiceLogFile(root: string, env: NodeJS.ProcessEnv = process.env): string {
return path.join(cacheDir(root, env), "practice-log.jsonl");
}

export function readPractice(file: string): PracticeEntry[] {
try {
return readFileSync(file, "utf8").split("\n").filter(Boolean).flatMap((line) => {
try { return [JSON.parse(line) as PracticeEntry]; } catch { return []; }
});
} catch { return []; }
}

function recordPractice(file: string, entry: PracticeEntry): void {
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
appendFileSync(file, JSON.stringify(entry) + "\n", { mode: 0o600 });
}

const STALE_MS = 24 * 3_600_000;

/** Reads the learner snapshot written by the app (ELOS_STATE_FILE); read-only, strictly validated. */
Expand Down Expand Up @@ -349,6 +397,97 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer {
})) });
});

const baseNote = "Generated course synthesis (DERIVED_MATERIAL), not the official course: check facts against elos_ask_sources. Never counts as mastery.";

const practiceSummary = (chapterId: string) => {
const entries = root ? readPractice(practiceLogFile(root, env)).filter((entry) => entry.chapterId === chapterId) : [];
return entries.length ? { answered: entries.length, correct: entries.filter((entry) => entry.correct).length, evidence: "DERIVED_PRACTICE" } : null;
};

server.registerTool("elos_course_map", {
title: "Course map of the learner's programme",
description: `Subjects and chapters of the learner's programme from the pedagogical base (ELOS_BASE_DIR). Without chapter_id: chapters list and quality audit. With chapter_id: objectives, prerequisites, notions, worked example, analogy and frequent mistakes, to structure an explanation. ${baseNote}`,
inputSchema: { chapter_id: z.string().optional() },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Namespace chapter lookups by pedagogical base

When two configured export folders reuse a chapter ID, the list response distinguishes them by base name, but the follow-up request accepts only chapter_id and returns the first matching base in sorted order. IDs are only required to be nonempty and are not globally unique, so the corresponding chapter in every later base becomes inaccessible; exercise IDs have the same ambiguity in the other tool. Include a base identifier in both lookup contracts or return composite IDs.

Useful? React with 👍 / 👎.

}, async ({ chapter_id }) => {
const bases = loadBases(env);
if (bases.length === 0) return json({ error: "No pedagogical base: set ELOS_BASE_DIR to a folder containing extracted exports (courses.json, exercises.json, flashcards.json)." });
if (!chapter_id) return json({ note: baseNote, bases: bases.map(({ name, base, report, answersInSharedFolder }) => ({ name, ...(answersInSharedFolder ? { warning: "Answer key is still inside exercises.json in the shared folder: run npm run mcp:split-base." } : {}), quality: report.findings.map(({ severity, code, count, total, message }) => ({ severity, code, count, total, message })), usable: report.usable,
chapters: base.chapters.map((chapter) => ({ id: chapter.id, subject: chapter.subject, title: chapter.title, practiceExercises: practiceExercises(base, report, chapter.id).length, practice: practiceSummary(chapter.id) })) })) });
for (const { base, report } of bases) {
const chapter = base.chapters.find((item) => item.id === chapter_id);
if (chapter) {
const { exerciseIds: _exercises, flashcardIds: _cards, concepts, ...rest } = chapter;
const duplicated = new Set(concepts.map((concept) => concept.definition)).size === 1 && concepts.length > 1;
return json({ note: baseNote, ...rest, notions: concepts.map((concept) => concept.label), ...(duplicated ? {} : { definitions: concepts }), practiceExercises: practiceExercises(base, report, chapter.id).length });
}
}
return json({ error: "Unknown chapter_id: call elos_course_map without arguments to list chapters." });
});

const findChapter = (chapterId: string) => {
for (const { base } of loadBases(env)) {
const chapter = base.chapters.find((item) => item.id === chapterId);
if (chapter) return chapter;
}
return null;
};
const sourceQuestionId = (chapterId: string, cardId: string) => `src:${Buffer.from(JSON.stringify([chapterId, cardId])).toString("base64url")}`;
const parseSourceQuestionId = (id: string): [string, string] | null => {
try { const value = JSON.parse(Buffer.from(id.slice(4), "base64url").toString("utf8")) as unknown; return Array.isArray(value) && value.length === 2 ? value as [string, string] : null; } catch { return null; }
};

server.registerTool("elos_course_exercise", {
title: "Practice exercise with answer revealed after the attempt",
description: `Practice on a programme chapter. With chapter_id: one exercise, statement only. By default (source "courses") a multiple-choice question built from the learner's own course files, with a page citation, preferring questions missed before; otherwise (or with source "base") an exercise of the pedagogical base. Multiple-choice questions that failed the quality audit are never served. With exercise_id and the learner's answer (a letter A-D or the option text for a multiple-choice question): the correction; course questions are graded and logged as practice (DERIVED_PRACTICE, never mastery). Never reveal a solution before the learner has answered. ${baseNote}`,
inputSchema: { chapter_id: z.string().optional(), exercise_id: z.string().optional(), answer: z.string().min(1).max(5000).optional(), source: z.enum(["courses", "base"]).default("courses") },
}, async ({ chapter_id, exercise_id, answer, source }) => {
const bases = loadBases(env);
if (exercise_id?.startsWith("src:")) {
const parsed = parseSourceQuestionId(exercise_id);
const chapter = parsed && findChapter(parsed[0]);
if (!parsed || !chapter) return json({ error: "Unknown exercise_id." });
if (!answer) return json({ error: "Ask the learner to answer first; the solution is revealed only after an attempt." });
const documents = await loadSources(root, env);
const question = chapterSourceQuiz(documents, chapter, 50).find((item) => item.cardId === parsed[1]);
if (!question) return json({ error: "This question is no longer available (the course files changed)." });
const correct = isCorrectChoice(question, answer);
if (root) recordPractice(practiceLogFile(root, env), { at: new Date().toISOString(), chapterId: chapter.id, questionId: exercise_id, correct });
return json({ correct, rightAnswer: question.answer, learnerAnswer: answer, citation: cite(documents, question.citations[0]), evidence: "DERIVED_PRACTICE (not mastery)" });
}
if (exercise_id) {
for (const { base, report } of bases) {
const exercise = base.exercises.find((item) => item.id === exercise_id);
if (!exercise) continue;
if (!practiceExercises(base, report, exercise.chapterId).some((item) => item.id === exercise.id)) return json({ error: "This exercise failed the quality audit and is not used for practice." });
if (!answer) return json({ error: "Ask the learner to answer first; the solution is revealed only after an attempt." });
const key = base.answerKey.get(exercise.id);
return json({ note: baseNote, exercise: exercise.prompt, learnerAnswer: answer, referenceSolution: key?.solution, ...(key?.correctOption != null ? { correctOption: exercise.options[key.correctOption] } : {}), commonErrors: key?.commonErrors ?? [], verified: false });
}
return json({ error: "Unknown exercise_id." });
}
if (!chapter_id) return json({ error: "Give a chapter_id (see elos_course_map)." });
const chapter = findChapter(chapter_id);
if (chapter && source === "courses") {
const questions = chapterSourceQuiz(await loadSources(root, env), chapter, 50);
if (questions.length > 0) {
const history = root ? readPractice(practiceLogFile(root, env)) : [];
const last = new Map(history.map((entry) => [entry.questionId, entry.correct]));
const rank = (id: string) => (last.get(id) === false ? 0 : last.has(id) ? 2 : 1);
const ranked = questions.map((question) => ({ question, id: sourceQuestionId(chapter.id, question.cardId) })).sort((a, b) => rank(a.id) - rank(b.id));
const best = ranked.filter((item) => rank(item.id) === rank(ranked[0].id));
const { question, id } = best[Math.floor(Math.random() * best.length)];
return json({ exercise_id: id, type: "qcm_from_courses", prompt: question.prompt, options: question.choices.map((choice, index) => `${String.fromCharCode(65 + index)}. ${choice}`), note: "Built from the learner's course files; the page citation is given with the correction." });
}
}
for (const { base, report } of bases) {
const pool = practiceExercises(base, report, chapter_id);
if (pool.length === 0) continue;
const exercise = pool[Math.floor(Math.random() * pool.length)];
return json({ note: baseNote, exercise_id: exercise.id, type: exercise.type, difficulty: exercise.difficulty, prompt: exercise.prompt, ...(exercise.options.length ? { options: exercise.options } : {}) });
}
return json({ error: "No practice exercise passes the quality audit for this chapter." });
});

return server;
}

Expand Down
20 changes: 20 additions & 0 deletions mcp/split-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Moves a pedagogical base's answer key out of the folder shared with the agent.
// Usage: npm run mcp:split-base -- <base folder> <answers dir>
// Writes <answers dir>/<base name>.json (mode 600) and rewrites exercises.json without it.
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { splitAnswerKey } from "../src/modules/learning-studio/pedagogical-base.ts";

const [folder, answersDir] = process.argv.slice(2).map((value) => path.resolve(value));
if (!folder || !answersDir) throw new Error("Usage: npm run mcp:split-base -- <base folder> <answers dir>");
if (answersDir === folder || answersDir.startsWith(folder + path.sep) || folder.startsWith(answersDir + path.sep)) throw new Error("The answers dir must be outside the base folder.");

const exercisesFile = path.join(folder, "exercises.json");
const { questions, answerKey } = splitAnswerKey(JSON.parse(readFileSync(exercisesFile, "utf8")));
if (answerKey.length === 0) { console.log("No answer key in exercises.json: nothing to do."); process.exit(0); }
mkdirSync(answersDir, { recursive: true, mode: 0o700 });
chmodSync(answersDir, 0o700);
const target = path.join(answersDir, `${path.basename(folder)}.json`);
writeFileSync(target, JSON.stringify(answerKey), { mode: 0o600 });
writeFileSync(exercisesFile, JSON.stringify(questions, null, 1));
console.log(`${answerKey.length} answers moved to ${target}; exercises.json now holds questions only.`);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"lint": "eslint .",
"mcp:elos": "node --experimental-strip-types --no-warnings --import ./tests/register-loader.mjs mcp/elos-server.ts",
"mcp:ocr": "node --experimental-strip-types --no-warnings --import ./tests/register-loader.mjs mcp/prepare-ocr.ts",
"mcp:split-base": "node --experimental-strip-types --no-warnings --import ./tests/register-loader.mjs mcp/split-base.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-strip-types --import ./tests/register-loader.mjs \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\" \"tests/integration/**/*.test.cjs\"",
"test:ci": "node --test --test-concurrency=2 --experimental-strip-types --import ./tests/register-loader.mjs \"tests/unit/**/*.test.ts\" \"tests/integration/**/*.test.ts\" \"tests/integration/**/*.test.cjs\"",
Expand Down
2 changes: 2 additions & 0 deletions src/components/learning-studio/LearningStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { MIN_PREDICTIONS, replaySchedulers } from "@/modules/scientific-pedagogy
import { deleteImportedDocument, listImportedDocuments } from "@/modules/learning-studio/browser-library";
import { ImportPanel } from "@/components/learning-studio/ImportPanel";
import { AcademicCataloguePanel } from "@/components/learning-studio/AcademicCataloguePanel";
import { PedagogicalBasePanel } from "@/components/learning-studio/PedagogicalBasePanel";
import { ListenButton } from "@/components/learning-studio/ListenButton";
import { AudioOverviewPlayer } from "@/components/learning-studio/AudioOverviewPlayer";
import { buildAudioOverview } from "@/modules/learning-studio/overview";
Expand Down Expand Up @@ -217,6 +218,7 @@ export function LearningStudio() {
<p className="text-xs text-slate-500">{documents.length} source(s) active(s) dans ce périmètre</p>
<ImportPanel onImported={addImported} />
<AcademicCataloguePanel documents={imported} onImported={addImported} />
<PedagogicalBasePanel />
</CardContent>
</Card>
<Card>
Expand Down
Loading
Loading