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>
363 lines
13 KiB
Python
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()
|