import os import re import time import uuid from typing import Optional, List, Dict, Any import fitz from pageindex.client import PageIndexClient from utils.logger import app_logger class DocumentService: def __init__(self, api_key: Optional[str] = None): api_keys = [] if api_key: api_keys.append(api_key) api_keys.extend( [ os.getenv("PAGEINDEX_API_KEY_1"), os.getenv("PAGEINDEX_API_KEY_2"), os.getenv("PAGEINDEX_API_KEY_3"), ] ) self.api_keys = [key.strip() for key in api_keys if key and key.strip()] def _extract_images(self, file_path: str, max_images: int = 30) -> dict: doc = fitz.open(file_path) static_dir = os.path.join(os.getcwd(), "app", "static") if not os.path.exists(static_dir): static_dir = os.path.join(os.getcwd(), "static") os.makedirs(static_dir, exist_ok=True) page_images = {} total_extracted = 0 for page_index in range(len(doc)): if total_extracted >= max_images: break page = doc[page_index] images = [] # 1. Raster images for img in page.get_images(full=True): if total_extracted >= max_images: break xref = img[0] try: extracted = doc.extract_image(xref) ext = extracted.get("ext", "png") image_bytes = extracted.get("image") if not image_bytes or len(image_bytes) < 1024: continue filename = f"{uuid.uuid4().hex}.{ext}" path = os.path.join(static_dir, filename) with open(path, "wb") as f: f.write(image_bytes) images.append(f"/static/{filename}") total_extracted += 1 except Exception: continue # 2. Vector figures / diagram captions (LaTeX / scientific publications) text_blocks = page.get_text("blocks") for b in text_blocks: if total_extracted >= max_images: break b_text = b[4].strip() m = re.match(r'(Fig(?:ure|\.)\s*(\d+)\.?\s*(.*?))(?:\n|$)', b_text, re.IGNORECASE) if m: fig_num = m.group(2) caption_y = b[1] is_col2 = b[0] >= 280 is_col1 = b[2] <= 320 if is_col2: x0, x1 = 300, min(page.rect.width - 35, b[2] + 20) elif is_col1: x0, x1 = 40, 300 else: x0, x1 = 40, page.rect.width - 40 y0 = max(35, caption_y - 260) y1 = min(page.rect.height - 35, b[3] + 8) clip_rect = fitz.Rect(x0, y0, x1, y1) filename = f"fig_p{page_index + 1}_{fig_num}_{uuid.uuid4().hex[:6]}.png" path = os.path.join(static_dir, filename) try: pix = page.get_pixmap(clip=clip_rect, dpi=150) pix.save(path) images.append(f"/static/{filename}") total_extracted += 1 except Exception: continue if images: page_images[page_index + 1] = images doc.close() return page_images def _inject_images(self, tree: Any, page_images: dict): if isinstance(tree, dict): tree = [tree] if not isinstance(tree, list): return tree # Flatten nodes to determine page span of each section flat_nodes = [] def collect_nodes(items): for it in items: if isinstance(it, dict): flat_nodes.append(it) if it.get("nodes"): collect_nodes(it["nodes"]) collect_nodes(tree) for i, node in enumerate(flat_nodes): p_start = node.get("page_index") or node.get("page") if not p_start: continue p_next = flat_nodes[i + 1].get("page_index") or flat_nodes[i + 1].get("page") if i + 1 < len(flat_nodes) else None p_end = (p_next - 1) if p_next and p_next > p_start else p_start # Collect images across all pages in this section's span section_images = [] for p in range(p_start, p_end + 1): if p in page_images: section_images.extend(page_images[p]) if section_images: existing = node.get("images") or [] node["images"] = list(dict.fromkeys(existing + section_images)) return tree def _local_pdf_parse(self, file_path: str) -> list: """Fast local fallback parser using PyMuPDF when cloud indexing is unavailable or times out.""" app_logger.info(f"Extracting document structure locally via PyMuPDF for: {file_path}") doc = fitz.open(file_path) nodes = [] # Try to extract TOC / Outlines if available toc = doc.get_toc() # [[lvl, title, page], ...] page_texts = {} for i, page in enumerate(doc): page_texts[i + 1] = page.get_text() if toc and len(toc) > 0: for item in toc: level, title, page_num = item[0], item[1], item[2] text = page_texts.get(page_num, "") nodes.append({ "node_id": f"sec_{uuid.uuid4().hex[:8]}", "title": title.strip() or f"Section p.{page_num}", "page_index": page_num, "text": text, "summary": text[:200].replace("\n", " ").strip(), }) if not nodes: # Fallback: page by page extraction for page_num, text in page_texts.items(): if not text.strip(): continue lines = [line.strip() for line in text.split("\n") if line.strip()] first_heading = lines[0][:80] if lines else f"Page {page_num}" nodes.append({ "node_id": f"page_{page_num}", "title": first_heading, "page_index": page_num, "text": text, "summary": " ".join(lines[:3])[:250], }) doc.close() return nodes @staticmethod def _is_valid_tree(tree: Any) -> bool: if not tree: return False if isinstance(tree, dict): tree = [tree] if not isinstance(tree, list) or len(tree) == 0: return False def has_valid_content(items: list) -> bool: for it in items: if not isinstance(it, dict): continue if (it.get("title") and it["title"].strip()) or (it.get("text") and it["text"].strip()) or (it.get("summary") and it["summary"].strip()): return True if it.get("nodes") and isinstance(it["nodes"], list) and has_valid_content(it["nodes"]): return True return False return has_valid_content(tree) @staticmethod def _count_sections(tree: Any) -> int: if not isinstance(tree, list): return 1 if isinstance(tree, dict) else 0 count = 0 for node in tree: if isinstance(node, dict): count += 1 if node.get("nodes"): count += DocumentService._count_sections(node["nodes"]) return count def process_pdf( self, file_path: str, document_id: Optional[str] = None, on_stage: Optional[Any] = None, ) -> Dict[str, Any]: t_start = time.monotonic() doc_log = f"document_id={document_id} " if document_id else "" # 1. Physical page count of the PDF via PyMuPDF (guaranteed accurate) actual_page_count = 1 try: with fitz.open(file_path) as pdf_doc: actual_page_count = len(pdf_doc) except Exception as e: app_logger.warning(f"Could not read page count via PyMuPDF for {file_path}: {e}") app_logger.info(f"[INDEX] {doc_log}stage=upload pages={actual_page_count}") if on_stage: on_stage("Processing PDF", 15) page_images = self._extract_images(file_path) t_images = time.monotonic() - t_start if not self.api_keys: app_logger.warning(f"[INDEX] {doc_log}stage=fallback method=pymupdf reason='no_api_keys'") if on_stage: on_stage("Building document structure", 50) t_parse_start = time.monotonic() nodes = self._local_pdf_parse(file_path) t_parse = time.monotonic() - t_parse_start tree = self._inject_images(nodes, page_images) sec_count = self._count_sections(tree) total_time = time.monotonic() - t_start app_logger.info(f"[INDEX] {doc_log}stage=ready duration={total_time:.2f}s method=pymupdf pages={actual_page_count} sections={sec_count}") return { "tree": tree, "page_count": actual_page_count, "section_count": sec_count, "indexing_method": "pymupdf", "duration": round(total_time, 2), "failure_reason": "No PageIndex API keys configured", } last_error = None for key in self.api_keys: try: client = PageIndexClient(api_key=key) app_logger.info(f"[INDEX] {doc_log}stage=pageindex_submit key=...{key[-4:]}") if on_stage: on_stage("Building document structure", 25) t_sub_start = time.monotonic() submit_res = client.submit_document(file_path) cloud_doc_id = submit_res.get("doc_id") t_submit = time.monotonic() - t_sub_start if not cloud_doc_id: raise Exception(f"No doc_id returned by PageIndex: {submit_res}") # Poll until PageIndex tree generation is completed max_retries = 40 # up to ~75-85 seconds is_completed = False t_poll_start = time.monotonic() for attempt in range(max_retries): app_logger.info(f"[INDEX] {doc_log}stage=pageindex_poll attempt={attempt + 1}/{max_retries}") if on_stage: on_stage("Building document structure", min(75, 25 + int(attempt * 1.3))) meta = client.get_document(cloud_doc_id) status = meta.get("status") if status == "completed": is_completed = True app_logger.info(f"[INDEX] {doc_log}stage=pageindex_poll completed in {time.monotonic() - t_poll_start:.2f}s") break elif status == "failed": raise Exception(f"PageIndex document processing failed for {cloud_doc_id}") # Adaptive sleep: start fast (1.0s) then back off slightly (up to 2.2s) sleep_sec = min(1.0 + attempt * 0.08, 2.2) time.sleep(sleep_sec) t_poll = time.monotonic() - t_poll_start if is_completed: app_logger.info(f"[INDEX] {doc_log}stage=validation") if on_stage: on_stage("Validating index", 80) t_fetch_start = time.monotonic() tree_res = client.get_tree(cloud_doc_id, node_summary=True) raw_tree = tree_res.get("result", []) t_fetch = time.monotonic() - t_fetch_start if self._is_valid_tree(raw_tree): tree = self._inject_images(raw_tree, page_images) sec_count = self._count_sections(tree) total_time = time.monotonic() - t_start app_logger.info( f"[INDEX] {doc_log}stage=ready duration={total_time:.2f}s " f"method=pageindex pages={actual_page_count} sections={sec_count} " f"submit={t_submit:.2f}s poll={t_poll:.2f}s" ) return { "tree": tree, "page_count": actual_page_count, "section_count": sec_count, "indexing_method": "pageindex", "duration": round(total_time, 2), "failure_reason": None, } else: app_logger.warning(f"[INDEX] {doc_log}stage=validation status=invalid_tree") last_error = "PageIndex returned empty or incomplete tree" else: last_error = "PageIndex indexing poll timed out" # If reached here, PageIndex produced invalid tree or timed out -> fallback app_logger.info(f"[INDEX] {doc_log}stage=fallback method=pymupdf reason='{last_error}'") if on_stage: on_stage("Building document structure", 85) t_parse_start = time.monotonic() nodes = self._local_pdf_parse(file_path) t_parse = time.monotonic() - t_parse_start tree = self._inject_images(nodes, page_images) sec_count = self._count_sections(tree) total_time = time.monotonic() - t_start app_logger.info( f"[INDEX] {doc_log}stage=ready duration={total_time:.2f}s " f"method=pymupdf pages={actual_page_count} sections={sec_count} reason='{last_error}'" ) return { "tree": tree, "page_count": actual_page_count, "section_count": sec_count, "indexing_method": "pymupdf", "duration": round(total_time, 2), "failure_reason": last_error, } except Exception as e: error_msg = str(e) app_logger.warning(f"PageIndex attempt failed with key {key[-4:]}: {error_msg}") last_error = error_msg if "LimitReached" in error_msg: continue else: break app_logger.info(f"[INDEX] {doc_log}stage=fallback method=pymupdf reason='{last_error}'") if on_stage: on_stage("Building document structure", 85) t_parse_start = time.monotonic() nodes = self._local_pdf_parse(file_path) tree = self._inject_images(nodes, page_images) sec_count = self._count_sections(tree) total_time = time.monotonic() - t_start app_logger.info( f"[INDEX] {doc_log}stage=ready duration={total_time:.2f}s " f"method=pymupdf pages={actual_page_count} sections={sec_count} reason='{last_error}'" ) return { "tree": tree, "page_count": actual_page_count, "section_count": sec_count, "indexing_method": "pymupdf", "duration": round(total_time, 2), "failure_reason": last_error, }