diff --git a/backend/app/main.py b/backend/app/main.py
index 9d5dff2..4fae014 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, favorites
+from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@@ -65,11 +65,19 @@ def seed_default_models():
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()
+ # Clean up legacy titan-embed-v2 "general" entry (was mistakenly seeded)
+ titan = db.query(AIModelConfig).filter(
+ AIModelConfig.model_id == "titan-embed-v2",
+ AIModelConfig.task == "general",
+ ).first()
+ if titan:
+ db.delete(titan)
+ db.commit()
+
# Always ensure OpenAI TTS voice models exist (idempotent)
tts_voices = [
# OpenAI (work with OPENAI_API_KEY)
@@ -123,7 +131,23 @@ def setup_pgvector():
from sqlalchemy import text
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category, favorite # noqa
+
+ # Kill stale idle-in-transaction connections from previous killed startups.
+ # They hold DDL locks and cause ALTER TABLE below to hang indefinitely.
with engine.connect() as conn:
+ conn.execute(text("""
+ SELECT pg_terminate_backend(pid)
+ FROM pg_stat_activity
+ WHERE datname = current_database()
+ AND state = 'idle in transaction'
+ AND query_start < NOW() - INTERVAL '30 seconds'
+ AND pid != pg_backend_pid()
+ """))
+ conn.commit()
+
+ with engine.connect() as conn:
+ # Fail fast instead of hanging if a lock can't be acquired within 10s.
+ conn.execute(text("SET lock_timeout = '10s'"))
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
conn.execute(text("""
@@ -292,6 +316,7 @@ app.include_router(categories.router, prefix="/api/categories", tags=["categorie
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.include_router(teach.router, prefix="/api/teach", tags=["teach"])
@app.get("/api/health")
diff --git a/backend/app/models/question.py b/backend/app/models/question.py
index 9d6b1c8..7f79cec 100644
--- a/backend/app/models/question.py
+++ b/backend/app/models/question.py
@@ -1,6 +1,6 @@
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey
-from sqlalchemy.orm import relationship
+from sqlalchemy.orm import relationship, deferred
from app.config import settings
from app.database import Base
@@ -23,7 +23,7 @@ class Question(Base):
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
- embedding = Column(Vector(1024), nullable=True) # semantic search vector
+ embedding = deferred(Column(Vector(1024), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id])
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index dd6f9d7..857616d 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -143,8 +143,8 @@ def create_model(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
- if data.task not in ("extraction", "tts", "general"):
- raise HTTPException(status_code=400, detail="Task must be extraction, tts, or general")
+ if data.task not in ("extraction", "tts", "general", "teach"):
+ raise HTTPException(status_code=400, detail="Task must be extraction, tts, general, or teach")
if data.is_default:
db.query(AIModelConfig).filter(
@@ -201,6 +201,124 @@ def delete_model(
db.commit()
+@router.post("/models/{model_id}/test")
+def test_model(
+ model_id: int,
+ db: Session = Depends(get_db),
+ admin: User = Depends(require_admin),
+):
+ """Send a simple test completion to verify an LLM model is reachable.
+ TTS models are previewed via /tts/speak instead."""
+ model = db.query(AIModelConfig).filter(AIModelConfig.id == model_id).first()
+ if not model:
+ raise HTTPException(status_code=404, detail="Model config not found")
+
+ if model.task == "tts":
+ raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.")
+
+ try:
+ import litellm
+ from app.services.ai_service import _proxy_model
+ use_model = _proxy_model(model.model_id)
+ kwargs = {
+ "model": use_model,
+ "messages": [{"role": "user", "content": "Reply with only the word: OK"}],
+ "max_tokens": 10,
+ }
+ if model.api_key:
+ kwargs["api_key"] = model.api_key
+ elif 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.completion(**kwargs)
+ reply = response.choices[0].message.content.strip()
+ return {"message": f"✓ {model.model_id} → {reply!r}"}
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=str(e))
+
+
+@router.get("/tts/voices")
+def search_tts_voices(
+ provider: str,
+ api_key: str | None = None,
+ region: str | None = None,
+ admin: User = Depends(require_admin),
+):
+ """Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
+
+ if provider == "elevenlabs":
+ key = api_key or settings.ELEVENLABS_API_KEY
+ if not key:
+ raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)")
+ try:
+ resp = httpx.get(
+ "https://api.elevenlabs.io/v1/voices",
+ headers={"xi-api-key": key},
+ timeout=15,
+ )
+ resp.raise_for_status()
+ voices = resp.json().get("voices", [])
+ return {"voices": [
+ {
+ "model_id": f"elevenlabs/{v['voice_id']}",
+ "name": v["name"],
+ "labels": v.get("labels", {}),
+ }
+ for v in sorted(voices, key=lambda x: x["name"])
+ ]}
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
+
+ elif provider == "polly":
+ access_key = api_key or settings.AWS_ACCESS_KEY_ID
+ secret_key = settings.AWS_SECRET_ACCESS_KEY
+ aws_region = region or settings.AWS_REGION or "us-east-1"
+ if not access_key or not secret_key:
+ raise HTTPException(
+ status_code=400,
+ detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env",
+ )
+ try:
+ import boto3
+ polly = boto3.client(
+ "polly",
+ aws_access_key_id=access_key,
+ aws_secret_access_key=secret_key,
+ region_name=aws_region,
+ )
+ resp = polly.describe_voices(Engine="neural")
+ voices = resp.get("Voices", [])
+ return {"voices": [
+ {
+ "model_id": f"polly/{v['Id']}",
+ "name": f"{v['Name']} — {v['LanguageName']} ({v.get('Gender', '')})",
+ "labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")},
+ }
+ for v in sorted(voices, key=lambda x: x["Name"])
+ ]}
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
+
+ elif provider == "openai":
+ voices = []
+ for model_name in ["tts-1", "tts-1-hd"]:
+ for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]:
+ voices.append({
+ "model_id": f"{model_name}:{voice}",
+ "name": f"{model_name} · {voice}",
+ "labels": {"model": model_name, "voice": voice},
+ })
+ return {"voices": voices}
+
+ else:
+ raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
+
+
# --- System Settings ---
@router.get("/settings")
@@ -262,3 +380,13 @@ def test_embedding(admin: User = Depends(require_admin)):
if result is None:
raise HTTPException(status_code=500, detail=f"Embedding failed for model: {model}")
return {"model": model, "dimensions": len(result), "status": "ok"}
+
+
+@router.post("/embedding/regenerate")
+def regenerate_embeddings(admin: User = Depends(require_admin)):
+ """Queue a background Celery task to regenerate all question embeddings."""
+ import uuid
+ from app.tasks.quiz_tasks import regenerate_embeddings as regen_task
+ job_id = str(uuid.uuid4())
+ regen_task.delay(job_id, admin.id)
+ return {"job_id": job_id, "message": "Regeneration started — progress visible in the Jobs badge."}
diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py
index a376c48..4e29e38 100644
--- a/backend/app/routers/attempts.py
+++ b/backend/app/routers/attempts.py
@@ -404,6 +404,7 @@ def get_quiz_history(
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({
+ "attempt_id": a.id,
"date": a.completed_at.isoformat(),
"percentage": pct,
"score": a.score,
diff --git a/backend/app/routers/teach.py b/backend/app/routers/teach.py
new file mode 100644
index 0000000..da12feb
--- /dev/null
+++ b/backend/app/routers/teach.py
@@ -0,0 +1,169 @@
+"""Teach chat endpoint — AI tutor for study mode questions."""
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from app.database import get_db
+from app.models.question import Question
+from app.models.ai_model_config import AIModelConfig
+from app.models.user import User
+from app.utils.auth import get_current_user
+
+router = APIRouter()
+
+
+class ChatMessage(BaseModel):
+ role: str # "user" | "assistant"
+ content: str
+
+
+class ChatRequest(BaseModel):
+ question_id: int
+ messages: list[ChatMessage]
+ model_id: int | None = None # AIModelConfig.id — if None, use default
+
+
+def _get_teach_model(db: Session, model_config_id: int | None = None):
+ """Return (model_id, api_key) for the requested (or default) teach model, or None."""
+ if model_config_id:
+ m = db.query(AIModelConfig).filter(
+ AIModelConfig.id == model_config_id,
+ AIModelConfig.task == "teach",
+ AIModelConfig.is_active == True,
+ ).first()
+ if m:
+ return (m.model_id, m.api_key or None)
+ # Fall back to default, then any active
+ m = db.query(AIModelConfig).filter(
+ AIModelConfig.task == "teach",
+ AIModelConfig.is_active == True,
+ AIModelConfig.is_default == True,
+ ).first()
+ if not m:
+ m = db.query(AIModelConfig).filter(
+ AIModelConfig.task == "teach",
+ AIModelConfig.is_active == True,
+ ).first()
+ return (m.model_id, m.api_key or None) if m else None
+
+
+def _find_similar_questions(db: Session, question: Question, limit: int = 4) -> list[Question]:
+ """Return up to `limit` questions with similar embeddings, excluding the current one."""
+ if question.embedding is None:
+ return []
+ try:
+ from sqlalchemy import text as sa_text
+ emb = question.embedding
+ emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
+ rows = db.execute(sa_text(f"""
+ SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
+ FROM questions
+ WHERE embedding IS NOT NULL AND id != {question.id}
+ ORDER BY embedding <=> '{emb_literal}'::vector
+ LIMIT {limit}
+ """)).fetchall()
+ ids = [r.id for r in rows if float(r.sim) >= 0.35]
+ if not ids:
+ return []
+ return db.query(Question).filter(Question.id.in_(ids)).all()
+ except Exception:
+ return []
+
+
+def _build_system_prompt(question: Question, similar: list[Question]) -> str:
+ opts = ""
+ if question.options:
+ letters = "ABCDE"
+ opts = "\n".join(f" {letters[i]}) {opt}" for i, opt in enumerate(question.options))
+
+ prompt = (
+ "You are a medical education tutor helping a student understand the following question. "
+ "Be accurate, educational, and concise. You may reveal and explain the correct answer.\n\n"
+ f"=== Current Question ===\n{question.question_text}\n"
+ )
+ if opts:
+ prompt += f"Options:\n{opts}\n"
+ if question.correct_answer:
+ prompt += f"Correct Answer: {question.correct_answer}\n"
+ if question.explanation:
+ prompt += f"Explanation: {question.explanation}\n"
+
+ if similar:
+ prompt += "\n=== Related Questions (for broader context) ===\n"
+ for i, sq in enumerate(similar, 1):
+ prompt += f"{i}. {sq.question_text}"
+ if sq.correct_answer:
+ prompt += f" → Answer: {sq.correct_answer}"
+ prompt += "\n"
+
+ prompt += (
+ "\nAnswer the student's questions about this topic. "
+ "If they ask why an option is wrong, explain the underlying concept. "
+ "Keep responses focused and under 200 words unless a longer explanation is needed."
+ )
+ return prompt
+
+
+@router.get("/models")
+def list_teach_models(
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Return available teach models for the frontend to display."""
+ models = db.query(AIModelConfig).filter(
+ AIModelConfig.task == "teach",
+ AIModelConfig.is_active == True,
+ ).all()
+ return [{"id": m.id, "name": m.name, "model_id": m.model_id, "is_default": m.is_default} for m in models]
+
+
+@router.post("/chat")
+def chat(
+ req: ChatRequest,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """Send a message to the teach AI with full question context."""
+ model_info = _get_teach_model(db, req.model_id)
+ if not model_info:
+ raise HTTPException(
+ status_code=503,
+ detail="No teaching AI model is configured. Ask an admin to add a model with task 'teach'.",
+ )
+ model_id, api_key = model_info
+
+ question = db.query(Question).filter(Question.id == req.question_id).first()
+ if not question:
+ raise HTTPException(status_code=404, detail="Question not found")
+
+ similar = _find_similar_questions(db, question)
+ system_prompt = _build_system_prompt(question, similar)
+
+ messages = [{"role": "system", "content": system_prompt}]
+ for msg in req.messages:
+ if msg.role not in ("user", "assistant"):
+ continue
+ messages.append({"role": msg.role, "content": msg.content})
+
+ try:
+ import litellm
+ from app.config import settings
+ from app.services.ai_service import _proxy_model
+ use_model = _proxy_model(model_id)
+ kwargs = {
+ "model": use_model,
+ "messages": messages,
+ "max_tokens": 600,
+ "temperature": 0.4,
+ }
+ if api_key:
+ kwargs["api_key"] = api_key
+ elif 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.completion(**kwargs)
+ reply = response.choices[0].message.content.strip()
+ return {"reply": reply}
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"AI model error: {str(e)}")
diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py
index 0af87c5..1a20250 100644
--- a/backend/app/tasks/quiz_tasks.py
+++ b/backend/app/tasks/quiz_tasks.py
@@ -273,3 +273,44 @@ def extract_quiz(
raise
finally:
db.close()
+
+
+@celery_app.task(name="regenerate_embeddings", bind=True)
+def regenerate_embeddings(self, job_id: str, user_id: int):
+ """Regenerate embeddings for all questions using the current embedding model."""
+ r = _redis()
+ r.set(f"extraction:status:{job_id}", "running", ex=EXPIRE_SECONDS)
+ r.set(f"extraction:job_title:{job_id}", "Regenerate Embeddings", ex=EXPIRE_SECONDS)
+ r.lpush(f"extraction:user_jobs:{user_id}", job_id)
+ r.expire(f"extraction:user_jobs:{user_id}", 86400)
+
+ db = SessionLocal()
+ try:
+ from app.models.question import Question
+ from app.services import embedding_service
+
+ questions = db.query(Question).all()
+ total = len(questions)
+ _push_step(r, job_id, "start", f"Regenerating embeddings for {total} questions…")
+
+ ok = 0
+ for i, q in enumerate(questions):
+ try:
+ if embedding_service.embed_question(q):
+ ok += 1
+ if (i + 1) % 50 == 0:
+ db.commit()
+ _push_step(r, job_id, "progress", f"{i + 1}/{total} processed ({ok} embedded)")
+ except Exception as e:
+ logger.warning(f"Embedding failed for question {q.id}: {e}")
+
+ db.commit()
+ _push_step(r, job_id, "done", f"Done — {ok}/{total} questions re-embedded.")
+ r.set(f"extraction:status:{job_id}", "completed", ex=EXPIRE_SECONDS)
+ except Exception as e:
+ logger.exception(f"Embedding regeneration failed for job {job_id}")
+ _push_step(r, job_id, "error", f"Failed: {e}")
+ r.set(f"extraction:status:{job_id}", "failed", ex=EXPIRE_SECONDS)
+ raise
+ finally:
+ db.close()
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index d76829b..a509b60 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -4,6 +4,13 @@ server {
root /usr/share/nginx/html;
index index.html;
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_comp_level 6;
+
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
diff --git a/frontend/src/components/Dialog.jsx b/frontend/src/components/Dialog.jsx
new file mode 100644
index 0000000..c2523ba
--- /dev/null
+++ b/frontend/src/components/Dialog.jsx
@@ -0,0 +1,41 @@
+/**
+ * Dialog — styled modal that replaces window.confirm() and window.alert().
+ * Used via the useDialog() hook. Matches the Suspend Quiz popup style.
+ *
+ * Props:
+ * open — boolean
+ * title — string (optional)
+ * message — string
+ * confirmLabel — string (default "OK")
+ * cancelLabel — string | null (null = no cancel button → alert mode)
+ * onConfirm — () => void
+ * onCancel — () => void
+ * danger — boolean (makes confirm button red)
+ */
+export default function Dialog({ open, title, message, confirmLabel = 'OK', cancelLabel = null, onConfirm, onCancel, danger = false }) {
+ if (!open) return null
+
+ return (
+
{ if (e.target === e.currentTarget && onCancel) onCancel() }}
+ >
+
+ {title &&
{title}
}
+
{message}
+
+ {cancelLabel && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx
index b350cbb..53e84b9 100644
--- a/frontend/src/components/Navbar.jsx
+++ b/frontend/src/components/Navbar.jsx
@@ -3,29 +3,10 @@ import { Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
-function JobsBadge() {
- const [activeJobs, setActiveJobs] = useState([])
- const [allJobs, setAllJobs] = useState([])
+function JobsBadge({ jobs }) {
const [open, setOpen] = useState(false)
-
- useEffect(() => {
- let interval
- const load = async () => {
- try {
- const res = await api.get('/quizzes/jobs')
- const jobs = res.data || []
- setAllJobs(jobs)
- const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
- setActiveJobs(running)
- // Poll faster when jobs running, slower when idle
- clearInterval(interval)
- interval = setInterval(load, running.length > 0 ? 3000 : 30000)
- } catch { }
- }
- load()
- interval = setInterval(load, 30000)
- return () => clearInterval(interval)
- }, [])
+ const allJobs = jobs
+ const activeJobs = jobs.filter(j => j.status === 'running' || j.status === 'pending')
if (allJobs.length === 0) return null
@@ -79,12 +60,32 @@ function JobsBadge() {
export default function Navbar() {
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
+ const [jobs, setJobs] = useState([])
const location = useLocation()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
// Close menu on route change
useEffect(() => { setMenuOpen(false) }, [location.pathname])
+ // Single job-polling instance for the whole navbar
+ useEffect(() => {
+ if (!user) return
+ let interval
+ const load = async () => {
+ try {
+ const res = await api.get('/quizzes/jobs')
+ const jobList = res.data || []
+ setJobs(jobList)
+ const running = jobList.filter(j => j.status === 'running' || j.status === 'pending')
+ clearInterval(interval)
+ interval = setInterval(load, running.length > 0 ? 3000 : 30000)
+ } catch { }
+ }
+ load()
+ interval = setInterval(load, 30000)
+ return () => clearInterval(interval)
+ }, [user])
+
const navLinks = user ? [
{ to: '/', label: 'Dashboard' },
@@ -109,13 +110,13 @@ export default function Navbar() {
{l.label}
))}
-
+
{/* Mobile: jobs + hamburger */}
-
+