Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""generate_quiz — interactive quiz (MCQ, true/false, numeric with tolerance) as strict JSON."""23from __future__ import annotations45import json6import re7from typing import Any, Literal89from pydantic import BaseModel, Field1011from app.db import SessionLocal12from app.llm.openrouter import get_llm13from app.llm.router import router14from app.llm.schemas import Artifact, ToolResult15from app.models import Quiz16from app.rag import retriever17from app.tools.registry import ToolContext, registry1819QuestionType = Literal["mcq", "true_false", "numeric"]202122class QuizArgs(BaseModel):23 topic: str = Field(..., min_length=2, max_length=200)24 course: str = Field("IMM1033")25 n: int = Field(5, ge=1, le=12)26 difficulty: Literal["facile", "moyen", "difficile"] = "moyen"27 types: list[QuestionType] = Field(default_factory=lambda: ["mcq", "true_false", "numeric"])282930QUIZ_SYSTEM = """Tu génères un quiz pédagogique en français pour un cours universitaire d'évaluation31immobilière (UQO). Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, de la forme :32{"title": str, "questions": [33 {"id": "q1", "type": "mcq", "prompt": str, "choices": [str, str, str, str], "answer": 0,34 "explanation": str},35 {"id": "q2", "type": "true_false", "prompt": str, "answer": true, "explanation": str},36 {"id": "q3", "type": "numeric", "prompt": str, "answer": 123.4, "tolerance": 0.02, "unit": "$",37 "explanation": str}38]}39Règles : questions variées et précises, chiffres réalistes (Gatineau/Outaouais), calculs vérifiés,40explications qui montrent la formule et la démarche, `tolerance` relative (0.02 = ±2 %),41`answer` pour mcq = index (0-based) du bon choix. Appuie-toi sur les passages de cours fournis."""424344def _parse_json(text: str) -> dict[str, Any]:45 text = text.strip()46 m = re.search(r"\{.*\}", text, re.S)47 if m:48 text = m.group(0)49 return json.loads(text)505152def _normalise(q: dict[str, Any], i: int) -> dict[str, Any] | None:53 t = q.get("type")54 out: dict[str, Any] = {"id": q.get("id") or f"q{i}", "type": t, "prompt": str(q.get("prompt", "")),55 "explanation": str(q.get("explanation", ""))}56 if not out["prompt"]:57 return None58 if t == "mcq":59 choices = [str(c) for c in q.get("choices", [])][:6]60 if len(choices) < 2:61 return None62 out["choices"] = choices63 try:64 out["answer"] = int(q.get("answer", 0)) % len(choices)65 except (TypeError, ValueError):66 return None67 elif t == "true_false":68 a = q.get("answer")69 out["answer"] = bool(a) if not isinstance(a, str) else a.lower() in {"true", "vrai", "v"}70 elif t == "numeric":71 try:72 out["answer"] = float(q.get("answer"))73 except (TypeError, ValueError):74 return None75 out["tolerance"] = float(q.get("tolerance", 0.02))76 out["unit"] = str(q.get("unit", ""))77 else:78 return None79 return out808182async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:83 await ctx.report("running", "Préparation du quiz…")84 course = str(args["course"]).upper()85 hits = retriever.index.search(args["topic"], top_k=4, courses={course})86 context = retriever.format_for_model(hits) if hits else ""87 plan = router.plan("quiz")88 user = (f"Cours : {course}. Sujet : {args['topic']}. Nombre de questions : {args['n']}. "89 f"Difficulté : {args['difficulty']}. Types permis : {', '.join(args['types'])}.\n\n"90 f"{context}")91 text, _usage = await get_llm().complete(92 [{"role": "system", "content": QUIZ_SYSTEM}, {"role": "user", "content": user}],93 plan.models, temperature=plan.temperature, max_tokens=3500,94 response_format=plan.response_format, user_id_hash=ctx.user_id_hash)95 try:96 raw = _parse_json(text)97 except json.JSONDecodeError:98 return ToolResult(content="Le générateur de quiz n'a pas produit un JSON valide. Réessaie "99 "avec un sujet plus précis.", error=True)100 questions = [q for q in (_normalise(q, i + 1) for i, q in enumerate(raw.get("questions", [])))101 if q]102 if not questions:103 return ToolResult(content="Aucune question valide générée. Réessaie.", error=True)104 payload = {"title": raw.get("title") or f"Quiz — {args['topic']}", "course": course,105 "topic": args["topic"], "difficulty": args["difficulty"], "questions": questions,106 "sources": [{"module": h.module, "section": h.section, "url": h.url} for h in hits]}107 async with SessionLocal() as session:108 quiz = Quiz(user_id=ctx.user_id, course_code=course, topic=args["topic"], payload=payload)109 session.add(quiz)110 await session.commit()111 quiz_id = quiz.id112 payload["quiz_id"] = quiz_id113 public = {**payload, "questions": [{k: v for k, v in q.items() if k not in114 {"answer", "explanation"}} for q in questions]}115 return ToolResult(116 content=f"Quiz « {payload['title']} » créé avec {len(questions)} questions "117 f"(id {quiz_id}). Il est affiché de façon interactive à l'étudiant : ne répète pas "118 "les questions ni les réponses ; invite-le simplement à le faire.",119 artifacts=[Artifact(type="quiz", file_id=quiz_id, filename=payload["title"], preview=public)],120 payload={"quiz": public},121 meta={"summary": f"Quiz : {len(questions)} questions"},122 )123124125registry.register("generate_quiz", run, QuizArgs, heavy=True)126