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%
13.9 KB · 334 lines tsx
Raw Blame History
1"use client";2// Quiz adaptatif : écran de départ (concept optionnel), question (mcq / réponse3// ouverte + confiance), rétroaction riche, synthèse de fin de session.4import Link from "next/link";5import { useEffect, useState } from "react";6import { useSearchParams } from "next/navigation";7import { ArrowRight, CheckCircle2, Flag, ListChecks, NotebookPen, XCircle } from "lucide-react";8import { Badge, Button, Card, ProgressBar, Skeleton, Spinner, Textarea, cn } from "@/components/ui";9import { Markdown } from "@/components/chat/markdown";10import { DifficultyDots, ErrorBanner, fetchJson, postJson } from "./shared";1112type Question = { id: number; type: string; difficulty: number; question: string; options: string[]; conceptId: number | null };13type Feedback = { correct: boolean; expected: string; explanation: string; score: { correct: number; total: number } };14type Summary = { correct: number; total: number; byConcept: { name: string; correct: number; total: number }[] };15type ConceptOpt = { slug: string; name: string };1617const TYPE_LABELS: Record<string, string> = {18  mcq: "Choix multiple", short: "Réponse courte", calc: "Calcul", "error-detect": "Détection d'erreur",19  case: "Étude de cas", order: "Mise en ordre", match: "Association",20};2122export function QuizApp({ course }: { course: string }) {23  const searchParams = useSearchParams();24  const upper = course.toUpperCase();2526  const [phase, setPhase] = useState<"start" | "question" | "feedback" | "summary">("start");27  const [concepts, setConcepts] = useState<ConceptOpt[] | null>(null);28  const [conceptSlug, setConceptSlug] = useState<string>(searchParams.get("concept") ?? "");29  const [sessionId, setSessionId] = useState<number | null>(null);30  const [question, setQuestion] = useState<Question | null>(null);31  const [answer, setAnswer] = useState("");32  const [confidence, setConfidence] = useState(3);33  const [feedback, setFeedback] = useState<Feedback | null>(null);34  const [summary, setSummary] = useState<Summary | null>(null);35  const [answered, setAnswered] = useState(0);36  const [busy, setBusy] = useState(false);37  const [error, setError] = useState<string | null>(null);38  const [exhaustedMsg, setExhaustedMsg] = useState<string | null>(null);3940  useEffect(() => {41    fetchJson<{ nodes: ConceptOpt[] }>(`/api/learning/${course}/concepts`)42      .then((d) => setConcepts(d.nodes))43      .catch(() => setConcepts([]));44  }, [course]);4546  async function start() {47    setBusy(true);48    setError(null);49    try {50      const d = await postJson<{ sessionId: number; question: Question }>("/api/learning/quiz", {51        action: "start", course: upper, conceptSlug: conceptSlug || null,52      });53      setSessionId(d.sessionId);54      setQuestion(d.question);55      setAnswer("");56      setConfidence(3);57      setAnswered(0);58      setExhaustedMsg(null);59      setPhase("question");60    } catch (e) {61      setError(e instanceof Error ? e.message : "Impossible de démarrer le quiz.");62    } finally {63      setBusy(false);64    }65  }6667  async function submitAnswer(value: string) {68    if (!sessionId || !question || busy) return;69    setBusy(true);70    setError(null);71    try {72      const d = await postJson<Feedback>("/api/learning/quiz", {73        action: "answer",74        sessionId,75        questionId: question.id,76        answer: value,77        confidence: question.type === "mcq" ? undefined : confidence,78        hintsUsed: 0,79      });80      setFeedback(d);81      setAnswered((n) => n + 1);82      setPhase("feedback");83    } catch (e) {84      setError(e instanceof Error ? e.message : "Erreur d'envoi de la réponse.");85    } finally {86      setBusy(false);87    }88  }8990  async function nextQuestion() {91    if (!sessionId || busy) return;92    setBusy(true);93    setError(null);94    try {95      const d = await postJson<{ question: Question | null; message?: string }>("/api/learning/quiz", {96        action: "next", sessionId,97      });98      if (!d.question) {99        setExhaustedMsg(d.message ?? "Banque de questions épuisée pour ce ciblage.");100        await finish();101        return;102      }103      setQuestion(d.question);104      setAnswer("");105      setConfidence(3);106      setFeedback(null);107      setPhase("question");108    } catch (e) {109      setError(e instanceof Error ? e.message : "Erreur de chargement de la question.");110    } finally {111      setBusy(false);112    }113  }114115  async function finish() {116    if (!sessionId) return;117    setBusy(true);118    setError(null);119    try {120      const d = await postJson<{ summary: Summary }>("/api/learning/quiz", { action: "finish", sessionId });121      setSummary(d.summary);122      setPhase("summary");123    } catch (e) {124      setError(e instanceof Error ? e.message : "Erreur de clôture de la session.");125    } finally {126      setBusy(false);127    }128  }129130  // ---------- Écran de départ ----------131  if (phase === "start") {132    return (133      <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full">134        <Card className="p-6 animate-fade-up">135          <div className="flex items-center gap-2.5 mb-1">136            <ListChecks size={20} className="text-brand-500" />137            <h2 className="text-lg font-bold text-fg">Quiz adaptatif</h2>138          </div>139          <p className="text-sm text-muted mb-5">140            La difficulté s'ajuste à vos réponses (deux bonnes de suite → plus dur ; une erreur → plus facile).141            Chaque erreur est ajoutée à votre cahier d'erreurs.142          </p>143          <label htmlFor="quiz-concept" className="block text-[13px] font-medium text-fg mb-1.5">144            Concept ciblé (optionnel)145          </label>146          {concepts === null ? (147            <Skeleton className="h-10 w-full" />148          ) : (149            <select150              id="quiz-concept"151              value={conceptSlug}152              onChange={(e) => setConceptSlug(e.target.value)}153              className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"154            >155              <option value="">Mix intelligent (faiblesses, consolidation, découverte)</option>156              {concepts.map((c) => <option key={c.slug} value={c.slug}>{c.name}</option>)}157            </select>158          )}159          {error && <ErrorBanner message={error} className="mt-4" />}160          <Button onClick={start} disabled={busy} className="w-full justify-center mt-5" size="lg">161            {busy ? <Spinner /> : <>Commencer le quiz <ArrowRight size={16} /></>}162          </Button>163        </Card>164      </main>165    );166  }167168  // ---------- Synthèse ----------169  if (phase === "summary") {170    const pct = summary && summary.total > 0 ? Math.round((summary.correct / summary.total) * 100) : 0;171    return (172      <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full space-y-4">173        <Card className="p-6 text-center animate-fade-up">174          <h2 className="text-lg font-bold text-fg mb-1">Session terminée</h2>175          {exhaustedMsg && <p className="text-[13px] text-muted mb-2">{exhaustedMsg}</p>}176          {summary && summary.total > 0 ? (177            <>178              <p className={cn("text-5xl font-bold my-3", pct >= 70 ? "text-emerald-500" : pct >= 50 ? "text-amber-500" : "text-red-500")}>179                {pct} %180              </p>181              <p className="text-sm text-muted">{summary.correct} bonne{summary.correct > 1 ? "s" : ""} réponse{summary.correct > 1 ? "s" : ""} sur {summary.total}</p>182            </>183          ) : (184            <p className="text-sm text-muted my-3">Aucune question répondue dans cette session.</p>185          )}186        </Card>187        {summary && summary.byConcept.length > 0 && (188          <Card className="p-5">189            <h3 className="text-sm font-semibold text-fg mb-3">Résultat par concept</h3>190            <div className="space-y-3">191              {summary.byConcept.map((c) => (192                <div key={c.name}>193                  <div className="flex justify-between text-[13px] mb-1">194                    <span className="text-fg">{c.name}</span>195                    <span className="text-muted tabular-nums">{c.correct}/{c.total}</span>196                  </div>197                  <ProgressBar value={c.total ? c.correct / c.total : 0} tone={c.correct === c.total ? "green" : "brand"} />198                </div>199              ))}200            </div>201          </Card>202        )}203        {error && <ErrorBanner message={error} />}204        <div className="flex flex-wrap gap-2">205          <Button onClick={() => { setPhase("start"); setSummary(null); setSessionId(null); }} variant="secondary">206            Nouveau quiz207          </Button>208          {summary && summary.correct < summary.total && (209            <Link href={`/apprendre/${course}/erreurs`}>210              <Button variant="primary"><NotebookPen size={15} /> Revoir mes erreurs</Button>211            </Link>212          )}213        </div>214      </main>215    );216  }217218  // ---------- Question / rétroaction ----------219  if (!question) return null;220  const isMcq = question.type === "mcq";221222  return (223    <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full space-y-4">224      <div className="flex flex-wrap items-center gap-2">225        <Badge tone="brand">{TYPE_LABELS[question.type] ?? question.type}</Badge>226        <DifficultyDots value={question.difficulty} />227        <span className="ml-auto text-[12.5px] text-muted tabular-nums">228          Question {answered + (phase === "question" ? 1 : 0)}{feedback ? ` · ${feedback.score.correct}/${feedback.score.total} réussie${feedback.score.correct > 1 ? "s" : ""}` : ""}229        </span>230      </div>231232      <Card className="p-5 animate-fade-up">233        <Markdown content={question.question} />234      </Card>235236      {phase === "question" ? (237        <>238          {isMcq ? (239            <div className="grid gap-2" role="group" aria-label="Options de réponse">240              {question.options.map((opt, i) => (241                <button242                  key={i}243                  onClick={() => submitAnswer(opt)}244                  disabled={busy}245                  className={cn(246                    "text-left border border-app bg-card rounded-xl px-4 py-3 text-sm text-fg transition-colors",247                    "hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 disabled:opacity-60"248                  )}249                >250                  <span className="font-semibold text-brand-600 dark:text-brand-300 mr-2">{String.fromCharCode(65 + i)}.</span>251                  <span className="[&_.prose-immbot]:inline"><Markdown content={opt} /></span>252                </button>253              ))}254            </div>255          ) : (256            <form257              onSubmit={(e) => { e.preventDefault(); if (answer.trim()) submitAnswer(answer); }}258              className="space-y-3"259            >260              <Textarea261                value={answer}262                onChange={(e) => setAnswer(e.target.value)}263                rows={4}264                placeholder={question.type === "calc" ? "Votre résultat (avec unités s'il y a lieu)…" : "Votre réponse…"}265                aria-label="Votre réponse"266                autoFocus267              />268              <div>269                <label htmlFor="quiz-conf" className="block text-[13px] font-medium text-fg mb-1">270                  Confiance dans votre réponse : {["très faible", "faible", "moyenne", "élevée", "très élevée"][confidence - 1]}271                </label>272                <input273                  id="quiz-conf" type="range" min={1} max={5} step={1}274                  value={confidence} onChange={(e) => setConfidence(Number(e.target.value))}275                  className="w-full accent-[#1d64b0]"276                />277                <p className="text-[11.5px] text-muted mt-0.5">Déclarer sa confiance améliore le calibrage de la maîtrise estimée.</p>278              </div>279              <div className="flex gap-2">280                <Button type="submit" disabled={busy || !answer.trim()} className="flex-1 justify-center">281                  {busy ? <Spinner /> : "Valider ma réponse"}282                </Button>283                <Button type="button" variant="ghost" onClick={finish} disabled={busy}>284                  <Flag size={14} /> Terminer285                </Button>286              </div>287            </form>288          )}289          {isMcq && (290            <div className="flex justify-end">291              <Button variant="ghost" size="sm" onClick={finish} disabled={busy}>292                <Flag size={14} /> Terminer la session293              </Button>294            </div>295          )}296        </>297      ) : feedback ? (298        <div className="space-y-3 animate-fade-up">299          <div300            className={cn(301              "rounded-xl border px-4 py-3 flex items-center gap-2.5 text-sm font-semibold",302              feedback.correct303                ? "bg-emerald-500/10 border-emerald-500/30 text-emerald-700 dark:text-emerald-400"304                : "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-400"305            )}306            role="status"307          >308            {feedback.correct ? <CheckCircle2 size={18} /> : <XCircle size={18} />}309            {feedback.correct ? "Bonne réponse !" : "Réponse incorrecte — elle a été ajoutée à votre cahier d'erreurs."}310          </div>311          <Card className="p-4">312            <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mb-1.5">Réponse attendue</p>313            <Markdown content={feedback.expected} />314            {feedback.explanation && (315              <>316                <p className="text-[12px] font-semibold text-muted uppercase tracking-wide mt-4 mb-1.5">Explication</p>317                <Markdown content={feedback.explanation} />318              </>319            )}320          </Card>321          <div className="flex gap-2">322            <Button onClick={nextQuestion} disabled={busy} className="flex-1 justify-center">323              {busy ? <Spinner /> : <>Question suivante <ArrowRight size={15} /></>}324            </Button>325            <Button variant="secondary" onClick={finish} disabled={busy}>Terminer</Button>326          </div>327        </div>328      ) : null}329330      {error && <ErrorBanner message={error} />}331    </main>332  );333}334