diff --git a/README.md b/README.md new file mode 100644 index 0000000..877ffa9 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# ๐Ÿฉบ PedQuiz + +AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automatically extract MCQ questions with AI, and take quizzes with text-to-speech support and semantic search. + +## Features + +- **PDF โ†’ Quiz**: Upload PREP PDFs, AI extracts questions, answers, and explanations +- **Quiz Modes**: Study (instant feedback) and Exam (timed, scored) +- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs โ€” voice selection per quiz +- **Semantic Search**: pgvector + AWS Titan Embed โ€” finds questions by meaning, not just keywords +- **Email Verification**: Required before first login; password reset via email +- **Role system**: Admin / Moderator / User +- **Nextcloud Integration**: Browse and import PDFs from your Nextcloud in Upload page +- **Themes**: Default and Markdown (GitHub-style) + +## Stack + +| Layer | Tech | +|---|---| +| Frontend | React + React Router, plain CSS, Nginx | +| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector | +| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock) | +| Embeddings | AWS Bedrock Titan Embed V2 via LiteLLM proxy | +| Document vectors | ChromaDB | +| TTS | OpenAI, AWS Polly, ElevenLabs | +| Queue | Celery + Redis | +| Email | SMTP (smtp2go) | + +## Quick Start + +```bash +git clone https://github.com/ifedan-ed/pdf-quiz-generator.git +cd pdf-quiz-generator + +# Configure environment +cp backend/.env.example backend/.env # edit with your keys + +docker compose up -d +``` + +Frontend available at `http://localhost:8081`. The first registered user becomes admin automatically. + +## Environment Variables + +Create `backend/.env`: + +```env +# Database +DATABASE_URL=postgresql://pedquiz:@postgres:5432/pedquiz +SECRET_KEY= + +# Redis +REDIS_URL=redis://redis:6379/0 + +# LLM (for question extraction) +LITELLM_MODEL=openai/claude-haiku-4.5 +LITELLM_API_KEY= +LITELLM_API_BASE=https://your-litellm-proxy.com # or leave empty for direct OpenAI +LITELLM_EMBEDDING_MODEL=openai/titan-embed-v2 + +# OpenAI (for TTS โ€” uses api.openai.com directly, not proxy) +OPENAI_API_KEY= + +# AWS (for Polly TTS + Bedrock embedding fallback) +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 +AWS_BEDROCK_REGION=us-east-1 + +# ElevenLabs TTS (optional) +ELEVENLABS_API_KEY= + +# Google Cloud TTS (optional) +GOOGLE_TTS_API_KEY= + +# Email +MAIL_SERVER=mail.smtp2go.com +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=noreply@yourdomain.com +MAIL_STARTTLS=true + +# App +APP_URL=https://your-domain.com +UPLOAD_DIR=/app/uploads +MAX_UPLOAD_SIZE=524288000 +CHROMA_PERSIST_DIR=/app/chroma_data +``` + +## CLI Management + +```bash +# Reset a user's password (e.g. locked-out admin) +docker compose exec backend python manage.py reset-password admin@example.com NewPassword123 + +# List all users and verification status +docker compose exec backend python manage.py list-users + +# Regenerate all question embeddings (e.g. after switching embedding model) +docker compose exec backend python manage.py reembed +``` + +## Architecture + +``` +Browser + โ”‚ + โ–ผ +Nginx (frontend + API proxy) + โ”œโ”€โ–บ React SPA (static) + โ””โ”€โ–บ FastAPI backend + โ”œโ”€ PostgreSQL (pgvector) โ† users, quizzes, questions + embeddings + โ”œโ”€ ChromaDB โ† document page chunks for quiz generation + โ”œโ”€ Redis โ† Celery task queue + โ”œโ”€ Celery workers โ† background PDF processing, emails + โ”œโ”€ LiteLLM proxy โ† Claude/Gemini/GPT for extraction + embeddings + โ”œโ”€ AWS Bedrock โ† Polly TTS, Titan embed fallback + โ””โ”€ OpenAI โ† TTS (direct, not via proxy) +``` + +### Search + +Quiz search uses **hybrid retrieval**: +1. **Semantic** โ€” embed the query with Titan Embed V2, cosine similarity against all questions via pgvector HNSW index +2. **Keyword** โ€” PostgreSQL `ILIKE` on question text and options +3. Results merged and ranked โ€” semantic matches shown first by score, keyword-only matches appended + +### TTS Providers + +| Provider | Model IDs | Key Needed | +|---|---|---| +| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1:echo`, `tts-1:shimmer`, `tts-1:onyx`, `tts-1:fable`, `tts-1-hd:*` | `OPENAI_API_KEY` | +| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy`, `polly/Brian` | `AWS_ACCESS_KEY_ID` + `polly:SynthesizeSpeech` IAM permission | +| ElevenLabs | `elevenlabs/` | `ELEVENLABS_API_KEY` | +| Google Cloud | `google/` | `GOOGLE_TTS_API_KEY` | + +## Project Structure + +``` +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ app/ +โ”‚ โ”‚ โ”œโ”€โ”€ main.py # App startup, seeding, pgvector setup +โ”‚ โ”‚ โ”œโ”€โ”€ config.py # Settings (pydantic-settings) +โ”‚ โ”‚ โ”œโ”€โ”€ models/ # SQLAlchemy models +โ”‚ โ”‚ โ”œโ”€โ”€ routers/ # FastAPI route handlers +โ”‚ โ”‚ โ”œโ”€โ”€ services/ +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ai_service.py # LLM extraction + TTS +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ embedding_service.py # pgvector embeddings +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ vector_service.py # ChromaDB (document pages) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ quiz_service.py # Quiz creation pipeline +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ email_service.py # Email templates + sending +โ”‚ โ”‚ โ””โ”€โ”€ utils/ +โ”‚ โ”œโ”€โ”€ manage.py # CLI: reset-password, list-users, reembed +โ”‚ โ”œโ”€โ”€ requirements.txt +โ”‚ โ””โ”€โ”€ Dockerfile +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Dashboard, Quiz, Results, Settings, Search โ€ฆ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # Navbar, LineChart +โ”‚ โ”‚ โ””โ”€โ”€ context/ # AuthContext, ThemeContext +โ”‚ โ”œโ”€โ”€ nginx.conf +โ”‚ โ””โ”€โ”€ Dockerfile +โ””โ”€โ”€ docker-compose.yml +``` + +## Deployment Notes + +- The frontend Nginx only binds to `127.0.0.1:8081` โ€” put a reverse proxy (Caddy/Nginx) in front for HTTPS +- PostgreSQL data is persisted in the `postgres_data` Docker volume โ€” back it up regularly +- Uploads live in `uploads_data` volume โ€” includes extracted question images +- Set `APP_URL` to your public domain so verification/reset email links work diff --git a/backend/app/config.py b/backend/app/config.py index 98a4fc3..2445f41 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,6 +13,17 @@ class Settings(BaseSettings): LITELLM_MODEL: str = "gpt-4o-mini" LITELLM_API_KEY: str = "" + LITELLM_API_BASE: str = "" + LITELLM_EMBEDDING_MODEL: str = "" + OPENAI_API_KEY: str = "" + ELEVENLABS_API_KEY: str = "" + GOOGLE_TTS_API_KEY: str = "" + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + AWS_REGION: str = "us-east-1" + AWS_BEDROCK_REGION: str = "us-east-1" + EMBEDDING_DIMENSIONS: int = 1024 + APP_URL: str = "https://quiz.danvics.com" CHROMA_PERSIST_DIR: str = "./chroma_data" diff --git a/backend/app/database.py b/backend/app/database.py index 3e5b470..aecff97 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,20 +1,9 @@ -from sqlalchemy import create_engine, event +from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base from app.config import settings -engine = create_engine( - settings.DATABASE_URL, - connect_args={"check_same_thread": False}, -) - - -@event.listens_for(engine, "connect") -def set_sqlite_pragma(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("PRAGMA journal_mode=WAL") - cursor.close() - +engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() diff --git a/backend/app/main.py b/backend/app/main.py index 9a42180..bfc5e71 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,8 @@ from app.utils.scheduler import start_scheduler, stop_scheduler def seed_admin(): """Create default admin user if none exists.""" from app.models.user import User + from app.models.email_verification import EmailVerification + from app.models.password_reset import PasswordReset db = SessionLocal() try: admin_exists = db.query(User).filter(User.role == "admin").first() @@ -26,7 +28,29 @@ def seed_admin(): role="admin", ) db.add(admin_user) + db.flush() + # Auto-verify seeded admin + from datetime import datetime + db.add(EmailVerification( + user_id=admin_user.id, + token="seeded", + expires_at=datetime.utcnow(), + verified_at=datetime.utcnow(), + )) db.commit() + else: + # Ensure existing admin has a verified email record + from app.models.email_verification import EmailVerification + from datetime import datetime + existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first() + if not existing_v: + db.add(EmailVerification( + user_id=admin_exists.id, + token=f"legacy_{admin_exists.id}", + expires_at=datetime.utcnow(), + verified_at=datetime.utcnow(), + )) + db.commit() finally: db.close() @@ -38,57 +62,123 @@ def seed_default_models(): try: if db.query(AIModelConfig).count() == 0: defaults = [ - AIModelConfig( - name="GPT-4o Mini (Extraction)", - model_id="gpt-4o-mini", - task="extraction", - is_active=True, - is_default=True, - ), - AIModelConfig( - name="GPT-4o (Extraction)", - model_id="gpt-4o", - task="extraction", - is_active=True, - is_default=False, - ), - AIModelConfig( - name="Claude Sonnet 4.6 (Extraction)", - model_id="anthropic/claude-sonnet-4-6-20250514", - task="extraction", - is_active=True, - is_default=False, - ), - AIModelConfig( - name="Google Vertex TTS", - model_id="vertex_ai/google/cloud-tts", - task="tts", - is_active=True, - is_default=True, - ), - AIModelConfig( - name="OpenAI TTS", - model_id="openai/tts-1", - task="tts", - is_active=True, - is_default=False, - ), + AIModelConfig(name="Claude Haiku 4.5", model_id="claude-haiku-4.5", task="extraction", is_active=True, is_default=True), + AIModelConfig(name="Claude Sonnet 4.6", model_id="claude-sonnet-4.6", task="extraction", is_active=True, is_default=False), + AIModelConfig(name="Gemini 2.5 Flash", model_id="gemini-2.5-flash", task="extraction", is_active=True, is_default=False), + AIModelConfig(name="Titan Embed v2 (Embedding)", model_id="titan-embed-v2", task="general", is_active=True, is_default=False), ] db.add_all(defaults) db.commit() + + # Always ensure OpenAI TTS voice models exist (idempotent) + tts_voices = [ + # OpenAI (work with OPENAI_API_KEY) + ("OpenAI Alloy", "tts-1:alloy", True), + ("OpenAI Nova", "tts-1:nova", False), + ("OpenAI Echo", "tts-1:echo", False), + ("OpenAI Shimmer", "tts-1:shimmer", False), + ("OpenAI Onyx", "tts-1:onyx", False), + ("OpenAI Fable", "tts-1:fable", False), + ("OpenAI Alloy HD", "tts-1-hd:alloy", False), + ("OpenAI Nova HD", "tts-1-hd:nova", False), + # ElevenLabs (work with ELEVENLABS_API_KEY) + ("ElevenLabs Adam", "elevenlabs/adam", False), + # Google Cloud TTS (work with GOOGLE_TTS_API_KEY) + ("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False), + ("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False), + ("Google Studio O (en-US)", "google/en-US-Studio-O", False), + ("Google Studio Q (en-US)", "google/en-US-Studio-Q", False), + ("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False), + # AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY) + ("AWS Polly Joanna (en-US)", "polly/Joanna", False), + ("AWS Polly Matthew (en-US)", "polly/Matthew", False), + ("AWS Polly Amy (en-GB)", "polly/Amy", False), + ("AWS Polly Brian (en-GB)", "polly/Brian", False), + ] + # Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded) + for old_id in ("tts-1", "tts-1-hd"): + old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first() + if old: + old.is_active = False + old.is_default = False + + has_default_tts = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True, + ).first() is not None + + for name, model_id, _ in tts_voices: + exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first() + if not exists: + is_def = not has_default_tts + db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def)) + if is_def: + has_default_tts = True + db.commit() finally: db.close() +def setup_pgvector(): + """Enable pgvector extension and add embedding column if missing.""" + from sqlalchemy import text + with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.execute(text(""" + ALTER TABLE questions + ADD COLUMN IF NOT EXISTS embedding vector(1024) + """)) + # HNSW index for fast cosine similarity โ€” created only if not exists + conn.execute(text(""" + CREATE INDEX IF NOT EXISTS questions_embedding_hnsw + ON questions USING hnsw (embedding vector_cosine_ops) + """)) + conn.commit() + + +def backfill_embeddings(): + """Generate embeddings for questions that don't have one yet (background, best-effort).""" + import threading + from app.models.question import Question + from app.services import embedding_service + + def _run(): + db = SessionLocal() + try: + missing = db.query(Question).filter(Question.embedding.is_(None)).all() + if not missing: + return + import logging + log = logging.getLogger(__name__) + log.info(f"Backfilling embeddings for {len(missing)} questions...") + ok = 0 + for q in missing: + try: + if embedding_service.embed_question(q): + ok += 1 + except Exception: + pass + db.commit() + log.info(f"Backfill complete: {ok}/{len(missing)} embedded") + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Embedding backfill failed: {e}") + finally: + db.close() + + threading.Thread(target=_run, daemon=True).start() + + @asynccontextmanager async def lifespan(app: FastAPI): # Startup Base.metadata.create_all(bind=engine) + setup_pgvector() os.makedirs(settings.UPLOAD_DIR, exist_ok=True) os.makedirs(os.path.join(settings.UPLOAD_DIR, "images"), exist_ok=True) os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True) seed_admin() seed_default_models() + backfill_embeddings() start_scheduler() yield # Shutdown @@ -96,15 +186,15 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="PDF Quiz Generator", - description="Convert PDF files into interactive quizzes with AI", + title="PedQuiz", + description="Pediatric Knowledge Quiz Platform", version="2.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5173", "http://localhost:3000", "http://localhost"], + allow_origins=["https://quiz.danvics.com"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/app/models/attempt.py b/backend/app/models/attempt.py index 4d39567..99ecb78 100644 --- a/backend/app/models/attempt.py +++ b/backend/app/models/attempt.py @@ -10,8 +10,8 @@ class QuizAttempt(Base): __tablename__ = "quiz_attempts" id = Column(Integer, primary_key=True, index=True) - quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) score = Column(Integer, default=0) total_questions = Column(Integer, default=0) started_at = Column(DateTime, default=datetime.utcnow) @@ -26,8 +26,8 @@ class AttemptAnswer(Base): __tablename__ = "attempt_answers" id = Column(Integer, primary_key=True, index=True) - attempt_id = Column(Integer, ForeignKey("quiz_attempts.id"), nullable=False) - question_id = Column(Integer, ForeignKey("questions.id"), nullable=False) + attempt_id = Column(Integer, ForeignKey("quiz_attempts.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) user_answer = Column(String, nullable=False) is_correct = Column(Boolean, default=False) diff --git a/backend/app/models/email_verification.py b/backend/app/models/email_verification.py new file mode 100644 index 0000000..745a705 --- /dev/null +++ b/backend/app/models/email_verification.py @@ -0,0 +1,14 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from app.database import Base + + +class EmailVerification(Base): + __tablename__ = "email_verifications" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True) + token = Column(String, unique=True, nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + verified_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/password_reset.py b/backend/app/models/password_reset.py new file mode 100644 index 0000000..59c4f65 --- /dev/null +++ b/backend/app/models/password_reset.py @@ -0,0 +1,14 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean +from app.database import Base + + +class PasswordReset(Base): + __tablename__ = "password_resets" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + token = Column(String, unique=True, nullable=False, index=True) + expires_at = Column(DateTime, nullable=False) + used = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/pdf_document.py b/backend/app/models/pdf_document.py index 4ace831..3087dd4 100644 --- a/backend/app/models/pdf_document.py +++ b/backend/app/models/pdf_document.py @@ -10,7 +10,7 @@ class PDFDocument(Base): __tablename__ = "pdf_documents" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) filename = Column(String, nullable=False) original_filename = Column(String, nullable=False) total_pages = Column(Integer, nullable=True) diff --git a/backend/app/models/question.py b/backend/app/models/question.py index fca7d3c..9b7d5b2 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -1,6 +1,8 @@ +from pgvector.sqlalchemy import Vector from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey from sqlalchemy.orm import relationship +from app.config import settings from app.database import Base @@ -8,13 +10,14 @@ class Question(Base): __tablename__ = "questions" id = Column(Integer, primary_key=True, index=True) - quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False) + quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) question_text = Column(Text, nullable=False) question_type = Column(String, nullable=False) # mcq, true_false, fill_blank options = Column(JSON, nullable=True) # list of strings for mcq correct_answer = Column(String, nullable=False) explanation = Column(Text, nullable=True) page_reference = Column(Integer, nullable=True) - image_path = Column(String, nullable=True) # path to extracted image, if any + image_path = Column(String, nullable=True) + embedding = Column(Vector(1024), nullable=True) # semantic search vector quiz = relationship("Quiz", back_populates="questions") diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index f92ae6c..9b752e9 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text from sqlalchemy.orm import relationship from app.database import Base @@ -16,9 +16,11 @@ class Quiz(Base): questions_count = Column(Integer, default=0) time_limit_minutes = Column(Integer, nullable=True) # null = no limit mode = Column(String, default="timed") # timed, learning + skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts created_at = Column(DateTime, default=datetime.utcnow) section = relationship("Section", back_populates="quizzes") user = relationship("User", back_populates="quizzes") questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan") attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan") + reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index 8009fcb..8f9dfe6 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -10,8 +10,8 @@ class ReminderSchedule(Base): __tablename__ = "reminder_schedules" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - quiz_id = Column(Integer, ForeignKey("quizzes.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) next_reminder_at = Column(DateTime, nullable=False) interval_days = Column(Integer, default=1) performance_score = Column(Float, default=0.0) diff --git a/backend/app/models/section.py b/backend/app/models/section.py index c476c35..110f47b 100644 --- a/backend/app/models/section.py +++ b/backend/app/models/section.py @@ -8,7 +8,7 @@ class Section(Base): __tablename__ = "sections" id = Column(Integer, primary_key=True, index=True) - document_id = Column(Integer, ForeignKey("pdf_documents.id"), nullable=False) + document_id = Column(Integer, ForeignKey("pdf_documents.id", ondelete="CASCADE"), nullable=False) name = Column(String, nullable=False) start_page = Column(Integer, nullable=False) end_page = Column(Integer, nullable=False) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 1f82004..83b7094 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,12 +1,14 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session +import httpx +from app.config import settings from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig -from app.schemas.auth import UserResponse, UserUpdateRole +from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate -from app.utils.auth import require_admin +from app.utils.auth import require_admin, get_current_user, get_password_hash router = APIRouter() @@ -43,8 +45,88 @@ def update_user_role( return user +@router.post("/users", response_model=UserResponse) +def create_user( + user_data: UserCreate, + db: Session = Depends(get_db), + admin: User = Depends(require_admin), +): + """Admin creates a user directly โ€” email is auto-verified.""" + from app.models.email_verification import EmailVerification + from datetime import datetime + + if db.query(User).filter(User.email == user_data.email).first(): + raise HTTPException(status_code=400, detail="Email already registered") + + user = User( + email=user_data.email, + hashed_password=get_password_hash(user_data.password), + name=user_data.name, + role="user", + ) + db.add(user) + db.flush() + db.add(EmailVerification( + user_id=user.id, + token=f"admin_created_{user.id}", + expires_at=datetime.utcnow(), + verified_at=datetime.utcnow(), + )) + db.commit() + db.refresh(user) + return user + + # --- AI Model Configuration --- +@router.get("/models/available") +def list_available_models( + task: str = Query("extraction"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Returns active models for a given task โ€” for users to choose when taking/creating a quiz.""" + models = db.query(AIModelConfig).filter( + AIModelConfig.task == task, + AIModelConfig.is_active == True, + ).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() + result = [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models] + # Always include env default as fallback if nothing configured + if not result: + result.append({"id": None, "name": "Default (from config)", "model_id": settings.LITELLM_MODEL, "is_default": True}) + return result + + +@router.get("/litellm/models") +def search_litellm_models( + api_key: str = Query(None), + api_base: str = Query(None), + admin: User = Depends(require_admin), +): + """Query available models from LiteLLM proxy or OpenAI-compatible API.""" + base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/") + key = api_key or settings.LITELLM_API_KEY + + if base: + try: + headers = {"Authorization": f"Bearer {key}"} if key else {} + resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10) + resp.raise_for_status() + data = resp.json() + models = sorted([m["id"] for m in data.get("data", [])]) + return {"models": models, "source": base} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to query models API: {e}") + + # Fall back to LiteLLM's built-in model list + try: + import litellm + models = sorted(litellm.utils.get_valid_models()) + return {"models": models, "source": "litellm-builtin"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get LiteLLM models: {e}") + + @router.get("/models", response_model=list[AIModelConfigResponse]) def list_models( db: Session = Depends(get_db), @@ -62,7 +144,6 @@ def create_model( if data.task not in ("extraction", "tts", "general"): raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general") - # If setting as default, unset other defaults for same task if data.is_default: db.query(AIModelConfig).filter( AIModelConfig.task == data.task, @@ -89,7 +170,6 @@ def update_model( update_data = data.model_dump(exclude_unset=True) - # If setting as default, unset other defaults for same task task = update_data.get("task", model.task) if update_data.get("is_default"): db.query(AIModelConfig).filter( diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index f3126d3..2e6b186 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -29,7 +29,7 @@ def start_attempt( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.user_id == current_user.id).first() + quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") @@ -74,33 +74,37 @@ def submit_attempt( } score = 0 - answer_details = [] + submitted = {ans.question_id: ans.user_answer for ans in submission.answers} + # Save submitted answers for ans in submission.answers: question = questions.get(ans.question_id) if not question: continue - is_correct = ans.user_answer.strip().lower() == question.correct_answer.strip().lower() if is_correct: score += 1 - - attempt_answer = AttemptAnswer( + db.add(AttemptAnswer( attempt_id=attempt_id, question_id=ans.question_id, user_answer=ans.user_answer, is_correct=is_correct, - ) - db.add(attempt_answer) + )) + # Build full review โ€” include ALL questions, unanswered marked as incorrect + answer_details = [] + for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all(): + user_answer = submitted.get(q.id, "") + is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() answer_details.append(AnswerDetail( - question_id=question.id, - question_text=question.question_text, - question_type=question.question_type, - user_answer=ans.user_answer, - correct_answer=question.correct_answer, + question_id=q.id, + question_text=q.question_text, + question_type=q.question_type, + options=q.options, + user_answer=user_answer, + correct_answer=q.correct_answer, is_correct=is_correct, - explanation=question.explanation, + explanation=q.explanation, )) attempt.score = score @@ -153,6 +157,39 @@ def list_attempts( ] +@router.get("/history") +def get_quiz_history( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return per-quiz attempt history for the performance line graph.""" + completed = db.query(QuizAttempt).filter( + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.isnot(None), + ).order_by(QuizAttempt.completed_at).all() + + # Group by quiz + from collections import defaultdict + by_quiz: dict = defaultdict(list) + quiz_titles: dict = {} + for a in completed: + pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1) + by_quiz[a.quiz_id].append({ + "date": a.completed_at.isoformat(), + "percentage": pct, + "score": a.score, + "total": a.total_questions, + }) + if a.quiz_id not in quiz_titles: + quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first() + quiz_titles[a.quiz_id] = quiz.title if quiz else f"Quiz {a.quiz_id}" + + return [ + {"quiz_id": qid, "title": quiz_titles[qid], "attempts": attempts} + for qid, attempts in by_quiz.items() + ] + + @router.get("/stats/dashboard", response_model=DashboardStats) def get_dashboard_stats( db: Session = Depends(get_db), @@ -210,17 +247,20 @@ def get_attempt( if not attempt: raise HTTPException(status_code=404, detail="Attempt not found") + # Show ALL questions in review, not just answered ones + submitted_map = {ans.question_id: ans for ans in attempt.answers} answer_details = [] - for ans in attempt.answers: - question = ans.question + for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all(): + ans = submitted_map.get(q.id) answer_details.append(AnswerDetail( - question_id=question.id, - question_text=question.question_text, - question_type=question.question_type, - user_answer=ans.user_answer, - correct_answer=question.correct_answer, - is_correct=ans.is_correct, - explanation=question.explanation, + question_id=q.id, + question_text=q.question_text, + question_type=q.question_type, + options=q.options, + user_answer=ans.user_answer if ans else "", + correct_answer=q.correct_answer, + is_correct=ans.is_correct if ans else False, + explanation=q.explanation, )) percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0fc48dc..b3f56cc 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,49 +1,224 @@ -from fastapi import APIRouter, Depends, HTTPException, status +import secrets +from collections import defaultdict +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from sqlalchemy.orm import Session from app.database import get_db from app.models.user import User -from app.schemas.auth import UserCreate, UserResponse, Token, LoginRequest -from app.utils.auth import get_password_hash, verify_password, create_access_token, get_current_user +from app.models.email_verification import EmailVerification +from app.models.password_reset import PasswordReset +from app.schemas.auth import ( + UserCreate, UserResponse, Token, LoginRequest, + UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, +) +from app.services import email_service +from app.utils.auth import ( + get_password_hash, verify_password, create_access_token, get_current_user, +) router = APIRouter() +# Simple in-memory login rate limiter: max 10 attempts per IP per 15 min +_login_attempts: dict = defaultdict(list) -@router.post("/register", response_model=Token) -def register(user_data: UserCreate, db: Session = Depends(get_db)): - existing = db.query(User).filter(User.email == user_data.email).first() - if existing: +def _check_login_rate_limit(client_ip: str): + now = datetime.utcnow() + window = now - timedelta(minutes=15) + attempts = [t for t in _login_attempts[client_ip] if t > window] + _login_attempts[client_ip] = attempts + if len(attempts) >= 10: + raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.") + _login_attempts[client_ip].append(now) + + +# Rate limit: max 3 reset requests per email per hour +RESET_LIMIT = 3 +RESET_WINDOW_HOURS = 1 + + +def _check_reset_rate_limit(db: Session, email: str): + user = db.query(User).filter(User.email == email).first() + if not user: + return # silently ignore unknown emails + window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS) + count = db.query(PasswordReset).filter( + PasswordReset.user_id == user.id, + PasswordReset.created_at >= window_start, + ).count() + if count >= RESET_LIMIT: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered", + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Too many reset requests. Please wait before trying again.", ) + +@router.post("/register") +async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + if len(user_data.password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + existing = db.query(User).filter(User.email == user_data.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + is_first_user = db.query(User).count() == 0 user = User( email=user_data.email, hashed_password=get_password_hash(user_data.password), name=user_data.name, + role="admin" if is_first_user else "user", ) db.add(user) + db.flush() + + # Create email verification record + token = secrets.token_urlsafe(32) + verification = EmailVerification( + user_id=user.id, + token=token, + expires_at=datetime.utcnow() + timedelta(hours=24), + verified_at=datetime.utcnow() if is_first_user else None, + ) + db.add(verification) db.commit() db.refresh(user) - access_token = create_access_token(data={"sub": user.email}) - return Token(access_token=access_token) + if is_first_user: + # Auto-verified admin โ€” log them in immediately + access_token = create_access_token(data={"sub": user.email}) + return {"access_token": access_token, "token_type": "bearer"} + + # Regular user โ€” must verify email before logging in + background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token) + return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."} @router.post("/login", response_model=Token) -def login(login_data: LoginRequest, db: Session = Depends(get_db)): +def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None): + if request: + client_ip = request.client.host if request.client else "unknown" + _check_login_rate_limit(client_ip) + user = db.query(User).filter(User.email == login_data.email).first() if not user or not verify_password(login_data.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid email or password") + + # Check email verification โ€” skip for users without any verification record (legacy/seeded) + verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() + if verification and verification.verified_at is None: raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", + status_code=403, + detail="Email not verified. Please check your inbox and verify your email before logging in.", ) access_token = create_access_token(data={"sub": user.email}) return Token(access_token=access_token) +@router.get("/verify-email") +def verify_email(token: str, db: Session = Depends(get_db)): + record = db.query(EmailVerification).filter(EmailVerification.token == token).first() + if not record: + raise HTTPException(status_code=400, detail="Invalid verification token") + if record.verified_at is not None: + return {"message": "Email already verified. You can log in."} + if datetime.utcnow() > record.expires_at: + raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.") + + record.verified_at = datetime.utcnow() + db.commit() + return {"message": "Email verified successfully! You can now log in."} + + +@router.post("/resend-verification") +async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + user = db.query(User).filter(User.email == data.email).first() + if not user: + return {"message": "If that email exists, a verification link has been sent."} + + record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first() + if record and record.verified_at is not None: + return {"message": "Email already verified."} + + token = secrets.token_urlsafe(32) + if record: + record.token = token + record.expires_at = datetime.utcnow() + timedelta(hours=24) + else: + db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24))) + db.commit() + + background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token) + return {"message": "If that email exists, a verification link has been sent."} + + +@router.post("/forgot-password") +async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + _check_reset_rate_limit(db, data.email) + + user = db.query(User).filter(User.email == data.email).first() + # Always return same message to avoid email enumeration + if not user: + return {"message": "If that email is registered, a reset link has been sent."} + + token = secrets.token_urlsafe(32) + reset = PasswordReset( + user_id=user.id, + token=token, + expires_at=datetime.utcnow() + timedelta(hours=1), + ) + db.add(reset) + db.commit() + + background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token) + return {"message": "If that email is registered, a reset link has been sent."} + + +@router.post("/reset-password") +def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)): + if len(data.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + + record = db.query(PasswordReset).filter( + PasswordReset.token == data.token, + PasswordReset.used == False, + ).first() + if not record: + raise HTTPException(status_code=400, detail="Invalid or already used reset token") + if datetime.utcnow() > record.expires_at: + raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.") + + user = db.query(User).filter(User.id == record.user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.hashed_password = get_password_hash(data.new_password) + record.used = True + db.commit() + return {"message": "Password reset successfully. You can now log in."} + + @router.get("/me", response_model=UserResponse) def get_me(current_user: User = Depends(get_current_user)): return current_user + + +@router.put("/me", response_model=UserResponse) +def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + if data.new_password: + if not data.current_password: + raise HTTPException(status_code=400, detail="Current password required to set a new one") + if not verify_password(data.current_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Current password is incorrect") + if len(data.new_password) < 8: + raise HTTPException(status_code=400, detail="New password must be at least 8 characters") + current_user.hashed_password = get_password_hash(data.new_password) + + if data.name: + current_user.name = data.name + + db.commit() + db.refresh(current_user) + return current_user diff --git a/backend/app/routers/documents.py b/backend/app/routers/documents.py index dcfc0fb..348d6c0 100644 --- a/backend/app/routers/documents.py +++ b/backend/app/routers/documents.py @@ -28,7 +28,7 @@ def upload_document( # Save file to disk streaming (handles large files) os.makedirs(settings.UPLOAD_DIR, exist_ok=True) - safe_name = f"{uuid.uuid4()}_{file.filename}" + safe_name = f"{uuid.uuid4()}_{os.path.basename(file.filename)}" file_path = os.path.join(settings.UPLOAD_DIR, safe_name) with open(file_path, "wb") as buffer: @@ -140,6 +140,10 @@ def create_section( raise HTTPException(status_code=404, detail="Document not found") if doc.status != "ready": raise HTTPException(status_code=400, detail="Document is not ready yet") + if section_data.start_page < 1 or section_data.end_page < 1: + raise HTTPException(status_code=400, detail="Page numbers must be positive") + if section_data.start_page >= section_data.end_page: + raise HTTPException(status_code=400, detail="start_page must be less than end_page") if doc.total_pages and section_data.end_page > doc.total_pages: raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)") diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index e841721..05a88de 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -1,8 +1,11 @@ -from fastapi import APIRouter, Depends, HTTPException +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 @@ -37,6 +40,7 @@ def create_quiz( title=quiz_data.title, mode=quiz_data.mode, time_limit_minutes=quiz_data.time_limit_minutes, + model_id=quiz_data.model_id, ) return quiz except ValueError as e: @@ -45,6 +49,119 @@ def create_quiz( 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), @@ -61,19 +178,65 @@ def list_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. Learning mode includes answers.""" + """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 quiz.mode == "learning": + 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, @@ -96,6 +259,96 @@ def review_quiz( 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, diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index 0f9dc73..851d4de 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -3,8 +3,10 @@ from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session +from app.config import settings from app.database import get_db from app.models.user import User +from app.models.ai_model_config import AIModelConfig from app.services import ai_service from app.utils.auth import get_current_user @@ -13,6 +15,22 @@ router = APIRouter() class TTSRequest(BaseModel): text: str + voice: str | None = None # model_id override + + +@router.get("/voices") +def get_voices( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return available TTS voices from DB + env fallback.""" + db_models = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", + AIModelConfig.is_active == True, + ).order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() + + voices = [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] + return voices @router.post("/speak") @@ -21,15 +39,24 @@ def text_to_speech( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Convert text to speech using configured TTS model.""" + """Convert text to speech using configured or user-selected TTS model.""" if not request.text.strip(): raise HTTPException(status_code=400, detail="Text cannot be empty") - # Limit text length text = request.text[:2000] - # Get TTS model config - model_id, api_key = ai_service.get_model_for_task(db, "tts") + if request.voice: + config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first() + model_id = config.model_id if config else request.voice + api_key = config.api_key if (config and config.api_key) else None + else: + config = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", + AIModelConfig.is_active == True, + AIModelConfig.is_default == True, + ).first() + model_id = config.model_id if config else "tts-1:alloy" + api_key = config.api_key if (config and config.api_key) else None audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key) if audio is None: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index 59480a6..1658847 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -14,7 +14,7 @@ class AIModelConfigCreate(BaseModel): class AIModelConfigResponse(BaseModel): - model_config = {"protected_namespaces": ()} + model_config = {"protected_namespaces": (), "from_attributes": True} id: int name: str model_id: str @@ -23,9 +23,6 @@ class AIModelConfigResponse(BaseModel): is_default: bool created_at: datetime - class Config: - from_attributes = True - class AIModelConfigUpdate(BaseModel): model_config = {"protected_namespaces": ()} diff --git a/backend/app/schemas/attempt.py b/backend/app/schemas/attempt.py index 9fe175c..1b43e68 100644 --- a/backend/app/schemas/attempt.py +++ b/backend/app/schemas/attempt.py @@ -16,6 +16,7 @@ class AnswerDetail(BaseModel): question_id: int question_text: str question_type: str + options: list[str] | None = None user_answer: str correct_answer: str is_correct: bool diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 4cd0add..37b508f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -31,4 +31,19 @@ class LoginRequest(BaseModel): class UserUpdateRole(BaseModel): - role: str # admin, moderator, user + role: str + + +class UserUpdateMe(BaseModel): + name: str | None = None + current_password: str | None = None + new_password: str | None = None + + +class ForgotPasswordRequest(BaseModel): + email: EmailStr + + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 7f722af..e295a95 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -8,6 +8,7 @@ class QuizCreate(BaseModel): title: str mode: str = "timed" # timed, learning time_limit_minutes: int | None = None + model_id: str | None = None # override extraction model class QuestionResponse(BaseModel): @@ -35,6 +36,7 @@ class QuizResponse(BaseModel): questions_count: int mode: str time_limit_minutes: int | None + skipped_questions: str | None = None created_at: datetime class Config: diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 85ab6ee..e5fa4e0 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -7,28 +7,46 @@ from app.config import settings logger = logging.getLogger(__name__) -EXTRACTION_PROMPT = """You are a quiz question extractor. The following content is from a PDF that contains quiz/exam questions with answers and explanations. -Your job is to EXTRACT (not generate) all the questions, their options, correct answers, and explanations exactly as they appear in the content. +def _proxy_model(model_id: str) -> str: + """Prefix model with openai/ if using a LiteLLM proxy and no provider is specified.""" + if settings.LITELLM_API_BASE and "/" not in model_id: + return f"openai/{model_id}" + return model_id -Return a JSON object with a "questions" key containing an array where each object has: -- "question_text": the full question text as it appears -- "question_type": "mcq" if it has multiple choice options, "true_false" if True/False, "fill_blank" if fill-in-the-blank -- "options": array of option strings exactly as written (for mcq), ["True", "False"] (for true_false), or null (for fill_blank) -- "correct_answer": the correct answer exactly as marked in the source -- "explanation": the explanation text if provided, or "" if none -- "page_reference": {page_ref} +EXTRACTION_PROMPT = """You are extracting questions from a PREP (Pediatric Review and Education Program) exam PDF. -Important: -- Extract ALL questions found in the content โ€” do not skip any -- Preserve the original wording exactly -- If a question has an image reference, include "[IMAGE]" in the question_text where the image would be -- If no clear correct answer is marked, use the best answer based on the explanation +These PDFs follow a strict format: +1. A numbered question with a clinical vignette (patient scenario) +2. Five answer options labeled A, B, C, D, E +3. A line "Correct Answer: X" where X is the letter of the correct option +4. An explanation paragraph +5. A "Critique:" section with detailed reasoning +6. A "Content Specifications:" section listing the learning objectives + +Your task: extract every question from the content below and return ONLY a JSON object in this exact format: + +{{"questions": [ + {{ + "question_text": "", + "question_type": "mcq", + "options": ["