pdf-quiz-generator/backend/app/services/pdf_service.py
Daniel a1459b2965 refactor: name the study plans ourselves, and stop reserving 64k tokens a call
"PREP" is the American Academy of Pediatrics' trademark for their own product.
The plans here are our own sets of questions grouped by year, so they are now
named for what they are: Board Review 2021, and Mixed Review for the plan that
draws from every year at once.

Renamed in the database as well as the code — 13 plans, 14 quizzes a learner had
already generated from a block, and the 12 year tags, which appear in the
question bank's filters and are as visible as the plans. The seeder matches both
the old and new names so a fresh import still finds its material, and the tagger
mints the new one so the next run cannot undo this. Prompts and comments that
described the source PDFs by that name now describe them by what they are.

The generation run's 377 failures were not a bug
Every call was reserving the model's full 64k output ceiling, and OpenRouter
refuses the whole request when the balance is below the reservation — "you
requested up to 64000 tokens, but can only afford 52017" — however short the
answer would actually be. `_call_model` now takes a max_tokens, and the article
writer asks for 4000, which is comfortable for three views of one topic and
keeps each request small enough to be affordable. 98 articles were written
before the balance ran down; 158 exist in total.

Generation is paused at the user's request while credits are topped up.

208 backend, 243 frontend green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-11 01:41:18 +02:00

122 lines
4 KiB
Python

import os
import logging
import fitz # PyMuPDF
from app.config import settings
logger = logging.getLogger(__name__)
def get_page_count(file_path: str) -> int:
doc = fitz.open(file_path)
count = len(doc)
doc.close()
return count
def extract_text_by_page(file_path: str) -> dict[int, str]:
"""Extract text from every page. Returns {page_num: text} (1-indexed)."""
doc = fitz.open(file_path)
pages = {}
for i in range(len(doc)):
text = doc[i].get_text()
if text.strip():
pages[i + 1] = text
doc.close()
return pages
def extract_text_for_range(file_path: str, start: int, end: int) -> str:
"""Extract text for a page range (1-indexed, inclusive)."""
doc = fitz.open(file_path)
texts = []
for i in range(start - 1, min(end, len(doc))):
text = doc[i].get_text()
if text.strip():
texts.append(f"--- Page {i + 1} ---\n{text}")
doc.close()
return "\n\n".join(texts)
# MD5 hashes of known repeated branding images (logos, headers) to skip during extraction.
# These appear on every page of the source PDFs and are not clinical images.
_SKIP_IMAGE_HASHES = {
"f48b094ec260f0aa8d7c52bc3cf562e4", # AAP logo (34300 bytes, appears 869 times across the source PDFs)
"82c449d72791fe181fc9964bb8efad0f", # Sepsis document header/logo (20397 bytes, repeated per page)
}
def extract_images_from_page(file_path: str, page_num: int, document_id: int) -> list[str]:
"""Extract images from a specific page. Returns list of saved image paths."""
import hashlib
image_dir = os.path.join(settings.UPLOAD_DIR, "images", f"doc_{document_id}")
os.makedirs(image_dir, exist_ok=True)
saved_paths = []
try:
doc = fitz.open(file_path)
if page_num < 1 or page_num > len(doc):
doc.close()
return []
page = doc[page_num - 1]
images = page.get_images(full=True)
for img_idx, img_info in enumerate(images):
xref = img_info[0]
try:
base_image = doc.extract_image(xref)
if not base_image:
continue
image_ext = base_image.get("ext", "png")
image_bytes = base_image["image"]
# Skip tiny images (likely icons/bullets/decorations)
if len(image_bytes) < 2000:
continue
# Skip known branding/logo images by hash
if hashlib.md5(image_bytes).hexdigest() in _SKIP_IMAGE_HASHES:
continue
# Skip images with very small dimensions (banners, line art, icons)
w = base_image.get("width", 0)
h = base_image.get("height", 0)
if w > 0 and h > 0 and (w < 80 or h < 80):
continue
filename = f"page_{page_num}_img_{img_idx}.{image_ext}"
filepath = os.path.join(image_dir, filename)
with open(filepath, "wb") as f:
f.write(image_bytes)
# Return relative path for serving
rel_path = f"images/doc_{document_id}/{filename}"
saved_paths.append(rel_path)
except Exception as e:
logger.warning(f"Failed to extract image {img_idx} from page {page_num}: {e}")
continue
doc.close()
except Exception as e:
logger.warning(f"Image extraction failed for page {page_num}: {e}")
return saved_paths
def extract_all_images(file_path: str, document_id: int, start_page: int = 1, end_page: int | None = None) -> dict[int, list[str]]:
"""Extract images from a page range. Returns {page_num: [image_paths]}."""
doc = fitz.open(file_path)
if end_page is None:
end_page = len(doc)
doc.close()
result = {}
for page_num in range(start_page, end_page + 1):
images = extract_images_from_page(file_path, page_num, document_id)
if images:
result[page_num] = images
return result