Aarya2004
Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)
47b2a99
Raw
History Blame Contribute Delete
6.11 kB
"""ParseModel: the Document Capture client (ADR-0011), backed by Nemotron Parse on Modal.
Parse is the Extraction Model Role (ADR-0009). It is VISUAL, so it does not fit the
vLLM OpenAI route the brain uses — it has its own Modal endpoint (modal_parse_app.py)
that POSTs a base64 image and returns structured blocks [{class, bbox, text}], having
already run the model's repo-shipped postprocessing server-side.
This client is the on-device half: it turns those blocks into the two things the
Estimate pipeline understands, per ADR-0011 decision C —
- a priced table row -> ProposedLineItem (human confirms the price via Agent Pause)
- everything else -> Observation(kind="text") (flows straight through)
The document is the *source*, but any price it read is *proposed*, never a fact: the
human gates every customer-facing number (Facts-from-Tools, ADR-0004).
The base URL (printed by `modal deploy modal_parse_app.py`) comes from FF_MODAL_PARSE_URL.
"""
import base64
import os
import re
import requests
from quillwright.models import Observation, ProposedLineItem
# Parse's table content arrives as markdown. A money cell looks like "$42.50",
# "$1,250.00", or "42.50" — capture the numeric value, tolerating $ and thousands commas.
_MONEY = re.compile(r"\$?\s*([\d,]+\.\d{1,2}|\d[\d,]*)")
# A leading integer/decimal in a cell is the quantity ("2", "4", "1.5").
_QTY = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*$")
class ParseModel:
name = "nemotron-parse-v1.2"
def __init__(self, base_url: str | None = None, timeout: float = 180.0):
self._base = (base_url or os.environ.get("FF_MODAL_PARSE_URL", "")).rstrip("/")
if not self._base:
raise RuntimeError(
"FF_MODAL_PARSE_URL is not set — deploy modal_parse_app.py and export "
"the URL it prints (see backends/modal_parse_app.py)."
)
self._timeout = timeout
def parse_document(self, image_path: str) -> tuple[list[Observation], list[ProposedLineItem]]:
"""Read a document image; return (observations, proposed_line_items)."""
with open(image_path, "rb") as fh:
b64 = base64.b64encode(fh.read()).decode("ascii")
resp = requests.post(f"{self._base}/parse", json={"image": b64}, timeout=self._timeout)
resp.raise_for_status()
blocks = resp.json().get("blocks", [])
return blocks_to_pipeline(blocks)
def blocks_to_pipeline(
blocks: list[dict],
) -> tuple[list[Observation], list[ProposedLineItem]]:
"""Split Parse blocks into Observations + Proposed Line Items (ADR-0011).
Pure function (no network) so it can be unit-tested against real Parse output.
Table blocks become priced ProposedLineItems where a price is present; their
non-priced rows and every non-table block become text Observations.
"""
observations: list[Observation] = []
proposed: list[ProposedLineItem] = []
for block in blocks:
cls = block.get("class", "")
text = (block.get("text") or "").strip()
if not text:
continue
if cls == "Table":
rows_obs, rows_items = _table_to_items(text)
observations.extend(rows_obs)
proposed.extend(rows_items)
else:
observations.append(Observation(kind="text", text=text))
return observations, proposed
def _table_to_items(
table_md: str,
) -> tuple[list[Observation], list[ProposedLineItem]]:
"""Parse a markdown table into priced line items, gating on a real price.
A row with a money cell -> ProposedLineItem (description = its longest text
cell, quantity from a bare-number cell if present, rate from the price).
A row with no price falls back to an Observation so nothing is silently dropped.
"""
observations: list[Observation] = []
proposed: list[ProposedLineItem] = []
for line in table_md.splitlines():
line = line.strip()
if not line or set(line) <= {"|", "-", " ", ":"}:
continue # blank or the header separator row (|---|---|)
cells = [c.strip() for c in line.strip("|").split("|")]
cells = [c for c in cells if c != ""]
if not cells:
continue
price = _row_price(cells)
if price is None:
observations.append(Observation(kind="text", text=" ".join(cells)))
continue
# Skip a header row that happens to contain the literal word "price" but no
# numeric description (e.g. "| Item | Qty | Price |" has no money cell, so it
# already fell through above — this guards a row that is only labels).
description = _row_description(cells)
if not description:
observations.append(Observation(kind="text", text=" ".join(cells)))
continue
proposed.append(
ProposedLineItem(
description=description,
quantity=_row_quantity(cells),
rate=price,
source_text=" ".join(cells),
)
)
return observations, proposed
def _row_price(cells: list[str]) -> float | None:
"""The price of a row = the money value in its last cell that has one.
Scanning right-to-left picks the line *total* / unit price over an earlier
quantity that also matches the number pattern.
"""
for cell in reversed(cells):
if "$" in cell or "." in cell:
m = _MONEY.search(cell)
if m:
return float(m.group(1).replace(",", ""))
return None
def _row_quantity(cells: list[str]) -> float:
"""A standalone integer/decimal cell is the quantity; default 1."""
for cell in cells:
m = _QTY.match(cell)
if m:
return float(m.group(1))
return 1.0
def _row_description(cells: list[str]) -> str:
"""The description is the longest cell that is neither a price nor a bare number."""
candidates = [
c for c in cells if not _QTY.match(c) and "$" not in c and not _MONEY.fullmatch(c)
]
return max(candidates, key=len) if candidates else ""