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.0 KB · 121 lines typescript
Raw Blame History
1// Moteur de quiz adaptatif : escalier de difficulté (± 1 selon les 2 dernières réponses),2// ciblage 60 % faiblesses / 25 % consolidation / 15 % découverte.34import { all, get } from "../db/index.ts";5import { masteryForCourse } from "./mastery.ts";67export type QuizQuestion = {8  id: number;9  course_code: string;10  concept_id: number | null;11  type: string;12  difficulty: number;13  question: string;14  options: string; // JSON15  answer: string;16  explanation: string;17};1819export function startingDifficulty(score: number): number {20  if (score >= 0.85) return 4;21  if (score >= 0.6) return 3;22  if (score >= 0.3) return 2;23  return 1;24}2526export function nextDifficulty(current: number, lastTwo: boolean[]): number {27  if (lastTwo.length >= 2 && lastTwo[0] && lastTwo[1]) return Math.min(5, current + 1);28  if (lastTwo.length >= 1 && !lastTwo[0]) return Math.max(1, current - 1);29  return current;30}3132/** Choisit le prochain concept à interroger selon le mix faiblesses/consolidation/découverte. */33export function pickTargetConcept(userId: number, courseCode: string, focusConceptId: number | null, rand = Math.random()): number | null {34  if (focusConceptId) return focusConceptId;35  const mastery = masteryForCourse(userId, courseCode);36  const withQuestions = new Set(37    all<{ concept_id: number }>(38      "SELECT DISTINCT concept_id FROM quiz_questions WHERE course_code = ? AND concept_id IS NOT NULL",39      courseCode40    ).map((r) => r.concept_id)41  );42  const candidates = mastery.filter((c) => withQuestions.has(c.conceptId));43  if (!candidates.length) return null;44  const weak = candidates.filter((c) => c.observations > 0 && c.score < 0.6);45  const consolidate = candidates.filter((c) => c.score >= 0.6 && c.score < 0.9);46  const fresh = candidates.filter((c) => c.observations === 0);47  const pool = rand < 0.6 && weak.length ? weak : rand < 0.85 && consolidate.length ? consolidate : fresh.length ? fresh : candidates;48  // pondération par importance du concept49  const weighted: typeof pool = [];50  for (const c of pool) for (let i = 0; i < c.importance; i++) weighted.push(c);51  return weighted[Math.floor(rand * weighted.length) % weighted.length].conceptId;52}5354/** Prochaine question : concept ciblé, difficulté voulue, en évitant les questions déjà vues récemment. */55export function pickQuestion(opts: {56  userId: number;57  courseCode: string;58  conceptId: number | null;59  difficulty: number;60  excludeIds: number[];61}): QuizQuestion | null {62  const seen = all<{ question_id: number }>(63    `SELECT DISTINCT qa.question_id FROM quiz_answers qa64     JOIN quiz_sessions qs ON qs.id = qa.session_id65     WHERE qs.user_id = ? AND qa.answered_at >= datetime('now','-3 days')`,66    opts.userId67  ).map((r) => r.question_id);68  const exclude = [...new Set([...opts.excludeIds, ...seen])];69  const excludeSql = exclude.length ? `AND q.id NOT IN (${exclude.map(() => "?").join(",")})` : "";7071  // Essais par distance croissante de difficulté, puis sans exclusion des vues récentes.72  for (const relax of [false, true]) {73    for (const dist of [0, 1, 2, 3, 4]) {74      const params: unknown[] = [opts.courseCode];75      let sql = `SELECT q.* FROM quiz_questions q WHERE q.course_code = ?`;76      if (opts.conceptId) {77        sql += " AND q.concept_id = ?";78        params.push(opts.conceptId);79      }80      sql += ` AND ABS(q.difficulty - ?) <= ?`;81      params.push(opts.difficulty, dist);82      if (!relax && exclude.length) {83        sql += ` AND q.id NOT IN (${exclude.map(() => "?").join(",")})`;84        params.push(...exclude);85      } else if (relax && opts.excludeIds.length) {86        sql += ` AND q.id NOT IN (${opts.excludeIds.map(() => "?").join(",")})`;87        params.push(...opts.excludeIds);88      }89      sql += " ORDER BY RANDOM() LIMIT 1";90      const q = get<QuizQuestion>(sql, ...params);91      if (q) return q;92    }93    // au 2e passage : abandonner le concept ciblé94    if (opts.conceptId) opts = { ...opts, conceptId: null };95  }96  return null;97}9899/** Correction d'une réponse (côté serveur). */100export function gradeAnswer(question: QuizQuestion, userAnswer: string): boolean {101  const ua = userAnswer.trim().toLowerCase();102  const expected = question.answer.trim().toLowerCase();103  if (question.type === "mcq") return ua === expected;104  if (question.type === "calc") {105    // tolérance numérique 1 % si les deux contiennent un nombre106    const num = (s: string) => {107      const m = s.replace(/\s|\$/g, "").replace(/,/g, ".").match(/-?\d+(\.\d+)?/g);108      return m ? parseFloat(m[m.length - 1]) : NaN;109    };110    const a = num(ua), b = num(expected);111    if (isFinite(a) && isFinite(b) && b !== 0) return Math.abs(a - b) / Math.abs(b) <= 0.01;112    return ua === expected;113  }114  // short / error-detect : correspondance permissive (mots clés de la réponse attendue)115  if (ua === expected) return true;116  const keywords = expected.split(/[,;]| et /).map((k) => k.trim()).filter((k) => k.length > 3);117  if (!keywords.length) return ua.includes(expected) || expected.includes(ua);118  const hits = keywords.filter((k) => ua.includes(k)).length;119  return hits >= Math.ceil(keywords.length * 0.6);120}121