// Moteur de quiz adaptatif : escalier de difficulté (± 1 selon les 2 dernières réponses), // ciblage 60 % faiblesses / 25 % consolidation / 15 % découverte. import { all, get } from "../db/index.ts"; import { masteryForCourse } from "./mastery.ts"; export type QuizQuestion = { id: number; course_code: string; concept_id: number | null; type: string; difficulty: number; question: string; options: string; // JSON answer: string; explanation: string; }; export function startingDifficulty(score: number): number { if (score >= 0.85) return 4; if (score >= 0.6) return 3; if (score >= 0.3) return 2; return 1; } export function nextDifficulty(current: number, lastTwo: boolean[]): number { if (lastTwo.length >= 2 && lastTwo[0] && lastTwo[1]) return Math.min(5, current + 1); if (lastTwo.length >= 1 && !lastTwo[0]) return Math.max(1, current - 1); return current; } /** Choisit le prochain concept à interroger selon le mix faiblesses/consolidation/découverte. */ export function pickTargetConcept(userId: number, courseCode: string, focusConceptId: number | null, rand = Math.random()): number | null { if (focusConceptId) return focusConceptId; const mastery = masteryForCourse(userId, courseCode); const withQuestions = new Set( all<{ concept_id: number }>( "SELECT DISTINCT concept_id FROM quiz_questions WHERE course_code = ? AND concept_id IS NOT NULL", courseCode ).map((r) => r.concept_id) ); const candidates = mastery.filter((c) => withQuestions.has(c.conceptId)); if (!candidates.length) return null; const weak = candidates.filter((c) => c.observations > 0 && c.score < 0.6); const consolidate = candidates.filter((c) => c.score >= 0.6 && c.score < 0.9); const fresh = candidates.filter((c) => c.observations === 0); const pool = rand < 0.6 && weak.length ? weak : rand < 0.85 && consolidate.length ? consolidate : fresh.length ? fresh : candidates; // pondération par importance du concept const weighted: typeof pool = []; for (const c of pool) for (let i = 0; i < c.importance; i++) weighted.push(c); return weighted[Math.floor(rand * weighted.length) % weighted.length].conceptId; } /** Prochaine question : concept ciblé, difficulté voulue, en évitant les questions déjà vues récemment. */ export function pickQuestion(opts: { userId: number; courseCode: string; conceptId: number | null; difficulty: number; excludeIds: number[]; }): QuizQuestion | null { const seen = all<{ question_id: number }>( `SELECT DISTINCT qa.question_id FROM quiz_answers qa JOIN quiz_sessions qs ON qs.id = qa.session_id WHERE qs.user_id = ? AND qa.answered_at >= datetime('now','-3 days')`, opts.userId ).map((r) => r.question_id); const exclude = [...new Set([...opts.excludeIds, ...seen])]; const excludeSql = exclude.length ? `AND q.id NOT IN (${exclude.map(() => "?").join(",")})` : ""; // Essais par distance croissante de difficulté, puis sans exclusion des vues récentes. for (const relax of [false, true]) { for (const dist of [0, 1, 2, 3, 4]) { const params: unknown[] = [opts.courseCode]; let sql = `SELECT q.* FROM quiz_questions q WHERE q.course_code = ?`; if (opts.conceptId) { sql += " AND q.concept_id = ?"; params.push(opts.conceptId); } sql += ` AND ABS(q.difficulty - ?) <= ?`; params.push(opts.difficulty, dist); if (!relax && exclude.length) { sql += ` AND q.id NOT IN (${exclude.map(() => "?").join(",")})`; params.push(...exclude); } else if (relax && opts.excludeIds.length) { sql += ` AND q.id NOT IN (${opts.excludeIds.map(() => "?").join(",")})`; params.push(...opts.excludeIds); } sql += " ORDER BY RANDOM() LIMIT 1"; const q = get(sql, ...params); if (q) return q; } // au 2e passage : abandonner le concept ciblé if (opts.conceptId) opts = { ...opts, conceptId: null }; } return null; } /** Correction d'une réponse (côté serveur). */ export function gradeAnswer(question: QuizQuestion, userAnswer: string): boolean { const ua = userAnswer.trim().toLowerCase(); const expected = question.answer.trim().toLowerCase(); if (question.type === "mcq") return ua === expected; if (question.type === "calc") { // tolérance numérique 1 % si les deux contiennent un nombre const num = (s: string) => { const m = s.replace(/\s|\$/g, "").replace(/,/g, ".").match(/-?\d+(\.\d+)?/g); return m ? parseFloat(m[m.length - 1]) : NaN; }; const a = num(ua), b = num(expected); if (isFinite(a) && isFinite(b) && b !== 0) return Math.abs(a - b) / Math.abs(b) <= 0.01; return ua === expected; } // short / error-detect : correspondance permissive (mots clés de la réponse attendue) if (ua === expected) return true; const keywords = expected.split(/[,;]| et /).map((k) => k.trim()).filter((k) => k.length > 3); if (!keywords.length) return ua.includes(expected) || expected.includes(ua); const hits = keywords.filter((k) => ua.includes(k)).length; return hits >= Math.ceil(keywords.length * 0.6); }