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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/integrations/HERMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<cache>/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.

Expand Down
21 changes: 19 additions & 2 deletions mcp/elos-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
};
Expand All @@ -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) {
Expand Down
24 changes: 23 additions & 1 deletion src/components/learner-snapshot/AgentBridgePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof savedFolder>>;
const noSubscription = () => () => {};
Expand All @@ -13,10 +14,17 @@ const noSubscription = () => () => {};
export function AgentBridgePanel() {
const [folder, setFolder] = useState<Folder>(null);
const [status, setStatus] = useState<string | null>(null);
const [practice, setPractice] = useState<AgentPractice | null>(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]);
Comment on lines +22 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh practice when the agent updates the file

If Hermes records an answer while the Data page remains mounted, only elos-practice.json changes; neither folder nor status changes, so this effect never reads the new counts. AgentAutoSync also only writes the snapshot and does not notify this panel, leaving the displayed practice stale until navigation/remount or a status transition. Polling while visible or reacting to visibility/focus would make the new hand-back behavior observable.

Useful? React with 👍 / 👎.


async function run(action: () => Promise<string>) {
try { setStatus(await action()); } catch (error) { setStatus(error instanceof Error && error.name === "AbortError" ? "Choix annulé." : "Opération impossible."); }
Expand Down Expand Up @@ -51,6 +59,20 @@ export function AgentBridgePanel() {
) : <p className="text-slate-600">Ce navigateur ne permet pas d’écrire dans un dossier : utilise le téléchargement ci-dessous (ou Chrome).</p>}
<Button type="button" size="sm" variant="ghost" onClick={() => run(async () => { await downloadSnapshot(); return `${SNAPSHOT_FILE} téléchargé.`; })}>Télécharger {SNAPSHOT_FILE}</Button>
{status && <p role="status" className="text-xs text-emerald-800">{status}</p>}
{practice && practice.chapters.length > 0 && (
<section aria-label="Entraînement avec l’agent" className="space-y-2 border-t pt-3">
<p className="font-medium">Entraînement avec l’agent</p>
<p className="text-xs text-slate-600">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.</p>
<ul className="space-y-1">
{practice.chapters.map((chapter) => (
<li key={chapter.chapterId} className="flex flex-wrap justify-between gap-2 text-xs">
<span>{chapter.title}{chapter.subject ? <span className="text-slate-500"> · {chapter.subject}</span> : null}</span>
<span>{chapter.correct}/{chapter.answered} justes{chapter.toRetry > 0 ? ` · ${chapter.toRetry} à revoir` : ""}</span>
</li>
))}
</ul>
</section>
)}
</CardContent>
</Card>
);
Expand Down
66 changes: 66 additions & 0 deletions src/modules/learner-snapshot/agent-practice.ts
Original file line number Diff line number Diff line change
@@ -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<string, PracticeAttempt[]>();
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<AgentPractice>;
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;
}
14 changes: 13 additions & 1 deletion src/modules/learner-snapshot/browser-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -20,7 +21,10 @@ type DirectoryHandle = {
name: string;
queryPermission(options: { mode: "readwrite" }): Promise<Permission>;
requestPermission(options: { mode: "readwrite" }): Promise<Permission>;
getFileHandle(name: string, options: { create: boolean }): Promise<{ createWritable(): Promise<{ write(data: string): Promise<void>; close(): Promise<void> }> }>;
getFileHandle(name: string, options: { create: boolean }): Promise<{
createWritable(): Promise<{ write(data: string): Promise<void>; close(): Promise<void> }>;
getFile(): Promise<{ text(): Promise<string> }>;
}>;
};
type PickerWindow = Window & { showDirectoryPicker?: (options: { mode: "readwrite"; id?: string }) => Promise<DirectoryHandle> };

Expand Down Expand Up @@ -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<AgentPractice | null> {
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<void> {
const blob = new Blob([JSON.stringify(await collectSnapshot(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
Expand Down
Loading
Loading