Skip to content

Quiz Generation Endpoint 5.3#17

Open
1000jae wants to merge 5 commits intomainfrom
sprint5/generate-quiz-endpoint
Open

Quiz Generation Endpoint 5.3#17
1000jae wants to merge 5 commits intomainfrom
sprint5/generate-quiz-endpoint

Conversation

@1000jae
Copy link
Collaborator

@1000jae 1000jae commented Mar 1, 2026

Feature(Quiz Generation Endpoint): Implement endpoint to generate 5–10 MCQ quiz questions via Gemini.

  • 🎉 New feature (Extends application, non-breaking feature)

PR Summary

This PR introduces the Quiz Generation Endpoint, which generates 5–10 multiple-choice quiz questions using Gemini. The endpoint validates input, enforces structured JSON output, and stores the question, options, answer, and linked topic.

Overview

  • What feature/problem does this PR address?
    • This PR introduces a new endpoint that generates 5–10 multiple-choice questions from the provided input text, ensures the output is returned in a consistent and structured JSON format, validates the correctness and formatting of response from Gemini, and stores each question along with its answer options, correct answer, and linked topic.
  • What approach was taken?
    • Created a POST /api/v1/quiz endpoint in FastAPI.
    • MCQuiz introduced as a child of QuizQuestion to hold a single multiple choice quiz question with a related linked topic.
    • Added validation to ensure 5-10 questions are generated, each question contains the required content, and response meets the JSON schema and there are no hallucination formatting.
  • Any important design decisions or trade-offs?
    • Utilized StudyPackRequest was reused for the request body since quiz generation requires the user’s notes, which already include validation logic. This avoided duplicating validation code.
    • To make sure the linked topic is related to the quiz, wrote specific prompt stating what a good linked topic looks like.
    • unit test method: test_generate_quiz_unit.py (mocked) to manage quota usage

Checklist

  • [] Added a clear description
  • [] Included evidence of Unit Testing
  • [] Updated documentation (if needed)

Unit Test Evidence

tests/test_generate_quiz_unit.py::TestQuizRequest::test_valid_request_creation PASSED                        [  2%]
tests/test_generate_quiz_unit.py::TestQuizRequest::test_accepts_text_at_minimum_boundary PASSED              [  4%]
tests/test_generate_quiz_unit.py::TestQuizRequest::test_accepts_text_at_maximum_boundary PASSED              [  7%]
tests/test_generate_quiz_unit.py::TestQuizQuestionStructure::test_response_contains_quiz_array PASSED        [  9%]
tests/test_generate_quiz_unit.py::TestQuizQuestionStructure::test_each_question_has_required_fields PASSED   [ 12%]
tests/test_generate_quiz_unit.py::TestQuizQuestionStructure::test_options_is_a_list PASSED                   [ 14%]
tests/test_generate_quiz_unit.py::TestQuizQuestionStructure::test_question_topic_and_answer_are_strings PASSED [ 17%]
tests/test_generate_quiz_unit.py::TestQuizQuestionStructure::test_topic_is_non_empty PASSED                  [ 19%]
tests/test_generate_quiz_unit.py::TestCorrectAnswerStored::test_answer_match_an_option PASSED                [ 21%]
tests/test_generate_quiz_unit.py::TestCorrectAnswerStored::test_answer_is_stored_verbatim PASSED             [ 24%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_question_count_within_bounds PASSED               [ 26%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_fewer_than_5_questions PASSED                     [ 29%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_more_than_10_questions PASSED                     [ 31%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_missing_quiz_key PASSED                           [ 34%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_missing_question_field PASSED                     [ 36%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_missing_options_field PASSED                      [ 39%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_missing_answer_field PASSED                       [ 41%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_missing_topic_field PASSED                        [ 43%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_answer_not_in_options PASSED                      [ 46%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_options_as_string_not_list PASSED                 [ 48%]
tests/test_generate_quiz_unit.py::TestJSONValidation::test_quiz_item_not_dict PASSED                         [ 51%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_handles_markdown_json_fence PASSED      [ 53%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_handles_generic_code_fence PASSED       [ 56%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_handles_leading_and_trailing_whitespace PASSED [ 58%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_plain_text_response PASSED              [ 60%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_empty_string_from_gemini PASSED         [ 63%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_clean_response_strips_json_fence PASSED [ 65%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_clean_response_strips_generic_fence PASSED [ 68%]
tests/test_generate_quiz_unit.py::TestNoHallucinatedFormatting::test_clean_response_leaves_plain_json_unchanged PASSED [ 70%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_endpoint_exists PASSED                      [ 73%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_accepts_raw_text_input PASSED               [ 75%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_validates_empty_input PASSED                [ 78%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_validates_whitespace_only_input PASSED      [ 80%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_validates_input_too_short PASSED            [ 82%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_validates_input_exceeds_maximum PASSED      [ 85%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_validates_missing_text_field PASSED         [ 87%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_handles_gemini_api_unavailable PASSED       [ 90%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_response_content_type_is_json PASSED        [ 92%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_successful_response_schema PASSED           [ 95%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_endpoint_works PASSED                       [ 97%]
tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_various_input_formats PASSED                [100%]

================================================ 41 passed in 4.61s ================================================

Testing Method

cd backend
source venv/bin/activate  # macOS/Linux

# Install dependencies
pip install -r requirements.txt

# Run unit tests (no API calls)
pytest tests/test_generate_quiz_unit.py -v

# Test real API connection (uses quota)
# Test via curl (server must be running)
curl -X POST http://localhost:8000/api/v1/quiz \
  -H "Content-Type: application/json" \
  -d '{"text": "The mitochondria is the powerhouse of the cell. It produces ATP through cellular respiration, which involves glycolysis, the Krebs cycle, and the electron transport chain."}'

Additional Notes

  • API Quota Warning: use 'test_generate_quiz_unit.py' for regular development as other methods consumes Gemini quota as it makes real API calls.
  • Environment Setup: Ensure GEMINI_API_KEY is set in the root .env file before running.

Jira Ticket

Jira Ticket(s) - [SOC-28]

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 1, 2026

Greptile Summary

This PR successfully implements the Quiz Generation Endpoint with comprehensive validation and testing. The implementation addresses all issues from previous review threads:

  • ✅ Answer validation against options (line 372 in main.py)
  • ✅ Empty topic string validation (line 370 in main.py)
  • ✅ Proper import ordering in test file (sys.path before imports)
  • ✅ Correct test file paths in docstrings

Key Implementation Details:

  • Reuses StudyPackRequest for input validation (10-10000 character limit)
  • Validates quiz count bounds (5-10 questions)
  • Checks that answers match one of the provided options
  • Ensures topic fields are non-empty strings
  • Handles markdown code fence formatting from Gemini
  • 41 comprehensive unit tests with mocks to avoid API quota usage

Architecture Note:

The endpoint uses an inline prompt rather than the centralized build_custom_quiz_prompt from prompts.study_gen_v1. This is intentional since the quiz endpoint has different requirements (5-10 questions with topics) compared to the study pack endpoint (3-5 questions without topics). The prompt is well-structured with clear examples and format specifications.

The validation logic is duplicated from validate_data() with additional checks for topics and answer matching, which was noted in previous threads but reflects the endpoint's specific requirements.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The code demonstrates excellent quality with comprehensive validation, thorough error handling, and extensive test coverage (41 unit tests). All issues from previous review threads have been addressed, including answer validation against options and empty topic checks. The implementation follows existing patterns in the codebase and includes proper request validation, response structuring, and error messages.
  • No files require special attention

Important Files Changed

Filename Overview
backend/main.py Added new /api/v1/quiz endpoint that generates 5-10 MCQ questions with topics. Includes comprehensive validation for question count bounds, answer matching options, and non-empty topics. Previous review issues (answer validation, empty topic check) have been addressed.
backend/tests/test_generate_quiz_unit.py Comprehensive unit test suite with 41 tests covering request validation, response structure, field validation, count bounds, JSON formatting, and edge cases. Uses mocks to avoid API quota consumption. All imports and fixtures properly configured.
backend/tests/test_studypack_unit.py Minor formatting changes: removed colon from docstring header and removed trailing whitespace. No functional changes.

Last reviewed commit: 5354637

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 8 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 368 to 371
if "answer" not in q:
raise ValueError(f"Quiz item {i} missing 'answer' field")
if "topic" not in q:
raise ValueError(f"Quiz item {i} missing 'topic' field")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answer not validated against options

The endpoint validates that answer is present but never verifies it actually appears in the options list. If Gemini returns inconsistent data (e.g. an answer that doesn't match any option), the bad data passes through and is returned to the client — silently violating the invariant your prompt instructs the model to uphold.

Suggested change
if "answer" not in q:
raise ValueError(f"Quiz item {i} missing 'answer' field")
if "topic" not in q:
raise ValueError(f"Quiz item {i} missing 'topic' field")
if "answer" not in q:
raise ValueError(f"Quiz item {i} missing 'answer' field")
if "topic" not in q:
raise ValueError(f"Quiz item {i} missing 'topic' field")
if q["answer"] not in q["options"]:
raise ValueError(
f"Quiz item {i} answer '{q['answer']}' not found in options"
)

Note that test_answer_matche_an_option passes today only because the mock always returns well-formed data — there is no test case that sends a response where answer is absent from options and expects a 500.


response = client.post(
"/api/v1/quiz",
json={"text": VALID_NOTES},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sys.path.insert placed after the imports it is meant to enable

The sys.path.insert call on this line appears after the imports from main and services.gemini (lines 190–191) that require the backend/ directory to be on the path. Those imports would have already raised an ImportError if the path hadn't been configured beforehand. This makes the sys.path.insert effectively dead code — it only "works" because pytest is run from the backend/ directory anyway.

Move the path manipulation to the top of the file, before any project-level imports, or remove it entirely since the test suite already relies on being invoked from backend/.

Suggested change
json={"text": VALID_NOTES},
import sys
import os
# Ensure the backend directory is on the path when running tests directly
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import pytest
import json
from unittest.mock import AsyncMock, patch
from main import app, StudyPackRequest, MCQuiz, clean_response
from services.gemini import GeminiService
from fastapi.testclient import TestClient
from pydantic import ValidationError

Comment on lines +165 to +170


# ---------------------------------------------------------------------------
# TestQuizQuestionStructure
# ---------------------------------------------------------------------------

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect filename in docstring run commands

Both example commands in the module docstring reference tests/test_quiz_unit.py, but the actual file is tests/test_generate_quiz_unit.py. A developer following these instructions would get a "no tests ran" result.

Suggested change
# ---------------------------------------------------------------------------
# TestQuizQuestionStructure
# ---------------------------------------------------------------------------
Run all tests:
pytest tests/test_generate_quiz_unit.py -v
Run a specific test:
pytest tests/test_generate_quiz_unit.py::TestGenerateQuizEndpoint::test_endpoint_exists -v

@1000jae
Copy link
Collaborator Author

1000jae commented Mar 1, 2026

@greptileai

backend/main.py Outdated
if q["answer"] not in q["options"]:
raise ValueError(f"Quiz item {i} 'answer' not in 'options'")
if "topic" not in q:
raise ValueError(f"Quiz item {i} missing 'topic' field")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty topic string bypasses validation

The check on this line only verifies that a "topic" key exists in the dict, but it does not verify that the value is non-empty. A Gemini response containing "topic": "" or "topic": " " will pass this guard, get stored in MCQuiz, and be returned to the client — silently violating the invariant your prompt instructs the model to uphold.

Note that test_topic_is_non_empty does not catch this: it only verifies that the mock data (which always has valid topics) round-trips correctly. There is no negative test that sends a blank topic and expects a 500.

Add a content check immediately after the key presence check:

Suggested change
raise ValueError(f"Quiz item {i} missing 'topic' field")
if "topic" not in q or not q["topic"].strip():
raise ValueError(f"Quiz item {i} missing 'topic' field")

Comment on lines 360 to 383
quiz_questions = []
for i, q in enumerate(data.get("quiz", [])):
if not isinstance(q, dict):
raise ValueError(f"Quiz item {i} is not an object")
if "question" not in q:
raise ValueError(f"Quiz item {i} missing 'question' field")
if "options" not in q or not isinstance(q["options"], list):
raise ValueError(f"Quiz item {i} missing 'options' array")
if "answer" not in q:
raise ValueError(f"Quiz item {i} missing 'answer' field")
if q["answer"] not in q["options"]:
raise ValueError(f"Quiz item {i} 'answer' not in 'options'")
if "topic" not in q:
raise ValueError(f"Quiz item {i} missing 'topic' field")



quiz_questions.append(MCQuiz(
question=q["question"],
options=q["options"],
answer=q["answer"],
topic=q["topic"]
))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation logic duplicated from validate_data()

The item-level validation loop here (checking isinstance(q, dict), presence of question, options, answer) replicates the logic already present in validate_data(). The only additions specific to the quiz endpoint are the answer in options guard and the topic check.

If validate_data() is ever updated (e.g., stricter type checks on option values), this endpoint would need a separate manual update.

Consider extracting a shared helper, or extending validate_data() to accept an optional extra_fields parameter, so both endpoints share the same base validation path.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +571 to +572
# ---------------------------------------------------------------------------

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy-pasted docstring is wrong

The docstring says "Test that a quiz item missing 'answer' causes a 500 mentioning the field" but this test actually verifies the answer not in options scenario, not a missing answer field. That case is already covered by test_missing_answer_field above.

Suggested change
# ---------------------------------------------------------------------------
def test_answer_not_in_options(self, mock_gemini, client, auth_headers):
"""Test that a quiz item whose answer is not one of the options causes a 500"""

@1000jae
Copy link
Collaborator Author

1000jae commented Mar 1, 2026

@greptileai

backend/main.py Outdated
):
"""Generate MC Quiz from user notes. Auth controlled by REQUIRE_AUTH_FOR_GENERATE."""

prompt = f"""You are a study assistant. Based on the following notes, generate, 5-10 multiple choice questions where:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extra comma before "5-10"

Suggested change
prompt = f"""You are a study assistant. Based on the following notes, generate, 5-10 multiple choice questions where:
prompt = f"""You are a study assistant. Based on the following notes, generate 5-10 multiple choice questions where:

backend/main.py Outdated
))

if len(quiz_questions) < 5:
raise ValueError(f"Expected at least 5 quiz questions, got {len(quiz_questions)}.\n")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inconsistent with line 387 which has no trailing newline

Suggested change
raise ValueError(f"Expected at least 5 quiz questions, got {len(quiz_questions)}.\n")
raise ValueError(f"Expected at least 5 quiz questions, got {len(quiz_questions)}")

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@1000jae
Copy link
Collaborator Author

1000jae commented Mar 1, 2026

@greptileai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant