From 12d99d36099d3235478d0812a565327283fd44fe Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 3 Apr 2026 20:44:11 +0200 Subject: [PATCH] Add switchable embedding model, Polly toggle, job cancellation, and UI fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedding: - Embedding model now configurable via Admin UI (More tab) or LITELLM_EMBEDDING_MODEL env - Calls LiteLLM proxy directly via httpx (bypasses LiteLLM library param validation) - Passes dimensions=1024 to proxy; Redis setting overrides env var - Default model: ge-gemini-embedding-001 (Gemini AI Studio, 1024-dim) - Test button in admin UI to verify model works - Fixed vector_service to use httpx + Redis model (was broken with non-prefixed model names) Polly: - Global enable/disable toggle in Admin → More settings (stored in Redis) - /tts/voices filters out polly/* when disabled - /tts/speak rejects polly requests when disabled Job cancellation: - POST /quizzes/job/{job_id}/cancel endpoint - Cancel button on JobsPage for running jobs - Celery task checks Redis status at each chunk boundary and exits cleanly - Fixes DB lock on restart caused by cancelled jobs leaving open transactions Admin UI: - Settings tab renamed to "More" (heading: More Settings) - Model row overflow fixed (minWidth: 0 + ellipsis on model_id) - Embedding model search shows all proxy models (no auto-filter by "embed") - Navbar correctly excludes cancelled/failed jobs from "extracting" count README: - Added Rebuild & Restart section with commands - Updated embedding model reference Co-Authored-By: Claude Sonnet 4.6 --- README.md | 26 ++- backend/app/main.py | 21 ++- backend/app/models/__init__.py | 2 + backend/app/models/favorite.py | 16 ++ backend/app/models/reminder.py | 2 +- backend/app/models/user.py | 1 + backend/app/routers/admin.py | 82 +++++++-- backend/app/routers/attempts.py | 81 +++++++-- backend/app/routers/auth.py | 37 +++- backend/app/routers/favorites.py | 73 ++++++++ backend/app/routers/questions.py | 16 ++ backend/app/routers/quizzes.py | 39 ++++- backend/app/routers/tts.py | 25 ++- backend/app/schemas/favorite.py | 16 ++ backend/app/schemas/quiz.py | 4 + backend/app/services/embedding_service.py | 41 +++-- backend/app/services/vector_service.py | 32 ++-- backend/app/tasks/quiz_tasks.py | 6 + backend/app/utils/auth.py | 50 +++++- frontend/src/App.jsx | 2 + frontend/src/api/client.js | 15 +- frontend/src/components/Navbar.jsx | 2 +- frontend/src/pages/AdminPage.jsx | 201 +++++++++++++++++++++- frontend/src/pages/JobsPage.jsx | 11 ++ frontend/src/pages/NotFoundPage.jsx | 14 ++ frontend/src/pages/QuestionBankPage.jsx | 84 ++++++--- frontend/src/pages/QuizEditPage.jsx | 59 ++++++- frontend/src/pages/QuizPage.jsx | 178 ++++++++++++------- frontend/src/pages/QuizzesPage.jsx | 4 + frontend/src/pages/SettingsPage.jsx | 2 +- 30 files changed, 961 insertions(+), 181 deletions(-) create mode 100644 backend/app/models/favorite.py create mode 100644 backend/app/routers/favorites.py create mode 100644 backend/app/schemas/favorite.py create mode 100644 frontend/src/pages/NotFoundPage.jsx diff --git a/README.md b/README.md index 451c6f6..3092567 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automa | 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 | +| Embeddings | Configurable via Admin UI or `LITELLM_EMBEDDING_MODEL` env (default: `ge-gemini-embedding-001`) | | Document vectors | ChromaDB | | TTS | OpenAI, AWS Polly, ElevenLabs | | Queue | Celery + Redis | @@ -56,7 +56,7 @@ REDIS_URL=redis://redis:6379/0 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 +LITELLM_EMBEDDING_MODEL=ge-gemini-embedding-001 # model name as proxy knows it, no prefix needed # OpenAI (for TTS — uses api.openai.com directly, not proxy) OPENAI_API_KEY= @@ -88,6 +88,28 @@ MAX_UPLOAD_SIZE=524288000 CHROMA_PERSIST_DIR=/app/chroma_data ``` +## Rebuild & Restart + +```bash +# Rebuild and restart everything +docker compose build && docker compose up -d + +# Rebuild and restart a single service (backend, frontend, or celery) +docker compose build backend && docker compose up -d backend +docker compose build frontend && docker compose up -d frontend +docker compose build celery && docker compose up -d celery + +# Restart without rebuilding (picks up .env changes, not code changes) +docker compose restart backend +docker compose restart frontend + +# View logs +docker compose logs backend --tail=50 +docker compose logs celery --tail=50 +``` + +> **Note:** Frontend and backend are built into Docker images — code changes require a `build` before they take effect. Only `.env` changes and volume-mounted data (uploads, chroma, postgres) are picked up by a plain `restart`. + ## CLI Management All commands run inside the backend container: diff --git a/backend/app/main.py b/backend/app/main.py index e3c2e86..9d5dff2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings from app.database import engine, Base, SessionLocal -from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories +from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -122,7 +122,7 @@ def setup_pgvector(): """Enable pgvector, add new columns/tables, run schema migrations.""" from sqlalchemy import text # Import new models so create_all picks them up - from app.models import quiz_category, quiz_question_link, question_category # noqa + from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa 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)")) @@ -193,6 +193,17 @@ def setup_pgvector(): """)) # Make quiz_id on questions nullable (it becomes informational "source_quiz_id") conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL")) + + # Favorites table + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS favorites ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, question_id) + ) + """)) conn.commit() conn.commit() @@ -260,8 +271,13 @@ app.add_middleware( allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-New-Token"], # Allow frontend to read this header ) +# Add token refresh middleware AFTER CORS (middleware applies in reverse) +from app.utils.auth import TokenRefreshMiddleware +app.add_middleware(TokenRefreshMiddleware) + # Serve uploaded images as static files app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") @@ -275,6 +291,7 @@ app.include_router(nextcloud.router, prefix="/api/nextcloud", tags=["nextcloud"] app.include_router(categories.router, prefix="/api/categories", tags=["categories"]) app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) +app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]) @app.get("/api/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9626e0e..3a21511 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,7 @@ from app.models.question import Question from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.reminder import ReminderSchedule from app.models.ai_model_config import AIModelConfig +from app.models.favorite import Favorite __all__ = [ "User", @@ -17,4 +18,5 @@ __all__ = [ "AttemptAnswer", "ReminderSchedule", "AIModelConfig", + "Favorite", ] diff --git a/backend/app/models/favorite.py b/backend/app/models/favorite.py new file mode 100644 index 0000000..c65fd09 --- /dev/null +++ b/backend/app/models/favorite.py @@ -0,0 +1,16 @@ +from datetime import datetime +from sqlalchemy import Column, Integer, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from app.database import Base + + +class Favorite(Base): + __tablename__ = "favorites" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="favorites") + question = relationship("Question") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py index 8f9dfe6..90fd648 100644 --- a/backend/app/models/reminder.py +++ b/backend/app/models/reminder.py @@ -20,4 +20,4 @@ class ReminderSchedule(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) user = relationship("User", back_populates="reminders") - quiz = relationship("Quiz") + quiz = relationship("Quiz", back_populates="reminders") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 3855750..4ecdc7e 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -20,6 +20,7 @@ class User(Base): quizzes = relationship("Quiz", back_populates="user") attempts = relationship("QuizAttempt", back_populates="user") reminders = relationship("ReminderSchedule", back_populates="user") + favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") @property def is_admin(self): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 11db76f..dd6f9d7 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -56,11 +56,12 @@ def create_user( from app.models.email_verification import EmailVerification from datetime import datetime - if db.query(User).filter(User.email == user_data.email).first(): + email_normalized = user_data.email.lower().strip() + if db.query(User).filter(User.email == email_normalized).first(): raise HTTPException(status_code=400, detail="Email already registered") user = User( - email=user_data.email, + email=email_normalized, hashed_password=get_password_hash(user_data.password), name=user_data.name, role="user", @@ -98,19 +99,15 @@ def list_available_models( return result -class LiteLLMModelQuery(BaseModel): - api_key: str | None = None - api_base: str | None = None - - -@router.post("/litellm/models") +@router.get("/litellm/models") def search_litellm_models( - body: LiteLLMModelQuery = LiteLLMModelQuery(), + api_key: str | None = None, + api_base: str | None = None, admin: User = Depends(require_admin), ): """Query available models from LiteLLM proxy or OpenAI-compatible API.""" - base = (body.api_base or settings.LITELLM_API_BASE or "").rstrip("/") - key = body.api_key or settings.LITELLM_API_KEY + base = (api_base or settings.LITELLM_API_BASE or "").rstrip("/") + key = api_key or settings.LITELLM_API_KEY if base: try: @@ -202,3 +199,66 @@ def delete_model( raise HTTPException(status_code=404, detail="Model config not found") db.delete(model) db.commit() + + +# --- System Settings --- + +@router.get("/settings") +def get_settings(admin: User = Depends(require_admin)): + """Get system settings.""" + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + registration_enabled = r.get("settings:registration_enabled") + embedding_model = r.get("settings:embedding_model") + polly_enabled = r.get("settings:polly_enabled") + return { + "registration_enabled": registration_enabled != "false", + "embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "", + "polly_enabled": polly_enabled != "false", + } + except Exception: + return { + "registration_enabled": True, + "embedding_model": settings.LITELLM_EMBEDDING_MODEL or "", + "polly_enabled": True, + } + + +@router.put("/settings") +def update_settings( + settings_data: dict, + admin: User = Depends(require_admin), +): + """Update system settings.""" + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + + if "registration_enabled" in settings_data: + value = "true" if settings_data["registration_enabled"] else "false" + r.set("settings:registration_enabled", value) + + if "embedding_model" in settings_data: + r.set("settings:embedding_model", settings_data["embedding_model"]) + + if "polly_enabled" in settings_data: + value = "true" if settings_data["polly_enabled"] else "false" + r.set("settings:polly_enabled", value) + + return {"success": True, "message": "Settings updated"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}") + + +@router.post("/embedding/test") +def test_embedding(admin: User = Depends(require_admin)): + """Test the currently configured embedding model.""" + from app.services.embedding_service import generate_embedding, _get_embedding_model + model = _get_embedding_model() + if not model: + raise HTTPException(status_code=400, detail="No embedding model configured") + result = generate_embedding("The quick brown fox jumps over the lazy dog") + if result is None: + raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}") + return {"model": model, "dimensions": len(result), "status": "ok"} diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index d5ee41c..a376c48 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -192,7 +192,9 @@ class ProgressSave(BaseModel): current_idx: int mode: str voice: str | None = None - time_left: int | None = None # remaining seconds for timed mode + time_left: int | None = None # remaining seconds for timed mode (informational, not authoritative) + started_at: str | None = None # ISO timestamp when quiz started (for timer calculation) + total_time: int | None = None # original time limit in seconds @router.post("/progress") @@ -200,19 +202,23 @@ def save_progress( data: ProgressSave, current_user: User = Depends(get_current_user), ): - """Save in-progress quiz answers to Redis (survives logout/browser change).""" + """Save in-progress quiz answers to Redis (survives logout/browser change). + Each attempt gets its own saved progress (key includes attempt_id).""" try: import redis as redis_lib, json as _json from app.config import settings r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - key = f"quiz_progress:{current_user.id}:{data.quiz_id}" + key = f"quiz_progress:{current_user.id}:{data.attempt_id}" r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days + "quiz_id": data.quiz_id, "attempt_id": data.attempt_id, "answers": data.answers, "current_idx": data.current_idx, "mode": data.mode, "voice": data.voice, "time_left": data.time_left, + "started_at": data.started_at, + "total_time": data.total_time, })) except Exception: pass # Redis unavailable — degrade gracefully @@ -226,35 +232,74 @@ def get_progress( current_user: User = Depends(get_current_user), ): """Retrieve in-progress quiz answers from Redis. - Clears stale data if the saved attempt has already been submitted.""" + Finds the latest incomplete attempt for this quiz, then checks Redis. + Clears stale data if the saved attempt has already been submitted. + Auto-submits timed quizzes if timer has expired.""" try: import redis as redis_lib, json as _json from app.config import settings + from datetime import datetime, timezone r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - key = f"quiz_progress:{current_user.id}:{quiz_id}" + + # Find latest incomplete attempt for this quiz + attempt = db.query(QuizAttempt).filter( + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.is_(None), + ).order_by(QuizAttempt.started_at.desc()).first() + + if not attempt: + return None + + key = f"quiz_progress:{current_user.id}:{attempt.id}" data = r.get(key) if not data: return None + saved = _json.loads(data) - attempt_id = saved.get("attempt_id") - if attempt_id: - attempt = db.query(QuizAttempt).filter( - QuizAttempt.id == attempt_id, - QuizAttempt.user_id == current_user.id, - ).first() - # Stale: attempt was submitted or doesn't exist - if not attempt or attempt.completed_at is not None: - r.delete(key) - return None + + # Check if timer expired for timed quiz (auto-submit) + total_time = saved.get("total_time") + started_at_str = saved.get("started_at") + if total_time is not None and started_at_str: + try: + started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00')) + elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() + if elapsed >= total_time: + # Timer expired — auto-submit + questions = {q.id: q for q in get_quiz_questions(db, quiz_id)} + score = 0 + submitted_answers = saved.get("answers", {}) + for qid_str, user_ans in submitted_answers.items(): + qid = int(qid_str) + question = questions.get(qid) + if question: + correct = (question.correct_answer or "").strip().lower() + if (user_ans or "").strip().lower() == correct: + score += 1 + db.add(AttemptAnswer( + attempt_id=attempt.id, + question_id=qid, + user_answer=user_ans, + is_correct=(user_ans or "").strip().lower() == correct, + )) + attempt.score = score + attempt.completed_at = datetime.utcnow() + db.commit() + r.delete(key) + return None # no progress to resume — already submitted + except Exception: + pass + return saved except Exception: pass return None -@router.delete("/progress/{quiz_id}", status_code=204) +@router.delete("/progress/{attempt_id}", status_code=204) def clear_progress( - quiz_id: int, + attempt_id: int, current_user: User = Depends(get_current_user), ): """Clear saved progress when quiz is submitted.""" @@ -262,7 +307,7 @@ def clear_progress( import redis as redis_lib from app.config import settings r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - r.delete(f"quiz_progress:{current_user.id}:{quiz_id}") + r.delete(f"quiz_progress:{current_user.id}:{attempt_id}") except Exception: pass diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 68ef9a7..ef3a767 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -43,7 +43,8 @@ RESET_WINDOW_HOURS = 1 def _check_reset_rate_limit(db: Session, email: str): - user = db.query(User).filter(User.email == email).first() + email_normalized = email.lower().strip() + user = db.query(User).filter(User.email == email_normalized).first() if not user: return # silently ignore unknown emails window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS) @@ -60,16 +61,31 @@ def _check_reset_rate_limit(db: Session, email: str): @router.post("/register") async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)): + # Check if registration is enabled (unless this is the first user - always allow admin creation) + is_first_user = db.query(User).count() == 0 + if not is_first_user: + try: + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1) + registration_enabled = r.get("settings:registration_enabled") + # Default to enabled if not set, disabled if explicitly set to "false" + if registration_enabled == "false": + raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator") + except HTTPException: + raise + except Exception: + pass # Redis unavailable — allow registration (fail open) + 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() + email_normalized = user_data.email.lower().strip() + existing = db.query(User).filter(User.email == email_normalized).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, + email=email_normalized, hashed_password=get_password_hash(user_data.password), name=user_data.name, role="admin" if is_first_user else "user", @@ -105,7 +121,8 @@ def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Requ 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() + email_normalized = login_data.email.lower().strip() + user = db.query(User).filter(User.email == email_normalized).first() if not user or not verify_password(login_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid email or password") @@ -138,7 +155,8 @@ def verify_email(token: str, db: Session = Depends(get_db)): @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() + email_normalized = data.email.lower().strip() + user = db.query(User).filter(User.email == email_normalized).first() if not user: return {"message": "If that email exists, a verification link has been sent."} @@ -160,9 +178,10 @@ async def resend_verification(data: ForgotPasswordRequest, background_tasks: Bac @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) + email_normalized = data.email.lower().strip() + _check_reset_rate_limit(db, email_normalized) - user = db.query(User).filter(User.email == data.email).first() + user = db.query(User).filter(User.email == email_normalized).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."} diff --git a/backend/app/routers/favorites.py b/backend/app/routers/favorites.py new file mode 100644 index 0000000..facd79d --- /dev/null +++ b/backend/app/routers/favorites.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app.database import get_db +from app.models.user import User +from app.models.favorite import Favorite +from app.models.question import Question +from app.schemas.favorite import FavoriteCreate, FavoriteResponse +from app.utils.auth import get_current_user + +router = APIRouter() + + +@router.post("", response_model=FavoriteResponse, status_code=201) +def add_favorite( + data: FavoriteCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Add a question to user's favorites.""" + # Check if question exists + question = db.query(Question).filter(Question.id == data.question_id).first() + if not question: + raise HTTPException(status_code=404, detail="Question not found") + + # Check if already favorited + existing = db.query(Favorite).filter( + Favorite.user_id == current_user.id, + Favorite.question_id == data.question_id + ).first() + if existing: + return existing # Already favorited, return existing + + # Create new favorite + favorite = Favorite( + user_id=current_user.id, + question_id=data.question_id + ) + db.add(favorite) + db.commit() + db.refresh(favorite) + return favorite + + +@router.delete("/{question_id}", status_code=204) +def remove_favorite( + question_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Remove a question from user's favorites.""" + favorite = db.query(Favorite).filter( + Favorite.user_id == current_user.id, + Favorite.question_id == question_id + ).first() + if not favorite: + raise HTTPException(status_code=404, detail="Favorite not found") + + db.delete(favorite) + db.commit() + return None + + +@router.get("", response_model=list[int]) +def list_favorites( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get list of favorited question IDs for current user.""" + favorites = db.query(Favorite.question_id).filter( + Favorite.user_id == current_user.id + ).all() + return [f[0] for f in favorites] diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index 964b977..da6022d 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -9,6 +9,7 @@ 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.models.favorite import Favorite from app.utils.auth import get_current_user, require_moderator router = APIRouter() @@ -40,6 +41,7 @@ def get_bank_ids( quiz_id: int | None = Query(None), category_id: int | None = Query(None), uncategorized: bool = Query(False), + favorites_only: bool = Query(False), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -51,6 +53,12 @@ def get_bank_ids( query = query.filter(Question.question_category_id == category_id) if uncategorized: query = query.filter(Question.question_category_id.is_(None)) + if favorites_only: + favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all() + fav_ids = [f[0] for f in favorite_ids] + if not fav_ids: + return [] + query = query.filter(Question.id.in_(fav_ids)) if q and q.strip(): phrase = q.strip() query = query.filter( @@ -68,6 +76,7 @@ def get_question_bank( quiz_id: int | None = Query(None), category_id: int | None = Query(None), uncategorized: bool = Query(False), + favorites_only: bool = Query(False), search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid" limit: int = Query(50, le=200), offset: int = Query(0), @@ -86,6 +95,13 @@ def get_question_bank( if uncategorized: query = query.filter(Question.question_category_id.is_(None)) + if favorites_only: + favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all() + fav_ids = [f[0] for f in favorite_ids] + if not fav_ids: + return {"total": 0, "questions": []} + query = query.filter(Question.id.in_(fav_ids)) + # ── Semantic search (pgvector) ───────────────────────────────── semantic_ids_ordered: list[int] = [] if q and q.strip() and search_mode in ("semantic", "hybrid"): diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index c1ce788..ad9de2a 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -10,7 +10,7 @@ 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.schemas.quiz import QuizCreate, QuizUpdate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.services import quiz_service from app.utils.auth import get_current_user, require_moderator from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz @@ -125,6 +125,23 @@ def get_extraction_job(job_id: str, current_user: User = Depends(require_moderat return result +@router.post("/job/{job_id}/cancel") +def cancel_extraction_job(job_id: str, current_user: User = Depends(get_current_user)): + """Cancel a running extraction job.""" + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + # Verify this job belongs to the user + user_jobs = r.lrange(f"extraction:user_jobs:{current_user.id}", 0, 19) + if job_id not in user_jobs: + raise HTTPException(status_code=403, detail="Job not found") + status = r.get(f"extraction:status:{job_id}") + if status != "running": + raise HTTPException(status_code=400, detail=f"Job is not running (status: {status})") + r.set(f"extraction:status:{job_id}", "cancelled") + return {"status": "cancelled"} + + @router.get("/search") def search_quizzes( q: str = Query(..., min_length=2, max_length=200), @@ -274,6 +291,26 @@ def get_quiz( return QuizDetail.model_validate(quiz) +@router.patch("/{quiz_id}", response_model=QuizResponse) +def update_quiz( + quiz_id: int, + data: QuizUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Update quiz metadata (title, etc.) — moderator only.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found") + + if data.title: + quiz.title = data.title.strip() + + db.commit() + db.refresh(quiz) + return quiz + + @router.post("/{quiz_id}/shuffle", response_model=QuizDetail) def shuffle_quiz( quiz_id: int, diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index 851d4de..f880b0f 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -18,19 +18,30 @@ class TTSRequest(BaseModel): voice: str | None = None # model_id override +def _polly_enabled() -> bool: + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + val = r.get("settings:polly_enabled") + return val != "false" + except Exception: + return True + + @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( + """Return available TTS voices from DB, excluding Polly if disabled.""" + query = 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 + ) + if not _polly_enabled(): + query = query.filter(~AIModelConfig.model_id.like("polly/%")) + db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() + return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] @router.post("/speak") @@ -46,6 +57,8 @@ def text_to_speech( text = request.text[:2000] if request.voice: + if request.voice.startswith("polly/") and not _polly_enabled(): + raise HTTPException(status_code=400, detail="AWS Polly is currently disabled") 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 diff --git a/backend/app/schemas/favorite.py b/backend/app/schemas/favorite.py new file mode 100644 index 0000000..02e119b --- /dev/null +++ b/backend/app/schemas/favorite.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from datetime import datetime + + +class FavoriteCreate(BaseModel): + question_id: int + + +class FavoriteResponse(BaseModel): + id: int + user_id: int + question_id: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index 068cb89..ba74c08 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -18,6 +18,10 @@ class QuizCreate(BaseModel): # ai_decide — AI reads a sample and decides which approach to use +class QuizUpdate(BaseModel): + title: str + + class QuestionResponse(BaseModel): id: int question_text: str diff --git a/backend/app/services/embedding_service.py b/backend/app/services/embedding_service.py index 16f3c31..721e546 100644 --- a/backend/app/services/embedding_service.py +++ b/backend/app/services/embedding_service.py @@ -1,8 +1,9 @@ """Embedding generation for semantic search via pgvector. Priority: - 1. AWS Bedrock Titan Embed V2 (direct, best quality, 1024 dims) - 2. LiteLLM proxy with configured LITELLM_EMBEDDING_MODEL (fallback) + 1. LiteLLM proxy — model from Redis settings (overrides env) + 2. LiteLLM proxy — model from LITELLM_EMBEDDING_MODEL env + 3. AWS Bedrock Titan Embed V2 (direct fallback) """ import logging @@ -11,6 +12,19 @@ from app.config import settings logger = logging.getLogger(__name__) +def _get_embedding_model() -> str: + """Return the active embedding model: Redis setting takes precedence over env.""" + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + model = r.get("settings:embedding_model") + if model: + return model + except Exception: + pass + return settings.LITELLM_EMBEDDING_MODEL + + def _text_for_question(question_text: str, options: list[str] | None) -> str: """Build the text to embed for a question — stem + options, no explanation.""" parts = [question_text] @@ -30,20 +44,23 @@ def generate_embedding(text: str) -> list[float] | None: if not clean: return None - # ── 1. LiteLLM proxy (best quality) ──────────────────────── - if settings.LITELLM_EMBEDDING_MODEL and settings.LITELLM_API_KEY: + # ── 1. LiteLLM proxy (direct httpx — avoids LiteLLM param validation) ── + embedding_model = _get_embedding_model() + api_base = (settings.LITELLM_API_BASE or "").rstrip("/") + if embedding_model and settings.LITELLM_API_KEY and api_base: try: - import litellm - resp = litellm.embedding( - model=settings.LITELLM_EMBEDDING_MODEL, - input=[clean], - api_key=settings.LITELLM_API_KEY, - api_base=settings.LITELLM_API_BASE or None, + import httpx, json as _json + body: dict = {"model": embedding_model, "input": [clean], "dimensions": settings.EMBEDDING_DIMENSIONS} + resp = httpx.post( + f"{api_base}/v1/embeddings", + headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"}, + content=_json.dumps(body), + timeout=30, ) - emb = resp.data[0]["embedding"] + resp.raise_for_status() + emb = resp.json()["data"][0]["embedding"] if len(emb) == settings.EMBEDDING_DIMENSIONS: return emb - # Dimension mismatch — don't store incompatible vector logger.warning(f"Embedding dim mismatch: got {len(emb)}, expected {settings.EMBEDDING_DIMENSIONS}") except Exception as e: logger.warning(f"LiteLLM embedding failed: {e}") diff --git a/backend/app/services/vector_service.py b/backend/app/services/vector_service.py index e4e765d..bd90a7e 100644 --- a/backend/app/services/vector_service.py +++ b/backend/app/services/vector_service.py @@ -7,20 +7,22 @@ _client = None class LiteLLMEmbeddingFunction(EmbeddingFunction): - """ChromaDB embedding function backed by LiteLLM.""" + """ChromaDB embedding function backed by the LiteLLM proxy (direct httpx).""" def __call__(self, input: list[str]) -> Embeddings: - import litellm - kwargs = { - "model": settings.LITELLM_EMBEDDING_MODEL, - "input": input, - } - if settings.LITELLM_API_KEY: - kwargs["api_key"] = settings.LITELLM_API_KEY - if settings.LITELLM_API_BASE: - kwargs["api_base"] = settings.LITELLM_API_BASE - response = litellm.embedding(**kwargs) - return [item["embedding"] for item in response.data] + import httpx, json as _json + from app.services.embedding_service import _get_embedding_model + model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL + api_base = (settings.LITELLM_API_BASE or "").rstrip("/") + body = {"model": model, "input": input, "dimensions": settings.EMBEDDING_DIMENSIONS} + resp = httpx.post( + f"{api_base}/v1/embeddings", + headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}", "Content-Type": "application/json"}, + content=_json.dumps(body), + timeout=60, + ) + resp.raise_for_status() + return [item["embedding"] for item in resp.json()["data"]] def get_client() -> chromadb.PersistentClient: @@ -32,8 +34,10 @@ def get_client() -> chromadb.PersistentClient: def get_or_create_collection(document_id: int): client = get_client() - # Only use custom embedding if model is configured and has a provider prefix - use_ef = bool(settings.LITELLM_EMBEDDING_MODEL and "/" in settings.LITELLM_EMBEDDING_MODEL) + from app.services.embedding_service import _get_embedding_model + active_model = _get_embedding_model() or settings.LITELLM_EMBEDDING_MODEL + # Only use custom embedding if model and API base are configured + use_ef = bool(active_model and settings.LITELLM_API_BASE) ef = LiteLLMEmbeddingFunction() if use_ef else None kwargs = {"name": f"doc_{document_id}"} if ef: diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index c27c2e8..0af87c5 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -122,6 +122,9 @@ def extract_quiz( if resolved_mode == "questions_only": _push_step(r, job_id, "ai", "Mode: Questions Only — extracting questions without answers.") for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): + if r.get(f"extraction:status:{job_id}") == "cancelled": + _push_step(r, job_id, "cancelled", "Job cancelled.") + return _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p}…") chunk_content = vector_service.get_pages_text( document_id=section.document_id, start_page=start_p, end_page=end_p) @@ -160,6 +163,9 @@ def extract_quiz( if extraction_mode == "standard": # ── STANDARD: existing working extraction (unchanged) ────────────── for chunk_idx, (start_p, end_p) in enumerate(chunks, 1): + if r.get(f"extraction:status:{job_id}") == "cancelled": + _push_step(r, job_id, "cancelled", "Job cancelled.") + return if n_chunks > 1: _push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}–{end_p} → {model_name}…") else: diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index c09a16d..d3f70b0 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,10 +1,11 @@ from datetime import datetime, timedelta -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request, Response from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext from sqlalchemy.orm import Session +from starlette.middleware.base import BaseHTTPMiddleware from app.config import settings from app.database import get_db @@ -25,10 +26,18 @@ def get_password_hash(password: str) -> str: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: to_encode = data.copy() expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)) - to_encode.update({"exp": expire}) + to_encode.update({"exp": expire, "iat": datetime.utcnow().timestamp()}) return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) +def decode_token(token: str): + """Decode JWT token and return payload, or None if invalid.""" + try: + return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + except JWTError: + return None + + def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), @@ -46,7 +55,7 @@ def get_current_user( except JWTError: raise credentials_exception - user = db.query(User).filter(User.email == email).first() + user = db.query(User).filter(User.email == email.lower().strip()).first() if user is None: raise credentials_exception @@ -73,3 +82,38 @@ def require_moderator(current_user: User = Depends(get_current_user)) -> User: if not current_user.is_moderator: raise HTTPException(status_code=403, detail="Moderator access required") return current_user + + +class TokenRefreshMiddleware(BaseHTTPMiddleware): + """Middleware to refresh JWT token if it's older than 12 hours or expires soon (sliding expiration).""" + + async def dispatch(self, request: Request, call_next): + # Extract token from Authorization header + auth_header = request.headers.get("authorization", "") + new_token = None + + if auth_header.startswith("Bearer "): + token = auth_header[7:] + payload = decode_token(token) + if payload: + iat = payload.get("iat") + exp = payload.get("exp") + now = datetime.utcnow().timestamp() + + if iat and exp: + age_hours = (now - iat) / 3600 + time_until_expiry_hours = (exp - now) / 3600 + + # Refresh if: token > 12 hours old OR will expire in < 1 hour + if age_hours > 12 or time_until_expiry_hours < 1: + email = payload.get("sub") + if email: + new_token = create_access_token({"sub": email}) + + response = await call_next(request) + + # Add new token to response headers if generated + if new_token: + response.headers["X-New-Token"] = new_token + + return response diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 01c5687..c6877d7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,7 @@ import QuizEditPage from './pages/QuizEditPage' import VerifyEmailPage from './pages/VerifyEmailPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage' +import NotFoundPage from './pages/NotFoundPage' function ProtectedRoute({ children, requireModerator = false }) { const { user, loading } = useAuth() @@ -66,6 +67,7 @@ function AppRoutes() { } /> } /> } /> + } />