doc_rag / payload_utils.py
TytonTerrapin's picture
Upload 13 files
68fe04b verified
Raw
History Blame Contribute Delete
17.6 kB
"""
payload_utils.py — Space 2 helper library for querying the enriched IngestPayload.
Drop this file into Space 2's source tree. It is the *only* file Space 2 needs
to work with the new payload fields; it has no dependency on networkx, OpenCV,
or any OCR library.
All functions accept the raw payload dict (the JSON-decoded body of the POST
from Space 1) and return plain Python types.
Public API
----------
chunks_in_reading_order(payload)
-> list[dict] all chunks, sorted by document reading order
chunks_for_section(payload, section_title, *, partial=True)
-> list[dict] all chunks whose outline_path contains the given title
chunks_by_type(payload, region_type)
-> list[dict] all chunks of a given region_type ("paragraph"|"table"|"figure")
chunks_on_page(payload, page_num)
-> list[dict] all chunks whose source blocks are on page_num
outline_tree(payload)
-> list[dict] the nested section tree (the `outline` field, convenience accessor)
breadcrumb_for_chunk(payload, chunk_id)
-> list[str] the outline_path for a chunk, e.g. ["Methods","Data Collection"]
embed_text_for_chunk(chunk, payload=None)
-> str builds the recommended embedding string for a chunk.
Fixes applied vs the previous version:
1. Strips [Context: ...] prefix before embedding — these
carry-forward sentences from the chunker pollute embeddings
with unrelated topic signals and cause false-positive
retrieval (Issue #1 and #4 from the RAG issues report).
2. Prefixes the clean text with the full outline breadcrumb
so that a sentence like "it was found to be significant"
embeds near its section topic rather than floating in
generic space.
3. For table chunks: converts the plain-text table content
to a simple Markdown representation so the embedding
model sees structured rows rather than a flat string.
If table_html is present it is used directly (HTML
preserves row/column structure better than OCR'd text).
4. For figure chunks: only the caption / semantic description
is embedded, not raw OCR text from logos or decorative
elements. The figure's section_title is preserved in the
breadcrumb so the figure is still reachable by section.
strip_context_prefix(text)
-> str LEGACY GUARD — Space 1 now stores context in chunk.context
(separate field) so chunk.text is always clean. This function
is kept as a safety net for any old payloads still in flight
but is a no-op on current payloads.
table_text_to_markdown(text)
-> str best-effort conversion of a flat OCR'd table string into
Markdown pipe-table format so the embedding model sees
column structure. Used internally by embed_text_for_chunk.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
# ── context-prefix stripping ──────────────────────────────────────────────────
_CONTEXT_PREFIX_RE = re.compile(r'^\[Context:[^\]]*\]\s*\n?', re.DOTALL)
def strip_context_prefix(text: str) -> str:
"""
LEGACY GUARD: Space 1 now stores the carry-forward sentence in Chunk.context
(a separate field) so Chunk.text is always clean. This function is kept only
as a safety net for any old payloads still in flight.
On current payloads this is effectively a no-op.
"""
return _CONTEXT_PREFIX_RE.sub("", text).lstrip()
# ── table text → markdown ─────────────────────────────────────────────────────
def table_text_to_markdown(text: str) -> str:
"""
Best-effort conversion of a flat OCR'd table string to Markdown pipe-table.
The OCR pipeline produces tables as plain text with whitespace-separated
columns and newline-separated rows. Embedding that flat string loses all
column structure so parameter lookups ("what is the sensing radius?") fail
to retrieve the correct table row.
Strategy
--------
1. Split by newlines into candidate rows.
2. For each row, split on 2+ consecutive spaces (typical column separator
in OCR'd tables) or on tab characters.
3. Align all rows to the same column count (pad with empty strings).
4. Emit a Markdown pipe-table with a separator row after the first (header)
row.
If the text has fewer than 2 lines or cannot be split into multiple columns,
the original text is returned unchanged — no point adding Markdown noise to
a single-column block.
"""
lines = [l for l in text.splitlines() if l.strip()]
if len(lines) < 2:
return text
# Split each line on 2+ spaces or tabs
_col_split = re.compile(r'\t| +')
rows = [_col_split.split(line.strip()) for line in lines]
# Check that at least some rows have multiple columns
max_cols = max(len(r) for r in rows)
if max_cols < 2:
return text # single-column — not a structured table
# Pad every row to max_cols
rows = [r + [""] * (max_cols - len(r)) for r in rows]
def _row_to_pipe(cells: List[str]) -> str:
return "| " + " | ".join(c.strip() for c in cells) + " |"
header_sep = "| " + " | ".join("---" for _ in range(max_cols)) + " |"
md_rows = [_row_to_pipe(rows[0]), header_sep]
md_rows += [_row_to_pipe(r) for r in rows[1:]]
return "\n".join(md_rows)
# ── internal helpers ──────────────────────────────────────────────────────────
def _chunk_index(payload: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Build a chunk_id -> chunk dict from the payload."""
return {c["chunk_id"]: c for c in payload.get("chunks", [])}
# ── reading order ─────────────────────────────────────────────────────────────
def chunks_in_reading_order(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Return all chunks in document reading order.
Uses `reading_order` (list of block IDs) + `block_to_chunks` (block ->
chunk IDs) to reconstruct the sequence. Falls back to the order they
appear in payload["chunks"] when block_to_chunks is absent (old payloads).
"""
reading_order = payload.get("reading_order", [])
block_to_chunks = payload.get("block_to_chunks") or {}
chunk_idx = _chunk_index(payload)
if not block_to_chunks:
return list(payload.get("chunks", []))
seen: set = set()
ordered: List[Dict[str, Any]] = []
for block_id in reading_order:
for chunk_id in block_to_chunks.get(block_id, []):
if chunk_id not in seen and chunk_id in chunk_idx:
ordered.append(chunk_idx[chunk_id])
seen.add(chunk_id)
# Append any chunks not reached via reading_order (edge case)
for c in payload.get("chunks", []):
if c["chunk_id"] not in seen:
ordered.append(c)
return ordered
# ── section filtering ─────────────────────────────────────────────────────────
def chunks_for_section(
payload: Dict[str, Any],
section_title: str,
*,
partial: bool = True,
) -> List[Dict[str, Any]]:
"""
Return all chunks whose outline_path contains `section_title`.
Parameters
----------
section_title : the heading text to match (case-insensitive)
partial : if True (default), match substrings
("Data" matches "Data Collection")
if False, require exact match
Results are returned in reading order.
"""
needle = section_title.lower()
chunk_outline_path = payload.get("chunk_outline_path") or {}
def _matches(path: List[str]) -> bool:
for title in path:
t = title.lower()
if partial and needle in t:
return True
if not partial and t == needle:
return True
return False
ordered = chunks_in_reading_order(payload)
return [
c for c in ordered
if _matches(chunk_outline_path.get(c["chunk_id"], c.get("outline_path") or []))
]
# ── type filtering ────────────────────────────────────────────────────────────
def chunks_by_type(
payload: Dict[str, Any],
region_type: str,
) -> List[Dict[str, Any]]:
"""
Return all chunks of the given region_type in reading order.
region_type: "paragraph" | "table" | "figure"
Uses `content_type_index` for O(1) lookup then resolves to chunk dicts.
Falls back to a full scan if the index is absent (backwards compatibility).
"""
content_type_index = payload.get("content_type_index")
chunk_idx = _chunk_index(payload)
if content_type_index is not None:
ids = set(content_type_index.get(region_type, []))
return [c for c in chunks_in_reading_order(payload) if c["chunk_id"] in ids]
return [
c for c in chunks_in_reading_order(payload)
if c.get("region_type") == region_type
]
# ── page filtering ────────────────────────────────────────────────────────────
def chunks_on_page(
payload: Dict[str, Any],
page_num: int,
) -> List[Dict[str, Any]]:
"""
Return all chunks whose source blocks appear on `page_num` (1-indexed).
"""
page_index = payload.get("page_index")
block_to_chunks = payload.get("block_to_chunks") or {}
chunk_idx = _chunk_index(payload)
if page_index is not None:
block_ids = page_index.get(str(page_num), [])
seen: set = set()
result: List[Dict[str, Any]] = []
for block_id in block_ids:
for chunk_id in block_to_chunks.get(block_id, []):
if chunk_id not in seen and chunk_id in chunk_idx:
result.append(chunk_idx[chunk_id])
seen.add(chunk_id)
return result
return [c for c in payload.get("chunks", []) if c.get("page_num") == page_num]
# ── outline tree accessor ─────────────────────────────────────────────────────
def outline_tree(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return the nested section outline tree."""
return payload.get("outline") or []
def flat_outline(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Flatten the nested outline tree into a list of dicts in document order.
Each entry: { node_id, title, h_level, depth, chunk_ids }
"""
result: List[Dict[str, Any]] = []
def _walk(nodes: List[Dict[str, Any]], depth: int) -> None:
for node in nodes:
result.append({
"node_id": node["node_id"],
"title": node["title"],
"h_level": node["h_level"],
"depth": depth,
"chunk_ids": node["chunk_ids"],
})
_walk(node.get("children", []), depth + 1)
_walk(outline_tree(payload), 0)
return result
# ── breadcrumb helpers ────────────────────────────────────────────────────────
def breadcrumb_for_chunk(
payload: Dict[str, Any],
chunk_id: str,
) -> List[str]:
"""
Return the outline_path (ancestor title breadcrumb) for a specific chunk.
Checks `chunk_outline_path` index first, falls back to the `outline_path`
field on the chunk dict itself.
"""
path = (payload.get("chunk_outline_path") or {}).get(chunk_id)
if path is not None:
return path
chunk_idx = _chunk_index(payload)
chunk = chunk_idx.get(chunk_id)
if chunk:
return chunk.get("outline_path") or []
return []
# ── embedding text builder ────────────────────────────────────────────────────
def embed_text_for_chunk(
chunk: Dict[str, Any],
payload: Optional[Dict[str, Any]] = None,
) -> str:
"""
Build the recommended string to feed to your embedding model for a chunk.
Fixes applied (see module docstring for full rationale):
1. [Context: ...] prefix is stripped before embedding.
2. Full outline breadcrumb is prepended as "[h1 > h2 > h3]".
3. Table chunks are converted to Markdown (or HTML) for column structure.
4. Figure chunks embed only their caption/description text, not OCR noise.
Format (paragraph / title):
[Section > Subsection]
<clean chunk text>
Format (table):
[Section > Subsection]
| col1 | col2 | col3 |
| --- | --- | --- |
| val | val | val |
Format (figure):
[Section > Subsection]
Figure: <caption text>
"""
region = chunk.get("region_type", "paragraph")
# ── 1. resolve breadcrumb ─────────────────────────────────────────────────
if payload is not None:
path = breadcrumb_for_chunk(payload, chunk["chunk_id"])
else:
path = chunk.get("outline_path") or []
# Fall back to section_title alone when no outline path exists
if not path:
section = (chunk.get("section_title") or "").strip()
path = [section] if section else []
breadcrumb_str = " > ".join(path) if path else ""
# ── 2. build raw text for this region type ────────────────────────────────
if region == "table":
# Prefer structured HTML (preserves rows/columns perfectly for the LLM).
# text is already clean (no [Context:] prefix — Space 1 FIX #1).
if chunk.get("table_html"):
body = chunk["table_html"]
else:
# Convert OCR'd flat text to Markdown pipe-table.
# strip_context_prefix is a no-op on current payloads but kept as guard.
raw = strip_context_prefix(chunk.get("text") or "")
body = table_text_to_markdown(raw)
elif region == "figure":
raw = strip_context_prefix(chunk.get("text") or "")
# Space 1 FIX #7/#8: chart OCR noise is already replaced with a semantic
# placeholder like "[Figure on page N — chart or diagram ...]" by the
# chunker. Detect that and embed it directly; only run caption extraction
# on genuine figure OCR text.
if raw.startswith("[Figure on page"):
body = raw
else:
caption = _extract_figure_caption(raw)
body = f"Figure: {caption}" if caption else f"Figure (no caption): {raw[:120]}"
else:
# paragraph / title / unknown
# text is already clean — strip_context_prefix is a no-op guard only.
body = strip_context_prefix(chunk.get("text") or "")
# ── 3. assemble final string ──────────────────────────────────────────────
if breadcrumb_str:
return f"[{breadcrumb_str}]\n{body}"
return body
def _extract_figure_caption(ocr_text: str) -> str:
"""
Extract the human-readable caption from a figure OCR block.
Figure OCR blocks typically contain:
- Axis labels, tick values, legend text (noise)
- A caption line beginning with "Figure", "Fig.", "FIG", or a digit
Strategy: scan lines for a caption-pattern match; if none is found return
the full text (better than nothing).
"""
_CAPTION_RE = re.compile(
r'^(fig(?:ure)?\.?\s*\d+|figure\s+\d+|\d+\s*[:\.])',
re.IGNORECASE,
)
lines = [l.strip() for l in ocr_text.splitlines() if l.strip()]
for line in lines:
if _CAPTION_RE.match(line):
return line
# No structured caption found — return all lines that look like prose
prose = " ".join(
l for l in lines
if len(l) > 20 and not re.match(r'^[\d\s\.\-\+]+$', l)
)
return prose or ocr_text.strip()