# 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) ``` ### Junction and Link Tables ``` 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. ```sql 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: ```json {"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) ### pgvector — Question Embeddings (Semantic Search) - **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: ```python 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: ```sh cat > /usr/share/nginx/html/config.js <` 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)."