"""generate_quiz — interactive quiz (MCQ, true/false, numeric with tolerance) as strict JSON.""" from __future__ import annotations import json import re from typing import Any, Literal from pydantic import BaseModel, Field from app.db import SessionLocal from app.llm.openrouter import get_llm from app.llm.router import router from app.llm.schemas import Artifact, ToolResult from app.models import Quiz from app.rag import retriever from app.tools.registry import ToolContext, registry QuestionType = Literal["mcq", "true_false", "numeric"] class QuizArgs(BaseModel): topic: str = Field(..., min_length=2, max_length=200) course: str = Field("IMM1033") n: int = Field(5, ge=1, le=12) difficulty: Literal["facile", "moyen", "difficile"] = "moyen" types: list[QuestionType] = Field(default_factory=lambda: ["mcq", "true_false", "numeric"]) QUIZ_SYSTEM = """Tu génères un quiz pédagogique en français pour un cours universitaire d'évaluation immobilière (UQO). Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, de la forme : {"title": str, "questions": [ {"id": "q1", "type": "mcq", "prompt": str, "choices": [str, str, str, str], "answer": 0, "explanation": str}, {"id": "q2", "type": "true_false", "prompt": str, "answer": true, "explanation": str}, {"id": "q3", "type": "numeric", "prompt": str, "answer": 123.4, "tolerance": 0.02, "unit": "$", "explanation": str} ]} Règles : questions variées et précises, chiffres réalistes (Gatineau/Outaouais), calculs vérifiés, explications qui montrent la formule et la démarche, `tolerance` relative (0.02 = ±2 %), `answer` pour mcq = index (0-based) du bon choix. Appuie-toi sur les passages de cours fournis.""" def _parse_json(text: str) -> dict[str, Any]: text = text.strip() m = re.search(r"\{.*\}", text, re.S) if m: text = m.group(0) return json.loads(text) def _normalise(q: dict[str, Any], i: int) -> dict[str, Any] | None: t = q.get("type") out: dict[str, Any] = {"id": q.get("id") or f"q{i}", "type": t, "prompt": str(q.get("prompt", "")), "explanation": str(q.get("explanation", ""))} if not out["prompt"]: return None if t == "mcq": choices = [str(c) for c in q.get("choices", [])][:6] if len(choices) < 2: return None out["choices"] = choices try: out["answer"] = int(q.get("answer", 0)) % len(choices) except (TypeError, ValueError): return None elif t == "true_false": a = q.get("answer") out["answer"] = bool(a) if not isinstance(a, str) else a.lower() in {"true", "vrai", "v"} elif t == "numeric": try: out["answer"] = float(q.get("answer")) except (TypeError, ValueError): return None out["tolerance"] = float(q.get("tolerance", 0.02)) out["unit"] = str(q.get("unit", "")) else: return None return out async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: await ctx.report("running", "Préparation du quiz…") course = str(args["course"]).upper() hits = retriever.index.search(args["topic"], top_k=4, courses={course}) context = retriever.format_for_model(hits) if hits else "" plan = router.plan("quiz") user = (f"Cours : {course}. Sujet : {args['topic']}. Nombre de questions : {args['n']}. " f"Difficulté : {args['difficulty']}. Types permis : {', '.join(args['types'])}.\n\n" f"{context}") text, _usage = await get_llm().complete( [{"role": "system", "content": QUIZ_SYSTEM}, {"role": "user", "content": user}], plan.models, temperature=plan.temperature, max_tokens=3500, response_format=plan.response_format, user_id_hash=ctx.user_id_hash) try: raw = _parse_json(text) except json.JSONDecodeError: return ToolResult(content="Le générateur de quiz n'a pas produit un JSON valide. Réessaie " "avec un sujet plus précis.", error=True) questions = [q for q in (_normalise(q, i + 1) for i, q in enumerate(raw.get("questions", []))) if q] if not questions: return ToolResult(content="Aucune question valide générée. Réessaie.", error=True) payload = {"title": raw.get("title") or f"Quiz — {args['topic']}", "course": course, "topic": args["topic"], "difficulty": args["difficulty"], "questions": questions, "sources": [{"module": h.module, "section": h.section, "url": h.url} for h in hits]} async with SessionLocal() as session: quiz = Quiz(user_id=ctx.user_id, course_code=course, topic=args["topic"], payload=payload) session.add(quiz) await session.commit() quiz_id = quiz.id payload["quiz_id"] = quiz_id public = {**payload, "questions": [{k: v for k, v in q.items() if k not in {"answer", "explanation"}} for q in questions]} return ToolResult( content=f"Quiz « {payload['title']} » créé avec {len(questions)} questions " f"(id {quiz_id}). Il est affiché de façon interactive à l'étudiant : ne répète pas " "les questions ni les réponses ; invite-le simplement à le faire.", artifacts=[Artifact(type="quiz", file_id=quiz_id, filename=payload["title"], preview=public)], payload={"quiz": public}, meta={"summary": f"Quiz : {len(questions)} questions"}, ) registry.register("generate_quiz", run, QuizArgs, heavy=True)