Aarya2004
Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)
47b2a99 | """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 "" | |