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.3 KB · 150 lines typescript
Raw Blame History
1// Examens blancs : liste, démarrage d'une tentative, soumission, correction et analyse.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, type QuizQuestion } from "@/lib/learning/quiz.ts";8import { recordMasteryEvent } from "@/lib/learning/mastery.ts";9import { logActivity } from "@/lib/usage.ts";1011export async function GET(req: Request) {12  try {13    const user = await requireUser();14    const url = new URL(req.url);15    const course = url.searchParams.get("course")?.toUpperCase();16    if (course !== "IMM1003" && course !== "IMM1033") return NextResponse.json({ error: "Cours requis." }, { status: 400 });17    requireEnrollment(user.id, course);18    const exams = all<{ id: number; kind: string; title: string; description: string; duration_minutes: number; question_ids: string }>(19      "SELECT id, kind, title, description, duration_minutes, question_ids FROM mock_exams WHERE course_code = ? ORDER BY kind, id",20      course21    );22    const attempts = all(23      `SELECT ea.id, ea.exam_id, ea.mode, ea.started_at, ea.finished_at, ea.score, ea.total24       FROM exam_attempts ea JOIN mock_exams me ON me.id = ea.exam_id25       WHERE ea.user_id = ? AND me.course_code = ? ORDER BY ea.id DESC LIMIT 50`,26      user.id, course27    );28    return NextResponse.json({29      exams: exams.map((e) => ({ ...e, questionCount: (JSON.parse(e.question_ids) as number[]).length, question_ids: undefined })),30      attempts,31    });32  } catch (e) {33    return apiError(e);34  }35}3637const startSchema = z.object({38  action: z.literal("start"),39  examId: z.number().int().positive(),40  mode: z.enum(["timed", "practice"]),41});42const submitSchema = z.object({43  action: z.literal("submit"),44  attemptId: z.number().int().positive(),45  answers: z.record(z.string(), z.string().max(4000)),46});4748export async function POST(req: Request) {49  try {50    await assertSameOrigin();51    const user = await requireUser();52    const body = await parseBody(req, z.discriminatedUnion("action", [startSchema, submitSchema]));5354    if (body.action === "start") {55      const exam = get<{ id: number; course_code: string; question_ids: string; duration_minutes: number; title: string }>(56        "SELECT id, course_code, question_ids, duration_minutes, title FROM mock_exams WHERE id = ?", body.examId57      );58      if (!exam) return NextResponse.json({ error: "Examen introuvable." }, { status: 404 });59      requireEnrollment(user.id, exam.course_code);60      const ids = JSON.parse(exam.question_ids) as number[];61      const questions = ids.length62        ? all<QuizQuestion>(`SELECT * FROM quiz_questions WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids)63        : [];64      const ordered = ids.map((id) => questions.find((q) => q.id === id)).filter((q): q is QuizQuestion => !!q);65      const r = run(66        "INSERT INTO exam_attempts (user_id, exam_id, mode) VALUES (?, ?, ?)",67        user.id, exam.id, body.mode68      );69      return NextResponse.json({70        attemptId: Number(r.lastInsertRowid),71        durationMinutes: exam.duration_minutes,72        title: exam.title,73        questions: ordered.map((q) => ({74          id: q.id, type: q.type, difficulty: q.difficulty, question: q.question,75          options: JSON.parse(q.options || "[]"),76        })),77      });78    }7980    // submit81    const attempt = get<{ id: number; user_id: number; exam_id: number; finished_at: string | null; mode: string; started_at: string }>(82      "SELECT * FROM exam_attempts WHERE id = ?", body.attemptId83    );84    if (!attempt || attempt.user_id !== user.id) return NextResponse.json({ error: "Tentative introuvable." }, { status: 404 });85    if (attempt.finished_at) return NextResponse.json({ error: "Tentative déjà soumise." }, { status: 409 });86    const exam = get<{ course_code: string; question_ids: string; title: string }>(87      "SELECT course_code, question_ids, title FROM mock_exams WHERE id = ?", attempt.exam_id88    )!;89    const ids = JSON.parse(exam.question_ids) as number[];90    const questions = all<QuizQuestion>(`SELECT * FROM quiz_questions WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids);9192    let score = 0;93    const detail: Record<string, { answer: string; correct: boolean; expected: string; explanation: string }> = {};94    const byConcept = new Map<string, { name: string; correct: number; total: number; conceptId: number | null }>();95    const byAxis = new Map<string, { correct: number; total: number }>();96    for (const q of questions) {97      const ua = body.answers[String(q.id)] ?? "";98      const correct = ua ? gradeAnswer(q, ua) : false;99      if (correct) score++;100      detail[String(q.id)] = { answer: ua, correct, expected: q.answer, explanation: q.explanation };101      const c = q.concept_id102        ? get<{ name: string; axis: string }>("SELECT name, axis FROM concepts WHERE id = ?", q.concept_id)103        : null;104      const key = c?.name ?? "Divers";105      const e = byConcept.get(key) ?? { name: key, correct: 0, total: 0, conceptId: q.concept_id };106      e.total++;107      if (correct) e.correct++;108      byConcept.set(key, e);109      const axisKey = c?.axis ?? "connaissances";110      const ax = byAxis.get(axisKey) ?? { correct: 0, total: 0 };111      ax.total++;112      if (correct) ax.correct++;113      byAxis.set(axisKey, ax);114      if (q.concept_id) {115        recordMasteryEvent({ userId: user.id, conceptId: q.concept_id, kind: "exam", correct, difficulty: q.difficulty });116      }117      if (!correct && ua) {118        run(119          `INSERT INTO error_notebook (user_id, course_code, concept_id, question, given_answer, correction, explanation, source)120           VALUES (?, ?, ?, ?, ?, ?, ?, 'exam')`,121          user.id, exam.course_code, q.concept_id, q.question, ua.slice(0, 2000), q.answer, q.explanation122        );123      }124    }125126    const weakest = [...byConcept.values()].filter((c) => c.total >= 1).sort((a, b) => a.correct / a.total - b.correct / b.total).slice(0, 3);127    const analysis = {128      byConcept: [...byConcept.values()],129      byAxis: [...byAxis.entries()].map(([axis, v]) => ({ axis, ...v })),130      weakest: weakest.map((w) => w.name),131      recommendation: weakest.length132        ? `Priorités de révision : ${weakest.map((w) => w.name).join(", ")}. Refaites un quiz ciblé sur chacune, puis retentez un examen thématique.`133        : "Excellente performance — passez au niveau supérieur avec le mode Défi du chat.",134    };135    run(136      "UPDATE exam_attempts SET finished_at = datetime('now'), answers = ?, score = ?, total = ?, analysis = ? WHERE id = ?",137      JSON.stringify(detail), score, questions.length, JSON.stringify(analysis), attempt.id138    );139    logActivity(user.id, "exam", exam.course_code, questions.length * 90, { attemptId: attempt.id, score, total: questions.length });140141    const previous = all<{ score: number; total: number; finished_at: string }>(142      "SELECT score, total, finished_at FROM exam_attempts WHERE user_id = ? AND exam_id = ? AND finished_at IS NOT NULL AND id != ? ORDER BY id DESC LIMIT 5",143      user.id, attempt.exam_id, attempt.id144    );145    return NextResponse.json({ ok: true, score, total: questions.length, detail, analysis, previous });146  } catch (e) {147    return apiError(e);148  }149}150