Phase 2: structured build instructions (deterministic + optional AI polish)

instructions.py: build_steps(scene, plan) emits an ordered, DETERMINISTIC step
list from the CutPlan + scene — gather stock, cut to size (per cut layout), mark
& cut joinery, sand, dry-fit/glue/fasten (per connection), finish. Every number
and part name comes from the model. polish_prompt() asks the AI to rephrase into
friendly prose while forbidding any change to measurements.

BOM window gains an Instructions tab: shows the deterministic steps immediately,
"Rewrite in plain English (AI)" runs claude in a background thread to polish, and
Print.

111 tests pass (steps cover phases, carry real data, prompt guards numbers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 14:36:50 -03:00
parent d44d36a773
commit ecce86ddb5
3 changed files with 168 additions and 1 deletions

View File

@ -3,7 +3,9 @@ printable. The Cut Layout tab draws the cutting-stock nesting and can try
alternative arrangements."""
from __future__ import annotations
from PySide6.QtCore import Qt
import subprocess
from PySide6.QtCore import Qt, QThreadPool
from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtPrintSupport import QPrintDialog, QPrinter
from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
@ -12,7 +14,9 @@ from PySide6.QtWidgets import (QDialog, QGraphicsRectItem, QGraphicsScene,
from ..cutlist import _fmt_len, cut_rows, shopping
from ..cutplan import STRATEGIES, best_cut_plan, build_cut_plan
from ..instructions import build_steps, format_steps, polish_prompt
from ..layout import waste_summary
from .workers import run_async
_PX = 7.0 # pixels per inch in the layout view
_PIECE = "#c8965a"
@ -27,11 +31,13 @@ class BomWindow(QDialog):
self.resize(820, 640)
self._order = 0
self._optimized = False
self.pool = QThreadPool.globalInstance()
tabs = QTabWidget()
tabs.addTab(self._text_tab(self._cut_text()), "Cut List")
tabs.addTab(self._text_tab(self._shop_text()), "Shopping List")
tabs.addTab(self._layout_tab(), "Cut Layout")
tabs.addTab(self._instructions_tab(), "Instructions")
root = QVBoxLayout(self)
root.addWidget(tabs)
@ -83,6 +89,44 @@ class BomWindow(QDialog):
if QPrintDialog(printer, self).exec():
te.print_(printer)
# ----- instructions tab --------------------------------------------
def _instructions_tab(self) -> QWidget:
w = QWidget()
v = QVBoxLayout(w)
self._instr = QTextEdit(readOnly=True)
self._instr.setFont(QFont("monospace"))
self._instr.setPlainText(format_steps(build_steps(self.c.scene)))
v.addWidget(self._instr)
row = QHBoxLayout()
self._polish = QPushButton("Rewrite in plain English (AI)")
self._polish.clicked.connect(self._polish_instructions)
pr = QPushButton("Print…")
pr.clicked.connect(lambda: self._print_text(self._instr))
row.addWidget(self._polish); row.addStretch(); row.addWidget(pr)
v.addLayout(row)
return w
def _polish_instructions(self) -> None:
prompt = polish_prompt(build_steps(self.c.scene))
self._polish.setEnabled(False)
self._polish.setText("Rewriting…")
def work():
r = subprocess.run(["claude", "-p"], input=prompt, capture_output=True, text=True)
return (r.stdout or "").strip()
def done(text):
self._polish.setEnabled(True)
self._polish.setText("Rewrite in plain English (AI)")
if text:
self._instr.setPlainText(text)
def failed(err):
self._polish.setEnabled(True)
self._polish.setText("Rewrite in plain English (AI)")
run_async(self.pool, work, on_done=done, on_error=failed)
# ----- layout tab ---------------------------------------------------
def _layout_tab(self) -> QWidget:
w = QWidget()

View File

@ -0,0 +1,82 @@
"""Build instructions: a DETERMINISTIC ordered step list from the CutPlan + scene.
Every number/part name comes from the model. The AI is only used (optionally) to
rephrase these steps into friendlier prose `polish_prompt()` builds a prompt
that explicitly forbids changing any measurement.
"""
from __future__ import annotations
from collections import Counter
from .cutlist import _fmt_len
from .cutplan import build_cut_plan
def build_steps(scene, plan=None) -> list:
"""Return [(title, [lines])] in build order. Deterministic."""
plan = plan or build_cut_plan(scene)
names = {p.id: (p.name or p.id) for p in scene.parts}
part_of = {it.id: it.part_id for it in plan.items}
sections = []
buy = []
for stock, n in sorted(Counter(sp.stock for sp in plan.stock_pieces).items()):
unit = "sheet" if stock.startswith("ply-") else "8' stick"
buy.append(f"{n} × {stock} ({unit}{'s' if n != 1 else ''})")
if buy:
sections.append(("Gather stock", buy))
cuts = []
for sp in plan.stock_pieces:
kind = "sheet" if sp.is_sheet else "stick"
pieces = []
for p in sp.placements:
nm = names.get(part_of.get(p.item_id, ""), p.item_id)
dims = (f"{_fmt_len(p.wid_in)}×{_fmt_len(p.len_in)}" if sp.is_sheet
else _fmt_len(p.len_in))
pieces.append(f"{nm} ({dims})")
if pieces:
cuts.append(f"From a {sp.stock} {kind}: cut " + ", ".join(pieces))
if cuts:
sections.append(("Cut pieces to size (see the Cut Layout tab)", cuts))
joinery = []
for p in scene.parts:
for f in p.features:
dims = (f"{f.diameter_in:g}\"" if f.kind == "hole"
else f"{f.width_in:g}×{f.height_in:g}×{f.depth_in:g}\"")
joinery.append(f"On {names[p.id]}{f.kind} on the {f.face} face ({dims})")
if joinery:
sections.append(("Mark and cut the joinery", joinery))
sanded = [names[p.id] for p in scene.parts if "sanded" in p.finishes]
sections.append(("Sand", [f"Sand {', '.join(sanded)} smooth." if sanded
else "Sand all parts smooth."]))
asm = []
for c in scene.connections:
if not scene._conn_valid(c):
continue
ap, mp = scene.feature_owner(c.anchor), scene.feature_owner(c.moving)
asm.append(f"Join {names[mp.id]} to {names[ap.id]} (seat the joint).")
if asm:
sections.append(("Dry-fit, then glue and fasten", asm))
sections.append(("Finish", ["Apply your chosen finish."]))
return sections
def format_steps(sections) -> str:
out = []
for n, (title, lines) in enumerate(sections, 1):
out.append(f"{n}. {title}")
out += [f"{ln}" for ln in lines]
out.append("")
return "\n".join(out).rstrip() or "Nothing to build yet."
def polish_prompt(sections) -> str:
return ("Rewrite these woodworking build steps as clear, friendly, numbered shop "
"instructions a beginner could follow. KEEP every measurement, part name, "
"and count EXACTLY as given — do not invent or change any number. Plain text only.\n\n"
+ format_steps(sections))

View File

@ -0,0 +1,41 @@
"""Tests for deterministic build-step generation."""
from woodshop.instructions import build_steps, format_steps, polish_prompt
from woodshop.scene import Scene
def _scene():
s = Scene()
s.place("2x4", 24)
s.rename("p1", "leg")
s.add_feature("p1", "tenon", face="end_b", depth_in=1) # f1
s.place("2x4", 48)
s.add_feature("p2", "mortise", face="top", along_in=24,
width_in=1.5, height_in=1, depth_in=1) # f2
s.connect("f2", "f1") # seat the joint
return s
def test_steps_cover_the_build_phases():
sections = build_steps(_scene())
titles = [t for t, _ in sections]
assert "Gather stock" in titles
assert any("Cut pieces" in t for t in titles)
assert any("joinery" in t.lower() for t in titles)
assert any("glue" in t.lower() for t in titles) # assembly step (has connections)
assert titles[-1] == "Finish"
def test_steps_carry_real_data():
text = format_steps(build_steps(_scene()))
assert "leg" in text # named part appears
assert "tenon" in text and "mortise" in text # joinery listed
def test_polish_prompt_guards_numbers():
p = polish_prompt(build_steps(_scene()))
assert "do not invent" in p.lower()
assert "Gather stock" in p
def test_empty_scene_still_formats():
assert format_steps(build_steps(Scene()))