From 27eaed615cd64e79d8a018a3904f70eaa767f22a Mon Sep 17 00:00:00 2001 From: Euloge Date: Fri, 23 Oct 2026 13:33:18 +0200 Subject: [PATCH 1/2] feat(elos): import pedagogical bases with a quality audit Course map (objectives, prerequisites, example, analogy, frequent mistakes) from a schema 1.0 export, kept as DERIVED_MATERIAL. Answer keys stay apart from questions, and an audit blocks guessable MCQs (longest option correct, reused distractors) and copy-pasted flashcards. New MCP tools elos_course_map and elos_course_exercise (solution only after the learner's attempt); Studio import panel. Co-Authored-By: Claude Opus 5.5 --- docs/integrations/HERMES.md | 17 +- mcp/elos-server.ts | 69 ++++++ .../learning-studio/LearningStudio.tsx | 2 + .../learning-studio/PedagogicalBasePanel.tsx | 90 ++++++++ .../learning-studio/pedagogical-base.ts | 197 ++++++++++++++++++ tests/integration/mcp/elos-server.test.ts | 2 +- .../learning-studio/pedagogical-base.test.ts | 82 ++++++++ tests/unit/mcp/course-base.test.ts | 71 +++++++ 8 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 src/components/learning-studio/PedagogicalBasePanel.tsx create mode 100644 src/modules/learning-studio/pedagogical-base.ts create mode 100644 tests/unit/learning-studio/pedagogical-base.test.ts create mode 100644 tests/unit/mcp/course-base.test.ts diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md index ffd8fc4..fbd82f8 100644 --- a/docs/integrations/HERMES.md +++ b/docs/integrations/HERMES.md @@ -14,6 +14,8 @@ 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` | Exercice de la base sans corrigé ; le corrigé n’est rendu qu’après la réponse de l’apprenant. Les QCM qui échouent au contrôle qualité ne sont jamais servis | ## Installation @@ -21,11 +23,24 @@ Aucun accès réseau ; les modules utilisés sont ceux de l’application (Studi 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. +La séparation des corrigés est appliquée par les outils, pas par le système de fichiers : un agent qui dispose +d’outils de lecture de fichiers pourrait lire `exercises.json` directement. + +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 : diff --git a/mcp/elos-server.ts b/mcp/elos-server.ts index 418c3ea..a50221b 100644 --- a/mcp/elos-server.ts +++ b/mcp/elos-server.ts @@ -19,6 +19,7 @@ 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 { 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"; @@ -195,6 +196,25 @@ 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. + */ +export function loadBases(env: NodeJS.ProcessEnv = process.env): { name: string; base: PedagogicalBase; report: QualityReport }[] { + 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(path.join(folder, file)) ? JSON.parse(readFileSync(path.join(folder, file), "utf8")) as unknown : undefined); + try { + const base = parsePedagogicalBase({ courses: read("courses.json"), exercises: read("exercises.json"), flashcards: read("flashcards.json") }); + return [{ name, base, report: auditPedagogicalBase(base) }]; + } catch { return []; } + }); +} + const STALE_MS = 24 * 3_600_000; /** Reads the learner snapshot written by the app (ELOS_STATE_FILE); read-only, strictly validated. */ @@ -349,6 +369,55 @@ 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."; + + 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() }, + }, 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 }) => ({ name, 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 })) })) }); + 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." }); + }); + + server.registerTool("elos_course_exercise", { + title: "Practice exercise with answer revealed after the attempt", + description: `Practice from the pedagogical base. With chapter_id: one exercise (statement only, no solution); multiple-choice questions that failed the quality audit are never served. With exercise_id and the learner's own answer: the reference solution and common errors, for the learner to compare (no score is recorded). 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() }, + }, async ({ chapter_id, exercise_id, answer }) => { + const bases = loadBases(env); + 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)." }); + 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; } diff --git a/src/components/learning-studio/LearningStudio.tsx b/src/components/learning-studio/LearningStudio.tsx index 0ac735f..10d5896 100644 --- a/src/components/learning-studio/LearningStudio.tsx +++ b/src/components/learning-studio/LearningStudio.tsx @@ -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"; @@ -217,6 +218,7 @@ export function LearningStudio() {

{documents.length} source(s) active(s) dans ce périmètre

+ diff --git a/src/components/learning-studio/PedagogicalBasePanel.tsx b/src/components/learning-studio/PedagogicalBasePanel.tsx new file mode 100644 index 0000000..e8bef1b --- /dev/null +++ b/src/components/learning-studio/PedagogicalBasePanel.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { ImportError } from "@/modules/learning-studio/import"; +import { auditPedagogicalBase, parsePedagogicalBase, type BaseChapter, type QualityReport } from "@/modules/learning-studio/pedagogical-base"; + +const STORAGE_KEY = "elos-pedagogical-base-v1"; +const MAX_FILE_BYTES = 5 * 1024 * 1024; + +type Saved = Readonly<{ chapters: readonly BaseChapter[]; report: QualityReport; importedAt: string }>; + +function readSaved(): Saved | null { + try { return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "null") as Saved | null; } catch { return null; } +} + +/** + * Imports a pedagogical base export (courses.json, exercises.json, flashcards.json) on this device. + * Only the course map and the quality audit are kept; answer keys are not stored here. + */ +export function PedagogicalBasePanel() { + const [saved, setSaved] = useState(() => (typeof window === "undefined" ? null : readSaved())); + const [message, setMessage] = useState(""); + + async function load(files: FileList | null) { + if (!files?.length) return; + setMessage(""); + try { + const byName = new Map(); + for (const file of Array.from(files)) { + if (file.size > MAX_FILE_BYTES) throw new ImportError(`« ${file.name} » dépasse 5 Mo.`); + byName.set(file.name.toLowerCase(), JSON.parse(await file.text()) as unknown); + } + if (!byName.has("courses.json")) throw new ImportError("Sélectionnez au moins courses.json (et si possible exercises.json et flashcards.json)."); + const base = parsePedagogicalBase({ courses: byName.get("courses.json"), exercises: byName.get("exercises.json"), flashcards: byName.get("flashcards.json") }); + const next: Saved = { chapters: base.chapters, report: auditPedagogicalBase(base), importedAt: new Date().toISOString() }; + try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* kept for this session only */ } + setSaved(next); + setMessage(`${base.chapters.length} chapitres importés sur cet appareil.`); + } catch (error) { + setMessage(error instanceof ImportError ? error.message : "Fichiers illisibles : JSON attendu. Rien n’a été envoyé."); + } + } + + function forget() { + try { window.localStorage.removeItem(STORAGE_KEY); } catch { /* nothing stored */ } + setSaved(null); setMessage("Base pédagogique retirée de cet appareil."); + } + + const subjects = saved ? [...new Set(saved.chapters.map((chapter) => chapter.subject))] : []; + return
+ Base pédagogique (programme) +

Synthèse générée, pas le cours officiel : utile pour s’orienter, à vérifier dans tes sources. Ne compte jamais comme maîtrise.

+ + {message &&

{message}

} + {saved &&
+
+

Contrôle qualité

+ {saved.report.findings.length === 0 ?

Aucun problème détecté.

:
    + {saved.report.findings.map((finding) =>
  • + {finding.severity === "BLOCKING" ? "Bloquant" : "À vérifier"} · {finding.count}/{finding.total} — {finding.message} +
  • )} +
} +

+ QCM {saved.report.usable.mcq ? "utilisables" : "écartés"} · exercices ouverts {saved.report.usable.openExercises ? "utilisables" : "écartés"} · cartes {saved.report.usable.flashcards ? "utilisables" : "écartées"} +

+
+ {subjects.map((subject) =>
+

{subject}

+
    + {saved.chapters.filter((chapter) => chapter.subject === subject).map((chapter) =>
  • +
    + {chapter.title} +
    + {chapter.objectives.length > 0 && <>
    Objectifs
    {chapter.objectives.join(" · ")}
    } + {chapter.concepts.length > 0 && <>
    Notions
    {chapter.concepts.map((concept) => concept.label).join(", ")}
    } + {chapter.example && <>
    Exemple
    {chapter.example}
    } + {chapter.analogy && <>
    Analogie
    {chapter.analogy}
    } + {chapter.mistakes && <>
    Erreurs fréquentes
    {chapter.mistakes}
    } +
    +
    +
  • )} +
+
)} + +
} +
; +} diff --git a/src/modules/learning-studio/pedagogical-base.ts b/src/modules/learning-studio/pedagogical-base.ts new file mode 100644 index 0000000..40c330b --- /dev/null +++ b/src/modules/learning-studio/pedagogical-base.ts @@ -0,0 +1,197 @@ +import { ImportError } from "./import"; + +/** + * Pedagogical base export (schema 1.0: courses.json, exercises.json, flashcards.json). + * + * The course map (objectives, prerequisites, examples, analogies, frequent mistakes) + * is imported as DERIVED_MATERIAL: it is a generated synthesis, not a source, and it + * never grants mastery. Answer keys are kept apart from the questions, and a quality + * audit decides which generated exercises are fit for practice. + */ + +export type BaseChapter = Readonly<{ + id: string; + subject: string; + title: string; + summary: string; + example: string; + analogy: string; + mistakes: string; + concepts: readonly Readonly<{ label: string; definition: string }>[]; + objectives: readonly string[]; + prerequisites: readonly string[]; + exerciseIds: readonly string[]; + flashcardIds: readonly string[]; + relatedSourceIds: readonly string[]; +}>; + +export type BaseExercise = Readonly<{ + id: string; + chapterId: string; + type: string; + difficulty: string; + prompt: string; + options: readonly string[]; + sourceIds: readonly string[]; +}>; + +export type BaseAnswer = Readonly<{ correctOption: number | null; solution: string; commonErrors: readonly string[] }>; +export type BaseFlashcard = Readonly<{ id: string; chapterId: string; front: string; back: string }>; + +export type PedagogicalBase = Readonly<{ + classification: "DERIVED_MATERIAL"; + chapters: readonly BaseChapter[]; + exercises: readonly BaseExercise[]; + flashcards: readonly BaseFlashcard[]; + answerKey: ReadonlyMap; +}>; + +export type QualityCode = + | "ANSWER_IN_QUESTIONS" | "LONGEST_OPTION_IS_CORRECT" | "REUSED_DISTRACTORS" | "TEMPLATE_PROMPTS" + | "DUPLICATE_DEFINITIONS" | "DUPLICATE_FLASHCARD_BACKS" | "UNSOURCED_EXERCISES" | "BROKEN_LINKS" | "MISSING_ANSWERS"; + +export type QualityFinding = Readonly<{ code: QualityCode; severity: "BLOCKING" | "WARNING"; count: number; total: number; message: string }>; + +export type QualityReport = Readonly<{ + findings: readonly QualityFinding[]; + /** Content fit for practice; blocked content stays readable but is not used as an assessment. */ + usable: Readonly<{ courseMap: boolean; mcq: boolean; openExercises: boolean; flashcards: boolean }>; +}>; + +const ANSWER_FIELDS = ["answer", "correct_option", "solution", "detailed_solution", "correct"]; +const MIN_MCQ_FOR_BIAS = 5; +const BIAS_RATIO = 0.6; +const SPREAD_CHAPTERS = 3; + +type Json = Record; +const object = (value: unknown): Json => (value && typeof value === "object" && !Array.isArray(value) ? value as Json : {}); +const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : []); +const text = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); +const texts = (value: unknown): string[] => array(value).map(text).filter(Boolean); + +export function parsePedagogicalBase(input: Readonly<{ courses: unknown; exercises?: unknown; flashcards?: unknown }>): PedagogicalBase & { leakedAnswers: number } { + const subjects = array(object(input.courses).subjects); + if (subjects.length === 0) throw new ImportError("courses.json invalide : aucune matière (subjects) trouvée."); + + const chapters: BaseChapter[] = []; + for (const entry of subjects) { + const subject = text(object(object(entry).subject).name) || "Sans matière"; + for (const subjectModule of array(object(entry).modules)) { + for (const raw of array(object(subjectModule).chapters)) { + const chapter = object(raw); + const lessons = array(chapter.lessons).map(object); + const id = text(chapter.id); + if (!id) continue; + chapters.push({ + id, subject, title: text(chapter.title) || id, summary: text(chapter.summary), example: text(chapter.example), + analogy: text(chapter.analogy), mistakes: text(chapter.mistakes), + concepts: lessons.flatMap((lesson) => array(lesson.concepts).map(object)) + .map((concept) => ({ label: text(concept.label), definition: text(concept.definition) })).filter((concept) => concept.label) + .concat(texts(chapter.concepts).map((label) => ({ label, definition: "" }))) + .filter((concept, index, all) => all.findIndex((other) => other.label === concept.label) === index), + objectives: lessons.flatMap((lesson) => texts(lesson.objectives)), + prerequisites: [...new Set(lessons.flatMap((lesson) => texts(lesson.prerequisites)))], + exerciseIds: lessons.flatMap((lesson) => texts(lesson.exercise_ids)), + flashcardIds: lessons.flatMap((lesson) => texts(lesson.flashcard_ids)), + relatedSourceIds: [...new Set(lessons.flatMap((lesson) => texts(lesson.source_ids)))], + }); + } + } + } + if (chapters.length === 0) throw new ImportError("courses.json invalide : aucun chapitre exploitable."); + + const exercisesFile = object(input.exercises); + let leakedAnswers = 0; + const exercises: BaseExercise[] = array(exercisesFile.items).map(object).flatMap((item) => { + const id = text(item.id); + if (!id) return []; + if (ANSWER_FIELDS.some((field) => field in item)) leakedAnswers += 1; + // Only whitelisted fields are kept: an answer shipped inside a question never reaches the learner. + return [{ id, chapterId: text(item.chapter_id), type: text(item.type), difficulty: text(item.difficulty), prompt: text(item.prompt), options: texts(item.options), sourceIds: texts(item.source_ids) }]; + }); + + const answerKey = new Map(); + for (const raw of array(exercisesFile.answer_key).map(object)) { + const id = text(raw.id); + if (!id) continue; + const correct = object(raw.answer).correct_option; + answerKey.set(id, { correctOption: typeof correct === "number" ? correct : null, solution: text(raw.detailed_solution), commonErrors: texts(raw.common_errors) }); + } + + const flashcards: BaseFlashcard[] = array(object(input.flashcards).items).map(object).flatMap((item) => { + const id = text(item.id); + return id ? [{ id, chapterId: text(item.chapter_id), front: text(item.front), back: text(item.back) }] : []; + }); + + return { classification: "DERIVED_MATERIAL", chapters, exercises, flashcards, answerKey, leakedAnswers }; +} + +function spreadAcrossChapters(entries: readonly (readonly [string, string])[]): { repeated: Set; occurrences: number } { + const chaptersByText = new Map>(); + for (const [chapterId, value] of entries) chaptersByText.set(value, (chaptersByText.get(value) ?? new Set()).add(chapterId)); + const repeated = new Set([...chaptersByText].filter(([, chapterIds]) => chapterIds.size >= SPREAD_CHAPTERS).map(([value]) => value)); + return { repeated, occurrences: entries.filter(([, value]) => repeated.has(value)).length }; +} + +export function auditPedagogicalBase(base: PedagogicalBase & { leakedAnswers?: number }): QualityReport { + const findings: QualityFinding[] = []; + const add = (code: QualityCode, severity: QualityFinding["severity"], count: number, total: number, message: string) => { + if (count > 0) findings.push({ code, severity, count, total, message }); + }; + const chapterIds = new Set(base.chapters.map((chapter) => chapter.id)); + + add("ANSWER_IN_QUESTIONS", "WARNING", base.leakedAnswers ?? 0, base.exercises.length, "Des questions contenaient leur réponse : elle a été retirée à l’import."); + + const mcq = base.exercises.filter((exercise) => exercise.options.length >= 2 && base.answerKey.get(exercise.id)?.correctOption != null); + const longest = mcq.filter((exercise) => { + const correct = base.answerKey.get(exercise.id)?.correctOption as number; + const length = exercise.options[correct]?.length ?? 0; + return exercise.options.every((option, index) => index === correct || option.length < length); + }).length; + const biased = mcq.length >= MIN_MCQ_FOR_BIAS && longest / mcq.length >= BIAS_RATIO; + if (biased) add("LONGEST_OPTION_IS_CORRECT", "BLOCKING", longest, mcq.length, "La bonne réponse est presque toujours la plus longue : le QCM se devine sans connaître le cours."); + + const distractors = mcq.flatMap((exercise) => exercise.options + .filter((_, index) => index !== base.answerKey.get(exercise.id)?.correctOption) + .map((option) => [exercise.chapterId, option] as const)); + const reused = spreadAcrossChapters(distractors); + const reusedDistractors = reused.occurrences / Math.max(1, distractors.length) >= 0.5; + add("REUSED_DISTRACTORS", reusedDistractors ? "BLOCKING" : "WARNING", reused.occurrences, distractors.length, `${reused.repeated.size} mauvaises réponses génériques reviennent dans ${SPREAD_CHAPTERS} chapitres ou plus.`); + + const prompts = spreadAcrossChapters(base.exercises.map((exercise) => [exercise.chapterId, exercise.prompt] as const)); + add("TEMPLATE_PROMPTS", "WARNING", prompts.occurrences, base.exercises.length, `${prompts.repeated.size} énoncés passe-partout répétés d’un chapitre à l’autre.`); + + const duplicateDefinitions = base.chapters.filter((chapter) => { + const definitions = chapter.concepts.map((concept) => concept.definition).filter(Boolean); + return definitions.length > 1 && new Set(definitions).size === 1; + }).length; + add("DUPLICATE_DEFINITIONS", "WARNING", duplicateDefinitions, base.chapters.length, "Dans ces chapitres, toutes les notions ont la même définition (le résumé du chapitre)."); + + const backs = new Map(); + for (const card of base.flashcards) backs.set(card.back, (backs.get(card.back) ?? 0) + 1); + const duplicateBacks = [...backs.values()].filter((count) => count > 1).reduce((sum, count) => sum + count, 0); + const flashcardsBlocked = duplicateBacks / Math.max(1, base.flashcards.length) >= 0.5; + add("DUPLICATE_FLASHCARD_BACKS", flashcardsBlocked ? "BLOCKING" : "WARNING", duplicateBacks, base.flashcards.length, "Plusieurs cartes ont exactement le même verso : elles ne testent pas une notion précise."); + + const unsourced = base.exercises.filter((exercise) => exercise.sourceIds.length === 0).length; + add("UNSOURCED_EXERCISES", "WARNING", unsourced, base.exercises.length, "Exercices générés sans page source citée : à vérifier contre le cours."); + + const broken = base.exercises.filter((exercise) => !chapterIds.has(exercise.chapterId)).length + + base.flashcards.filter((card) => !chapterIds.has(card.chapterId)).length; + add("BROKEN_LINKS", "WARNING", broken, base.exercises.length + base.flashcards.length, "Éléments rattachés à un chapitre inconnu."); + + const missing = base.exercises.filter((exercise) => !base.answerKey.has(exercise.id)).length; + add("MISSING_ANSWERS", "WARNING", missing, base.exercises.length, "Exercices sans corrigé."); + + const open = base.exercises.filter((exercise) => exercise.options.length < 2 && base.answerKey.has(exercise.id) && chapterIds.has(exercise.chapterId)); + return { + findings: findings.sort((a, b) => Number(b.severity === "BLOCKING") - Number(a.severity === "BLOCKING")), + usable: { courseMap: base.chapters.length > 0, mcq: mcq.length > 0 && !biased && !reusedDistractors, openExercises: open.length > 0, flashcards: base.flashcards.length > 0 && !flashcardsBlocked }, + }; +} + +/** Exercises fit for practice in a chapter, given the audit: blocked multiple-choice questions are left out. */ +export function practiceExercises(base: PedagogicalBase, report: QualityReport, chapterId: string): BaseExercise[] { + return base.exercises.filter((exercise) => exercise.chapterId === chapterId && base.answerKey.has(exercise.id) + && (exercise.options.length >= 2 ? report.usable.mcq : report.usable.openExercises)); +} diff --git a/tests/integration/mcp/elos-server.test.ts b/tests/integration/mcp/elos-server.test.ts index 9aaf909..6edcbf8 100644 --- a/tests/integration/mcp/elos-server.test.ts +++ b/tests/integration/mcp/elos-server.test.ts @@ -32,7 +32,7 @@ after(() => withSources.c.close()); test("server lists the ELOS tools", async () => { const { tools } = await withSources.c.listTools(); - assert.deepEqual(tools.map((tool) => tool.name).sort(), ["elos_ask_sources", "elos_compare_schedulers", "elos_correct_english", "elos_exam_plan", "elos_progress", "elos_roleplay", "elos_study_pack", "elos_today", "elos_vocabulary"]); + assert.deepEqual(tools.map((tool) => tool.name).sort(), ["elos_ask_sources", "elos_compare_schedulers", "elos_correct_english", "elos_course_exercise", "elos_course_map", "elos_exam_plan", "elos_progress", "elos_roleplay", "elos_study_pack", "elos_today", "elos_vocabulary"]); assert.match(tools.find((tool) => tool.name === "elos_ask_sources")!.description!, /model provider/); }); diff --git a/tests/unit/learning-studio/pedagogical-base.test.ts b/tests/unit/learning-studio/pedagogical-base.test.ts new file mode 100644 index 0000000..36707c7 --- /dev/null +++ b/tests/unit/learning-studio/pedagogical-base.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { auditPedagogicalBase, parsePedagogicalBase, practiceExercises } from "../../../src/modules/learning-studio/pedagogical-base.ts"; +import { ImportError } from "../../../src/modules/learning-studio/import.ts"; + +// Synthetic export in the schema 1.0 shape (no real course content). +const CHAPTERS = ["alpha", "beta", "gamma", "delta", "epsilon"]; +const GENERIC = ["Réponse générique A.", "Réponse générique B.", "Réponse générique C."]; + +function courses() { + return { schema_version: "1.0", subjects: [{ subject: { id: "s1", name: "Matière fictive" }, modules: [{ id: "m1", title: "Module", chapters: CHAPTERS.map((name) => ({ + id: `ch_${name}`, title: `Chapitre ${name}`, concepts: [`${name}-1`, `${name}-2`], summary: `Résumé ${name}.`, example: `Exemple ${name}.`, + analogy: `Analogie ${name}.`, mistakes: `Erreur ${name}.`, + lessons: [{ id: `l_${name}`, concepts: [1, 2].map((n) => ({ label: `${name}-${n}`, definition: `Résumé ${name}.` })), objectives: [`Objectif ${name}`], prerequisites: ["Base commune"], exercise_ids: [`q_${name}`, `o_${name}`], flashcard_ids: [`f_${name}_1`, `f_${name}_2`] }], + })) }] }] }; +} + +function biasedExercises() { + return { + items: CHAPTERS.flatMap((name) => [ + { id: `q_${name}`, chapter_id: `ch_${name}`, type: "qcm", prompt: `Que résume ${name} ?`, options: [`Le résumé complet et détaillé du chapitre ${name}.`, ...GENERIC], source_ids: [] }, + { id: `o_${name}`, chapter_id: `ch_${name}`, type: "open", prompt: "Comment progresser sur ce chapitre ?", source_ids: [] }, + ]), + answer_key: CHAPTERS.flatMap((name) => [ + { id: `q_${name}`, answer: { correct_option: 0 }, detailed_solution: "A.", common_errors: [] }, + { id: `o_${name}`, answer: {}, detailed_solution: `Grille ${name}.`, common_errors: ["Oubli"] }, + ]), + }; +} + +const flashcards = { items: CHAPTERS.flatMap((name) => [1, 2].map((n) => ({ id: `f_${name}_${n}`, chapter_id: `ch_${name}`, front: `${name}-${n} ?`, back: `Résumé ${name}.` }))) }; + +test("the course map keeps examples, analogies, mistakes and lesson links as derived material", () => { + const base = parsePedagogicalBase({ courses: courses(), exercises: biasedExercises(), flashcards }); + assert.equal(base.classification, "DERIVED_MATERIAL"); + assert.equal(base.chapters.length, 5); + const alpha = base.chapters[0]; + assert.deepEqual([alpha.subject, alpha.example, alpha.analogy, alpha.mistakes], ["Matière fictive", "Exemple alpha.", "Analogie alpha.", "Erreur alpha."]); + assert.deepEqual(alpha.exerciseIds, ["q_alpha", "o_alpha"]); + assert.equal(base.answerKey.get("o_alpha")?.solution, "Grille alpha."); + assert.deepEqual(alpha.concepts.map((concept) => concept.label), ["alpha-1", "alpha-2"]); +}); + +test("an answer shipped inside a question never reaches the question objects", () => { + const exercises = biasedExercises(); + (exercises.items[0] as Record).detailed_solution = "fuite"; + const base = parsePedagogicalBase({ courses: courses(), exercises }); + assert.equal(base.leakedAnswers, 1); + assert.ok(!JSON.stringify(base.exercises).includes("fuite")); + assert.ok(auditPedagogicalBase(base).findings.some((finding) => finding.code === "ANSWER_IN_QUESTIONS")); +}); + +test("a guessable multiple-choice bank and copy-pasted flashcards are blocked for practice", () => { + const base = parsePedagogicalBase({ courses: courses(), exercises: biasedExercises(), flashcards }); + const report = auditPedagogicalBase(base); + const codes = new Map(report.findings.map((finding) => [finding.code, finding])); + assert.equal(codes.get("LONGEST_OPTION_IS_CORRECT")?.severity, "BLOCKING"); + assert.equal(codes.get("REUSED_DISTRACTORS")?.severity, "BLOCKING"); + assert.equal(codes.get("DUPLICATE_DEFINITIONS")?.count, 5); + assert.equal(codes.get("DUPLICATE_FLASHCARD_BACKS")?.severity, "BLOCKING"); + assert.equal(codes.get("TEMPLATE_PROMPTS")?.count, 5); + assert.equal(codes.get("UNSOURCED_EXERCISES")?.count, 10); + assert.deepEqual(report.usable, { courseMap: true, mcq: false, openExercises: true, flashcards: false }); + assert.deepEqual(practiceExercises(base, report, "ch_alpha").map((exercise) => exercise.id), ["o_alpha"]); +}); + +test("a balanced, sourced bank passes the audit", () => { + const exercises = { + items: CHAPTERS.map((name, index) => ({ id: `q_${name}`, chapter_id: `ch_${name}`, type: "qcm", prompt: `Question propre ${name} ?`, source_ids: ["src_1"], + options: [`Option ${name} un`, `Autre ${name} deux plus longue`, `Choix ${name} trois`, `Réponse ${name} quatre`] })), + answer_key: CHAPTERS.map((name, index) => ({ id: `q_${name}`, answer: { correct_option: index % 4 }, detailed_solution: "Justification." })), + }; + const cards = { items: CHAPTERS.map((name) => ({ id: `f_${name}`, chapter_id: `ch_${name}`, front: `${name} ?`, back: `Définition propre de ${name}.` })) }; + const report = auditPedagogicalBase(parsePedagogicalBase({ courses: courses(), exercises, flashcards: cards })); + assert.deepEqual(report.findings.filter((finding) => finding.severity === "BLOCKING"), []); + assert.equal(report.usable.mcq, true); + assert.equal(report.usable.flashcards, true); +}); + +test("a file that is not a course export is refused", () => { + assert.throws(() => parsePedagogicalBase({ courses: { hello: "world" } }), ImportError); +}); diff --git a/tests/unit/mcp/course-base.test.ts b/tests/unit/mcp/course-base.test.ts new file mode 100644 index 0000000..c6945cf --- /dev/null +++ b/tests/unit/mcp/course-base.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createServer } from "../../../mcp/elos-server.ts"; + +// Synthetic export: one guessable multiple-choice question and one open exercise per chapter. +const NAMES = ["un", "deux", "trois", "quatre", "cinq"]; + +function writeBase(): string { + const dir = mkdtempSync(path.join(tmpdir(), "elos-base-")); + const folder = path.join(dir, "base-fictive"); + mkdirSync(folder); + writeFileSync(path.join(folder, "courses.json"), JSON.stringify({ subjects: [{ subject: { name: "Matière fictive" }, modules: [{ chapters: NAMES.map((name) => ({ + id: `ch_${name}`, title: `Chapitre ${name}`, summary: "Résumé.", example: `Exemple ${name}.`, analogy: "Analogie.", mistakes: "Erreur.", + lessons: [{ objectives: ["Objectif"], prerequisites: ["Base"], concepts: [{ label: "a", definition: "Même." }, { label: "b", definition: "Même." }] }], + })) }] }] })); + writeFileSync(path.join(folder, "exercises.json"), JSON.stringify({ + items: NAMES.flatMap((name) => [ + { id: `q_${name}`, chapter_id: `ch_${name}`, type: "qcm", prompt: "QCM ?", options: ["La réponse longue et complète.", "Non.", "Faux.", "Rien."] }, + { id: `o_${name}`, chapter_id: `ch_${name}`, type: "open", prompt: `Explique ${name}.` }, + ]), + answer_key: NAMES.flatMap((name) => [ + { id: `q_${name}`, answer: { correct_option: 0 }, detailed_solution: "A." }, + { id: `o_${name}`, detailed_solution: `Corrigé ${name}.`, common_errors: ["Oubli"] }, + ]), + })); + return dir; +} + +async function connect(env: NodeJS.ProcessEnv) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await createServer(env).connect(serverTransport); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientTransport); + return async (name: string, args: Record = {}) => { + const result = await client.callTool({ name, arguments: args }) as { content: { text: string }[] }; + return JSON.parse(result.content[0].text) as Record; + }; +} + +test("the course map lists chapters with the audit, and hides copy-pasted definitions", async () => { + const call = await connect({ NODE_ENV: "test", ELOS_BASE_DIR: writeBase(), ELOS_OCR: "off" }); + const map = await call("elos_course_map") as { bases: { usable: { mcq: boolean }; chapters: { id: string; practiceExercises: number }[] }[] }; + assert.equal(map.bases[0].usable.mcq, false); + assert.deepEqual(map.bases[0].chapters.map((chapter) => chapter.practiceExercises), [1, 1, 1, 1, 1]); + const chapter = await call("elos_course_map", { chapter_id: "ch_un" }); + assert.equal(chapter.example, "Exemple un."); + assert.deepEqual(chapter.notions, ["a", "b"]); + assert.equal("definitions" in chapter, false); +}); + +test("an exercise is served without its solution, which is revealed only after an attempt", async () => { + const call = await connect({ NODE_ENV: "test", ELOS_BASE_DIR: writeBase(), ELOS_OCR: "off" }); + const exercise = await call("elos_course_exercise", { chapter_id: "ch_deux" }); + assert.equal(exercise.exercise_id, "o_deux", "the guessable multiple-choice question is never served"); + assert.ok(!JSON.stringify(exercise).includes("Corrigé")); + assert.match(String((await call("elos_course_exercise", { exercise_id: "o_deux" })).error), /answer first/); + assert.match(String((await call("elos_course_exercise", { exercise_id: "q_deux", answer: "A" })).error), /quality audit/); + const corrected = await call("elos_course_exercise", { exercise_id: "o_deux", answer: "Ma réponse." }); + assert.equal(corrected.referenceSolution, "Corrigé deux."); + assert.equal(corrected.verified, false); +}); + +test("without ELOS_BASE_DIR the tools explain how to configure the base", async () => { + const call = await connect({ NODE_ENV: "test", ELOS_OCR: "off" }); + assert.match(String((await call("elos_course_map")).error), /ELOS_BASE_DIR/); +}); From f174a4f5ae59190a8f90ca3f06f8ee5c6643ef83 Mon Sep 17 00:00:00 2001 From: Euloge Date: Fri, 23 Oct 2026 13:48:22 +0200 Subject: [PATCH 2/2] feat(elos): cited course questions per chapter and private answer keys - chapter-practice: multiple-choice questions built from the learner's own course files for each programme chapter, with page citations; wrong answers from the same subject (curated confusions first) with similar length; extracted terms cleaned of bullets and clause starts. - elos_course_exercise serves them first, grades them after the attempt, logs DERIVED_PRACTICE outside the shared folder and asks missed questions again; base exercises remain the fallback. - mcp:split-base moves answer keys out of the folder the agent browses (ELOS_ANSWERS_DIR); an inline key is reported. Co-Authored-By: Claude Opus 5.5 --- docs/integrations/HERMES.md | 13 +- mcp/elos-server.ts | 90 ++++++++++++-- mcp/split-base.ts | 20 +++ package.json | 1 + .../learning-studio/chapter-practice.ts | 117 ++++++++++++++++++ .../learning-studio/pedagogical-base.ts | 13 +- .../learning-studio/chapter-practice.test.ts | 50 ++++++++ tests/unit/mcp/course-base.test.ts | 59 ++++++++- 8 files changed, 346 insertions(+), 17 deletions(-) create mode 100644 mcp/split-base.ts create mode 100644 src/modules/learning-studio/chapter-practice.ts create mode 100644 tests/unit/learning-studio/chapter-practice.test.ts diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md index fbd82f8..6f91375 100644 --- a/docs/integrations/HERMES.md +++ b/docs/integrations/HERMES.md @@ -15,7 +15,7 @@ Aucun accès réseau ; les modules utilisés sont ceux de l’application (Studi | `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` | Exercice de la base sans corrigé ; le corrigé n’est rendu qu’après la réponse de l’apprenant. Les QCM qui échouent au contrôle qualité ne sont jamais servis | +| `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 @@ -35,8 +35,15 @@ Dans une session Hermes déjà ouverte : `/reload-mcp`. 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. -La séparation des corrigés est appliquée par les outils, pas par le système de fichiers : un agent qui dispose -d’outils de lecture de fichiers pourrait lire `exercises.json` directement. +Les corrigés sortent du dossier partagé avec `npm run mcp:split-base -- ` : +`exercises.json` ne garde que les questions et le serveur lit les corrigés dans `ELOS_ANSWERS_DIR/.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 +`/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. diff --git a/mcp/elos-server.ts b/mcp/elos-server.ts index a50221b..f5b915c 100644 --- a/mcp/elos-server.ts +++ b/mcp/elos-server.ts @@ -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"; @@ -19,6 +19,7 @@ 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"; @@ -199,22 +200,49 @@ const cite = (documents: readonly StudioDocument[], citation: { documentId: stri /** * 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/.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 }[] { +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(path.join(folder, file)) ? JSON.parse(readFileSync(path.join(folder, file), "utf8")) as unknown : undefined); + const read = (file: string) => (existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) as unknown : undefined); + const answersDir = env.ELOS_ANSWERS_DIR?.trim(); try { - const base = parsePedagogicalBase({ courses: read("courses.json"), exercises: read("exercises.json"), flashcards: read("flashcards.json") }); - return [{ name, base, report: auditPedagogicalBase(base) }]; + 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. */ @@ -371,6 +399,11 @@ 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}`, @@ -378,8 +411,8 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { }, 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 }) => ({ name, 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 })) })) }); + 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) { @@ -391,12 +424,36 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { 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 from the pedagogical base. With chapter_id: one exercise (statement only, no solution); multiple-choice questions that failed the quality audit are never served. With exercise_id and the learner's own answer: the reference solution and common errors, for the learner to compare (no score is recorded). 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() }, - }, async ({ chapter_id, exercise_id, answer }) => { + 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); @@ -409,6 +466,19 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { 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; diff --git a/mcp/split-base.ts b/mcp/split-base.ts new file mode 100644 index 0000000..7822ad2 --- /dev/null +++ b/mcp/split-base.ts @@ -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 -- +// Writes /.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 -- "); +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.`); diff --git a/package.json b/package.json index 9b6a3c9..6d76798 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/src/modules/learning-studio/chapter-practice.ts b/src/modules/learning-studio/chapter-practice.ts new file mode 100644 index 0000000..fa7e44f --- /dev/null +++ b/src/modules/learning-studio/chapter-practice.ts @@ -0,0 +1,117 @@ +import { deriveFlashcards, normalizeText, tokenize } from "./core"; +import { conceptKey, remediationPath } from "./prerequisites"; +import type { Flashcard, QuizQuestion, StudioDocument } from "./types"; + +/** + * Cited multiple-choice questions for a programme chapter, built from the learner's + * own course sources instead of the generated bank. + * + * The notions of the chapter are matched against definitions found in the sources; + * each question quotes its definition with the page. Wrong answers are notions a + * learner can really confuse with the right one (curated confusions, then the same + * document and nearby pages), with a length close to the right answer so that the + * longest option is not a giveaway. DERIVED_MATERIAL: practice, never mastery. + */ + +export type ChapterNotions = Readonly<{ subject: string; title: string; concepts: readonly Readonly<{ label: string }>[] }>; + +const MIN_LABEL_CHARS = 3; +const CHOICES = 4; + +function phrase(value: string): string { + return ` ${tokenize(value).join(" ")} `; +} + +// Extracted "terms" often carry list bullets or start a clause ("Si le protocole A", "Ce système"). +const LEADING_MARKS = /^(?:[^\p{L}\p{N}@]|Ø(?=\s))+/u; +const LEADING_ARTICLE = /^(?:(?:le|la|les|un|une|des|du)\s+|l[’']\s?)/i; +const CLAUSE_START = /^(?:si|donc|on|ce|cet|cette|ces|chaque|elle|elles|il|ils|cela|ça|tout|toute|tous|alors|mais)(?:\s|$)/i; +const CLAUSE_END = /(?:^|\s)(?:ne|n[’']|se|s[’']|qui|que|qu[’']|de|d[’']|à|en|y|et|ou)$/i; + +export function cleanTerm(term: string): string | null { + const stripped = term.replace(LEADING_MARKS, "").trim(); + if (CLAUSE_START.test(stripped) || CLAUSE_END.test(stripped)) return null; + const cleaned = stripped.replace(LEADING_ARTICLE, "").trim(); + if (cleaned.replace(/[^\p{L}\p{N}]/gu, "").length < 2) return null; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +function cleanCards(documents: readonly StudioDocument[]): Flashcard[] { + return deriveFlashcards(documents).flatMap((card) => { + const term = cleanTerm(card.term); + return term ? [{ ...card, term }] : []; + }); +} + +/** Documents of the chapter's subject (folder names sharing a word with it); all documents when none does. */ +export function subjectDocuments(documents: readonly StudioDocument[], subject: string): readonly StudioDocument[] { + const words = new Set(tokenize(subject)); + const matching = documents.filter((document) => tokenize(document.subjectLabel.replace(/[-_]+/g, " ")).some((word) => words.has(word))); + return matching.length > 0 ? matching : documents; +} + +/** A source notion belongs to the chapter when its term contains a chapter notion, or the reverse. */ +export function chapterCards(documents: readonly StudioDocument[], chapter: ChapterNotions): Flashcard[] { + const labels = chapter.concepts.map((concept) => concept.label).filter((label) => normalizeText(label).length >= MIN_LABEL_CHARS).map(phrase); + if (labels.length === 0) return []; + const seen = new Set(); + return cleanCards(subjectDocuments(documents, chapter.subject)).filter((card) => { + const term = phrase(card.term); + const key = conceptKey(card.term); + if (seen.has(key) || !labels.some((label) => term.includes(label) || label.includes(term))) return false; + seen.add(key); + return true; + }); +} + +function stableIndex(value: string, modulo: number): number { + let hash = 0; + for (const char of value) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + return hash % modulo; +} + +function distractorsFor(card: Flashcard, pool: readonly Flashcard[], inChapter: ReadonlySet): string[] { + const key = conceptKey(card.term); + const confused = new Set(remediationPath(card.term, pool, []).confusedWith.map(conceptKey)); + const page = card.citations[0]?.pageNumber ?? 0; + const answer = phrase(card.term); + const candidates = new Map(); + for (const other of pool) { + const otherKey = conceptKey(other.term); + const term = phrase(other.term); + // A wrong answer that contains the right one (or the reverse) would be ambiguous. + if (otherKey === key || term.includes(answer) || answer.includes(term)) continue; + const sameDocument = other.documentId === card.documentId; + const score = (confused.has(otherKey) ? 8 : 0) + (inChapter.has(otherKey) ? 4 : 0) + (sameDocument ? 2 : 0) + + (sameDocument && Math.abs((other.citations[0]?.pageNumber ?? 0) - page) <= 3 ? 1 : 0) + - Math.abs(other.term.length - card.term.length) / Math.max(other.term.length, card.term.length); + if ((candidates.get(otherKey)?.score ?? -Infinity) < score) candidates.set(otherKey, { term: other.term, score }); + } + return [...candidates.values()].sort((a, b) => b.score - a.score || a.term.localeCompare(b.term)).slice(0, CHOICES - 1).map((item) => item.term); +} + +export function chapterSourceQuiz(documents: readonly StudioDocument[], chapter: ChapterNotions, count = 5): QuizQuestion[] { + const cards = chapterCards(documents, chapter); + const pool = cleanCards(subjectDocuments(documents, chapter.subject)); + const inChapter = new Set(cards.map((card) => conceptKey(card.term))); + return cards.slice(0, count).flatMap((card) => { + const distractors = distractorsFor(card, pool, inChapter); + if (distractors.length < CHOICES - 1) return []; + const position = stableIndex(card.id, CHOICES); + const choices = [...distractors]; + choices.splice(position, 0, card.term); + return [{ + id: `quiz:${card.id}`, cardId: card.id, answer: card.term, choices, + prompt: `Quelle notion correspond à cette définition du cours : « … ${card.definition}. » ?`, + materialKind: "DERIVED_MATERIAL" as const, canonical: false as const, citations: card.citations, + }]; + }); +} + +/** Accepts the option letter (A–D), its number (1–4) or the option text. */ +export function isCorrectChoice(question: QuizQuestion, answer: string): boolean { + const value = answer.trim(); + const letter = /^[A-Da-d]$/.test(value) ? value.toUpperCase().charCodeAt(0) - 65 : /^[1-4]$/.test(value) ? Number(value) - 1 : -1; + const chosen = letter >= 0 ? question.choices[letter] : question.choices.find((choice) => normalizeText(choice) === normalizeText(value)); + return chosen === question.answer; +} diff --git a/src/modules/learning-studio/pedagogical-base.ts b/src/modules/learning-studio/pedagogical-base.ts index 40c330b..446c7a3 100644 --- a/src/modules/learning-studio/pedagogical-base.ts +++ b/src/modules/learning-studio/pedagogical-base.ts @@ -69,7 +69,16 @@ const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : []) const text = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); const texts = (value: unknown): string[] => array(value).map(text).filter(Boolean); -export function parsePedagogicalBase(input: Readonly<{ courses: unknown; exercises?: unknown; flashcards?: unknown }>): PedagogicalBase & { leakedAnswers: number } { +/** + * Moves the answer key out of exercises.json so the folder shared with an agent holds questions only; + * the key is stored elsewhere and read by the MCP server alone. + */ +export function splitAnswerKey(exercises: unknown): { questions: Json; answerKey: unknown[] } { + const { answer_key: answerKey, ...questions } = object(exercises); + return { questions, answerKey: array(answerKey) }; +} + +export function parsePedagogicalBase(input: Readonly<{ courses: unknown; exercises?: unknown; flashcards?: unknown; answerKey?: unknown }>): PedagogicalBase & { leakedAnswers: number } { const subjects = array(object(input.courses).subjects); if (subjects.length === 0) throw new ImportError("courses.json invalide : aucune matière (subjects) trouvée."); @@ -111,7 +120,7 @@ export function parsePedagogicalBase(input: Readonly<{ courses: unknown; exercis }); const answerKey = new Map(); - for (const raw of array(exercisesFile.answer_key).map(object)) { + for (const raw of array(input.answerKey ?? exercisesFile.answer_key).map(object)) { const id = text(raw.id); if (!id) continue; const correct = object(raw.answer).correct_option; diff --git a/tests/unit/learning-studio/chapter-practice.test.ts b/tests/unit/learning-studio/chapter-practice.test.ts new file mode 100644 index 0000000..d292593 --- /dev/null +++ b/tests/unit/learning-studio/chapter-practice.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chapterCards, chapterSourceQuiz, cleanTerm, isCorrectChoice, subjectDocuments } from "../../../src/modules/learning-studio/chapter-practice.ts"; +import { buildImportedDocument, textToSections } from "../../../src/modules/learning-studio/import.ts"; + +// Synthetic course files (no real course content). +function doc(id: string, subject: string, text: string) { + return buildImportedDocument(id, { title: id, subjectLabel: subject, chapter: "", docType: "COURS" }, textToSections(text), "section", new Date(0)); +} + +const reseaux = doc("file:reseaux.md", "Reseaux", [ + "• Le masque de sous-réseau est la valeur qui sépare la partie réseau de la partie hôte.", + "Une adresse IPv4 est un identifiant de 32 bits attribué à une interface.", + "La passerelle désigne le routeur qui relie le réseau local aux autres réseaux.", + "Le commutateur est un équipement qui relie les machines d'un même réseau local.", + "Le routeur est un équipement qui achemine les paquets entre plusieurs réseaux.", + "Si le paquet est trop grand alors il est fragmenté en plusieurs morceaux.", +].join("\n\n")); +const communication = doc("file:com.md", "Communication-gestion-projet", "Le masque de saisie est un gabarit qui contrôle les caractères tapés dans un formulaire."); +const chapter = { subject: "Architecture des réseaux", title: "IPv4 et masques", concepts: [{ label: "IPv4" }, { label: "masque" }] }; + +test("extracted terms lose bullets and articles; clause fragments are rejected", () => { + assert.equal(cleanTerm("• Le masque de sous-réseau"), "Masque de sous-réseau"); + assert.equal(cleanTerm("→ L'encapsulation"), "Encapsulation"); + assert.equal(cleanTerm("Si le paquet"), null); + assert.equal(cleanTerm("IP ne"), null); +}); + +test("a chapter only uses the course files of its own subject", () => { + assert.deepEqual(subjectDocuments([reseaux, communication], chapter.subject).map((document) => document.id), ["file:reseaux.md"]); + assert.deepEqual(chapterCards([reseaux, communication], chapter).map((card) => card.term), ["Masque de sous-réseau", "Adresse IPv4"]); + assert.equal(subjectDocuments([communication], "Matière inconnue").length, 1, "falls back to every document"); +}); + +test("cited questions: same-subject wrong answers, no ambiguous option, gradable by letter or text", () => { + const quiz = chapterSourceQuiz([reseaux, communication], chapter); + assert.equal(quiz.length, 2); + for (const question of quiz) { + assert.equal(question.choices.length, 4); + assert.equal(new Set(question.choices).size, 4); + assert.ok(question.choices.includes(question.answer)); + assert.ok(!question.choices.some((choice) => /saisie/.test(choice)), "no notion from another subject"); + assert.ok(question.citations[0].quote.length > 0); + assert.equal(question.materialKind, "DERIVED_MATERIAL"); + const letter = String.fromCharCode(65 + question.choices.indexOf(question.answer)); + assert.equal(isCorrectChoice(question, letter), true); + assert.equal(isCorrectChoice(question, question.answer.toLowerCase()), true); + assert.equal(isCorrectChoice(question, question.choices.find((choice) => choice !== question.answer)!), false); + } +}); diff --git a/tests/unit/mcp/course-base.test.ts b/tests/unit/mcp/course-base.test.ts index c6945cf..67aeccf 100644 --- a/tests/unit/mcp/course-base.test.ts +++ b/tests/unit/mcp/course-base.test.ts @@ -1,11 +1,12 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { createServer } from "../../../mcp/elos-server.ts"; +import { createServer, practiceLogFile, readPractice } from "../../../mcp/elos-server.ts"; +import { splitAnswerKey } from "../../../src/modules/learning-studio/pedagogical-base.ts"; // Synthetic export: one guessable multiple-choice question and one open exercise per chapter. const NAMES = ["un", "deux", "trois", "quatre", "cinq"]; @@ -69,3 +70,57 @@ test("without ELOS_BASE_DIR the tools explain how to configure the base", async const call = await connect({ NODE_ENV: "test", ELOS_OCR: "off" }); assert.match(String((await call("elos_course_map")).error), /ELOS_BASE_DIR/); }); + +test("answer keys moved outside the shared folder are still used, and inline keys are reported", async () => { + const dir = writeBase(); + const exercisesFile = path.join(dir, "base-fictive", "exercises.json"); + const { questions, answerKey } = splitAnswerKey(JSON.parse(readFileSync(exercisesFile, "utf8"))); + const answers = mkdtempSync(path.join(tmpdir(), "elos-answers-")); + writeFileSync(exercisesFile, JSON.stringify(questions)); + writeFileSync(path.join(answers, "base-fictive.json"), JSON.stringify(answerKey)); + assert.ok(!readFileSync(exercisesFile, "utf8").includes("Corrigé"), "the shared folder holds questions only"); + + const call = await connect({ NODE_ENV: "test", ELOS_BASE_DIR: dir, ELOS_ANSWERS_DIR: answers, ELOS_OCR: "off" }); + const map = await call("elos_course_map") as { bases: { warning?: string }[] }; + assert.equal(map.bases[0].warning, undefined); + assert.equal((await call("elos_course_exercise", { exercise_id: "o_trois", answer: "Ma réponse." })).referenceSolution, "Corrigé trois."); + + const inline = await connect({ NODE_ENV: "test", ELOS_BASE_DIR: writeBase(), ELOS_OCR: "off" }); + assert.match(String(((await inline("elos_course_map")) as { bases: { warning?: string }[] }).bases[0].warning), /split-base/); +}); + +test("course questions come from the learner's files, are graded after the attempt and logged outside the shared folder", async () => { + const base = mkdtempSync(path.join(tmpdir(), "elos-src-")); + const root = path.join(base, "cours"); + mkdirSync(path.join(root, "Reseaux"), { recursive: true }); + writeFileSync(path.join(root, "Reseaux", "ip.md"), [ + "Le masque de sous-réseau est la valeur qui sépare la partie réseau de la partie hôte.", + "Une adresse IPv4 est un identifiant de 32 bits attribué à une interface.", + "La passerelle désigne le routeur qui relie le réseau local aux autres réseaux.", + "Le commutateur est un équipement qui relie les machines d'un même réseau local.", + ].join("\n\n")); + const bases = path.join(base, "bases", "lp"); + mkdirSync(bases, { recursive: true }); + writeFileSync(path.join(bases, "courses.json"), JSON.stringify({ subjects: [{ subject: { name: "Architecture des réseaux" }, modules: [{ chapters: [{ id: "ch_ip", title: "IPv4", lessons: [{ concepts: [{ label: "IPv4" }, { label: "masque" }] }] }] }] }] })); + const env: NodeJS.ProcessEnv = { NODE_ENV: "test", ELOS_SOURCES_DIR: root, ELOS_BASE_DIR: path.dirname(bases), ELOS_OCR: "off" }; + const call = await connect(env); + + const question = await call("elos_course_exercise", { chapter_id: "ch_ip" }) as { exercise_id: string; type: string; options: string[] }; + assert.equal(question.type, "qcm_from_courses"); + assert.equal(question.options.length, 4); + assert.ok(!JSON.stringify(question).includes("quote"), "no citation before the attempt"); + assert.match(String((await call("elos_course_exercise", { exercise_id: question.exercise_id })).error), /answer first/); + + const wrong = question.options.find((option) => !/IPv4|Masque/.test(option)) ?? question.options[0]; + const graded = await call("elos_course_exercise", { exercise_id: question.exercise_id, answer: wrong.slice(0, 1) }) as { correct: boolean; citation: { quote: string }; evidence: string }; + assert.equal(typeof graded.correct, "boolean"); + assert.ok(graded.citation.quote.length > 0); + assert.match(graded.evidence, /not mastery/); + + const log = practiceLogFile(root, env); + assert.ok(!log.startsWith(root + path.sep), "the log lives outside the shared course folder"); + assert.ok(existsSync(log)); + assert.equal(readPractice(log).length, 1); + const map = await call("elos_course_map") as { bases: { chapters: { practice: { answered: number } | null }[] }[] }; + assert.equal(map.bases[0].chapters[0].practice?.answered, 1); +});