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%
7.1 KB · 161 lines typescript
Raw Blame History
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