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>
This commit is contained in:
Daniel 2026-04-04 22:48:26 +02:00
parent 10b9b1b7cf
commit 2cbbfe00c3
51 changed files with 5088 additions and 385 deletions

379
README.md
View file

@ -1,32 +1,47 @@
# PedQuiz
# PedsHub
AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automatically extract MCQ questions with AI, and take quizzes with text-to-speech support and semantic search.
AI-powered pediatric learning platform. Upload PDF study materials, automatically extract or generate MCQ questions with AI, study with an AI tutor, and track your progress. Installable as a PWA.
## Features
- **PDF → Quiz**: Upload PREP PDFs, AI extracts questions, answers, and explanations
- **Quiz Modes**: Study (instant feedback) and Exam (timed, scored)
- **Generative Mode**: AI reads plain text/textbook chapters and creates MCQ questions from scratch
- **Quiz Modes**: Study (instant feedback with AI tutor) and Exam (timed, scored)
- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips.
- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags
- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
- **Embedding Model**: Configurable via Admin UI or env — supports any LiteLLM proxy model or AWS Bedrock
- **Job Cancellation**: Cancel running extractions from the web UI
- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode
- **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images
- **Performance Tracking**: Per-quiz attempt history with line charts, score trends, delete individual attempts
- **PDF Processing Progress**: Step-by-step progress reporting for PDF uploads and quiz extraction — see each stage in real time
- **Concurrent Quiz Protection**: Redis session locks prevent the same quiz from being resumed on multiple devices simultaneously
- **Landing Page**: Integrated with the app — Sign In / Register open as modal overlays, shared Navbar
- **PWA**: Installable on mobile/desktop (no caching — avoids stale JS issues)
- **Bot Protection**: Cloudflare Turnstile on registration and contact forms
- **Email Verification**: Required before first login; password reset via email
- **Role system**: Admin / Moderator / User
- **Role System**: Admin / Moderator / User with optional rate-limit exemption (unthrottle)
- **Admin User Management**: Delete users, change roles, toggle unthrottle — all from the admin dashboard
- **Nextcloud Integration**: Browse and import PDFs from your Nextcloud
- **Themes**: Default and Warm Brown
- **Themes**: Default (blue) and Warm/Literary (brown, serif fonts)
- **Multi-worker**: 4 uvicorn workers with Redis singleton lock for scheduler
## Stack
| Layer | Tech |
|---|---|
| Frontend | React + React Router, plain CSS, Nginx |
| Frontend | React 18 + React Router 6, plain CSS, Nginx, PWA (network-only service worker) |
| Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector |
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, and more) |
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim, set via Admin UI or env) |
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
| Document vectors | ChromaDB |
| TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS |
| Queue | Celery + Redis |
| Queue | Celery + Redis (4 fork workers) |
| Email | SMTP (smtp2go or any SMTP server) |
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
For detailed architecture documentation, see [docs/architecture.md](docs/architecture.md).
## Quick Start
@ -44,7 +59,7 @@ Frontend available at `http://localhost:8081`. The first registered user becomes
## Environment Variables
Create `backend/.env`:
### Backend (`backend/.env`)
```env
# Database
@ -61,7 +76,7 @@ LITELLM_API_BASE=https://your-litellm-proxy.com # leave empty for direct OpenA
# Embedding model — use the model name exactly as your proxy lists it (no prefix needed)
# Can also be changed live via Admin → More settings without redeploying
LITELLM_EMBEDDING_MODEL=ge-gemini-embedding-001
LITELLM_EMBEDDING_MODEL=gemini-embedding-001
# OpenAI (for TTS — calls api.openai.com directly, not the proxy)
OPENAI_API_KEY=<openai-api-key>
@ -86,6 +101,12 @@ MAIL_PASSWORD=<smtp2go-password>
MAIL_FROM=noreply@yourdomain.com
MAIL_STARTTLS=true
# Bot protection — Cloudflare Turnstile (backend secret)
TURNSTILE_SECRET_KEY=<cloudflare-turnstile-secret-key>
# Contact form admin notifications
ADMIN_EMAIL=admin@yourdomain.com
# App
APP_URL=https://your-domain.com
UPLOAD_DIR=/app/uploads
@ -93,9 +114,163 @@ MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data
```
### Frontend (`frontend/.env`)
```env
# Bot protection — Cloudflare Turnstile (public site key)
TURNSTILE_SITE_KEY=<cloudflare-turnstile-site-key>
```
The frontend env file is **not** baked into the Docker image at build time. Instead, `docker-entrypoint.sh` generates a `/config.js` file from the env vars when the container **starts**. This means:
- Change keys by editing `frontend/.env` and restarting the container (no rebuild)
- Switch captcha providers by updating the entrypoint script and the widget component
- Remove bot protection by clearing the key (empty = disabled)
## Cloudflare Turnstile (Bot Protection)
### How it works
Turnstile protects the **registration** and **contact** forms from bot submissions. It does NOT require Cloudflare DNS/proxy — it works standalone on any domain.
**Flow:**
```
1. Page loads → Turnstile JS loads from challenges.cloudflare.com
2. Widget renders (invisible or interactive depending on risk score)
3. User completes challenge → widget calls onVerify(token)
4. Frontend stores token in state → Sign Up button becomes enabled
5. User submits form → token sent as `turnstile_token` in POST body
6. Backend receives token → POSTs to Cloudflare's siteverify API:
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
Body: { secret: TURNSTILE_SECRET_KEY, response: turnstile_token }
7. Cloudflare returns { success: true/false }
8. If false → 400 "Bot verification failed"
9. If true → registration proceeds normally
```
**Where Turnstile is active:**
- Register form (modal overlay on landing page)
- Register form (standalone `/register` page)
- Contact form (landing page)
**Where it is NOT active (by design):**
- Login — protected by IP-based rate limiting (10 attempts / 15 min) instead
### Setup
1. Go to https://dash.cloudflare.com → Turnstile → Add widget
2. Add your domain(s), choose "Managed" widget type
3. Copy the Site Key and Secret Key
```bash
# frontend/.env
TURNSTILE_SITE_KEY=0x4AAAAAAA...
# backend/.env
TURNSTILE_SECRET_KEY=0x4AAAAAAA...
# Restart (no rebuild needed)
docker compose restart frontend backend
```
### Testing
Cloudflare provides test keys for development:
| Purpose | Site Key | Secret Key |
|---|---|---|
| Always passes | `1x00000000000000000000AA` | `1x0000000000000000000000000000000AA` |
| Always blocks | `2x00000000000000000000AB` | `2x0000000000000000000000000000000AB` |
### Disabling
Set both keys to empty strings (or remove them), restart. The widget won't render and the backend skips verification.
### Runtime Config Architecture
```
docker-compose.yml
└─ frontend service: env_file: ./frontend/.env
Container startup (docker-entrypoint.sh):
└─ Reads $TURNSTILE_SITE_KEY from environment
└─ Writes /usr/share/nginx/html/config.js:
window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: "0x4AAA..." };
Browser loads index.html:
└─ <script src="/config.js"> sets window.__APP_CONFIG__
└─ <script type="module" src="/src/main.jsx"> React app starts
└─ Components read: window.__APP_CONFIG__?.TURNSTILE_SITE_KEY
```
This avoids Vite's `import.meta.env.VITE_*` which bakes values into the JS bundle at build time.
## Extraction Modes
| Mode | Use Case | How It Works |
|---|---|---|
| **Standard** | PREP PDFs with inline answers | Finds "Correct Answer: X" / "Preferred Response: X" after each question |
| **Two-Step** | PREP 2013-style (separate answer key) | Phase 1: extract questions, Phase 2: extract answer key, Phase 3: match |
| **Questions Only** | No answers available | Extracts Q + options, sets answer to "PENDING" for manual fill |
| **AI + Regex** | Non-standard answer formats | AI analyses format, generates regex, applies it for fast extraction |
| **AI Decides** | Unknown format | AI samples start + end of document, picks the best strategy |
| **Generate** | Textbook chapters, study notes | AI reads the text and **creates** MCQ questions (correct answer from text, distractors from AI knowledge) |
The extraction model is selected per-job from the dropdown on the document detail page. Models are configured in Admin → AI Models with task `extraction`.
## Tag Classification
PedsHub can automatically classify questions with structured tags using AI. Tags are organized into three types:
| Tag Type | Examples | Purpose |
|---|---|---|
| **Subject** | Cardiology, Infectious Disease, Neonatology | Medical specialty / rotation |
| **Disease** | Kawasaki Disease, Pneumonia, Type 1 Diabetes | Specific condition mentioned |
| **Keyword** | fever workup, antibiotic resistance, fluid management | Clinical concept / topic |
**How it works:**
1. Admin triggers classification from the admin dashboard (or via the `/api/admin/classify` endpoint)
2. A Celery background task (`classify_questions`) finds all untagged questions
3. Questions are sent to the AI in batches of 10 — the AI returns subjects, diseases, and keywords for each
4. Tags are stored in `question_tags` with case-insensitive deduplication and linked via `question_tag_links`
5. Users can filter the question bank by any combination of tags in the Question Bank page
The classification model is configured in Admin → AI Models with task `keyword`.
## AI Tutor (TeachChat)
The study-mode AI tutor opens as a slide-in drawer. It:
- Knows the current question, correct answer, and explanation
- Pulls related questions from the question bank via semantic similarity (pgvector)
- Supports model selection (dropdown when multiple teach models configured)
- Renders full markdown including GFM tables (via `remark-gfm` + `rehype-raw`)
- Generates follow-up suggestion chips after each response
- Uses `litellm.acompletion()` (async) so it doesn't block other requests
Models for teach are configured in Admin → AI Models with task `teach`.
## Rate Limiting
| Endpoint | Limit | Scope |
|---|---|---|
| Login | 10 attempts / 15 min | Per IP (Redis) |
| TeachChat | 30 calls / 10 min | Per user (Redis) |
| TTS | 20 calls / 5 min | Per user (Redis) |
Admins, moderators, and users marked as "unthrottled" are exempt. Rate limiting degrades gracefully if Redis is unavailable (allows requests through).
## PWA (Progressive Web App)
The app is installable on mobile and desktop. The service worker is **network-only** (no caching) — it exists solely to satisfy the PWA installability requirement. This avoids stale cached JS causing blank screens or module-not-found errors after deployments.
Files: `frontend/public/manifest.json`, `frontend/public/sw.js`, `frontend/public/icon.svg`
## Rebuild & Restart
Frontend and backend are built into Docker images — code changes require a `build` before they take effect. `.env` changes only need a `restart`.
Frontend and backend are built into Docker images — code changes require a `build` before they take effect. Environment variable changes only need a `restart`.
```bash
# Rebuild and restart everything
@ -104,10 +279,9 @@ docker compose build && docker compose up -d
# Rebuild a single service
docker compose build backend && docker compose up -d backend
docker compose build frontend && docker compose up -d frontend
docker compose build celery && docker compose up -d celery
# Restart without rebuilding (for .env changes only)
docker compose restart backend
# Restart without rebuilding (for .env changes, including Turnstile keys)
docker compose restart frontend backend
# View logs
docker compose logs backend --tail=50
@ -120,35 +294,25 @@ Accessible at `/admin` for admin users. Three tabs:
### AI Models
- **Search models** from your LiteLLM proxy — click any result to pre-fill the add form
- Configure models per task: `extraction` (PDF → questions), `tts` (voices), `general`
- Configure models per task: `extraction` (PDF → questions), `teach` (AI tutor), `keyword` (tag classification), `tts` (voices)
- Same model can be added to multiple tasks (e.g. `bedrock-claude-haiku-4.5` for both extraction and teach)
- Set a default model per task; enable/disable individual models
- Extraction models from the proxy don't need an `openai/` prefix — the backend adds it automatically
### Users
- Create users directly (email auto-verified)
- Change user roles: admin / moderator / user
- Unthrottle users (exempt from rate limits)
- **Delete users** — removes the user and all associated data (documents, quizzes, attempts)
### More Settings
- **Public Registration** — enable/disable new user sign-ups
- **AWS Polly** — global enable/disable toggle for all Polly voices (individual voices still manageable in AI Models tab)
- **AWS Polly** — global enable/disable toggle for all Polly voices
- **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task)
- **Embedding Model** — set the model used for semantic search vectors:
- Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models
- Click **Test** to verify the model works and returns the correct dimensions (must be 1024)
- Setting is stored in Redis and takes effect immediately — no restart needed
- Priority: Admin UI setting → `LITELLM_EMBEDDING_MODEL` env → direct AWS Bedrock fallback
- Model name should match exactly what your proxy lists (no prefix required)
#### Switching embedding models
To switch to a different embedding model:
1. Go to Admin → More → click **Search LiteLLM**, find your model, click **Select**
2. Click **Test** to confirm it returns 1024 dimensions
3. Run `docker compose exec backend python manage.py reembed` to regenerate all existing question embeddings with the new model
To revert to AWS Bedrock (when available):
- Set model to the Bedrock model ID as configured on your proxy, or
- Clear the Redis key to fall back to env: `docker compose exec redis redis-cli DEL settings:embedding_model`
- Then set `LITELLM_EMBEDDING_MODEL=` to empty in `.env` to use direct Bedrock
## CLI Management
@ -158,115 +322,116 @@ docker compose exec backend python manage.py reset-password admin@example.com Ne
docker compose exec backend python manage.py list-users
# ── Quiz extraction ───────────────────────────────────────────────────────────
# 1. Find document and section IDs
docker compose exec backend python manage.py list-sections
# 2a. Extract in background (monitor via navbar jobs badge)
docker compose exec backend python manage.py extract 6 --bg
docker compose exec backend python manage.py extract 6 --bg --title "PREP 2012 Full" --mode timed
# 2b. Extract inline (blocking, live output in terminal)
docker compose exec backend python manage.py extract 6
# 3. Check job status
docker compose exec backend python manage.py jobs
docker compose exec backend python manage.py jobs --user admin@example.com
# CLI extract options:
# --title "My Quiz" Custom quiz title
# --mode timed|learning Quiz mode (default: timed)
# --user email Owner of the quiz (default: first admin)
# --bg Run in background via Celery
# ── Embeddings ───────────────────────────────────────────────────────────────
# Regenerate all question embeddings (run after switching embedding model)
docker compose exec backend python manage.py reembed
```
### How extraction works
1. **Upload PDF** via the web UI — text is extracted and stored
2. **Create a section** on the document page (define page range)
3. **Extract quiz** from the web UI or CLI:
- Large sections are split into 50-page chunks automatically
- Progress shown live in the web UI; running jobs can be cancelled from the Extraction Jobs page
- Supports multiple extraction modes: standard, two-step (separate answer key), questions-only, regex, AI-decides
4. Questions land in the **Question Bank** and can be assigned to categories
## Architecture
```
Browser
Nginx (frontend + API proxy)
├─► React SPA (static)
└─► FastAPI backend
├─ PostgreSQL (pgvector) ← users, quizzes, questions + 1024-dim embeddings
├─ ChromaDB ← document page chunks for semantic search
├─ Redis ← Celery queue + runtime settings (embedding model, toggles)
├─ Celery workers ← background PDF processing, emails
├─ LiteLLM proxy ← Claude/Gemini/GPT for extraction; embedding models
├─ AWS Bedrock ← Polly TTS; embedding fallback
└─ OpenAI ← TTS (direct, not via proxy)
Caddy/Nginx (HTTPS termination)
Nginx (frontend container — serves React SPA + proxies /api to backend)
├─► React SPA (static files + runtime config.js)
└─► FastAPI backend (4 uvicorn workers)
├─ PostgreSQL 16 + pgvector ← users, quizzes, questions + 1024-dim embeddings
├─ ChromaDB ← document page chunks for RAG context
├─ Redis ← Celery queue, rate limits, settings, quiz progress, singleton lock
├─ Celery workers (x4) ← background PDF processing, quiz extraction, tag classification, emails
├─ LiteLLM proxy ← Claude/Gemini/GPT for extraction + teach + classification; embedding models
├─ AWS Bedrock ← Polly TTS; embedding fallback
├─ OpenAI ← TTS (direct, not via proxy)
└─ Cloudflare Turnstile ← bot verification for registration + contact
```
### Embeddings
For deep architecture documentation (database schema, request flow, background tasks, vector search, auth, and more), see [docs/architecture.md](docs/architecture.md).
Question embeddings are 1024-dimensional vectors stored in PostgreSQL via pgvector with an HNSW index for fast cosine similarity search. The embedding model is called **directly via the LiteLLM proxy HTTP API** (not the Python library) so the `dimensions=1024` parameter is forwarded correctly.
### Multi-Worker Setup
Priority chain:
1. Model set in Admin UI (stored in Redis `settings:embedding_model`)
2. `LITELLM_EMBEDDING_MODEL` env var
3. Direct AWS Bedrock (`amazon.titan-embed-text-v2:0`) — automatic fallback if AWS credentials are present
The backend runs 4 uvicorn workers. A Redis SETNX lock (`startup:singleton_lock`, TTL 300s) ensures only one worker starts the APScheduler and backfill thread. Stale `idle in transaction` DB connections are killed at startup to prevent DDL migration hangs.
### Search
### Landing Page
Quiz search uses hybrid retrieval:
1. **Semantic** — embed the query, cosine similarity via pgvector HNSW index
2. **Keyword** — PostgreSQL `ILIKE` on question text and options
3. Results merged — semantic matches ranked first by score, keyword-only matches appended
The landing page at `/home` uses the shared `Navbar` component. When not logged in:
- Navbar shows Sign In / Register buttons
- Clicking either opens a **modal overlay** (no page navigation) with tabbed Sign In / Register forms
- Hero CTA buttons also open the modal
- Standalone `/login` and `/register` pages still exist for direct links, email verification redirects, and bookmarks
### TTS Providers
### Security
| Provider | Model ID format | Key needed |
|---|---|---|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1:echo`, `tts-1:shimmer`, `tts-1:onyx`, `tts-1:fable`, `tts-1-hd:*` | `OPENAI_API_KEY` (calls api.openai.com directly) |
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy`, `polly/Brian` | `AWS_ACCESS_KEY_ID` + `polly:SynthesizeSpeech` IAM permission |
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
| Google Cloud | `google/<voice-name>` e.g. `google/en-US-Wavenet-D` | `GOOGLE_TTS_API_KEY` |
AWS Polly can be globally disabled/enabled via Admin → More settings without removing the individual voice configs.
- **SQL injection**: pgvector queries use parameterized bind variables (`CAST(:vec AS vector)`)
- **API key exposure**: LiteLLM/TTS model search uses POST body, not URL query params
- **Rate limiting**: Redis INCR + TTL keys on login, teach, and TTS endpoints
- **Bot protection**: Cloudflare Turnstile on registration and contact forms
- **CSP headers**: Configured in nginx.conf for fonts, Turnstile, and self
## Project Structure
```
├── backend/
│ ├── app/
│ │ ├── main.py # App startup, seeding, pgvector setup
│ │ ├── main.py # App startup, DB migrations, Redis singleton lock
│ │ ├── config.py # Settings (pydantic-settings, reads .env)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── routers/ # FastAPI route handlers
│ │ ├── routers/
│ │ │ ├── auth.py # Login, register (with Turnstile), verify email, password reset
│ │ │ ├── quizzes.py # Quiz CRUD, async extraction jobs
│ │ │ ├── attempts.py # Quiz attempts, progress save/resume, history, stats
│ │ │ ├── teach.py # AI tutor chat (async, with follow-up suggestions)
│ │ │ ├── admin.py # Model management, user roles, settings, classification trigger
│ │ │ ├── tags.py # Tag listing and filtering endpoints
│ │ │ ├── contact.py # Contact form (with Turnstile)
│ │ │ └── ...
│ │ ├── services/
│ │ │ ├── ai_service.py # LLM extraction + TTS routing
│ │ │ ├── ai_service.py # LLM calls + _proxy_model() routing
│ │ │ ├── extraction_modes.py # 6 extraction strategies (standard → generate)
│ │ │ ├── embedding_service.py # pgvector embeddings (httpx → proxy)
│ │ │ ├── vector_service.py # ChromaDB document page chunks
│ │ │ ├── quiz_service.py # Quiz creation pipeline
│ │ │ └── email_service.py # Email templates + sending
│ │ ├── tasks/
│ │ │ ├── quiz_tasks.py # Celery: PDF extraction with cancellation support
│ │ │ └── pdf_tasks.py # Celery: PDF text extraction
│ │ │ ├── quiz_tasks.py # Celery: quiz extraction, tag classification, re-embed
│ │ │ └── pdf_tasks.py # Celery: PDF text extraction + vectorization
│ │ └── utils/
├── manage.py # CLI: reset-password, list-users, reembed
│ ├── requirements.txt
│ └── auth.py # JWT, password hashing, rate limiting
│ ├── manage.py # CLI: reset-password, list-users, reembed, extract
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── pages/ # All page components
│ │ ├── components/ # Navbar (with jobs badge), shared UI
│ │ └── context/ # AuthContext, ThemeContext
│ ├── nginx.conf
│ │ ├── pages/
│ │ │ ├── LandingPage.jsx # Landing + contact form + auth modal + Turnstile
│ │ │ ├── DashboardPage.jsx # Stats, performance chart, attempt history with delete
│ │ │ ├── QuizzesPage.jsx # Quiz grid, search, past attempts with delete
│ │ │ ├── QuizPage.jsx # Quiz taking (study/exam), TTS, progress save
│ │ │ ├── ResultsPage.jsx # Score card, answer review, delete attempt
│ │ │ ├── DocumentDetailPage.jsx # Sections, extraction mode picker, model selector
│ │ │ ├── RegisterPage.jsx # Standalone register (with Turnstile)
│ │ │ └── ...
│ │ ├── components/
│ │ │ ├── Navbar.jsx # Shared navbar (auth-aware, optional modal callbacks)
│ │ │ ├── TeachChat.jsx # AI tutor drawer (markdown, tables, suggestions)
│ │ │ └── ...
│ │ └── context/ # AuthContext, ThemeContext
│ ├── public/
│ │ ├── manifest.json # PWA manifest
│ │ ├── sw.js # Service worker (network-only, no cache)
│ │ ├── icon.svg # App icon
│ │ └── config.js # Dev fallback (overwritten by entrypoint in prod)
│ ├── nginx.conf # API proxy + CSP headers
│ ├── docker-entrypoint.sh # Generates /config.js from env vars at runtime
│ └── Dockerfile
├── docs/
│ └── architecture.md # Deep architecture documentation
├── frontend/.env # Frontend runtime config (Turnstile site key)
├── backend/.env # Backend config (all secrets)
└── docker-compose.yml
```
@ -277,3 +442,13 @@ AWS Polly can be globally disabled/enabled via Admin → More settings without r
- Uploads live in the `uploads_data` volume — includes extracted question images
- Redis data persists in `redis_data` volume — holds runtime settings and job state
- Set `APP_URL` to your public domain so email verification and password reset links work
- Frontend env vars (like Turnstile site key) are injected at container startup, not build time — change and restart, no rebuild needed
## TTS Providers
| Provider | Model ID format | Key needed |
|---|---|---|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` |
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |

View file

@ -3,7 +3,7 @@ from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_recycle=300)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

View file

@ -7,7 +7,7 @@ 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, favorites, teach, contact
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -145,11 +145,24 @@ def setup_pgvector():
"""))
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:
# Fail fast instead of hanging if a lock can't be acquired within 10s.
conn.execute(text("SET lock_timeout = '10s'"))
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("SET lock_timeout = '15s'"))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
ON questions USING hnsw (embedding vector_cosine_ops)
@ -190,8 +203,9 @@ def setup_pgvector():
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:
pass # constraint may already be correct
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"))
@ -242,6 +256,25 @@ def setup_pgvector():
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.commit()
@ -265,8 +298,8 @@ def backfill_embeddings():
try:
if embedding_service.embed_question(q):
ok += 1
except Exception:
pass
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:
@ -346,6 +379,7 @@ app.include_router(question_categories.router, prefix="/api/question-categories"
app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"])
app.include_router(teach.router, prefix="/api/teach", tags=["teach"])
app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
@app.get("/api/health")

View file

@ -1,16 +1,19 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint
from app.database import Base
class AIModelConfig(Base):
__tablename__ = "ai_model_configs"
__table_args__ = (
UniqueConstraint("model_id", "task", name="uq_model_id_task"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False) # display name
model_id = Column(String, nullable=False, unique=True) # litellm model id e.g. gpt-4o-mini
model_id = Column(String, nullable=False) # litellm model id e.g. gpt-4o-mini
task = Column(String, nullable=False) # extraction, tts, general
api_key = Column(String, nullable=True) # override api key, if null uses default
is_active = Column(Boolean, default=True)

View file

@ -63,6 +63,26 @@ def set_user_unthrottle(
return user
@router.delete("/users/{user_id}", status_code=204)
def delete_user(
user_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
"""Delete a user and all their data (attempts, favorites, settings)."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Nullify non-cascading FKs before delete
from sqlalchemy import text
db.execute(text("UPDATE question_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
db.execute(text("UPDATE quiz_categories SET user_id = NULL WHERE user_id = :uid"), {"uid": user_id})
db.delete(user)
db.commit()
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
@ -167,8 +187,8 @@ def create_model(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
if data.task not in ("extraction", "tts", "general", "teach"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, general, or teach")
if data.task not in ("extraction", "tts", "teach", "keyword"):
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
if data.is_default:
db.query(AIModelConfig).filter(
@ -176,9 +196,25 @@ def create_model(
AIModelConfig.is_default == True,
).update({"is_default": False})
model = AIModelConfig(**data.model_dump())
# Auto-set as default if this is the first model for the task
existing = db.query(AIModelConfig).filter(
AIModelConfig.task == data.task, AIModelConfig.is_active == True,
).count()
if existing == 0:
data_dict = data.model_dump()
data_dict["is_default"] = True
else:
data_dict = data.model_dump()
model = AIModelConfig(**data_dict)
db.add(model)
db.commit()
try:
db.commit()
except Exception as e:
db.rollback()
if "uq_model_task" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(status_code=409, detail=f"Model '{data.model_id}' already exists for task '{data.task}'")
raise HTTPException(status_code=500, detail=str(e))
db.refresh(model)
return model

View file

@ -1,6 +1,6 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
@ -200,14 +200,23 @@ class ProgressSave(BaseModel):
@router.post("/progress")
def save_progress(
data: ProgressSave,
request: Request = None,
current_user: User = Depends(get_current_user),
):
"""Save in-progress quiz answers to Redis (survives logout/browser change).
Each attempt gets its own saved progress (key includes attempt_id)."""
Each attempt gets its own saved progress (key includes attempt_id).
Also refreshes the active session lock to prevent concurrent resume on another device."""
try:
import redis as redis_lib, json as _json
from app.config import settings
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
# Session lock: identify this device by a session_id header
session_id = request.headers.get("x-quiz-session", "") if request else ""
lock_key = f"quiz_active:{current_user.id}:{data.attempt_id}"
if session_id:
r.setex(lock_key, 30, session_id) # 30s TTL, refreshed every save
key = f"quiz_progress:{current_user.id}:{data.attempt_id}"
r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days
"quiz_id": data.quiz_id,
@ -228,12 +237,13 @@ def save_progress(
@router.get("/progress")
def get_progress(
quiz_id: int,
request: Request = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Retrieve in-progress quiz answers from Redis.
Finds the latest incomplete attempt for this quiz, then checks Redis.
Clears stale data if the saved attempt has already been submitted.
Rejects if another device is actively using this attempt.
Auto-submits timed quizzes if timer has expired."""
try:
import redis as redis_lib, json as _json
@ -251,6 +261,16 @@ def get_progress(
if not attempt:
return None
# Check if another device is actively using this attempt
session_id = request.headers.get("x-quiz-session", "") if request else ""
lock_key = f"quiz_active:{current_user.id}:{attempt.id}"
active_session = r.get(lock_key)
if active_session and session_id and active_session != session_id:
raise HTTPException(
status_code=409,
detail="This quiz is active on another device. It will become available when the other session ends or times out (~30 seconds)."
)
key = f"quiz_progress:{current_user.id}:{attempt.id}"
data = r.get(key)
if not data:

View file

@ -33,8 +33,8 @@ def _check_login_rate_limit(client_ip: str):
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
except HTTPException:
raise
except Exception:
pass # Redis unavailable — degrade gracefully, don't block login
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis rate limit unavailable (failing open): {e}")
# Rate limit: max 3 reset requests per email per hour
@ -59,8 +59,35 @@ def _check_reset_rate_limit(db: Session, email: str):
)
async def _verify_turnstile(token: str) -> bool:
"""Verify Cloudflare Turnstile token. Returns True if valid or if not configured."""
from app.config import settings as cfg
secret = cfg.TURNSTILE_SECRET_KEY
if not secret:
return True
try:
import httpx
resp = httpx.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token},
timeout=10,
)
return resp.json().get("success", False)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Turnstile verification failed (failing open): {e}")
return True
@router.post("/register")
async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
if not user_data.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(user_data.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
# Check if registration is enabled (unless this is the first user - always allow admin creation)
is_first_user = db.query(User).count() == 0
if not is_first_user:
@ -74,8 +101,8 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
raise HTTPException(status_code=403, detail="Registration is currently disabled by administrator")
except HTTPException:
raise
except Exception:
pass # Redis unavailable — allow registration (fail open)
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Redis registration check failed (failing open): {e}")
if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
@ -116,7 +143,15 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
@router.post("/login", response_model=Token)
def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
if not login_data.turnstile_token:
raise HTTPException(status_code=400, detail="Bot verification required")
if not await _verify_turnstile(login_data.turnstile_token):
raise HTTPException(status_code=400, detail="Bot verification failed — please try again")
if request:
client_ip = request.client.host if request.client else "unknown"
_check_login_rate_limit(client_ip)
@ -232,7 +267,8 @@ def get_user_settings(current_user: User = Depends(get_current_user)):
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
data = r.get(f"user_settings:{current_user.id}")
return json.loads(data) if data else {}
except Exception:
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
return {}
@ -247,8 +283,8 @@ def save_user_settings(
from app.config import settings as cfg
r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True)
r.set(f"user_settings:{current_user.id}", json.dumps(settings_data))
except Exception:
pass
except Exception as e:
import logging; logging.getLogger(__name__).warning(f"Failed to save user settings: {e}")
return {"saved": True}

View file

@ -66,14 +66,14 @@ async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)):
db.commit()
# Email admin
_email_admin(name, req.email, req.type, message)
await _email_admin(name, req.email, req.type, message)
return {"success": True}
def _email_admin(name: str, email: str, type_: str, message: str):
async def _email_admin(name: str, email: str, type_: str, message: str):
try:
from app.services.email_service import send_email
from app.services.email_service import _send
label = "Moderator Application" if type_ == "moderator" else "Contact Form Question"
body = (
f"<h2>New {label} — PedsHub</h2>"
@ -85,7 +85,7 @@ def _email_admin(name: str, email: str, type_: str, message: str):
)
admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None)
if admin_email:
send_email(to=admin_email, subject=f"[PedsHub] {label} from {name}", html=body)
await _send(admin_email, f"[PedsHub] {label} from {name}", body)
except Exception as e:
log.warning(f"Failed to email admin about contact submission: {e}")

View file

@ -125,6 +125,22 @@ def get_document_status(
)
@router.get("/{document_id}/processing-steps")
def get_processing_steps(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Poll PDF processing progress steps from Redis."""
_get_doc_or_404(document_id, current_user, db)
import json as _json
import redis
r = redis.from_url(settings.REDIS_URL, decode_responses=True)
raw = r.lrange(f"pdf:steps:{document_id}", 0, -1)
steps = [_json.loads(s) for s in raw] if raw else []
return {"steps": steps}
@router.post("/{document_id}/sections", response_model=SectionResponse)
def create_section(
document_id: int,

View file

@ -1,7 +1,7 @@
"""Question bank — view, search, categorise, and create quizzes from individual questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import cast, String, or_
from sqlalchemy import cast, String, or_, text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
@ -40,16 +40,22 @@ def get_bank_ids(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just IDs for all matching questions (for server-side select-all)."""
query = db.query(Question.id)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if category_id is not None:
query = query.filter(Question.source_quiz_id == quiz_id)
if category_ids:
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
if cat_id_list:
query = query.filter(Question.question_category_id.in_(cat_id_list))
elif category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
@ -67,6 +73,20 @@ def get_bank_ids(
cast(Question.options, String).ilike(f"%{phrase}%"),
)
)
# Tag filter: questions must have ALL specified tags
if tag_ids:
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT question_id FROM question_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY question_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
if matching_ids:
query = query.filter(Question.id.in_(matching_ids))
else:
return []
return [row[0] for row in query.all()]
@ -75,8 +95,10 @@ def get_question_bank(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
uncategorized: bool = Query(False),
favorites_only: bool = Query(False),
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200),
offset: int = Query(0),
@ -87,14 +109,36 @@ def get_question_bank(
query = db.query(Question)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
query = query.filter(Question.source_quiz_id == quiz_id)
if category_id is not None:
if category_ids:
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
if cat_id_list:
query = query.filter(Question.question_category_id.in_(cat_id_list))
elif category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
# Tag filter: questions must have ALL specified tags
if tag_ids:
import logging as _log
_log.getLogger(__name__).info(f"Tag filter: tag_ids={tag_ids!r}")
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
if tag_id_list:
matching_ids = list(db.execute(sa_text("""
SELECT question_id FROM question_tag_links
WHERE tag_id = ANY(:tag_ids)
GROUP BY question_id
HAVING COUNT(DISTINCT tag_id) = :cnt
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
_log.getLogger(__name__).info(f"Tag filter matched {len(matching_ids)} questions for tags {tag_id_list}")
if matching_ids:
query = query.filter(Question.id.in_(matching_ids))
else:
return {"total": 0, "questions": []}
if favorites_only:
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
fav_ids = [f[0] for f in favorite_ids]
@ -106,7 +150,6 @@ def get_question_bank(
semantic_ids_ordered: list[int] = []
if q and q.strip() and search_mode in ("semantic", "hybrid"):
from app.services.embedding_service import generate_embedding
from sqlalchemy import text as sa_text
emb = generate_embedding(q.strip())
if emb:
# Validate all values are finite floats before interpolating into SQL
@ -116,9 +159,9 @@ def get_question_bank(
FROM questions
WHERE embedding IS NOT NULL
ORDER BY embedding <=> CAST(:vec AS vector)
LIMIT 200
"""), {"vec": emb_literal}).fetchall()
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
LIMIT :lim
"""), {"vec": emb_literal, "lim": limit * 2}).fetchall()
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.55]
# ── Keyword filter ─────────────────────────────────────────────
if q and q.strip() and search_mode in ("keyword", "hybrid"):
@ -172,6 +215,7 @@ def get_question_bank(
"options": qu.options,
"correct_answer": qu.correct_answer,
"explanation": qu.explanation,
"image_path": qu.image_path,
})
return {"total": total, "questions": result}

View file

@ -188,16 +188,16 @@ def search_quizzes(
query_emb = embedding_service.generate_embedding(phrase)
if query_emb:
# Use f-string for the vector literal — safe because it's a list of floats
emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]"
rows = db.execute(sa_text(f"""
emb_literal = "[" + ",".join(str(float(x)) for x in query_emb) + "]"
rows = db.execute(sa_text("""
SELECT q.id, q.quiz_id, q.question_text, q.options,
q.correct_answer, q.explanation,
1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity
1 - (q.embedding <=> CAST(:vec AS vector)) AS similarity
FROM questions q
WHERE q.embedding IS NOT NULL
ORDER BY q.embedding <=> '{emb_literal}'::vector
ORDER BY q.embedding <=> CAST(:vec AS vector)
LIMIT 40
""")).fetchall()
"""), {"vec": emb_literal}).fetchall()
for row in rows:
similarity = float(row.similarity)

View file

@ -0,0 +1,67 @@
"""AI-powered question tagging/classification — subjects, diseases, keywords.
Tables are created via raw SQL in setup_pgvector() to avoid race conditions with multiple workers.
"""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
log = logging.getLogger(__name__)
@router.get("")
def get_tags(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Return all tags grouped by type, with question counts."""
rows = db.execute(text("""
SELECT t.id, t.name, t.type, COUNT(tl.question_id) AS count
FROM question_tags t
LEFT JOIN question_tag_links tl ON tl.tag_id = t.id
GROUP BY t.id, t.name, t.type
ORDER BY count DESC, t.name
""")).fetchall()
result = {"subjects": [], "diseases": [], "keywords": []}
for r in rows:
key = {"subject": "subjects", "disease": "diseases", "keyword": "keywords"}.get(r.type)
if key:
result[key].append({"id": r.id, "name": r.name, "count": r.count})
return result
@router.post("/classify")
def trigger_classification(
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Admin/moderator: trigger background classification of untagged questions."""
from app.tasks.quiz_tasks import classify_questions
job_id = str(uuid.uuid4())
classify_questions.delay(job_id, current_user.id)
return {"job_id": job_id, "status": "started"}
@router.get("/status/{job_id}")
def get_classification_status(
job_id: str,
current_user: User = Depends(get_current_user),
):
"""Check classification job progress."""
import json
import redis
from app.config import settings
r = redis.from_url(settings.REDIS_URL, decode_responses=True)
status = r.get(f"classify:status:{job_id}") or "unknown"
steps_raw = r.lrange(f"classify:steps:{job_id}", 0, -1)
steps = [json.loads(s) for s in steps_raw] if steps_raw else []
error = r.get(f"classify:error:{job_id}")
return {"job_id": job_id, "status": status, "steps": steps, "error": error}

View file

@ -7,6 +7,7 @@ class UserCreate(BaseModel):
email: EmailStr
password: str
name: str
turnstile_token: str | None = None
class UserResponse(BaseModel):
@ -29,6 +30,7 @@ class Token(BaseModel):
class LoginRequest(BaseModel):
email: EmailStr
password: str
turnstile_token: str | None = None
class UserUpdateRole(BaseModel):

View file

@ -4,7 +4,7 @@ from pydantic import BaseModel
class QuizCreate(BaseModel):
section_id: int
section_id: int | None = None
title: str
mode: str = "timed" # timed, learning
time_limit_minutes: int | None = None
@ -41,7 +41,7 @@ class QuestionWithAnswer(QuestionResponse):
class QuizResponse(BaseModel):
id: int
section_id: int
section_id: int | None = None
user_id: int
title: str
questions_count: int

View file

@ -34,6 +34,7 @@ Your task: extract every question and return ONLY a JSON object in this exact fo
"options": ["<option A text>", "<option B text>", "<option C text>", "<option D text>", "<option E text>"],
"correct_answer": "<full text of the correct option, NOT the letter>",
"explanation": "<ALL text verbatim from after the correct answer line to before the next question — explanation, Critique, Learning Points, Content Specifications, Suggested Reading, everything>",
"has_figure": false,
"page_reference": {page_ref}
}}
]}}
@ -57,7 +58,12 @@ CRITICAL RULES — follow exactly:
4. OPTIONS: Extract just the text for each option, without the letter prefix (A. B. C. D. E.).
5. Return ONLY the JSON no markdown fences, no explanation, no preamble.
5. HAS_FIGURE: Set to true ONLY if the question text explicitly references an image, figure, photograph,
radiograph, X-ray, ECG, rash photo, growth chart, or similar visual that is essential to answering.
Do NOT set true for decorative logos, page headers, publisher icons, or repeated branding images.
If the question can be answered from text alone, set false.
6. Return ONLY the JSON no markdown fences, no explanation, no preamble.
Content from page(s) {page_info}:
{content}"""
@ -92,8 +98,8 @@ def get_model_for_task(db, task: str = "extraction") -> tuple[str, str | None]:
).first()
if config:
return config.model_id, config.api_key
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to load AI model config for task '{task}', using fallback: {e}")
return settings.LITELLM_MODEL, settings.LITELLM_API_KEY or None

View file

@ -6,6 +6,7 @@ questions_only Extract Q+options with no answers. User fills answers later via
two_step Separate answer key section (PREP 2013): Phase 1 = questions, Phase 2 = key, Phase 3 = match.
regex AI generates a regex pattern for the document's answer format, then we apply it.
ai_decide AI samples the document and picks standard / two_step / questions_only.
generate AI reads plain text/study material and creates MCQ questions from scratch.
"""
from __future__ import annotations
import json
@ -34,6 +35,7 @@ Return ONLY JSON:
"question_text": "<full vignette + stem>",
"question_type": "mcq",
"options": ["<A>", "<B>", "<C>", "<D>", "<E>"],
"has_figure": false,
"page_reference": {page_ref}
}}
]}}
@ -42,6 +44,7 @@ Rules:
- A new question starts with "Item NNN" or "ltem NNN".
- Extract ALL questions even if no answer is visible.
- Do NOT include answer explanations or "Preferred Response:" content as questions.
- has_figure: true ONLY if the question references an image, figure, radiograph, photo, ECG, or chart essential to answering. false for decorative/branding images.
- Return ONLY JSON no markdown, no preamble.
Content (pages {page_info}):
@ -347,6 +350,92 @@ Return ONLY JSON:
"reasoning": "<one sentence>"}}"""
# ─── GENERATE FROM TEXT ──────────────────────────────────────────────────────
GENERATE_PROMPT = """You are a pediatric medical education expert. Read the text below and generate {n} high-quality multiple-choice questions to test comprehension.
Rules:
- The CORRECT answer must be directly supported by the text do not invent facts
- Create 3 plausible but incorrect distractors using your medical knowledge
- Questions should test understanding of concepts, not just exact word recall
- Prefer clinical application questions over pure recall when the content allows
- Include a 1-2 sentence explanation that cites the key concept from the text
- Spread questions across different parts of the text, not just the first section
- Each option should be a complete, standalone phrase (not "A", "B" labels)
Return ONLY valid JSON (no markdown, no preamble):
{{"questions": [
{{
"question_text": "<clear clinical or conceptual question>",
"question_type": "mcq",
"options": ["<correct answer text>", "<distractor B>", "<distractor C>", "<distractor D>"],
"correct_answer": "<exact text of the correct option>",
"explanation": "<1-2 sentence explanation>",
"page_reference": {page_ref}
}}
]}}
Text (pages {page_info}):
{content}"""
QUESTIONS_PER_CHUNK = 8 # target questions per ~50-page chunk
def generate_from_text(content: str, page_info: str, page_ref: int | None,
model_id: str | None, api_key: str | None,
n: int = QUESTIONS_PER_CHUNK) -> list[dict]:
"""Generate MCQ questions from plain text — correct answers from the document,
distractors from AI medical knowledge."""
content = ai_service._truncate_content(content)
prompt = GENERATE_PROMPT.format(
content=content,
page_info=page_info,
page_ref=page_ref if page_ref is not None else "null",
n=n,
)
for attempt in range(3):
try:
text = ai_service._call_model(prompt, model_id, api_key).strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
qs = data.get("questions", data) if isinstance(data, dict) else data
result = []
for q in qs:
if not q.get("question_text") or not q.get("correct_answer"):
continue
options = q.get("options") or []
correct = q["correct_answer"]
# Validate correct_answer is among options
if options and correct not in options:
# Try to find closest match
matches = [o for o in options if correct.lower() in o.lower() or o.lower() in correct.lower()]
if matches:
correct = matches[0]
else:
continue # skip malformed question
result.append({
"question_text": q["question_text"],
"question_type": q.get("question_type", "mcq"),
"options": options,
"correct_answer": correct,
"explanation": q.get("explanation", ""),
"page_reference": q.get("page_reference"),
"item_number": None,
})
if result:
return result
raise ValueError("No valid questions generated")
except Exception as e:
logger.warning(f"generate_from_text attempt {attempt + 1}: {e}")
raise RuntimeError("generate_from_text failed after 3 attempts")
# ─── AI DECIDES ──────────────────────────────────────────────────────────────
def ai_decide_strategy(
document_id: int,
section_start: int,

View file

@ -39,8 +39,17 @@ def extract_text_for_range(file_path: str, start: int, end: int) -> str:
return "\n\n".join(texts)
# MD5 hashes of known repeated branding images (logos, headers) to skip during extraction.
# These appear on every page of PREP PDFs and are not clinical images.
_SKIP_IMAGE_HASHES = {
"f48b094ec260f0aa8d7c52bc3cf562e4", # AAP logo (34300 bytes, appears 869 times across PREP PDFs)
"82c449d72791fe181fc9964bb8efad0f", # Sepsis document header/logo (20397 bytes, repeated per page)
}
def extract_images_from_page(file_path: str, page_num: int, document_id: int) -> list[str]:
"""Extract images from a specific page. Returns list of saved image paths."""
import hashlib
image_dir = os.path.join(settings.UPLOAD_DIR, "images", f"doc_{document_id}")
os.makedirs(image_dir, exist_ok=True)
@ -64,8 +73,18 @@ def extract_images_from_page(file_path: str, page_num: int, document_id: int) ->
image_ext = base_image.get("ext", "png")
image_bytes = base_image["image"]
# Skip tiny images (likely icons/bullets)
if len(image_bytes) < 1000:
# Skip tiny images (likely icons/bullets/decorations)
if len(image_bytes) < 2000:
continue
# Skip known branding/logo images by hash
if hashlib.md5(image_bytes).hexdigest() in _SKIP_IMAGE_HASHES:
continue
# Skip images with very small dimensions (banners, line art, icons)
w = base_image.get("width", 0)
h = base_image.get("height", 0)
if w > 0 and h > 0 and (w < 80 or h < 80):
continue
filename = f"page_{page_num}_img_{img_idx}.{image_ext}"

View file

@ -21,6 +21,9 @@ class LiteLLMEmbeddingFunction(EmbeddingFunction):
content=_json.dumps(body),
timeout=60,
)
if resp.status_code != 200:
import logging
logging.getLogger(__name__).error(f"Embedding API error: model={model}, inputs={len(input)}, status={resp.status_code}, body={resp.text[:500]}")
resp.raise_for_status()
return [item["embedding"] for item in resp.json()["data"]]
@ -49,8 +52,9 @@ def delete_collection(document_id: int):
client = get_client()
try:
client.delete_collection(name=f"doc_{document_id}")
except Exception:
pass
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to delete vector collection doc_{document_id}: {e}")
def chunk_text(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
@ -82,14 +86,17 @@ def store_pages(document_id: int, pages: dict[int, str]):
all_docs.append(chunk)
all_metadatas.append({"page_num": page_num, "document_id": document_id})
# ChromaDB has a batch limit; add in batches of 500
batch_size = 500
# ChromaDB sends each batch to embedding API in one call — keep under provider limit
import time as _time
batch_size = 100
for i in range(0, len(all_ids), batch_size):
collection.add(
ids=all_ids[i:i + batch_size],
documents=all_docs[i:i + batch_size],
metadatas=all_metadatas[i:i + batch_size],
)
if i + batch_size < len(all_ids):
_time.sleep(3) # rate limit buffer between batches
def query_pages(

View file

@ -1,46 +1,75 @@
import logging
import json
import time
from app.tasks import celery_app
from app.database import SessionLocal
from app.models.pdf_document import PDFDocument
from app.services import pdf_service, vector_service
from app.config import settings
logger = logging.getLogger(__name__)
def _redis():
import redis
return redis.from_url(settings.REDIS_URL, decode_responses=True)
def _push_step(r, doc_id: int, step: str, message: str):
r.rpush(f"pdf:steps:{doc_id}", json.dumps({"step": step, "message": message, "ts": time.time()}))
r.expire(f"pdf:steps:{doc_id}", 3600)
@celery_app.task(name="process_pdf")
def process_pdf(document_id: int, file_path: str):
"""Background task: extract PDF text and store in ChromaDB."""
db = SessionLocal()
r = _redis()
try:
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
if not doc:
logger.error(f"Document {document_id} not found")
return
_push_step(r, document_id, "start", "Starting PDF processing…")
# Get page count
total_pages = pdf_service.get_page_count(file_path)
doc.total_pages = total_pages
db.commit()
_push_step(r, document_id, "pages", f"Found {total_pages} pages")
# Extract text from all pages
_push_step(r, document_id, "text", "Extracting text from pages…")
pages = pdf_service.extract_text_by_page(file_path)
if not pages:
doc.status = "error"
doc.error_message = "No text could be extracted from the PDF"
db.commit()
_push_step(r, document_id, "error", "No text could be extracted")
return
_push_step(r, document_id, "text", f"Extracted text from {len(pages)} pages")
# Store in ChromaDB
total_chunks = 0
for page_num, text in pages.items():
chunks = vector_service.chunk_text(text)
total_chunks += len(chunks)
_push_step(r, document_id, "vector", f"Vectorizing {total_chunks} chunks across {len(pages)} pages…")
vector_service.store_pages(document_id, pages)
_push_step(r, document_id, "vector", "Vectorization complete")
# Mark as ready
doc.status = "ready"
db.commit()
_push_step(r, document_id, "done", f"Ready — {total_pages} pages, {len(pages)} with text")
logger.info(f"Document {document_id} processed: {total_pages} pages, {len(pages)} with text")
except Exception as e:
logger.exception(f"Error processing document {document_id}")
_push_step(r, document_id, "error", f"Failed: {str(e)[:200]}")
try:
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
if doc:

View file

@ -106,7 +106,7 @@ def extract_quiz(
if extraction_mode != "standard":
from app.services.extraction_modes import (
extract_questions_only, extract_two_step,
extract_with_regex, ai_decide_strategy,
extract_with_regex, ai_decide_strategy, generate_from_text,
)
resolved_mode = extraction_mode
@ -156,6 +156,25 @@ def extract_quiz(
chunk_pages=CHUNK_PAGES,
)
elif resolved_mode == "generate":
_push_step(r, job_id, "ai", "Mode: Generate — AI creates questions from the text.")
for chunk_idx, (start_p, end_p) in enumerate(chunks, 1):
if r.get(f"extraction:status:{job_id}") == "cancelled":
_push_step(r, job_id, "cancelled", "Job cancelled.")
return
_push_step(r, job_id, "ai", f"Chunk {chunk_idx}/{n_chunks}: pages {start_p}{end_p}")
chunk_content = vector_service.get_pages_text(
document_id=section.document_id, start_page=start_p, end_page=end_p)
if not chunk_content:
continue
try:
qs = generate_from_text(_normalize_ocr(chunk_content),
f"{start_p}-{end_p}", start_p, model_id, api_key)
all_valid_questions.extend(qs)
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p}: {len(qs)} questions generated. Total: {len(all_valid_questions)}.")
except Exception as e:
_push_step(r, job_id, "ai", f" Pages {start_p}{end_p} failed: {e}")
else:
# ai_decide resolved to standard — fall through to standard loop below
extraction_mode = "standard"
@ -203,6 +222,18 @@ def extract_quiz(
if not valid_questions:
raise ValueError("No valid questions extracted. The AI could not find questions with correct answers in this page range.")
# Refresh DB connection — it may have gone stale during long LLM extraction
from sqlalchemy import text as _text
try:
db.execute(_text("SELECT 1"))
except Exception:
db.rollback()
db.close()
db = SessionLocal()
# Re-fetch objects that were bound to the old session
section = db.query(Section).filter(Section.id == section_id).first()
document = db.query(PDFDocument).filter(PDFDocument.id == section.document_id).first()
# Extract images
_push_step(r, job_id, "images", "Extracting question images…")
file_path = os.path.join(settings.UPLOAD_DIR, document.filename)
@ -233,7 +264,8 @@ def extract_quiz(
for pos, q in enumerate(valid_questions):
page_ref = q.get("page_reference")
image_path = None
if page_ref and page_ref in page_images and page_images[page_ref]:
# Only link an image if the AI flagged the question as having a figure
if q.get("has_figure") and page_ref and page_ref in page_images and page_images[page_ref]:
image_path = page_images[page_ref].pop(0)
if not page_images[page_ref]:
del page_images[page_ref]
@ -275,6 +307,157 @@ def extract_quiz(
db.close()
CLASSIFY_EXPIRE = 3600
def _push_classify_step(r, job_id: str, step: str, message: str):
key = f"classify:steps:{job_id}"
entry = json.dumps({"step": step, "message": message, "ts": time.time()})
r.rpush(key, entry)
r.expire(key, CLASSIFY_EXPIRE)
@celery_app.task(name="classify_questions", bind=True)
def classify_questions(self, job_id: str, user_id: int):
"""Classify untagged questions using AI — subjects, diseases, keywords."""
r = _redis()
r.set(f"classify:status:{job_id}", "running", ex=CLASSIFY_EXPIRE)
db = SessionLocal()
try:
from app.models.question import Question
from app.services import ai_service
from sqlalchemy import text as sa_text
_push_classify_step(r, job_id, "start", "Finding untagged questions...")
# Get IDs of questions that already have tags
tagged_ids_rows = db.execute(sa_text(
"SELECT DISTINCT question_id FROM question_tag_links"
)).fetchall()
tagged_ids = {row[0] for row in tagged_ids_rows}
# Get all questions not yet tagged
all_questions = db.query(Question).all()
untagged = [q for q in all_questions if q.id not in tagged_ids]
if not untagged:
_push_classify_step(r, job_id, "done", "All questions are already tagged.")
r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE)
return
total = len(untagged)
_push_classify_step(r, job_id, "start", f"Found {total} untagged questions. Starting classification...")
# Get AI model for keyword task
model_id, api_key = ai_service.get_model_for_task(db, "keyword")
batch_size = 10
classified = 0
for i in range(0, total, batch_size):
if r.get(f"classify:status:{job_id}") == "cancelled":
_push_classify_step(r, job_id, "cancelled", "Job cancelled.")
return
batch = untagged[i:i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (total + batch_size - 1) // batch_size
_push_classify_step(r, job_id, "progress",
f"Batch {batch_num}/{total_batches}: classifying {len(batch)} questions...")
# Build questions JSON for prompt
questions_json = json.dumps([
{"id": q.id, "question_text": q.question_text[:500]}
for q in batch
], indent=2)
prompt = f"""Classify each medical question below. For each question, provide:
- subjects: 1-3 medical subjects/specialties (e.g., "Cardiology", "Infectious Disease", "Neonatology")
- diseases: 1-3 specific diseases/conditions mentioned (e.g., "Kawasaki Disease", "Pneumonia", "Type 1 Diabetes")
- keywords: 2-4 key clinical concepts (e.g., "fever workup", "antibiotic resistance", "fluid management")
Return ONLY JSON:
{{"classifications": [
{{"id": <question_id>, "subjects": [...], "diseases": [...], "keywords": [...]}}
]}}
Questions:
{questions_json}"""
try:
raw = ai_service._call_model(prompt, model_id, api_key)
# Parse JSON from response
text = raw.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
data = json.loads(text)
classifications = data.get("classifications", [])
# Store tags
for cls in classifications:
q_id = cls.get("id")
if not q_id:
continue
for tag_type, tag_list in [("subject", cls.get("subjects", [])),
("disease", cls.get("diseases", [])),
("keyword", cls.get("keywords", []))]:
for tag_name in tag_list:
if not tag_name or not isinstance(tag_name, str):
continue
normalized = tag_name.strip().title()
if not normalized:
continue
# Insert tag (ON CONFLICT DO NOTHING for case-insensitive uniqueness)
db.execute(sa_text("""
INSERT INTO question_tags (name, type)
VALUES (:name, :type)
ON CONFLICT (LOWER(name), type) DO NOTHING
"""), {"name": normalized, "type": tag_type})
db.flush()
# Get the tag ID
tag_row = db.execute(sa_text("""
SELECT id FROM question_tags
WHERE LOWER(name) = LOWER(:name) AND type = :type
"""), {"name": normalized, "type": tag_type}).fetchone()
if tag_row:
db.execute(sa_text("""
INSERT INTO question_tag_links (question_id, tag_id)
VALUES (:qid, :tid)
ON CONFLICT DO NOTHING
"""), {"qid": q_id, "tid": tag_row[0]})
db.commit()
classified += len(batch)
_push_classify_step(r, job_id, "progress",
f"Batch {batch_num}/{total_batches} done. {classified}/{total} classified.")
except Exception as e:
logger.warning(f"Classification batch {batch_num} failed: {e}")
_push_classify_step(r, job_id, "progress",
f"Batch {batch_num} failed: {e}. Continuing...")
continue
_push_classify_step(r, job_id, "done", f"Classification complete. {classified}/{total} questions classified.")
r.set(f"classify:status:{job_id}", "completed", ex=CLASSIFY_EXPIRE)
except Exception as e:
logger.exception(f"Classification failed for job {job_id}")
_push_classify_step(r, job_id, "error", f"Failed: {e}")
r.set(f"classify:status:{job_id}", "failed", ex=CLASSIFY_EXPIRE)
r.set(f"classify:error:{job_id}", str(e)[:500], ex=CLASSIFY_EXPIRE)
raise
finally:
db.close()
@celery_app.task(name="regenerate_embeddings", bind=True)
def regenerate_embeddings(self, job_id: str, user_id: int):
"""Regenerate embeddings for all questions using the current embedding model."""

View file

@ -18,6 +18,8 @@ services:
frontend:
build: ./frontend
env_file:
- ./frontend/.env
ports:
- "127.0.0.1:8081:80"
depends_on:

19
docs/README.md Normal file
View file

@ -0,0 +1,19 @@
# PedsHub Documentation
Deep technical documentation for the PedsHub pediatric learning platform.
## Pages
| Document | Description |
|----------|-------------|
| [Architecture](architecture.md) | System overview, database schema, multi-worker setup, vector search, auth, rate limiting, concurrent quiz protection |
| [API Reference](api-reference.md) | Every endpoint: method, path, auth, request/response, notes |
| [Services](services.md) | Backend service layer: AI extraction, embedding, vector search, PDF processing, email, reminders |
| [Frontend](frontend.md) | React app structure, pages, components, state patterns, runtime config |
| [Deployment](deployment.md) | Docker setup, environment variables, HTTPS, rebuilding, monitoring, troubleshooting, scaling |
## Quick Links
- **Main README**: [../README.md](../README.md)
- **Backend .env template**: [../backend/.env.example](../backend/.env.example)
- **Docker Compose**: [../docker-compose.yml](../docker-compose.yml)

1051
docs/api-reference.md Normal file

File diff suppressed because it is too large Load diff

533
docs/architecture.md Normal file
View file

@ -0,0 +1,533 @@
# 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 <<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)."

446
docs/deployment.md Normal file
View file

@ -0,0 +1,446 @@
# PedsHub Deployment and Operations Guide
## Docker Compose Setup
The application runs as 5 services defined in `docker-compose.yml`:
| Service | Image | Purpose | Port |
|---------|-------|---------|------|
| `postgres` | `pgvector/pgvector:pg16` | PostgreSQL with pgvector extension | Internal only |
| `redis` | `redis:7-alpine` | Caching, quiz progress, job tracking, Celery broker | Internal only |
| `backend` | Built from `./backend` | FastAPI app (uvicorn, 4 workers) | Internal only |
| `celery` | Built from `./backend` (same image) | Celery worker (concurrency=2) | None |
| `frontend` | Built from `./frontend` | Nginx serving React SPA | `127.0.0.1:8081:80` |
### Volumes
| Volume | Used by | Purpose |
|--------|---------|---------|
| `postgres_data` | postgres | Database persistence |
| `redis_data` | redis | Redis persistence |
| `uploads_data` | backend, celery | Uploaded PDF files and extracted images |
| `chroma_data` | backend, celery | ChromaDB vector store |
### Health Checks
- **postgres**: `pg_isready -U pedquiz` every 5s, 10 retries.
- Backend and celery depend on postgres being healthy (`condition: service_healthy`) and redis being started.
### Startup Order
1. Redis and Postgres start first.
2. Backend and Celery wait for Postgres to be healthy.
3. Frontend waits for backend to start.
---
## Environment Variables
### Backend (`backend/.env`)
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `sqlite:///./quiz.db` | PostgreSQL connection string. Production: `postgresql://user:pass@postgres:5432/pedquiz` |
| `SECRET_KEY` | `change-me-...` | JWT signing key. Generate with `openssl rand -hex 32` |
| `ALGORITHM` | `HS256` | JWT algorithm |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | Token lifetime (default 24 hours) |
| `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL |
| `LITELLM_MODEL` | `gpt-4o-mini` | Default LLM model for extraction |
| `LITELLM_API_KEY` | (empty) | API key for LiteLLM proxy or provider |
| `LITELLM_API_BASE` | (empty) | Custom API base URL (for LiteLLM proxy) |
| `LITELLM_EMBEDDING_MODEL` | (empty) | Model for semantic search embeddings |
| `OPENAI_API_KEY` | (empty) | OpenAI API key (if using OpenAI models directly) |
| `ELEVENLABS_API_KEY` | (empty) | ElevenLabs API key for TTS voices |
| `GOOGLE_TTS_API_KEY` | (empty) | Google Cloud TTS API key |
| `AWS_ACCESS_KEY_ID` | (empty) | AWS credentials for Bedrock / Polly |
| `AWS_SECRET_ACCESS_KEY` | (empty) | AWS secret key |
| `AWS_REGION` | `us-east-1` | AWS region |
| `AWS_BEDROCK_REGION` | `us-east-1` | AWS Bedrock region |
| `EMBEDDING_DIMENSIONS` | `1024` | Vector embedding dimension size |
| `CHROMA_PERSIST_DIR` | `/app/chroma_data` | ChromaDB storage directory |
| `MAIL_USERNAME` | (empty) | SMTP username |
| `MAIL_PASSWORD` | (empty) | SMTP password |
| `MAIL_FROM` | (empty) | From address for emails |
| `MAIL_PORT` | `587` | SMTP port |
| `MAIL_SERVER` | `smtp.gmail.com` | SMTP server hostname |
| `MAIL_STARTTLS` | `true` | Use STARTTLS |
| `MAIL_SSL_TLS` | `false` | Use SSL/TLS |
| `UPLOAD_DIR` | `/app/uploads` | Upload storage directory |
| `MAX_UPLOAD_SIZE` | `524288000` | Max upload size in bytes (500MB) |
| `APP_URL` | `https://quiz.danvics.com` | Public URL (used in emails, links) |
| `TURNSTILE_SECRET_KEY` | (empty) | Cloudflare Turnstile secret key. Leave blank to disable captcha |
| `ADMIN_EMAIL` | (empty) | Email address for contact form submissions |
### Frontend (`frontend/.env`)
| Variable | Description |
|----------|-------------|
| `TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key (public). Injected at runtime via `docker-entrypoint.sh` into `window.__APP_CONFIG__` |
---
## HTTPS Setup with Caddy
The frontend binds to `127.0.0.1:8081` (not exposed externally). Use a reverse proxy for HTTPS.
Example Caddyfile:
```caddyfile
quiz.example.com {
reverse_proxy localhost:8081
}
```
Caddy handles automatic HTTPS certificate provisioning via Let's Encrypt. No additional TLS configuration is needed.
For setups where the backend needs to be accessed directly (rare):
```caddyfile
quiz.example.com {
reverse_proxy localhost:8081
# Optional: direct backend access for debugging
handle_path /api-direct/* {
reverse_proxy localhost:8000
}
}
```
Note: The frontend's Nginx already proxies `/api` to the backend container internally, so you only need to expose the frontend port.
---
## Rebuilding
### When to Rebuild vs Restart
| Change | Action |
|--------|--------|
| Backend Python code | Rebuild backend: `docker compose build backend` |
| Frontend source code | Rebuild frontend: `docker compose build frontend` |
| Backend `.env` changes | Restart only: `docker compose restart backend celery` |
| Frontend `.env` changes | Restart only: `docker compose restart frontend` |
| `requirements.txt` changes | Rebuild backend: `docker compose build backend` |
| `package.json` changes | Rebuild frontend: `docker compose build frontend` |
| `docker-compose.yml` changes | `docker compose up -d` (recreates changed services) |
### Celery shares the backend image
The `celery` service builds from `./backend` — the same Dockerfile as `backend`. When you rebuild `backend`, you must also rebuild `celery`:
```bash
docker compose build backend
docker compose up -d backend celery
```
Or build both explicitly:
```bash
docker compose build backend celery
docker compose up -d
```
### Stale code / cache issues
If code changes are not reflected after a rebuild, use `--no-cache`:
```bash
docker compose build --no-cache backend
docker compose build --no-cache frontend
```
This forces Docker to re-run all build steps, including `pip install` and `npm ci`.
---
## Database
### PostgreSQL with pgvector
The database uses `pgvector/pgvector:pg16` which includes the `vector` extension. On startup, the backend runs:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024);
CREATE INDEX IF NOT EXISTS questions_embedding_hnsw
ON questions USING hnsw (embedding vector_cosine_ops);
```
### Connection Pool
SQLAlchemy engine is configured with:
```python
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True, pool_recycle=300)
```
- `pool_pre_ping=True` — Tests connections before use. Prevents "connection closed" errors after database restarts.
- `pool_recycle=300` — Recycles connections every 5 minutes. Prevents stale connections in long-running processes (Celery workers).
### Backups
```bash
# Dump the database
docker compose exec postgres pg_dump -U pedquiz pedquiz > backup_$(date +%Y%m%d).sql
# Restore from backup
docker compose exec -T postgres psql -U pedquiz pedquiz < backup_20240101.sql
```
For automated backups, add a cron job on the host:
```bash
0 2 * * * cd /home/danvics/docker/quiz && docker compose exec -T postgres pg_dump -U pedquiz pedquiz | gzip > /backups/pedquiz_$(date +\%Y\%m\%d).sql.gz
```
---
## Monitoring
### Checking Logs
```bash
# Backend logs (FastAPI + uvicorn)
docker compose logs -f backend
# Celery worker logs (extraction jobs, embeddings)
docker compose logs -f celery
# All services
docker compose logs -f
# Last 100 lines
docker compose logs --tail=100 backend
```
### Common Errors and Fixes
| Error | Cause | Fix |
|-------|-------|-----|
| `connection already closed` | Stale DB connection in Celery | Already handled by `pool_pre_ping` and `pool_recycle`. If persistent, restart celery |
| `lock timeout` on startup | Previous killed process holding DDL locks | Backend auto-terminates stale connections and retries 3 times with 5s delay |
| `429 / rate limit` from LLM API | Too many extraction requests | Reduce celery concurrency or add rate limiting config |
| `embedding failed` | Embedding API rate limit or model issue | Check LITELLM_EMBEDDING_MODEL config, verify API key |
| `CORS error` in browser | Frontend not going through Nginx proxy | Ensure requests go through the frontend's Nginx (which proxies /api) |
| `502 Bad Gateway` | Backend not ready yet | Wait for backend to start, check `docker compose logs backend` |
---
## Troubleshooting
### Lock Timeout on Startup
The backend runs DDL migrations (ALTER TABLE, CREATE INDEX) at startup. If a previous instance was killed while holding a lock, the new instance will hang. The startup code handles this automatically:
1. Terminates stale `idle in transaction` connections older than 30 seconds.
2. Sets `lock_timeout = '15s'` before running DDL.
3. Retries up to 3 times with 5-second delays if a lock timeout occurs.
If this still fails, manually kill stale connections:
```bash
docker compose exec postgres psql -U pedquiz -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle in transaction' AND pid <> pg_backend_pid();"
```
### Singleton Lock for Startup Tasks
The backend uses a Redis-based singleton lock (`startup:singleton_lock`, 5-minute TTL) to ensure that only one of the 4 uvicorn workers runs the scheduler and backfill tasks. If Redis is unavailable, it assumes single-worker mode and runs everything.
### Stale DB Connections in Celery Tasks
Celery workers are long-running processes. Without `pool_recycle=300`, connections can go stale (especially after postgres restarts). The `pool_pre_ping=True` setting validates connections before use. If you see database errors in celery logs after a postgres restart, restart the celery service:
```bash
docker compose restart celery
```
### Embedding Batch Size / Rate Limits
Embedding generation happens during quiz extraction and on startup (backfill). If the embedding API has rate limits, you may see failures in the celery logs. The embedding service handles individual failures gracefully (logs and continues). To re-embed all questions:
```bash
docker compose exec backend python manage.py reembed
```
### Docker Build Cache Issues
If you update Python dependencies or JS packages but the build uses cached layers:
```bash
# Force fresh install of all dependencies
docker compose build --no-cache backend frontend
docker compose up -d
```
---
## Scaling
### Worker Counts
- **Backend (uvicorn)**: Runs with `--workers 4`. For higher throughput, increase in `docker-compose.yml`. Each worker runs the startup lifespan (idempotent DDL + singleton lock for scheduler).
- **Celery**: Runs with `--concurrency=2`. This controls how many extraction jobs run in parallel. Increase for faster throughput, but watch LLM API rate limits.
### Redis Memory
Redis stores:
- Quiz progress (per-user, per-quiz)
- Extraction job status and steps
- Celery broker messages
- Singleton lock
- User job lists
For most deployments, default Redis memory is sufficient. Monitor with:
```bash
docker compose exec redis redis-cli info memory
```
### ChromaDB Storage
ChromaDB stores question embeddings for semantic search. Storage grows with the number of questions. The `chroma_data` volume persists this data. Monitor disk usage:
```bash
docker system df -v | grep chroma
```
---
## Updating
### Standard Update Flow
```bash
cd /home/danvics/docker/quiz
git pull
docker compose build backend frontend
docker compose up -d
```
### Migration Safety
Database migrations run automatically on startup via `Base.metadata.create_all()` and explicit DDL in `setup_pgvector()`. These are idempotent:
- `CREATE TABLE IF NOT EXISTS`
- `ADD COLUMN IF NOT EXISTS`
- `CREATE INDEX IF NOT EXISTS`
DDL statements use `SET lock_timeout = '15s'` and retry up to 3 times to handle concurrent lock contention from Celery workers or other uvicorn processes.
### Zero-Downtime Updates
For zero-downtime updates (if needed):
```bash
# Build new images first
docker compose build backend frontend
# Restart one service at a time
docker compose up -d --no-deps backend
docker compose up -d --no-deps celery
docker compose up -d --no-deps frontend
```
---
## CLI Tools
The `manage.py` script provides management commands. Run inside the backend container:
```bash
docker compose exec backend python manage.py <command>
```
### Commands
#### `reset-password <email> <new-password>`
Reset a user's password. Password must be at least 8 characters.
```bash
docker compose exec backend python manage.py reset-password user@example.com newpassword123
```
#### `list-users`
List all users with ID, email, role, verified status, and name.
```bash
docker compose exec backend python manage.py list-users
```
Output:
```
ID Email Role Verified Name
------------------------------------------------------------------------------------------
1 admin@example.com admin yes Admin
2 user@example.com user yes John
```
#### `reembed`
Regenerate all question embeddings. Useful after changing the embedding model. Clears existing embeddings and re-generates them one by one.
```bash
docker compose exec backend python manage.py reembed
```
#### `extract <section_id> [options]`
Run quiz extraction from a section. Can run inline (blocking) or in background (via Celery).
```bash
# Inline extraction (blocking, shows live output)
docker compose exec backend python manage.py extract 5
# With options
docker compose exec backend python manage.py extract 5 --title "Chapter 3 Quiz" --mode learning --user admin@example.com
# Background (via Celery)
docker compose exec backend python manage.py extract 5 --bg
```
Options:
- `--title` — Quiz title (default: auto-generated from section name)
- `--mode``timed` or `learning` (default: `timed`)
- `--user` — User email to assign the quiz to (default: first admin user)
- `--bg` — Run in background via Celery
#### `list-sections [doc_id]`
List all documents and their sections. Optionally filter by document ID.
```bash
# All documents
docker compose exec backend python manage.py list-sections
# Specific document
docker compose exec backend python manage.py list-sections 3
```
Output:
```
Doc 3: PREP_2024.pdf (ready, 450 pages)
Section 5: 'Chapter 1' pages 1-50
Section 6: 'Chapter 2' pages 51-120
```
#### `jobs [--user email]`
Show recent extraction jobs from Redis. Optionally filter by user email.
```bash
# All users' jobs
docker compose exec backend python manage.py jobs
# Specific user
docker compose exec backend python manage.py jobs --user admin@example.com
```
Output:
```
admin@example.com:
[completed ] Quiz: Chapter 1 steps= 12 quiz_id=7
Saved 25 questions to quiz
[running ] Quiz: Chapter 2 steps= 5
Extracting questions from pages 51-80...
```

341
docs/frontend.md Normal file
View file

@ -0,0 +1,341 @@
# PedsHub Frontend Documentation
## Architecture Overview
The frontend is a React SPA built with Vite and served via Nginx. It communicates with the backend exclusively through `/api` (proxied by Nginx to the backend container).
### Entry Point
`main.jsx` renders `<App />` inside `<React.StrictMode>`.
`App.jsx` provides the top-level provider hierarchy:
```
BrowserRouter
ThemeProvider
AuthProvider
AppRoutes
```
### Routing
`AppRoutes` reads `useAuth()` to decide what to render:
| Route | Component | Access |
|---|---|---|
| `/home` | LandingPage | Public (always) |
| `/login` | LoginPage | Public (redirects to `/` if logged in) |
| `/register` | RegisterPage | Public (redirects to `/` if logged in) |
| `/verify-email` | VerifyEmailPage | Public |
| `/forgot-password` | ForgotPasswordPage | Public |
| `/reset-password` | ResetPasswordPage | Public |
| `/` | DashboardPage | Authenticated |
| `/quizzes` | QuizzesPage | Authenticated |
| `/quizzes/:id` | QuizPage | Authenticated |
| `/results/:id` | ResultsPage | Authenticated |
| `/documents/:id` | DocumentDetailPage | Authenticated |
| `/account` | AccountPage | Authenticated |
| `/settings` | SettingsPage | Authenticated |
| `/question-bank` | QuestionBankPage | Authenticated |
| `/admin` | AdminPage | Authenticated |
| `/upload` | UploadPage | Moderator only |
| `/quizzes/:id/edit` | QuizEditPage | Moderator only |
| `/jobs` | JobsPage | Moderator only |
| `/trash` | TrashPage | Moderator only |
| `*` | NotFoundPage or redirect to `/home` | Catch-all |
Unauthenticated users hitting any protected route are redirected to `/home`. Non-moderators hitting moderator routes are redirected to `/`.
`AppLayout` wraps authenticated routes with `<Navbar />`, a container div, and a footer.
---
## Context Providers
### AuthContext (`context/AuthContext.jsx`)
Provides `{ user, loading, login, loginWithToken, logout }`.
- On mount, checks `localStorage.getItem('token')` and calls `GET /auth/me` to hydrate the user object.
- `login(email, password, turnstileToken)` posts to `/auth/login`, stores the JWT, then fetches `/auth/me`.
- `loginWithToken(token)` stores a token directly (used after registration when verification is not required).
- `logout()` clears the token and sets user to null.
### ThemeContext (`context/ThemeContext.jsx`)
Provides `{ theme, setTheme }`.
- Persists the active theme name in `localStorage` under key `pedquiz_theme`.
- Applies the theme by setting `data-theme` attribute on `document.body`.
---
## Page Descriptions
### LandingPage (`/home`)
The public-facing marketing page. Sections:
1. **Hero** - Gradient background with tagline "Turn your textbooks into tests". Shows Sign In / Register buttons for guests, or Dashboard / My Quizzes / Question Bank links for logged-in users.
2. **Features** - 6 feature cards (Quiz from Any PDF, AI Tutor, Audio Mode, Question Bank, Track Your Progress, Fast by Design) rendered from a static `FEATURES` array. Cards have hover lift animations.
3. **AI Scribe cross-sell** - Promotes the sibling PedsHub AI Scribe product (links to `https://peds.danvics.com`).
4. **Contact form** - `ContactForm` component with name, email, type selector (Question or Apply as Moderator), and message. Validates required fields. Includes a `TurnstileWidget` for Cloudflare captcha. Posts to `POST /contact`.
5. **Auth modal** - `AuthModal` component rendered as an overlay with Sign In / Register tabs. Handles login, registration, email verification resend, and unverified-email warnings. The Turnstile widget is embedded inside the modal. The Navbar receives `onSignIn` and `onRegister` callbacks to open the modal.
**Turnstile integration**: The site key comes from `window.__APP_CONFIG__.TURNSTILE_SITE_KEY`. The Turnstile script is loaded dynamically once. `TurnstileWidget` retries rendering with a 200ms polling loop until `window.turnstile` is available.
### DashboardPage (`/`)
The authenticated home screen. Loads three API calls in parallel on mount:
- `GET /documents/` (moderators only, otherwise skipped)
- `GET /attempts/stats/dashboard` (total quizzes, attempts, avg score, optionally documents)
- `GET /attempts/history` (attempt history grouped by quiz)
Features:
- **Greeting** - Time-of-day greeting using the user's first name.
- **Stats cards** - Grid of stat cards (Documents for moderators, Quizzes, Attempts, Avg Score).
- **Performance chart** - `LineChart` SVG component showing score trend for a selected quiz. Dropdown to switch between quizzes. Shows latest score, best score, and a retake button. Warns if latest score is below 75%.
- **Attempt history** - Listed under the chart. Each attempt row shows date, score link to results, and inline delete with two-click confirmation (`confirmAttempt` state pattern).
- **Documents list** - Moderator-only card listing uploaded PDFs with status badges and an Upload PDF button.
### QuizzesPage (`/quizzes`)
Loads quizzes and categories in parallel on mount.
Features:
- **Search bar** - Debounced search (350ms) across quizzes and questions. Three filter modes: All, Title only, Questions only. Results show quiz title matches and matching question snippets with `HighlightText` highlighting.
- **Expanded search** - Toggle to view all matching questions in a flat list. Each question has a Study button.
- **QuestionStudyModal** - Interactive modal that lets you answer a question, shows correct/incorrect feedback, and displays the explanation. Includes a lazy-loaded `TeachChat` with the `elevated` prop for higher z-index (above the modal).
- **In-progress quizzes** (`InProgressSection`) - Shows quizzes the user started but hasn't finished. Resume and Delete buttons.
- **Past attempts** (`PastAttemptsSection`) - Collapsible section, loads on demand. Delete buttons on each attempt.
- **Quiz grid** - Quizzes grouped by category (moderator-created categories). `QuizCard` component shows title, question count, mode badge (Learning/Timed), time limit. Hover lift effect.
- **Moderator controls** - Edit link, publish/unpublish toggle, category assignment dropdown, trash button with `ConfirmButton`.
- **Category management** - Moderators can add categories inline, delete categories, and see quiz counts per category.
### QuizPage (`/quizzes/:id`)
The core quiz-taking experience. Two phases: mode selection, then quiz.
**Mode selection** (`ModeSelectScreen`):
- Study Mode: answers and explanations shown as you go.
- Exam Mode: answers hidden until submitted.
- Optional timer input (minutes) for exam mode.
- TTS voice selector (if voices are configured).
**Quiz phase**:
- **Concurrent device lock** - A unique `SESSION_ID` is generated per tab (`Math.random() + Date.now()`). Sent as `x-quiz-session` header when fetching/saving progress. The backend returns 409 if the quiz is active on another device.
- **Auto-resume** - On load, fetches `GET /attempts/progress?quiz_id=...`. If saved progress exists, calls `resumeQuiz()` which restores mode, answers, current index, timer state, and voice selection. For study mode, re-fetches the quiz with `?study=true` to get correct answers.
- **Timer** - Uses the `timerStarted` pattern: `const timerStarted = timeLeft !== null` declared outside the effect, used as a dependency. The timer is calculated from `started_at + total_time`, so it survives page reloads. Auto-submits when `timeLeft` reaches 0.
- **Progress save to Redis** - Debounced at 1.5 seconds. Saves `quiz_id, attempt_id, answers, current_idx, mode, voice, time_left, started_at, total_time` via `POST /attempts/progress`. Uses the session ID header.
- **Answer tracking** - `answers` state is a `{ [questionId]: selectedOption }` object. `setAnswer` merges into the object.
- **Navigation** - Previous/Next buttons, question number dots (desktop sidebar + mobile collapsible grid). `QuestionDot` shows active (primary), answered (green), and unanswered (gray) states.
- **TTS** - `TTSButton` component calls `POST /tts/speak` to get audio blob, creates `Audio` object for playback. Voice selector disabled while audio is playing.
- **Favorites** - Star toggle on each question, calls `POST /favorites` or `DELETE /favorites/:id`.
- **Suspend** - "Suspend" button shows a confirmation dialog. Progress is already saved; timer warning shown for timed quizzes. Navigates to dashboard on confirm.
- **beforeunload** - Browser tab close warning when mid-quiz.
- **AI Tutor** - `TeachChat` lazy-loaded in study mode only.
### ResultsPage (`/results/:id`)
Shows quiz results. Accepts `location.state.result` for instant display after submission, or fetches `GET /attempts/:id` if navigated directly.
Features:
- **Score card** - Large percentage display with color coding (green >= 75, yellow >= 50, red < 50). Score bar visualization. Contextual message.
- **Summary row** - Correct, Incorrect, and Skipped counts with colored backgrounds.
- **Question review** - Full review of each question with:
- Correct/Incorrect/Skipped badge
- Option display with letter badges, color-coded (correct = green, user's wrong answer = red)
- Fill-in-the-blank display for non-MCQ
- Explanation section
- **Actions** - Retake Quiz, All Quizzes, Dashboard, Delete Attempt (with two-click confirmation).
### QuestionBankPage (`/question-bank`)
The central question repository with advanced filtering.
Features:
- **Search modes** - Three modes available: `keyword` (text search), `semantic` (vector similarity), `hybrid` (combines both). Controlled by `searchMode` state, sent as `search_mode` param.
- **Multi-category filter** - `filterCatIds` array state. Toggling a category adds/removes its ID. Uses `catIdsKey = filterCatIds.join(',')` as a useEffect dependency (avoids reference equality issues with arrays).
- **Tag browser** (`TagBrowser` component) - Three sections: Subjects, Diseases, Keywords. Each section has its own search input. Tags shown as clickable buttons with counts. Selected tags turn colored. `selectedTagIds` array tracked similarly with `tagIdsKey = selectedTagIds.join(',')`.
- **Debounced loading** - 300ms debounce on search query, category, tag, favorites, uncategorized, and page size changes.
- **Pagination** - Configurable page size (25, 50, 100, All). Load more button appends to existing questions.
- **Favorites filter** - Toggle to show only favorited questions.
- **Uncategorized filter** - Toggle to show only questions without a category.
- **Bulk operations** - Checkbox selection on questions. "Select All" fetches ALL matching IDs from `GET /questions/bank/ids` (not just the loaded page). Bulk assign category via `POST /questions/bulk-category`.
- **Create quiz from selection** - `CreateQuizModal` lets you create a quiz from selected questions or from all questions in a category. Choose title, mode (Timed/Learning), and optional time limit.
- **Question study modal** - Same interactive study modal as QuizzesPage with TeachChat (elevated z-index).
- **Question edit modal** (`QuestionEditModal`) - Moderators can edit question text, options (with radio button for correct answer), category, and explanation. Warning that changes apply to all quizzes sharing the question.
- **Tag classification** - Moderators can trigger AI classification (`POST /tags/classify`). Progress is polled every 2 seconds. Tags reload on completion.
- **Category management** - Add new categories inline. Delete categories with optional move-to target.
### DocumentDetailPage (`/documents/:id`)
Moderator-focused document management page.
Features:
- **Document info** - Filename, page count, upload date, status badge. Delete document button.
- **Processing progress** - When `doc.status === 'processing'`, polls `GET /documents/:id/status` and `GET /documents/:id/processing-steps` every 2 seconds. Shows step-by-step progress (text extraction, image extraction, etc.).
- **Section management** - Form to create sections with name, start page, end page. List of existing sections with Extract & Create Quiz and Delete buttons.
- **Quiz settings** (moderator-only panel):
- Quiz title (optional, auto-generated if blank)
- Mode: Timed or Learning
- Time limit (for timed mode)
- Extraction mode: Standard, Questions Only, Two-Step, AI + Regex, AI Decides, Generate
- Extraction model selector (from `GET /admin/models/available?task=extraction`)
- Question bank category selector with inline "New" button
- **Extraction progress** (`ExtractionProgress` component) - Modal overlay that polls `GET /quizzes/job/:jobId` every 2 seconds. Shows step icons (text, AI, images, save, error, done) and messages. Job is tracked in `localStorage` under key `pedquiz_jobs` so the Navbar can show it. Close button dismisses the panel but the job continues in the background.
### UploadPage (`/upload`)
Moderator-only page for uploading PDFs. Two tabs:
1. **Local File** - Drag-and-drop area or file picker. Accepts `.pdf` only, up to 500MB. Shows file name and size when selected.
2. **Nextcloud** - `NextcloudBrowser` component. Reads credentials from `localStorage` (configured in Settings). Browses folders via `POST /nextcloud/files`, downloads PDFs via `POST /nextcloud/download`. Importing a file auto-triggers upload.
**Upload progress** - Uses axios upload progress. Reads `e.progress` (axios 1.x pattern) with fallback to `e.loaded / e.total`. Shows progress bar and percentage.
**Auto-upload on Nextcloud import** - When `handleFile` is called with `fromNextcloud = true`, `doUpload(f)` is called immediately without waiting for the user to click Upload.
### AdminPage (`/admin`)
Admin-only page with three tabs: Models, Users, Settings.
**Access control**: Redirects to `/` if `user.role !== 'admin'`.
**loadData(false) pattern**: After mutations (role change, model add/delete, etc.), calls `loadData(false)` which skips the loading spinner but refreshes all data.
**Models tab**:
- Lists models grouped by task (extraction, tts, teach, keyword).
- Each model shows name, model_id, active/inactive toggle, default toggle, test button, delete button.
- TTS models have an inline preview player.
- **LiteLLM model search** - Posts to `POST /admin/litellm/models` with optional API key and base URL. Shows filterable results. Click "Add" to pre-fill the add-model form.
- **TTS voice discovery** - Select provider (ElevenLabs, etc.), enter API key, search. Shows voice list with "Add" buttons.
- Add model form: name, model_id, task dropdown, API key, active/default toggles.
**Users tab**:
- User list with name, email, role, verified status.
- Role dropdown (user, moderator, admin).
- Unthrottle toggle (removes rate limits for a user).
- Delete user with Dialog confirmation.
- Create user form.
**Settings tab**:
- Registration enabled toggle.
- Embedding model selector with LiteLLM search, test button, and regeneration trigger if model changed.
- Polly enabled toggle.
---
## Components
### Navbar (`components/Navbar.jsx`)
Auth-aware navigation bar. Behavior changes based on login state:
- **Logged out**: Shows Sign In and Register buttons/links. If `onSignIn` and `onRegister` props are provided (from LandingPage), they trigger callbacks instead of navigating. Otherwise, links to `/login` and `/register`.
- **Logged in**: Desktop nav with links (Home, Dashboard, Quizzes, Question Bank, Upload PDF for moderators, Settings). Mobile hamburger menu with animated bars. Logout button.
- **JobsBadge** - Polls `GET /quizzes/jobs` for active extraction jobs. Poll interval: 3 seconds if jobs are running, 30 seconds otherwise. Shows running count or total job count. Dropdown with job details and links to completed quizzes.
- Auto-closes mobile menu on route change.
### TeachChat (`components/TeachChat.jsx`)
Lazy-loaded AI tutor drawer. Appears as a floating action button (graduation cap icon).
- **Mobile**: Bottom sheet, 70vh height, slides up.
- **Desktop**: Right-side panel, 340px wide, slides in from right.
- **Elevated mode**: When `elevated` prop is true, z-index is raised above modals (1060-1070 vs 380-400). Used when TeachChat appears alongside a study modal in QuizzesPage/QuestionBankPage.
- **Features**:
- Question context chip at top.
- Model selector dropdown (if multiple teach models configured).
- Markdown rendering with `react-markdown`, `remark-gfm` (GFM tables), and `rehype-raw`.
- Follow-up suggestion chips on the last assistant message. Clicking fills the input.
- Posts to `POST /teach/chat` with question_id, messages array, and model_id.
- Shows "no model" error if backend returns 503.
- Chat clears when question changes (keyed on `question.id`).
### Dialog (`components/Dialog.jsx`)
Modal replacement for `window.confirm()` and `window.alert()`. Used via the `useDialog()` hook which returns `{ dialogProps, openConfirm, openAlert }`.
Props: `open`, `title`, `message`, `confirmLabel`, `cancelLabel` (null = alert mode), `onConfirm`, `onCancel`, `danger` (red confirm button).
### ConfirmButton (`components/ConfirmButton.jsx`)
Inline two-click confirmation. First click shows "Confirm" and "Cancel" buttons. Second click fires `onConfirm`. Used throughout for delete operations.
### LineChart (`components/LineChart.jsx`)
Pure SVG line chart for performance trends. Renders a line with fill, dot markers (green >= 75%, red < 75%), grid lines, and a dashed 75% threshold line. Requires at least 2 data points. Responsive via `viewBox`.
---
## API Client (`api/client.js`)
Axios instance with `baseURL: '/api'`.
**Request interceptor**: Attaches `Authorization: Bearer <token>` from localStorage.
**Response interceptor**:
- **Token refresh**: Checks `x-new-token` response header on both success and error responses. If present, updates localStorage. This implements sliding token expiration.
- **401 handling**: Clears token and redirects to `/login`. Skips redirect for auth endpoints (login, register, verify, reset, forgot).
- **Unverified email (403)**: If response has `x-unverified: true` header, clears token and redirects to `/login` (which shows a resend verification option).
---
## State Patterns
### Set vs Array for useEffect deps
`selectedIds` in QuestionBankPage is a `Set` (for O(1) lookups). Since React compares deps by reference, toggling creates a new `Set` each time, which correctly triggers re-renders.
### tagIdsKey / catIdsKey join pattern
Arrays like `selectedTagIds` and `filterCatIds` would cause useEffect to fire on every render due to reference inequality. The pattern:
```js
const tagIdsKey = selectedTagIds.join(',')
const catIdsKey = filterCatIds.join(',')
useEffect(() => { ... }, [searchQuery, catIdsKey, tagIdsKey, ...])
```
Converts arrays to stable string keys so useEffect only fires when the actual content changes.
### timerStarted pattern
In QuizPage, the timer effect must only start once when `timeLeft` transitions from null to a number:
```js
const timerStarted = timeLeft !== null
useEffect(() => {
if (!timerStarted) return
timerRef.current = setInterval(...)
return () => clearInterval(timerRef.current)
}, [timerStarted])
```
This prevents the interval from being torn down and recreated every second as `timeLeft` decrements.
---
## Runtime Configuration
The frontend Docker image uses `docker-entrypoint.sh` to inject runtime config at container startup:
```sh
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'
```
`config.js` is loaded by `index.html` before the app bundle. Components access it via `window.__APP_CONFIG__?.TURNSTILE_SITE_KEY`. This avoids baking environment-specific values into the build.
The frontend `.env` file sets `TURNSTILE_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.

394
docs/services.md Normal file
View file

@ -0,0 +1,394 @@
# PedsHub Service Layer Documentation
This document covers the backend service modules in `backend/app/services/`. These services contain the core business logic for AI extraction, PDF processing, vector storage, embeddings, email, and spaced repetition.
---
## ai_service.py
Central module for LLM interactions: question extraction, answer key extraction, and TTS audio generation.
### `_proxy_model(model_id: str) -> str`
Prefixes a model ID with `openai/` when routing through a LiteLLM proxy. This is needed because LiteLLM requires a provider prefix, but models stored in the database use bare names (e.g. `gpt-4o`).
- If `LITELLM_API_BASE` is set **and** the model_id contains no `/`, returns `openai/{model_id}`
- Otherwise returns the model_id unchanged
This function is used throughout the codebase (ai_service, teach router, admin router) whenever calling `litellm.completion()`.
### `get_model_for_task(db, task: str) -> tuple[str, str | None]`
Resolves which AI model to use for a given task by querying the `AIModelConfig` database table.
**Fallback chain:**
1. DB: active + default model for the requested task
2. Environment: `LITELLM_MODEL` setting with `LITELLM_API_KEY`
Returns `(model_id, api_key)`. The `api_key` may be `None` if the model uses the global proxy key.
### `_truncate_content(content: str, max_chars: int = 100000) -> str`
Truncates long content by keeping the first and last half (each `max_chars // 2`), inserting `"... [content truncated] ..."` in the middle. This prevents exceeding LLM context windows while preserving content from both the beginning and end of the document.
### `_call_model(prompt: str, model_id, api_key) -> str`
Low-level wrapper that calls `litellm.completion()` and returns the raw text response. Handles:
- Model proxying via `_proxy_model()`
- API key selection (per-model key > global `LITELLM_API_KEY`)
- API base URL from `LITELLM_API_BASE`
- Temperature fixed at 0.1 for faithful extraction
### EXTRACTION_PROMPT
The main extraction prompt template. Designed for PREP (Pediatric Review and Education Program) exam PDFs. Key instructions to the LLM:
1. **Correct answer resolution:** Find "Correct Answer: X" or "Preferred Response: X", look up the full option text, never store just the letter
2. **Explanation completeness:** Copy everything verbatim between the correct answer line and the next question (Critique, Content Specifications, Suggested Reading, etc.)
3. **Question boundaries:** "Item NNN" or "ltem NNN" (OCR artifact) marks a new question
4. **Options:** Extract text only, strip A/B/C/D/E letter prefixes
5. **has_figure field:** Set to `true` only if the question references a clinical image (radiograph, ECG, photo, etc.) essential to answering. Decorative/branding images are `false`.
6. **Output:** Raw JSON only, no markdown fences
Template variables: `{content}`, `{page_info}`, `{page_ref}`
### ANSWER_KEY_PROMPT
Extracts answer keys from end-of-document sections where items are listed with their preferred responses. Returns `{"answers": {"193": "D", "194": "A", ...}}`. Handles OCR artifacts like `ltemXXX` (lowercase L instead of I).
### `extract_questions(content, page_info, page_ref, model_id, api_key) -> list[dict]`
Main extraction function. Calls the LLM with `EXTRACTION_PROMPT` and parses the response.
- **Retries:** 3 attempts on failure
- **JSON parsing:** Strips markdown code fences, handles multiple response shapes (`{"questions": [...]}`, bare arrays, `{"items": [...]}`, single question objects)
- **Validation:** Skips questions without `question_text` or `correct_answer`. Normalizes `question_type` to one of `mcq`, `true_false`, `fill_blank`.
- **Skipped tracking:** Questions without a correct answer are recorded in the first valid question's `skipped` list for caller visibility
- Returns validated question dicts or raises `RuntimeError` after 3 failures
### `extract_questions_no_answers(content, ...) -> list[dict]`
Variant that allows `correct_answer` to be `None`. Used in two-step and regex extraction modes where questions and answers are in separate sections. Also extracts `item_number` for later matching.
### `extract_answer_key(content, ...) -> dict[str, str]`
Extracts item-to-letter mappings from answer key pages. Returns `{"193": "D", ...}` with normalized keys (stripped leading zeros) and uppercase letters.
### `generate_tts_audio(text, model_id, api_key) -> bytes | None`
Multi-provider TTS generation. Provider is determined by `model_id` convention:
| Convention | Provider | Example |
|---|---|---|
| `tts-1:alloy` | OpenAI TTS | Voice after colon |
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
| `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name |
| `polly/<VoiceId>` | AWS Polly Neural | e.g. `polly/Joanna` |
**OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`.
Returns raw MP3 bytes or `None` on failure.
---
## extraction_modes.py
Implements 6 extraction strategies that the quiz extraction task can use. Each mode handles different PDF formats.
### Mode: `standard`
Not defined in this file -- uses `ai_service.extract_questions()` directly. For PDFs where "Correct Answer: X" or "Preferred Response: X" appears inline after each question.
### Mode: `questions_only`
**Function:** `extract_questions_only(content, page_info, page_ref, model_id, api_key) -> list[dict]`
Extracts questions and options **without** looking for correct answers. Sets `correct_answer = "PENDING"` as a placeholder for the admin to fill via the QuizEditPage UI.
**Prompt template (`QUESTIONS_ONLY_PROMPT`):** Instructs the LLM to extract item numbers, question text, options, and the `has_figure` flag. Explicitly tells it NOT to include answer explanations or "Preferred Response:" content as questions.
Use case: PDFs where answers are not extractable or need manual review.
### Mode: `two_step`
**Function:** `extract_two_step(document_id, section_start, section_end, model_id, api_key, push_step, chunk_pages=50) -> tuple[list[dict], list[str]]`
Three-phase extraction for PDFs with questions in the first portion and a separate answer key section at the back (e.g. PREP 2013 format).
**Phase 1 -- Question extraction:**
- Scans from `min_answer_start` (20% into the section or page 20, whichever is larger) looking for "Preferred Response:" text
- Chunks the question pages (before the answer key) into groups of `chunk_pages` (default 50)
- Calls `ai_service.extract_questions_no_answers()` on each chunk
**Phase 2 -- Answer key extraction:**
- Reads from the detected answer section start through the end
- Calls `ai_service.extract_answer_key()` on each chunk
- Builds an `{item_number: letter}` mapping
**Phase 3 -- Matching:**
- Matches each question's `item_number` to the answer key
- Converts the letter to the full option text using index lookup (`ord(letter) - ord('A')`)
- Questions that cannot be matched are added to the skipped list
Returns `(valid_questions, skipped_list)`. Raises `ValueError` if no answer key section is found.
### Mode: `regex`
**Function:** `extract_with_regex(document_id, section_start, section_end, model_id, api_key, push_step, chunk_pages=50) -> tuple[list[dict], list[str]]`
AI-assisted regex extraction. The LLM analyses a sample of the document to determine the answer format, then generates a Python regex pattern.
**Steps:**
1. Samples the first 30 pages
2. Sends `REGEX_ANALYSIS_PROMPT` to the LLM, which returns `{"indicator", "placement", "regex", "notes"}`
3. If `placement == "end_of_doc"`, scans for the answer section boundary using the generated regex
4. Extracts questions using `extract_questions_no_answers()`
5. Applies the regex to the full document to build `{item_number: letter}` mapping (combined pattern: `Item\s+(\d+)[\s\S]{0,300}?` + the generated regex)
6. Matches questions to answers
### Mode: `ai_decide`
**Function:** `ai_decide_strategy(document_id, section_start, section_end, model_id, api_key) -> tuple[str, str]`
The LLM reads samples from the start (first 30 pages) and end (last 20 pages) of the document and decides which strategy to use.
**Prompt (`AI_DECIDE_PROMPT`):** Asks the AI to choose between:
- `standard` -- inline answers after each question
- `two_step` -- separate answer key section
- `questions_only` -- no answer indicators at all
Returns `(strategy_name, reasoning)`. Falls back to `"standard"` on error.
### Mode: `generate`
**Function:** `generate_from_text(content, page_info, page_ref, model_id, api_key, n=8) -> list[dict]`
Creates MCQ questions from scratch using plain text/study material. The LLM generates questions where:
- The correct answer is directly supported by the source text
- 3 plausible distractors are created from medical knowledge
- Questions prefer clinical application over pure recall
- Each question includes a 1-2 sentence explanation
**Prompt (`GENERATE_PROMPT`):** Targets `QUESTIONS_PER_CHUNK = 8` questions per ~50-page chunk.
**Validation:** Checks that `correct_answer` is among the `options` list. Attempts fuzzy matching (substring) if exact match fails. Skips malformed questions.
### Helper: `_normalize(text) -> str`
Fixes common OCR artifacts in PREP PDFs:
- `"Pref erred"`, `"Pre ferred"`, `"Prefer red"` -> `"Preferred"`
- `"ltem"`, `"ltcm"` -> `"Item"` (lowercase L misread as I)
---
## pdf_service.py
PDF text and image extraction using PyMuPDF (fitz).
### `get_page_count(file_path) -> int`
Returns the total number of pages in a PDF.
### `extract_text_by_page(file_path) -> dict[int, str]`
Extracts text from every page. Returns `{page_number: text}` with 1-based page numbers. Skips pages with no text content.
### `extract_text_for_range(file_path, start, end) -> str`
Extracts text for a specific page range (1-indexed, inclusive). Each page is prefixed with `--- Page N ---`. Returns all pages joined with double newlines.
### Image Extraction
#### `extract_images_from_page(file_path, page_num, document_id) -> list[str]`
Extracts images from a single PDF page and saves them to disk.
**Filtering pipeline:**
1. **Size filter:** Skips images smaller than 2000 bytes (icons, bullets, decorations)
2. **MD5 hash skip list (`_SKIP_IMAGE_HASHES`):** Known repeated branding images:
- `f48b094ec260f0aa8d7c52bc3cf562e4` -- AAP logo (34300 bytes, appears 869 times across PREP PDFs)
- `82c449d72791fe181fc9964bb8efad0f` -- Sepsis document header/logo (20397 bytes, repeated per page)
3. **Dimension filter:** Skips images where width < 80px or height < 80px
Images are saved to `{UPLOAD_DIR}/images/doc_{document_id}/page_{N}_img_{idx}.{ext}`. Returns relative paths for serving (e.g. `images/doc_5/page_12_img_0.png`).
#### `extract_all_images(file_path, document_id, start_page=1, end_page=None) -> dict[int, list[str]]`
Batch version that extracts images from a page range. Returns `{page_num: [image_paths]}`.
---
## vector_service.py
ChromaDB integration for storing and querying document page embeddings. Used for semantic search within documents during quiz extraction.
### ChromaDB Client Singleton
**`get_client() -> chromadb.PersistentClient`**
Returns a module-level singleton `PersistentClient`. Persistence directory is `settings.CHROMA_PERSIST_DIR`.
### `LiteLLMEmbeddingFunction`
Custom ChromaDB `EmbeddingFunction` implementation that calls the LiteLLM proxy's `/v1/embeddings` endpoint directly via `httpx`. Uses the embedding model from `_get_embedding_model()` (Redis setting > env variable). Sends `dimensions` parameter from `settings.EMBEDDING_DIMENSIONS`.
Only used when both an embedding model and `LITELLM_API_BASE` are configured; otherwise ChromaDB uses its default embedding function.
### `get_or_create_collection(document_id) -> Collection`
Gets or creates a ChromaDB collection named `doc_{document_id}`. Attaches the `LiteLLMEmbeddingFunction` if configured.
### `delete_collection(document_id)`
Deletes a document's ChromaDB collection. Silently handles errors.
### `chunk_text(text, chunk_size=1000, overlap=200) -> list[str]`
Splits text into overlapping chunks for embedding. Default: 1000-character chunks with 200-character overlap. Returns the full text as a single chunk if it is shorter than `chunk_size`.
### `store_pages(document_id, pages: dict[int, str])`
Stores all page text as chunked embeddings in ChromaDB.
- Each chunk gets an ID of `doc_{id}_page_{num}_chunk_{i}`
- Metadata includes `page_num` and `document_id`
- **Batch size:** 100 chunks per API call
- **Rate limiting:** 3-second delay between batches to avoid embedding API rate limits
### `query_pages(document_id, query, start_page=None, end_page=None, n_results=20) -> list[dict]`
Semantic query against a document's vector collection. Supports optional page range filter via ChromaDB `$and` / `$gte` / `$lte` operators. Returns `[{"text": "...", "page_num": N}]`.
### `get_pages_text(document_id, start_page, end_page) -> str`
Retrieves all stored text for a page range, reconstructing the original text from chunks.
**Overlap deduplication:** When reassembling chunks for the same page, strips the first 200 characters (the overlap region) from each subsequent chunk to avoid duplicated content.
Output is sorted by page number, each page prefixed with `--- Page N ---`.
---
## embedding_service.py
Question-level embedding generation for pgvector semantic search (separate from the ChromaDB document embeddings in vector_service).
### `_get_embedding_model() -> str`
Resolves the active embedding model. Priority:
1. Redis key `settings:embedding_model` (set via admin settings)
2. Environment variable `LITELLM_EMBEDDING_MODEL`
This allows changing the embedding model at runtime without restarting the application.
### `generate_embedding(text: str) -> list[float] | None`
Generates an embedding vector for the given text.
**Input processing:** Collapses whitespace, truncates to 4000 characters.
**Provider priority:**
1. **LiteLLM proxy** (direct httpx to `/v1/embeddings`): Used when `embedding_model`, `LITELLM_API_KEY`, and `LITELLM_API_BASE` are all configured. Sends the `dimensions` parameter from `settings.EMBEDDING_DIMENSIONS`. Validates that the returned embedding has the expected dimension count.
2. **AWS Bedrock Titan Embed V2** (direct fallback): Uses `amazon.titan-embed-text-v2:0` via `boto3`. Requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Region from `AWS_BEDROCK_REGION` or defaults to `us-east-1`.
Returns `None` if all providers fail.
### `embed_question(question) -> bool`
Convenience function that generates and stores an embedding on a Question ORM object.
**Text construction (`_text_for_question`):** Concatenates the question stem and all options (no explanation), truncated to 4000 characters. This ensures semantic search matches on question content, not answer explanations.
Returns `True` on success, `False` on failure.
---
## email_service.py
Email sending via `fastapi-mail` with SMTP. Uses clean, minimal HTML templates.
### Configuration
**`get_mail_config() -> ConnectionConfig`**
Builds the FastMail connection configuration from settings:
- `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_FROM`, `MAIL_PORT`, `MAIL_SERVER`
- `MAIL_STARTTLS`, `MAIL_SSL_TLS`
- `USE_CREDENTIALS` is `True` when both username and password are set
From name is hardcoded to `"PedsHub"`.
### Template Rendering
#### `_render(md: str) -> str`
Minimal Markdown-to-HTML converter for email templates. Supports:
- `# H1` and `## H2` headings
- `**bold**` inline
- `> blockquote`
- `---` horizontal rule
- `[button:Label](url)` -- renders as a dark call-to-action button
- `[link:Label](url)` -- renders as a small copy-this-link line
- Plain paragraphs
Style: white background, dark text (#09090b), clean sans-serif, single column (540px max width).
#### `_wrap(subject, body_md) -> str`
Wraps rendered body HTML in a full email document with:
- PedsHub logo/header
- White card container with border
- Footer with app URL and "Didn't expect this email?" note
### `_send(to_email, subject, html)`
Low-level send function. Silently logs and returns if SMTP is not configured (no `MAIL_USERNAME` or `MAIL_FROM`). Uses `fastapi-mail` `FastMail.send_message()` with HTML subtype.
### Email Templates
#### `send_verification_email(to_email, name, token)`
Subject: "Verify your PedsHub email". Contains a verification button linking to `{APP_URL}/verify-email?token={token}`. Notes 24-hour expiry.
#### `send_password_reset_email(to_email, name, token)`
Subject: "Reset your PedsHub password". Contains a reset button linking to `{APP_URL}/reset-password?token={token}`. Notes 1-hour expiry and single use.
#### `send_reminder_email(email, user_name, quiz_title, score, next_date)`
Subject: "Time to review: {quiz_title}". Shows last score percentage with contextual message (encouraging if >= 75%, motivational if below). Contains a "Take Quiz Now" button. Mentions spaced repetition benefits.
---
## reminder_service.py
Simplified SM-2 spaced repetition scheduler for quiz review reminders.
### Interval Schedule
```python
INTERVALS = [1, 3, 7, 14, 30] # days
```
### `update_reminder_schedule(db, user_id, quiz_id, score_percentage)`
Called after every quiz attempt submission. Creates or updates a `ReminderSchedule` record.
**Algorithm:**
1. Find or create the reminder for this user+quiz pair
2. Determine the current position in the interval schedule
3. Adjust based on score:
| Score | Action | Detail |
|---|---|---|
| < 75% | Reset to shortest interval | `INTERVALS[0]` = 1 day |
| 75% - 89% | Advance one step | e.g. 1 -> 3 days |
| >= 90% | Advance two steps or deactivate | If already at max interval (30 days), sets `is_active = False` (mastered) |
4. Set `next_reminder_at` to `now + new_interval`
**Deactivation:** When a user scores >= 90% and is already at the longest interval (30 days), the reminder is deactivated, indicating the material is considered mastered.
The reminder records are used by a separate scheduled task (not in this service) to send `send_reminder_email()` notifications.

View file

@ -1,13 +1,15 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["/docker-entrypoint.sh"]

View file

@ -0,0 +1,10 @@
#!/bin/sh
# Generate runtime config from environment variables.
# This avoids baking secrets/keys into the Docker image at build time.
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'

View file

@ -6,6 +6,7 @@
<title>PedsHub</title>
<meta name="description" content="Pediatric learning platform — create quizzes from PDFs, study with AI explanations, and track your progress." />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏥</text></svg>" />
<link rel="manifest" href="/manifest.json" />
<!-- Inter: clean professional UI font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -13,6 +14,14 @@
</head>
<body>
<div id="root"></div>
<script src="/config.js"></script>
<script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {})
})
}
</script>
</body>
</html>

View file

@ -35,6 +35,16 @@ server {
proxy_read_timeout 600s;
}
# Uploaded images proxy to backend
location /uploads/ {
resolver 127.0.0.11 valid=10s;
set $backend http://backend:8000;
proxy_pass $backend;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;

View file

@ -12,7 +12,9 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0"
"react-router-dom": "^6.22.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.2.55",
@ -1537,6 +1539,18 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -1631,6 +1645,18 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/estree-util-is-identifier-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
@ -1805,6 +1831,64 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-parse5": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"devlop": "^1.0.0",
"hastscript": "^9.0.0",
"property-information": "^7.0.0",
"vfile": "^6.0.0",
"vfile-location": "^5.0.0",
"web-namespaces": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-raw": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"@ungap/structured-clone": "^1.0.0",
"hast-util-from-parse5": "^8.0.0",
"hast-util-to-parse5": "^8.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"parse5": "^7.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@ -1832,6 +1916,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"devlop": "^1.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"web-namespaces": "^2.0.0",
"zwitch": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
@ -1845,6 +1948,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -1855,6 +1975,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@ -1981,6 +2111,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1990,6 +2130,22 @@
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-from-markdown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@ -2014,6 +2170,107 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@ -2212,6 +2469,127 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@ -2663,6 +3041,18 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2812,6 +3202,39 @@
"react-dom": ">=16.8"
}
},
"node_modules/rehype-raw": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@ -2845,6 +3268,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@ -3113,6 +3551,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-location": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
@ -3187,6 +3639,16 @@
}
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -13,7 +13,9 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0"
"react-router-dom": "^6.22.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^18.2.55",

View file

@ -0,0 +1,2 @@
// Fallback for local dev. In production, nginx generates this from env vars.
window.__APP_CONFIG__ = window.__APP_CONFIG__ || {};

4
frontend/public/icon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#0f172a"/>
<text y=".9em" font-size="72" x="14" fill="white">🏥</text>
</svg>

After

Width:  |  Height:  |  Size: 192 B

View file

@ -0,0 +1,19 @@
{
"name": "PedsHub",
"short_name": "PedsHub",
"description": "Pediatric learning platform — quizzes, AI tutor, and question bank",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#0f172a",
"background_color": "#0f172a",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

12
frontend/public/sw.js Normal file
View file

@ -0,0 +1,12 @@
// Minimal service worker — network-only, no caching.
// This exists solely to make the app installable as a PWA.
// Do NOT add caching here: the app loads JS modules dynamically
// and stale cache causes blank-screen / module-not-found failures.
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()))
// Pass every fetch straight through to the network.
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request))
})

View file

@ -57,7 +57,7 @@ function JobsBadge({ jobs }) {
)
}
export default function Navbar() {
export default function Navbar({ onSignIn, onRegister }) {
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const [jobs, setJobs] = useState([])
@ -101,7 +101,7 @@ export default function Navbar() {
<div className="container navbar-inner">
<Link to="/" className="logo" onClick={() => setMenuOpen(false)}>🏥 PedsHub</Link>
{user && (
{user ? (
<>
{/* Desktop nav */}
<nav className="nav-desktop">
@ -143,6 +143,18 @@ export default function Navbar() {
</button>
</div>
</>
) : (
/* Logged-out: Sign In + Register */
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{onSignIn
? <button onClick={onSignIn} style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, background: 'none', border: 'none', cursor: 'pointer', padding: '6px 10px' }}>Sign In</button>
: <Link to="/login" style={{ fontSize: '0.875rem', color: 'var(--navbar-fg)', opacity: 0.8, textDecoration: 'none', padding: '6px 10px' }}>Sign In</Link>
}
{onRegister
? <button onClick={onRegister} className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', borderRadius: 8 }}>Register</button>
: <Link to="/register" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '6px 14px', textDecoration: 'none', borderRadius: 8 }}>Register</Link>
}
</div>
)}
</div>

View file

@ -1,5 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import api from '../api/client'
/**
@ -12,7 +14,7 @@ import api from '../api/client'
* Mobile bottom sheet, 70vh
* Desktop right-side panel inside quiz-layout, 320px wide
*/
export default function TeachChat({ question }) {
export default function TeachChat({ question, elevated = false }) {
const [open, setOpen] = useState(false)
const [messages, setMessages] = useState([]) // {role, content, suggestions?}
const [input, setInput] = useState('')
@ -84,7 +86,7 @@ export default function TeachChat({ question }) {
position: 'fixed',
bottom: 80,
right: 18,
zIndex: 400,
zIndex: elevated ? 1070 : 400,
width: 48,
height: 48,
borderRadius: '50%',
@ -111,7 +113,7 @@ export default function TeachChat({ question }) {
background: 'var(--card-bg)',
borderTop: '2px solid var(--primary)',
borderRadius: '16px 16px 0 0',
zIndex: 390,
zIndex: elevated ? 1060 : 390,
display: 'flex',
flexDirection: 'column',
boxShadow: '0 -8px 32px rgba(0,0,0,0.18)',
@ -136,7 +138,7 @@ export default function TeachChat({ question }) {
<div
className="teach-chat-backdrop"
onClick={() => setOpen(false)}
style={{ position: 'fixed', inset: 0, zIndex: 380, background: 'rgba(0,0,0,0.3)' }}
style={{ position: 'fixed', inset: 0, zIndex: elevated ? 1050 : 380, background: 'rgba(0,0,0,0.3)' }}
/>
)}
@ -198,13 +200,22 @@ export default function TeachChat({ question }) {
border: m.role === 'assistant' ? '1px solid var(--border)' : 'none',
}}>
{m.role === 'assistant'
? <ReactMarkdown components={{
? <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} components={{
p: ({children}) => <p style={{margin: '0 0 6px'}}>{children}</p>,
ul: ({children}) => <ul style={{margin: '4px 0', paddingLeft: 18}}>{children}</ul>,
ol: ({children}) => <ol style={{margin: '4px 0', paddingLeft: 18}}>{children}</ol>,
li: ({children}) => <li style={{marginBottom: 2}}>{children}</li>,
strong: ({children}) => <strong style={{fontWeight: 700}}>{children}</strong>,
code: ({children}) => <code style={{background: 'var(--border)', borderRadius: 3, padding: '1px 4px', fontSize: '0.82em'}}>{children}</code>,
table: ({children}) => (
<div style={{overflowX: 'auto', margin: '6px 0'}}>
<table style={{borderCollapse: 'collapse', width: '100%', fontSize: '0.82em'}}>{children}</table>
</div>
),
thead: ({children}) => <thead style={{background: 'var(--border)'}}>{children}</thead>,
th: ({children}) => <th style={{border: '1px solid var(--border)', padding: '5px 10px', textAlign: 'left', fontWeight: 700, whiteSpace: 'nowrap'}}>{children}</th>,
td: ({children}) => <td style={{border: '1px solid var(--border)', padding: '5px 10px'}}>{children}</td>,
tr: ({children}) => <tr style={{borderBottom: '1px solid var(--border)'}}>{children}</tr>,
}}>{m.content}</ReactMarkdown>
: m.content
}

View file

@ -19,8 +19,8 @@ export function AuthProvider({ children }) {
}
}, [])
const login = async (email, password) => {
const res = await api.post('/auth/login', { email, password })
const login = async (email, password, turnstileToken) => {
const res = await api.post('/auth/login', { email, password, turnstile_token: turnstileToken || null })
localStorage.setItem('token', res.data.access_token)
const me = await api.get('/auth/me')
setUser(me.data)

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'general', 'teach']
const TASKS = ['extraction', 'tts', 'teach', 'keyword']
export default function AdminPage() {
const { user } = useAuth()
@ -58,8 +58,8 @@ export default function AdminPage() {
loadData()
}, [user])
const loadData = async () => {
setLoading(true)
const loadData = async (showSpinner = true) => {
if (showSpinner) setLoading(true)
try {
const [usersRes, modelsRes, settingsRes] = await Promise.all([
api.get('/admin/users'),
@ -81,17 +81,29 @@ export default function AdminPage() {
try {
await api.put(`/admin/users/${userId}/role`, { role })
setSuccess(`Role updated to ${role}`)
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update role')
}
}
const deleteUser = async (userId, userName) => {
const ok = await openConfirm(`Delete user "${userName}"? This removes all their data (attempts, favorites, settings).`, { title: 'Delete User', confirmLabel: 'Delete', danger: true })
if (!ok) return
try {
await api.delete(`/admin/users/${userId}`)
setSuccess(`User "${userName}" deleted`)
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete user')
}
}
const toggleUnthrottle = async (userId, currentVal) => {
try {
await api.put(`/admin/users/${userId}/unthrottle`, { unthrottled: !currentVal })
setSuccess(currentVal ? 'Rate limits restored for user' : 'Rate limits removed for user')
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update throttle setting')
}
@ -104,7 +116,7 @@ export default function AdminPage() {
await api.post('/admin/models', newModel)
setSuccess('Model added')
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add model')
}
@ -114,7 +126,7 @@ export default function AdminPage() {
try {
await api.put(`/admin/models/${modelId}`, { is_default: true })
setSuccess('Default model updated')
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
@ -123,7 +135,7 @@ export default function AdminPage() {
const toggleActive = async (model) => {
try {
await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active })
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
@ -134,7 +146,7 @@ export default function AdminPage() {
if (!ok) return
try {
await api.delete(`/admin/models/${modelId}`)
loadData()
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete')
}
@ -337,7 +349,7 @@ export default function AdminPage() {
<div className="form-group">
<label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
{['extraction', 'teach', 'general'].map(t => <option key={t} value={t}>{t}</option>)}
{TASKS.filter(t => t !== 'tts').map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
@ -561,7 +573,7 @@ export default function AdminPage() {
await api.post('/admin/users', newUser)
setSuccess(`User ${newUser.email} created`)
setNewUser({ name: '', email: '', password: '' })
loadData()
loadData(false)
} catch (err) { setError(err.response?.data?.detail || 'Failed to create user') }
}}>
<div className="grid-2">
@ -615,6 +627,15 @@ export default function AdminPage() {
>
{u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'}
</button>
{u.id !== user?.id && (
<button
className="btn btn-danger btn-sm"
onClick={() => deleteUser(u.id, u.name || u.email)}
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
>
Delete
</button>
)}
</div>
</div>
))}

View file

@ -19,6 +19,23 @@ export default function DashboardPage() {
const [history, setHistory] = useState([])
const [selectedQuizId, setSelectedQuizId] = useState(null)
const [loading, setLoading] = useState(true)
const [deletingAttempt, setDeletingAttempt] = useState(null)
const [confirmAttempt, setConfirmAttempt] = useState(null)
const deleteAttempt = async (attemptId) => {
if (confirmAttempt !== attemptId) { setConfirmAttempt(attemptId); return }
setDeletingAttempt(attemptId)
setConfirmAttempt(null)
try {
await api.delete(`/attempts/${attemptId}`)
setHistory(prev => prev.map(q => ({
...q,
attempts: q.attempts.filter(a => a.attempt_id !== attemptId),
})).filter(q => q.attempts.length > 0))
} catch { }
finally { setDeletingAttempt(null) }
}
useEffect(() => {
const promises = [
@ -33,7 +50,7 @@ export default function DashboardPage() {
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
}).catch(console.error)
.finally(() => setLoading(false))
}, [])
}, [isModerator])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
@ -98,6 +115,44 @@ export default function DashboardPage() {
Below 75% a reminder will be sent to review this quiz
</p>
)}
{/* Attempt rows with delete */}
{selectedQuiz.attempts.length > 0 && (
<div style={{ marginTop: 14, borderTop: '1px solid var(--border)', paddingTop: 10 }}>
{[...selectedQuiz.attempts].reverse().map(a => (
<div key={a.attempt_id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '5px 0', borderBottom: '1px solid var(--border)',
fontSize: '0.82rem', color: 'var(--text-muted)',
}}>
<span style={{ flex: 1 }}>{new Date(a.date).toLocaleDateString()}</span>
<Link to={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', textDecoration: 'none', fontWeight: 600 }}>
{a.percentage}% ({a.score}/{a.total})
</Link>
{confirmAttempt === a.attempt_id ? (
<>
<button
onClick={() => deleteAttempt(a.attempt_id)}
disabled={deletingAttempt === a.attempt_id}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--wrong-fg)', fontSize: '0.75rem', padding: '2px 4px', fontWeight: 700 }}
>
{deletingAttempt === a.attempt_id ? '…' : 'Delete'}
</button>
<button
onClick={() => setConfirmAttempt(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '0.75rem', padding: '2px 4px' }}
>Cancel</button>
</>
) : (
<button
onClick={() => deleteAttempt(a.attempt_id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--wrong-fg)', fontSize: '0.78rem', padding: '2px 6px', borderRadius: 4, opacity: 0.6 }}
title="Delete this attempt"
></button>
)}
</div>
))}
</div>
)}
</>
)}
</div>

View file

@ -141,22 +141,27 @@ export default function DocumentDetailPage() {
api.get('/question-categories/').catch(() => ({ data: [] })),
]).then(([modelsRes, catsRes]) => {
setAvailableModels(modelsRes.data)
const def = modelsRes.data.find(m => m.is_default)
const def = modelsRes.data.find(m => m.is_default) || modelsRes.data[0]
if (def) setSelectedModelId(def.model_id)
setQuestionCategories(catsRes.data)
})
}, [id])
const [processingSteps, setProcessingSteps] = useState([])
useEffect(() => {
if (!doc || doc.status !== 'processing') return
const interval = setInterval(() => {
api.get(`/documents/${id}/status`).then(res => {
if (res.data.status !== 'processing') {
Promise.all([
api.get(`/documents/${id}/status`),
api.get(`/documents/${id}/processing-steps`).catch(() => ({ data: { steps: [] } })),
]).then(([statusRes, stepsRes]) => {
setProcessingSteps(stepsRes.data.steps || [])
if (statusRes.data.status !== 'processing') {
fetchDoc()
clearInterval(interval)
}
})
}, 3000)
}, 2000)
return () => clearInterval(interval)
}, [doc?.status])
@ -275,9 +280,20 @@ export default function DocumentDetailPage() {
</div>
{doc.status === 'processing' && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#64748b' }}>
<div className="spinner"></div>
<p style={{ marginTop: 8 }}>Processing PDF and extracting text... This may take a while for large files.</p>
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<div className="spinner"></div>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>Processing PDF</span>
</div>
{processingSteps.length > 0 && (
<div style={{ padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, fontSize: '0.82rem', color: 'var(--text-muted)', maxHeight: 150, overflowY: 'auto' }}>
{processingSteps.map((s, i) => (
<div key={i} style={{ marginBottom: 2 }}>
{s.step === 'error' ? '✗' : s.step === 'done' ? '✓' : ''} {s.message}
</div>
))}
</div>
)}
</div>
)}
{doc.status === 'error' && (
@ -368,6 +384,7 @@ export default function DocumentDetailPage() {
<option value="two_step">Two-Step separate answer key section (PREP 2013 style)</option>
<option value="regex">AI + Regex AI analyses format then applies regex for answers</option>
<option value="ai_decide">AI Decides AI reads the document and picks best strategy</option>
<option value="generate">Generate AI creates questions from plain text / textbook chapters</option>
</select>
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>
{extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'}
@ -375,6 +392,7 @@ export default function DocumentDetailPage() {
{extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'}
{extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'}
{extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy.'}
{extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'}
</p>
</div>
{availableModels.length > 1 && (

View file

@ -1,20 +1,27 @@
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client'
import { useAuth } from '../context/AuthContext'
import Navbar from '../components/Navbar'
// Turnstile widget
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || ''
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
const widgetId = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY || !window.turnstile) return
const id = window.turnstile.render(ref.current, {
sitekey: TURNSTILE_SITE_KEY,
callback: onVerify,
})
return () => { try { window.turnstile.remove(id) } catch {} }
if (!TURNSTILE_SITE_KEY) return
let timer
const tryRender = () => {
if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return }
widgetId.current = window.turnstile.render(ref.current, {
sitekey: TURNSTILE_SITE_KEY,
callback: onVerify,
})
}
tryRender()
return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
@ -157,11 +164,176 @@ function ContactForm() {
)
}
// Auth modal (Sign In / Register as overlay)
function AuthModal({ mode, onClose, onSwitch }) {
const { login, loginWithToken } = useAuth()
const navigate = useNavigate()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [unverified, setUnverified] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [registered, setRegistered] = useState(false)
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
const handleLogin = async (e) => {
e.preventDefault()
setError(''); setUnverified(false)
setLoading(true)
try {
await login(email, password, turnstileToken)
onClose()
navigate('/')
} catch (err) {
if (err.response?.status === 403) setUnverified(true)
else setError(err.response?.data?.detail || 'Login failed')
} finally { setLoading(false) }
}
const handleRegister = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
if (res.data.requires_verification) {
setRegistered(true)
} else {
await loginWithToken(res.data.access_token)
onClose()
navigate('/')
}
} catch (err) {
setError(err.response?.data?.detail || 'Registration failed')
} finally { setLoading(false) }
}
const resendVerification = async () => {
setResending(true)
try { await api.post('/auth/resend-verification', { email }) } catch {}
setResendSent(true)
setResending(false)
}
const isLogin = mode === 'login'
return (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}}>
<div onClick={e => e.stopPropagation()} style={{
background: 'var(--card-bg)', borderRadius: 'var(--card-radius, 14px)',
padding: '28px 24px', maxWidth: 400, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', position: 'relative',
}}>
<button onClick={onClose} style={{
position: 'absolute', top: 12, right: 14, background: 'none',
border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem',
}}></button>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '2px solid var(--border)' }}>
{[['login', 'Sign In'], ['register', 'Register']].map(([m, label]) => (
<button key={m} onClick={() => switchMode(m)} style={{
flex: 1, padding: '10px 0', background: 'none', border: 'none', cursor: 'pointer',
fontWeight: mode === m ? 700 : 400, fontSize: '0.95rem',
color: mode === m ? 'var(--primary)' : 'var(--text-muted)',
borderBottom: mode === m ? '2px solid var(--primary)' : '2px solid transparent',
marginBottom: -2,
}}>{label}</button>
))}
</div>
{/* Registration success */}
{registered && (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{ fontSize: '2.5rem', marginBottom: 12 }}>📧</div>
<h3 style={{ marginBottom: 8 }}>Check your email</h3>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
</p>
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
Go to Sign In
</button>
</div>
)}
{/* Login form */}
{isLogin && !registered && (
<>
{unverified && !resendSent && (
<div style={{ background: '#fffbeb', border: '1px solid #fcd34d', borderRadius: 8, padding: '12px 14px', marginBottom: 14 }}>
<p style={{ margin: '0 0 8px', fontSize: '0.85rem', color: '#92400e', fontWeight: 500 }}>
Email not verified. Check your inbox.
</p>
<button className="btn btn-sm btn-primary" onClick={resendVerification} disabled={resending}>
{resending ? 'Sending…' : 'Resend verification email'}
</button>
</div>
)}
{resendSent && <div className="alert alert-success" style={{ marginBottom: 14 }}>Verification email sent.</div>}
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
<form onSubmit={handleLogin}>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
<div style={{ textAlign: 'center', marginTop: 12, fontSize: '0.82rem' }}>
<Link to="/forgot-password" onClick={onClose} style={{ color: 'var(--text-muted)' }}>Forgot password?</Link>
</div>
</>
)}
{/* Register form */}
{!isLogin && !registered && (
<>
{error && <div className="alert alert-error" style={{ marginBottom: 14 }}>{error}</div>}
<form onSubmit={handleRegister}>
<div className="form-group">
<label>Name</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} required autoFocus />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Creating account…' : 'Sign Up'}
</button>
</form>
</>
)}
</div>
</div>
)
}
// Main landing page
const navLink = { fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 10px', borderRadius: 6 }
export default function LandingPage() {
const { user } = useAuth()
const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register'
// Load Turnstile script once
useEffect(() => {
@ -176,37 +348,23 @@ export default function LandingPage() {
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)' }}>
{/* ── Navbar ─────────────────────────────────────────────────────────── */}
<nav style={{
position: 'sticky', top: 0, zIndex: 100,
background: 'var(--navbar-bg)', color: 'var(--navbar-fg)',
borderBottom: '1px solid rgba(255,255,255,0.06)',
backdropFilter: 'blur(12px)',
}}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px', height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 800, fontSize: '1.25rem', letterSpacing: '-0.02em', color: '#e2e8f0' }}>
Peds<span style={{ color: '#60a5fa' }}>Hub</span>
</span>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
{user && <>
<Link to="/" style={navLink}>Dashboard</Link>
<Link to="/quizzes" style={navLink}>Quizzes</Link>
<Link to="/question-bank" style={navLink}>Question Bank</Link>
</>}
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={navLink}>AI Scribe </a>
<a href="#contact" style={navLink}>Contact</a>
{user
? <Link to="/" className="btn btn-primary" style={{ fontSize: '0.85rem', padding: '7px 16px', borderRadius: 8, textDecoration: 'none' }}>Go to App</Link>
: <Link to="/login" style={{ fontSize: '0.875rem', color: '#e2e8f0', textDecoration: 'none', padding: '7px 16px', borderRadius: 8, border: '1px solid rgba(255,255,255,0.18)' }}>Sign In</Link>
}
</div>
</div>
</nav>
{/* Auth modal overlay */}
{authModal && (
<AuthModal
mode={authModal}
onClose={() => setAuthModal(null)}
onSwitch={setAuthModal}
/>
)}
{/* ── Shared Navbar (handles auth state) ─────────────────────────────── */}
<style>{`.navbar { margin-bottom: 0 !important; }`}</style>
<Navbar onSignIn={() => setAuthModal('login')} onRegister={() => setAuthModal('register')} />
{/* ── Hero ───────────────────────────────────────────────────────────── */}
<section style={{
background: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #0f172a 100%)',
color: 'white',
background: 'var(--navbar-bg)',
color: 'var(--navbar-fg)',
padding: '96px 24px 88px',
textAlign: 'center',
position: 'relative',
@ -228,18 +386,18 @@ export default function LandingPage() {
into tests
</span>
</h1>
<p style={{ fontSize: '1.15rem', color: '#94a3b8', lineHeight: 1.7, marginBottom: 40, maxWidth: 560, margin: '0 auto 40px' }}>
<p style={{ fontSize: '1.15rem', color: 'rgba(226,232,240,0.75)', lineHeight: 1.7, marginBottom: 40, maxWidth: 560, margin: '0 auto 40px' }}>
Upload any PDF PREP materials, lecture notes, textbook chapters.
AI extracts questions, reads them aloud, and explains every answer.
</p>
<div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
{user ? <>
<Link to="/" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Dashboard</Link>
<Link to="/quizzes" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'white', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
<Link to="/question-bank" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'white', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Question Bank</Link>
<Link to="/quizzes" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>My Quizzes</Link>
<Link to="/question-bank" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Question Bank</Link>
</> : <>
<Link to="/login" className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In</Link>
<a href="#contact" className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'white', border: '1px solid rgba(255,255,255,0.18)', textDecoration: 'none' }}>Request Access</a>
<button onClick={() => setAuthModal('login')} className="btn btn-primary" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10 }}>Sign In</button>
<button onClick={() => setAuthModal('register')} className="btn" style={{ padding: '12px 28px', fontSize: '1rem', borderRadius: 10, background: 'rgba(255,255,255,0.08)', color: 'var(--navbar-fg)', border: '1px solid rgba(255,255,255,0.18)' }}>Register</button>
</>}
</div>
</div>
@ -351,19 +509,19 @@ export default function LandingPage() {
{/* ── Footer ─────────────────────────────────────────────────────────── */}
<footer style={{
background: 'var(--navbar-bg)', color: '#475569',
background: 'var(--navbar-bg)',
padding: '32px 24px',
borderTop: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ maxWidth: 1100, margin: '0 auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 16 }}>
<span style={{ fontWeight: 700, color: '#94a3b8' }}>
Peds<span style={{ color: '#60a5fa' }}>Hub</span>
<span style={{ fontWeight: 700, color: 'rgba(226,232,240,0.6)', fontSize: '0.9rem' }}>
🏥 PedsHub
<span style={{ fontWeight: 400, marginLeft: 16 }}>© {new Date().getFullYear()}</span>
</span>
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
<Link to="/login" style={{ color: '#475569', textDecoration: 'none' }}>Sign In</Link>
<a href="#contact" style={{ color: '#475569', textDecoration: 'none' }}>Contact</a>
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={{ color: '#475569', textDecoration: 'none' }}>AI Scribe</a>
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
<a href="https://peds.danvics.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
</div>
</div>
</footer>

View file

@ -1,8 +1,34 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
const widgetId = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return
if (!document.getElementById('cf-turnstile-script')) {
const s = document.createElement('script')
s.id = 'cf-turnstile-script'
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
s.async = true
document.head.appendChild(s)
}
let timer
const tryRender = () => {
if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return }
widgetId.current = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify })
}
tryRender()
return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
}
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@ -11,6 +37,7 @@ export default function LoginPage() {
const [unverified, setUnverified] = useState(false)
const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const { login } = useAuth()
const navigate = useNavigate()
@ -20,7 +47,7 @@ export default function LoginPage() {
setUnverified(false)
setLoading(true)
try {
await login(email, password)
await login(email, password, turnstileToken)
navigate('/')
} catch (err) {
if (err.response?.status === 403) {
@ -78,7 +105,8 @@ export default function LoginPage() {
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>

View file

@ -1,52 +1,67 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, lazy, Suspense } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
function QuestionStudyModal({ question, onClose }) {
const [answered, setAnswered] = useState(null)
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>{question.question_text}</p>
{question.options && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{question.options.map((opt, i) => {
const isSelected = answered === opt
const hasAnswered = !!answered
const isCorrectOpt = opt === question.correct_answer
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswered(opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}>{opt}</span>
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
<>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>
{question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
)}
{answered && question.explanation && (
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
)}
{answered && <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>}
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>{question.question_text}</p>
{question.image_path && (
<div style={{ margin: '0 0 14px' }}>
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
{question.options && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{question.options.map((opt, i) => {
const isSelected = answered === opt
const hasAnswered = !!answered
const isCorrectOpt = opt === question.correct_answer
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswered(opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}>{opt}</span>
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
)}
{answered && question.explanation && (
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
)}
{answered && <div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>}
</div>
</div>
</div>
{/* AI Tutor — z-index above the modal */}
<Suspense fallback={null}>
<TeachChat question={question} elevated />
</Suspense>
</>
)
}
@ -120,6 +135,53 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
)
}
function TagBrowser({ tags, selectedTagIds, toggleTag }) {
const [search, setSearch] = useState({ subjects: '', diseases: '', keywords: '' })
const sections = [
{ key: 'subjects', label: 'Subjects', color: '#8b5cf6', items: tags.subjects || [] },
{ key: 'diseases', label: 'Diseases', color: '#ef4444', items: tags.diseases || [] },
{ key: 'keywords', label: 'Keywords', color: '#0ea5e9', items: tags.keywords || [] },
]
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 10, marginTop: 10 }}>
{sections.map(sec => {
if (sec.items.length === 0) return null
const q = search[sec.key].toLowerCase()
const filtered = q ? sec.items.filter(t => t.name.toLowerCase().includes(q)) : sec.items
return (
<div key={sec.key} style={{ background: 'var(--bg)', borderRadius: 8, padding: 10, border: '1px solid var(--border)' }}>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: sec.color, textTransform: 'uppercase', marginBottom: 6 }}>
{sec.label} ({sec.items.length})
</div>
<input type="text" placeholder={`Search ${sec.label.toLowerCase()}...`}
value={search[sec.key]}
onChange={e => setSearch(s => ({ ...s, [sec.key]: e.target.value }))}
style={{ width: '100%', padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.78rem', background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 6, boxSizing: 'border-box' }} />
<div style={{ maxHeight: 180, overflowY: 'auto' }}>
{filtered.map(tag => {
const selected = selectedTagIds.includes(tag.id)
return (
<button key={tag.id} onClick={() => toggleTag(tag.id)}
style={{
display: 'block', width: '100%', textAlign: 'left',
background: selected ? sec.color : 'transparent',
color: selected ? '#fff' : 'var(--text)',
border: 'none', borderRadius: 4, padding: '3px 8px',
fontSize: '0.78rem', cursor: 'pointer', marginBottom: 1,
}}>
{tag.name} <span style={{ opacity: 0.6 }}>({tag.count})</span>
</button>
)
})}
{filtered.length === 0 && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', padding: 4 }}>No matches</div>}
</div>
</div>
)
})}
</div>
)
}
const LETTERS = ['A','B','C','D','E','F']
function QuestionEditModal({ question, categories, onSaved, onClose }) {
@ -228,7 +290,7 @@ export default function QuestionBankPage() {
const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatId, setFilterCatId] = useState('')
const [filterCatIds, setFilterCatIds] = useState([])
const [showUncategorized, setShowUncategorized] = useState(false)
const [showFavorites, setShowFavorites] = useState(false)
const [favorites, setFavorites] = useState([])
@ -242,24 +304,37 @@ export default function QuestionBankPage() {
const [assignCatId, setAssignCatId] = useState('')
const [bulkError, setBulkError] = useState('')
const [pageSize, setPageSize] = useState(50)
const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] })
const [selectedTagIds, setSelectedTagIds] = useState([])
const [showTags, setShowTags] = useState(false)
const [classifyJobId, setClassifyJobId] = useState(null)
const [classifyStatus, setClassifyStatus] = useState(null)
const [classifySteps, setClassifySteps] = useState([])
const debounceRef = useRef(null)
const classifyPollRef = useRef(null)
const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const LIMIT = pageSize
const loadTags = () => {
api.get('/tags').then(res => setTags(res.data)).catch(() => {})
}
useEffect(() => {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {})
loadTags()
}, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize) => {
const loadQuestions = async (query = searchQuery, off = 0, catIds = filterCatIds, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize, tagIds = selectedTagIds) => {
setLoading(true)
try {
const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (catIds.length > 0) params.category_ids = catIds.join(',')
if (uncatOnly) params.uncategorized = true
if (favOnly) params.favorites_only = true
if (tagIds.length > 0) params.tag_ids = tagIds.join(',')
const res = await api.get('/questions/bank', { params })
setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions])
setTotal(res.data.total)
@ -267,11 +342,49 @@ export default function QuestionBankPage() {
} catch { } finally { setLoading(false) }
}
const tagIdsKey = selectedTagIds.join(',')
const catIdsKey = filterCatIds.join(',')
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, showFavorites, searchMode, pageSize), 300)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300)
return () => clearTimeout(debounceRef.current)
}, [searchQuery, filterCatId, showUncategorized, showFavorites, searchMode, pageSize])
}, [searchQuery, catIdsKey, showUncategorized, showFavorites, searchMode, pageSize, tagIdsKey])
const toggleTag = (tagId) => {
setSelectedTagIds(prev => {
const n = prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
return n
})
}
const startClassification = async () => {
try {
const res = await api.post('/tags/classify')
setClassifyJobId(res.data.job_id)
setClassifyStatus('running')
setClassifySteps([])
// Start polling
if (classifyPollRef.current) clearInterval(classifyPollRef.current)
classifyPollRef.current = setInterval(async () => {
try {
const s = await api.get(`/tags/status/${res.data.job_id}`)
setClassifyStatus(s.data.status)
setClassifySteps(s.data.steps || [])
if (s.data.status === 'completed' || s.data.status === 'failed') {
clearInterval(classifyPollRef.current)
classifyPollRef.current = null
if (s.data.status === 'completed') loadTags()
}
} catch { }
}, 2000)
} catch (err) {
await openAlert(err.response?.data?.detail || 'Failed to start classification', { title: 'Error' })
}
}
useEffect(() => {
return () => { if (classifyPollRef.current) clearInterval(classifyPollRef.current) }
}, [])
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
@ -280,9 +393,10 @@ export default function QuestionBankPage() {
try {
const params = {}
if (searchQuery.trim()) params.q = searchQuery.trim()
if (filterCatId) params.category_id = parseInt(filterCatId)
if (filterCatIds.length > 0) params.category_ids = filterCatIds.join(',')
if (showUncategorized) params.uncategorized = true
if (showFavorites) params.favorites_only = true
if (selectedTagIds.length > 0) params.tag_ids = selectedTagIds.join(',')
const res = await api.get('/questions/bank/ids', { params })
setSelectedIds(new Set(res.data))
} catch {
@ -333,8 +447,8 @@ export default function QuestionBankPage() {
try {
await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} })
setCategories(prev => prev.filter(c => c.id !== catId))
if (parseInt(filterCatId) === catId) setFilterCatId('')
loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites)
setFilterCatIds(prev => prev.filter(c => c !== catId))
loadQuestions(searchQuery, 0, filterCatIds.filter(c => c !== catId), showUncategorized, showFavorites)
} catch { }
}
@ -424,25 +538,82 @@ export default function QuestionBankPage() {
{/* Category filter chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.04em', alignSelf: 'center', marginRight: 4 }}>Filter:</span>
<button className={`btn btn-sm ${!filterCatId && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(''); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
<button className={`btn btn-sm ${filterCatIds.length === 0 && !showUncategorized && !showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatIds([]); setShowUncategorized(false); setShowFavorites(false) }}>All ({total})</button>
<button className={`btn btn-sm ${showFavorites ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowFavorites(v => !v); setFilterCatId(''); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}> Favorites ({favorites.length})</button>
<button className={`btn btn-sm ${showUncategorized ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setShowUncategorized(v => !v); setFilterCatId(''); setShowFavorites(false) }}>Uncategorized</button>
{categories.map(cat => (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${filterCatId === String(cat.id) ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatId(filterCatId === String(cat.id) ? '' : String(cat.id)); setShowUncategorized(false); setShowFavorites(false) }}>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
onClick={() => { setShowUncategorized(v => !v); setFilterCatIds([]); setShowFavorites(false) }}>Uncategorized</button>
{categories.map(cat => {
const isActive = filterCatIds.includes(cat.id)
return (
<div key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<button className={`btn btn-sm ${isActive ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilterCatIds(prev => prev.includes(cat.id) ? prev.filter(c => c !== cat.id) : [...prev, cat.id]); setShowUncategorized(false); setShowFavorites(false) }}>
{cat.name} <span style={{ opacity: 0.65 }}>({cat.question_count})</span>
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
)
})}
{/* Active tag chips — shown inline with categories */}
{selectedTagIds.map(tid => {
const allTags = [...(tags.subjects || []), ...(tags.diseases || []), ...(tags.keywords || [])]
const tag = allTags.find(t => t.id === tid)
if (!tag) return null
const color = (tags.subjects || []).find(t => t.id === tid) ? '#8b5cf6' : (tags.diseases || []).find(t => t.id === tid) ? '#ef4444' : '#0ea5e9'
return (
<button key={`tag-${tid}`} onClick={() => toggleTag(tid)}
style={{ background: color, color: '#fff', border: 'none', fontSize: '0.72rem', padding: '3px 10px', borderRadius: 12, cursor: 'pointer', fontWeight: 600 }}>
{tag.name}
</button>
{isModerator && <button onClick={() => deleteCategory(cat.id)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#cbd5e1', fontSize: '0.72rem', padding: '2px 3px', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#cbd5e1'}></button>}
</div>
))}
)
})}
{selectedTagIds.length > 0 && (
<button onClick={() => setSelectedTagIds([])}
style={{ background: 'none', border: 'none', fontSize: '0.72rem', color: 'var(--text-muted)', cursor: 'pointer', textDecoration: 'underline' }}>
Clear tags
</button>
)}
</div>
{/* Tag browser — collapsible */}
{(tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0) && (
<div style={{ marginBottom: 12 }}>
<button className="btn btn-sm btn-secondary" onClick={() => setShowTags(v => !v)}
style={{ fontSize: '0.78rem', marginBottom: showTags ? 6 : 0 }}>
🏷 Browse Tags {showTags ? '▲' : '▼'}
</button>
{showTags && <TagBrowser tags={tags} selectedTagIds={selectedTagIds} toggleTag={toggleTag} />}
</div>
)}
{/* Classify + status */}
{isModerator && (
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-sm btn-secondary" onClick={startClassification}
disabled={classifyStatus === 'running'}>
{classifyStatus === 'running' ? 'Classifying...' : 'Classify Questions (AI)'}
</button>
{classifyStatus && (
<span style={{ fontSize: '0.78rem', color: classifyStatus === 'completed' ? 'var(--correct-fg)' : classifyStatus === 'failed' ? 'var(--wrong-fg)' : 'var(--text-muted)' }}>
{classifyStatus === 'completed' ? 'Done' : classifyStatus === 'failed' ? 'Failed' : classifyStatus === 'running' ? 'Running...' : ''}
</span>
)}
</div>
{classifySteps.length > 0 && classifyStatus === 'running' && (
<div style={{ marginTop: 6, padding: '8px 12px', background: 'var(--bg)', borderRadius: 8, fontSize: '0.78rem', color: 'var(--text-muted)', maxHeight: 120, overflowY: 'auto' }}>
{classifySteps.slice(-5).map((s, i) => (
<div key={i}>{s.message}</div>
))}
</div>
)}
</div>
)}
{/* Search row */}
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
@ -564,7 +735,7 @@ export default function QuestionBankPage() {
{questions.length < total && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, offset + LIMIT, filterCatId, showUncategorized, showFavorites)} disabled={loading}>
<button className="btn btn-secondary" onClick={() => loadQuestions(searchQuery, questions.length, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds)} disabled={loading}>
{loading ? 'Loading…' : `Load more (${total - questions.length} remaining)`}
</button>
</div>

View file

@ -128,6 +128,9 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
)
}
// Unique session ID per tab used to prevent concurrent resume on multiple devices
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
export default function QuizPage() {
const { id } = useParams()
const navigate = useNavigate()
@ -172,13 +175,21 @@ export default function QuizPage() {
try {
const quizRes = await api.get(`/quizzes/${id}?study=true`)
setQuiz(quizRes.data)
} catch { }
} catch (err) {
console.error('Failed to load quiz:', err)
navigate('/')
return
}
} else {
// For exam mode, ensure quiz data is loaded
try {
const quizRes = await api.get(`/quizzes/${id}`)
setQuiz(quizRes.data)
} catch { }
} catch (err) {
console.error('Failed to load quiz:', err)
navigate('/')
return
}
}
hasStarted.current = true
@ -224,11 +235,18 @@ export default function QuizPage() {
// Check for saved progress and auto-resume
try {
const progressRes = await api.get('/attempts/progress', { params: { quiz_id: id } })
const progressRes = await api.get('/attempts/progress', {
params: { quiz_id: id },
headers: { 'x-quiz-session': SESSION_ID },
})
if (progressRes.data) {
await resumeQuiz(progressRes.data)
}
} catch {}
} catch (err) {
if (err.response?.status === 409) {
showToast(err.response.data.detail || 'Quiz active on another device')
}
}
} catch { navigate('/') }
finally { setLoading(false) }
}
@ -268,14 +286,19 @@ export default function QuizPage() {
finally { setStarting(false) }
}
useEffect(() => {
if (timeLeft === null) return
if (timeLeft <= 0) { handleSubmit(true); return }
const timerStarted = timeLeft !== null
useEffect(() => {
if (!timerStarted) return
timerRef.current = setInterval(() => {
setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 })
}, 1000)
return () => clearInterval(timerRef.current)
}, [timeLeft === null])
}, [timerStarted])
// Auto-submit when timer reaches zero
useEffect(() => {
if (timeLeft === 0) handleSubmit(true)
}, [timeLeft])
// Save progress to Redis (survives logout/browser change)
const saveProgressRef = useRef(null)
@ -293,7 +316,7 @@ useEffect(() => {
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
}).catch(() => {})
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
}, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
@ -322,7 +345,7 @@ useEffect(() => {
} catch (err) {
if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed')
} finally { setSubmitting(false) }
}, [attemptId, answers, submitting])
}, [attemptId, answers, submitting, navigate, showToast])
if (loading) return <div className="loading"><div className="spinner"></div> Loading quiz...</div>
if (!quiz) return null

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, lazy, Suspense } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
@ -6,6 +6,8 @@ import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat'))
function InProgressSection() {
const [inProgress, setInProgress] = useState([])
const navigate = useNavigate()
@ -56,6 +58,7 @@ function PastAttemptsSection() {
const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null)
const [loading, setLoading] = useState(false)
const [deletingId, setDeletingId] = useState(null)
const load = async () => {
if (history !== null) { setOpen(v => !v); return }
@ -67,6 +70,19 @@ function PastAttemptsSection() {
finally { setLoading(false); setOpen(true) }
}
const deleteAttempt = async (attemptId) => {
setDeletingId(attemptId)
try {
await api.delete(`/attempts/${attemptId}`)
setHistory(prev =>
prev
.map(q => ({ ...q, attempts: q.attempts.filter(a => a.attempt_id !== attemptId) }))
.filter(q => q.attempts.length > 0)
)
} catch { }
finally { setDeletingId(null) }
}
const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0
return (
@ -92,13 +108,13 @@ function PastAttemptsSection() {
<div key={quiz.quiz_id} style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: '0.88rem', marginBottom: 6 }}>{quiz.title}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{quiz.attempts.map((a, i) => (
{quiz.attempts.map((a) => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
display: 'flex', alignItems: 'center',
padding: '7px 12px', background: 'var(--bg)', borderRadius: 8,
fontSize: '0.82rem', gap: 8,
}}>
<span style={{ color: 'var(--text-muted)' }}>
<span style={{ color: 'var(--text-muted)', flex: 1 }}>
{new Date(a.date).toLocaleDateString()} {a.score}/{a.total}
</span>
<span style={{
@ -108,6 +124,21 @@ function PastAttemptsSection() {
<a href={`/results/${a.attempt_id}`} style={{ color: 'var(--primary)', fontSize: '0.78rem', textDecoration: 'none', flexShrink: 0 }}>
Review
</a>
<button
onClick={() => deleteAttempt(a.attempt_id)}
disabled={deletingId === a.attempt_id}
title="Delete this attempt"
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--wrong-fg)', fontSize: '0.78rem',
padding: '2px 4px', borderRadius: 4, flexShrink: 0,
opacity: deletingId === a.attempt_id ? 0.4 : 0.6,
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = deletingId === a.attempt_id ? '0.4' : '0.6'}
>
{deletingId === a.attempt_id ? '…' : '✕'}
</button>
</div>
))}
</div>
@ -135,47 +166,60 @@ function HighlightText({ text, query }) {
function QuestionStudyModal({ question, query, onClose }) {
const [answered, setAnswered] = useState(null)
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Study Question</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
<>
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 'var(--card-radius)', padding: 24, maxWidth: 600, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase' }}>Study Question</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>
<HighlightText text={question.question_text} query={query} />
</p>
{question.image_path && (
<div style={{ margin: '0 0 14px' }}>
<img src={`/uploads/${question.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
</div>
)}
{question.options && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{question.options.map((opt, i) => {
const isSelected = answered === opt
const hasAnswered = !!answered
const isCorrectOpt = opt === question.correct_answer
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswered(opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}><HighlightText text={opt} query={query} /></span>
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
)}
{answered && question.explanation && (
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
)}
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>
)}
</div>
<p style={{ fontWeight: 600, fontSize: '0.95rem', lineHeight: 1.6, marginBottom: 16 }}>
<HighlightText text={question.question_text} query={query} />
</p>
{question.options && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{question.options.map((opt, i) => {
const isSelected = answered === opt
const hasAnswered = !!answered
const isCorrectOpt = opt === question.correct_answer
return (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${hasAnswered && isCorrectOpt ? 'correct' : ''} ${hasAnswered && isSelected && !isCorrectOpt ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswered(opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
<span className="option-letter">{String.fromCharCode(65 + i)}</span>
<span style={{ flex: 1 }}><HighlightText text={opt} query={query} /></span>
{hasAnswered && isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> {isSelected ? 'Your answer' : 'Correct'}</span>}
{hasAnswered && isSelected && !isCorrectOpt && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div>
)
})}
</div>
)}
{answered && question.explanation && (
<div className="explanation"><strong>Explanation:</strong><div style={{ marginTop: 8 }}>{question.explanation}</div></div>
)}
{answered && (
<div style={{ marginTop: 14, display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnswered(null)}>Try again</button>
<button className="btn btn-secondary btn-sm" onClick={onClose}>Close</button>
</div>
)}
</div>
</div>
{/* AI Tutor — z-index above the modal */}
<Suspense fallback={null}>
<TeachChat question={question} elevated />
</Suspense>
</>
)
}

View file

@ -1,8 +1,34 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || ''
function TurnstileWidget({ onVerify }) {
const ref = useRef(null)
useEffect(() => {
if (!TURNSTILE_SITE_KEY) return
// Load script if needed
if (!document.getElementById('cf-turnstile-script')) {
const s = document.createElement('script')
s.id = 'cf-turnstile-script'
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
s.async = true
document.head.appendChild(s)
}
const tryRender = () => {
if (!window.turnstile || !ref.current) return setTimeout(tryRender, 200)
const id = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify })
return () => { try { window.turnstile.remove(id) } catch {} }
}
const cleanup = tryRender()
return () => { if (typeof cleanup === 'function') cleanup() }
}, [])
if (!TURNSTILE_SITE_KEY) return null
return <div ref={ref} style={{ margin: '8px 0' }} />
}
export default function RegisterPage() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
@ -10,6 +36,7 @@ export default function RegisterPage() {
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [turnstileToken, setTurnstileToken] = useState('')
const { loginWithToken } = useAuth()
const handleSubmit = async (e) => {
@ -17,7 +44,7 @@ export default function RegisterPage() {
setError('')
setLoading(true)
try {
const res = await api.post('/auth/register', { email, password, name })
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
if (res.data.requires_verification) {
setDone(true)
} else {
@ -71,7 +98,8 @@ export default function RegisterPage() {
<label>Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8} />
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading}>
<TurnstileWidget onVerify={setTurnstileToken} />
<button className="btn btn-primary" style={{ width: '100%' }} disabled={loading || (TURNSTILE_SITE_KEY && !turnstileToken)}>
{loading ? 'Creating account...' : 'Sign Up'}
</button>
</form>

View file

@ -8,12 +8,26 @@ export default function ResultsPage() {
const navigate = useNavigate()
const [result, setResult] = useState(location.state?.result || null)
const [loading, setLoading] = useState(!result)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const deleteAttempt = async () => {
if (!confirmDelete) { setConfirmDelete(true); return }
setDeleting(true)
try {
await api.delete(`/attempts/${result.id}`)
navigate('/quizzes')
} catch {
setDeleting(false)
setConfirmDelete(false)
}
}
useEffect(() => {
if (!result) {
api.get(`/attempts/${id}`)
.then(res => setResult(res.data))
.catch(() => navigate('/'))
.catch(() => navigate('/quizzes'))
.finally(() => setLoading(false))
}
}, [id])
@ -60,6 +74,27 @@ export default function ResultsPage() {
<Link to={`/quizzes/${result.quiz_id}`} className="btn btn-primary">Retake Quiz</Link>
<Link to="/quizzes" className="btn btn-secondary">All Quizzes</Link>
<Link to="/" className="btn btn-secondary">Dashboard</Link>
{confirmDelete ? (
<>
<button
onClick={deleteAttempt}
disabled={deleting}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
{deleting ? 'Deleting…' : 'Confirm Delete'}
</button>
<button className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>Cancel</button>
</>
) : (
<button
onClick={deleteAttempt}
className="btn btn-secondary"
style={{ color: 'var(--wrong-fg)', borderColor: 'var(--wrong-bd)' }}
>
Delete Attempt
</button>
)}
</div>
</div>
</div>

View file

@ -127,24 +127,19 @@ export default function UploadPage() {
const hasNextcloud = !!localStorage.getItem('nc_username')
const handleFile = (f, fromNextcloud = false) => {
if (f && f.type === 'application/pdf') {
setFile(f); setError('')
if (!fromNextcloud) setTab('local')
} else {
setError('Please select a PDF file')
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true); setError('')
const [uploadName, setUploadName] = useState('')
const doUpload = async (f) => {
if (!f) return
setUploadName(f.name)
setUploading(true); setError(''); setProgress(0)
const formData = new FormData()
formData.append('file', file)
formData.append('file', f)
try {
const res = await api.post('/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => { if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) },
onUploadProgress: (e) => {
if (e.progress != null) setProgress(Math.round(e.progress * 100))
else if (e.total) setProgress(Math.round((e.loaded / e.total) * 100))
},
})
navigate(`/documents/${res.data.id}`)
} catch (err) {
@ -152,6 +147,18 @@ export default function UploadPage() {
} finally { setUploading(false) }
}
const handleFile = (f, fromNextcloud = false) => {
if (f && f.type === 'application/pdf') {
setFile(f); setError('')
if (!fromNextcloud) setTab('local')
if (fromNextcloud) doUpload(f)
} else {
setError('Please select a PDF file')
}
}
const handleUpload = () => doUpload(file)
return (
<div>
<div className="card">
@ -213,17 +220,25 @@ export default function UploadPage() {
)}
{uploading && (
<div className="progress-bar" style={{ marginTop: 12 }}>
<div className="fill" style={{ width: `${progress}%` }} />
<div style={{ marginTop: 16, padding: '16px', background: 'var(--option-sel-bg)', borderRadius: 8, border: '1px solid var(--option-sel-bd)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8, fontSize: '0.9rem', fontWeight: 600 }}>
<span>Uploading {uploadName || file?.name || 'file'}...</span>
<span>{progress}%</span>
</div>
<div className="progress-bar">
<div className="fill" style={{ width: `${Math.max(progress, 5)}%`, transition: 'width 0.3s' }} />
</div>
</div>
)}
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
{uploading ? `Uploading ${progress}%...` : 'Upload & Process'}
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
</div>
{!uploading && (
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
<button className="btn btn-primary" onClick={handleUpload} disabled={!file || uploading}>
Upload & Process
</button>
<button className="btn btn-secondary" onClick={() => navigate('/')}>Cancel</button>
</div>
)}
</div>
</div>
)