393 lines
18 KiB
Markdown
393 lines
18 KiB
Markdown
# 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.
|