rpmjp/portfolio
rpmjp/projects/skillbridge/quiz_generator.py
CompletedFebruary – April 2026

SkillBridge AI — Test Prep Academy Platform

Multi-tenant AI learning platform for test prep academies. Socratic AI tutor that refuses to give answers, AI quiz generation from uploaded files, async pre-grading, weak-spot detection, and AI parent summaries. Full role-based platform across admin, instructor, student, and parent.

LIVE DEMO
Python 3.12FastAPIPostgreSQL 16SQLAlchemy 2.0ReactTypeScriptTailwindGroq / Llama
Languages
Python54.2%
TypeScript45.1%
CSS0.3%
JavaScript0.2%
Dockerfile0.1%
Mako0.1%
quiz_generator.py
"""
AI-powered quiz generator. Given a standard and a topic, asks the LLM to
produce a JSON-formatted quiz with multiple-choice and short-answer questions.

The instructor reviews and edits before publishing to students. We deliberately
return raw JSON-shaped dicts here rather than DB rows — the calling service
persists them after the instructor approves.

The important pattern below: AI output is treated as UNTRUSTED. Code fences are
stripped, JSON is parsed with explicit error handling, and every question is
validated (valid type, non-empty prompt, exactly one correct option) before it
reaches the instructor. Malformed output raises rather than showing garbage.
"""
import json
from typing import Literal

from app.services.ai_provider import get_provider
from app.services.standards_catalog import STANDARDS, get_standard_by_id


# Tunable: per-quiz max output tokens. 10 questions ≈ 4000 output tokens.
_MAX_TOKENS_PER_QUIZ = 8000


def _build_generation_prompt(
    standard_label: str,
    standard_description: str,
    topic: str,
    num_questions: int,
    difficulty: str,
    mix: str,
) -> str:
    """The system prompt that drives the AI to produce a JSON quiz."""
    mix_instructions = {
        "all_mc": "All questions must be multiple_choice with 4 options each.",
        "mostly_mc": (
            "Mostly multiple_choice (4 options). Include 1-2 short_answer "
            "questions for higher-order thinking."
        ),
        "mixed": (
            "Roughly half multiple_choice (4 options) and half short_answer. "
            "Use short_answer for questions that need worked solutions."
        ),
    }.get(mix, "Mostly multiple_choice with 4 options each.")

    return f"""You are an expert curriculum designer creating practice questions for a test prep academy.

STANDARD: {standard_label}
ABOUT THIS STANDARD: {standard_description}
TOPIC FOCUS: {topic}
NUMBER OF QUESTIONS: {num_questions}
DIFFICULTY: {difficulty}
QUESTION MIX: {mix_instructions}

YOUR TASK:
Generate {num_questions} original practice questions aligned to this standard and topic.
Return ONLY valid JSON — no markdown fences, no commentary before or after.

REQUIRED JSON SHAPE:
{{
  "questions": [
    {{
      "type": "multiple_choice",
      "prompt": "The question text. Be specific and clear.",
      "options": [
        {{"text": "Option A", "is_correct": false}},
        {{"text": "Option B", "is_correct": true}},
        {{"text": "Option C", "is_correct": false}},
        {{"text": "Option D", "is_correct": false}}
      ],
      "explanation": "Brief explanation shown after grading. 1-2 sentences.",
      "points": 10
    }},
    {{
      "type": "short_answer",
      "prompt": "The question text.",
      "reference_answer": "The expected answer or key elements a correct response must include.",
      "explanation": "Brief explanation of the correct approach. 1-2 sentences.",
      "points": 10
    }}
  ]
}}

CRITICAL RULES:
- Do NOT reproduce real exam questions verbatim. Generate ORIGINAL questions in the style of the standard.
- For multiple_choice: exactly ONE option must have is_correct=true.
- For multiple_choice: distractors should be plausible — represent common student mistakes, not silly answers.
- All questions must be answerable without external context (no "see figure 3").
- Mathematical notation: use plain text (e.g. "x^2", "sqrt(5)", "3/4") — no LaTeX, no special characters.
- Difficulty calibration: {difficulty} means the typical student at this level should find these challenging-but-fair.

Return only the JSON object. Begin your response with {{ and end with }}."""


def generate_quiz(
    prompt: str,
    standard_id: str | None = None,
    num_questions: int = 10,
    difficulty: Literal["easy", "medium", "hard"] = "medium",
    mix: Literal["all_mc", "mostly_mc", "mixed"] = "mostly_mc",
) -> dict:
    """
    Generate a quiz draft. Returns the cleaned questions plus token usage and
    (when no standard was chosen) an AI-suggested standard or topic label for
    auto-tagging.

    Raises ValueError if the AI's output isn't valid JSON or doesn't match the
    expected shape — caller should retry or surface to the instructor.
    """
    if num_questions < 1 or num_questions > 25:
        raise ValueError("num_questions must be between 1 and 25")

    standard = get_standard_by_id(standard_id) if standard_id else None
    standard_label = standard["label"] if standard else "Custom"
    standard_description = (
        standard["description"]
        if standard
        else "No specific standard chosen — use the user prompt as the source of truth."
    )
    system_prompt = _build_generation_prompt(
        standard_label=standard_label,
        standard_description=standard_description,
        topic=prompt,
        num_questions=num_questions,
        difficulty=difficulty,
        mix=mix,
    )

    # If no standard was picked, ask the AI to classify the content — either
    # match the catalog or propose a free-form topic label. This drives the
    # auto-tagging of generated quizzes.
    if standard is None:
        catalog_lines = [f'  - "{s["id"]}": {s["label"]}' for s in STANDARDS]
        system_prompt += (
            "\n\nADDITIONALLY, classify this content with a topic tag. Add TWO top-level fields to your JSON:\n"
            "1. `suggested_standard_id`: if the content clearly matches one of the standards below, "
            "set this to the exact id (otherwise null):\n"
            + "\n".join(catalog_lines)
            + "\n\n2. `suggested_topic_label`: ALWAYS set this. A short 2-5 word topic label "
            'describing what the content covers (e.g. "Calculus Derivatives", "Linear Equations"). '
            "Title Case. Use the most specific accurate label."
        )

    provider = get_provider()
    response = provider.send_message(
        system_prompt=system_prompt,
        messages=[
            {
                "role": "user",
                "content": (
                    f"Generate a {num_questions}-question {difficulty} quiz. "
                    f"User's request: {prompt}. Return JSON only."
                ),
            }
        ],
        max_tokens=_MAX_TOKENS_PER_QUIZ,
    )

    raw = response.content.strip()
    # Defensive: strip code fences if the model returned them despite instructions
    if raw.startswith("```"):
        raw = raw.lstrip("`")
        if raw.lower().startswith("json"):
            raw = raw[4:]
        raw = raw.strip()
        if raw.endswith("```"):
            raw = raw[:-3].strip()

    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError as e:
        raise ValueError(
            f"AI returned invalid JSON: {e}. First 200 chars: {raw[:200]!r}"
        )

    questions = parsed.get("questions")
    if not isinstance(questions, list) or not questions:
        raise ValueError("AI returned no questions in expected shape")

    # Light validation — surface issues early so the instructor isn't shown garbage
    cleaned: list[dict] = []
    for i, q in enumerate(questions):
        q_type = q.get("type")
        if q_type not in ("multiple_choice", "short_answer"):
            raise ValueError(f"Question {i + 1} has invalid type: {q_type}")
        if not q.get("prompt"):
            raise ValueError(f"Question {i + 1} missing prompt")

        item: dict = {
            "type": q_type,
            "prompt": q["prompt"].strip(),
            "explanation": (q.get("explanation") or "").strip(),
            "points": int(q.get("points") or 10),
        }

        if q_type == "multiple_choice":
            options = q.get("options") or []
            if len(options) < 2:
                raise ValueError(f"Question {i + 1} needs at least 2 options")
            correct_count = sum(1 for o in options if o.get("is_correct"))
            if correct_count != 1:
                raise ValueError(
                    f"Question {i + 1} must have exactly one correct option, found {correct_count}"
                )
            item["options"] = [
                {
                    "text": (o.get("text") or "").strip(),
                    "is_correct": bool(o.get("is_correct")),
                }
                for o in options
            ]
        else:  # short_answer
            item["reference_answer"] = (q.get("reference_answer") or "").strip()

        cleaned.append(item)

    return {
        "questions": cleaned,
        "input_tokens": response.input_tokens,
        "output_tokens": response.output_tokens,
        "tokens_used": response.input_tokens + response.output_tokens,
    }