import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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.utils.auth import get_password_hash 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() if not admin_exists: admin_user = User( email="admin@quizapp.com", hashed_password=get_password_hash("admin123"), name="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() def seed_default_models(): """Seed default AI model configs if none exist.""" from app.models.ai_model_config import AIModelConfig db = SessionLocal() try: if db.query(AIModelConfig).count() == 0: defaults = [ 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, 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 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)")) conn.execute(text(""" CREATE INDEX IF NOT EXISTS questions_embedding_hnsw ON questions USING hnsw (embedding vector_cosine_ops) """)) # Quiz categories conn.execute(text(""" CREATE TABLE IF NOT EXISTS quiz_categories ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, user_id INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ) """)) conn.execute(text(""" ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS category_id INTEGER REFERENCES quiz_categories(id) ON DELETE SET NULL """)) # Question categories (separate from quiz categories) conn.execute(text(""" CREATE TABLE IF NOT EXISTS question_categories ( id SERIAL PRIMARY KEY, name VARCHAR NOT NULL, description TEXT, user_id INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT NOW() ) """)) conn.execute(text(""" ALTER TABLE questions ADD COLUMN IF NOT EXISTS question_category_id INTEGER REFERENCES question_categories(id) ON DELETE SET NULL """)) # Fix email collation to avoid en_US.utf8 B-tree index corruption conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"')) # Junction table: quiz ↔ question many-to-many conn.execute(text(""" CREATE TABLE IF NOT EXISTS quiz_question_links ( quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE, position INTEGER DEFAULT 0, PRIMARY KEY (quiz_id, question_id) ) """)) # Populate junction from existing questions (idempotent via ON CONFLICT DO NOTHING) conn.execute(text(""" INSERT INTO quiz_question_links (quiz_id, question_id, position) SELECT quiz_id, id, ROW_NUMBER() OVER (PARTITION BY quiz_id ORDER BY id) - 1 FROM questions WHERE quiz_id IS NOT NULL ON CONFLICT DO NOTHING """)) # 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")) conn.commit() 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 stop_scheduler() app = FastAPI( title="PedQuiz", description="Pediatric Knowledge Quiz Platform", version="2.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["https://quiz.danvics.com"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Serve uploaded images as static files app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads") app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) app.include_router(documents.router, prefix="/api/documents", tags=["documents"]) app.include_router(quizzes.router, prefix="/api/quizzes", tags=["quizzes"]) app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) app.include_router(admin.router, prefix="/api/admin", tags=["admin"]) app.include_router(tts.router, prefix="/api/tts", tags=["tts"]) 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.get("/api/health") def health_check(): return {"status": "ok"}