# 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 |

**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.
