Spaces:
Running
Running
Add complex invoice (MiniCPM vision OCR) + complex multi-step web automation tab
Browse files- .gitattributes +1 -0
- backend/app/browser/agent.py +30 -2
- backend/app/browser/session.py +70 -2
- backend/app/pipeline/nodes.py +77 -4
- backend/app/providers/__init__.py +10 -1
- backend/app/providers/minicpm_llm.py +113 -0
- backend/app/providers/pricing.py +3 -0
- backend/app/router.py +6 -0
- backend/app/schemas.py +4 -2
- backend/evals/datasets/complex_invoice_messy.gt.json +44 -0
- backend/evals/datasets/complex_invoice_messy.png +3 -0
- gradio_app.py +27 -0
- scripts/generate_complex_invoice.py +151 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
backend/evals/datasets/complex_invoice_messy.png filter=lfs diff=lfs merge=lfs -text
|
backend/app/browser/agent.py
CHANGED
|
@@ -52,6 +52,20 @@ def _parse_decision(text: str) -> dict:
|
|
| 52 |
|
| 53 |
def _build_plan(scenario: str, base_url: str, order: dict | None) -> list[dict]:
|
| 54 |
"""The recorded plan the offline provider replays (ignored by real LLMs)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if scenario == "order_fill":
|
| 56 |
order = order or {}
|
| 57 |
plan = [{"tool": "navigate", "args": {"url": f"{base_url}/orders/new"},
|
|
@@ -87,10 +101,11 @@ def run_browser_agent(
|
|
| 87 |
order: dict | None = None,
|
| 88 |
base_url: str | None = None,
|
| 89 |
headless: bool = True,
|
|
|
|
| 90 |
) -> dict:
|
| 91 |
base_url = base_url or settings.demo_portal_url
|
| 92 |
run_id = uuid.uuid4().hex
|
| 93 |
-
session = get_session(headless=headless)
|
| 94 |
registry = browser_registry()
|
| 95 |
registry.bind("navigate", lambda url: session.navigate(url))
|
| 96 |
registry.bind("click", lambda selector: session.click(selector))
|
|
@@ -101,6 +116,17 @@ def run_browser_agent(
|
|
| 101 |
plan = _build_plan(scenario, base_url, order)
|
| 102 |
tool_defs = _tool_defs_block(registry)
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
trace: list[dict] = []
|
| 105 |
final_result = None
|
| 106 |
last_extract = None
|
|
@@ -117,7 +143,8 @@ def run_browser_agent(
|
|
| 117 |
max_tokens=400,
|
| 118 |
context={"plan": plan, "step": step},
|
| 119 |
)
|
| 120 |
-
resp =
|
|
|
|
| 121 |
decision = _parse_decision(resp.text)
|
| 122 |
tool = decision.get("tool", "done")
|
| 123 |
args = decision.get("args", {}) or {}
|
|
@@ -158,6 +185,7 @@ def run_browser_agent(
|
|
| 158 |
agg = metrics.call_aggregates(run_id)
|
| 159 |
return {
|
| 160 |
"mode": "agentic",
|
|
|
|
| 161 |
"backend": session.backend,
|
| 162 |
"goal": goal,
|
| 163 |
"scenario": scenario,
|
|
|
|
| 52 |
|
| 53 |
def _build_plan(scenario: str, base_url: str, order: dict | None) -> list[dict]:
|
| 54 |
"""The recorded plan the offline provider replays (ignored by real LLMs)."""
|
| 55 |
+
if scenario == "complex_order":
|
| 56 |
+
# intricate multi-step interaction: dashboard → Procurement → +Create Order
|
| 57 |
+
# → read the complex order-form fields.
|
| 58 |
+
return [
|
| 59 |
+
{"tool": "navigate", "args": {"url": f"{base_url}/erp/"},
|
| 60 |
+
"reason": "Open the ERP dashboard."},
|
| 61 |
+
{"tool": "click", "args": {"selector": "#tile-procurement"},
|
| 62 |
+
"reason": "Click the Procurement module tile."},
|
| 63 |
+
{"tool": "click", "args": {"selector": "#create-order"},
|
| 64 |
+
"reason": "Click '+ Create Order' to open the order-form modal."},
|
| 65 |
+
{"tool": "extract", "args": {},
|
| 66 |
+
"reason": "Read the complex order fields (vendor, terms, ship-to, line items, totals, approver)."},
|
| 67 |
+
{"tool": "done", "args": {"result": None}, "reason": "Captured the order; finish."},
|
| 68 |
+
]
|
| 69 |
if scenario == "order_fill":
|
| 70 |
order = order or {}
|
| 71 |
plan = [{"tool": "navigate", "args": {"url": f"{base_url}/orders/new"},
|
|
|
|
| 101 |
order: dict | None = None,
|
| 102 |
base_url: str | None = None,
|
| 103 |
headless: bool = True,
|
| 104 |
+
prefer_simulated: bool | None = None,
|
| 105 |
) -> dict:
|
| 106 |
base_url = base_url or settings.demo_portal_url
|
| 107 |
run_id = uuid.uuid4().hex
|
| 108 |
+
session = get_session(headless=headless, prefer_simulated=prefer_simulated)
|
| 109 |
registry = browser_registry()
|
| 110 |
registry.bind("navigate", lambda url: session.navigate(url))
|
| 111 |
registry.bind("click", lambda selector: session.click(selector))
|
|
|
|
| 116 |
plan = _build_plan(scenario, base_url, order)
|
| 117 |
tool_defs = _tool_defs_block(registry)
|
| 118 |
|
| 119 |
+
# Decision "brain": a capable frontier agent LLM (Claude/Gemini) drives autonomously
|
| 120 |
+
# when configured; otherwise we replay a recorded plan deterministically (reliable RPA).
|
| 121 |
+
# (MiniCPM-V is used for OCR/extraction, not as the browser-agent controller.)
|
| 122 |
+
reg = router.registry
|
| 123 |
+
if reg.anthropic and reg.anthropic.available():
|
| 124 |
+
agent_provider, agent_model, agent_mode = reg.anthropic, settings.anthropic_model_smart, "llm:claude"
|
| 125 |
+
elif reg.gemini and reg.gemini.available():
|
| 126 |
+
agent_provider, agent_model, agent_mode = reg.gemini, settings.gemini_model, "llm:gemini"
|
| 127 |
+
else:
|
| 128 |
+
agent_provider, agent_model, agent_mode = reg.mock, "mock", "deterministic-plan"
|
| 129 |
+
|
| 130 |
trace: list[dict] = []
|
| 131 |
final_result = None
|
| 132 |
last_extract = None
|
|
|
|
| 143 |
max_tokens=400,
|
| 144 |
context={"plan": plan, "step": step},
|
| 145 |
)
|
| 146 |
+
resp = agent_provider.complete(req, agent_model)
|
| 147 |
+
metrics.record_call(run_id, resp, "agent")
|
| 148 |
decision = _parse_decision(resp.text)
|
| 149 |
tool = decision.get("tool", "done")
|
| 150 |
args = decision.get("args", {}) or {}
|
|
|
|
| 185 |
agg = metrics.call_aggregates(run_id)
|
| 186 |
return {
|
| 187 |
"mode": "agentic",
|
| 188 |
+
"agent_mode": agent_mode,
|
| 189 |
"backend": session.backend,
|
| 190 |
"goal": goal,
|
| 191 |
"scenario": scenario,
|
backend/app/browser/session.py
CHANGED
|
@@ -33,6 +33,28 @@ ORDER_FORM_FIELDS = [
|
|
| 33 |
]
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
@dataclass
|
| 37 |
class ActionRecord:
|
| 38 |
tool: str
|
|
@@ -52,18 +74,39 @@ class SimulatedBrowser:
|
|
| 52 |
screenshots: int = 0
|
| 53 |
log: list = field(default_factory=list)
|
| 54 |
backend: str = "simulated"
|
|
|
|
| 55 |
|
| 56 |
# --- tool primitives ---
|
| 57 |
def navigate(self, url: str) -> ActionRecord:
|
| 58 |
self.url = url
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
self.log.append(rec)
|
| 61 |
return rec
|
| 62 |
|
| 63 |
def click(self, selector: str) -> ActionRecord:
|
| 64 |
ok = True
|
|
|
|
| 65 |
note = f"clicked {selector}"
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
self.submitted = True
|
| 68 |
self.confirmation_id = f"CONF-{uuid.uuid4().hex[:6].upper()}"
|
| 69 |
note = f"submitted form → confirmation {self.confirmation_id}"
|
|
@@ -84,6 +127,9 @@ class SimulatedBrowser:
|
|
| 84 |
return rec
|
| 85 |
|
| 86 |
def extract(self) -> dict:
|
|
|
|
|
|
|
|
|
|
| 87 |
if "orders" in self.url and "new" not in self.url:
|
| 88 |
data = {"orders": PENDING_ORDERS}
|
| 89 |
elif self.submitted:
|
|
@@ -101,6 +147,28 @@ class SimulatedBrowser:
|
|
| 101 |
|
| 102 |
# --- sanitized page state for the LLM (the 'compact markdown DOM') ---
|
| 103 |
def get_state(self) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
if self.submitted:
|
| 105 |
return (f"# Order Portal — Confirmation\nURL: {self.url}\n\n"
|
| 106 |
f"Order submitted. Confirmation ID: **{self.confirmation_id}**\n"
|
|
|
|
| 33 |
]
|
| 34 |
|
| 35 |
|
| 36 |
+
# Complex order the ERP "Create Order" modal exposes (read by the agent).
|
| 37 |
+
COMPLEX_ORDER = {
|
| 38 |
+
"po_number": "PO-2026-88440",
|
| 39 |
+
"vendor": "Meridian Industrial Components Ltd",
|
| 40 |
+
"contact": "j.harlow@meridian.example",
|
| 41 |
+
"incoterms": "DAP",
|
| 42 |
+
"payment_terms": "Net 45",
|
| 43 |
+
"priority": "Expedited",
|
| 44 |
+
"ship_to": {"facility": "Aperture DC-West (Bay 14)",
|
| 45 |
+
"address": "4400 Logistics Pkwy, Reno NV 89506, USA",
|
| 46 |
+
"need_by": "2026-07-09"},
|
| 47 |
+
"line_items": [
|
| 48 |
+
{"sku": "HX-220", "description": "Hydraulic pump HX-220", "qty": 4, "unit_price": 1250.00, "amount": 5000.00},
|
| 49 |
+
{"sku": "SK-12", "description": "Seal kit (set of 12)", "qty": 10, "unit_price": 85.50, "amount": 855.00},
|
| 50 |
+
{"sku": "PG-300", "description": "Pressure gauge 0-300psi", "qty": 6, "unit_price": 142.75, "amount": 856.50},
|
| 51 |
+
{"sku": "LAB-1", "description": "Installation labor", "qty": 1, "unit_price": 2400.00, "amount": 2400.00},
|
| 52 |
+
],
|
| 53 |
+
"subtotal": 9111.50, "tax": 751.70, "freight": 180.00, "total": 10043.20, "currency": "USD",
|
| 54 |
+
"approver": "Priya Anand (Procurement Lead)",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
@dataclass
|
| 59 |
class ActionRecord:
|
| 60 |
tool: str
|
|
|
|
| 74 |
screenshots: int = 0
|
| 75 |
log: list = field(default_factory=list)
|
| 76 |
backend: str = "simulated"
|
| 77 |
+
erp_stage: str | None = None # None | dashboard | procurement | form
|
| 78 |
|
| 79 |
# --- tool primitives ---
|
| 80 |
def navigate(self, url: str) -> ActionRecord:
|
| 81 |
self.url = url
|
| 82 |
+
note = f"loaded {url}"
|
| 83 |
+
if "/erp" in url:
|
| 84 |
+
self.erp_stage = "dashboard"
|
| 85 |
+
note = f"loaded ERP dashboard ({url})"
|
| 86 |
+
rec = ActionRecord("navigate", {"url": url}, True, note)
|
| 87 |
self.log.append(rec)
|
| 88 |
return rec
|
| 89 |
|
| 90 |
def click(self, selector: str) -> ActionRecord:
|
| 91 |
ok = True
|
| 92 |
+
sel = selector.lower()
|
| 93 |
note = f"clicked {selector}"
|
| 94 |
+
# --- multi-step ERP flow: dashboard → procurement → order modal ---
|
| 95 |
+
if self.erp_stage is not None:
|
| 96 |
+
if "procurement" in sel:
|
| 97 |
+
self.erp_stage = "procurement"
|
| 98 |
+
note = "opened Procurement module"
|
| 99 |
+
elif "create-order" in sel or "create order" in sel:
|
| 100 |
+
self.erp_stage = "form"
|
| 101 |
+
note = "clicked '+ Create Order' → order form modal opened"
|
| 102 |
+
elif "submit" in sel:
|
| 103 |
+
self.submitted = True
|
| 104 |
+
self.confirmation_id = f"PO-CONF-{uuid.uuid4().hex[:6].upper()}"
|
| 105 |
+
note = f"submitted order for approval → {self.confirmation_id}"
|
| 106 |
+
rec = ActionRecord("click", {"selector": selector}, ok, note)
|
| 107 |
+
self.log.append(rec)
|
| 108 |
+
return rec
|
| 109 |
+
if "submit" in sel:
|
| 110 |
self.submitted = True
|
| 111 |
self.confirmation_id = f"CONF-{uuid.uuid4().hex[:6].upper()}"
|
| 112 |
note = f"submitted form → confirmation {self.confirmation_id}"
|
|
|
|
| 127 |
return rec
|
| 128 |
|
| 129 |
def extract(self) -> dict:
|
| 130 |
+
if self.erp_stage == "form":
|
| 131 |
+
self.log.append(ActionRecord("extract", {}, True, "read complex order form fields"))
|
| 132 |
+
return dict(COMPLEX_ORDER)
|
| 133 |
if "orders" in self.url and "new" not in self.url:
|
| 134 |
data = {"orders": PENDING_ORDERS}
|
| 135 |
elif self.submitted:
|
|
|
|
| 147 |
|
| 148 |
# --- sanitized page state for the LLM (the 'compact markdown DOM') ---
|
| 149 |
def get_state(self) -> str:
|
| 150 |
+
# multi-step ERP flow states
|
| 151 |
+
if self.erp_stage == "dashboard":
|
| 152 |
+
return ("# Acme ERP — Dashboard\nURL: " + self.url +
|
| 153 |
+
"\n\nModules (tiles):\n"
|
| 154 |
+
"- Procurement (link `#tile-procurement` → procurement/) — create & manage POs\n"
|
| 155 |
+
"- Invoices `#tile-invoices` · Suppliers `#tile-suppliers` · Reports `#tile-reports`\n"
|
| 156 |
+
"Goal hint: open Procurement to create/read an order.")
|
| 157 |
+
if self.erp_stage == "procurement":
|
| 158 |
+
return ("# Acme ERP — Procurement\nURL: " + self.url +
|
| 159 |
+
"\n\nA table of purchase orders is shown.\n"
|
| 160 |
+
"Button: '+ Create Order' (selector `#create-order`) — opens the new-order form modal.")
|
| 161 |
+
if self.erp_stage == "form":
|
| 162 |
+
o = COMPLEX_ORDER
|
| 163 |
+
lines = "\n".join(f" - {li['sku']} {li['description']} x{li['qty']} @ ${li['unit_price']:.2f} = ${li['amount']:.2f}"
|
| 164 |
+
for li in o["line_items"])
|
| 165 |
+
return (f"# New Purchase Order (modal)\nURL: {self.url}\n\n"
|
| 166 |
+
f"PO: {o['po_number']} · Vendor: {o['vendor']} · Terms: {o['payment_terms']} · Priority: {o['priority']}\n"
|
| 167 |
+
f"Ship to: {o['ship_to']['facility']}, {o['ship_to']['address']} (need-by {o['ship_to']['need_by']})\n"
|
| 168 |
+
f"Line items:\n{lines}\n"
|
| 169 |
+
f"Subtotal ${o['subtotal']:.2f} · Tax ${o['tax']:.2f} · Freight ${o['freight']:.2f} · "
|
| 170 |
+
f"Total ${o['total']:.2f} {o['currency']} · Approver {o['approver']}\n"
|
| 171 |
+
f"Buttons: 'Submit for approval' (`#submit-order`). Use extract() to read the fields.")
|
| 172 |
if self.submitted:
|
| 173 |
return (f"# Order Portal — Confirmation\nURL: {self.url}\n\n"
|
| 174 |
f"Order submitted. Confirmation ID: **{self.confirmation_id}**\n"
|
backend/app/pipeline/nodes.py
CHANGED
|
@@ -44,7 +44,31 @@ def _parse_json(text: str) -> dict:
|
|
| 44 |
return {}
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def _coerce_to_schema(data: dict, doc_type: str) -> dict:
|
|
|
|
| 48 |
schema = SCHEMA_BY_TYPE.get(doc_type)
|
| 49 |
if schema is None:
|
| 50 |
return data
|
|
@@ -126,6 +150,24 @@ def classify_node(state: dict, ctx: PipelineContext) -> dict:
|
|
| 126 |
}
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def extract_node(state: dict, ctx: PipelineContext) -> dict:
|
| 130 |
doc_type = state["doc_type"]
|
| 131 |
|
|
@@ -149,6 +191,34 @@ def extract_node(state: dict, ctx: PipelineContext) -> dict:
|
|
| 149 |
|
| 150 |
schema = SCHEMA_BY_TYPE[doc_type]
|
| 151 |
schema_json = json.dumps(schema.model_json_schema().get("properties", {}), indent=0)[:1500]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
text = (state.get("text") or "")[:MAX_TEXT_FOR_LLM]
|
| 153 |
req = LLMRequest(
|
| 154 |
# cacheable prefix: instructions + schema (stable across all docs of this type)
|
|
@@ -250,12 +320,15 @@ def validate_node(state: dict, ctx: PipelineContext) -> dict:
|
|
| 250 |
doc_type = state["doc_type"]
|
| 251 |
data = state.get("normalized") or state.get("extracted") or {}
|
| 252 |
vr = validate_financials(data, doc_type)
|
| 253 |
-
#
|
|
|
|
| 254 |
coverage = float(state.get("coverage", 1.0))
|
| 255 |
-
|
|
|
|
|
|
|
| 256 |
flags = list(vr.flags)
|
| 257 |
-
if
|
| 258 |
-
flags.append(f"
|
| 259 |
return {
|
| 260 |
"validation": {"confidence": vr.confidence, "checks": vr.checks, "flags": vr.flags},
|
| 261 |
"confidence": blended,
|
|
|
|
| 44 |
return {}
|
| 45 |
|
| 46 |
|
| 47 |
+
_NUM_FIELDS = {"subtotal", "tax_amount", "tax", "total", "shipping", "freight", "amount",
|
| 48 |
+
"unit_price", "line_total", "quantity", "contract_value", "selected_total",
|
| 49 |
+
"grand_total", "net_total", "vat_amount"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _clean_numbers(data):
|
| 53 |
+
"""Convert money-formatted strings ('9,111.50', '$1,250.00') to floats anywhere in
|
| 54 |
+
the structure — models often return formatted numbers regardless of instructions."""
|
| 55 |
+
from .. import extraction_heuristics as H
|
| 56 |
+
if isinstance(data, dict):
|
| 57 |
+
out = {}
|
| 58 |
+
for k, v in data.items():
|
| 59 |
+
if isinstance(v, str) and k in _NUM_FIELDS:
|
| 60 |
+
m = H.parse_money(v)
|
| 61 |
+
out[k] = m if m is not None else v
|
| 62 |
+
else:
|
| 63 |
+
out[k] = _clean_numbers(v)
|
| 64 |
+
return out
|
| 65 |
+
if isinstance(data, list):
|
| 66 |
+
return [_clean_numbers(x) for x in data]
|
| 67 |
+
return data
|
| 68 |
+
|
| 69 |
+
|
| 70 |
def _coerce_to_schema(data: dict, doc_type: str) -> dict:
|
| 71 |
+
data = _clean_numbers(data)
|
| 72 |
schema = SCHEMA_BY_TYPE.get(doc_type)
|
| 73 |
if schema is None:
|
| 74 |
return data
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
|
| 153 |
+
def _image_bytes(file_path: str) -> bytes:
|
| 154 |
+
"""First-page PNG bytes for vision extraction (image file as-is; PDF → rasterize)."""
|
| 155 |
+
p = Path(file_path)
|
| 156 |
+
if p.suffix.lower() in {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}:
|
| 157 |
+
return p.read_bytes()
|
| 158 |
+
import io
|
| 159 |
+
|
| 160 |
+
import fitz # PyMuPDF
|
| 161 |
+
doc = fitz.open(str(p))
|
| 162 |
+
pix = doc.load_page(0).get_pixmap(matrix=fitz.Matrix(2, 2))
|
| 163 |
+
from PIL import Image
|
| 164 |
+
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
| 165 |
+
doc.close()
|
| 166 |
+
buf = io.BytesIO()
|
| 167 |
+
img.save(buf, format="PNG")
|
| 168 |
+
return buf.getvalue()
|
| 169 |
+
|
| 170 |
+
|
| 171 |
def extract_node(state: dict, ctx: PipelineContext) -> dict:
|
| 172 |
doc_type = state["doc_type"]
|
| 173 |
|
|
|
|
| 191 |
|
| 192 |
schema = SCHEMA_BY_TYPE[doc_type]
|
| 193 |
schema_json = json.dumps(schema.model_json_schema().get("properties", {}), indent=0)[:1500]
|
| 194 |
+
|
| 195 |
+
# VISION EXTRACTION: for scanned / complex-layout docs, send the IMAGE directly to
|
| 196 |
+
# the VLM (MiniCPM-V) for structured JSON — it sees the layout (rotated text,
|
| 197 |
+
# scattered values, watermarks) that linear OCR text loses.
|
| 198 |
+
reg = getattr(ctx.router, "registry", None)
|
| 199 |
+
vlm = getattr(reg, "minicpm", None)
|
| 200 |
+
if vlm and vlm.available() and state.get("channel") == "scanned":
|
| 201 |
+
try:
|
| 202 |
+
img = _image_bytes(state["file_path"])
|
| 203 |
+
fields = list(schema.model_json_schema().get("properties", {}).keys())
|
| 204 |
+
data, usage = vlm.vision_extract(img, json.dumps(fields), doc_type)
|
| 205 |
+
if data:
|
| 206 |
+
from ..providers.base import LLMResponse, Usage
|
| 207 |
+
resp = LLMResponse.build(
|
| 208 |
+
text=json.dumps(data), model=f"minicpm-vision:{ctx.settings.minicpm_model}",
|
| 209 |
+
provider="minicpm", usage=Usage(
|
| 210 |
+
input_tokens=usage.get("prompt_tokens", 800),
|
| 211 |
+
output_tokens=usage.get("completion_tokens", 200)),
|
| 212 |
+
latency_ms=0.0, routing_reason="vision extraction (image→JSON)")
|
| 213 |
+
ctx.metrics.record_call(ctx.run_id, resp, "extract")
|
| 214 |
+
data.setdefault("doc_type", doc_type)
|
| 215 |
+
extracted = _coerce_to_schema(data, doc_type)
|
| 216 |
+
return {"extracted": extracted, "extract_model": resp.model,
|
| 217 |
+
"_summary": f"Vision extraction (image→JSON) via {resp.model} — "
|
| 218 |
+
"reads complex layout directly"}
|
| 219 |
+
except Exception:
|
| 220 |
+
pass # fall back to text-LLM extraction below
|
| 221 |
+
|
| 222 |
text = (state.get("text") or "")[:MAX_TEXT_FOR_LLM]
|
| 223 |
req = LLMRequest(
|
| 224 |
# cacheable prefix: instructions + schema (stable across all docs of this type)
|
|
|
|
| 320 |
doc_type = state["doc_type"]
|
| 321 |
data = state.get("normalized") or state.get("extracted") or {}
|
| 322 |
vr = validate_financials(data, doc_type)
|
| 323 |
+
# Effective input quality: a digital text layer is 1.0; a scanned doc uses its OCR
|
| 324 |
+
# engine's confidence (a high-confidence VLM like MiniCPM is NOT treated as 0).
|
| 325 |
coverage = float(state.get("coverage", 1.0))
|
| 326 |
+
ocr_conf = float(((state.get("ocr") or {}).get("ocr_channel") or {}).get("confidence", 0.0) or 0.0)
|
| 327 |
+
quality = coverage if coverage >= 0.5 else max(coverage, ocr_conf)
|
| 328 |
+
blended = round(0.8 * vr.confidence + 0.2 * quality, 3)
|
| 329 |
flags = list(vr.flags)
|
| 330 |
+
if quality < 0.5:
|
| 331 |
+
flags.append(f"low_ocr_quality:{round(quality,2)}")
|
| 332 |
return {
|
| 333 |
"validation": {"confidence": vr.confidence, "checks": vr.checks, "flags": vr.flags},
|
| 334 |
"confidence": blended,
|
backend/app/providers/__init__.py
CHANGED
|
@@ -41,13 +41,20 @@ class ProviderRegistry:
|
|
| 41 |
self.local = LocalProvider(
|
| 42 |
settings.ollama_base_url, settings.local_model, settings.local_backend
|
| 43 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def capabilities(self) -> dict:
|
| 46 |
"""Report which tiers are live — surfaced at /api/capabilities."""
|
| 47 |
anthropic_ok = bool(self.anthropic) and self.anthropic.available()
|
| 48 |
gemini_ok = bool(self.gemini) and self.gemini.available()
|
| 49 |
local_ok = bool(self.local) and self.local.available()
|
| 50 |
-
|
|
|
|
| 51 |
active = "hosted"
|
| 52 |
elif local_ok:
|
| 53 |
active = "local"
|
|
@@ -65,6 +72,8 @@ class ProviderRegistry:
|
|
| 65 |
"local": {"available": local_ok, "tier": "local",
|
| 66 |
"model": self.settings.local_model,
|
| 67 |
"backend": self.settings.local_backend},
|
|
|
|
|
|
|
| 68 |
},
|
| 69 |
"ocr": _ocr_capabilities(),
|
| 70 |
"browser": {"playwright": _has("playwright")},
|
|
|
|
| 41 |
self.local = LocalProvider(
|
| 42 |
settings.ollama_base_url, settings.local_model, settings.local_backend
|
| 43 |
)
|
| 44 |
+
# MiniCPM-V as an LLM (text reasoning) — a capable small model.
|
| 45 |
+
self.minicpm = None
|
| 46 |
+
if settings.minicpm_base_url:
|
| 47 |
+
from .minicpm_llm import MiniCPMLLMProvider
|
| 48 |
+
self.minicpm = MiniCPMLLMProvider(
|
| 49 |
+
settings.minicpm_base_url, settings.minicpm_api_key, settings.minicpm_model)
|
| 50 |
|
| 51 |
def capabilities(self) -> dict:
|
| 52 |
"""Report which tiers are live — surfaced at /api/capabilities."""
|
| 53 |
anthropic_ok = bool(self.anthropic) and self.anthropic.available()
|
| 54 |
gemini_ok = bool(self.gemini) and self.gemini.available()
|
| 55 |
local_ok = bool(self.local) and self.local.available()
|
| 56 |
+
minicpm_ok = bool(self.minicpm) and self.minicpm.available()
|
| 57 |
+
if anthropic_ok or gemini_ok or minicpm_ok:
|
| 58 |
active = "hosted"
|
| 59 |
elif local_ok:
|
| 60 |
active = "local"
|
|
|
|
| 72 |
"local": {"available": local_ok, "tier": "local",
|
| 73 |
"model": self.settings.local_model,
|
| 74 |
"backend": self.settings.local_backend},
|
| 75 |
+
"minicpm": {"available": minicpm_ok, "tier": "hosted",
|
| 76 |
+
"model": self.settings.minicpm_model},
|
| 77 |
},
|
| 78 |
"ocr": _ocr_capabilities(),
|
| 79 |
"browser": {"playwright": _has("playwright")},
|
backend/app/providers/minicpm_llm.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniCPM-V as an LLM provider (text reasoning) — not just OCR.
|
| 2 |
+
|
| 3 |
+
MiniCPM-V-4.6 (~8B) is a capable small model. Beyond image OCR, we use it for the
|
| 4 |
+
text reasoning steps (classify / extract / normalize) via the same OpenAI-compatible
|
| 5 |
+
ModelBest endpoint. This makes the whole pipeline run on a single small model — ideal
|
| 6 |
+
for the Build Small hackathon — instead of falling back to regex heuristics.
|
| 7 |
+
|
| 8 |
+
For complex/scattered documents it can also extract structured JSON directly from the
|
| 9 |
+
*image* (vision extraction); see `vision_extract`.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import base64
|
| 14 |
+
import json
|
| 15 |
+
import urllib.request
|
| 16 |
+
|
| 17 |
+
from .base import LLMProvider, LLMRequest, LLMResponse, Usage, estimate_tokens
|
| 18 |
+
from ..ocr.backends.minicpm import _ssl_context
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MiniCPMLLMProvider(LLMProvider):
|
| 22 |
+
name = "minicpm"
|
| 23 |
+
tier = "hosted"
|
| 24 |
+
|
| 25 |
+
def __init__(self, base_url: str | None, api_key: str | None, model: str,
|
| 26 |
+
timeout: int = 90) -> None:
|
| 27 |
+
self.base_url = (base_url or "").rstrip("/")
|
| 28 |
+
self.api_key = api_key
|
| 29 |
+
self.model = model
|
| 30 |
+
self.timeout = timeout
|
| 31 |
+
|
| 32 |
+
def available(self) -> bool:
|
| 33 |
+
return bool(self.base_url)
|
| 34 |
+
|
| 35 |
+
def _endpoint(self) -> str:
|
| 36 |
+
b = self.base_url
|
| 37 |
+
return b + ("/chat/completions" if b.endswith("/v1") else "/v1/chat/completions")
|
| 38 |
+
|
| 39 |
+
def _post(self, messages: list, max_tokens: int, temperature: float) -> dict:
|
| 40 |
+
payload = json.dumps({"model": self.model, "messages": messages,
|
| 41 |
+
"temperature": temperature, "max_tokens": max_tokens}).encode()
|
| 42 |
+
headers = {"Content-Type": "application/json"}
|
| 43 |
+
if self.api_key:
|
| 44 |
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
| 45 |
+
req = urllib.request.Request(self._endpoint(), data=payload, headers=headers)
|
| 46 |
+
ctx = _ssl_context() if self._endpoint().startswith("https") else None
|
| 47 |
+
with urllib.request.urlopen(req, timeout=self.timeout, context=ctx) as r:
|
| 48 |
+
return json.loads(r.read().decode())
|
| 49 |
+
|
| 50 |
+
def complete(self, req: LLMRequest, model: str | None = None) -> LLMResponse:
|
| 51 |
+
t0 = self._now()
|
| 52 |
+
model = model or self.model
|
| 53 |
+
try:
|
| 54 |
+
system = req.cacheable_prefix() + "\n" + "\n".join(
|
| 55 |
+
b.text for b in req.system_blocks if not b.cacheable)
|
| 56 |
+
user = req.user_content
|
| 57 |
+
if req.json_schema:
|
| 58 |
+
user += "\n\nReturn ONLY a single valid JSON object — no prose, no markdown fences."
|
| 59 |
+
messages = []
|
| 60 |
+
if system.strip():
|
| 61 |
+
messages.append({"role": "system", "content": system.strip()})
|
| 62 |
+
messages.append({"role": "user", "content": user})
|
| 63 |
+
body = self._post(messages, req.max_tokens, req.temperature)
|
| 64 |
+
text = body["choices"][0]["message"]["content"]
|
| 65 |
+
u = body.get("usage", {}) or {}
|
| 66 |
+
usage = Usage(
|
| 67 |
+
input_tokens=u.get("prompt_tokens", estimate_tokens(system + user)),
|
| 68 |
+
output_tokens=u.get("completion_tokens", estimate_tokens(text)),
|
| 69 |
+
)
|
| 70 |
+
return LLMResponse.build(text=text, model=f"minicpm:{model}", provider=self.name,
|
| 71 |
+
usage=usage, latency_ms=(self._now() - t0) * 1000,
|
| 72 |
+
routing_reason=f"minicpm-llm:{model}")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
return LLMResponse.build(text="", model=f"minicpm:{model}", provider=self.name,
|
| 75 |
+
usage=Usage(input_tokens=estimate_tokens(req.full_prompt())),
|
| 76 |
+
latency_ms=(self._now() - t0) * 1000,
|
| 77 |
+
routing_reason="minicpm-llm (error)", error=str(e))
|
| 78 |
+
|
| 79 |
+
# --- direct vision extraction (image -> structured JSON), best for messy layouts ---
|
| 80 |
+
def vision_extract(self, image_bytes: bytes, schema_hint: str, doc_type: str) -> dict:
|
| 81 |
+
b64 = base64.b64encode(image_bytes).decode()
|
| 82 |
+
prompt = (
|
| 83 |
+
f"You are extracting fields from a {doc_type} that may have a complex layout, "
|
| 84 |
+
"rotated text, watermarks, and values scattered across the page. Read the WHOLE "
|
| 85 |
+
"image carefully and return ONLY a JSON object with these fields (use null if "
|
| 86 |
+
f"truly absent):\n{schema_hint}\n"
|
| 87 |
+
"Amounts are numbers (strip currency symbols); dates as ISO-8601 (YYYY-MM-DD). "
|
| 88 |
+
"For line_items return an array of {description, quantity, unit_price, line_total}. "
|
| 89 |
+
"No prose, no markdown fences."
|
| 90 |
+
)
|
| 91 |
+
messages = [{"role": "user", "content": [
|
| 92 |
+
{"type": "text", "text": prompt},
|
| 93 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}]
|
| 94 |
+
body = self._post(messages, 2048, 0.0)
|
| 95 |
+
text = body["choices"][0]["message"]["content"]
|
| 96 |
+
return _parse_json(text), body.get("usage", {}) or {}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _parse_json(text: str) -> dict:
|
| 100 |
+
import re
|
| 101 |
+
text = (text or "").strip()
|
| 102 |
+
text = re.sub(r"^```(?:json)?", "", text).strip()
|
| 103 |
+
text = re.sub(r"```$", "", text).strip()
|
| 104 |
+
try:
|
| 105 |
+
return json.loads(text)
|
| 106 |
+
except json.JSONDecodeError:
|
| 107 |
+
m = re.search(r"\{.*\}", text, re.DOTALL)
|
| 108 |
+
if m:
|
| 109 |
+
try:
|
| 110 |
+
return json.loads(m.group(0))
|
| 111 |
+
except json.JSONDecodeError:
|
| 112 |
+
return {}
|
| 113 |
+
return {}
|
backend/app/providers/pricing.py
CHANGED
|
@@ -31,6 +31,7 @@ PRICES: dict[str, Price] = {
|
|
| 31 |
"claude-haiku": Price.standard(0.80, 4.00),
|
| 32 |
"gemini-flash": Price(input=0.30, output=2.50, cached_read=0.075, cache_write=0.30),
|
| 33 |
"gemma-local": Price(input=0.0, output=0.0, cached_read=0.0, cache_write=0.0),
|
|
|
|
| 34 |
"mock": Price(input=0.0, output=0.0, cached_read=0.0, cache_write=0.0),
|
| 35 |
}
|
| 36 |
|
|
@@ -46,6 +47,8 @@ def price_family(model: str) -> str:
|
|
| 46 |
return "claude-haiku"
|
| 47 |
if "gemini" in m:
|
| 48 |
return "gemini-flash"
|
|
|
|
|
|
|
| 49 |
if "gemma" in m or "mistral" in m or "llama" in m or m.startswith("local"):
|
| 50 |
return "gemma-local"
|
| 51 |
return "mock"
|
|
|
|
| 31 |
"claude-haiku": Price.standard(0.80, 4.00),
|
| 32 |
"gemini-flash": Price(input=0.30, output=2.50, cached_read=0.075, cache_write=0.30),
|
| 33 |
"gemma-local": Price(input=0.0, output=0.0, cached_read=0.0, cache_write=0.0),
|
| 34 |
+
"minicpm": Price(input=0.10, output=0.30, cached_read=0.03, cache_write=0.12), # small VLM
|
| 35 |
"mock": Price(input=0.0, output=0.0, cached_read=0.0, cache_write=0.0),
|
| 36 |
}
|
| 37 |
|
|
|
|
| 47 |
return "claude-haiku"
|
| 48 |
if "gemini" in m:
|
| 49 |
return "gemini-flash"
|
| 50 |
+
if "minicpm" in m:
|
| 51 |
+
return "minicpm"
|
| 52 |
if "gemma" in m or "mistral" in m or "llama" in m or m.startswith("local"):
|
| 53 |
return "gemma-local"
|
| 54 |
return "mock"
|
backend/app/router.py
CHANGED
|
@@ -121,6 +121,9 @@ class ModelRouter:
|
|
| 121 |
if self.registry.gemini and self.registry.gemini.available():
|
| 122 |
chain.append((self.registry.gemini, self.settings.gemini_model,
|
| 123 |
"cheap: Gemini Flash"))
|
|
|
|
|
|
|
|
|
|
| 124 |
return chain
|
| 125 |
|
| 126 |
def _smart_chain(self) -> list[tuple[LLMProvider, str, str]]:
|
|
@@ -131,6 +134,9 @@ class ModelRouter:
|
|
| 131 |
if self.registry.gemini and self.registry.gemini.available():
|
| 132 |
chain.append((self.registry.gemini, self.settings.gemini_model,
|
| 133 |
"smart: Gemini Flash"))
|
|
|
|
|
|
|
|
|
|
| 134 |
if self.registry.local and self.registry.local.available():
|
| 135 |
chain.append((self.registry.local, self.settings.local_model,
|
| 136 |
"smart: local Gemma (no frontier available)"))
|
|
|
|
| 121 |
if self.registry.gemini and self.registry.gemini.available():
|
| 122 |
chain.append((self.registry.gemini, self.settings.gemini_model,
|
| 123 |
"cheap: Gemini Flash"))
|
| 124 |
+
if getattr(self.registry, "minicpm", None) and self.registry.minicpm.available():
|
| 125 |
+
chain.append((self.registry.minicpm, self.settings.minicpm_model,
|
| 126 |
+
"cheap: MiniCPM-V (small model)"))
|
| 127 |
return chain
|
| 128 |
|
| 129 |
def _smart_chain(self) -> list[tuple[LLMProvider, str, str]]:
|
|
|
|
| 134 |
if self.registry.gemini and self.registry.gemini.available():
|
| 135 |
chain.append((self.registry.gemini, self.settings.gemini_model,
|
| 136 |
"smart: Gemini Flash"))
|
| 137 |
+
if getattr(self.registry, "minicpm", None) and self.registry.minicpm.available():
|
| 138 |
+
chain.append((self.registry.minicpm, self.settings.minicpm_model,
|
| 139 |
+
"smart: MiniCPM-V (small model)"))
|
| 140 |
if self.registry.local and self.registry.local.available():
|
| 141 |
chain.append((self.registry.local, self.settings.local_model,
|
| 142 |
"smart: local Gemma (no frontier available)"))
|
backend/app/schemas.py
CHANGED
|
@@ -42,6 +42,7 @@ class InvoiceSchema(BaseModel):
|
|
| 42 |
currency: Optional[str] = Field(default=None, description="ISO-4217, e.g. USD.")
|
| 43 |
subtotal: Optional[float] = None
|
| 44 |
tax_amount: Optional[float] = None
|
|
|
|
| 45 |
total: Optional[float] = None
|
| 46 |
line_items: List[LineItem] = Field(default_factory=list)
|
| 47 |
notes: Optional[str] = None
|
|
@@ -316,12 +317,13 @@ def validate_financials(data: dict, doc_type: str) -> ValidationResult:
|
|
| 316 |
if missing:
|
| 317 |
flags.append(f"missing_required:{','.join(missing)}")
|
| 318 |
|
| 319 |
-
# 2) Financial balance: net + tax ≈ total (where present).
|
| 320 |
subtotal, tax, total = num(data.get("subtotal")), num(data.get("tax_amount")), num(
|
| 321 |
data.get("total")
|
| 322 |
)
|
|
|
|
| 323 |
if subtotal is not None and total is not None:
|
| 324 |
-
expected = subtotal + (tax or 0.0)
|
| 325 |
ok = abs(expected - total) <= max(MONEY_TOL, 0.01 * total)
|
| 326 |
checks["totals_balance"] = ok
|
| 327 |
if not ok:
|
|
|
|
| 42 |
currency: Optional[str] = Field(default=None, description="ISO-4217, e.g. USD.")
|
| 43 |
subtotal: Optional[float] = None
|
| 44 |
tax_amount: Optional[float] = None
|
| 45 |
+
shipping: Optional[float] = None
|
| 46 |
total: Optional[float] = None
|
| 47 |
line_items: List[LineItem] = Field(default_factory=list)
|
| 48 |
notes: Optional[str] = None
|
|
|
|
| 317 |
if missing:
|
| 318 |
flags.append(f"missing_required:{','.join(missing)}")
|
| 319 |
|
| 320 |
+
# 2) Financial balance: net + tax (+ shipping) ≈ total (where present).
|
| 321 |
subtotal, tax, total = num(data.get("subtotal")), num(data.get("tax_amount")), num(
|
| 322 |
data.get("total")
|
| 323 |
)
|
| 324 |
+
shipping = num(data.get("shipping")) or num(data.get("freight")) or 0.0
|
| 325 |
if subtotal is not None and total is not None:
|
| 326 |
+
expected = subtotal + (tax or 0.0) + shipping
|
| 327 |
ok = abs(expected - total) <= max(MONEY_TOL, 0.01 * total)
|
| 328 |
checks["totals_balance"] = ok
|
| 329 |
if not ok:
|
backend/evals/datasets/complex_invoice_messy.gt.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"doc_type": "invoice",
|
| 3 |
+
"invoice_number": "INV-9X-44821",
|
| 4 |
+
"issue_date": "2026-04-18",
|
| 5 |
+
"due_date": "2026-05-18",
|
| 6 |
+
"vendor_name": "Meridian Industrial Components Ltd",
|
| 7 |
+
"bill_to_name": "Aperture Retail Group",
|
| 8 |
+
"currency": "USD",
|
| 9 |
+
"subtotal": 9111.5,
|
| 10 |
+
"tax_amount": 751.7,
|
| 11 |
+
"total": 10043.2,
|
| 12 |
+
"line_items": [
|
| 13 |
+
{
|
| 14 |
+
"description": "Hydraulic pump HX-220",
|
| 15 |
+
"quantity": 4,
|
| 16 |
+
"unit_price": 1250.0,
|
| 17 |
+
"line_total": 5000.0
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"description": "Seal kit (set of 12)",
|
| 21 |
+
"quantity": 10,
|
| 22 |
+
"unit_price": 85.5,
|
| 23 |
+
"line_total": 855.0
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"description": "Pressure gauge 0-300psi",
|
| 27 |
+
"quantity": 6,
|
| 28 |
+
"unit_price": 142.75,
|
| 29 |
+
"line_total": 856.5
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"description": "Installation labor",
|
| 33 |
+
"quantity": 1,
|
| 34 |
+
"unit_price": 2400.0,
|
| 35 |
+
"line_total": 2400.0
|
| 36 |
+
}
|
| 37 |
+
],
|
| 38 |
+
"_meta": {
|
| 39 |
+
"doc_type": "invoice",
|
| 40 |
+
"channel": "scanned",
|
| 41 |
+
"difficulty": "complex_layout",
|
| 42 |
+
"skip_eval": true
|
| 43 |
+
}
|
| 44 |
+
}
|
backend/evals/datasets/complex_invoice_messy.png
ADDED
|
Git LFS Details
|
gradio_app.py
CHANGED
|
@@ -101,6 +101,22 @@ def search(query: str):
|
|
| 101 |
for r in RAG.search(query, k=8)]
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
with gr.Blocks(title="Aperture — Retail Document Intelligence") as demo:
|
| 105 |
gr.Markdown("# 📄 Aperture — Retail Document Intelligence\n"
|
| 106 |
"Open-source agentic OCR + IDP (orders · receipts · invoices · contracts · subscription memos). "
|
|
@@ -125,6 +141,17 @@ with gr.Blocks(title="Aperture — Retail Document Intelligence") as demo:
|
|
| 125 |
search_btn = gr.Button("🔍 Search")
|
| 126 |
results = gr.Dataframe(headers=["ref", "score", "text"], label="Results")
|
| 127 |
search_btn.click(search, [q], [results])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
if __name__ == "__main__":
|
| 130 |
try:
|
|
|
|
| 101 |
for r in RAG.search(query, k=8)]
|
| 102 |
|
| 103 |
|
| 104 |
+
def run_complex_web_automation():
|
| 105 |
+
"""Intricate multi-step browser automation: ERP dashboard → Procurement →
|
| 106 |
+
+Create Order → read the complex order-form fields."""
|
| 107 |
+
from app.browser import run_browser_agent
|
| 108 |
+
# The Gradio app doesn't host the ERP portal, so drive the simulated browser
|
| 109 |
+
# (the same multi-step flow runs on real Chromium in the full web app).
|
| 110 |
+
res = run_browser_agent(
|
| 111 |
+
"Open the ERP dashboard, click Procurement, click '+ Create Order', and read all order fields",
|
| 112 |
+
router=ROUTER, settings=S, metrics=METRICS, scenario="complex_order",
|
| 113 |
+
base_url="https://portal.local/portal", prefer_simulated=True)
|
| 114 |
+
trace_md = f"**{res['backend']} browser · {res['agent_mode']} · {res['steps']} steps**\n\n"
|
| 115 |
+
for t in res["trace"]:
|
| 116 |
+
trace_md += f"- **step {t['step']}** `{t['tool']}` {t.get('args') or ''} — {t.get('note','')[:90]}\n"
|
| 117 |
+
return trace_md, res.get("result")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
with gr.Blocks(title="Aperture — Retail Document Intelligence") as demo:
|
| 121 |
gr.Markdown("# 📄 Aperture — Retail Document Intelligence\n"
|
| 122 |
"Open-source agentic OCR + IDP (orders · receipts · invoices · contracts · subscription memos). "
|
|
|
|
| 141 |
search_btn = gr.Button("🔍 Search")
|
| 142 |
results = gr.Dataframe(headers=["ref", "score", "text"], label="Results")
|
| 143 |
search_btn.click(search, [q], [results])
|
| 144 |
+
with gr.Tab("Web Automation"):
|
| 145 |
+
gr.Markdown("### Complex multi-step browser automation\n"
|
| 146 |
+
"Replaces UiPath Studio Web: navigate the ERP dashboard → click the **Procurement** "
|
| 147 |
+
"tile → click **+ Create Order** → open the order-form modal → **read the complex "
|
| 148 |
+
"nested fields** (vendor & terms, ship-to, line items, totals, approver). Runs on real "
|
| 149 |
+
"Chromium when Playwright is installed; replays on the simulated browser here.")
|
| 150 |
+
cx_btn = gr.Button("▶ Run: Procurement → Create Order → read fields", variant="primary")
|
| 151 |
+
with gr.Row():
|
| 152 |
+
cx_trace = gr.Markdown()
|
| 153 |
+
cx_result = gr.JSON(label="Order fields read from the form")
|
| 154 |
+
cx_btn.click(run_complex_web_automation, [], [cx_trace, cx_result])
|
| 155 |
|
| 156 |
if __name__ == "__main__":
|
| 157 |
try:
|
scripts/generate_complex_invoice.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Generate a DELIBERATELY HARD invoice image to stress the OCR backends:
|
| 3 |
+
• rotated vertical "INVOICE" banner + sideways Terms block (orientation problems)
|
| 4 |
+
• a diagonal translucent "ORIGINAL COPY" watermark overlapping text
|
| 5 |
+
• scattered header fields (invoice #, dates, vendor, bill-to in offset boxes)
|
| 6 |
+
• a misaligned line-item table (inconsistent column alignment)
|
| 7 |
+
• totals scattered across corners (subtotal / tax / shipping / grand total / balance due)
|
| 8 |
+
|
| 9 |
+
This is the kind of layout where classic OCR (Tesseract) struggles but a vision
|
| 10 |
+
LLM (MiniCPM-V) reads it correctly. Writes:
|
| 11 |
+
backend/evals/datasets/complex_invoice_messy.png
|
| 12 |
+
backend/evals/datasets/complex_invoice_messy.gt.json (skip_eval — showcase only)
|
| 13 |
+
No .txt sidecar on purpose — this document REQUIRES a real OCR engine.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 21 |
+
|
| 22 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
OUT = ROOT / "backend" / "evals" / "datasets"
|
| 24 |
+
W, H = 1240, 1600
|
| 25 |
+
|
| 26 |
+
GT = {
|
| 27 |
+
"doc_type": "invoice",
|
| 28 |
+
"invoice_number": "INV-9X-44821",
|
| 29 |
+
"issue_date": "2026-04-18",
|
| 30 |
+
"due_date": "2026-05-18",
|
| 31 |
+
"vendor_name": "Meridian Industrial Components Ltd",
|
| 32 |
+
"bill_to_name": "Aperture Retail Group",
|
| 33 |
+
"currency": "USD",
|
| 34 |
+
"subtotal": 9111.50,
|
| 35 |
+
"tax_amount": 751.70,
|
| 36 |
+
"total": 10043.20,
|
| 37 |
+
"line_items": [
|
| 38 |
+
{"description": "Hydraulic pump HX-220", "quantity": 4, "unit_price": 1250.00, "line_total": 5000.00},
|
| 39 |
+
{"description": "Seal kit (set of 12)", "quantity": 10, "unit_price": 85.50, "line_total": 855.00},
|
| 40 |
+
{"description": "Pressure gauge 0-300psi", "quantity": 6, "unit_price": 142.75, "line_total": 856.50},
|
| 41 |
+
{"description": "Installation labor", "quantity": 1, "unit_price": 2400.00, "line_total": 2400.00},
|
| 42 |
+
],
|
| 43 |
+
"_meta": {"doc_type": "invoice", "channel": "scanned", "difficulty": "complex_layout", "skip_eval": True},
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def font(sz, bold=False):
|
| 48 |
+
for p in ([
|
| 49 |
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
|
| 50 |
+
"/System/Library/Fonts/Helvetica.ttc",
|
| 51 |
+
"/Library/Fonts/Arial.ttf",
|
| 52 |
+
]):
|
| 53 |
+
try:
|
| 54 |
+
return ImageFont.truetype(p, sz)
|
| 55 |
+
except Exception:
|
| 56 |
+
continue
|
| 57 |
+
return ImageFont.load_default()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def rotated(base, text, xy, angle, fnt, fill=(20, 20, 20)):
|
| 61 |
+
tmp = Image.new("RGBA", (max(20, len(text) * fnt.size), fnt.size + 16), (0, 0, 0, 0))
|
| 62 |
+
ImageDraw.Draw(tmp).text((2, 2), text, font=fnt, fill=fill)
|
| 63 |
+
tmp = tmp.rotate(angle, expand=True)
|
| 64 |
+
base.paste(tmp, xy, tmp)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
img = Image.new("RGB", (W, H), "white")
|
| 70 |
+
d = ImageDraw.Draw(img)
|
| 71 |
+
|
| 72 |
+
# --- diagonal translucent watermark overlapping content ---
|
| 73 |
+
wm = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
| 74 |
+
wd = ImageDraw.Draw(wm)
|
| 75 |
+
wd.text((140, 120), "ORIGINAL COPY", font=font(120, True), fill=(150, 150, 150, 60))
|
| 76 |
+
wm = wm.rotate(28, center=(W // 2, H // 2))
|
| 77 |
+
img.paste(wm, (0, 0), wm)
|
| 78 |
+
|
| 79 |
+
# --- left vertical INVOICE banner (rotated 90) ---
|
| 80 |
+
d.rectangle([18, 60, 96, 760], fill=(28, 40, 80))
|
| 81 |
+
rotated(img, "INVOICE", (24, 470), 90, font(54, True), fill=(255, 255, 255))
|
| 82 |
+
|
| 83 |
+
# --- scattered header fields (offset boxes, inconsistent placement) ---
|
| 84 |
+
d.text((900, 70), "Invoice No.", font=font(20, True), fill=(90, 90, 90))
|
| 85 |
+
d.text((900, 98), GT["invoice_number"], font=font(26, True), fill=(10, 10, 10))
|
| 86 |
+
d.text((690, 150), f"Issued: {GT['issue_date']}", font=font(20), fill=(10, 10, 10))
|
| 87 |
+
d.text((980, 200), f"Due {GT['due_date']}", font=font(20), fill=(160, 30, 30)) # offset, different spot
|
| 88 |
+
# Balance due repeated near top in red (inconsistent)
|
| 89 |
+
d.text((620, 60), "BALANCE DUE $10,043.20", font=font(26, True), fill=(190, 20, 20))
|
| 90 |
+
|
| 91 |
+
# vendor block (top-left, after banner) and bill-to (offset right-middle)
|
| 92 |
+
d.rectangle([130, 90, 560, 220], outline=(120, 120, 120), width=2)
|
| 93 |
+
d.text((145, 100), "FROM / Remit to:", font=font(18, True), fill=(70, 70, 70))
|
| 94 |
+
d.text((145, 128), GT["vendor_name"], font=font(22, True), fill=(10, 10, 10))
|
| 95 |
+
d.text((145, 160), "Unit 7, Kvaerner Estate", font=font(18), fill=(40, 40, 40))
|
| 96 |
+
d.text((145, 184), "VAT GB-882-114", font=font(18), fill=(40, 40, 40))
|
| 97 |
+
|
| 98 |
+
d.rectangle([640, 300, 1080, 420], outline=(120, 120, 120), width=2)
|
| 99 |
+
d.text((655, 310), "Bill To", font=font(18, True), fill=(70, 70, 70))
|
| 100 |
+
d.text((655, 338), GT["bill_to_name"], font=font(22, True), fill=(10, 10, 10))
|
| 101 |
+
d.text((655, 372), "Accounts Payable, Floor 3", font=font(18), fill=(40, 40, 40))
|
| 102 |
+
d.text((655, 396), "PO ref: PO-77-3391", font=font(18), fill=(40, 40, 40))
|
| 103 |
+
|
| 104 |
+
# --- misaligned line-item table ---
|
| 105 |
+
ty = 470
|
| 106 |
+
d.line([130, ty - 10, 1110, ty - 10], fill=(40, 40, 40), width=2)
|
| 107 |
+
# headers placed inconsistently (not above their columns)
|
| 108 |
+
d.text((150, ty), "Item / Description", font=font(20, True), fill=(20, 20, 20))
|
| 109 |
+
d.text((720, ty), "Unit", font=font(20, True), fill=(20, 20, 20))
|
| 110 |
+
d.text((640, ty), "Qty", font=font(20, True), fill=(20, 20, 20))
|
| 111 |
+
d.text((980, ty), "Amount", font=font(20, True), fill=(20, 20, 20))
|
| 112 |
+
ry = ty + 44
|
| 113 |
+
for i, it in enumerate(GT["line_items"], 1):
|
| 114 |
+
d.text((150, ry), f"{i}. {it['description']}", font=font(20), fill=(15, 15, 15))
|
| 115 |
+
# qty/unit/amount with inconsistent alignment (left vs right vs centered)
|
| 116 |
+
d.text((648, ry), str(int(it["quantity"])), font=font(20), fill=(15, 15, 15))
|
| 117 |
+
d.text((700, ry), f"${it['unit_price']:,.2f}", font=font(20), fill=(15, 15, 15))
|
| 118 |
+
amt = f"${it['line_total']:,.2f}"
|
| 119 |
+
d.text((1090 - d.textlength(amt, font=font(20)), ry), amt, font=font(20), fill=(15, 15, 15))
|
| 120 |
+
ry += 46
|
| 121 |
+
d.line([130, ry + 6, 1110, ry + 6], fill=(40, 40, 40), width=1)
|
| 122 |
+
|
| 123 |
+
# --- totals scattered across corners ---
|
| 124 |
+
d.text((180, ry + 60), "Tax @ 8.25%", font=font(20), fill=(15, 15, 15)) # bottom-left
|
| 125 |
+
d.text((320, ry + 60), "$751.70", font=font(20), fill=(15, 15, 15))
|
| 126 |
+
d.text((640, ry + 30), "Sub-total", font=font(20), fill=(15, 15, 15)) # middle
|
| 127 |
+
d.text((780, ry + 30), "$9,111.50", font=font(20), fill=(15, 15, 15))
|
| 128 |
+
d.text((640, ry + 90), "Freight/Shipping", font=font(20), fill=(15, 15, 15))
|
| 129 |
+
d.text((820, ry + 90), "$180.00", font=font(20), fill=(15, 15, 15))
|
| 130 |
+
# grand total in a bold box, bottom-right
|
| 131 |
+
d.rectangle([800, ry + 140, 1110, ry + 210], fill=(28, 40, 80))
|
| 132 |
+
d.text((820, ry + 150), "GRAND TOTAL", font=font(20, True), fill=(255, 255, 255))
|
| 133 |
+
d.text((820, ry + 178), "$10,043.20 USD", font=font(24, True), fill=(255, 255, 255))
|
| 134 |
+
|
| 135 |
+
# --- sideways Terms & Conditions block (rotated 90) on the right edge ---
|
| 136 |
+
rotated(img, "Terms: Net 30 days. Late fee 1.5%/mo. Goods remain property of seller until paid.",
|
| 137 |
+
(1150, 360), 90, font(18), fill=(90, 90, 90))
|
| 138 |
+
|
| 139 |
+
# footer note (two-column-ish)
|
| 140 |
+
d.text((150, H - 120), "Notes: Partial back-order on item 3 may apply.", font=font(18), fill=(60, 60, 60))
|
| 141 |
+
d.text((150, H - 92), "Remittance: SWIFT MERIGB2L / IBAN GB22 MERI 0099 8812", font=font(18), fill=(60, 60, 60))
|
| 142 |
+
|
| 143 |
+
out_png = OUT / "complex_invoice_messy.png"
|
| 144 |
+
img.save(out_png)
|
| 145 |
+
(OUT / "complex_invoice_messy.gt.json").write_text(json.dumps(GT, indent=2))
|
| 146 |
+
print(f"✓ wrote {out_png} ({W}x{H})")
|
| 147 |
+
print(f"✓ wrote {OUT/'complex_invoice_messy.gt.json'}")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
if __name__ == "__main__":
|
| 151 |
+
main()
|