pdf-quiz-generator/docs/architecture.md
Daniel 2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00

21 KiB

PedsHub Architecture

Deep technical documentation for the PedsHub platform internals.

System Overview

                          ┌───────────────────────┐
                          │       Browser          │
                          │  (React 18 SPA / PWA)  │
                          └───────────┬────────────┘
                                      │ HTTPS
                          ┌───────────▼────────────┐
                          │   Caddy / Nginx (TLS)  │
                          └───────────┬────────────┘
                                      │ :8081
                ┌─────────────────────▼─────────────────────┐
                │         Nginx (frontend container)         │
                │  static files + /api proxy to backend:8000 │
                └──────┬──────────────────────┬─────────────┘
                       │                      │
              static assets           /api/* proxy
              + config.js                     │
                                ┌─────────────▼──────────────┐
                                │  FastAPI (4 uvicorn workers) │
                                └──┬────┬────┬────┬────┬─────┘
                                   │    │    │    │    │
                    ┌──────────────┘    │    │    │    └──────────────┐
                    ▼                   ▼    │    ▼                   ▼
              ┌──────────┐     ┌───────────┐ │ ┌─────────┐   ┌──────────────┐
              │PostgreSQL│     │   Redis   │ │ │ChromaDB │   │  LiteLLM     │
              │16+pgvector│    │  7-alpine │ │ │(embedded)│  │  proxy       │
              └──────────┘     └─────┬─────┘ │ └─────────┘   └──────────────┘
                                     │       │
                               ┌─────▼─────┐ │
                               │  Celery   │ │
                               │ (4 fork   │ │
                               │  workers) │ │
                               └───────────┘ │
                                             │
                          ┌──────────────────┘
                          ▼
              ┌───────────────────────┐
              │  External Services    │
              │  - OpenAI (TTS)       │
              │  - AWS Bedrock/Polly  │
              │  - ElevenLabs         │
              │  - Google Cloud TTS   │
              │  - Cloudflare (CAPTCHA│
              │  - SMTP (email)       │
              └───────────────────────┘

Request Flow

Browser
  → HTTPS request
  → Caddy/Nginx (TLS termination)
  → Nginx (frontend container, port 80)
      ├─ Static files: served directly (React SPA, config.js, icons)
      └─ /api/*: reverse-proxied to backend:8000
          → FastAPI (one of 4 uvicorn workers)
              ├─ JWT auth middleware (validates token, refreshes if stale)
              ├─ Route handler
              │   ├─ PostgreSQL (SQLAlchemy ORM) — reads/writes
              │   ├─ Redis — rate limit checks, session locks, settings
              │   ├─ ChromaDB — RAG context retrieval
              │   └─ LiteLLM proxy — AI completions (async)
              └─ Response (JSON)
          ← Backend response
      ← Proxied response (+ CSP headers added by Nginx)
  ← HTTPS response

For background jobs (PDF processing, quiz extraction, classification), the FastAPI handler enqueues a Celery task and returns a job_id immediately. The frontend polls Redis for progress steps.

Docker Services

postgres

  • Image: pgvector/pgvector:pg16
  • Purpose: Primary data store — users, documents, quizzes, questions, attempts, tags
  • Extensions: vector (pgvector) for 1024-dimensional question embeddings with HNSW index
  • Volume: postgres_data (persistent)
  • Healthcheck: pg_isready -U pedquiz every 5s

redis

  • Image: redis:7-alpine
  • Purpose: Celery broker, rate limiting, runtime settings, quiz progress state, extraction step logs, singleton lock, quiz session locks
  • Volume: redis_data (persistent)

backend

  • Build: ./backend/Dockerfile
  • Command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
  • Depends on: postgres (healthy), redis (started)
  • Volumes: uploads_data (PDFs + extracted images), chroma_data (ChromaDB persistence)
  • Key env vars: DATABASE_URL, REDIS_URL, SECRET_KEY, LITELLM_*, OPENAI_API_KEY, AWS_*, MAIL_*, TURNSTILE_SECRET_KEY

celery

  • Build: ./backend/Dockerfile (same image as backend)
  • Command: celery -A app.tasks worker --loglevel=info --concurrency=4
  • Purpose: Background task execution — PDF processing, quiz extraction, tag classification, embedding regeneration
  • Depends on: postgres (healthy), redis (started)
  • Volumes: Same as backend (needs access to uploads and ChromaDB)

frontend

  • Build: ./frontend/Dockerfile
  • Entrypoint: docker-entrypoint.sh generates /config.js from env vars at startup
  • Port: 127.0.0.1:8081:80
  • Depends on: backend
  • Nginx config: Serves static files, proxies /api/* to backend, sets CSP headers

Database Schema

Core Tables

users
├── id (PK)
├── email (unique, collation "C")
├── hashed_password (bcrypt)
├── name
├── role ("admin" | "moderator" | "user")
├── is_unthrottled (0 | 1)
└── created_at

pdf_documents
├── id (PK)
├── user_id → users(id) ON DELETE CASCADE
├── filename (stored name)
├── original_filename
├── total_pages
├── status ("processing" | "ready" | "error")
├── error_message
└── uploaded_at

sections
├── id (PK)
├── document_id → pdf_documents(id) ON DELETE CASCADE
├── name
├── start_page
└── end_page

quizzes
├── id (PK)
├── section_id → sections(id) ON DELETE SET NULL  ← section deletion preserves quiz
├── user_id → users(id)
├── category_id → quiz_categories(id) ON DELETE SET NULL
├── title
├── questions_count
├── time_limit_minutes (nullable)
├── mode ("timed" | "learning")
├── skipped_questions (JSON text)
├── deleted_at (soft delete)
├── is_published (0 | 1)
└── created_at

questions
├── id (PK)
├── quiz_id (source_quiz_id) → quizzes(id) ON DELETE SET NULL  ← informational origin
├── question_category_id → question_categories(id) ON DELETE SET NULL
├── question_text
├── question_type ("mcq" | "true_false" | "fill_blank")
├── options (JSON array)
├── correct_answer
├── explanation
├── page_reference
├── image_path
└── embedding (vector(1024), deferred load, HNSW index)
quiz_question_links (many-to-many: quizzes ↔ questions)
├── quiz_id (PK) → quizzes(id) ON DELETE CASCADE
├── question_id (PK) → questions(id) ON DELETE CASCADE
└── position

quiz_attempts
├── id (PK)
├── quiz_id → quizzes(id) ON DELETE CASCADE
├── user_id → users(id) ON DELETE CASCADE
├── score
├── total_questions
├── started_at
└── completed_at

attempt_answers
├── id (PK)
├── attempt_id → quiz_attempts(id) ON DELETE CASCADE
├── question_id → questions(id) ON DELETE CASCADE
├── user_answer
└── is_correct

Tag System

question_tags
├── id (PK)
├── name (varchar 200)
├── type ("subject" | "disease" | "keyword")
├── created_at
└── UNIQUE INDEX on (LOWER(name), type)  ← case-insensitive dedup

question_tag_links (many-to-many: questions ↔ tags)
├── question_id (PK) → questions(id) ON DELETE CASCADE
└── tag_id (PK) → question_tags(id) ON DELETE CASCADE

Category Tables

quiz_categories
├── id (PK)
├── name
├── user_id → users(id)
└── created_at

question_categories
├── id (PK)
├── name
├── description
├── user_id → users(id)
└── created_at

Auth and Lifecycle Tables

email_verifications
├── id (PK)
├── user_id → users(id) ON DELETE CASCADE (unique)
├── token (unique, indexed)
├── expires_at
├── verified_at (null until verified)
└── created_at

password_resets
├── id (PK)
├── user_id → users(id) ON DELETE CASCADE
├── token (unique, indexed)
├── expires_at
├── used (boolean)
└── created_at

favorites
├── id (PK)
├── user_id → users(id) ON DELETE CASCADE
├── question_id → questions(id) ON DELETE CASCADE
├── created_at
└── UNIQUE(user_id, question_id)

reminder_schedules
├── id (PK)
├── user_id → users(id) ON DELETE CASCADE
├── quiz_id → quizzes(id) ON DELETE CASCADE
├── next_reminder_at
├── interval_days
├── performance_score
├── is_active
├── created_at
└── updated_at

ai_model_configs
├── id (PK)
├── name (display name)
├── model_id (LiteLLM model identifier)
├── task ("extraction" | "teach" | "tts" | "keyword")
├── api_key (optional override)
├── is_active
├── is_default
├── created_at
└── UNIQUE(model_id, task)

contact_submissions
├── id (PK)
├── name
├── email
├── type
├── message
├── read (0 | 1)
└── created_at

Key Cascade Rules

Parent deleted Child behavior
User deleted Documents, attempts, favorites, reminders, verifications, password resets cascade delete
Document deleted Sections cascade delete
Section deleted Quizzes get section_id = NULL (ON DELETE SET NULL) — quizzes and questions survive
Quiz deleted Attempts and quiz_question_links cascade delete; questions get quiz_id = NULL
Question deleted Attempt answers, quiz_question_links, tag links, favorites cascade delete
Tag deleted Tag links cascade delete

Multi-Worker Startup

The backend runs 4 uvicorn workers (separate processes). All workers execute the same lifespan startup sequence, but certain tasks must run only once:

What every worker does (idempotent)

  1. Base.metadata.create_all() — SQLAlchemy creates missing tables
  2. setup_pgvector() — enables pgvector extension, adds columns, runs DDL migrations
  3. Create upload/chroma directories
  4. seed_admin() — creates default admin user if none exists
  5. seed_default_models() — seeds AI model configs and TTS voices

What only one worker does (singleton)

The _acquire_singleton_lock() function uses Redis SETNX to atomically set the key startup:singleton_lock with a 300-second TTL. Only the worker that successfully sets it (returns True) runs:

  1. backfill_embeddings() — background thread that generates embeddings for questions that don't have one
  2. start_scheduler() — APScheduler instance for periodic tasks (reminders)

If Redis is unavailable, the function returns True (fail-open for single-worker deployments).

Stale Connection Cleanup

Before DDL migrations, setup_pgvector() terminates PostgreSQL connections that have been idle in transaction for more than 30 seconds. This prevents hangs when a previous crashed worker left an open transaction holding a DDL lock.

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()

DDL Migration Retry Logic

Schema migrations (ALTER TABLE, CREATE EXTENSION) use a 15-second lock_timeout and retry up to 3 times with a 5-second delay between attempts. This handles transient lock contention from Celery workers or other uvicorn processes.

Background Task System

Stack

  • Broker: Redis (same instance used for caching/rate limiting)
  • Worker: Celery with --concurrency=4 (fork pool, 4 child processes)
  • Task module: app.tasks (auto-discovered by Celery)

Task Types

Task name Module Trigger Purpose
process_pdf pdf_tasks.py Document upload Extract text page-by-page, store chunks in ChromaDB
extract_quiz quiz_tasks.py "Extract" button on document detail page Run AI extraction (6 modes), create quiz + questions, generate embeddings
classify_questions quiz_tasks.py Admin dashboard "Classify" button AI tags untagged questions with subjects, diseases, keywords in batches of 10
regenerate_embeddings quiz_tasks.py Admin dashboard or CLI reembed Regenerate 1024-dim embeddings for all questions using current embedding model

Progress Reporting

All tasks report progress via Redis lists (RPUSH). Each entry is a JSON object:

{"step": "ai", "message": "Chunk 2/4: pages 51-100...", "ts": 1712345678.123}

Key patterns:

  • extraction:steps:{job_id} — quiz extraction progress
  • pdf:steps:{document_id} — PDF processing progress
  • classify:steps:{job_id} — classification progress
  • extraction:status:{job_id} — job state: running | completed | failed | cancelled

The frontend polls these keys to render real-time step-by-step progress. All keys expire after 1 hour (EXPIRE_SECONDS = 3600).

Cancellation

Extraction and classification tasks check extraction:status:{job_id} (or classify:status:{job_id}) at each chunk boundary. If the value is cancelled, the task exits early. The frontend can cancel a running job by setting this key.

Large Document Handling

Documents over 50 pages are split into chunks of 50 pages each. Each chunk is processed independently and results are accumulated. This prevents LLM context window overflows and allows progress reporting per chunk.

Vector Search Architecture

PedsHub uses a dual-index approach with two separate vector stores for different purposes:

ChromaDB — Document Page Vectors (RAG)

  • Purpose: Retrieval-augmented generation for the AI tutor (TeachChat)
  • Content: Raw PDF page text, chunked and embedded
  • Flow: PDF upload → process_pdf task → text extraction → chunking → ChromaDB storage
  • Usage: When the AI tutor answers a question, relevant document pages are retrieved as context
  • Storage: Persistent volume (chroma_data), embedded mode (no separate server)
  • Purpose: "Find similar questions" in the question bank
  • Content: Question text embedded as 1024-dimensional vectors
  • Index: HNSW (vector_cosine_ops) for fast approximate nearest-neighbor search
  • Flow: Question created → embedding_service.embed_question() → HTTP call to LiteLLM proxy → vector stored in questions.embedding column
  • Usage: Question bank semantic search, "related questions" in TeachChat context
  • Model: Configurable via Admin → More Settings (stored in Redis, no restart needed)
  • Backfill: On startup, a background thread embeds any questions with NULL embedding

Why Two Stores?

Concern ChromaDB pgvector
Data type Raw document text chunks Structured question text
Query pattern "Find pages about X" (RAG context) "Find questions similar to Y" (search)
Lifecycle Tied to document (delete document = delete vectors) Tied to question (survives document deletion)
Dimensions ChromaDB default 1024 (configurable model)

Authentication

JWT Tokens with Sliding Expiration

  1. User logs in with email + password → backend verifies bcrypt hash
  2. Backend issues a JWT containing sub (email), exp (expiry), iat (issued-at)
  3. Token expiry is configured via ACCESS_TOKEN_EXPIRE_MINUTES (default: 1440 = 24 hours)
  4. Sliding expiration: TokenRefreshMiddleware checks every request:
    • If token is older than 12 hours OR will expire in less than 1 hour
    • A new token is generated and returned in the X-New-Token response header
    • Frontend detects this header and updates its stored token transparently
  5. Algorithm: HS256, secret from SECRET_KEY env var

Password Hashing

  • Library: passlib with bcrypt scheme
  • All passwords are hashed before storage; plaintext is never persisted

Email Verification Flow

1. User registers → backend creates User + EmailVerification (token, expires_at)
2. Verification email sent with link: {APP_URL}/verify-email?token={token}
3. User clicks link → frontend calls POST /api/auth/verify-email
4. Backend checks token validity + expiry → sets verified_at timestamp
5. Until verified: get_current_user() returns 403 with X-Unverified header
6. Frontend redirects unverified users to a "check your email" page

Password Reset Flow

1. User requests reset → POST /api/auth/forgot-password with email
2. Backend creates PasswordReset (token, expires_at) → sends reset email
3. Email contains link: {APP_URL}/reset-password?token={token}
4. User clicks link → enters new password → POST /api/auth/reset-password
5. Backend validates token, checks expiry, marks as used, updates password hash

Rate Limiting

Implementation

Rate limiting uses Redis INCR + TTL via the check_rate_limit() utility:

count = redis.incr(key)      # Atomic increment
if count == 1:
    redis.expire(key, window)  # Set TTL on first hit
if count > max_calls:
    raise HTTPException(429)

Limits

Endpoint Key pattern Max calls Window Scope
Login rate:login:{ip} 10 15 min Per IP
TeachChat rate:teach:{user_id} 30 10 min Per user
TTS rate:tts:{user_id} 20 5 min Per user

Exemptions

The following users bypass all rate limits:

  • Users with role admin
  • Users with role moderator
  • Users with is_unthrottled = 1 (set via admin dashboard)

Graceful Degradation

If Redis is unavailable (connection timeout of 1 second), rate limiting fails open — requests are allowed through. This prevents Redis outages from blocking legitimate users.

Runtime Configuration

No Build-Time Secrets

The frontend Docker image contains only the built React SPA with no environment-specific values. At container startup, docker-entrypoint.sh runs:

cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
  TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'

The SPA loads config.js before the React bundle via a <script> tag in index.html. Components read values from window.__APP_CONFIG__.

Benefits

  • Change Turnstile keys (or add new config) by editing frontend/.env and restarting — no rebuild
  • Same Docker image works across staging/production with different env files
  • No risk of secrets leaking into the JS bundle via Vite's import.meta.env

Concurrent Quiz Protection

Problem

A user could open the same in-progress quiz on two devices (or two browser tabs), causing conflicting answer submissions and corrupted progress state.

Solution

Redis session locks with TTL:

  1. Each browser tab generates a unique session ID (stored in component state)
  2. The session ID is sent with every request via the X-Quiz-Session header
  3. When saving progress, the backend sets a Redis key:
    quiz_active:{user_id}:{attempt_id} = {session_id}   (TTL ~30s)
    
  4. When resuming a quiz, the backend checks this key:
    • If the key exists and the session ID matches → allow (same tab)
    • If the key exists and the session ID differs → reject with HTTP 409: "This quiz is active on another device"
    • If the key doesn't exist → allow (no active session, or previous session timed out)
  5. The lock TTL is refreshed on each progress save, so it stays active as long as the user is actively taking the quiz
  6. When the quiz is submitted (completed), the lock is released

User Experience

If a user tries to resume on a second device, they see: "This quiz is active on another device. It will become available when the other session ends or times out (~30 seconds)."