File size: 2,630 Bytes
47b2a99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Document Capture: a handed-over document -> Observations + Proposed Line Items (ADR-0011).

Thin adapter over the Extraction role (Nemotron Parse on Modal): file path in, JSON
the frontend confirm card renders out. The model is injectable for tests. In
production it resolves to ParseModel when FF_MODAL_PARSE_URL is set; otherwise a
deterministic demo parse runs the REAL blocks_to_pipeline logic over a canned
supplier quote, so the stub Space demos the flow with zero models (the same
honest-scaffolding pattern as _stub_perception in api/estimate.py).
"""

import os

from quillwright.backends.parse import blocks_to_pipeline

# The canned supplier quote the demo "reads" when no Modal Parse endpoint is
# configured. Mirrors the test fixture in test_parse_backend.py.
_DEMO_BLOCKS = [
    {"class": "Title", "bbox": [], "text": "ACME HVAC Supply — Quote #1042"},
    {
        "class": "Table",
        "bbox": [],
        "text": (
            "| Item | Qty | Unit Price |\n"
            "| --- | --- | --- |\n"
            "| Dual run capacitor | 2 | $42.50 |\n"
            "| Compressor contactor | 1 | $28.00 |\n"
            "| R-410A refrigerant | 4 | $30.00 |\n"
        ),
    },
    {"class": "Text", "bbox": [], "text": "Net 30 terms. Prices valid 30 days."},
]


def _resolve_extraction():
    """Real Nemotron Parse when its Modal URL is configured; else None (demo parse)."""
    if os.environ.get("FF_MODAL_PARSE_URL"):
        from quillwright.resolver import ModelResolver

        return ModelResolver(mode="best", backend="modal").for_role("extraction")
    return None


def parse_document_capture(path: str, model=None) -> dict:
    """Parse the document at `path`; return {model, observations, proposed_items}.

    Every price stays *proposed* — the human confirms it in the UI before it becomes
    a LineItem with price_source="document" (Facts-from-Tools, ADR-0004/0011).
    """
    parser = model if model is not None else _resolve_extraction()
    if parser is None:
        observations, proposed = blocks_to_pipeline(_DEMO_BLOCKS)
        name = "parse-stub (demo quote)"
    else:
        observations, proposed = parser.parse_document(path)
        name = parser.name
    return {
        "model": name,
        "observations": [{"kind": o.kind, "text": o.text} for o in observations],
        "proposed_items": [
            {
                "description": p.description,
                "quantity": p.quantity,
                "unit": p.unit,
                "rate": p.rate,
                "source_text": p.source_text,
            }
            for p in proposed
        ],
    }