TypeScript 98.3%
CSS 0.9%
Shell 0.7%
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