rpmjp/portfolio
rpmjp/projects/skillbridge/quiz_grader.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_grader.py
"""
AI-powered grading for short-answer quiz questions.

Returns a suggested score + feedback per answer. The instructor reviews
the AI's suggestions and either approves them or overrides with their own
score before final-grade publishing — the AI never publishes a grade on its own.

Runs off the request's critical path (async pre-grading): the student's
submission persists and confirms immediately, and this grading happens
separately so a slow LLM round-trip never makes a student wait.
"""
import json

from app.services.ai_provider import get_provider


_MAX_TOKENS_PER_GRADE = 400


def _build_grading_prompt(
    question_prompt: str,
    reference_answer: str,
    max_points: int,
) -> str:
    return f"""You are a fair, careful grader of short-answer quiz questions.

QUESTION: {question_prompt}

REFERENCE ANSWER (what the instructor expects, or key elements to look for):
{reference_answer or "(no reference answer provided — grade based on the question alone)"}

MAXIMUM POINTS: {max_points}

YOUR TASK:
Grade the student's response. Return ONLY valid JSON — no markdown, no commentary.

REQUIRED JSON SHAPE:
{{
  "score": <integer between 0 and {max_points}>,
  "is_correct": <true if score >= {max_points}, false otherwise>,
  "feedback": "<1-2 sentences of constructive feedback for the student>"
}}

GRADING PRINCIPLES:
- Award full credit for fully correct answers, even if phrased differently from the reference.
- Award partial credit for partially correct answers — be specific about what's missing.
- For math: equivalent expressions are correct (e.g. "x = 7", "7", "the value is 7" all credit).
- For short-answer concepts: if the student captures the key idea, give credit even if wording differs.
- Be strict but fair. Don't reward answers that are off-topic or fundamentally wrong.
- Feedback should help the student learn — point to what's right AND what's missing.
- Do NOT mention the reference answer in feedback. Speak directly to the student about their work.

Return only the JSON object."""


def grade_short_answer(
    question_prompt: str,
    student_answer: str,
    reference_answer: str | None,
    max_points: int,
) -> dict:
    """
    Grade a single short-answer response.

    Returns {"score", "is_correct", "feedback", "input_tokens", "output_tokens"}.
    Raises ValueError if the AI returns invalid JSON.
    """
    if not student_answer or not student_answer.strip():
        # Don't waste tokens on empty answers
        return {
            "score": 0,
            "is_correct": False,
            "feedback": "No answer was provided.",
            "input_tokens": 0,
            "output_tokens": 0,
        }

    system_prompt = _build_grading_prompt(
        question_prompt=question_prompt,
        reference_answer=reference_answer or "",
        max_points=max_points,
    )

    provider = get_provider()
    response = provider.send_message(
        system_prompt=system_prompt,
        messages=[
            {
                "role": "user",
                "content": f"Student's answer:\n\n{student_answer.strip()}",
            }
        ],
        max_tokens=_MAX_TOKENS_PER_GRADE,
    )

    raw = response.content.strip()
    # Strip code fences if the model returned them
    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 grading JSON: {e}. First 200 chars: {raw[:200]!r}"
        )

    score = int(parsed.get("score", 0))
    score = max(0, min(score, max_points))  # clamp to valid range

    return {
        "score": score,
        "is_correct": bool(parsed.get("is_correct", score == max_points)),
        "feedback": (parsed.get("feedback") or "").strip(),
        "input_tokens": response.input_tokens,
        "output_tokens": response.output_tokens,
    }