diff --git a/src/woodshop/driver.py b/src/woodshop/driver.py index 5e1543d..3eedd2d 100644 --- a/src/woodshop/driver.py +++ b/src/woodshop/driver.py @@ -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, diff --git a/src/woodshop/gui/bom_window.py b/src/woodshop/gui/bom_window.py index ec013aa..a13d432 100644 --- a/src/woodshop/gui/bom_window.py +++ b/src/woodshop/gui/bom_window.py @@ -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] diff --git a/tests/test_bom_window.py b/tests/test_bom_window.py index 16b9803..441c38e 100644 --- a/tests/test_bom_window.py +++ b/tests/test_bom_window.py @@ -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 diff --git a/tests/test_driver.py b/tests/test_driver.py index 8531299..31e6f13 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -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"]