# 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:**
  ```json
  {
    "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": "..."}`
- **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:**
  ```json
  {
    "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:**
  ```json
  {
    "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:**
  ```json
  {
    "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 from `MAX_UPLOAD_SIZE` setting)
- **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:**
  ```json
  {
    "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:**
  ```json
  {
    "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:**
  ```json
  [
    {
      "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) or `QuizLearningDetail` (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:** `QuizDetail` with 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 search
  - `quiz_id` (int, optional) — filter by source quiz
  - `category_id` (int, optional) — filter by question category
  - `category_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:**
  ```json
  {
    "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` (except `limit`, `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:**
  ```json
  {
    "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:**
  ```json
  {
    "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:**
  ```json
  {
    "answers": [
      {"question_id": "int", "user_answer": "string"}
    ]
  }
  ```
- **Response:** `AttemptDetail` — includes `answers[]` 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:**
  ```json
  {
    "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:**
  ```json
  [
    {
      "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:**
  ```json
  [
    {
      "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:**
  ```json
  {
    "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:**
  ```json
  {
    "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:**
  ```json
  {
    "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:**
  ```json
  {
    "text": "string (max 2000 chars)",
    "voice": "string | null (model_id override)"
  }
  ```
- **Response:** Binary `audio/mpeg` data
- **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:**
  ```json
  {
    "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_MODEL` if 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:**
  ```json
  {
    "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:**
  ```json
  {
    "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/pdf` binary
- **Errors:** 400 if not a PDF file
