diff --git a/docs/integrations/HERMES.md b/docs/integrations/HERMES.md index 6f91375..304faeb 100644 --- a/docs/integrations/HERMES.md +++ b/docs/integrations/HERMES.md @@ -42,9 +42,19 @@ terminal tourne avec ton compte : c’est une protection contre la lecture accid 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 +(puces, débuts de proposition comme « Si… ») sont écartés. Quand un chapitre n’a pas de définition +« X est… » dans les cours (cas fréquent des diapositives), une phrase du cours qui emploie la notion est proposée +avec la notion masquée ; les mauvaises réponses sont les notions des autres chapitres de la même matière. Les +phrases retenues sont de vraies phrases (pas d’horodatage de transcription, de ligne de tableau, de code ni de +titres de diapositives collés), sans doublon, et un mot courant (« objet », « revue ») n’est demandé que si la +phrase partage un mot-clé avec le chapitre. Les réponses sont journalisées dans `/practice-log.jsonl` (`DERIVED_PRACTICE`), hors du dossier partagé. +Retour vers l’application : après chaque réponse, le serveur écrit `elos-practice.json` à côté de +`ELOS_STATE_FILE` (format `engineer-learning-os/agent-practice` v1, des comptes par chapitre seulement). +ELOS le lit dans le dossier déjà partagé et l’affiche dans **Données → Agent local (Hermes)** comme +entraînement assisté ; il ne modifie aucun niveau de maîtrise et un fichier invalide est ignoré. + 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 f5b915c..a83fe7d 100644 --- a/mcp/elos-server.ts +++ b/mcp/elos-server.ts @@ -28,6 +28,7 @@ import { ROLEPLAY_SCENARIOS, evaluateTurn } from "../src/modules/daily-english/r import { VOCABULARY, VOCABULARY_PACKS } from "../src/modules/daily-english/vocabulary.ts"; import { replaySchedulers, type ReviewEvent } from "../src/modules/scientific-pedagogy/replay.ts"; import { parseLearnerSnapshot, type LearnerSnapshot } from "../src/modules/learner-snapshot/core.ts"; +import { AGENT_PRACTICE_FILE, buildAgentPractice } from "../src/modules/learner-snapshot/agent-practice.ts"; const MAX_FILES = 1000; const MAX_FILE_BYTES = 25 * 1024 * 1024; @@ -243,6 +244,15 @@ function recordPractice(file: string, entry: PracticeEntry): void { appendFileSync(file, JSON.stringify(entry) + "\n", { mode: 0o600 }); } +/** Hands the per-chapter practice summary back to the app, next to the learner snapshot (counts only). */ +export function publishPractice(log: string, chapters: readonly { id: string; title: string; subject: string }[], env: NodeJS.ProcessEnv = process.env): void { + const stateFile = env.ELOS_STATE_FILE?.trim(); + if (!stateFile) return; + try { + writeFileSync(path.join(path.dirname(path.resolve(stateFile)), AGENT_PRACTICE_FILE), JSON.stringify(buildAgentPractice(readPractice(log), chapters, new Date()), null, 2)); + } catch { /* the app keeps the previous summary */ } +} + const STALE_MS = 24 * 3_600_000; /** Reads the learner snapshot written by the app (ELOS_STATE_FILE); read-only, strictly validated. */ @@ -427,7 +437,10 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { const findChapter = (chapterId: string) => { for (const { base } of loadBases(env)) { const chapter = base.chapters.find((item) => item.id === chapterId); - if (chapter) return chapter; + if (chapter) { + const related = base.chapters.filter((item) => item.subject === chapter.subject && item.id !== chapter.id).flatMap((item) => item.concepts.map((concept) => concept.label)); + return { ...chapter, related, context: [chapter.summary, chapter.example, chapter.mistakes, ...chapter.objectives].join(" ") }; + } } return null; }; @@ -451,7 +464,11 @@ export function createServer(env: NodeJS.ProcessEnv = process.env): McpServer { 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 }); + if (root) { + const log = practiceLogFile(root, env); + recordPractice(log, { at: new Date().toISOString(), chapterId: chapter.id, questionId: exercise_id, correct }); + publishPractice(log, bases.flatMap(({ base }) => base.chapters), env); + } return json({ correct, rightAnswer: question.answer, learnerAnswer: answer, citation: cite(documents, question.citations[0]), evidence: "DERIVED_PRACTICE (not mastery)" }); } if (exercise_id) { diff --git a/src/components/learner-snapshot/AgentBridgePanel.tsx b/src/components/learner-snapshot/AgentBridgePanel.tsx index b21af55..b6db40c 100644 --- a/src/components/learner-snapshot/AgentBridgePanel.tsx +++ b/src/components/learner-snapshot/AgentBridgePanel.tsx @@ -4,7 +4,8 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { Bot, FolderOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { SNAPSHOT_FILE, chooseFolder, downloadSnapshot, folderSyncSupported, forgetFolder, savedFolder, syncSnapshot } from "@/modules/learner-snapshot/browser-bridge"; +import { SNAPSHOT_FILE, chooseFolder, downloadSnapshot, folderSyncSupported, forgetFolder, readAgentPractice, savedFolder, syncSnapshot } from "@/modules/learner-snapshot/browser-bridge"; +import type { AgentPractice } from "@/modules/learner-snapshot/agent-practice"; type Folder = Awaited>; const noSubscription = () => () => {}; @@ -13,10 +14,17 @@ const noSubscription = () => () => {}; export function AgentBridgePanel() { const [folder, setFolder] = useState(null); const [status, setStatus] = useState(null); + const [practice, setPractice] = useState(null); // Server render cannot know the browser API: decide on the client only (no hydration mismatch). const supported = useSyncExternalStore(noSubscription, folderSyncSupported, () => false); useEffect(() => { savedFolder().then(setFolder).catch(() => {}); }, []); + useEffect(() => { + if (!folder) return; + let alive = true; + void readAgentPractice(folder).then((value) => { if (alive) setPractice(value); }); + return () => { alive = false; }; + }, [folder, status]); async function run(action: () => Promise) { try { setStatus(await action()); } catch (error) { setStatus(error instanceof Error && error.name === "AbortError" ? "Choix annulé." : "Opération impossible."); } @@ -51,6 +59,20 @@ export function AgentBridgePanel() { ) :

Ce navigateur ne permet pas d’écrire dans un dossier : utilise le téléchargement ci-dessous (ou Chrome).

} {status &&

{status}

} + {practice && practice.chapters.length > 0 && ( +
+

Entraînement avec l’agent

+

QCM tirés de tes cours et faits avec Hermes. C’est de l’entraînement assisté : il ne change aucun niveau de maîtrise.

+
    + {practice.chapters.map((chapter) => ( +
  • + {chapter.title}{chapter.subject ? · {chapter.subject} : null} + {chapter.correct}/{chapter.answered} justes{chapter.toRetry > 0 ? ` · ${chapter.toRetry} à revoir` : ""} +
  • + ))} +
+
+ )} ); diff --git a/src/modules/learner-snapshot/agent-practice.ts b/src/modules/learner-snapshot/agent-practice.ts new file mode 100644 index 0000000..0cdabff --- /dev/null +++ b/src/modules/learner-snapshot/agent-practice.ts @@ -0,0 +1,66 @@ +/** + * Practice done with a local agent (Hermes through the ELOS MCP server), handed back to + * the app as a per-chapter summary next to the learner snapshot. + * + * DERIVED_PRACTICE by design: questions answered through an agent are assisted and + * generated, so this summary is shown as practice and never changes a mastery state. + * It holds counts only: no question text, answer or course passage. + */ + +export const AGENT_PRACTICE_FORMAT = "engineer-learning-os/agent-practice"; +export const AGENT_PRACTICE_VERSION = 1; +export const AGENT_PRACTICE_FILE = "elos-practice.json"; + +export type PracticeAttempt = Readonly<{ at: string; chapterId: string; questionId: string; correct: boolean }>; +export type ChapterLabel = Readonly<{ id: string; title: string; subject: string }>; + +export type AgentPracticeChapter = Readonly<{ + chapterId: string; + title: string; + subject: string; + answered: number; + correct: number; + /** Questions whose latest answer was wrong: what the agent will ask again first. */ + toRetry: number; + lastAt: string; +}>; + +export type AgentPractice = Readonly<{ + format: typeof AGENT_PRACTICE_FORMAT; + version: typeof AGENT_PRACTICE_VERSION; + generatedAt: string; + evidence: "DERIVED_PRACTICE"; + chapters: readonly AgentPracticeChapter[]; +}>; + +export function buildAgentPractice(attempts: readonly PracticeAttempt[], chapters: readonly ChapterLabel[], now: Date): AgentPractice { + const labels = new Map(chapters.map((chapter) => [chapter.id, chapter])); + const byChapter = new Map(); + for (const attempt of [...attempts].sort((a, b) => a.at.localeCompare(b.at))) { + byChapter.set(attempt.chapterId, [...(byChapter.get(attempt.chapterId) ?? []), attempt]); + } + return { + format: AGENT_PRACTICE_FORMAT, version: AGENT_PRACTICE_VERSION, generatedAt: now.toISOString(), evidence: "DERIVED_PRACTICE", + chapters: [...byChapter].map(([chapterId, list]) => { + const latest = new Map(list.map((attempt) => [attempt.questionId, attempt.correct])); + return { + chapterId, title: labels.get(chapterId)?.title ?? chapterId, subject: labels.get(chapterId)?.subject ?? "", + answered: list.length, correct: list.filter((attempt) => attempt.correct).length, + toRetry: [...latest.values()].filter((correct) => !correct).length, lastAt: list[list.length - 1].at, + }; + }).sort((a, b) => b.lastAt.localeCompare(a.lastAt)), + }; +} + +const count = (value: unknown) => typeof value === "number" && Number.isInteger(value) && value >= 0; + +/** Strict validation for the app: an unknown or damaged file is rejected, never partially trusted. */ +export function parseAgentPractice(text: string): AgentPractice { + const value = JSON.parse(text) as Partial; + const valid = value?.format === AGENT_PRACTICE_FORMAT && value.version === AGENT_PRACTICE_VERSION && value.evidence === "DERIVED_PRACTICE" + && typeof value.generatedAt === "string" && Array.isArray(value.chapters) + && value.chapters.every((chapter) => typeof chapter?.chapterId === "string" && typeof chapter.title === "string" && typeof chapter.subject === "string" + && count(chapter.answered) && count(chapter.correct) && count(chapter.toRetry) && chapter.correct <= chapter.answered && typeof chapter.lastAt === "string"); + if (!valid) throw new Error("INVALID_AGENT_PRACTICE"); + return value as AgentPractice; +} diff --git a/src/modules/learner-snapshot/browser-bridge.ts b/src/modules/learner-snapshot/browser-bridge.ts index 634d91a..e0d4d27 100644 --- a/src/modules/learner-snapshot/browser-bridge.ts +++ b/src/modules/learner-snapshot/browser-bridge.ts @@ -6,6 +6,7 @@ import { deriveFlashcards, readStudioState } from "../learning-studio/core"; import { listImportedDocuments } from "../learning-studio/browser-library"; import { SYNTHETIC_DOCUMENTS } from "../learning-studio/fixtures"; import { buildLearnerSnapshot, type LearnerSnapshot } from "./core"; +import { AGENT_PRACTICE_FILE, parseAgentPractice, type AgentPractice } from "./agent-practice"; // Writes the learner snapshot to a folder chosen by the learner (File System Access // API, Chrome / Edge), so a local agent can read it. Nothing is uploaded anywhere. @@ -20,7 +21,10 @@ type DirectoryHandle = { name: string; queryPermission(options: { mode: "readwrite" }): Promise; requestPermission(options: { mode: "readwrite" }): Promise; - getFileHandle(name: string, options: { create: boolean }): Promise<{ createWritable(): Promise<{ write(data: string): Promise; close(): Promise }> }>; + getFileHandle(name: string, options: { create: boolean }): Promise<{ + createWritable(): Promise<{ write(data: string): Promise; close(): Promise }>; + getFile(): Promise<{ text(): Promise }>; + }>; }; type PickerWindow = Window & { showDirectoryPicker?: (options: { mode: "readwrite"; id?: string }) => Promise }; @@ -98,6 +102,14 @@ export async function syncSnapshot(handle: DirectoryHandle, options: { force?: b return "written"; } +/** Reads the practice summary the agent wrote in the shared folder; null when absent, unreadable or invalid. */ +export async function readAgentPractice(handle: DirectoryHandle): Promise { + try { + if (await handle.queryPermission({ mode: "readwrite" }) !== "granted") return null; + return parseAgentPractice(await (await (await handle.getFileHandle(AGENT_PRACTICE_FILE, { create: false })).getFile()).text()); + } catch { return null; } +} + export async function downloadSnapshot(): Promise { const blob = new Blob([JSON.stringify(await collectSnapshot(), null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); diff --git a/src/modules/learning-studio/chapter-practice.ts b/src/modules/learning-studio/chapter-practice.ts index fa7e44f..1136e4d 100644 --- a/src/modules/learning-studio/chapter-practice.ts +++ b/src/modules/learning-studio/chapter-practice.ts @@ -1,4 +1,4 @@ -import { deriveFlashcards, normalizeText, tokenize } from "./core"; +import { allPassages, citationFor, deriveFlashcards, normalizeText, splitSentences, tokenize } from "./core"; import { conceptKey, remediationPath } from "./prerequisites"; import type { Flashcard, QuizQuestion, StudioDocument } from "./types"; @@ -13,7 +13,15 @@ import type { Flashcard, QuizQuestion, StudioDocument } from "./types"; * longest option is not a giveaway. DERIVED_MATERIAL: practice, never mastery. */ -export type ChapterNotions = Readonly<{ subject: string; title: string; concepts: readonly Readonly<{ label: string }>[] }>; +export type ChapterNotions = Readonly<{ + subject: string; + title: string; + concepts: readonly Readonly<{ label: string }>[]; + /** Notions of the other chapters of the same subject: wrong answers for sentence questions. */ + related?: readonly string[]; + /** Chapter text (summary, example…) used to keep common-word notions in their sense. */ + context?: string; +}>; const MIN_LABEL_CHARS = 3; const CHOICES = 4; @@ -90,7 +98,96 @@ function distractorsFor(card: Flashcard, pool: readonly Flashcard[], inChapter: return [...candidates.values()].sort((a, b) => b.score - a.score || a.term.localeCompare(b.term)).slice(0, CHOICES - 1).map((item) => item.term); } +// --- Sentence questions: a course sentence with the notion blanked out ------------------------ +// Slides rarely say "X est…"; they still use the notion in context. The learner finds which +// notion of the subject completes the quoted sentence. + +const BLANK = "_____"; +const SENTENCE_CHARS = { min: 50, max: 240 }; +const MIN_WORDS = 7; +const SENTENCES_PER_NOTION = 3; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Whole-word, case-insensitive: "for" never matches "format". */ +function wordPattern(label: string): RegExp { + return new RegExp(`(?]/g) ?? []).length >= 2 || (text.match(/[/·|]/g) ?? []).length >= 2) return null; + if ((text.match(/\d/g) ?? []).length / text.length > 0.08) return null; + // Slide headings glued together read as one long title-cased "sentence". + const words = text.split(/\s+/).slice(1); + if (words.filter((word) => /^\p{Lu}/u.test(word)).length / Math.max(1, words.length) > 0.25) return null; + return text; +} + +/** A single lowercase word ("objet", "revue") has several senses; acronyms and multi-word terms do not. */ +function commonWord(label: string): boolean { + return !/\s/.test(label.trim()) && label === label.toLowerCase(); +} + +function clozeQuestions(documents: readonly StudioDocument[], chapter: ChapterNotions): QuizQuestion[] { + const labels = chapter.concepts.map((concept) => concept.label.trim()).filter((label) => normalizeText(label).length >= MIN_LABEL_CHARS); + const pool = [...new Set([...(chapter.related ?? []), ...labels].map((label) => label.trim()).filter((label) => normalizeText(label).length >= MIN_LABEL_CHARS))]; + const passages = allPassages(subjectDocuments(documents, chapter.subject)); + const questions: QuizQuestion[] = []; + const asked = new Set(); + const context = new Set(tokenize(`${chapter.title} ${chapter.context ?? ""}`)); + for (const label of labels) { + const pattern = wordPattern(label); + const own = new Set(tokenize(label)); + const inSense = (sentence: string) => !commonWord(label) || tokenize(sentence).some((token) => !own.has(token) && context.has(token)); + let found = 0; + for (const { document, page, passage } of passages) { + if (found >= SENTENCES_PER_NOTION) break; + for (const raw of splitSentences(passage.text)) { + if (found >= SENTENCES_PER_NOTION) break; + const sentence = readableSentence(raw); + if (!sentence || !inSense(sentence) || asked.has(normalizeText(sentence))) continue; + if (sentence.length < SENTENCE_CHARS.min || sentence.length > SENTENCE_CHARS.max || sentence.includes("?")) continue; + if (sentence.split(/\s+/).length < MIN_WORDS || (sentence.match(pattern) ?? []).length !== 1) continue; + const answer = phrase(label); + const distractors = pool + .filter((other) => { const term = phrase(other); return !term.includes(answer) && !answer.includes(term) && !wordPattern(other).test(sentence); }) + .sort((a, b) => Math.abs(a.length - label.length) - Math.abs(b.length - label.length) || a.localeCompare(b)) + .slice(0, CHOICES - 1); + if (distractors.length < CHOICES - 1) continue; + const id = `cloze:${document.id}:${page.pageNumber}:${passage.id}:${conceptKey(label)}`; + const choices = [...distractors]; + choices.splice(stableIndex(id, CHOICES), 0, label); + questions.push({ + id: `quiz:${id}`, cardId: id, answer: label, choices, + prompt: `Quelle notion complète cette phrase du cours : « ${sentence.replace(pattern, BLANK)} » ?`, + materialKind: "DERIVED_MATERIAL", canonical: false, citations: [citationFor(document, page.pageNumber, passage.id, sentence)], + }); + asked.add(normalizeText(sentence)); + found += 1; + } + } + } + return questions; +} + +/** Definition questions first; sentence questions cover chapters whose sources define nothing. */ export function chapterSourceQuiz(documents: readonly StudioDocument[], chapter: ChapterNotions, count = 5): QuizQuestion[] { + const definitions = definitionQuestions(documents, chapter); + const covered = (label: string) => definitions.some((question) => phrase(question.answer).includes(phrase(label))); + const sentences = clozeQuestions(documents, chapter).filter((question) => !covered(question.answer)); + return [...definitions, ...sentences].slice(0, count); +} + +function definitionQuestions(documents: readonly StudioDocument[], chapter: ChapterNotions, count = Infinity): QuizQuestion[] { const cards = chapterCards(documents, chapter); const pool = cleanCards(subjectDocuments(documents, chapter.subject)); const inChapter = new Set(cards.map((card) => conceptKey(card.term))); diff --git a/tests/unit/learner-snapshot/agent-practice.test.ts b/tests/unit/learner-snapshot/agent-practice.test.ts new file mode 100644 index 0000000..901c8c4 --- /dev/null +++ b/tests/unit/learner-snapshot/agent-practice.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildAgentPractice, parseAgentPractice } from "../../../src/modules/learner-snapshot/agent-practice.ts"; + +const chapters = [{ id: "ch_a", title: "Chapitre A", subject: "Matière fictive" }]; +const attempts = [ + { at: "2026-01-01T10:00:00Z", chapterId: "ch_a", questionId: "q1", correct: false }, + { at: "2026-01-02T10:00:00Z", chapterId: "ch_a", questionId: "q1", correct: true }, + { at: "2026-01-02T11:00:00Z", chapterId: "ch_a", questionId: "q2", correct: false }, + { at: "2026-01-03T09:00:00Z", chapterId: "ch_inconnu", questionId: "q3", correct: true }, +]; + +test("per-chapter counts: the latest answer decides what is left to retry", () => { + const practice = buildAgentPractice(attempts, chapters, new Date("2026-01-04T00:00:00Z")); + assert.equal(practice.evidence, "DERIVED_PRACTICE"); + const a = practice.chapters.find((chapter) => chapter.chapterId === "ch_a")!; + assert.deepEqual([a.title, a.answered, a.correct, a.toRetry, a.lastAt], ["Chapitre A", 3, 1, 1, "2026-01-02T11:00:00Z"]); + assert.equal(practice.chapters[0].chapterId, "ch_inconnu", "most recent first; unknown chapters keep their id"); + assert.ok(!JSON.stringify(practice).includes("q1"), "counts only, no question identifiers"); +}); + +test("the app accepts a valid summary and rejects damaged or inflated ones", () => { + const text = JSON.stringify(buildAgentPractice(attempts, chapters, new Date(0))); + assert.equal(parseAgentPractice(text).chapters.length, 2); + const inflated = JSON.parse(text); inflated.chapters[0].correct = 99; + assert.throws(() => parseAgentPractice(JSON.stringify(inflated)), /INVALID_AGENT_PRACTICE/); + const mastery = JSON.parse(text); mastery.evidence = "MASTERY"; + assert.throws(() => parseAgentPractice(JSON.stringify(mastery)), /INVALID_AGENT_PRACTICE/); +}); + +test("the app reads the summary from the shared folder, and ignores it without permission or when damaged", async () => { + const { readAgentPractice } = await import("../../../src/modules/learner-snapshot/browser-bridge.ts"); + const folder = (content: string, permission: "granted" | "prompt" = "granted") => ({ + name: "ELOS-Hermes", + queryPermission: async () => permission, + requestPermission: async () => permission, + getFileHandle: async (name: string) => { + if (name !== "elos-practice.json") throw new Error("NotFound"); + return { getFile: async () => ({ text: async () => content }), createWritable: async () => { throw new Error("read only"); } }; + }, + }); + const valid = JSON.stringify(buildAgentPractice(attempts, chapters, new Date(0))); + assert.equal((await readAgentPractice(folder(valid)))?.chapters.length, 2); + assert.equal(await readAgentPractice(folder(valid, "prompt")), null); + assert.equal(await readAgentPractice(folder("{not json")), null); +}); diff --git a/tests/unit/learning-studio/chapter-practice.test.ts b/tests/unit/learning-studio/chapter-practice.test.ts index d292593..057e8f6 100644 --- a/tests/unit/learning-studio/chapter-practice.test.ts +++ b/tests/unit/learning-studio/chapter-practice.test.ts @@ -1,6 +1,6 @@ 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 { chapterCards, chapterSourceQuiz, cleanTerm, isCorrectChoice, readableSentence, 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). @@ -48,3 +48,39 @@ test("cited questions: same-subject wrong answers, no ambiguous option, gradable assert.equal(isCorrectChoice(question, question.choices.find((choice) => choice !== question.answer)!), false); } }); + +const projet = doc("file:projet.md", "Communication-gestion-projet", [ + "Chaque sprint dure au maximum trois semaines et se termine par une démonstration au client.", + "Le backlog regroupe les fonctionnalités attendues, classées par valeur pour le client.", + "[12:04.5 - 12:09.1] Le sprint commence lundi matin avec toute l’équipe réunie.", + "Tableau Sprint Durée Livrable Objectif Valeur Semaine Repère Responsable Statut Sprint.", + "Le programme est écrit dans un langage orienté objet très répandu aujourd’hui.", +].join("\n\n")); +const agile = { subject: "Gestion de projet", title: "Planification agile et sprints", concepts: [{ label: "sprint" }, { label: "backlog" }], + related: ["AMDEC", "McCall", "cahier des charges", "objet"], context: "Le backlog est découpé en sprints courts ; chaque démonstration au client valide la valeur produite." }; + +test("sentence questions: blanked notion, quoted sentence, same-subject wrong answers", () => { + const quiz = chapterSourceQuiz([projet], agile, 10); + assert.equal(quiz.length, 2); + for (const question of quiz) { + assert.match(question.prompt, /_____/); + assert.ok(!new RegExp(`\\b${question.answer}\\b`, "i").test(question.prompt), "the answer is hidden"); + assert.equal(question.choices.length, 4); + assert.ok(question.citations[0].quote.includes(question.answer)); + } +}); + +test("timestamps, glued slide headings and duplicate sentences never become questions", () => { + assert.equal(readableSentence("[12:04.5 - 12:09.1] Le sprint commence lundi matin avec toute l’équipe."), null); + assert.equal(readableSentence("Tableau Sprint Durée Livrable Objectif Valeur Semaine Repère Responsable Statut Sprint."), null); + assert.equal(readableSentence("CREATE TABLE Client (id NUMBER, nom VARCHAR2(50));"), null); + assert.equal(readableSentence("• Chaque sprint dure trois semaines."), "Chaque sprint dure trois semaines."); + const twice = doc("file:double.md", "Communication-gestion-projet", "Chaque sprint dure au maximum trois semaines et se termine par une démonstration au client."); + assert.equal(chapterSourceQuiz([projet, twice], agile, 10).filter((question) => question.answer === "sprint").length, 1); +}); + +test("a common word is only asked in the chapter's sense", () => { + const email = { subject: "Communication", title: "Rédaction des courriels professionnels", concepts: [{ label: "objet" }], + related: ["sprint", "backlog", "AMDEC"], context: "L’objet du courriel résume la demande en quelques mots." }; + assert.deepEqual(chapterSourceQuiz([projet], email, 10), [], "« langage orienté objet » is another sense"); +}); diff --git a/tests/unit/mcp/course-base.test.ts b/tests/unit/mcp/course-base.test.ts index 67aeccf..3dbce82 100644 --- a/tests/unit/mcp/course-base.test.ts +++ b/tests/unit/mcp/course-base.test.ts @@ -7,6 +7,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createServer, practiceLogFile, readPractice } from "../../../mcp/elos-server.ts"; import { splitAnswerKey } from "../../../src/modules/learning-studio/pedagogical-base.ts"; +import { parseAgentPractice } from "../../../src/modules/learner-snapshot/agent-practice.ts"; // Synthetic export: one guessable multiple-choice question and one open exercise per chapter. const NAMES = ["un", "deux", "trois", "quatre", "cinq"]; @@ -102,7 +103,7 @@ test("course questions come from the learner's files, are graded after the attem 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 env: NodeJS.ProcessEnv = { NODE_ENV: "test", ELOS_SOURCES_DIR: root, ELOS_BASE_DIR: path.dirname(bases), ELOS_STATE_FILE: path.join(base, "elos-state.json"), 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[] }; @@ -121,6 +122,8 @@ test("course questions come from the learner's files, are graded after the attem 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 summary = parseAgentPractice(readFileSync(path.join(base, "elos-practice.json"), "utf8")); + assert.deepEqual([summary.chapters[0].chapterId, summary.chapters[0].answered], ["ch_ip", 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); });