27 KiB
PedsHub API Reference
Base URL: /api
All endpoints return JSON unless noted otherwise. Authentication is via Bearer token in the Authorization header. Role hierarchy: admin > moderator > user.
Auth (/api/auth)
POST /api/auth/register
Register a new user account.
- Auth: None
- Rate limit: Turnstile verification required if configured
- Request body:
{ "email": "string", "password": "string (min 8 chars)", "name": "string", "turnstile_token": "string | null" } - Response:
- First user ever:
{"access_token": "...", "token_type": "bearer"}(auto-admin, auto-verified) - Subsequent users:
{"requires_verification": true, "message": "..."}
- First user ever:
- Notes: Registration can be disabled by admin via settings. First registered user is always admin and auto-verified.
POST /api/auth/login
Authenticate and receive a JWT token.
- Auth: None
- Rate limit: 10 attempts per IP per 15 minutes (Redis). Turnstile verification if configured.
- Request body:
{ "email": "string", "password": "string", "turnstile_token": "string | null" } - Response:
{"access_token": "string", "token_type": "bearer"} - Errors: 401 invalid credentials, 403 email not verified, 429 rate limited
GET /api/auth/verify-email
Verify a user's email address via token link.
- Auth: None
- Query params:
token(string, required) - Response:
{"message": "Email verified successfully! You can now log in."} - Errors: 400 invalid/expired token
POST /api/auth/resend-verification
Resend the email verification link.
- Auth: None
- Request body:
{"email": "string"} - Response:
{"message": "If that email exists, a verification link has been sent."} - Notes: Deliberately vague response to prevent email enumeration.
POST /api/auth/forgot-password
Request a password reset link.
- Auth: None
- Rate limit: 3 reset requests per email per hour
- Request body:
{"email": "string"} - Response:
{"message": "If that email is registered, a reset link has been sent."} - Notes: Deliberately vague response to prevent email enumeration. Sends email in background.
POST /api/auth/reset-password
Reset password using a valid token.
- Auth: None
- Request body:
{ "token": "string", "new_password": "string (min 8 chars)" } - Response:
{"message": "Password reset successfully. You can now log in."} - Errors: 400 invalid/expired/used token
GET /api/auth/me
Get the current authenticated user's profile.
- Auth: User
- Response:
UserResponse—{id, email, name, role, created_at}
PUT /api/auth/me
Update current user's profile (name, password).
- Auth: User
- Request body:
{ "name": "string | null", "current_password": "string | null (required to change password)", "new_password": "string | null (min 8 chars)" } - Response:
UserResponse
GET /api/auth/me/settings
Get user settings stored in Redis (e.g. Nextcloud config).
- Auth: User
- Response:
{}or a JSON object of user settings
PUT /api/auth/me/settings
Save user settings to Redis.
- Auth: User
- Request body: Any JSON object
- Response:
{"saved": true}
Documents (/api/documents)
POST /api/documents/upload
Upload a PDF document for processing.
- Auth: Moderator
- Content-Type:
multipart/form-data - Request:
file(PDF, max size fromMAX_UPLOAD_SIZEsetting) - Response:
DocumentResponse—{id, user_id, filename, original_filename, status, total_pages, error_message, uploaded_at} - Notes: Processing dispatched to Celery (falls back to sync). Status starts as
"processing".
GET /api/documents/
List all documents. Moderators see all; regular users see only their own.
- Auth: User
- Response:
DocumentResponse[]
GET /api/documents/{document_id}
Get a single document by ID.
- Auth: User (own documents only; moderators can access any)
- Response:
DocumentResponse - Errors: 404
GET /api/documents/{document_id}/status
Poll document processing status.
- Auth: User (own documents only; moderators can access any)
- Response:
{id, status, total_pages, error_message}
GET /api/documents/{document_id}/processing-steps
Get PDF processing progress steps from Redis.
- Auth: User (own documents only; moderators can access any)
- Response:
{"steps": [{step object}, ...]} - Notes: Steps are stored in Redis list
pdf:steps:{document_id}.
POST /api/documents/{document_id}/sections
Create a named page range section within a document.
- Auth: Moderator
- Request body:
{ "name": "string", "start_page": "int (>= 1)", "end_page": "int (> start_page, <= total_pages)" } - Response:
SectionResponse—{id, document_id, name, start_page, end_page} - Errors: 400 if document not ready, invalid page range
DELETE /api/documents/{document_id}/sections/{section_id}
Delete a section.
- Auth: Moderator
- Response: 204 No Content
DELETE /api/documents/{document_id}
Delete a document, its file, and its ChromaDB vector collection.
- Auth: Moderator
- Response: 204 No Content
Quizzes (/api/quizzes)
POST /api/quizzes/
Start async quiz extraction from a document section.
- Auth: Moderator
- Request body:
{ "section_id": "int", "title": "string", "mode": "timed | learning", "time_limit_minutes": "int | null", "model_id": "int | null (AIModelConfig.id)", "question_category_id": "int | null", "extraction_mode": "string | null (standard, questions_only, two_step, regex, ai_decide, generate)" } - Response:
{"job_id": "uuid", "status": "pending"} - Notes: Poll
/api/quizzes/job/{job_id}for progress. Falls back to sync if Celery unavailable.
GET /api/quizzes/jobs
List all recent extraction jobs for the current user (any browser session).
- Auth: User
- Response:
[ { "job_id": "uuid", "title": "string", "status": "pending | running | completed | failed | cancelled", "steps_count": "int", "last_step": "string (truncated to 80 chars)", "quiz_id": "int | null" } ]
GET /api/quizzes/job/{job_id}
Poll extraction job progress.
- Auth: Moderator
- Response:
{"job_id", "status", "steps": [...], "quiz_id" (if completed), "error" (if failed)}
POST /api/quizzes/job/{job_id}/cancel
Cancel a running extraction job.
- Auth: User (must own the job)
- Response:
{"status": "cancelled"} - Errors: 403 if not owner, 400 if not running
GET /api/quizzes/search
Hybrid semantic + keyword search across quiz titles and questions.
- Auth: User
- Query params:
q(string, required, 2-200 chars)mode(string:"title"|"questions"|"all", default"all")
- Response: Array of search results with
quiz_id,quiz_title,match_type,matching_questions[] - Notes: Uses pgvector cosine similarity (threshold 0.30) for semantic search, plus ILIKE for keyword matching.
GET /api/quizzes/
List all quizzes. Moderators see all (non-deleted); regular users see only published.
- Auth: User
- Response:
QuizResponse[]—{id, section_id, user_id, title, questions_count, mode, time_limit_minutes, is_published, created_at}
GET /api/quizzes/{quiz_id}
Get a quiz for taking. Learning-mode quizzes include answers/explanations.
- Auth: User
- Query params:
study(bool, default false) — forces answers/explanations to be included - Response:
QuizDetail(timed) orQuizLearningDetail(learning/study) - Notes: Excludes soft-deleted quizzes.
PATCH /api/quizzes/{quiz_id}
Update quiz metadata (title).
- Auth: Moderator
- Request body:
{"title": "string"} - Response:
QuizResponse
POST /api/quizzes/{quiz_id}/shuffle
Return quiz with shuffled question and option order (does not modify DB).
- Auth: User
- Query params:
shuffle_options(bool, default true) - Response:
QuizDetailwith shuffled questions
GET /api/quizzes/{quiz_id}/review
Get quiz with full answers. Requires a completed attempt (or moderator role).
- Auth: User (must have completed attempt) or Moderator
- Response:
QuizReview
GET /api/quizzes/{quiz_id}/questions
Get all questions with answers for editing.
- Auth: Moderator
- Response: Array of
{id, question_text, question_type, options, correct_answer, explanation}
PATCH /api/quizzes/{quiz_id}/questions/{question_id}
Update a question. Changes are shared across all quizzes referencing this question.
- Auth: Moderator
- Request body: Any of
{question_text, options, correct_answer, explanation, question_type} - Response: Updated question object
- Errors: 400 if correct_answer does not match one of the options
DELETE /api/quizzes/{quiz_id}/questions/{question_id}
Remove a question from this quiz. If shared with other quizzes, only the link is removed. If exclusive, the question is deleted from the bank.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/publish
Show or hide a quiz for regular users.
- Auth: Moderator
- Query params:
published(bool, default true) - Response:
{"quiz_id": int, "is_published": int}
GET /api/quizzes/trash
List soft-deleted quizzes.
- Auth: Moderator
- Response:
QuizResponse[]
DELETE /api/quizzes/{quiz_id}
Soft-delete a quiz (move to trash).
- Auth: Moderator
- Response: 204 No Content
PATCH /api/quizzes/{quiz_id}/restore
Restore a soft-deleted quiz from trash.
- Auth: Moderator
- Response:
QuizResponse
DELETE /api/quizzes/{quiz_id}/permanent
Permanently delete a quiz that is already in trash. Exclusive questions are orphaned to the bank.
- Auth: Moderator
- Response: 204 No Content
- Errors: 400 if quiz is not in trash
Questions (/api/questions)
GET /api/questions/bank
List all questions across all quizzes with filtering, search, and pagination.
- Auth: User
- Query params:
q(string, optional) — keyword/semantic searchquiz_id(int, optional) — filter by source quizcategory_id(int, optional) — filter by question categorycategory_ids(string, optional) — comma-separated category IDs (OR filter)uncategorized(bool, default false)favorites_only(bool, default false)tag_ids(string, optional) — comma-separated tag IDs (AND filter)search_mode(string:"keyword"|"semantic"|"hybrid", default"hybrid")limit(int, default 50, max 200)offset(int, default 0)
- Response:
{ "total": "int", "questions": [ { "id": "int", "quiz_id": "int", "quiz_title": "string", "question_category_id": "int | null", "question_category_name": "string | null", "question_text": "string", "question_type": "string", "options": ["string"], "correct_answer": "string", "explanation": "string", "image_path": "string | null" } ] } - Notes: Hybrid search returns semantic matches first (pgvector cosine similarity >= 0.55), then keyword matches.
GET /api/questions/bank/ids
Return just question IDs for all matching questions (for server-side select-all).
- Auth: User
- Query params: Same filters as
/bank(exceptlimit,offset,search_mode) - Response:
[int]
PATCH /api/questions/{question_id}/category
Assign or remove a question category.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"question_id": int, "question_category_id": int | null}
POST /api/questions/from-bank
Create a new quiz by referencing existing bank questions (no copying -- edits propagate).
- Auth: Moderator
- Request body:
{ "title": "string", "question_ids": [1, 2, 3], "mode": "timed | learning", "time_limit_minutes": "int | null" } - Response:
{"id": int, "title": "string", "questions_count": int}
POST /api/questions/bulk-category
Assign or remove a category from multiple questions at once.
- Auth: Moderator
- Request body:
{ "question_ids": [1, 2, 3], "category_id": "int | null" } - Response:
{"updated": int, "question_category_id": int | null}
Attempts (/api/attempts)
POST /api/attempts/start
Start a new quiz attempt (or resume the most recent incomplete one).
- Auth: User
- Query params:
quiz_id(int, required)fresh(bool, default false) — force a new attempt even if one is in progress
- Response:
AttemptResponse—{id, quiz_id, score, total_questions, percentage, started_at, completed_at}
POST /api/attempts/{attempt_id}/submit
Submit answers for a quiz attempt.
- Auth: User (must own the attempt)
- Request body:
{ "answers": [ {"question_id": "int", "user_answer": "string"} ] } - Response:
AttemptDetail— includesanswers[]with full review (question text, options, user answer, correct answer, is_correct, explanation) - Notes: Updates spaced repetition reminder schedule. Clears saved progress from Redis. Unanswered questions are marked incorrect.
GET /api/attempts/
List all attempts for the current user.
- Auth: User
- Query params:
quiz_id(int, optional) - Response:
AttemptResponse[]
GET /api/attempts/{attempt_id}
Get a specific attempt with full answer details.
- Auth: User (must own the attempt)
- Response:
AttemptDetail
GET /api/attempts/stats/dashboard
Get dashboard statistics for the current user.
- Auth: User
- Response:
{ "total_documents": "int", "total_quizzes": "int (distinct quizzes attempted)", "total_attempts": "int", "average_score": "float", "quiz_stats": [ { "quiz_id": "int", "quiz_title": "string", "attempts_count": "int", "best_score": "float", "latest_score": "float", "average_score": "float" } ] }
GET /api/attempts/history
Get per-quiz attempt history for the performance line graph.
- Auth: User
- Response:
[ { "quiz_id": "int", "title": "string", "attempts": [ {"attempt_id": "int", "date": "ISO string", "percentage": "float", "score": "int", "total": "int"} ] } ]
GET /api/attempts/in-progress
Return all incomplete (not yet submitted) attempts for the current user.
- Auth: User
- Response:
[ { "attempt_id": "int", "quiz_id": "int", "quiz_title": "string", "total_questions": "int", "started_at": "ISO string" } ]
GET /api/attempts/{attempt_id}/in-progress
Return the latest incomplete attempt for a specific quiz.
- Auth: User
- Query params:
quiz_id(int, required) - Response:
AttemptResponse | null
POST /api/attempts/progress
Save in-progress quiz answers to Redis (survives logout/browser change).
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Request body:
{ "quiz_id": "int", "attempt_id": "int", "answers": {"question_id": "answer"}, "current_idx": "int", "mode": "string", "voice": "string | null", "time_left": "int | null", "started_at": "string | null (ISO timestamp)", "total_time": "int | null (seconds)" } - Response:
{"saved": true} - Notes: Progress expires after 7 days. Session lock (30s TTL) prevents concurrent resume on another device.
GET /api/attempts/progress
Retrieve saved progress for a quiz. Auto-submits timed quizzes if timer expired.
- Auth: User
- Headers:
x-quiz-session(optional, for device session lock) - Query params:
quiz_id(int, required) - Response: Saved progress object or
null - Errors: 409 if another device is actively using this attempt
DELETE /api/attempts/progress/{attempt_id}
Clear saved progress.
- Auth: User
- Response: 204 No Content
DELETE /api/attempts/{attempt_id}
Delete an attempt, its answers, and associated reminder schedule.
- Auth: User (must own the attempt)
- Response: 204 No Content
Tags (/api/tags)
GET /api/tags
Get all tags grouped by type (subjects, diseases, keywords), with question counts.
- Auth: User
- Response:
{ "subjects": [{"id": "int", "name": "string", "count": "int"}], "diseases": [{"id": "int", "name": "string", "count": "int"}], "keywords": [{"id": "int", "name": "string", "count": "int"}] }
POST /api/tags/classify
Trigger background AI classification of untagged questions.
- Auth: Moderator
- Response:
{"job_id": "uuid", "status": "started"}
GET /api/tags/status/{job_id}
Check classification job progress.
- Auth: User
- Response:
{"job_id": "string", "status": "string", "steps": [...], "error": "string | null"}
Teach (/api/teach)
GET /api/teach/models
Return available teach AI models.
- Auth: User
- Response:
[{"id": int, "name": "string", "model_id": "string", "is_default": bool}]
POST /api/teach/chat
Send a message to the AI tutor with full question context.
- Auth: User
- Rate limit: 30 messages per user per 10 minutes (admins and unthrottled users exempt)
- Request body:
{ "question_id": "int", "messages": [ {"role": "user | assistant", "content": "string"} ], "model_id": "int | null (AIModelConfig.id)" } - Response:
{"reply": "string (markdown)", "suggestions": ["string", "string", "string"]} - Notes: System prompt includes the question, correct answer, explanation, and up to 4 semantically similar questions for broader context. Follow-up suggestions are parsed from lines starting with
>. - Errors: 503 if no teach model configured, 502 if AI temporarily unavailable
TTS (/api/tts)
GET /api/tts/voices
Return available TTS voices. Excludes Polly voices if Polly is disabled.
- Auth: User
- Response:
[{"id": "string (model_id)", "name": "string", "is_default": bool}]
POST /api/tts/speak
Convert text to speech audio.
- Auth: User
- Rate limit: 60 requests per user per hour (admins and unthrottled users exempt)
- Request body:
{ "text": "string (max 2000 chars)", "voice": "string | null (model_id override)" } - Response: Binary
audio/mpegdata - Notes: Supports LiteLLM-routed local TTS voices, e.g.
local-kokoro-tts:am_adam. The prefix before:is the LiteLLM model route; the suffix is the speaker voice sent to that route.
Favorites (/api/favorites)
POST /api/favorites
Add a question to the current user's favorites.
- Auth: User
- Request body:
{"question_id": "int"} - Response (201):
FavoriteResponse—{id, user_id, question_id, created_at} - Notes: Idempotent -- returns existing favorite if already favorited.
DELETE /api/favorites/{question_id}
Remove a question from favorites.
- Auth: User
- Response: 204 No Content
GET /api/favorites
Get list of favorited question IDs for the current user.
- Auth: User
- Response:
[int]
Categories (/api/categories)
Quiz-level categories for organizing quizzes.
GET /api/categories/
List all quiz categories with quiz counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "quiz_count": int}]
POST /api/categories/
Create a new quiz category.
- Auth: Moderator
- Request body:
{"name": "string"} - Response:
CategoryResponse
DELETE /api/categories/{category_id}
Delete a quiz category. Unassigns all quizzes in the category.
- Auth: Moderator
- Response: 204 No Content
PATCH /api/categories/quizzes/{quiz_id}
Assign or remove a category from a quiz.
- Auth: Moderator
- Query params:
category_id(int | null) - Response:
{"quiz_id": int, "category_id": int | null}
Question Categories (/api/question-categories)
Question-level categories for organizing the question bank.
GET /api/question-categories/
List all question categories with question counts.
- Auth: User
- Response:
[{"id": int, "name": "string", "description": "string | null", "question_count": int}]
POST /api/question-categories/
Create a new question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
PATCH /api/question-categories/{cat_id}
Update a question category.
- Auth: Moderator
- Request body:
{"name": "string", "description": "string | null"} - Response:
QCatResponse
DELETE /api/question-categories/{cat_id}
Delete a question category. Optionally move its questions to another category.
- Auth: Moderator
- Query params:
move_to(int | null) -- target category ID, or null to uncategorize - Response: 204 No Content
POST /api/question-categories/{cat_id}/create-quiz
Create a new quiz from all questions in a question category.
- Auth: Moderator
- Query params:
title(string, required)mode(string:"timed"|"learning", default"timed")time_limit_minutes(int | null)
- Response:
{"id": int, "title": "string", "questions_count": int}
Contact (/api/contact)
POST /api/contact
Submit a contact form (public, no auth).
- Auth: None
- Request body:
{ "name": "string (max 120)", "email": "valid email", "type": "question | moderator", "message": "string (max 2000)", "turnstile_token": "string | null" } - Response:
{"success": true} - Notes: Stores submission in DB and emails admin. Turnstile verification if configured.
GET /api/contact/submissions
List all contact submissions.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
[{id, name, email, type, message, read, created_at}]
PUT /api/contact/submissions/{submission_id}/read
Mark a contact submission as read.
- Auth: Admin (enforced in endpoint body, not decorator)
- Response:
{"success": true}
Admin (/api/admin)
User Management
GET /api/admin/users
List all users.
- Auth: Admin
- Response:
UserResponse[]
POST /api/admin/users
Create a user directly (email auto-verified).
- Auth: Admin
- Request body:
{"email": "string", "password": "string", "name": "string"} - Response:
UserResponse
PUT /api/admin/users/{user_id}/role
Change a user's role.
- Auth: Admin
- Request body:
{"role": "admin | moderator | user"} - Response:
UserResponse - Errors: 400 cannot change own role
PUT /api/admin/users/{user_id}/unthrottle
Set or clear the unthrottle flag (exempt from AI/TTS rate limits).
- Auth: Admin
- Request body:
{"unthrottled": true | false} - Response:
UserResponse
DELETE /api/admin/users/{user_id}
Delete a user and all their data.
- Auth: Admin
- Response: 204 No Content
- Errors: 400 cannot delete yourself
AI Model Configuration
GET /api/admin/models/available
List active models for a task (for users when creating/taking quizzes).
- Auth: User
- Query params:
task(string, default"extraction") - Response:
[{"id": int | null, "name": "string", "model_id": "string", "is_default": bool}] - Notes: Falls back to env
LITELLM_MODELif nothing configured.
GET /api/admin/models
List all AI model configurations.
- Auth: Admin
- Response:
AIModelConfigResponse[]--{id, name, model_id, task, is_active, is_default, api_key (masked)}
POST /api/admin/models
Create an AI model configuration.
- Auth: Admin
- Request body:
{ "name": "string", "model_id": "string", "task": "extraction | tts | teach | keyword", "is_active": "bool", "is_default": "bool", "api_key": "string | null" } - Response:
AIModelConfigResponse - Notes: Auto-sets as default if first model for that task. Unique constraint on (model_id, task).
PUT /api/admin/models/{model_id}
Update an AI model configuration.
- Auth: Admin
- Request body: Partial
AIModelConfigUpdate - Response:
AIModelConfigResponse
DELETE /api/admin/models/{model_id}
Delete an AI model configuration.
- Auth: Admin
- Response: 204 No Content
POST /api/admin/models/{model_id}/test
Send a test completion to verify an LLM model is reachable.
- Auth: Admin
- Response:
{"message": "model_id -> 'OK'"} - Errors: 400 for TTS models (use Preview button instead), 502 if unreachable
POST /api/admin/litellm/models
Query available models from LiteLLM proxy or OpenAI-compatible API.
- Auth: Admin
- Request body:
{"api_key": "string | null", "api_base": "string | null"} - Response:
{"models": ["string"], "source": "string"}
TTS Voice Search
POST /api/admin/tts/voices
Discover available TTS voices from a provider.
- Auth: Admin
- Request body:
{ "provider": "elevenlabs | polly | openai", "api_key": "string | null", "region": "string | null" } - Response:
{"voices": [{"model_id": "string", "name": "string", "labels": {}}]} - Notes: OpenAI returns hardcoded list of tts-1 and tts-1-hd voices.
System Settings
GET /api/admin/settings
Get system settings (registration enabled, embedding model, Polly enabled).
- Auth: Admin
- Response:
{"registration_enabled": bool, "embedding_model": "string"}
PUT /api/admin/settings
Update system settings.
- Auth: Admin
- Request body:
{"registration_enabled": bool, "embedding_model": "string"} - Response:
{"success": true, "message": "Settings updated"}
Embedding Management
POST /api/admin/embedding/test
Test the currently configured embedding model.
- Auth: Admin
- Response:
{"model": "string", "dimensions": int, "status": "ok"}
POST /api/admin/embedding/regenerate
Queue a Celery task to regenerate all question embeddings.
- Auth: Admin
- Response:
{"job_id": "uuid", "message": "Regeneration started..."}
Nextcloud (/api/nextcloud)
WebDAV proxy for importing PDFs from Nextcloud (avoids browser CORS issues).
POST /api/nextcloud/test
Test Nextcloud credentials.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"ok": true, "message": "Connected successfully"}
POST /api/nextcloud/files
List PDFs and folders at a given Nextcloud path.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/"} - Response:
{"path": "string", "items": [{"name", "path", "type": "dir | pdf", "size"}]}
POST /api/nextcloud/download
Download a PDF from Nextcloud and stream it back.
- Auth: Moderator
- Request body:
{"server": "string", "username": "string", "password": "string", "path": "/some/file.pdf"} - Response: Streamed
application/pdfbinary - Errors: 400 if not a PDF file