SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
5.2 KB · 126 lines typescript
Raw Blame History
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