// Quiz adaptatif : démarrage, question suivante, réponse, fin de session. import { NextResponse } from "next/server"; import { z } from "zod"; import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts"; import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts"; import { all, get, run } from "@/lib/db/index.ts"; import { gradeAnswer, nextDifficulty, pickQuestion, pickTargetConcept, startingDifficulty, type QuizQuestion } from "@/lib/learning/quiz.ts"; import { masteryForCourse, recordMasteryEvent } from "@/lib/learning/mastery.ts"; import { logActivity } from "@/lib/usage.ts"; const startSchema = z.object({ action: z.literal("start"), course: z.enum(["IMM1003", "IMM1033"]), conceptSlug: z.string().max(120).nullable().optional(), }); const answerSchema = z.object({ action: z.literal("answer"), sessionId: z.number().int().positive(), questionId: z.number().int().positive(), answer: z.string().max(4000), confidence: z.number().int().min(1).max(5).optional(), hintsUsed: z.number().int().min(0).max(5).default(0), }); const nextSchema = z.object({ action: z.literal("next"), sessionId: z.number().int().positive(), }); const finishSchema = z.object({ action: z.literal("finish"), sessionId: z.number().int().positive(), }); function sessionState(sessionId: number, userId: number) { 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 }>( "SELECT * FROM quiz_sessions WHERE id = ?", sessionId ); if (!s || s.user_id !== userId) return null; return s; } function publicQuestion(q: QuizQuestion) { return { id: q.id, type: q.type, difficulty: q.difficulty, question: q.question, options: JSON.parse(q.options || "[]") as string[], conceptId: q.concept_id, }; } function pickNext(userId: number, courseCode: string, focusConceptId: number | null, sessionId: number) { const answered = all<{ question_id: number }>( "SELECT question_id FROM quiz_answers WHERE session_id = ?", sessionId ).map((r) => r.question_id); const lastTwo = all<{ correct: number; difficulty: number }>( `SELECT qa.correct, q.difficulty FROM quiz_answers qa JOIN quiz_questions q ON q.id = qa.question_id WHERE qa.session_id = ? ORDER BY qa.id DESC LIMIT 2`, sessionId ); const conceptId = pickTargetConcept(userId, courseCode, focusConceptId); const mastery = masteryForCourse(userId, courseCode).find((m) => m.conceptId === conceptId); let difficulty = startingDifficulty(mastery?.score ?? 0); if (lastTwo.length) { difficulty = nextDifficulty(lastTwo[0].difficulty, lastTwo.map((l) => !!l.correct)); } return pickQuestion({ userId, courseCode, conceptId, difficulty, excludeIds: answered }); } export async function POST(req: Request) { try { await assertSameOrigin(); const user = await requireUser(); const body = await parseBody(req, z.discriminatedUnion("action", [startSchema, answerSchema, nextSchema, finishSchema])); if (body.action === "start") { requireEnrollment(user.id, body.course); const focus = body.conceptSlug ? get<{ id: number }>("SELECT id FROM concepts WHERE course_code = ? AND slug = ?", body.course, body.conceptSlug)?.id ?? null : null; const r = run( "INSERT INTO quiz_sessions (user_id, course_code, focus_concept_id) VALUES (?, ?, ?)", user.id, body.course, focus ); const sessionId = Number(r.lastInsertRowid); const q = pickNext(user.id, body.course, focus, sessionId); if (!q) return NextResponse.json({ error: "Aucune question disponible pour ce choix." }, { status: 404 }); return NextResponse.json({ sessionId, question: publicQuestion(q) }); } const session = sessionState(body.sessionId, user.id); if (!session) return NextResponse.json({ error: "Session introuvable." }, { status: 404 }); if (body.action === "answer") { const q = get("SELECT * FROM quiz_questions WHERE id = ?", body.questionId); if (!q || q.course_code !== session.course_code) return NextResponse.json({ error: "Question invalide." }, { status: 400 }); const already = get("SELECT 1 as ok FROM quiz_answers WHERE session_id = ? AND question_id = ?", session.id, q.id); if (already) return NextResponse.json({ error: "Question déjà répondue." }, { status: 409 }); const correct = gradeAnswer(q, body.answer); run( "INSERT INTO quiz_answers (session_id, question_id, user_answer, correct, confidence, hints_used) VALUES (?, ?, ?, ?, ?, ?)", session.id, q.id, body.answer.slice(0, 4000), correct ? 1 : 0, body.confidence ?? null, body.hintsUsed ); run( "UPDATE quiz_sessions SET n_total = n_total + 1, n_correct = n_correct + ? WHERE id = ?", correct ? 1 : 0, session.id ); if (q.concept_id) { recordMasteryEvent({ userId: user.id, conceptId: q.concept_id, kind: "quiz", correct, difficulty: q.difficulty, autonomy: body.hintsUsed ? 0.6 : 1, confidence: body.confidence, }); } if (!correct) { run( `INSERT INTO error_notebook (user_id, course_code, concept_id, question, given_answer, correction, explanation, source) VALUES (?, ?, ?, ?, ?, ?, ?, 'quiz')`, user.id, session.course_code, q.concept_id, q.question, body.answer.slice(0, 2000), q.answer, q.explanation ); } return NextResponse.json({ correct, expected: q.answer, explanation: q.explanation, score: { correct: session.n_correct + (correct ? 1 : 0), total: session.n_total + 1 }, }); } if (body.action === "next") { const q = pickNext(user.id, session.course_code, session.focus_concept_id, session.id); if (!q) return NextResponse.json({ question: null, message: "Banque épuisée pour ce ciblage — bravo, faites une pause ou changez de concept." }); return NextResponse.json({ question: publicQuestion(q) }); } // finish run("UPDATE quiz_sessions SET finished_at = datetime('now') WHERE id = ?", session.id); const answers = all<{ correct: number; question_id: number; concept_id: number | null; name: string | null }>( `SELECT qa.correct, qa.question_id, q.concept_id, c.name FROM quiz_answers qa JOIN quiz_questions q ON q.id = qa.question_id LEFT JOIN concepts c ON c.id = q.concept_id WHERE qa.session_id = ?`, session.id ); logActivity(user.id, "quiz", session.course_code, answers.length * 45, { sessionId: session.id }); const byConcept = new Map(); for (const a of answers) { const key = a.name ?? "Divers"; const e = byConcept.get(key) ?? { correct: 0, total: 0 }; e.total++; e.correct += a.correct; byConcept.set(key, e); } return NextResponse.json({ ok: true, summary: { correct: answers.filter((a) => a.correct).length, total: answers.length, byConcept: [...byConcept.entries()].map(([name, v]) => ({ name, ...v })), }, }); } catch (e) { return apiError(e); } }