- 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>
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 pedquizevery 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.shgenerates/config.jsfrom 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)
Base.metadata.create_all()— SQLAlchemy creates missing tablessetup_pgvector()— enables pgvector extension, adds columns, runs DDL migrations- Create upload/chroma directories
seed_admin()— creates default admin user if none existsseed_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:
backfill_embeddings()— background thread that generates embeddings for questions that don't have onestart_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 progresspdf:steps:{document_id}— PDF processing progressclassify:steps:{job_id}— classification progressextraction: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_pdftask → 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 inquestions.embeddingcolumn - 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
NULLembedding
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
- User logs in with email + password → backend verifies bcrypt hash
- Backend issues a JWT containing
sub(email),exp(expiry),iat(issued-at) - Token expiry is configured via
ACCESS_TOKEN_EXPIRE_MINUTES(default: 1440 = 24 hours) - Sliding expiration:
TokenRefreshMiddlewarechecks 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-Tokenresponse header - Frontend detects this header and updates its stored token transparently
- Algorithm: HS256, secret from
SECRET_KEYenv 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/.envand 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:
- Each browser tab generates a unique session ID (stored in component state)
- The session ID is sent with every request via the
X-Quiz-Sessionheader - When saving progress, the backend sets a Redis key:
quiz_active:{user_id}:{attempt_id} = {session_id} (TTL ~30s) - 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)
- The lock TTL is refreshed on each progress save, so it stays active as long as the user is actively taking the quiz
- 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)."