Spaces:
Runtime error
Runtime error
File size: 5,679 Bytes
865bc90 dc1b199 865bc90 dc1b199 49f0cfb dc1b199 865bc90 dc1b199 865bc90 dc1b199 865bc90 dc1b199 865bc90 dc1b199 865bc90 dc1b199 865bc90 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | """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
|