"""Question bank — view, search, categorise, and create quizzes from individual questions.""" from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import cast, String, or_ from sqlalchemy.orm import Session from app.database import get_db from app.models.question import Question from app.models.question_category import QuestionCategory from app.models.quiz import Quiz from app.models.user import User from app.utils.auth import get_current_user, require_moderator router = APIRouter() @router.patch("/{question_id}/category") def set_question_category( question_id: int, category_id: int | None = None, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Assign or remove a question category.""" question = db.query(Question).filter(Question.id == question_id).first() if not question: raise HTTPException(status_code=404, detail="Question not found") if category_id is not None: cat = db.query(QuestionCategory).filter(QuestionCategory.id == category_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") question.question_category_id = category_id db.commit() return {"question_id": question_id, "question_category_id": category_id} @router.get("/bank/ids") def get_bank_ids( q: str | None = Query(None), quiz_id: int | None = Query(None), category_id: int | None = Query(None), uncategorized: bool = Query(False), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return just IDs for all matching questions (for server-side select-all).""" query = db.query(Question.id) if quiz_id: query = query.filter(Question.quiz_id == quiz_id) if category_id is not None: query = query.filter(Question.question_category_id == category_id) if uncategorized: query = query.filter(Question.question_category_id.is_(None)) if q and q.strip(): phrase = q.strip() query = query.filter( or_( Question.question_text.ilike(f"%{phrase}%"), cast(Question.options, String).ilike(f"%{phrase}%"), ) ) return [row[0] for row in query.all()] @router.get("/bank") def get_question_bank( q: str | None = Query(None), quiz_id: int | None = Query(None), category_id: int | None = Query(None), uncategorized: bool = Query(False), search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid" limit: int = Query(50, le=200), offset: int = Query(0), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all questions across all quizzes. Supports keyword filter and quiz filter.""" query = db.query(Question) if quiz_id: query = query.filter(Question.quiz_id == quiz_id) if category_id is not None: query = query.filter(Question.question_category_id == category_id) if uncategorized: query = query.filter(Question.question_category_id.is_(None)) # ── Semantic search (pgvector) ───────────────────────────────── semantic_ids_ordered: list[int] = [] if q and q.strip() and search_mode in ("semantic", "hybrid"): from app.services.embedding_service import generate_embedding from sqlalchemy import text as sa_text emb = generate_embedding(q.strip()) if emb: emb_literal = "[" + ",".join(str(x) for x in emb) + "]" rows = db.execute(sa_text(f""" SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim FROM questions WHERE embedding IS NOT NULL ORDER BY embedding <=> '{emb_literal}'::vector LIMIT 200 """)).fetchall() semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30] # ── Keyword filter ───────────────────────────────────────────── if q and q.strip() and search_mode in ("keyword", "hybrid"): phrase = q.strip() query = query.filter( or_( Question.question_text.ilike(f"%{phrase}%"), cast(Question.options, String).ilike(f"%{phrase}%"), ) ) # Apply semantic ID filter if semantic-only mode if q and q.strip() and search_mode == "semantic" and semantic_ids_ordered: query = query.filter(Question.id.in_(semantic_ids_ordered)) total = query.count() questions = query.order_by(Question.source_quiz_id, Question.id).offset(offset).limit(limit).all() # If hybrid: merge semantic first then keyword remainder if semantic_ids_ordered and search_mode == "hybrid": sem_set = set(semantic_ids_ordered) sem_qs = [qu for qu in questions if qu.id in sem_set] kw_qs = [qu for qu in questions if qu.id not in sem_set] # Sort semantic by original similarity order sem_order = {qid: i for i, qid in enumerate(semantic_ids_ordered)} sem_qs.sort(key=lambda qu: sem_order.get(qu.id, 999)) questions = sem_qs + kw_qs quiz_cache: dict[int, str] = {} cat_cache: dict[int, str] = {} result = [] for qu in questions: src_id = qu.source_quiz_id if src_id not in quiz_cache: quiz = db.query(Quiz).filter(Quiz.id == src_id).first() if src_id else None quiz_cache[src_id] = quiz.title if quiz else (f"Quiz {src_id}" if src_id else "Unknown") cat_name = None if qu.question_category_id: if qu.question_category_id not in cat_cache: cat = db.query(QuestionCategory).filter(QuestionCategory.id == qu.question_category_id).first() cat_cache[qu.question_category_id] = cat.name if cat else None cat_name = cat_cache.get(qu.question_category_id) result.append({ "id": qu.id, "quiz_id": qu.source_quiz_id, "quiz_title": quiz_cache[src_id], "question_category_id": qu.question_category_id, "question_category_name": cat_name, "question_text": qu.question_text, "question_type": qu.question_type, "options": qu.options, "correct_answer": qu.correct_answer, "explanation": qu.explanation, }) return {"total": total, "questions": result} class CreateFromBankRequest(BaseModel): title: str question_ids: list[int] mode: str = "timed" time_limit_minutes: int | None = None @router.post("/from-bank") def create_quiz_from_bank( data: CreateFromBankRequest, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Create a new quiz referencing existing bank questions (no copying — edits propagate).""" if not data.title.strip(): raise HTTPException(status_code=400, detail="Title is required") if not data.question_ids: raise HTTPException(status_code=400, detail="Select at least one question") if data.mode not in ("timed", "learning"): raise HTTPException(status_code=400, detail="Mode must be timed or learning") source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all() if not source_questions: raise HTTPException(status_code=404, detail="No valid questions found") # Preserve caller's requested order id_order = {qid: i for i, qid in enumerate(data.question_ids)} source_questions.sort(key=lambda q: id_order.get(q.id, len(data.question_ids))) # Find a section_id from the source questions' origin quiz first_src_quiz_id = source_questions[0].source_quiz_id first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None section_id = first_quiz.section_id if first_quiz else source_questions[0].source_quiz_id or 1 new_quiz = Quiz( section_id=section_id, user_id=current_user.id, title=data.title.strip(), questions_count=len(source_questions), mode=data.mode, time_limit_minutes=data.time_limit_minutes, ) db.add(new_quiz) db.flush() # Reference existing questions via junction (no copies) from app.models.quiz_question_link import QuizQuestionLink for pos, sq in enumerate(source_questions): db.add(QuizQuestionLink(quiz_id=new_quiz.id, question_id=sq.id, position=pos)) db.commit() db.refresh(new_quiz) return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count} class BulkCategoryRequest(BaseModel): question_ids: list[int] category_id: int | None = None @router.post("/bulk-category") def bulk_set_question_category( data: BulkCategoryRequest, db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): """Assign or remove a category from multiple questions.""" if data.category_id is not None: cat = db.query(QuestionCategory).filter(QuestionCategory.id == data.category_id).first() if not cat: raise HTTPException(status_code=404, detail="Category not found") updated = db.query(Question).filter(Question.id.in_(data.question_ids)).update( {"question_category_id": data.category_id}, synchronize_session=False ) db.commit() return {"updated": updated, "question_category_id": data.category_id}