SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
5.5 KB · 126 lines python
Raw Blame History
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