Add voice/conversational loop reusing CmdForge tools
- driver.py (woodshop-talk): the conversational loop. Reuses dictate (STT), pa-load-tools (schemas), claude -p (interpret), pa-execute-tool (dispatch), read-aloud (TTS). Resolves $N symbols so multi-op utterances can reference boards placed earlier in the same sentence; tolerates fenced/garbage output. - wood-* CmdForge tools generator (scripts/gen_wood_tools.py): place/join/sand/ delete/undo wrappers over the woodshop CLI; arg descriptions double as the LLM's command documentation. - UX/realism fixes: lenient anchor parsing (end/start/far/near), and joins now stack board B on A's face in Z instead of interpenetrating centerlines. - Tests: 25 passing (added anchor, Z-stack, and driver symbol-resolution tests). - CLAUDE.md: architecture, entry points, setup, known limitations. Verified end-to-end (typed): the canonical sentence produces the correct 4-op scene; follow-up commands on a non-empty scene resolve ids correctly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a688623caf
commit
fa03ee71d3
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -4,7 +4,74 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler
|
**WoodShop** - Voice-driven conversational 3D woodworking & furniture modeler.
|
||||||
|
Speak (or type) commands like *"place a 6 foot 2x4, sand it, attach a 2 foot 2x4
|
||||||
|
at 90 degrees 10 inches from the end"* and watch the model build in a live 3D
|
||||||
|
viewport — Holodeck-style.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Design principle:** reuse existing CmdForge tools for everything that isn't
|
||||||
|
woodshop-specific; don't reinvent voice/AI plumbing.
|
||||||
|
|
||||||
|
```
|
||||||
|
woodshop-talk (driver.py) ── the conversational loop
|
||||||
|
│ dictate ............... speech→text (CmdForge tool, reused)
|
||||||
|
│ pa-load-tools ......... wood-* → Claude schemas (reused)
|
||||||
|
│ claude -p ............. interpret utterance → JSON tool calls (reused provider)
|
||||||
|
│ pa-execute-tool ....... dispatch each wood-* tool (reused)
|
||||||
|
│ read-aloud ........... speak confirmation (reused)
|
||||||
|
▼
|
||||||
|
scene.json ← single source of truth (parts, joints, selection, undo stack)
|
||||||
|
▲ │ writes
|
||||||
|
│ reads/mutates ▼
|
||||||
|
wood-* CmdForge tools woodshop-view (viewer.py)
|
||||||
|
(place/join/sand/delete/undo) watches scene.json → live pyvista 3D
|
||||||
|
thin wrappers over `woodshop` CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
Only woodshop-specific code lives in this repo: the scene model
|
||||||
|
(`scene.py`), nominal→actual lumber table (`lumber.py`), length parsing
|
||||||
|
(`units.py`), the `woodshop` CLI (`cli.py`), build123d geometry + STL/STEP
|
||||||
|
export (`geometry.py`), the pyvista viewport (`viewer.py`), and the driver
|
||||||
|
(`driver.py`). The driver uses Claude (not `pa-tool-loop`, which hard-wires a
|
||||||
|
small local model) for reliable structured tool-calling.
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `woodshop <op>` | CLI: `place`, `join`, `sand`, `delete`, `undo`, `export`, `status` |
|
||||||
|
| `woodshop-view` | Live 3D viewport (watches `scene.json`) |
|
||||||
|
| `woodshop-talk` | Conversational driver (`--voice` for mic, `--once "..."` for one command) |
|
||||||
|
|
||||||
|
Scene file location: `$WOODSHOP_SCENE` or `~/.local/share/woodshop/scene.json`.
|
||||||
|
|
||||||
|
### CmdForge tools (the documented command vocabulary)
|
||||||
|
|
||||||
|
`wood-place`, `wood-join`, `wood-sand`, `wood-delete`, `wood-undo` live in
|
||||||
|
`~/.cmdforge/<name>/` and wrap the `woodshop` CLI. Regenerate them with
|
||||||
|
`/tmp/gen_wood_tools.py` (kept in the repo plan) if their schemas change. The
|
||||||
|
arg descriptions ARE the LLM's documentation, so keep them clear.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -e ".[viewer,dev]" # viewer extra pulls build123d + pyvista
|
||||||
|
pytest # 25 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Known limitations / next steps
|
||||||
|
|
||||||
|
1. **No vertical orientation.** Boards only rotate in the horizontal (XY) plane
|
||||||
|
(`rotation_deg` about Z). Furniture legs that "stand up" (length along Z)
|
||||||
|
aren't representable yet — this is the top priority for real furniture.
|
||||||
|
2. **Joins stack in Z** (board B rests on B's top face). This avoids
|
||||||
|
interpenetration but isn't true joinery (no butt/mortise/lap geometry).
|
||||||
|
3. **Latency** ~7–13s per utterance (one `claude -p` call). Fine for now.
|
||||||
|
4. Voice path (`--voice`) reuses `dictate`; not yet exercised on real hardware
|
||||||
|
in this repo's tests.
|
||||||
|
|
||||||
## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals
|
## ⚠️ CRITICAL: Updating Todos, Milestones, and Goals
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ dependencies = []
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
woodshop = "woodshop.cli:main"
|
woodshop = "woodshop.cli:main"
|
||||||
woodshop-view = "woodshop.viewer:main"
|
woodshop-view = "woodshop.viewer:main"
|
||||||
|
woodshop-talk = "woodshop.driver:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
|
# Heavy 3D stack (OpenCASCADE etc.) — only needed to run the live viewport.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
"""Generate the wood-* CmdForge tools: the documented woodworking command
|
||||||
|
vocabulary. Each is a thin wrapper over the `woodshop` CLI so the logic lives in
|
||||||
|
one place; pa-load-tools turns these into Claude function schemas."""
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
CMDFORGE_PY = "/home/rob/.local/share/pipx/venvs/cmdforge/bin/python"
|
||||||
|
CMDFORGE_DIR = Path.home() / ".cmdforge"
|
||||||
|
BIN_DIR = Path.home() / ".local" / "bin"
|
||||||
|
|
||||||
|
WS = 'ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")'
|
||||||
|
|
||||||
|
PLACE = f'''import subprocess, os
|
||||||
|
{WS}
|
||||||
|
r = subprocess.run([ws, "place", stock, length], capture_output=True, text=True)
|
||||||
|
out = (r.stdout + r.stderr).strip()
|
||||||
|
'''
|
||||||
|
|
||||||
|
JOIN = f'''import subprocess, os
|
||||||
|
{WS}
|
||||||
|
cmd = [ws, "join", part_b]
|
||||||
|
if to: cmd += ["--to", to]
|
||||||
|
if angle: cmd += ["--angle", str(angle)]
|
||||||
|
if offset: cmd += ["--offset", offset]
|
||||||
|
if anchor: cmd += ["--anchor", anchor]
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
out = (r.stdout + r.stderr).strip()
|
||||||
|
'''
|
||||||
|
|
||||||
|
SAND = f'''import subprocess, os
|
||||||
|
{WS}
|
||||||
|
cmd = [ws, "sand"] + ([part] if part else [])
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
out = (r.stdout + r.stderr).strip()
|
||||||
|
'''
|
||||||
|
|
||||||
|
DELETE = f'''import subprocess, os
|
||||||
|
{WS}
|
||||||
|
cmd = [ws, "delete"] + ([part] if part else [])
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
out = (r.stdout + r.stderr).strip()
|
||||||
|
'''
|
||||||
|
|
||||||
|
UNDO = f'''import subprocess, os
|
||||||
|
{WS}
|
||||||
|
r = subprocess.run([ws, "undo"], capture_output=True, text=True)
|
||||||
|
out = (r.stdout + r.stderr).strip()
|
||||||
|
'''
|
||||||
|
|
||||||
|
TOOLS = {
|
||||||
|
"wood-place": {
|
||||||
|
"description": "Place a new board of dimensional lumber into the scene. Use for any 'place', 'add', 'put', 'grab', 'cut me a' board command.",
|
||||||
|
"arguments": [
|
||||||
|
{"flag": "--stock", "variable": "stock",
|
||||||
|
"description": "Nominal lumber size, e.g. 2x4, 2x6, 1x4, 4x4"},
|
||||||
|
{"flag": "--length", "variable": "length",
|
||||||
|
"description": "Length with units, e.g. '6 ft', '72 in', '3 ft 6 in'"},
|
||||||
|
],
|
||||||
|
"code": PLACE,
|
||||||
|
},
|
||||||
|
"wood-join": {
|
||||||
|
"description": "Attach/join one board to another at an angle, optionally offset along the target board. Use for 'attach', 'join', 'connect', 'fasten', 'screw to'.",
|
||||||
|
"arguments": [
|
||||||
|
{"flag": "--part-b", "variable": "part_b",
|
||||||
|
"description": "Id of the board being attached, e.g. p2"},
|
||||||
|
{"flag": "--to", "variable": "to", "default": "",
|
||||||
|
"description": "Id of the board to attach to, e.g. p1. Omit to use the most recently touched board."},
|
||||||
|
{"flag": "--angle", "variable": "angle", "default": "90",
|
||||||
|
"description": "Angle in degrees between the two boards (default 90)"},
|
||||||
|
{"flag": "--offset", "variable": "offset", "default": "",
|
||||||
|
"description": "Distance from the anchor end, e.g. '10 in'. Omit to attach at the very end."},
|
||||||
|
{"flag": "--anchor", "variable": "anchor", "default": "end_b",
|
||||||
|
"description": "Measure offset from 'end_a' (start) or 'end_b' (far end)"},
|
||||||
|
],
|
||||||
|
"code": JOIN,
|
||||||
|
},
|
||||||
|
"wood-sand": {
|
||||||
|
"description": "Sand a board smooth. Use for 'sand', 'smooth', 'finish'.",
|
||||||
|
"arguments": [
|
||||||
|
{"flag": "--part", "variable": "part", "default": "",
|
||||||
|
"description": "Id of the board to sand, e.g. p1. Omit to sand the most recently touched board ('it')."},
|
||||||
|
],
|
||||||
|
"code": SAND,
|
||||||
|
},
|
||||||
|
"wood-delete": {
|
||||||
|
"description": "Remove a board from the scene. Use for 'delete', 'remove', 'get rid of', 'scrap'.",
|
||||||
|
"arguments": [
|
||||||
|
{"flag": "--part", "variable": "part", "default": "",
|
||||||
|
"description": "Id of the board to delete, e.g. p2. Omit for the most recently touched board."},
|
||||||
|
],
|
||||||
|
"code": DELETE,
|
||||||
|
},
|
||||||
|
"wood-undo": {
|
||||||
|
"description": "Undo the last operation. Use for 'undo', 'never mind', 'take that back', 'go back'.",
|
||||||
|
"arguments": [],
|
||||||
|
"code": UNDO,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
WRAPPER = '''#!/bin/bash
|
||||||
|
# CmdForge wrapper for '{name}'
|
||||||
|
# Auto-generated - do not edit
|
||||||
|
exec "{py}" -m cmdforge.runner "{name}" "$@"
|
||||||
|
'''
|
||||||
|
|
||||||
|
for name, spec in TOOLS.items():
|
||||||
|
tool_dir = CMDFORGE_DIR / name
|
||||||
|
tool_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config = {
|
||||||
|
"name": name,
|
||||||
|
"description": spec["description"],
|
||||||
|
"category": "Other",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"arguments": spec["arguments"],
|
||||||
|
"steps": [{"type": "code", "code": spec["code"], "output_var": "out"}],
|
||||||
|
"output": "{out}",
|
||||||
|
}
|
||||||
|
(tool_dir / "config.yaml").write_text(yaml.safe_dump(config, sort_keys=False))
|
||||||
|
|
||||||
|
wrapper = BIN_DIR / name
|
||||||
|
wrapper.write_text(WRAPPER.format(name=name, py=CMDFORGE_PY))
|
||||||
|
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
print(f"created {name}: {tool_dir/'config.yaml'} + {wrapper}")
|
||||||
|
|
@ -29,11 +29,23 @@ def cmd_place(scene: Scene, args) -> str:
|
||||||
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
|
return f"Placed {part.id}: a {_fmt_len(length)} {part.stock}."
|
||||||
|
|
||||||
|
|
||||||
|
_ANCHOR_ALIASES = {
|
||||||
|
"end_a": "end_a", "start": "end_a", "near": "end_a", "beginning": "end_a",
|
||||||
|
"end_b": "end_b", "end": "end_b", "far": "end_b", "tip": "end_b",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_anchor(value: str) -> str:
|
||||||
|
"""Accept loose spoken anchors ('the end', 'start') -> end_a/end_b."""
|
||||||
|
return _ANCHOR_ALIASES.get((value or "end_b").strip().lower(), "end_b")
|
||||||
|
|
||||||
|
|
||||||
def cmd_join(scene: Scene, args) -> str:
|
def cmd_join(scene: Scene, args) -> str:
|
||||||
|
anchor = normalize_anchor(args.anchor)
|
||||||
offset = to_inches(args.offset, default_unit=args.unit) if args.offset else 0.0
|
offset = to_inches(args.offset, default_unit=args.unit) if args.offset else 0.0
|
||||||
joint = scene.join(args.part_a, args.part_b, angle_deg=args.angle,
|
joint = scene.join(args.part_a, args.part_b, angle_deg=args.angle,
|
||||||
offset_in=offset, anchor=args.anchor)
|
offset_in=offset, anchor=anchor)
|
||||||
where = f" {_fmt_len(offset)} from {'the start' if args.anchor == 'end_a' else 'the end'}" if offset else ""
|
where = f" {_fmt_len(offset)} from {'the start' if anchor == 'end_a' else 'the end'}" if offset else ""
|
||||||
return f"Joined {joint.part_b} to {joint.part_a} at {args.angle:g} degrees{where}."
|
return f"Joined {joint.part_b} to {joint.part_a} at {args.angle:g} degrees{where}."
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,8 +98,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
sp.add_argument("--to", dest="part_a", default=None, help="Board to attach to (default: selection)")
|
sp.add_argument("--to", dest="part_a", default=None, help="Board to attach to (default: selection)")
|
||||||
sp.add_argument("--angle", type=float, default=90.0, help="Angle in degrees")
|
sp.add_argument("--angle", type=float, default=90.0, help="Angle in degrees")
|
||||||
sp.add_argument("--offset", default=None, help="Distance from anchor, e.g. '10 in'")
|
sp.add_argument("--offset", default=None, help="Distance from anchor, e.g. '10 in'")
|
||||||
sp.add_argument("--anchor", choices=["end_a", "end_b"], default="end_b",
|
sp.add_argument("--anchor", default="end_b",
|
||||||
help="Measure offset from start (end_a) or far end (end_b)")
|
help="Measure offset from start (end_a/start) or far end (end_b/end)")
|
||||||
sp.add_argument("--unit", default="inch")
|
sp.add_argument("--unit", default="inch")
|
||||||
sp.set_defaults(func=cmd_join)
|
sp.set_defaults(func=cmd_join)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""The conversational driver: speak (or type) a command, watch it build.
|
||||||
|
|
||||||
|
Reuses existing CmdForge tools for everything that isn't woodshop-specific:
|
||||||
|
* `dictate` -> speech to text (with --voice)
|
||||||
|
* `pa-load-tools` -> turns the wood-* tools into Claude function schemas
|
||||||
|
* `claude -p` -> interprets the utterance into tool calls
|
||||||
|
* `pa-execute-tool`-> dispatches each wood-* tool
|
||||||
|
* `read-aloud` -> speaks the confirmation back
|
||||||
|
|
||||||
|
Only the orchestration here is woodshop-specific (it must be: we use Claude
|
||||||
|
rather than pa-tool-loop's hard-wired local model). Run the viewer alongside it:
|
||||||
|
|
||||||
|
woodshop-view & # 3D window
|
||||||
|
woodshop-talk # type commands; add --voice to speak them
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
WOOD_TOOLS = ["wood-place", "wood-join", "wood-sand", "wood-delete", "wood-undo"]
|
||||||
|
REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling
|
||||||
|
|
||||||
|
# A board placed earlier in the SAME utterance is referenced as $1, $2, ...
|
||||||
|
_SYMBOL = re.compile(r"\$(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: list[str], stdin: str = "") -> str:
|
||||||
|
proc = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
|
||||||
|
return (proc.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def load_schemas() -> str:
|
||||||
|
return _run(["pa-load-tools", "--tools", ",".join(WOOD_TOOLS), "--format", "anthropic"])
|
||||||
|
|
||||||
|
|
||||||
|
def scene_summary() -> str:
|
||||||
|
ws = os.path.expanduser("~/PycharmProjects/woodshop/.venv/bin/woodshop")
|
||||||
|
return _run([ws, "status"]) or "empty"
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM = """You are WoodShop, a voice-driven woodworking assistant. Translate the \
|
||||||
|
user's spoken command into a JSON array of tool calls.
|
||||||
|
|
||||||
|
Tools (JSON schemas):
|
||||||
|
{schemas}
|
||||||
|
|
||||||
|
Current scene:
|
||||||
|
{scene}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Respond with ONLY a JSON array. No prose, no markdown fences.
|
||||||
|
- Each element is {{"tool": "<name>", "args": {{...}}}}.
|
||||||
|
- Refer to boards that ALREADY exist by their real id (p1, p2, ...).
|
||||||
|
- For a board you place earlier in THIS response, refer to it later as $1, $2, ...
|
||||||
|
numbered by the order you place boards in this response (the first wood-place is $1).
|
||||||
|
- For wood-join, "part_b" is the board being attached (it gets moved); "to" is the
|
||||||
|
board it attaches to. Anchor is "end" (far end) or "start".
|
||||||
|
- If the command is ambiguous or not about woodworking, return a single
|
||||||
|
{{"tool": "say", "args": {{"text": "<short question or reply>"}}}}.
|
||||||
|
|
||||||
|
User said: "{utterance}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def interpret(utterance: str, schemas: str) -> list[dict]:
|
||||||
|
prompt = SYSTEM.format(schemas=schemas, scene=scene_summary(), utterance=utterance)
|
||||||
|
raw = _run(REASON_PROVIDER.split(), stdin=prompt)
|
||||||
|
match = re.search(r"\[.*\]", raw, re.DOTALL) # tolerate stray text/fences
|
||||||
|
if not match:
|
||||||
|
return [{"tool": "say", "args": {"text": "Sorry, I didn't catch a command."}}]
|
||||||
|
try:
|
||||||
|
calls = json.loads(match.group(0))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [{"tool": "say", "args": {"text": "Sorry, I couldn't parse that."}}]
|
||||||
|
return calls if isinstance(calls, list) else [calls]
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(calls: list[dict], verbose: bool = True) -> list[str]:
|
||||||
|
"""Execute calls in order, resolving $N to ids of boards placed this turn."""
|
||||||
|
placed: list[str] = []
|
||||||
|
messages: list[str] = []
|
||||||
|
|
||||||
|
def resolve(value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
def sub(m):
|
||||||
|
i = int(m.group(1)) - 1
|
||||||
|
return placed[i] if 0 <= i < len(placed) else m.group(0)
|
||||||
|
return _SYMBOL.sub(sub, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
for call in calls:
|
||||||
|
tool = call.get("tool", "")
|
||||||
|
args = {k: resolve(v) for k, v in (call.get("args") or {}).items()}
|
||||||
|
|
||||||
|
if tool == "say":
|
||||||
|
messages.append(args.get("text", ""))
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = _run(["pa-execute-tool", "--tool-name", tool,
|
||||||
|
"--tool-args", json.dumps(args)])
|
||||||
|
try:
|
||||||
|
payload = json.loads(result)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {"success": False, "output": "", "error": result}
|
||||||
|
|
||||||
|
out = payload.get("output") or payload.get("error") or "(no output)"
|
||||||
|
if payload.get("success") and tool == "wood-place":
|
||||||
|
m = re.search(r"\b(p\d+)\b", out) # remember the new id for $N
|
||||||
|
if m:
|
||||||
|
placed.append(m.group(1))
|
||||||
|
messages.append(out)
|
||||||
|
if verbose:
|
||||||
|
print(f" {tool}{args} -> {out}")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def speak(text: str) -> None:
|
||||||
|
if text.strip():
|
||||||
|
subprocess.run(["read-aloud", "--strip-md", "true"], input=text, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None:
|
||||||
|
calls = interpret(utterance, schemas)
|
||||||
|
messages = dispatch(calls, verbose=verbose)
|
||||||
|
summary = " ".join(m for m in messages if m).strip()
|
||||||
|
print(f"WoodShop: {summary}")
|
||||||
|
if voice and summary:
|
||||||
|
speak(summary)
|
||||||
|
|
||||||
|
|
||||||
|
def get_utterance(voice: bool, duration: int) -> str | None:
|
||||||
|
if voice:
|
||||||
|
print(f"[listening {duration}s...]")
|
||||||
|
text = _run(["dictate", "--duration", str(duration)])
|
||||||
|
print(f"You said: {text!r}")
|
||||||
|
return text or None
|
||||||
|
try:
|
||||||
|
return input("you> ").strip() or None
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(prog="woodshop-talk", description="Conversational woodworking.")
|
||||||
|
ap.add_argument("--voice", action="store_true", help="Listen on the mic instead of typing")
|
||||||
|
ap.add_argument("--duration", type=int, default=6, help="Mic recording seconds (--voice)")
|
||||||
|
ap.add_argument("--once", help="Run a single command (non-interactive) and exit")
|
||||||
|
ap.add_argument("--quiet", action="store_true", help="Don't print per-call detail")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
schemas = load_schemas()
|
||||||
|
if not schemas:
|
||||||
|
print("Could not load wood-* tool schemas (is CmdForge/pa-load-tools available?)",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.once is not None:
|
||||||
|
handle(args.once, schemas, voice=args.voice, verbose=not args.quiet)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("WoodShop ready. Say things like 'place a 6 foot 2x4'. Ctrl-C to quit.")
|
||||||
|
while True:
|
||||||
|
utterance = get_utterance(args.voice, args.duration)
|
||||||
|
if utterance is None:
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
if utterance.lower() in ("quit", "exit", "stop", "done"):
|
||||||
|
return 0
|
||||||
|
handle(utterance, schemas, voice=args.voice, verbose=not args.quiet)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -149,9 +149,12 @@ class Scene:
|
||||||
# Distance measured along A from its start.
|
# Distance measured along A from its start.
|
||||||
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
|
along = offset_in if anchor == "end_a" else max(a.length_in - offset_in, 0.0)
|
||||||
ux, uy = a.axis_unit()
|
ux, uy = a.axis_unit()
|
||||||
|
# Stack B on A's top face (in Z) so the boards rest against each other
|
||||||
|
# instead of interpenetrating at the centerlines, as real lumber would.
|
||||||
|
stack_z = a.section_in[0] / 2 + b.section_in[0] / 2
|
||||||
attach = [a.position_in[0] + ux * along,
|
attach = [a.position_in[0] + ux * along,
|
||||||
a.position_in[1] + uy * along,
|
a.position_in[1] + uy * along,
|
||||||
a.position_in[2]]
|
a.position_in[2] + stack_z]
|
||||||
b.position_in = attach
|
b.position_in = attach
|
||||||
b.rotation_deg = a.rotation_deg + angle_deg
|
b.rotation_deg = a.rotation_deg + angle_deg
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Tests for the driver's orchestration logic (external tools are mocked)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from woodshop import driver
|
||||||
|
from woodshop.cli import normalize_anchor
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_aliases():
|
||||||
|
assert normalize_anchor("end") == "end_b"
|
||||||
|
assert normalize_anchor("the end") == "end_b" # falls through to default end_b
|
||||||
|
assert normalize_anchor("start") == "end_a"
|
||||||
|
assert normalize_anchor("NEAR") == "end_a"
|
||||||
|
assert normalize_anchor("") == "end_b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_resolves_dollar_symbols(monkeypatch):
|
||||||
|
"""$1/$2 in a multi-op turn resolve to the ids of boards placed this turn."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_run(cmd, stdin=""):
|
||||||
|
if cmd[0] != "pa-execute-tool":
|
||||||
|
return ""
|
||||||
|
name, args = cmd[2], json.loads(cmd[4])
|
||||||
|
seen.append((name, args))
|
||||||
|
if name == "wood-place":
|
||||||
|
n = sum(1 for c in seen if c[0] == "wood-place")
|
||||||
|
return json.dumps({"success": True, "output": f"Placed p{n}: a board.", "error": ""})
|
||||||
|
return json.dumps({"success": True, "output": f"did {name}", "error": ""})
|
||||||
|
|
||||||
|
monkeypatch.setattr(driver, "_run", fake_run)
|
||||||
|
calls = [
|
||||||
|
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||||
|
{"tool": "wood-place", "args": {"stock": "2x4", "length": "2 ft"}},
|
||||||
|
{"tool": "wood-join", "args": {"part_b": "$2", "to": "$1", "angle": "90"}},
|
||||||
|
]
|
||||||
|
driver.dispatch(calls, verbose=False)
|
||||||
|
join_args = next(a for n, a in seen if n == "wood-join")
|
||||||
|
assert join_args["part_b"] == "p2"
|
||||||
|
assert join_args["to"] == "p1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_say_pseudo_tool_does_not_dispatch(monkeypatch):
|
||||||
|
calls_made = []
|
||||||
|
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": calls_made.append(cmd) or "")
|
||||||
|
msgs = driver.dispatch([{"tool": "say", "args": {"text": "which end?"}}], verbose=False)
|
||||||
|
assert msgs == ["which end?"]
|
||||||
|
assert calls_made == [] # nothing executed
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_tolerates_fenced_json(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
driver, "_run",
|
||||||
|
lambda cmd, stdin="": '```json\n[{"tool": "wood-undo", "args": {}}]\n```'
|
||||||
|
if cmd[:2] != ["pa-load-tools", "--tools"] else "[]",
|
||||||
|
)
|
||||||
|
calls = driver.interpret("undo that", schemas="[]")
|
||||||
|
assert calls == [{"tool": "wood-undo", "args": {}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_handles_garbage(monkeypatch):
|
||||||
|
monkeypatch.setattr(driver, "_run", lambda cmd, stdin="": "I'm not sure what you mean")
|
||||||
|
calls = driver.interpret("blah", schemas="[]")
|
||||||
|
assert calls[0]["tool"] == "say"
|
||||||
|
|
@ -60,6 +60,8 @@ def test_the_example_sentence():
|
||||||
assert "sanded" in p1.finishes
|
assert "sanded" in p1.finishes
|
||||||
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
|
# attach point is 10in back from p1's far end (72 - 10 = 62 along +X)
|
||||||
assert p2.position_in[0] == pytest.approx(62.0)
|
assert p2.position_in[0] == pytest.approx(62.0)
|
||||||
|
# p2 rests on p1's top face: z = t_a/2 + t_b/2 = 0.75 + 0.75
|
||||||
|
assert p2.position_in[2] == pytest.approx(1.5)
|
||||||
assert p2.rotation_deg == pytest.approx(90.0)
|
assert p2.rotation_deg == pytest.approx(90.0)
|
||||||
# p2 now runs along +Y
|
# p2 now runs along +Y
|
||||||
ux, uy = p2.axis_unit()
|
ux, uy = p2.axis_unit()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue