""" pdf_parser.py ───────────── Utility called by fastapi_main.py before invoking the LangGraph pipeline. Accepts a list of (filename, raw_bytes) tuples, parses each PDF with LiteParse, and returns a list of {"filename": str, "content": str} dicts ready to be stored in the LangGraph state as `pdf_context`. Each file's extracted text is truncated to MAX_SOURCE_CHARS (same cap used for web-scraped content) so the synthesis token budget is not blown. """ import tempfile import os from liteparse import LiteParse from config import MAX_SOURCE_CHARS def parse_pdfs(file_bytes_list: list[tuple[str, bytes]]) -> list[dict] | None: """ Parse a list of PDF files and return their text content. Args: file_bytes_list: List of (filename, raw_bytes) tuples. Returns: List of {"filename": str, "content": str} dicts, or None if input is empty. Files that fail to parse are skipped with a warning printed to stdout. """ if not file_bytes_list: return None parser = LiteParse() results = [] for filename, raw_bytes in file_bytes_list: # Write to a temp file because LiteParse expects a file path suffix = os.path.splitext(filename)[-1] or ".pdf" tmp = None try: with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(raw_bytes) tmp_path = tmp.name print(f" [PDF] Parsing: {filename} ({len(raw_bytes) / 1024:.1f} KB)") parsed = parser.parse(tmp_path) # Concatenate all page texts full_text = "\n\n".join( page.text for page in parsed.pages if page.text and page.text.strip() ) if not full_text.strip(): print(f" ⚠️ [PDF] No text extracted from: {filename} (may be image-only)") continue # Truncate to the same per-source cap used for web content if len(full_text) > MAX_SOURCE_CHARS: marker = "\n\n[PDF content truncated to fit the source budget.]" full_text = full_text[: MAX_SOURCE_CHARS - len(marker)].rsplit(" ", 1)[0] + marker results.append({"filename": filename, "content": full_text}) print(f" ✅ [PDF] Parsed: {filename} ({len(full_text):,} chars extracted)") except Exception as exc: print(f" ⚠️ [PDF] Failed to parse '{filename}': {type(exc).__name__}: {exc}") finally: # Always clean up the temp file if tmp is not None and os.path.exists(tmp.name): try: os.unlink(tmp.name) except OSError: pass return results if results else None