From 975a31fb018a26af0fef75de930372452c4f38eb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Apr 2026 00:37:35 +0200 Subject: [PATCH] TeachChat: model selector, proxy routing fix, titan seed cleanup - teach.py: use _proxy_model() + pass api_key/api_base from settings (fixes LiteLLM provider error for openrouter/bedrock models) - teach.py: accept model_id in ChatRequest so frontend can select model - main.py: remove titan-embed-v2 from general seed, auto-delete legacy entry on startup - main.py: kill stale idle-in-transaction DB connections at startup to prevent DDL lock hangs - main.py: set lock_timeout=10s on DDL connection as fast-fail safety net - TeachChat.jsx: fetch /teach/models, show selector dropdown in header when >1 model available Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 29 ++- backend/app/models/question.py | 4 +- backend/app/routers/admin.py | 132 +++++++++++- backend/app/routers/attempts.py | 1 + backend/app/routers/teach.py | 169 +++++++++++++++ backend/app/tasks/quiz_tasks.py | 41 ++++ frontend/nginx.conf | 7 + frontend/src/components/Dialog.jsx | 41 ++++ frontend/src/components/Navbar.jsx | 49 ++--- frontend/src/components/TeachChat.jsx | 276 ++++++++++++++++++++++++ frontend/src/hooks/useDialog.js | 49 +++++ frontend/src/pages/AdminPage.jsx | 267 ++++++++++++++++++++--- frontend/src/pages/JobsPage.jsx | 6 +- frontend/src/pages/QuestionBankPage.jsx | 8 +- frontend/src/pages/QuizPage.jsx | 37 +++- frontend/src/pages/QuizzesPage.jsx | 76 ++++++- frontend/src/pages/SettingsPage.jsx | 2 +- 17 files changed, 1129 insertions(+), 65 deletions(-) create mode 100644 backend/app/routers/teach.py create mode 100644 frontend/src/components/Dialog.jsx create mode 100644 frontend/src/components/TeachChat.jsx create mode 100644 frontend/src/hooks/useDialog.js 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 */}
- + + + {/* Backdrop on mobile */} + {open && ( +
setOpen(false)} + style={{ position: 'fixed', inset: 0, zIndex: 380, background: 'rgba(0,0,0,0.3)' }} + /> + )} + + {/* Drawer */} + {open && ( +
+ {/* Header */} +
+ 🎓 AI Tutor + {models.length > 1 && ( + + )} + +
+ + {/* Question context chip */} +
+ 📋 {question.question_text?.slice(0, 100)}{question.question_text?.length > 100 ? '…' : ''} +
+ + {/* Messages */} +
+ {messages.length === 0 && !noModel && ( +
+ Ask the AI tutor anything about this question — why an answer is wrong, the underlying concept, related topics, etc. +
+ )} + {noModel && ( +
+ No teaching AI is configured. Ask an admin to add a model with task teach in the Admin Dashboard. +
+ )} + {messages.map((m, i) => ( +
+ {m.content} +
+ ))} + {loading && ( +
+ + Thinking… + +
+ )} +
+
+ + {/* Input */} +
+