pdf-quiz-generator/docs/services.md
Daniel 2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00

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_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
polly/<VoiceId> AWS Polly Neural e.g. polly/Joanna

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

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)
  1. 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.