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:
parent
10b9b1b7cf
commit
2cbbfe00c3
51 changed files with 5088 additions and 385 deletions
379
README.md
379
README.md
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
backend/app/routers/tags.py
Normal file
67
backend/app/routers/tags.py
Normal 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}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
19
docs/README.md
Normal 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
1051
docs/api-reference.md
Normal file
File diff suppressed because it is too large
Load diff
533
docs/architecture.md
Normal file
533
docs/architecture.md
Normal 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
446
docs/deployment.md
Normal 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
341
docs/frontend.md
Normal 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
394
docs/services.md
Normal 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.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
10
frontend/docker-entrypoint.sh
Normal file
10
frontend/docker-entrypoint.sh
Normal 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;'
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
464
frontend/package-lock.json
generated
464
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
2
frontend/public/config.js
Normal file
2
frontend/public/config.js
Normal 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
4
frontend/public/icon.svg
Normal 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 |
19
frontend/public/manifest.json
Normal file
19
frontend/public/manifest.json
Normal 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
12
frontend/public/sw.js
Normal 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))
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue