Spaces:
Running
Running
| """Smart metadata probe for the Add Book wizard. | |
| Given the bytes of an uploaded PDF, this module: | |
| 1. Counts pages (free, PyMuPDF). | |
| 2. Detects native text vs scan via a chars-per-page peek (free, PyMuPDF). | |
| 3. Sends the first N pages to Gemini for a structured-JSON metadata guess — | |
| as TEXT for native-text PDFs (cheaper, more accurate against the real | |
| text layer) or as page IMAGES for scans. | |
| Output is a typed dataclass shaped to prefill the form. The probe runs only | |
| when the user clicks "Analyze" in the UI; results are cached in session_state | |
| keyed by SHA-256 of the upload bytes so form re-renders never re-bill Gemini. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from dataclasses import asdict, dataclass, field | |
| from pathlib import Path | |
| import fitz # PyMuPDF | |
| from src.config import REPO_ROOT, load_config | |
| from src.stage3_ocr.gemini_client import generate, image_part | |
| PROMPT_PATH = REPO_ROOT / "prompts" / "metadata_probe.txt" | |
| ERAS = ("ante-nicene", "nicene", "post-nicene", "medieval", "modern") | |
| TRADITIONS = ("coptic", "antiochene", "cappadocian", "latin", "syriac", "other") | |
| LANGUAGES = ("ar", "en", "gr", "la") | |
| RELIGIONS = ("christian", "islamic", "jewish", "other") | |
| # Average chars/page on the first 3 pages above which we treat the PDF as a | |
| # real text-layer PDF (vs a scanned image PDF). 100 is well below the density | |
| # of any typeset Arabic page and well above what stray OCR junk produces. | |
| NATIVE_TEXT_MIN_CHARS_PER_PAGE = 100 | |
| class ProbeResult: | |
| """One analyze() call's output, ready to prefill the Add Book form.""" | |
| title_ar: str | None | |
| title_en: str | None | |
| author: str | None | |
| author_id: str | None | |
| era: str | None | |
| tradition: str | None | |
| language: str | None | |
| pages_total: int | |
| extraction_mode_suggested: str # "ocr" | "native_text" | |
| avg_chars_per_page: int # what the native-text peek measured | |
| confidence: str # "low" | "medium" | "high" | |
| rationale: str | None | |
| suggested_labels: list[str] = field(default_factory=list) | |
| book_religion: str | None = None | |
| author_religion: str | None = None | |
| error: str | None = None # set if Gemini call or JSON parse failed | |
| def asdict(self) -> dict: | |
| return asdict(self) | |
| # ---------- Free probes (no API) ------------------------------------------- | |
| def page_count(pdf_bytes: bytes) -> int: | |
| try: | |
| with fitz.open(stream=pdf_bytes, filetype="pdf") as doc: | |
| return doc.page_count | |
| except Exception: # noqa: BLE001 | |
| return 0 | |
| def peek_native_text(pdf_bytes: bytes, n_pages: int = 3) -> tuple[bool, int]: | |
| """(is_text_pdf, avg_chars/page on the first n pages). | |
| Returns (False, 0) on parse failure so callers default to OCR — the | |
| safer choice for an unreadable PDF. | |
| """ | |
| try: | |
| with fitz.open(stream=pdf_bytes, filetype="pdf") as doc: | |
| n = min(n_pages, doc.page_count) | |
| if n == 0: | |
| return False, 0 | |
| total = sum( | |
| len(doc.load_page(i).get_text("text").strip()) | |
| for i in range(n) | |
| ) | |
| avg = total // n | |
| return avg >= NATIVE_TEXT_MIN_CHARS_PER_PAGE, avg | |
| except Exception: # noqa: BLE001 | |
| return False, 0 | |
| # ---------- Sample helpers -------------------------------------------------- | |
| def sample_text(pdf_bytes: bytes, n_pages: int) -> str: | |
| chunks: list[str] = [] | |
| with fitz.open(stream=pdf_bytes, filetype="pdf") as doc: | |
| n = min(n_pages, doc.page_count) | |
| for i in range(n): | |
| text = doc.load_page(i).get_text("text").strip() | |
| chunks.append(f"=== Page {i+1} ===\n{text}") | |
| return "\n\n".join(chunks) | |
| def _sample_images(pdf_bytes: bytes, n_pages: int, dpi: int = 150) -> list[bytes]: | |
| zoom = dpi / 72 | |
| matrix = fitz.Matrix(zoom, zoom) | |
| out: list[bytes] = [] | |
| with fitz.open(stream=pdf_bytes, filetype="pdf") as doc: | |
| n = min(n_pages, doc.page_count) | |
| for i in range(n): | |
| pix = doc.load_page(i).get_pixmap(matrix=matrix, alpha=False) | |
| out.append(pix.tobytes("png")) | |
| return out | |
| # ---------- JSON parsing helpers -------------------------------------------- | |
| def _existing_label_names() -> list[str]: | |
| """Manual labels already in the user's library, for the prompt context. | |
| Lazy import to avoid a labels → metadata_probe coupling at module load. | |
| Returns [] on any failure — the prompt template tolerates an empty list. | |
| """ | |
| try: | |
| from src.lib.labels import list_labels | |
| return [ | |
| (l.display_en or l.display_ar) | |
| for l in list_labels(kind="manual") | |
| if (l.display_en or l.display_ar) | |
| ] | |
| except Exception: # noqa: BLE001 | |
| return [] | |
| def _fill_prompt_template(prompt: str) -> str: | |
| """Fill `{existing_labels_list}` with the user's known manual labels.""" | |
| names = _existing_label_names() | |
| listed = ", ".join(sorted(names)) if names else "(none yet — propose new ones if any apply)" | |
| return prompt.replace("{existing_labels_list}", listed) | |
| def _strip_fences(s: str) -> str: | |
| s = s.strip() | |
| if s.startswith("```"): | |
| s = re.sub(r"^```[a-zA-Z]*\n?", "", s) | |
| s = re.sub(r"\n?```\s*$", "", s) | |
| return s.strip() | |
| def _pick_enum(value, allowed: tuple[str, ...]) -> str | None: | |
| if not value: | |
| return None | |
| v = str(value).strip().lower() | |
| return v if v in allowed else None | |
| def _clean_suggested_labels(value, *, max_labels: int = 5) -> list[str]: | |
| """Coerce Gemini's suggested_labels into a clean list of short strings. | |
| Filters out non-strings, lowercases, dedupes (preserving order), trims | |
| to <=64 chars per tag, caps the list length, and drops anything that's | |
| obviously era/tradition/language (those have their own fields, even | |
| though the prompt asks the model not to duplicate them). | |
| """ | |
| if not isinstance(value, list): | |
| return [] | |
| excluded = set(ERAS) | set(TRADITIONS) | set(LANGUAGES) | |
| out: list[str] = [] | |
| seen: set[str] = set() | |
| for s in value: | |
| if not isinstance(s, str): | |
| continue | |
| v = s.strip().lower()[:64] | |
| if not v or v in seen or v in excluded: | |
| continue | |
| seen.add(v) | |
| out.append(v) | |
| if len(out) >= max_labels: | |
| break | |
| return out | |
| def _normalize( | |
| raw: dict, | |
| *, | |
| pages_total: int, | |
| extraction_mode_suggested: str, | |
| avg_chars_per_page: int, | |
| ) -> ProbeResult: | |
| return ProbeResult( | |
| title_ar=(raw.get("title_ar") or None), | |
| title_en=(raw.get("title_en") or None), | |
| author=(raw.get("author") or None), | |
| author_id=(raw.get("author_id") or None), | |
| era=_pick_enum(raw.get("era"), ERAS), | |
| tradition=_pick_enum(raw.get("tradition"), TRADITIONS), | |
| language=_pick_enum(raw.get("language"), LANGUAGES), | |
| pages_total=pages_total, | |
| extraction_mode_suggested=extraction_mode_suggested, | |
| avg_chars_per_page=avg_chars_per_page, | |
| confidence=str(raw.get("confidence") or "low").lower(), | |
| rationale=(raw.get("rationale") or None), | |
| suggested_labels=_clean_suggested_labels(raw.get("suggested_labels")), | |
| book_religion=_pick_enum(raw.get("book_religion"), RELIGIONS), | |
| author_religion=_pick_enum(raw.get("author_religion"), RELIGIONS), | |
| ) | |
| # ---------- Public entry point --------------------------------------------- | |
| def _ask_gemini(parts: list, *, model: str, note: str) -> tuple[dict, str | None]: | |
| """One Gemini structured-JSON call. Returns (parsed_dict, error_or_none).""" | |
| raw_text = "" | |
| try: | |
| raw_text = generate( | |
| model=model, | |
| parts=parts, | |
| response_mime_type="application/json", | |
| stage="metadata_probe", | |
| book_id=None, | |
| note=note, | |
| ) | |
| except Exception as e: # noqa: BLE001 | |
| return {}, f"{type(e).__name__}: {e}" | |
| if not raw_text: | |
| return {}, None | |
| try: | |
| parsed = json.loads(_strip_fences(raw_text)) | |
| except json.JSONDecodeError as e: | |
| return {}, f"JSON parse: {e}" | |
| return (parsed, None) if isinstance(parsed, dict) else ({}, "model returned non-object JSON") | |
| def probe_metadata_from_text( | |
| text: str, | |
| *, | |
| pages_total: int, | |
| extraction_mode_suggested: str = "ocr", | |
| avg_chars_per_page: int = 0, | |
| ) -> ProbeResult: | |
| """Ask Gemini for metadata given a chunk of already-extracted text. | |
| Used by the OCR sample flow: after we OCR + clean N pages of an unsaved | |
| PDF, the cleaned text is fed back through the same metadata prompt as | |
| the text-layer path. Same prompt, same enum normalization, same shape | |
| of `ProbeResult` — keeping a single source of truth for what "metadata" | |
| means downstream. | |
| """ | |
| cfg = load_config() | |
| section = cfg.section("metadata_probe") | |
| model = section.get("model", "gemini-2.5-flash") | |
| prompt = _fill_prompt_template(PROMPT_PATH.read_text(encoding="utf-8")) | |
| raw_dict, error = _ask_gemini( | |
| [ | |
| prompt, | |
| "\n\n--- Sample text from the first pages " | |
| "(produced by OCR + cleanup of the uploaded PDF) ---\n\n" | |
| + (text or ""), | |
| ], | |
| model=model, | |
| note=f"source=ocr_sample chars={len(text or '')}", | |
| ) | |
| result = _normalize( | |
| raw_dict, | |
| pages_total=pages_total, | |
| extraction_mode_suggested=extraction_mode_suggested, | |
| avg_chars_per_page=avg_chars_per_page, | |
| ) | |
| result.error = error | |
| return result | |
| def probe_metadata( | |
| pdf_bytes: bytes, | |
| *, | |
| force_mode: str | None = None, | |
| ) -> ProbeResult: | |
| """Detect mode, sample pages, ask Gemini, return a normalized result. | |
| By default the probe uses the auto-detect peek to choose between text | |
| sampling (native_text PDFs) and image sampling (scans). Pass | |
| `force_mode="ocr"` to send page images even when the PDF has a text | |
| layer — useful when the embedded text is corrupted or the title | |
| metadata lives only on the cover image. | |
| `force_mode` only affects this one metadata call. The | |
| `extraction_mode_suggested` field of the result still reflects the | |
| PDF's own text-layer peek (i.e. what the actual ingest should do). | |
| """ | |
| cfg = load_config() | |
| section = cfg.section("metadata_probe") | |
| model = section.get("model", "gemini-2.5-flash") | |
| n_pages = int(section.get("pages_to_sample", 5)) | |
| pages = page_count(pdf_bytes) | |
| is_native_pdf, avg_chars = peek_native_text(pdf_bytes) | |
| suggested_mode = "native_text" if is_native_pdf else "ocr" | |
| # Pick how to sample for THIS probe call. Forcing native_text on a PDF | |
| # without a text layer would send empty content to Gemini, so fall back | |
| # to images in that case. | |
| if force_mode == "ocr": | |
| probe_mode = "ocr" | |
| elif force_mode == "native_text" and is_native_pdf: | |
| probe_mode = "native_text" | |
| else: | |
| probe_mode = "native_text" if is_native_pdf else "ocr" | |
| prompt = _fill_prompt_template(PROMPT_PATH.read_text(encoding="utf-8")) | |
| parts: list = [prompt] | |
| if probe_mode == "native_text": | |
| parts.append( | |
| "\n\n--- Sample text from the first pages " | |
| "(extracted via PyMuPDF, exactly as embedded in the PDF) ---\n\n" | |
| + sample_text(pdf_bytes, n_pages) | |
| ) | |
| else: | |
| for png in _sample_images(pdf_bytes, n_pages): | |
| parts.append(image_part(png)) | |
| raw_dict, error = _ask_gemini( | |
| parts, | |
| model=model, | |
| note=( | |
| f"probe_mode={probe_mode} " | |
| f"forced={bool(force_mode)} " | |
| f"pages_sampled={min(n_pages, pages)}" | |
| ), | |
| ) | |
| result = _normalize( | |
| raw_dict, | |
| pages_total=pages, | |
| extraction_mode_suggested=suggested_mode, | |
| avg_chars_per_page=avg_chars, | |
| ) | |
| result.error = error | |
| return result | |