pdf-quiz-generator/backend/app/main.py
Daniel 7cd2fa5cf4
Some checks failed
Tests / backend (push) Failing after 4s
Tests / frontend (push) Successful in 26s
Tests / e2e (push) Failing after 28s
feat: AI Mode gives the same answer twice, and a typo no longer empties the library
Measured first, by the ped-ai session, fifteen runs of five prompts with
the gateway cache bypassed. Retrieval was already deterministic:
identical shortlist and identical scores every time, and the citation
checker stripped none of the 45 markers written — invented citations are
not the problem here. Generation was the whole variance. At temperature
0.3 the same sources and the same prompt gave answers differing by
15-70% of their text; one differential swung between a 35-word uncited
paraphrase and a 180-word cited list.

So temperature 0 and a seed. Temperature 0 alone was not enough — three
runs still differed — and temperature 0 with a fixed seed came back
byte-identical. The seed is derived from the question, normalised for
case and spacing, so two people asking the same thing get the same
answer and a different question is not pinned to the same sample.

An empty reply is asked once more before it becomes a 502. One in
fifteen came back empty from a healthy model in 4.9 seconds — not a
refusal, not an error, just nothing.

A short query that finds almost nothing is retried against the nearest
article title. "kawasaki criteria" finds fourteen sources; "kawasaki
critera" found none — the lexical ranker cannot match a token that is in
no index, and the embedding of a misspelling is not near the embedding
of the word. Trigrams do not care: that typo scores 0.36 against
"Kawasaki disease" with the next article at 0.11, and the gap is what
makes it safe to act on. pg_trgm is created at startup beside vector,
with a migration for the record.

And an answer drawn from the library must cite it. Not a hallucination
guard — nothing was stripped in fifteen runs — but one answer used the
sources and cited none of them, which leaves the learner an assertion
and nowhere to check it.

Also, article drafts are weighted towards mechanism, in the wording the
ped-ai rewriter is using, so the two lanes read alike: why the body does
what it does, with features and management explained through it rather
than listed. Figure lines and cross-references survive a refine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 16:24:48 +02:00

655 lines
30 KiB
Python

import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal
from app.api import errors as api_errors
from app.api.versioning import VERSIONED_ROOT, VersionAlias
from app.routers import auth, documents, quizzes, attempts, admin, tts, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, mynote, exams
from app.routers import access
from app.routers import feedback
from app.routers import folders
from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode, drafts, public
from app.utils.auth import get_password_hash
def seed_admin():
"""Create the bootstrap admin if there is none, and say how to sign in as it."""
import logging
import secrets
from datetime import datetime
from app.models.user import User
from app.models.email_verification import EmailVerification
log = logging.getLogger(__name__)
db = SessionLocal()
try:
admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists:
if not settings.DEFAULT_ADMIN_EMAIL:
log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
return
# A password from the environment if one is set, otherwise one
# generated here and printed once. A fresh stack with no way in is
# a fresh stack nobody can use; a fresh stack with a *guessable*
# way in is worse. This is the third option: unguessable, and
# written where whoever started it is already looking.
password = (settings.DEFAULT_ADMIN_PASSWORD or "").strip()
generated = not password
if generated:
password = secrets.token_urlsafe(15)
elif len(password) < 8:
log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.")
return
admin_user = User(
email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
hashed_password=get_password_hash(password),
name="Admin",
role="admin",
)
db.add(admin_user)
db.flush()
# Auto-verify seeded admin
db.add(EmailVerification(
user_id=admin_user.id,
token="seeded",
expires_at=datetime.utcnow(),
verified_at=datetime.utcnow(),
))
db.commit()
if generated:
# Loud, and once: this is the only time it exists in plain
# text. `docker compose logs backend | grep -A3 "FIRST ADMIN"`.
log.warning(
"\n%s\nFIRST ADMIN CREATED — change this password after signing in\n"
" email: %s\n password: %s\n%s",
"=" * 62, admin_user.email, password, "=" * 62)
else:
log.info("First admin created from DEFAULT_ADMIN_PASSWORD: %s", admin_user.email)
else:
# Ensure existing admin has a verified email record
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 sqlalchemy import text
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),
]
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 LiteLLM-routed local speech models exist (idempotent).
tts_voices = [
("Kokoro Adam", "local-kokoro-tts:am_adam", True),
("Kokoro Michael", "local-kokoro-tts:am_michael", False),
("Kokoro Bella", "local-kokoro-tts:af_bella", False),
("Kokoro Nicole", "local-kokoro-tts:af_nicole", False),
("Kokoro Emma", "local-kokoro-tts:bf_emma", False),
("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False),
]
stt_models = [
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
("Groq Whisper Large v3", "groq-whisper-large-v3", False),
]
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
if old.model_id and not old.model_id.startswith("local-"):
old.is_active = False
old.is_default = False
if old.model_id == "local-kokoro-tts":
old.is_active = False
old.is_default = False
if old.model_id == "local-chatterbox-turbo":
old.is_active = False
old.is_default = False
if old.model_id == "local-qwen3-tts":
old.is_active = False
old.is_default = False
for name, model_id, _ in tts_voices:
db.execute(text("""
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
VALUES (:name, :model_id, 'tts', true, false, NOW())
ON CONFLICT (model_id, task) DO NOTHING
"""), {"name": name, "model_id": model_id})
# LiteLLM Kokoro is the intended default for read-aloud; admins can change it later.
db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False})
local_default = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.model_id == "local-kokoro-tts:am_adam",
).first()
if local_default:
local_default.is_active = True
local_default.is_default = True
for task, rows in (("stt", stt_models),):
has_default = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.is_active == True,
).first() is not None
for name, model_id, preferred_default in rows:
exists = db.query(AIModelConfig).filter(
AIModelConfig.model_id == model_id,
AIModelConfig.task == task,
).first()
if not exists:
is_def = preferred_default and not has_default
db.execute(text("""
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
VALUES (:name, :model_id, :task, true, :is_default, NOW())
ON CONFLICT (model_id, task) DO NOTHING
"""), {"name": name, "model_id": model_id, "task": task, "is_default": is_def})
if is_def:
has_default = 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, favorite # noqa
from app.models import flashcard, refresh_token # noqa
from app.models import category_grant, conversation, exam, media, question_media, study_plan # noqa
from app.models import folder # 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()
# Retry schema migration up to 3 times — Celery tasks may hold locks briefly
for attempt in range(3):
try:
with engine.connect() as conn:
conn.execute(text("SET lock_timeout = '15s'"))
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
conn.commit()
break
except Exception as e:
if attempt < 2 and "lock" in str(e).lower():
import logging; logging.getLogger(__name__).warning(f"Schema migration lock timeout (attempt {attempt+1}/3), retrying in 5s...")
import time; time.sleep(5)
continue
raise
with engine.connect() as conn:
conn.execute(text("SET lock_timeout = '15s'"))
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
"""))
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS max_attempts INTEGER
"""))
conn.execute(text("""
ALTER TABLE quizzes
ADD COLUMN IF NOT EXISTS questions_per_attempt INTEGER
"""))
conn.execute(text("""
ALTER TABLE quiz_attempts
ADD COLUMN IF NOT EXISTS selected_question_ids JSON
"""))
conn.execute(text("""
ALTER TABLE quiz_attempts
ADD COLUMN IF NOT EXISTS expired INTEGER DEFAULT 0
"""))
# Question ownership
conn.execute(text("""
ALTER TABLE questions
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(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"'))
# Fix: section deletion must not cascade-delete quizzes
conn.execute(text("ALTER TABLE quizzes ALTER COLUMN section_id DROP NOT NULL"))
try:
conn.execute(text("ALTER TABLE quizzes DROP CONSTRAINT IF EXISTS quizzes_section_id_fkey"))
conn.execute(text("ALTER TABLE quizzes ADD CONSTRAINT quizzes_section_id_fkey FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE SET NULL"))
except Exception as e:
import logging
logging.getLogger(__name__).debug(f"FK constraint migration (may already exist): {e}")
# Soft delete + publish control for quizzes
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_published INTEGER DEFAULT 1"))
# 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"))
# 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)
)
"""))
# Unthrottle flag for users (exempt from AI/TTS rate limits)
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_unthrottled INTEGER DEFAULT 0"))
# Contact form submissions table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS contact_submissions (
id SERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
email VARCHAR NOT NULL,
type VARCHAR NOT NULL,
message TEXT NOT NULL,
read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
# AI question tags (subjects, diseases, keywords)
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_tags (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
type VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_name_type ON question_tags(LOWER(name), type)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_tag_links (
question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES question_tags(id) ON DELETE CASCADE,
PRIMARY KEY (question_id, tag_id)
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_classification_snapshots (
id SERIAL PRIMARY KEY,
job_id VARCHAR(64),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
reason VARCHAR(120) NOT NULL DEFAULT 'before_ai_classification',
question_count INTEGER NOT NULL DEFAULT 0,
link_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS ix_question_classification_snapshots_created_at
ON question_classification_snapshots (created_at DESC)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS question_classification_snapshot_links (
snapshot_id INTEGER NOT NULL REFERENCES question_classification_snapshots(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
tag_name VARCHAR(200) NOT NULL,
tag_type VARCHAR(50) NOT NULL,
PRIMARY KEY (snapshot_id, question_id, tag_name, tag_type)
)
"""))
# Flashcard decks and cards
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_decks (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
section_id INTEGER REFERENCES sections(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
card_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcards (
id SERIAL PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
front TEXT NOT NULL,
back TEXT NOT NULL,
page_reference INTEGER,
image_path VARCHAR,
created_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_tag_links (
flashcard_id INTEGER NOT NULL REFERENCES flashcards(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES question_tags(id) ON DELETE CASCADE,
PRIMARY KEY (flashcard_id, tag_id)
)
"""))
# Soft-delete + sharing for flashcard decks
conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP"))
conn.execute(text("ALTER TABLE flashcard_decks ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
# Deck ratings
conn.execute(text("""
CREATE TABLE IF NOT EXISTS flashcard_deck_ratings (
id SERIAL PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (user_id, deck_id)
)
"""))
# Quiz sharing
conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_shared INTEGER DEFAULT 0"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS explanation_image_path VARCHAR"))
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 as e:
log.warning(f"Embedding failed for question {q.id}: {e}")
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()
def _acquire_singleton_lock() -> bool:
"""Returns True if this worker wins the startup lock for singleton tasks (scheduler, backfill).
Uses Redis SETNX with a 5-minute TTL. Degrades gracefully if Redis is unavailable."""
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=2)
return bool(r.set("startup:singleton_lock", "1", nx=True, ex=300))
except Exception:
return True # Redis unavailable — assume single worker, run everything
# Stable int64 for pg_advisory_lock — arbitrary but must not collide with other uses.
_STARTUP_DDL_LOCK_KEY = 8472931
def _create_vector_extension():
"""The extensions, before anything declares a column or a query that needs one.
`vector` for the embeddings. `pg_trgm` for AI Mode's spelling fallback: a
one-letter slip in a short query empties the library, because neither the
lexical nor the semantic ranker can catch a token that is not a word, and
trigrams do not care how it is spelled.
"""
from sqlalchemy import text
import logging
try:
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
conn.commit()
except Exception as exc:
# Not fatal on its own: a database where the extension cannot be
# installed will fail loudly at create_all a moment later, and saying
# so twice helps nobody read the traceback.
logging.getLogger(__name__).warning("Could not create the vector extension: %s", exc)
def _run_startup_ddl():
"""Serialize startup DDL across uvicorn workers using a Postgres advisory lock.
Without this, N workers run ALTER TABLE / CREATE TABLE in parallel at startup
and occasionally acquire AccessExclusiveLocks in different orders, tripping
Postgres's deadlock detector and killing one worker. The DDL itself is
idempotent (IF NOT EXISTS / create_all), so the losing worker runs it again
as a no-op after the winner releases the lock.
"""
from sqlalchemy import text
import logging
log = logging.getLogger(__name__)
# The advisory lock is session-scoped, held for the lifetime of this connection.
with engine.connect() as lock_conn:
log.info("Acquiring startup DDL advisory lock...")
lock_conn.execute(text("SELECT pg_advisory_lock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY})
lock_conn.commit()
try:
# The extension first, and on its own. Several tables declare a
# `vector` column, so `create_all` against a database that has
# never had pgvector installed dies on the first of them — which
# is every fresh deploy, and was invisible here because the
# long-lived database had the extension already.
_create_vector_extension()
Base.metadata.create_all(bind=engine)
setup_pgvector()
finally:
lock_conn.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": _STARTUP_DDL_LOCK_KEY})
lock_conn.commit()
log.info("Startup DDL complete.")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup — all workers gate DDL behind a Postgres advisory lock to avoid deadlocks.
_run_startup_ddl()
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()
# The backfill must run in one worker only, or several race each other.
if _acquire_singleton_lock():
backfill_embeddings()
yield
app = FastAPI(
title="PedsHub",
description=(
"The API behind PedsHub: a question bank sat as timed or study "
"sessions, a library of articles and cards written against it, and the "
"analysis of what a learner actually knows.\n\n"
"Every route lives under `/api/v1`. `/api/...` reaches the same route "
"and always will — it is the address the web app was written against — "
"but a client written today should say the version, so that the day "
"`/api/v2` exists it keeps the behaviour it was built on.\n\n"
"Errors carry an `error` object: `code` is a stable word to switch on, "
"`message` is one line for a person, and `fields` says which inputs "
"were wrong when that is the problem."
),
version="3.0.0",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
api_errors.install(app)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://quiz.danvics.com", "https://pedshub.com", "https://www.pedshub.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-New-Token"], # Allow frontend to read this header
)
# Session middleware for OIDC state (authlib needs it).
#
# The OIDC state and nonce live in this cookie for the length of one redirect,
# and they are what stops somebody replaying an authorization response at you.
# `lax` because the provider sends the browser back with a top-level GET, which
# lax allows and `strict` would drop — taking the state with it and failing
# every sign-in. Secure whenever the site is served over https, which is what
# APP_URL says; a plain-http development host keeps a cookie it can actually
# set.
from starlette.middleware.sessions import SessionMiddleware
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
same_site="lax",
https_only=str(getattr(settings, "APP_URL", "")).startswith("https://"),
)
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware)
# Request logging middleware — logs every request with user, duration, status
from app.middleware.request_logging import RequestLoggingMiddleware
app.add_middleware(RequestLoggingMiddleware)
# Outermost, so that everything downstream — routing, logging, the docs — sees
# one address per endpoint however the caller spelled it.
app.add_middleware(VersionAlias)
# Serve uploaded images as static files. Not versioned: an image is at a URL
# that gets written into markdown and shared, and those must not move.
app.include_router(uploads.router)
app.include_router(auth.router, prefix=f"{VERSIONED_ROOT}/auth", tags=["auth"])
# Counts the landing page states about itself. No auth: a stranger reads it.
app.include_router(public.router, prefix=f"{VERSIONED_ROOT}/public", tags=["public"])
app.include_router(articles.router, prefix=f"{VERSIONED_ROOT}/articles", tags=["articles"])
app.include_router(access.router, prefix=f"{VERSIONED_ROOT}/access", tags=["access"])
app.include_router(feedback.router, prefix=f"{VERSIONED_ROOT}/feedback", tags=["feedback"])
app.include_router(folders.router, prefix=f"{VERSIONED_ROOT}/folders", tags=["folders"])
app.include_router(exams.router, prefix=f"{VERSIONED_ROOT}/exams", tags=["exams"])
app.include_router(study_plans.router, prefix=f"{VERSIONED_ROOT}/study-plans", tags=["study-plans"])
app.include_router(drafts.router, prefix=f"{VERSIONED_ROOT}/drafts", tags=["drafts"])
app.include_router(media.router, prefix=f"{VERSIONED_ROOT}/media", tags=["media"])
app.include_router(share.router, prefix=f"{VERSIONED_ROOT}/share", tags=["share"])
app.include_router(collections.router, prefix=f"{VERSIONED_ROOT}/collections", tags=["collections"])
app.include_router(documents.router, prefix=f"{VERSIONED_ROOT}/documents", tags=["documents"])
app.include_router(quizzes.router, prefix=f"{VERSIONED_ROOT}/quizzes", tags=["quizzes"])
app.include_router(attempts.router, prefix=f"{VERSIONED_ROOT}/attempts", tags=["attempts"])
app.include_router(admin.router, prefix=f"{VERSIONED_ROOT}/admin", tags=["admin"])
app.include_router(tts.router, prefix=f"{VERSIONED_ROOT}/tts", tags=["tts"])
app.include_router(categories.router, prefix=f"{VERSIONED_ROOT}/categories", tags=["categories"])
app.include_router(questions.router, prefix=f"{VERSIONED_ROOT}/questions", tags=["questions"])
app.include_router(question_categories.router, prefix=f"{VERSIONED_ROOT}/question-categories", tags=["question-categories"])
app.include_router(favorites.router, prefix=f"{VERSIONED_ROOT}/favorites", tags=["favorites"])
app.include_router(teach.router, prefix=f"{VERSIONED_ROOT}/teach", tags=["teach"])
app.include_router(contact.router, prefix=f"{VERSIONED_ROOT}/contact", tags=["contact"])
app.include_router(tags.router, prefix=f"{VERSIONED_ROOT}/tags", tags=["tags"])
app.include_router(flashcards.router, prefix=f"{VERSIONED_ROOT}/flashcards", tags=["flashcards"])
app.include_router(mynote.router, prefix=f"{VERSIONED_ROOT}/mynote", tags=["mynote"])
app.include_router(study_tools.router, prefix=f"{VERSIONED_ROOT}/study-tools", tags=["study-tools"])
app.include_router(search.router, prefix=f"{VERSIONED_ROOT}/search", tags=["search"])
app.include_router(ai_mode.router, prefix=f"{VERSIONED_ROOT}/ai", tags=["ai-mode"])
@app.get("/api/health", tags=["health"])
@app.get(f"{VERSIONED_ROOT}/health", tags=["health"], include_in_schema=False)
def health_check():
"""Is the process up. Deliberately unversioned as well as versioned: the
thing that watches this is a monitor nobody edits for a year."""
return {"status": "ok"}