Mark purchased: scan a receipt to fill actual prices

The Mark-purchased dialog gains a "Scan receipt…" button: attach a receipt
photo/PDF and the model reads it, fills the unit price each item actually cost,
and offers to save those to the price book — so inventory spend and future
estimates reflect what you really paid.

- driver.read_receipt(path, labels): claude -p reads the receipt image/PDF and
  returns {item label: unit price}; _extract_json_object parses the JSON object.
- PurchaseDialog(pool=): "Scan receipt…" resolves the file (image/PDF) and runs
  read_receipt off the UI thread, filling matched price spins + a status line;
  auto-ticks "save to price book" when prices were read.
- tests: JSON-object extraction, read_receipt parsing (drops non-numeric),
  dialog price-fill path. 230 pass.

Honest limit: receipt OCR accuracy is the model's; review the filled prices
before confirming. Live read needs your machine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 23:47:00 -03:00
parent a4ef3a7d1e
commit 28ca8ee338
4 changed files with 138 additions and 4 deletions

View File

@ -326,6 +326,54 @@ def critique(reference_paths: list[str], render_paths: list[str], schemas: str,
return calls or [{"tool": "say", "args": {"text": "LGTM (no changes parsed)."}}]
_RECEIPT_PROMPT = (
"Read the store receipt saved at this path (open the image / PDF):\n {path}\n"
"It lists purchased building materials with prices. For EACH item below that I "
"bought, find its UNIT price (price per stick / sheet / each, BEFORE tax — not "
"the line total) on the receipt. Items I'm buying:\n{labels}\n"
"Respond with ONLY a JSON object mapping each item label to its unit price in "
'dollars, e.g. {{"2x4": 3.98, "oak 1x4": 14.50}}. Omit items you cannot find on '
"the receipt. No prose, no code fences.")
def _extract_json_object(raw: str) -> dict:
raw = (raw or "").strip()
if raw.startswith("```"):
raw = re.sub(r"^```[a-zA-Z]*\n?", "", raw)
raw = re.sub(r"\n?```$", "", raw).strip()
start = raw.find("{")
if start == -1:
return {}
depth = 0
for i in range(start, len(raw)):
if raw[i] == "{":
depth += 1
elif raw[i] == "}":
depth -= 1
if depth == 0:
try:
v = json.loads(raw[start:i + 1])
return v if isinstance(v, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def read_receipt(path: str, labels: list[str]) -> dict[str, float]:
"""Read a receipt image/PDF and return {item label: unit price} for the
purchased items it can find. Empty if nothing parses."""
prompt = _RECEIPT_PROMPT.format(path=os.path.abspath(path),
labels="\n".join(f" - {x}" for x in labels))
obj = _extract_json_object(_run(REASON_PROVIDER.split(), stdin=prompt))
out = {}
for k, v in obj.items():
try:
out[str(k)] = round(float(v), 2)
except (TypeError, ValueError):
continue
return out
def _subprocess_executor(tool: str, args: dict) -> str:
"""Default executor: dispatch a wood-* tool via the CmdForge pa-execute-tool."""
result = _run(["pa-execute-tool", "--tool-name", tool,

View File

@ -23,6 +23,7 @@ from ..cutplan import (STRATEGIES, best_cut_plan, build_cut_plan, find_placement
snap_x, _plan_key)
from ..instructions import build_steps, format_steps, polish_prompt
from ..jigs import explain_prompt, format_jigs, suggest_jigs
from .. import driver
from .. import prices as prices_mod
from .. import estimate as estimate_mod
from .. import inventory as inventory_mod
@ -149,7 +150,7 @@ class BomWindow(QDialog):
if not bought:
QMessageBox.information(self, "Nothing to buy", "This plan needs no new stock.")
return
dlg = PurchaseDialog(bought, self._prices, self)
dlg = PurchaseDialog(bought, self._prices, self, pool=self.pool)
if not dlg.exec():
return
rows, save = dlg.result()
@ -813,13 +814,15 @@ class RatesEditDialog(QDialog):
class PurchaseDialog(QDialog):
"""Confirm a shopping list before adding it to shop inventory; optionally
save the entered prices back to the price book (opt-in)."""
attach a receipt photo/PDF to fill the actual prices, and save them to the
price book (opt-in)."""
def __init__(self, bought, prices, parent=None):
def __init__(self, bought, prices, parent=None, pool=None):
super().__init__(parent)
from ..lumber import default_material
self.setWindowTitle("Mark purchased")
self.resize(380, 320)
self.resize(400, 360)
self.pool = pool
mults = prices_mod.load_material_multipliers()
v = QVBoxLayout(self)
v.addWidget(QLabel("Add these to your shop inventory?"))
@ -828,6 +831,7 @@ class PurchaseDialog(QDialog):
self._table.setHorizontalHeaderLabels(["Stock", "Qty", "Price $ each"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._spins = []
self._by_label = {} # label -> price spin (for receipt fill)
for r, ((stock, material), qty) in enumerate(rows):
label = (f"{material} {stock}" if material and material != default_material(stock)
else stock)
@ -840,13 +844,59 @@ class PurchaseDialog(QDialog):
self._table.setCellWidget(r, 1, q)
self._table.setCellWidget(r, 2, p)
self._spins.append((stock, material, q, p))
self._by_label[label] = p
v.addWidget(self._table)
if pool is not None: # receipt scanning needs a worker pool
rrow = QHBoxLayout()
self._receipt_btn = QPushButton("Scan receipt…")
self._receipt_btn.setToolTip("Read a receipt photo/PDF and fill the actual "
"prices you paid")
self._receipt_btn.clicked.connect(self._scan_receipt)
rrow.addWidget(self._receipt_btn)
self._receipt_status = QLabel("")
self._receipt_status.setStyleSheet("color:#c8965a")
rrow.addWidget(self._receipt_status, 1)
v.addLayout(rrow)
self._save = QCheckBox("Save these prices to my price book")
v.addWidget(self._save)
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.accepted.connect(self.accept); bb.rejected.connect(self.reject)
v.addWidget(bb)
def _scan_receipt(self) -> None:
exts = " ".join("*" + e for e in sorted(driver.IMG_EXTS | driver.DOC_EXTS))
path, _ = QFileDialog.getOpenFileName(self, "Scan receipt", "",
f"Receipt image/PDF ({exts});;All files (*)")
if not path:
return
labels = list(self._by_label)
self._receipt_btn.setEnabled(False)
self._receipt_status.setText("reading receipt…")
def work():
img, _txt = driver.resolve_reference(path) # image/PDF -> a path
return driver.read_receipt(img or path, labels)
def done(prices_map):
self._receipt_btn.setEnabled(True)
n = 0
for label, price in (prices_map or {}).items():
spin = self._by_label.get(label)
if spin is not None and price > 0:
spin.setValue(price)
n += 1
self._save.setChecked(n > 0) # offer to keep the real prices
self._receipt_status.setText(
f"✓ filled {n} price(s) from receipt" if n else "couldn't read prices — enter by hand")
def failed(err):
self._receipt_btn.setEnabled(True)
self._receipt_status.setText(f"{err}")
run_async(self.pool, work, on_done=done, on_error=failed)
def result(self):
rows = [(stock, material, q.value(), round(p.value(), 2))
for stock, material, q, p in self._spins if q.value() > 0]

View File

@ -175,3 +175,24 @@ def test_optimize_preserves_offcut_toggle(tmp_path, monkeypatch):
assert w._plan.score["stock_count"] == 0
w._best_of_n()
assert w._plan.score["stock_count"] == 0
def test_purchase_dialog_receipt_fills_prices(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "cfg"))
from collections import Counter
from woodshop.gui.bom_window import PurchaseDialog
from PySide6.QtCore import QThreadPool
bought = Counter({("2x4", "spruce"): 3, ("ply-3/4", "spruce-ply"): 1})
dlg = PurchaseDialog(bought, {"2x4": 4.0, "ply-3/4": 60.0}, pool=QThreadPool.globalInstance())
# simulate the worker's done() applying receipt-read prices
dlg._by_label["2x4"].setValue(0.0)
prices_map = {"2x4": 3.49, "ply-3/4": 58.0, "unknown": 9.0}
n = 0
for label, price in prices_map.items():
spin = dlg._by_label.get(label)
if spin is not None and price > 0:
spin.setValue(price); n += 1
assert n == 2
rows, _ = dlg.result()
by_stock = {s: pr for s, _m, _q, pr in rows}
assert by_stock["2x4"] == 3.49 and by_stock["ply-3/4"] == 58.0

View File

@ -272,3 +272,18 @@ def test_render_mesh_real_if_possible(tmp_path):
assert _os.path.exists(png) and png.endswith(".png")
assert "bounding box" in dims
_os.remove(png)
def test_extract_json_object():
assert driver._extract_json_object('```json\n{"2x4": 3.98}\n```') == {"2x4": 3.98}
assert driver._extract_json_object('here: {"a": 1} done') == {"a": 1}
assert driver._extract_json_object("no object") == {}
def test_read_receipt_parses_unit_prices(monkeypatch):
captured = {}
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="":
captured.update(prompt=stdin) or '{"2x4": 3.98, "oak 1x4": 14.5, "bad": "x"}')
out = driver.read_receipt("/tmp/receipt.jpg", ["2x4", "oak 1x4", "ply-3/4"])
assert out == {"2x4": 3.98, "oak 1x4": 14.5} # 'bad' non-numeric dropped
assert "UNIT price" in captured["prompt"] and "/tmp/receipt.jpg" in captured["prompt"]