18 KiB
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_BASEis set and the model_id contains no/, returnsopenai/{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:
- DB: active + default model for the requested task
- Environment:
LITELLM_MODELsetting withLITELLM_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:
- Correct answer resolution: Find "Correct Answer: X" or "Preferred Response: X", look up the full option text, never store just the letter
- Explanation completeness: Copy everything verbatim between the correct answer line and the next question (Critique, Content Specifications, Suggested Reading, etc.)
- Question boundaries: "Item NNN" or "ltem NNN" (OCR artifact) marks a new question
- Options: Extract text only, strip A/B/C/D/E letter prefixes
- has_figure field: Set to
trueonly if the question references a clinical image (radiograph, ECG, photo, etc.) essential to answering. Decorative/branding images arefalse. - 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_textorcorrect_answer. Normalizesquestion_typeto one ofmcq,true_false,fill_blank. - Skipped tracking: Questions without a correct answer are recorded in the first valid question's
skippedlist for caller visibility - Returns validated question dicts or raises
RuntimeErrorafter 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_numberto 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:
- Samples the first 30 pages
- Sends
REGEX_ANALYSIS_PROMPTto the LLM, which returns{"indicator", "placement", "regex", "notes"} - If
placement == "end_of_doc", scans for the answer section boundary using the generated regex - Extracts questions using
extract_questions_no_answers() - 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) - 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 questiontwo_step-- separate answer key sectionquestions_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:
- Size filter: Skips images smaller than 2000 bytes (icons, bullets, decorations)
- 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)
- 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_numanddocument_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:
- Redis key
settings:embedding_model(set via admin settings) - 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:
- LiteLLM proxy (direct httpx to
/v1/embeddings): Used whenembedding_model,LITELLM_API_KEY, andLITELLM_API_BASEare all configured. Sends thedimensionsparameter fromsettings.EMBEDDING_DIMENSIONS. Validates that the returned embedding has the expected dimension count. - AWS Bedrock Titan Embed V2 (direct fallback): Uses
amazon.titan-embed-text-v2:0viaboto3. RequiresAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY. Region fromAWS_BEDROCK_REGIONor defaults tous-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_SERVERMAIL_STARTTLS,MAIL_SSL_TLSUSE_CREDENTIALSisTruewhen 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:
# H1and## H2headings**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
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:
- Find or create the reminder for this user+quiz pair
- Determine the current position in the interval schedule
- 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) |
- Set
next_reminder_attonow + 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.