kenmandal commited on
Commit
ae053b7
·
verified ·
1 Parent(s): 32b00ed

Add complex invoice (MiniCPM vision OCR) + complex multi-step web automation tab

Browse files
.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 = router.run(req, run_id)
 
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
- rec = ActionRecord("navigate", {"url": url}, True, f"loaded {url}")
 
 
 
 
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
- if "submit" in selector.lower():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Blend in OCR coverage: low-confidence OCR should lower overall confidence.
 
254
  coverage = float(state.get("coverage", 1.0))
255
- blended = round(0.8 * vr.confidence + 0.2 * coverage, 3)
 
 
256
  flags = list(vr.flags)
257
- if coverage < 0.5:
258
- flags.append(f"low_ocr_coverage:{coverage}")
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
- if anthropic_ok or gemini_ok:
 
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

  • SHA256: 6bcc27814e9f5e9b657a14112284bb9d4c283e48f9c1c742639f026ddf04f85e
  • Pointer size: 131 Bytes
  • Size of remote file: 118 kB
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()