TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Maîtrise estimée par concept : score composite [0,1] mis à jour par lissage exponentiel,2// dont le pas dépend de la force de l'évidence (type de tâche, difficulté, autonomie, confiance).3// Présenté partout comme une ESTIMATION. Voir docs/learning-science-strategy.md.45import { all, get, run } from "../db/index.ts";67export type MasteryLevel = "a-decouvrir" | "en-construction" | "solide" | "maitrise";89export function levelFor(score: number): MasteryLevel {10 if (score >= 0.85) return "maitrise";11 if (score >= 0.6) return "solide";12 if (score >= 0.3) return "en-construction";13 return "a-decouvrir";14}1516export const LEVEL_LABELS: Record<MasteryLevel, string> = {17 "a-decouvrir": "À découvrir",18 "en-construction": "En construction",19 solide: "Solide",20 maitrise: "Maîtrisé",21};2223const KIND_WEIGHT: Record<string, number> = { exam: 1.0, quiz: 0.75, correction: 0.7, flashcard: 0.45 };2425export function recordMasteryEvent(opts: {26 userId: number;27 conceptId: number;28 kind: "flashcard" | "quiz" | "exam" | "correction";29 correct: boolean;30 difficulty?: number; // 1-531 autonomy?: number; // 1 sans aide ; 0.5 avec indices32 confidence?: number; // 1-5 déclaré33}) {34 const difficulty = Math.min(5, Math.max(1, opts.difficulty ?? 3));35 const autonomy = Math.min(1, Math.max(0.2, opts.autonomy ?? 1));36 run(37 "INSERT INTO mastery_events (user_id, concept_id, kind, correct, difficulty, autonomy, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)",38 opts.userId, opts.conceptId, opts.kind, opts.correct ? 1 : 0, difficulty, autonomy, opts.confidence ?? null39 );4041 const row = get<{ score: number; observations: number }>(42 "SELECT score, observations FROM mastery WHERE user_id = ? AND concept_id = ?",43 opts.userId, opts.conceptId44 );45 const prior = row?.score ?? 0.15;46 const observations = (row?.observations ?? 0) + 1;4748 // Force de l'évidence : tâche exigeante réussie sans aide = signal fort.49 const kindW = KIND_WEIGHT[opts.kind] ?? 0.5;50 const diffW = 0.6 + difficulty * 0.13; // 0.73 → 1.2551 let evidence = kindW * diffW * autonomy;5253 // Cible de l'observation : réussite → vers le haut (plus haut si difficile),54 // échec → vers le bas (plus bas si l'item était facile).55 const target = opts.correct ? Math.min(1, 0.7 + difficulty * 0.06) : Math.max(0, 0.35 - (5 - difficulty) * 0.06);5657 // Calibration de la confiance : sur-confiance erronée légèrement pénalisée.58 if (opts.confidence != null) {59 const conf = (opts.confidence - 1) / 4;60 if (opts.correct && Math.abs(conf - 1) < 0.3) evidence *= 1.05;61 if (!opts.correct && conf > 0.7) evidence *= 1.15;62 }6364 const alpha = Math.min(0.5, 0.12 + evidence * 0.22); // pas d'apprentissage borné65 const score = Math.min(1, Math.max(0, prior + alpha * (target - prior)));6667 run(68 `INSERT INTO mastery (user_id, concept_id, score, observations, updated_at) VALUES (?, ?, ?, ?, datetime('now'))69 ON CONFLICT(user_id, concept_id) DO UPDATE SET score = excluded.score, observations = excluded.observations, updated_at = excluded.updated_at`,70 opts.userId, opts.conceptId, score, observations71 );72 return score;73}7475/** Score avec décroissance temporelle (demi-vie 14 jours sans activité). */76export function decayedScore(score: number, updatedAt: string): number {77 const days = (Date.now() - new Date(updatedAt + "Z").getTime()) / 86400_000;78 if (!isFinite(days) || days <= 0) return score;79 const decay = Math.pow(0.5, days / 14);80 // On ne retombe jamais sous 40 % du score acquis : l'oubli n'est pas total.81 return score * (0.4 + 0.6 * decay);82}8384export type ConceptMastery = {85 conceptId: number;86 slug: string;87 name: string;88 week: number | null;89 importance: number;90 axis: string;91 score: number;92 level: MasteryLevel;93 observations: number;94};9596export function masteryForCourse(userId: number, courseCode: string): ConceptMastery[] {97 const rows = all<{98 id: number; slug: string; name: string; week: number | null; importance: number; axis: string;99 score: number | null; observations: number | null; updated_at: string | null;100 }>(101 `SELECT c.id, c.slug, c.name, c.week, c.importance, c.axis, m.score, m.observations, m.updated_at102 FROM concepts c LEFT JOIN mastery m ON m.concept_id = c.id AND m.user_id = ?103 WHERE c.course_code = ? ORDER BY c.week, c.id`,104 userId, courseCode105 );106 return rows.map((r) => {107 const raw = r.score == null ? 0 : decayedScore(r.score, r.updated_at ?? "");108 return {109 conceptId: r.id, slug: r.slug, name: r.name, week: r.week, importance: r.importance, axis: r.axis,110 score: raw, level: levelFor(raw), observations: r.observations ?? 0,111 };112 });113}114115/** Profil texte des faiblesses (injecté dans le mode Révision ciblée). */116export function weaknessProfile(userId: number, courseCode: string, max = 8): string {117 const items = masteryForCourse(userId, courseCode)118 .filter((c) => c.observations > 0)119 .sort((a, b) => a.score - b.score)120 .slice(0, max);121 if (!items.length) return "Aucune donnée de pratique encore — commencer par un diagnostic général.";122 return items123 .map((c) => `- ${c.name} (semaine ${c.week ?? "?"}) : maîtrise estimée ${(c.score * 100).toFixed(0)} % (${LEVEL_LABELS[c.level]})`)124 .join("\n");125}126