TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Quiz adaptatif : démarrage, question suivante, réponse, fin de session.2import { NextResponse } from "next/server";3import { z } from "zod";4import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts";5import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts";6import { all, get, run } from "@/lib/db/index.ts";7import { gradeAnswer, nextDifficulty, pickQuestion, pickTargetConcept, startingDifficulty, type QuizQuestion } from "@/lib/learning/quiz.ts";8import { masteryForCourse, recordMasteryEvent } from "@/lib/learning/mastery.ts";9import { logActivity } from "@/lib/usage.ts";1011const startSchema = z.object({12 action: z.literal("start"),13 course: z.enum(["IMM1003", "IMM1033"]),14 conceptSlug: z.string().max(120).nullable().optional(),15});16const answerSchema = z.object({17 action: z.literal("answer"),18 sessionId: z.number().int().positive(),19 questionId: z.number().int().positive(),20 answer: z.string().max(4000),21 confidence: z.number().int().min(1).max(5).optional(),22 hintsUsed: z.number().int().min(0).max(5).default(0),23});24const nextSchema = z.object({25 action: z.literal("next"),26 sessionId: z.number().int().positive(),27});28const finishSchema = z.object({29 action: z.literal("finish"),30 sessionId: z.number().int().positive(),31});3233function sessionState(sessionId: number, userId: number) {34 const s = get<{ id: number; user_id: number; course_code: string; focus_concept_id: number | null; n_correct: number; n_total: number; finished_at: string | null }>(35 "SELECT * FROM quiz_sessions WHERE id = ?", sessionId36 );37 if (!s || s.user_id !== userId) return null;38 return s;39}4041function publicQuestion(q: QuizQuestion) {42 return {43 id: q.id, type: q.type, difficulty: q.difficulty, question: q.question,44 options: JSON.parse(q.options || "[]") as string[], conceptId: q.concept_id,45 };46}4748function pickNext(userId: number, courseCode: string, focusConceptId: number | null, sessionId: number) {49 const answered = all<{ question_id: number }>(50 "SELECT question_id FROM quiz_answers WHERE session_id = ?", sessionId51 ).map((r) => r.question_id);52 const lastTwo = all<{ correct: number; difficulty: number }>(53 `SELECT qa.correct, q.difficulty FROM quiz_answers qa JOIN quiz_questions q ON q.id = qa.question_id54 WHERE qa.session_id = ? ORDER BY qa.id DESC LIMIT 2`,55 sessionId56 );57 const conceptId = pickTargetConcept(userId, courseCode, focusConceptId);58 const mastery = masteryForCourse(userId, courseCode).find((m) => m.conceptId === conceptId);59 let difficulty = startingDifficulty(mastery?.score ?? 0);60 if (lastTwo.length) {61 difficulty = nextDifficulty(lastTwo[0].difficulty, lastTwo.map((l) => !!l.correct));62 }63 return pickQuestion({ userId, courseCode, conceptId, difficulty, excludeIds: answered });64}6566export async function POST(req: Request) {67 try {68 await assertSameOrigin();69 const user = await requireUser();70 const body = await parseBody(req, z.discriminatedUnion("action", [startSchema, answerSchema, nextSchema, finishSchema]));7172 if (body.action === "start") {73 requireEnrollment(user.id, body.course);74 const focus = body.conceptSlug75 ? get<{ id: number }>("SELECT id FROM concepts WHERE course_code = ? AND slug = ?", body.course, body.conceptSlug)?.id ?? null76 : null;77 const r = run(78 "INSERT INTO quiz_sessions (user_id, course_code, focus_concept_id) VALUES (?, ?, ?)",79 user.id, body.course, focus80 );81 const sessionId = Number(r.lastInsertRowid);82 const q = pickNext(user.id, body.course, focus, sessionId);83 if (!q) return NextResponse.json({ error: "Aucune question disponible pour ce choix." }, { status: 404 });84 return NextResponse.json({ sessionId, question: publicQuestion(q) });85 }8687 const session = sessionState(body.sessionId, user.id);88 if (!session) return NextResponse.json({ error: "Session introuvable." }, { status: 404 });8990 if (body.action === "answer") {91 const q = get<QuizQuestion>("SELECT * FROM quiz_questions WHERE id = ?", body.questionId);92 if (!q || q.course_code !== session.course_code) return NextResponse.json({ error: "Question invalide." }, { status: 400 });93 const already = get("SELECT 1 as ok FROM quiz_answers WHERE session_id = ? AND question_id = ?", session.id, q.id);94 if (already) return NextResponse.json({ error: "Question déjà répondue." }, { status: 409 });9596 const correct = gradeAnswer(q, body.answer);97 run(98 "INSERT INTO quiz_answers (session_id, question_id, user_answer, correct, confidence, hints_used) VALUES (?, ?, ?, ?, ?, ?)",99 session.id, q.id, body.answer.slice(0, 4000), correct ? 1 : 0, body.confidence ?? null, body.hintsUsed100 );101 run(102 "UPDATE quiz_sessions SET n_total = n_total + 1, n_correct = n_correct + ? WHERE id = ?",103 correct ? 1 : 0, session.id104 );105 if (q.concept_id) {106 recordMasteryEvent({107 userId: user.id, conceptId: q.concept_id, kind: "quiz", correct,108 difficulty: q.difficulty, autonomy: body.hintsUsed ? 0.6 : 1, confidence: body.confidence,109 });110 }111 if (!correct) {112 run(113 `INSERT INTO error_notebook (user_id, course_code, concept_id, question, given_answer, correction, explanation, source)114 VALUES (?, ?, ?, ?, ?, ?, ?, 'quiz')`,115 user.id, session.course_code, q.concept_id, q.question, body.answer.slice(0, 2000), q.answer, q.explanation116 );117 }118 return NextResponse.json({119 correct,120 expected: q.answer,121 explanation: q.explanation,122 score: { correct: session.n_correct + (correct ? 1 : 0), total: session.n_total + 1 },123 });124 }125126 if (body.action === "next") {127 const q = pickNext(user.id, session.course_code, session.focus_concept_id, session.id);128 if (!q) return NextResponse.json({ question: null, message: "Banque épuisée pour ce ciblage — bravo, faites une pause ou changez de concept." });129 return NextResponse.json({ question: publicQuestion(q) });130 }131132 // finish133 run("UPDATE quiz_sessions SET finished_at = datetime('now') WHERE id = ?", session.id);134 const answers = all<{ correct: number; question_id: number; concept_id: number | null; name: string | null }>(135 `SELECT qa.correct, qa.question_id, q.concept_id, c.name FROM quiz_answers qa136 JOIN quiz_questions q ON q.id = qa.question_id LEFT JOIN concepts c ON c.id = q.concept_id137 WHERE qa.session_id = ?`,138 session.id139 );140 logActivity(user.id, "quiz", session.course_code, answers.length * 45, { sessionId: session.id });141 const byConcept = new Map<string, { correct: number; total: number }>();142 for (const a of answers) {143 const key = a.name ?? "Divers";144 const e = byConcept.get(key) ?? { correct: 0, total: 0 };145 e.total++;146 e.correct += a.correct;147 byConcept.set(key, e);148 }149 return NextResponse.json({150 ok: true,151 summary: {152 correct: answers.filter((a) => a.correct).length,153 total: answers.length,154 byConcept: [...byConcept.entries()].map(([name, v]) => ({ name, ...v })),155 },156 });157 } catch (e) {158 return apiError(e);159 }160}161