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%
4.4 KB · 101 lines typescript
Raw Blame History
1// Générateur de plan d'étude : répartit les concepts sur les jours disponibles avant l'examen,2// avec espacement (chaque notion revient ≥ 2 fois) et entrelacement (2-3 thèmes par séance).3// Le temps alloué à un concept est proportionnel à (importance × faiblesse).45import { masteryForCourse } from "./mastery.ts";67export type PlanConfig = {8  examDate: string;         // ISO9  weekdays: number[];       // 0=dim … 6=sam — jours disponibles10  minutesPerSession: number;11  weeksScope: [number, number]; // semaines de cours couvertes (ex. [1,6] pour l'intra)12  todayISO?: string;        // injectable pour les tests13};1415export type PlanItem = { kind: "review" | "flashcards" | "quiz" | "exam" | "reading"; conceptSlug: string | null; label: string; minutes: number; done?: boolean };16export type PlanDay = { date: string; items: PlanItem[] };1718export function generatePlan(userId: number, courseCode: string, cfg: PlanConfig): PlanDay[] {19  const today = cfg.todayISO ? new Date(cfg.todayISO) : new Date();20  const exam = new Date(cfg.examDate + "T12:00:00");21  const days: string[] = [];22  for (let d = new Date(today); d < exam; d.setDate(d.getDate() + 1)) {23    if (cfg.weekdays.includes(d.getDay())) days.push(d.toISOString().slice(0, 10));24  }25  if (!days.length) return [];2627  const mastery = masteryForCourse(userId, courseCode).filter(28    (c) => (c.week ?? 0) >= cfg.weeksScope[0] && (c.week ?? 99) <= cfg.weeksScope[1]29  );30  if (!mastery.length) return [];3132  // Poids : importance × (1 − maîtrise), plancher pour que tout soit revu au moins une fois.33  const weighted = mastery.map((c) => ({ c, w: Math.max(0.15, c.importance * (1 - c.score)) }));34  const totalW = weighted.reduce((s, x) => s + x.w, 0);35  const totalMinutes = days.length * cfg.minutesPerSession;36  // Réserver ~20 % pour les examens blancs / révisions générales de fin de parcours.37  const conceptMinutes = totalMinutes * 0.8;3839  // File de blocs de 15-25 minutes par concept, à répartir en round-robin pondéré.40  type Block = { slug: string; name: string; kind: PlanItem["kind"]; minutes: number };41  const blocks: Block[] = [];42  for (const { c, w } of weighted) {43    const minutes = Math.max(20, Math.round((conceptMinutes * w) / totalW / 5) * 5);44    let remaining = minutes;45    let first = true;46    while (remaining > 0) {47      const m = Math.min(25, Math.max(15, remaining));48      blocks.push({49        slug: c.slug,50        name: c.name,51        kind: first ? (c.score < 0.3 ? "review" : "flashcards") : remaining <= 25 ? "quiz" : "flashcards",52        minutes: m,53      });54      remaining -= m;55      first = false;56    }57  }5859  // Entrelacement : trier par (semaine, passe) puis distribuer en serpentin sur les jours.60  const plan: PlanDay[] = days.map((date) => ({ date, items: [] }));61  let dayIdx = 0;62  const capacity = plan.map(() => cfg.minutesPerSession);63  for (const b of blocks) {64    // trouver le prochain jour avec de la place, en évitant 2 blocs consécutifs du même concept65    let attempts = 0;66    while (attempts < plan.length) {67      const i = dayIdx % plan.length;68      const last = plan[i].items.at(-1);69      if (capacity[i] >= b.minutes && (!last || last.conceptSlug !== b.slug)) {70        plan[i].items.push({71          kind: b.kind,72          conceptSlug: b.slug,73          label:74            b.kind === "review"75              ? `Revoir « ${b.name} » (explication + exemple)`76              : b.kind === "quiz"77                ? `Quiz ciblé : ${b.name}`78                : `Cartes mémoire : ${b.name}`,79          minutes: b.minutes,80        });81        capacity[i] -= b.minutes;82        dayIdx++;83        break;84      }85      dayIdx++;86      attempts++;87    }88  }8990  // Examens blancs : mi-parcours et avant-dernier jour disponible.91  const examBlock = (label: string): PlanItem => ({ kind: "exam", conceptSlug: null, label, minutes: Math.min(90, cfg.minutesPerSession) });92  if (plan.length >= 4) plan[Math.floor(plan.length / 2)].items.push(examBlock("Examen blanc de mi-parcours (mode pratique)"));93  if (plan.length >= 2) plan[plan.length - 1].items.push(examBlock("Examen blanc complet (mode chronométré)"));94  else plan[plan.length - 1].items.push(examBlock("Examen blanc (mode chronométré)"));9596  // Dernier jour : révision du cahier d'erreurs.97  plan[plan.length - 1].items.push({ kind: "review", conceptSlug: null, label: "Revoir le cahier d'erreurs (toutes les entrées « à revoir »)", minutes: 20 });9899  return plan.filter((d) => d.items.length);100}101