// Maîtrise estimée par concept : score composite [0,1] mis à jour par lissage exponentiel, // dont le pas dépend de la force de l'évidence (type de tâche, difficulté, autonomie, confiance). // Présenté partout comme une ESTIMATION. Voir docs/learning-science-strategy.md. import { all, get, run } from "../db/index.ts"; export type MasteryLevel = "a-decouvrir" | "en-construction" | "solide" | "maitrise"; export function levelFor(score: number): MasteryLevel { if (score >= 0.85) return "maitrise"; if (score >= 0.6) return "solide"; if (score >= 0.3) return "en-construction"; return "a-decouvrir"; } export const LEVEL_LABELS: Record = { "a-decouvrir": "À découvrir", "en-construction": "En construction", solide: "Solide", maitrise: "Maîtrisé", }; const KIND_WEIGHT: Record = { exam: 1.0, quiz: 0.75, correction: 0.7, flashcard: 0.45 }; export function recordMasteryEvent(opts: { userId: number; conceptId: number; kind: "flashcard" | "quiz" | "exam" | "correction"; correct: boolean; difficulty?: number; // 1-5 autonomy?: number; // 1 sans aide ; 0.5 avec indices confidence?: number; // 1-5 déclaré }) { const difficulty = Math.min(5, Math.max(1, opts.difficulty ?? 3)); const autonomy = Math.min(1, Math.max(0.2, opts.autonomy ?? 1)); run( "INSERT INTO mastery_events (user_id, concept_id, kind, correct, difficulty, autonomy, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)", opts.userId, opts.conceptId, opts.kind, opts.correct ? 1 : 0, difficulty, autonomy, opts.confidence ?? null ); const row = get<{ score: number; observations: number }>( "SELECT score, observations FROM mastery WHERE user_id = ? AND concept_id = ?", opts.userId, opts.conceptId ); const prior = row?.score ?? 0.15; const observations = (row?.observations ?? 0) + 1; // Force de l'évidence : tâche exigeante réussie sans aide = signal fort. const kindW = KIND_WEIGHT[opts.kind] ?? 0.5; const diffW = 0.6 + difficulty * 0.13; // 0.73 → 1.25 let evidence = kindW * diffW * autonomy; // Cible de l'observation : réussite → vers le haut (plus haut si difficile), // échec → vers le bas (plus bas si l'item était facile). const target = opts.correct ? Math.min(1, 0.7 + difficulty * 0.06) : Math.max(0, 0.35 - (5 - difficulty) * 0.06); // Calibration de la confiance : sur-confiance erronée légèrement pénalisée. if (opts.confidence != null) { const conf = (opts.confidence - 1) / 4; if (opts.correct && Math.abs(conf - 1) < 0.3) evidence *= 1.05; if (!opts.correct && conf > 0.7) evidence *= 1.15; } const alpha = Math.min(0.5, 0.12 + evidence * 0.22); // pas d'apprentissage borné const score = Math.min(1, Math.max(0, prior + alpha * (target - prior))); run( `INSERT INTO mastery (user_id, concept_id, score, observations, updated_at) VALUES (?, ?, ?, ?, datetime('now')) ON CONFLICT(user_id, concept_id) DO UPDATE SET score = excluded.score, observations = excluded.observations, updated_at = excluded.updated_at`, opts.userId, opts.conceptId, score, observations ); return score; } /** Score avec décroissance temporelle (demi-vie 14 jours sans activité). */ export function decayedScore(score: number, updatedAt: string): number { const days = (Date.now() - new Date(updatedAt + "Z").getTime()) / 86400_000; if (!isFinite(days) || days <= 0) return score; const decay = Math.pow(0.5, days / 14); // On ne retombe jamais sous 40 % du score acquis : l'oubli n'est pas total. return score * (0.4 + 0.6 * decay); } export type ConceptMastery = { conceptId: number; slug: string; name: string; week: number | null; importance: number; axis: string; score: number; level: MasteryLevel; observations: number; }; export function masteryForCourse(userId: number, courseCode: string): ConceptMastery[] { const rows = all<{ id: number; slug: string; name: string; week: number | null; importance: number; axis: string; score: number | null; observations: number | null; updated_at: string | null; }>( `SELECT c.id, c.slug, c.name, c.week, c.importance, c.axis, m.score, m.observations, m.updated_at FROM concepts c LEFT JOIN mastery m ON m.concept_id = c.id AND m.user_id = ? WHERE c.course_code = ? ORDER BY c.week, c.id`, userId, courseCode ); return rows.map((r) => { const raw = r.score == null ? 0 : decayedScore(r.score, r.updated_at ?? ""); return { conceptId: r.id, slug: r.slug, name: r.name, week: r.week, importance: r.importance, axis: r.axis, score: raw, level: levelFor(raw), observations: r.observations ?? 0, }; }); } /** Profil texte des faiblesses (injecté dans le mode Révision ciblée). */ export function weaknessProfile(userId: number, courseCode: string, max = 8): string { const items = masteryForCourse(userId, courseCode) .filter((c) => c.observations > 0) .sort((a, b) => a.score - b.score) .slice(0, max); if (!items.length) return "Aucune donnée de pratique encore — commencer par un diagnostic général."; return items .map((c) => `- ${c.name} (semaine ${c.week ?? "?"}) : maîtrise estimée ${(c.score * 100).toFixed(0)} % (${LEVEL_LABELS[c.level]})`) .join("\n"); }