pdf-quiz-generator/backend/app/routers/quizzes.py
Daniel a7a5bdff62 Proper question bank system with question categories
Architecture:
- Questions are primary objects in a bank, tagged with question categories
- QuestionCategory is a separate taxonomy from QuizCategory (different concepts)
- Extraction → questions added to bank, optionally tagged to a question category
- Quizzes can be created from: individual question selection, question category, or PDF extraction

Backend:
- QuestionCategory model + question_categories table
- question_category_id column on questions table (nullable, SET NULL on delete)
- GET/POST/PATCH/DELETE /api/question-categories/
- POST /api/question-categories/{id}/create-quiz — create quiz from all questions in a category
- PATCH /api/questions/{id}/category — assign single question to category
- PATCH /api/questions/bulk-category — assign multiple questions at once
- GET /api/questions/bank?category_id=&uncategorized= — filter by category
- QuizCreate schema now accepts question_category_id for extraction
- quiz_service.create_quiz_from_section accepts question_category_id param

Frontend:
- DocumentDetailPage: Add to Bank Category dropdown in Quiz Settings (optional)
  Labels extracted questions with the selected category on creation
- QuestionBankPage: full rewrite
  - Category chips for filtering (All / Uncategorized / named categories)
  - Create category button inline
  - Checkbox multi-select with bulk category assignment
  - Create Quiz modal: choose from selected questions OR all from a category
  - Each question shows its category badge and quiz source
  - Study modal with instant answer feedback

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:34:39 +02:00

363 lines
13 KiB
Python

import random
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import cast, String, or_, and_, func
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.quiz import Quiz
from app.models.question import Question as QuestionModel
from app.models.section import Section
from app.models.attempt import QuizAttempt
from app.models.user import User
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.post("/", response_model=QuizResponse)
def create_quiz(
quiz_data: QuizCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Create quiz by extracting questions from PDF section. Moderator/Admin only."""
section = db.query(Section).filter(Section.id == quiz_data.section_id).first()
if not section:
raise HTTPException(status_code=404, detail="Section not found")
if not current_user.is_admin and section.document.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not your document")
if quiz_data.mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be 'timed' or 'learning'")
try:
quiz = quiz_service.create_quiz_from_section(
db=db,
user_id=current_user.id,
section_id=quiz_data.section_id,
title=quiz_data.title,
mode=quiz_data.mode,
time_limit_minutes=quiz_data.time_limit_minutes,
model_id=quiz_data.model_id,
question_category_id=quiz_data.question_category_id,
)
return quiz
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/search")
def search_quizzes(
q: str = Query(..., min_length=2, max_length=200),
mode: str = Query("all"), # "title" | "questions" | "all"
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Hybrid semantic + keyword search across quiz titles and questions."""
from sqlalchemy import text as sa_text
from app.services import embedding_service
phrase = q.strip()
if not phrase:
return []
results = {} # quiz_id -> result dict
def _ensure_quiz(quiz_id: int, match_type: str):
if quiz_id not in results:
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
return False
results[quiz_id] = {
"quiz_id": quiz.id,
"quiz_title": quiz.title,
"questions_count": quiz.questions_count,
"mode": quiz.mode,
"time_limit_minutes": quiz.time_limit_minutes,
"match_type": match_type,
"matching_questions": [],
}
elif results[quiz_id]["match_type"] != match_type and match_type != "title":
results[quiz_id]["match_type"] = "both"
return True
# ── Title search ─────────────────────────────────────────────
if mode in ("title", "all"):
for quiz in db.query(Quiz).filter(Quiz.title.ilike(f"%{phrase}%")).limit(30).all():
_ensure_quiz(quiz.id, "title")
# ── Semantic (vector) search ──────────────────────────────────
seen_question_ids = set()
if mode in ("questions", "all"):
query_emb = embedding_service.generate_embedding(phrase)
if query_emb:
# Use f-string for the vector literal — safe because it's a list of floats
emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]"
rows = db.execute(sa_text(f"""
SELECT q.id, q.quiz_id, q.question_text, q.options,
1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity
FROM questions q
WHERE q.embedding IS NOT NULL
ORDER BY q.embedding <=> '{emb_literal}'::vector
LIMIT 40
""")).fetchall()
for row in rows:
similarity = float(row.similarity)
if similarity < 0.30:
continue
if _ensure_quiz(row.quiz_id, "questions"):
seen_question_ids.add(row.id)
results[row.quiz_id]["matching_questions"].append({
"id": row.id,
"question_text": row.question_text,
"options": row.options,
"similarity": round(similarity, 3),
"match_source": "semantic",
})
# ── Keyword (ILIKE) search ────────────────────────────────────
if mode in ("questions", "all"):
q_filter = or_(
QuestionModel.question_text.ilike(f"%{phrase}%"),
cast(QuestionModel.options, String).ilike(f"%{phrase}%"),
)
keyword_rows = (
db.query(QuestionModel)
.filter(q_filter)
.order_by(QuestionModel.quiz_id, QuestionModel.id)
.limit(200)
.all()
)
for question in keyword_rows:
if _ensure_quiz(question.quiz_id, "questions"):
if question.id not in seen_question_ids:
seen_question_ids.add(question.id)
results[question.quiz_id]["matching_questions"].append({
"id": question.id,
"question_text": question.question_text,
"options": question.options,
"similarity": None,
"match_source": "keyword",
})
# Sort each quiz's questions: semantic first (by similarity desc), then keyword
for r in results.values():
r["matching_questions"].sort(
key=lambda x: (0 if x.get("match_source") == "semantic" else 1,
-(x.get("similarity") or 0))
)
# Sort results: title matches first, then by number of semantic hits desc
sorted_results = sorted(
results.values(),
key=lambda r: (
0 if r["match_type"] == "title" else 1,
-sum(1 for q in r["matching_questions"] if q.get("match_source") == "semantic"),
),
)
return sorted_results
@router.get("/", response_model=list[QuizResponse])
def list_quizzes(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all available quizzes. Admins/mods see all, users see all published."""
if current_user.is_moderator:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
else:
quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all()
return quizzes
@router.get("/{quiz_id}")
def get_quiz(
quiz_id: int,
study: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz for taking. study=true forces answers/explanations to be included."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
if study or quiz.mode == "learning":
return QuizLearningDetail.model_validate(quiz)
return QuizDetail.model_validate(quiz)
@router.post("/{quiz_id}/shuffle", response_model=QuizDetail)
def shuffle_quiz(
quiz_id: int,
shuffle_options: bool = Query(True, description="Also shuffle answer options within each question"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return quiz with question order and optionally option order shuffled. Does not modify DB."""
from app.models.question import Question as QuestionModel
from app.schemas.quiz import QuestionResponse
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).all()
shuffled_questions = questions.copy()
random.shuffle(shuffled_questions)
result = []
for q in shuffled_questions:
options = list(q.options) if q.options else None
if shuffle_options and options:
random.shuffle(options)
result.append(QuestionResponse(
id=q.id,
question_text=q.question_text,
question_type=q.question_type,
options=options,
image_path=q.image_path,
))
return {
"id": quiz.id,
"section_id": quiz.section_id,
"user_id": quiz.user_id,
"title": quiz.title,
"questions_count": quiz.questions_count,
"mode": quiz.mode,
"time_limit_minutes": quiz.time_limit_minutes,
"created_at": quiz.created_at,
"questions": result,
}
@router.get("/{quiz_id}/review", response_model=QuizReview)
def review_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get quiz with answers — only if user has completed an attempt."""
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
has_attempt = db.query(QuizAttempt).filter(
QuizAttempt.quiz_id == quiz_id,
QuizAttempt.user_id == current_user.id,
QuizAttempt.completed_at.isnot(None),
).first()
if not has_attempt and not current_user.is_moderator:
raise HTTPException(status_code=403, detail="Complete an attempt first to review answers")
return quiz
@router.get("/{quiz_id}/questions")
def get_quiz_questions_for_edit(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Get all questions with answers for editing — moderator/admin only."""
from app.models.question import Question as QuestionModel
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).order_by(QuestionModel.id).all()
return [
{
"id": q.id,
"question_text": q.question_text,
"question_type": q.question_type,
"options": q.options,
"correct_answer": q.correct_answer,
"explanation": q.explanation,
}
for q in questions
]
@router.patch("/{quiz_id}/questions/{question_id}")
def update_question(
quiz_id: int,
question_id: int,
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Update a quiz question — moderator/admin only."""
from app.models.question import Question as QuestionModel
question = db.query(QuestionModel).filter(
QuestionModel.id == question_id,
QuestionModel.quiz_id == quiz_id,
).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
allowed = {"question_text", "options", "correct_answer", "explanation", "question_type"}
for key, value in data.items():
if key in allowed:
setattr(question, key, value)
# Validate correct_answer is one of the options
if question.options and question.correct_answer not in question.options:
raise HTTPException(
status_code=400,
detail=f"correct_answer must match one of the options exactly. Options: {question.options}"
)
db.commit()
db.refresh(question)
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
}
@router.delete("/{quiz_id}/questions/{question_id}", status_code=204)
def delete_question(
quiz_id: int,
question_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Delete a single question from a quiz — moderator/admin only."""
from app.models.question import Question as QuestionModel
question = db.query(QuestionModel).filter(
QuestionModel.id == question_id,
QuestionModel.quiz_id == quiz_id,
).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if quiz and quiz.questions_count > 0:
quiz.questions_count -= 1
db.delete(question)
db.commit()
@router.delete("/{quiz_id}", status_code=204)
def delete_quiz(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
db.delete(quiz)
db.commit()