Spaces:
Runtime error
Runtime error
| """Layout- and table-aware PDF parsing (STEP 1). | |
| Replaces the plain ``PyMuPDFLoader`` (which flattens tables into ambiguous | |
| text and leaves OCR line-wrap corruption in place) with a hardened parser built | |
| on PyMuPDF (``fitz``) — already a project dependency, so no new packages. | |
| Per page we: | |
| 1. Extract text in reading order (``sort=True``) to reduce multi-column | |
| scrambling. | |
| 2. Detect tables with ``page.find_tables()`` and render each as Markdown | |
| wrapped in ``[TABLE]…[/TABLE]`` sentinels so condition-rating blocks and | |
| financial totals survive chunking with row/column structure intact and are | |
| NEVER flattened into prose. | |
| 3. Normalize the prose across pages: strip running headers/footers and page | |
| numbers, repair hyphenation, and unwrap hard-wrapped sentences. | |
| Output remains a list of LangChain ``Document`` objects (one per page) with the | |
| same ``page`` / ``source`` / ``total_pages`` metadata the rest of the pipeline | |
| expects, so this is a drop-in replacement. If anything fails, we fall back to | |
| the original ``PyMuPDFLoader`` so ingestion never hard-fails on a quirky PDF. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from pathlib import Path | |
| from langchain_core.documents import Document | |
| from app.ingest.ocr_normalize import TABLE_CLOSE, TABLE_OPEN, normalize_pages | |
| logger = logging.getLogger(__name__) | |
| def _cell(value: object) -> str: | |
| """Render one table cell: strip, collapse newlines, escape pipes.""" | |
| s = "" if value is None else str(value) | |
| return s.replace("\n", " ").replace("|", "\\|").strip() | |
| def _table_to_markdown(rows: list[list[object]]) -> str: | |
| """Render extracted table rows as a GitHub-flavoured Markdown table.""" | |
| cleaned = [[_cell(c) for c in row] for row in rows if row] | |
| cleaned = [r for r in cleaned if any(c for c in r)] | |
| if not cleaned: | |
| return "" | |
| width = max(len(r) for r in cleaned) | |
| cleaned = [r + [""] * (width - len(r)) for r in cleaned] | |
| header = cleaned[0] | |
| body = cleaned[1:] | |
| lines = ["| " + " | ".join(header) + " |", "| " + " | ".join(["---"] * width) + " |"] | |
| for r in body: | |
| lines.append("| " + " | ".join(r) + " |") | |
| return "\n".join(lines) | |
| def _extract_page_tables(page: object) -> list[str]: | |
| """Return Markdown for each table on ``page`` (best-effort, never raises).""" | |
| out: list[str] = [] | |
| try: | |
| finder = page.find_tables() | |
| except Exception as exc: # find_tables can choke on malformed content | |
| logger.debug("find_tables failed on a page: %s", exc) | |
| return out | |
| tables = getattr(finder, "tables", None) or [] | |
| for tbl in tables: | |
| try: | |
| md = _table_to_markdown(tbl.extract()) | |
| except Exception as exc: # noqa: BLE001 — a bad table must not kill the page | |
| logger.debug("table.extract failed: %s", exc) | |
| continue | |
| if md: | |
| out.append(f"{TABLE_OPEN}\n{md}\n{TABLE_CLOSE}") | |
| return out | |
| def _parse_pdf_fitz(file_path: Path) -> list[Document]: | |
| """Primary path: layout/table-aware extraction via PyMuPDF.""" | |
| import fitz # PyMuPDF — already a dependency | |
| doc = fitz.open(str(file_path)) | |
| try: | |
| total_pages = doc.page_count | |
| prose_pages: list[str] = [] | |
| page_tables: list[list[str]] = [] | |
| for page in doc: | |
| prose_pages.append(page.get_text("text", sort=True) or "") | |
| page_tables.append(_extract_page_tables(page)) | |
| finally: | |
| doc.close() | |
| normalized = normalize_pages(prose_pages) | |
| documents: list[Document] = [] | |
| for i, prose in enumerate(normalized): | |
| parts = [prose.strip()] if prose.strip() else [] | |
| parts.extend(page_tables[i]) | |
| content = "\n\n".join(parts) | |
| documents.append( | |
| Document( | |
| page_content=content, | |
| metadata={ | |
| "page": i, | |
| "total_pages": total_pages, | |
| "source": file_path.name, | |
| "file_path": str(file_path), | |
| "has_tables": bool(page_tables[i]), | |
| }, | |
| ) | |
| ) | |
| return documents | |
| def _parse_pdf_fallback(file_path: Path) -> list[Document]: | |
| """Fallback: original LangChain loader (no table structure, no normalization).""" | |
| from langchain_community.document_loaders import PyMuPDFLoader | |
| return PyMuPDFLoader(str(file_path)).load() | |
| def parse_pdf(file_path: Path) -> list[Document]: | |
| """Load a ``.pdf`` and return one normalized, table-aware Document per page. | |
| Args: | |
| file_path: Absolute path to a ``.pdf`` file. | |
| Returns: | |
| List of :class:`langchain_core.documents.Document`, one per page, with | |
| prose normalized and tables preserved as Markdown inside | |
| ``[TABLE]…[/TABLE]`` sentinels. | |
| Raises: | |
| FileNotFoundError: If ``file_path`` does not exist. | |
| ValueError: If the file cannot be opened as a valid PDF by either path. | |
| """ | |
| if not file_path.exists(): | |
| raise FileNotFoundError(f"File not found: {file_path}") | |
| try: | |
| docs = _parse_pdf_fitz(file_path) | |
| logger.debug( | |
| "Parsed %d page(s) from %s (table-aware PyMuPDF)", len(docs), file_path.name | |
| ) | |
| return docs | |
| except Exception as exc: # noqa: BLE001 — degrade gracefully, never lose ingestion | |
| logger.warning( | |
| "Table-aware PDF parse failed for %s (%s); falling back to PyMuPDFLoader", | |
| file_path.name, | |
| exc, | |
| ) | |
| try: | |
| return _parse_pdf_fallback(file_path) | |
| except Exception as exc2: | |
| raise ValueError(f"Cannot open PDF '{file_path}': {exc2}") from exc2 | |