CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
4eb4118
·
1 Parent(s): dacf755

docs: add implementation plan for planning review gate & guided wizard

Browse files
docs/superpowers/plans/2026-04-12-planning-review-gate.md ADDED
@@ -0,0 +1,1755 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Planning Review Gate & Guided Wizard Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add a planning phase with user approval before CAD generation, a guided wizard UI, 3MF export, and organized downloads.
6
+
7
+ **Architecture:** `DesignState` gains `phase` and `plan` fields. A scoring function triggers plan presentation when completeness crosses a configurable threshold. Two new API endpoints handle approve/reject. The frontend adds a Chat/Guided tab switcher with a 7-step wizard and inline plan cards.
8
+
9
+ **Tech Stack:** Python/Pydantic (backend), FastAPI (API), vanilla JS/HTML (frontend), CadQuery (3MF export)
10
+
11
+ ---
12
+
13
+ ### Task 1: PlanningConfig in settings
14
+
15
+ **Files:**
16
+ - Modify: `config/settings.py:82-96` (insert before `AgentConfig`)
17
+ - Modify: `config/settings.py:121-123` (add `planning` field to `Settings`)
18
+ - Modify: `config.yaml:72-73` (add `planning` section before `agents`)
19
+ - Test: `tests/test_settings.py`
20
+
21
+ - [ ] **Step 1: Write failing test**
22
+
23
+ ```python
24
+ # tests/test_settings.py — add to existing file
25
+
26
+ class TestPlanningConfig:
27
+ def test_planning_defaults(self):
28
+ from config.settings import Settings
29
+ s = Settings()
30
+ assert s.planning.threshold == 8.0
31
+ assert s.planning.weights["material"] == 3
32
+ assert s.planning.caps["dimension"] == 4
33
+ assert "plan" in s.planning.trigger_keywords
34
+ assert "cad" in s.planning.approved_agents
35
+
36
+ def test_planning_threshold_from_yaml(self):
37
+ from config.settings import settings
38
+ assert settings.planning.threshold == 8.0
39
+ ```
40
+
41
+ - [ ] **Step 2: Run test to verify it fails**
42
+
43
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_settings.py::TestPlanningConfig -v`
44
+ Expected: FAIL with `AttributeError: 'Settings' object has no attribute 'planning'`
45
+
46
+ - [ ] **Step 3: Add PlanningConfig model to settings.py**
47
+
48
+ Add after `RoutingConfig` class (line 96):
49
+
50
+ ```python
51
+ class PlanningConfig(BaseModel):
52
+ threshold: float = 8.0
53
+ weights: dict[str, float] = Field(default_factory=lambda: {
54
+ "material": 3, "dimension": 1, "feature": 1,
55
+ "constraint": 1, "part_name": 1, "description": 1,
56
+ "axis_recommendation": 2,
57
+ })
58
+ caps: dict[str, int] = Field(default_factory=lambda: {
59
+ "dimension": 4, "feature": 4, "constraint": 2,
60
+ })
61
+ trigger_keywords: list[str] = Field(default_factory=lambda: [
62
+ "plan", "review", "ready", "show plan", "summarize", "what do we have",
63
+ ])
64
+ approved_agents: list[str] = Field(default_factory=lambda: ["cad", "cnc"])
65
+ ```
66
+
67
+ Add to `Settings` class after `cam` field (line 121):
68
+
69
+ ```python
70
+ planning: PlanningConfig = Field(default_factory=PlanningConfig)
71
+ ```
72
+
73
+ - [ ] **Step 4: Add planning section to config.yaml**
74
+
75
+ Add before the `agents:` line (line 73 in config.yaml):
76
+
77
+ ```yaml
78
+ planning:
79
+ threshold: 8
80
+ weights:
81
+ material: 3
82
+ dimension: 1
83
+ feature: 1
84
+ constraint: 1
85
+ part_name: 1
86
+ description: 1
87
+ axis_recommendation: 2
88
+ caps:
89
+ dimension: 4
90
+ feature: 4
91
+ constraint: 2
92
+ trigger_keywords:
93
+ - "plan"
94
+ - "review"
95
+ - "ready"
96
+ - "show plan"
97
+ - "summarize"
98
+ - "what do we have"
99
+ approved_agents:
100
+ - "cad"
101
+ - "cnc"
102
+ ```
103
+
104
+ - [ ] **Step 5: Run test to verify it passes**
105
+
106
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_settings.py::TestPlanningConfig -v`
107
+ Expected: PASS
108
+
109
+ - [ ] **Step 6: Run full test suite**
110
+
111
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
112
+ Expected: All existing tests pass
113
+
114
+ - [ ] **Step 7: Commit**
115
+
116
+ ```bash
117
+ git add config/settings.py config.yaml tests/test_settings.py
118
+ git commit -m "feat: add PlanningConfig to settings with threshold, weights, caps"
119
+ ```
120
+
121
+ ---
122
+
123
+ ### Task 2: DesignPlan model and compute_score()
124
+
125
+ **Files:**
126
+ - Modify: `agents/design_state.py:1-10` (add imports)
127
+ - Modify: `agents/design_state.py:40-49` (add fields to DesignState)
128
+ - Create new class and function in `agents/design_state.py`
129
+ - Test: `tests/test_design_state.py`
130
+
131
+ - [ ] **Step 1: Write failing tests**
132
+
133
+ ```python
134
+ # tests/test_design_state.py — add to existing file
135
+
136
+ from agents.design_state import DesignPlan, compute_score
137
+
138
+
139
+ class TestDesignPlan:
140
+ def test_create_from_state(self):
141
+ state = DesignState(
142
+ part_name="bracket",
143
+ description="mounting bracket",
144
+ material="aluminum 6061",
145
+ dimensions={"width": 60.0, "height": 40.0, "depth": 20.0},
146
+ features=["4x M6 holes"],
147
+ constraints=["min wall 3mm"],
148
+ axis_recommendation="3-axis",
149
+ decisions=["use aluminum"],
150
+ )
151
+ plan = DesignPlan.from_state(state, confidence_score=9.0)
152
+ assert plan.part_name == "bracket"
153
+ assert plan.material == "aluminum 6061"
154
+ assert plan.dimensions == {"width": 60.0, "height": 40.0, "depth": 20.0}
155
+ assert plan.confidence_score == 9.0
156
+ assert plan.machining_notes == []
157
+
158
+ def test_plan_render(self):
159
+ plan = DesignPlan(
160
+ part_name="bracket",
161
+ description="test",
162
+ material="aluminum 6061",
163
+ dimensions={"width": 60.0},
164
+ features=["4x M6 holes"],
165
+ constraints=[],
166
+ axis_recommendation="3-axis",
167
+ machining_notes=["No undercuts"],
168
+ confidence_score=9.0,
169
+ )
170
+ rendered = plan.render_approved()
171
+ assert "APPROVED DESIGN PLAN" in rendered
172
+ assert "aluminum 6061" in rendered
173
+ assert "No undercuts" in rendered
174
+
175
+
176
+ class TestComputeScore:
177
+ def test_empty_state_scores_zero(self):
178
+ assert compute_score(DesignState()) == 0.0
179
+
180
+ def test_material_scores_3(self):
181
+ state = DesignState(material="aluminum")
182
+ assert compute_score(state) == 3.0
183
+
184
+ def test_full_state_above_threshold(self):
185
+ state = DesignState(
186
+ part_name="bracket",
187
+ description="test bracket",
188
+ material="aluminum 6061",
189
+ dimensions={"width": 60.0, "height": 40.0, "depth": 20.0},
190
+ features=["4x M6 holes"],
191
+ constraints=["min wall 3mm"],
192
+ axis_recommendation="3-axis",
193
+ )
194
+ score = compute_score(state)
195
+ assert score >= 8.0 # threshold
196
+
197
+ def test_dimension_cap_at_4(self):
198
+ state = DesignState(dimensions={
199
+ "width": 60, "height": 40, "depth": 20,
200
+ "length": 100, "diameter": 10, "radius": 5,
201
+ })
202
+ score = compute_score(state)
203
+ assert score == 4.0 # 6 dims but capped at 4
204
+
205
+ def test_feature_cap_at_4(self):
206
+ state = DesignState(features=["a", "b", "c", "d", "e", "f"])
207
+ score = compute_score(state)
208
+ assert score == 4.0 # 6 features but capped at 4
209
+
210
+ def test_constraint_cap_at_2(self):
211
+ state = DesignState(constraints=["a", "b", "c", "d"])
212
+ score = compute_score(state)
213
+ assert score == 2.0 # 4 constraints but capped at 2
214
+
215
+
216
+ class TestDesignStatePhase:
217
+ def test_default_phase_exploring(self):
218
+ state = DesignState()
219
+ assert state.phase == "exploring"
220
+ assert state.plan is None
221
+
222
+ def test_phase_serialization(self):
223
+ plan = DesignPlan(
224
+ part_name="b", description="", material="steel",
225
+ dimensions={}, features=[], constraints=[],
226
+ axis_recommendation="", machining_notes=[],
227
+ confidence_score=5.0,
228
+ )
229
+ state = DesignState(phase="planning", plan=plan)
230
+ d = state.model_dump()
231
+ assert d["phase"] == "planning"
232
+ assert d["plan"]["material"] == "steel"
233
+
234
+ def test_roundtrip_from_dict(self):
235
+ state = DesignState(phase="approved", material="brass")
236
+ d = state.model_dump()
237
+ restored = DesignState(**d)
238
+ assert restored.phase == "approved"
239
+ assert restored.material == "brass"
240
+ ```
241
+
242
+ - [ ] **Step 2: Run tests to verify they fail**
243
+
244
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py::TestDesignPlan -v`
245
+ Expected: FAIL with `ImportError: cannot import name 'DesignPlan'`
246
+
247
+ - [ ] **Step 3: Add DesignPlan model, compute_score(), and new DesignState fields**
248
+
249
+ In `agents/design_state.py`, add `DesignPlan` class before `DesignState`:
250
+
251
+ ```python
252
+ class DesignPlan(BaseModel):
253
+ """Structured plan presented to user for review/editing before generation."""
254
+ part_name: str = ""
255
+ description: str = ""
256
+ material: str = ""
257
+ dimensions: dict[str, float] = Field(default_factory=dict)
258
+ features: list[str] = Field(default_factory=list)
259
+ constraints: list[str] = Field(default_factory=list)
260
+ axis_recommendation: str = ""
261
+ machining_notes: list[str] = Field(default_factory=list)
262
+ confidence_score: float = 0.0
263
+
264
+ @classmethod
265
+ def from_state(cls, state: "DesignState", confidence_score: float) -> "DesignPlan":
266
+ """Create a plan snapshot from the current design state."""
267
+ return cls(
268
+ part_name=state.part_name,
269
+ description=state.description,
270
+ material=state.material,
271
+ dimensions=dict(state.dimensions),
272
+ features=list(state.features),
273
+ constraints=list(state.constraints),
274
+ axis_recommendation=state.axis_recommendation,
275
+ machining_notes=[],
276
+ confidence_score=confidence_score,
277
+ )
278
+
279
+ def render_approved(self) -> str:
280
+ """Render as an approved plan context block for LLM agents."""
281
+ lines = ["## APPROVED DESIGN PLAN (user-confirmed)"]
282
+ if self.part_name:
283
+ lines.append(f"Part: {self.part_name}")
284
+ if self.description:
285
+ lines.append(f"Description: {self.description}")
286
+ if self.material:
287
+ lines.append(f"Material: {self.material}")
288
+ if self.dimensions:
289
+ dims = ", ".join(f"{k}={v}mm" for k, v in self.dimensions.items())
290
+ lines.append(f"Dimensions: {dims}")
291
+ if self.features:
292
+ lines.append(f"Features: {'; '.join(self.features)}")
293
+ if self.constraints:
294
+ lines.append(f"Constraints: {'; '.join(self.constraints)}")
295
+ if self.axis_recommendation:
296
+ lines.append(f"Axis: {self.axis_recommendation}")
297
+ if self.machining_notes:
298
+ lines.append(f"Machining Notes: {'; '.join(self.machining_notes)}")
299
+ lines.append("")
300
+ lines.append("This plan has been reviewed and approved by the user.")
301
+ lines.append("Generate the model according to these specifications.")
302
+ return "\n".join(lines)
303
+ ```
304
+
305
+ Add two new fields to `DesignState` (after `axis_recommendation`):
306
+
307
+ ```python
308
+ phase: str = "exploring"
309
+ plan: DesignPlan | None = None
310
+ ```
311
+
312
+ Add `compute_score()` function after the `DesignState` class:
313
+
314
+ ```python
315
+ def compute_score(state: DesignState) -> float:
316
+ """Compute completeness score for a design state using configured weights and caps."""
317
+ cfg = settings.planning
318
+ score = 0.0
319
+ if state.material:
320
+ score += cfg.weights.get("material", 3)
321
+ if state.part_name:
322
+ score += cfg.weights.get("part_name", 1)
323
+ if state.description:
324
+ score += cfg.weights.get("description", 1)
325
+ if state.axis_recommendation:
326
+ score += cfg.weights.get("axis_recommendation", 2)
327
+ dim_cap = cfg.caps.get("dimension", 4)
328
+ dim_count = min(len(state.dimensions), dim_cap)
329
+ score += dim_count * cfg.weights.get("dimension", 1)
330
+ feat_cap = cfg.caps.get("feature", 4)
331
+ feat_count = min(len(state.features), feat_cap)
332
+ score += feat_count * cfg.weights.get("feature", 1)
333
+ const_cap = cfg.caps.get("constraint", 2)
334
+ const_count = min(len(state.constraints), const_cap)
335
+ score += const_count * cfg.weights.get("constraint", 1)
336
+ return score
337
+ ```
338
+
339
+ - [ ] **Step 4: Run tests to verify they pass**
340
+
341
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py -v`
342
+ Expected: All pass (existing + new)
343
+
344
+ - [ ] **Step 5: Run full test suite**
345
+
346
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
347
+ Expected: All pass
348
+
349
+ - [ ] **Step 6: Commit**
350
+
351
+ ```bash
352
+ git add agents/design_state.py tests/test_design_state.py
353
+ git commit -m "feat: add DesignPlan model, compute_score(), phase/plan fields to DesignState"
354
+ ```
355
+
356
+ ---
357
+
358
+ ### Task 3: Plan approve/reject API endpoints
359
+
360
+ **Files:**
361
+ - Modify: `server/routes.py:1-10` (add imports)
362
+ - Modify: `server/routes.py:38-39` (add request models)
363
+ - Add new endpoints at end of `server/routes.py`
364
+ - Test: `tests/test_api_routes.py`
365
+
366
+ - [ ] **Step 1: Write failing tests**
367
+
368
+ ```python
369
+ # tests/test_api_routes.py — add to existing file
370
+
371
+ class TestPlanApproveEndpoint:
372
+ def test_approve_sets_phase(self):
373
+ resp = client.post("/api/plan/approve", json={
374
+ "plan": {
375
+ "part_name": "bracket",
376
+ "description": "test",
377
+ "material": "aluminum 6061",
378
+ "dimensions": {"width": 60},
379
+ "features": ["4x M6 holes"],
380
+ "constraints": ["min wall 3mm"],
381
+ "axis_recommendation": "3-axis",
382
+ "machining_notes": [],
383
+ "confidence_score": 9.0,
384
+ },
385
+ "design_state": {
386
+ "part_name": "bracket",
387
+ "material": "steel",
388
+ "dimensions": {"width": 50},
389
+ "phase": "planning",
390
+ },
391
+ })
392
+ assert resp.status_code == 200
393
+ data = resp.json()
394
+ assert data["design_state"]["phase"] == "approved"
395
+ assert data["design_state"]["material"] == "aluminum 6061"
396
+ assert data["design_state"]["dimensions"]["width"] == 60
397
+
398
+ def test_approve_merges_plan_into_state(self):
399
+ resp = client.post("/api/plan/approve", json={
400
+ "plan": {
401
+ "part_name": "gear",
402
+ "description": "spur gear",
403
+ "material": "brass",
404
+ "dimensions": {"diameter": 40},
405
+ "features": [],
406
+ "constraints": [],
407
+ "axis_recommendation": "3-axis",
408
+ "machining_notes": ["No undercuts"],
409
+ "confidence_score": 8.0,
410
+ },
411
+ "design_state": {"phase": "planning"},
412
+ })
413
+ data = resp.json()
414
+ assert data["design_state"]["part_name"] == "gear"
415
+ assert data["design_state"]["plan"]["material"] == "brass"
416
+
417
+
418
+ class TestPlanRejectEndpoint:
419
+ def test_reject_resets_phase(self):
420
+ resp = client.post("/api/plan/reject", json={
421
+ "design_state": {
422
+ "phase": "planning",
423
+ "material": "aluminum",
424
+ "plan": {"part_name": "x", "description": "", "material": "aluminum",
425
+ "dimensions": {}, "features": [], "constraints": [],
426
+ "axis_recommendation": "", "machining_notes": [],
427
+ "confidence_score": 5.0},
428
+ },
429
+ })
430
+ assert resp.status_code == 200
431
+ data = resp.json()
432
+ assert data["design_state"]["phase"] == "exploring"
433
+ assert data["design_state"]["plan"] is None
434
+ assert data["design_state"]["material"] == "aluminum"
435
+ ```
436
+
437
+ - [ ] **Step 2: Run tests to verify they fail**
438
+
439
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_api_routes.py::TestPlanApproveEndpoint -v`
440
+ Expected: FAIL with 404 (endpoint doesn't exist)
441
+
442
+ - [ ] **Step 3: Add endpoints to routes.py**
443
+
444
+ Add imports at top of `server/routes.py`:
445
+
446
+ ```python
447
+ from agents.design_state import DesignState, DesignPlan
448
+ ```
449
+
450
+ Add request models after `ReportRequest`:
451
+
452
+ ```python
453
+ class PlanApproveRequest(BaseModel):
454
+ plan: dict = Field(...)
455
+ design_state: dict = Field(default_factory=dict)
456
+
457
+
458
+ class PlanRejectRequest(BaseModel):
459
+ design_state: dict = Field(default_factory=dict)
460
+ ```
461
+
462
+ Add endpoints at end of file:
463
+
464
+ ```python
465
+ @router.post("/api/plan/approve")
466
+ async def plan_approve(body: PlanApproveRequest):
467
+ """Approve (possibly edited) design plan, merge into state."""
468
+ plan = DesignPlan(**body.plan)
469
+ state = DesignState(**body.design_state)
470
+ # Merge plan edits back into state
471
+ state.part_name = plan.part_name
472
+ state.description = plan.description
473
+ state.material = plan.material
474
+ state.dimensions = dict(plan.dimensions)
475
+ state.features = list(plan.features)
476
+ state.constraints = list(plan.constraints)
477
+ state.axis_recommendation = plan.axis_recommendation
478
+ state.phase = "approved"
479
+ state.plan = plan
480
+ return JSONResponse({"design_state": state.model_dump()})
481
+
482
+
483
+ @router.post("/api/plan/reject")
484
+ async def plan_reject(body: PlanRejectRequest):
485
+ """Reject plan, reset to exploring."""
486
+ state = DesignState(**body.design_state)
487
+ state.phase = "exploring"
488
+ state.plan = None
489
+ return JSONResponse({"design_state": state.model_dump()})
490
+ ```
491
+
492
+ - [ ] **Step 4: Run tests to verify they pass**
493
+
494
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_api_routes.py -v`
495
+ Expected: All pass (existing + new)
496
+
497
+ - [ ] **Step 5: Commit**
498
+
499
+ ```bash
500
+ git add server/routes.py tests/test_api_routes.py
501
+ git commit -m "feat: add /api/plan/approve and /api/plan/reject endpoints"
502
+ ```
503
+
504
+ ---
505
+
506
+ ### Task 4: Orchestrator phase branching
507
+
508
+ **Files:**
509
+ - Modify: `agents/crew_orchestrator.py:17` (add imports)
510
+ - Modify: `agents/crew_orchestrator.py:30-55` (update `_build_agent_context`)
511
+ - Modify: `agents/crew_orchestrator.py:93-105` (update `chat_turn` entry)
512
+ - Modify: `agents/crew_orchestrator.py:122-135` (update `_run_crew` top)
513
+ - Test: `tests/test_crew_orchestrator.py`
514
+
515
+ - [ ] **Step 1: Read existing crew orchestrator tests**
516
+
517
+ Read `tests/test_crew_orchestrator.py` to understand the current test patterns and mock setup before writing new tests.
518
+
519
+ - [ ] **Step 2: Write failing tests**
520
+
521
+ ```python
522
+ # tests/test_crew_orchestrator.py — add to existing file
523
+
524
+ class TestPlanningPhase:
525
+ """Tests for planning phase in CrewOrchestrator."""
526
+
527
+ def test_manual_plan_trigger(self):
528
+ """User typing a trigger keyword returns plan without running crew."""
529
+ orch = CrewOrchestrator(backend_name="mock")
530
+ state = DesignState(
531
+ part_name="bracket",
532
+ material="aluminum 6061",
533
+ dimensions={"width": 60, "height": 40, "depth": 20},
534
+ axis_recommendation="3-axis",
535
+ )
536
+ result = orch.chat_turn(
537
+ message="show plan",
538
+ history=[],
539
+ design_state=state.model_dump(),
540
+ )
541
+ assert result["design_state"]["phase"] == "planning"
542
+ assert result["design_state"]["plan"] is not None
543
+ assert result["design_state"]["plan"]["material"] == "aluminum 6061"
544
+
545
+ def test_auto_plan_trigger_on_threshold(self):
546
+ """Score crossing threshold auto-triggers planning phase."""
547
+ orch = CrewOrchestrator(backend_name="mock")
548
+ # State that's above default threshold of 8:
549
+ # material(3) + 3 dims(3) + axis(2) = 8
550
+ state = DesignState(
551
+ material="aluminum 6061",
552
+ dimensions={"width": 60, "height": 40, "depth": 20},
553
+ axis_recommendation="3-axis",
554
+ )
555
+ result = orch.chat_turn(
556
+ message="looks good",
557
+ history=[],
558
+ design_state=state.model_dump(),
559
+ )
560
+ # After extract_decisions + score check, should transition to planning
561
+ ds = result["design_state"]
562
+ # State already has enough score, so after this turn it should trigger
563
+ assert ds["phase"] in ("planning", "exploring")
564
+ # If mock fallback runs, it might not trigger — check score directly
565
+ from agents.design_state import compute_score
566
+ assert compute_score(DesignState(**ds)) >= 8.0 or ds["phase"] == "planning"
567
+
568
+ def test_approved_phase_not_reset_by_normal_message(self):
569
+ """When phase is approved, orchestrator keeps it approved."""
570
+ orch = CrewOrchestrator(backend_name="mock")
571
+ from agents.design_state import DesignPlan
572
+ plan = DesignPlan(
573
+ part_name="bracket", description="test", material="aluminum",
574
+ dimensions={"width": 60}, features=[], constraints=[],
575
+ axis_recommendation="3-axis", machining_notes=[],
576
+ confidence_score=9.0,
577
+ )
578
+ state = DesignState(
579
+ phase="approved",
580
+ plan=plan,
581
+ material="aluminum",
582
+ dimensions={"width": 60},
583
+ )
584
+ result = orch.chat_turn(
585
+ message="Generate the approved design",
586
+ history=[],
587
+ design_state=state.model_dump(),
588
+ )
589
+ # Should stay in approved or transition based on CAD result
590
+ assert "responses" in result
591
+ ```
592
+
593
+ - [ ] **Step 3: Run tests to verify they fail**
594
+
595
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_crew_orchestrator.py::TestPlanningPhase -v`
596
+ Expected: FAIL (planning logic not implemented yet)
597
+
598
+ - [ ] **Step 4: Add planning imports to crew_orchestrator.py**
599
+
600
+ Add to imports at top:
601
+
602
+ ```python
603
+ from agents.design_state import DesignState, DesignPlan, extract_decisions, compute_score
604
+ ```
605
+
606
+ Remove the existing import:
607
+ ```python
608
+ from agents.design_state import DesignState, extract_decisions
609
+ ```
610
+
611
+ - [ ] **Step 5: Add trigger keyword detection function**
612
+
613
+ Add after `_build_agent_context` function:
614
+
615
+ ```python
616
+ def _is_plan_trigger(message: str) -> bool:
617
+ """Check if user message is requesting a plan review."""
618
+ lower = message.lower().strip()
619
+ for keyword in settings.planning.trigger_keywords:
620
+ if keyword in lower:
621
+ return True
622
+ return False
623
+ ```
624
+
625
+ - [ ] **Step 6: Update _build_agent_context for approved phase**
626
+
627
+ Modify `_build_agent_context` to accept an optional `DesignPlan` and render it differently when approved:
628
+
629
+ ```python
630
+ def _build_agent_context(
631
+ message: str,
632
+ history: list[dict],
633
+ design_state: DesignState,
634
+ max_history: int = 20,
635
+ approved_plan: DesignPlan | None = None,
636
+ ) -> str:
637
+ """Build a shared context string that each CrewAI agent receives."""
638
+ parts = []
639
+
640
+ if approved_plan:
641
+ parts.append(approved_plan.render_approved())
642
+ else:
643
+ spec = design_state.render()
644
+ if spec:
645
+ parts.append(f"## Current Design Spec\n{spec}")
646
+
647
+ recent = history[-max_history:] if len(history) > max_history else history
648
+ if recent:
649
+ lines = []
650
+ for msg in recent:
651
+ if msg.get("role") == "user":
652
+ lines.append(f"USER: {msg.get('content', '')}")
653
+ else:
654
+ aid = msg.get("agent_id", "unknown")
655
+ name = AGENTS.get(aid, AGENTS["design"]).name
656
+ lines.append(f"{name.upper()}: {msg.get('content', '')}")
657
+ parts.append("## Recent conversation\n" + "\n".join(lines))
658
+
659
+ parts.append(f"## User's latest message\n{message}")
660
+ return "\n\n".join(parts)
661
+ ```
662
+
663
+ - [ ] **Step 7: Add phase branching to chat_turn/_run_crew**
664
+
665
+ At the start of `_run_crew`, before the existing agent selection logic, add phase handling:
666
+
667
+ ```python
668
+ state = DesignState(**(design_state_dict or {}))
669
+
670
+ # Phase: manual plan trigger
671
+ if state.phase == "exploring" and _is_plan_trigger(message):
672
+ score = compute_score(state)
673
+ plan = DesignPlan.from_state(state, confidence_score=score)
674
+ state.phase = "planning"
675
+ state.plan = plan
676
+ return {
677
+ "responses": [],
678
+ "preview": None,
679
+ "design_state": state.model_dump(),
680
+ }
681
+
682
+ # Phase: if somehow in planning and user sends a message, reset to exploring
683
+ if state.phase == "planning":
684
+ state.phase = "exploring"
685
+ state.plan = None
686
+
687
+ # Phase: approved — override routing to approved_agents
688
+ if state.phase == "approved" and state.plan:
689
+ active_ids = list(settings.planning.approved_agents)
690
+ approved_plan = state.plan
691
+ else:
692
+ approved_plan = None
693
+ ```
694
+
695
+ Then pass `approved_plan` to `_build_agent_context`:
696
+
697
+ ```python
698
+ context = _build_agent_context(message, history, state, max_history, approved_plan=approved_plan)
699
+ ```
700
+
701
+ For the approved phase, skip the normal routing (the `active_ids` were already set above). Wrap the existing routing block with a condition:
702
+
703
+ ```python
704
+ if not (state.phase == "approved" and state.plan):
705
+ # Select which agents should respond
706
+ if mentions:
707
+ active_ids = list(mentions)
708
+ else:
709
+ active_ids = _router.route(message)
710
+
711
+ # Check CAD trigger
712
+ if "cad" not in active_ids and _router.has_cad_trigger(message):
713
+ active_ids.append("cad")
714
+ ```
715
+
716
+ After the crew result processing, before `return`, add auto-trigger for exploring phase:
717
+
718
+ ```python
719
+ # Auto-trigger plan if score crosses threshold
720
+ if updated_state.phase == "exploring":
721
+ score = compute_score(updated_state)
722
+ if score >= settings.planning.threshold:
723
+ plan = DesignPlan.from_state(updated_state, confidence_score=score)
724
+ updated_state.phase = "planning"
725
+ updated_state.plan = plan
726
+
727
+ # If approved and CAD said NOT READY, reset
728
+ if state.phase == "approved":
729
+ for r in responses:
730
+ if r.get("agent_id") == "cad" and r.get("message", "").upper().startswith("NOT READY:"):
731
+ updated_state.phase = "exploring"
732
+ updated_state.plan = None
733
+ break
734
+ ```
735
+
736
+ - [ ] **Step 8: Run tests to verify they pass**
737
+
738
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_crew_orchestrator.py -v`
739
+ Expected: All pass
740
+
741
+ - [ ] **Step 9: Run full test suite**
742
+
743
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
744
+ Expected: All pass
745
+
746
+ - [ ] **Step 10: Commit**
747
+
748
+ ```bash
749
+ git add agents/crew_orchestrator.py tests/test_crew_orchestrator.py
750
+ git commit -m "feat: add planning phase branching to CrewOrchestrator"
751
+ ```
752
+
753
+ ---
754
+
755
+ ### Task 5: 3MF export and endpoint
756
+
757
+ **Files:**
758
+ - Modify: `core/executor.py:183-190` (add `export_3mf`, update `export_all`)
759
+ - Modify: `server/web.py:205-206` (add 3MF endpoint after gcode endpoint)
760
+ - Test: `tests/test_executor.py`
761
+
762
+ - [ ] **Step 1: Write failing test**
763
+
764
+ ```python
765
+ # tests/test_executor.py — add to existing file
766
+
767
+ class TestExport3MF:
768
+ def test_export_3mf(self, tmp_path):
769
+ import cadquery as cq
770
+ from core.executor import export_3mf
771
+ shape = cq.Workplane("XY").box(10, 10, 10)
772
+ path = tmp_path / "test.3mf"
773
+ result = export_3mf(shape, path)
774
+ assert result.exists()
775
+ assert result.suffix == ".3mf"
776
+ assert result.stat().st_size > 0
777
+
778
+ def test_export_all_includes_3mf(self, tmp_path):
779
+ import cadquery as cq
780
+ from core.executor import export_all
781
+ shape = cq.Workplane("XY").box(10, 10, 10)
782
+ base = tmp_path / "part"
783
+ files = export_all(shape, base)
784
+ assert "3mf" in files
785
+ assert files["3mf"].exists()
786
+ assert files["step"].exists()
787
+ assert files["stl"].exists()
788
+ ```
789
+
790
+ - [ ] **Step 2: Run test to verify it fails**
791
+
792
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_executor.py::TestExport3MF -v`
793
+ Expected: FAIL with `ImportError: cannot import name 'export_3mf'`
794
+
795
+ - [ ] **Step 3: Add export_3mf and update export_all**
796
+
797
+ In `core/executor.py`, add after `export_stl` function:
798
+
799
+ ```python
800
+ def export_3mf(result: cq.Workplane, path: str | Path) -> Path:
801
+ """Export a CadQuery workplane to 3MF format (slicer-ready)."""
802
+ path = Path(path)
803
+ cq.exporters.export(result, str(path), exportType="3MF")
804
+ return path
805
+ ```
806
+
807
+ Update `export_all` to include 3MF:
808
+
809
+ ```python
810
+ def export_all(result: cq.Workplane, base_path: str | Path) -> dict[str, Path]:
811
+ """Export to STEP, STL, and 3MF."""
812
+ base = Path(base_path)
813
+ return {
814
+ "step": export_step(result, base.with_suffix(".step")),
815
+ "stl": export_stl(result, base.with_suffix(".stl")),
816
+ "3mf": export_3mf(result, base.with_suffix(".3mf")),
817
+ }
818
+ ```
819
+
820
+ - [ ] **Step 4: Run test to verify it passes**
821
+
822
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_executor.py::TestExport3MF -v`
823
+ Expected: PASS
824
+
825
+ - [ ] **Step 5: Add 3MF serving endpoint to web.py**
826
+
827
+ In `server/web.py`, add after the `get_gcode` endpoint (line 205):
828
+
829
+ ```python
830
+ @app.get("/api/models/{name}.3mf")
831
+ async def get_3mf(name: str):
832
+ path = OUTPUT_DIR / f"{name}.3mf"
833
+ if not path.exists():
834
+ return JSONResponse({"error": f"3MF not found: {name}"}, status_code=404)
835
+ return FileResponse(path, media_type="model/3mf", filename=f"{name}.3mf")
836
+ ```
837
+
838
+ - [ ] **Step 6: Run full test suite**
839
+
840
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
841
+ Expected: All pass
842
+
843
+ - [ ] **Step 7: Commit**
844
+
845
+ ```bash
846
+ git add core/executor.py server/web.py tests/test_executor.py
847
+ git commit -m "feat: add 3MF export and /api/models/{name}.3mf endpoint"
848
+ ```
849
+
850
+ ---
851
+
852
+ ### Task 6: 3MF in orchestrator preview response
853
+
854
+ **Files:**
855
+ - Modify: `agents/crew_orchestrator.py:296-310` (add `threemf_url` to preview dict)
856
+
857
+ - [ ] **Step 1: Update preview dict in _run_crew**
858
+
859
+ In `agents/crew_orchestrator.py`, find the block where `preview` dict is created (around line 303) and add `threemf_url`:
860
+
861
+ ```python
862
+ preview = {
863
+ "success": True,
864
+ "part_name": part_name,
865
+ "stl_url": f"/api/models/{part_name}.stl",
866
+ "step_url": f"/api/models/{part_name}.step",
867
+ "threemf_url": f"/api/models/{part_name}.3mf",
868
+ "execution": {"success": True},
869
+ "validation": validation.model_dump(),
870
+ }
871
+ ```
872
+
873
+ - [ ] **Step 2: Run full test suite**
874
+
875
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
876
+ Expected: All pass
877
+
878
+ - [ ] **Step 3: Commit**
879
+
880
+ ```bash
881
+ git add agents/crew_orchestrator.py
882
+ git commit -m "feat: include threemf_url in preview response"
883
+ ```
884
+
885
+ ---
886
+
887
+ ### Task 7: Frontend tab switcher and guided wizard
888
+
889
+ **Files:**
890
+ - Modify: `web/index.html:1256-1283` (chat panel header, add tabs)
891
+ - Modify: `web/index.html` (add wizard HTML, CSS, JS)
892
+
893
+ This is the largest frontend task. It adds the tab bar, guided wizard panel, and state sync logic.
894
+
895
+ - [ ] **Step 1: Add tab switcher CSS**
896
+
897
+ In `web/index.html`, add CSS after the existing chat panel styles (find the `#chat-panel` CSS block):
898
+
899
+ ```css
900
+ /* ---- TAB SWITCHER ---- */
901
+ .chat-tabs {
902
+ display: flex;
903
+ border-bottom: 1px solid var(--border);
904
+ background: var(--bg-panel);
905
+ flex-shrink: 0;
906
+ }
907
+ .chat-tab {
908
+ flex: 1;
909
+ padding: 8px 0;
910
+ text-align: center;
911
+ font-family: var(--font-mono);
912
+ font-size: 11px;
913
+ font-weight: 500;
914
+ color: var(--text-secondary);
915
+ background: none;
916
+ border: none;
917
+ cursor: pointer;
918
+ border-bottom: 2px solid transparent;
919
+ transition: color 0.2s, border-color 0.2s;
920
+ }
921
+ .chat-tab:hover { color: var(--text-primary); }
922
+ .chat-tab.active {
923
+ color: var(--accent);
924
+ border-bottom-color: var(--accent);
925
+ }
926
+ #guided-panel { display: none; overflow-y: auto; flex: 1; padding: 12px; }
927
+ #guided-panel.active { display: flex; flex-direction: column; gap: 12px; }
928
+ #chat-messages.hidden { display: none; }
929
+
930
+ /* ---- WIZARD STEPS ---- */
931
+ .wizard-step {
932
+ background: var(--bg-surface);
933
+ border: 1px solid var(--border);
934
+ border-radius: 8px;
935
+ padding: 12px;
936
+ }
937
+ .wizard-step.completed { border-color: var(--success); }
938
+ .wizard-step-header {
939
+ display: flex; justify-content: space-between; align-items: center;
940
+ margin-bottom: 8px;
941
+ }
942
+ .wizard-step-title {
943
+ font-family: var(--font-mono); font-size: 11px;
944
+ color: var(--text-secondary); font-weight: 600;
945
+ }
946
+ .wizard-step-check { color: var(--success); font-size: 14px; }
947
+ .wizard-chips {
948
+ display: flex; flex-wrap: wrap; gap: 6px;
949
+ }
950
+ .wizard-chip {
951
+ padding: 5px 12px; border-radius: 14px;
952
+ font-family: var(--font-mono); font-size: 11px;
953
+ background: var(--bg-input); border: 1px solid var(--border);
954
+ color: var(--text-primary); cursor: pointer;
955
+ transition: border-color 0.15s, background 0.15s;
956
+ }
957
+ .wizard-chip:hover { border-color: var(--accent); }
958
+ .wizard-chip.selected {
959
+ border-color: var(--accent); background: var(--accent-glow);
960
+ color: var(--accent);
961
+ }
962
+ .wizard-input {
963
+ width: 100%; padding: 6px 10px; margin-top: 6px;
964
+ background: var(--bg-input); border: 1px solid var(--border);
965
+ border-radius: 6px; color: var(--text-primary);
966
+ font-family: var(--font-mono); font-size: 12px;
967
+ }
968
+ .wizard-input:focus { outline: none; border-color: var(--accent); }
969
+ .wizard-dim-row {
970
+ display: flex; gap: 8px; align-items: center; margin-top: 6px;
971
+ }
972
+ .wizard-dim-label {
973
+ font-family: var(--font-mono); font-size: 11px;
974
+ color: var(--text-secondary); min-width: 50px;
975
+ }
976
+ .wizard-dim-input {
977
+ width: 80px; padding: 4px 8px;
978
+ background: var(--bg-input); border: 1px solid var(--border);
979
+ border-radius: 4px; color: var(--text-primary);
980
+ font-family: var(--font-mono); font-size: 12px;
981
+ }
982
+ .wizard-dim-unit {
983
+ font-family: var(--font-mono); font-size: 10px;
984
+ color: var(--text-muted);
985
+ }
986
+ .wizard-review-field {
987
+ display: flex; justify-content: space-between; align-items: center;
988
+ padding: 4px 0; border-bottom: 1px solid var(--border);
989
+ }
990
+ .wizard-review-label {
991
+ font-family: var(--font-mono); font-size: 10px;
992
+ color: var(--text-secondary);
993
+ }
994
+ .wizard-review-value {
995
+ font-family: var(--font-mono); font-size: 11px;
996
+ color: var(--text-primary);
997
+ }
998
+ .wizard-btn-row { display: flex; gap: 8px; margin-top: 10px; }
999
+ .wizard-btn {
1000
+ flex: 1; padding: 8px; border-radius: 6px;
1001
+ font-family: var(--font-mono); font-size: 11px;
1002
+ font-weight: 600; cursor: pointer; border: 1px solid var(--border);
1003
+ transition: background 0.15s;
1004
+ }
1005
+ .wizard-btn-primary {
1006
+ background: var(--accent); color: var(--bg-void); border-color: var(--accent);
1007
+ }
1008
+ .wizard-btn-secondary {
1009
+ background: var(--bg-surface); color: var(--text-secondary);
1010
+ }
1011
+ ```
1012
+
1013
+ - [ ] **Step 2: Add tab bar HTML**
1014
+
1015
+ Replace the `chat-header` div (lines 1260-1271) with:
1016
+
1017
+ ```html
1018
+ <div class="chat-tabs">
1019
+ <button class="chat-tab active" id="tab-chat" onclick="switchTab('chat')">Chat</button>
1020
+ <button class="chat-tab" id="tab-guided" onclick="switchTab('guided')">Guided</button>
1021
+ </div>
1022
+
1023
+ <div class="chat-header">
1024
+ <div class="chat-header-left">
1025
+ <span class="chat-header-title" data-i18n="designChat">Design Chat</span>
1026
+ <button onclick="newDesign()" title="New Design" style="background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);padding:2px 8px;font-size:10px;cursor:pointer;margin-left:8px;" data-i18n="newBtn">NEW</button>
1027
+ <div class="agent-dots">
1028
+ <div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
1029
+ <div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
1030
+ <div class="agent-dot" style="background: var(--agent-cnc);" title="CNC Agent"></div>
1031
+ <div class="agent-dot" style="background: var(--agent-cad);" title="CAD Coder Agent"></div>
1032
+ </div>
1033
+ </div>
1034
+ </div>
1035
+ ```
1036
+
1037
+ - [ ] **Step 3: Add guided panel HTML after chat-messages div**
1038
+
1039
+ After the closing `</div>` of `#chat-messages` (line 1283), add:
1040
+
1041
+ ```html
1042
+ <div id="guided-panel">
1043
+ <!-- Step 1: Part Type -->
1044
+ <div class="wizard-step" id="wiz-step-1">
1045
+ <div class="wizard-step-header">
1046
+ <span class="wizard-step-title">1. PART TYPE</span>
1047
+ <span class="wizard-step-check" id="wiz-check-1"></span>
1048
+ </div>
1049
+ <div class="wizard-chips">
1050
+ <button class="wizard-chip" onclick="wizSetPart('bracket','Mounting bracket')">Bracket</button>
1051
+ <button class="wizard-chip" onclick="wizSetPart('enclosure','Enclosure housing')">Enclosure</button>
1052
+ <button class="wizard-chip" onclick="wizSetPart('plate','Flat plate')">Plate</button>
1053
+ <button class="wizard-chip" onclick="wizSetPart('shaft','Cylindrical shaft')">Shaft</button>
1054
+ <button class="wizard-chip" onclick="wizSetPart('gear','Spur gear')">Gear</button>
1055
+ <button class="wizard-chip" onclick="wizSetPart('flange','Pipe flange')">Flange</button>
1056
+ </div>
1057
+ <input class="wizard-input" placeholder="Or type custom part name..." onchange="wizSetPart(this.value, this.value)">
1058
+ </div>
1059
+
1060
+ <!-- Step 2: Material -->
1061
+ <div class="wizard-step" id="wiz-step-2">
1062
+ <div class="wizard-step-header">
1063
+ <span class="wizard-step-title">2. MATERIAL</span>
1064
+ <span class="wizard-step-check" id="wiz-check-2"></span>
1065
+ </div>
1066
+ <div class="wizard-chips">
1067
+ <button class="wizard-chip" onclick="wizSetMaterial('aluminum 6061')">Aluminum 6061</button>
1068
+ <button class="wizard-chip" onclick="wizSetMaterial('aluminum 7075')">Aluminum 7075</button>
1069
+ <button class="wizard-chip" onclick="wizSetMaterial('stainless steel 304')">Steel 304</button>
1070
+ <button class="wizard-chip" onclick="wizSetMaterial('stainless steel 316')">Steel 316</button>
1071
+ <button class="wizard-chip" onclick="wizSetMaterial('brass')">Brass</button>
1072
+ <button class="wizard-chip" onclick="wizSetMaterial('titanium')">Titanium</button>
1073
+ <button class="wizard-chip" onclick="wizSetMaterial('nylon')">Nylon</button>
1074
+ <button class="wizard-chip" onclick="wizSetMaterial('delrin')">Delrin</button>
1075
+ </div>
1076
+ <input class="wizard-input" placeholder="Or type custom material..." onchange="wizSetMaterial(this.value)">
1077
+ </div>
1078
+
1079
+ <!-- Step 3: Dimensions -->
1080
+ <div class="wizard-step" id="wiz-step-3">
1081
+ <div class="wizard-step-header">
1082
+ <span class="wizard-step-title">3. DIMENSIONS (mm)</span>
1083
+ <span class="wizard-step-check" id="wiz-check-3"></span>
1084
+ </div>
1085
+ <div class="wizard-dim-row">
1086
+ <span class="wizard-dim-label">Width</span>
1087
+ <input class="wizard-dim-input" id="wiz-dim-width" type="number" onchange="wizSetDim('width', this.value)">
1088
+ <span class="wizard-dim-unit">mm</span>
1089
+ </div>
1090
+ <div class="wizard-dim-row">
1091
+ <span class="wizard-dim-label">Height</span>
1092
+ <input class="wizard-dim-input" id="wiz-dim-height" type="number" onchange="wizSetDim('height', this.value)">
1093
+ <span class="wizard-dim-unit">mm</span>
1094
+ </div>
1095
+ <div class="wizard-dim-row">
1096
+ <span class="wizard-dim-label">Depth</span>
1097
+ <input class="wizard-dim-input" id="wiz-dim-depth" type="number" onchange="wizSetDim('depth', this.value)">
1098
+ <span class="wizard-dim-unit">mm</span>
1099
+ </div>
1100
+ </div>
1101
+
1102
+ <!-- Step 4: Features -->
1103
+ <div class="wizard-step" id="wiz-step-4">
1104
+ <div class="wizard-step-header">
1105
+ <span class="wizard-step-title">4. FEATURES</span>
1106
+ <span class="wizard-step-check" id="wiz-check-4"></span>
1107
+ </div>
1108
+ <div class="wizard-chips">
1109
+ <button class="wizard-chip" id="wiz-feat-holes" onclick="wizToggleFeature(this, 'holes')">Mounting Holes</button>
1110
+ <button class="wizard-chip" id="wiz-feat-fillets" onclick="wizToggleFeature(this, 'fillets')">Fillets</button>
1111
+ <button class="wizard-chip" id="wiz-feat-chamfers" onclick="wizToggleFeature(this, 'chamfers')">Chamfers</button>
1112
+ <button class="wizard-chip" id="wiz-feat-pockets" onclick="wizToggleFeature(this, 'pockets')">Pockets</button>
1113
+ <button class="wizard-chip" id="wiz-feat-slots" onclick="wizToggleFeature(this, 'slots')">Slots</button>
1114
+ </div>
1115
+ <div id="wiz-holes-config" style="display:none;margin-top:8px;">
1116
+ <div class="wizard-dim-row">
1117
+ <span class="wizard-dim-label">Count</span>
1118
+ <input class="wizard-dim-input" id="wiz-hole-count" type="number" value="4" min="1" onchange="wizUpdateHoles()">
1119
+ </div>
1120
+ <div class="wizard-chips" style="margin-top:6px;">
1121
+ <button class="wizard-chip" id="wiz-hole-m3" onclick="wizSetHoleSize('M3')">M3</button>
1122
+ <button class="wizard-chip" id="wiz-hole-m4" onclick="wizSetHoleSize('M4')">M4</button>
1123
+ <button class="wizard-chip selected" id="wiz-hole-m6" onclick="wizSetHoleSize('M6')">M6</button>
1124
+ <button class="wizard-chip" id="wiz-hole-m8" onclick="wizSetHoleSize('M8')">M8</button>
1125
+ </div>
1126
+ </div>
1127
+ <input class="wizard-input" placeholder="Or type custom feature..." onkeydown="if(event.key==='Enter'){wizAddCustomFeature(this.value);this.value='';}">
1128
+ </div>
1129
+
1130
+ <!-- Step 5: Constraints -->
1131
+ <div class="wizard-step" id="wiz-step-5">
1132
+ <div class="wizard-step-header">
1133
+ <span class="wizard-step-title">5. CONSTRAINTS</span>
1134
+ <span class="wizard-step-check" id="wiz-check-5"></span>
1135
+ </div>
1136
+ <div class="wizard-dim-row">
1137
+ <span class="wizard-dim-label">Min wall</span>
1138
+ <input class="wizard-dim-input" id="wiz-min-wall" type="number" value="3" step="0.5" onchange="wizUpdateConstraints()">
1139
+ <span class="wizard-dim-unit">mm</span>
1140
+ </div>
1141
+ <div class="wizard-dim-row">
1142
+ <span class="wizard-dim-label">Max size</span>
1143
+ <input class="wizard-dim-input" id="wiz-max-size" type="number" value="500" onchange="wizUpdateConstraints()">
1144
+ <span class="wizard-dim-unit">mm</span>
1145
+ </div>
1146
+ </div>
1147
+
1148
+ <!-- Step 6: Machining -->
1149
+ <div class="wizard-step" id="wiz-step-6">
1150
+ <div class="wizard-step-header">
1151
+ <span class="wizard-step-title">6. MACHINING</span>
1152
+ <span class="wizard-step-check" id="wiz-check-6"></span>
1153
+ </div>
1154
+ <div class="wizard-chips">
1155
+ <button class="wizard-chip" onclick="wizSetAxis('3-axis', this)">3-axis</button>
1156
+ <button class="wizard-chip" onclick="wizSetAxis('3+2-axis', this)">3+2-axis</button>
1157
+ <button class="wizard-chip" onclick="wizSetAxis('5-axis', this)">5-axis</button>
1158
+ <button class="wizard-chip" onclick="wizSetAxis('', this)">Auto</button>
1159
+ </div>
1160
+ </div>
1161
+
1162
+ <!-- Step 7: Review -->
1163
+ <div class="wizard-step" id="wiz-step-7">
1164
+ <div class="wizard-step-header">
1165
+ <span class="wizard-step-title">7. REVIEW</span>
1166
+ <span class="wizard-step-check" id="wiz-check-7"></span>
1167
+ </div>
1168
+ <div id="wiz-review-content"></div>
1169
+ <div id="wiz-score" style="font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);margin-top:8px;"></div>
1170
+ <div class="wizard-btn-row">
1171
+ <button class="wizard-btn wizard-btn-primary" onclick="wizApprove()">Approve &amp; Generate</button>
1172
+ <button class="wizard-btn wizard-btn-secondary" onclick="switchTab('chat')">Back to Chat</button>
1173
+ </div>
1174
+ </div>
1175
+ </div>
1176
+ ```
1177
+
1178
+ - [ ] **Step 4: Add wizard JavaScript**
1179
+
1180
+ Add to the `<script>` section, after the existing state variables:
1181
+
1182
+ ```javascript
1183
+ // ── WIZARD STATE ──────────────────────────────────────
1184
+ let activeTab = 'chat';
1185
+ let wizHoleSize = 'M6';
1186
+ let wizFeatures = new Set();
1187
+
1188
+ function switchTab(tab) {
1189
+ activeTab = tab;
1190
+ document.getElementById('tab-chat').classList.toggle('active', tab === 'chat');
1191
+ document.getElementById('tab-guided').classList.toggle('active', tab === 'guided');
1192
+ document.getElementById('chat-messages').classList.toggle('hidden', tab === 'guided');
1193
+ document.querySelector('.chat-header').style.display = tab === 'chat' ? 'flex' : 'none';
1194
+ document.getElementById('guided-panel').classList.toggle('active', tab === 'guided');
1195
+ if (tab === 'guided') syncWizardFromState();
1196
+ }
1197
+
1198
+ function wizSetPart(name, desc) {
1199
+ designState.part_name = name;
1200
+ designState.description = desc;
1201
+ wizMarkStep(1); saveState();
1202
+ document.querySelectorAll('#wiz-step-1 .wizard-chip').forEach(c => c.classList.remove('selected'));
1203
+ event.target.classList.add('selected');
1204
+ }
1205
+
1206
+ function wizSetMaterial(mat) {
1207
+ designState.material = mat;
1208
+ wizMarkStep(2); saveState();
1209
+ document.querySelectorAll('#wiz-step-2 .wizard-chip').forEach(c => c.classList.remove('selected'));
1210
+ if (event && event.target) event.target.classList.add('selected');
1211
+ }
1212
+
1213
+ function wizSetDim(name, val) {
1214
+ if (!designState.dimensions) designState.dimensions = {};
1215
+ const v = parseFloat(val);
1216
+ if (v > 0) designState.dimensions[name] = v;
1217
+ else delete designState.dimensions[name];
1218
+ wizMarkStep(3); saveState();
1219
+ }
1220
+
1221
+ function wizToggleFeature(el, feat) {
1222
+ if (wizFeatures.has(feat)) {
1223
+ wizFeatures.delete(feat);
1224
+ el.classList.remove('selected');
1225
+ } else {
1226
+ wizFeatures.add(feat);
1227
+ el.classList.add('selected');
1228
+ }
1229
+ if (feat === 'holes') {
1230
+ document.getElementById('wiz-holes-config').style.display = wizFeatures.has('holes') ? 'block' : 'none';
1231
+ }
1232
+ wizRebuildFeatures();
1233
+ wizMarkStep(4); saveState();
1234
+ }
1235
+
1236
+ function wizSetHoleSize(size) {
1237
+ wizHoleSize = size;
1238
+ document.querySelectorAll('#wiz-holes-config .wizard-chip').forEach(c => c.classList.remove('selected'));
1239
+ document.getElementById('wiz-hole-' + size.toLowerCase()).classList.add('selected');
1240
+ wizRebuildFeatures();
1241
+ saveState();
1242
+ }
1243
+
1244
+ function wizUpdateHoles() { wizRebuildFeatures(); saveState(); }
1245
+
1246
+ function wizRebuildFeatures() {
1247
+ if (!designState.features) designState.features = [];
1248
+ // Remove auto-generated features, keep custom ones
1249
+ designState.features = designState.features.filter(f => f.startsWith('custom:'));
1250
+ if (wizFeatures.has('holes')) {
1251
+ const count = document.getElementById('wiz-hole-count')?.value || 4;
1252
+ designState.features.push(count + 'x ' + wizHoleSize + ' holes');
1253
+ }
1254
+ if (wizFeatures.has('fillets')) designState.features.push('fillets');
1255
+ if (wizFeatures.has('chamfers')) designState.features.push('chamfers');
1256
+ if (wizFeatures.has('pockets')) designState.features.push('pockets');
1257
+ if (wizFeatures.has('slots')) designState.features.push('slots');
1258
+ // Re-add custom features without prefix
1259
+ designState.features = designState.features.map(f => f.replace('custom:', ''));
1260
+ }
1261
+
1262
+ function wizAddCustomFeature(val) {
1263
+ if (!val.trim()) return;
1264
+ if (!designState.features) designState.features = [];
1265
+ designState.features.push(val.trim());
1266
+ wizMarkStep(4); saveState();
1267
+ }
1268
+
1269
+ function wizUpdateConstraints() {
1270
+ designState.constraints = [];
1271
+ const wall = document.getElementById('wiz-min-wall')?.value;
1272
+ const size = document.getElementById('wiz-max-size')?.value;
1273
+ if (wall) designState.constraints.push('min wall ' + wall + 'mm');
1274
+ if (size && parseFloat(size) < 500) designState.constraints.push('max size ' + size + 'mm');
1275
+ wizMarkStep(5); saveState();
1276
+ }
1277
+
1278
+ function wizSetAxis(axis, el) {
1279
+ designState.axis_recommendation = axis;
1280
+ document.querySelectorAll('#wiz-step-6 .wizard-chip').forEach(c => c.classList.remove('selected'));
1281
+ if (el) el.classList.add('selected');
1282
+ wizMarkStep(6); saveState();
1283
+ }
1284
+
1285
+ function wizMarkStep(n) {
1286
+ const check = document.getElementById('wiz-check-' + n);
1287
+ const step = document.getElementById('wiz-step-' + n);
1288
+ if (check) check.textContent = '\u2713';
1289
+ if (step) step.classList.add('completed');
1290
+ if (n <= 6) wizUpdateReview();
1291
+ }
1292
+
1293
+ function wizUpdateReview() {
1294
+ const el = document.getElementById('wiz-review-content');
1295
+ if (!el) return;
1296
+ let html = '';
1297
+ const fields = [
1298
+ ['Part', designState.part_name || ''],
1299
+ ['Material', designState.material || ''],
1300
+ ['Dimensions', Object.entries(designState.dimensions || {}).map(([k,v]) => k + '=' + v + 'mm').join(', ') || ''],
1301
+ ['Features', (designState.features || []).join(', ') || ''],
1302
+ ['Constraints', (designState.constraints || []).join(', ') || ''],
1303
+ ['Machining', designState.axis_recommendation || 'Auto'],
1304
+ ];
1305
+ for (const [label, value] of fields) {
1306
+ html += '<div class="wizard-review-field"><span class="wizard-review-label">' + label + '</span><span class="wizard-review-value">' + (value || '\u2014') + '</span></div>';
1307
+ }
1308
+ el.innerHTML = html;
1309
+
1310
+ // Show score
1311
+ const score = wizComputeScore();
1312
+ const scoreEl = document.getElementById('wiz-score');
1313
+ if (scoreEl) scoreEl.textContent = 'Score: ' + score.toFixed(0) + '/8 ' + (score >= 8 ? '\u2713 Ready' : '\u2717 Need more info');
1314
+ }
1315
+
1316
+ function wizComputeScore() {
1317
+ let s = 0;
1318
+ if (designState.material) s += 3;
1319
+ if (designState.part_name) s += 1;
1320
+ if (designState.description) s += 1;
1321
+ if (designState.axis_recommendation) s += 2;
1322
+ s += Math.min(Object.keys(designState.dimensions || {}).length, 4);
1323
+ s += Math.min((designState.features || []).length, 4);
1324
+ s += Math.min((designState.constraints || []).length, 2);
1325
+ return s;
1326
+ }
1327
+
1328
+ async function wizApprove() {
1329
+ const plan = {
1330
+ part_name: designState.part_name || '',
1331
+ description: designState.description || '',
1332
+ material: designState.material || '',
1333
+ dimensions: designState.dimensions || {},
1334
+ features: designState.features || [],
1335
+ constraints: designState.constraints || [],
1336
+ axis_recommendation: designState.axis_recommendation || '',
1337
+ machining_notes: [],
1338
+ confidence_score: wizComputeScore(),
1339
+ };
1340
+ try {
1341
+ const resp = await fetch('/api/plan/approve', {
1342
+ method: 'POST',
1343
+ headers: { 'Content-Type': 'application/json' },
1344
+ body: JSON.stringify({ plan: plan, design_state: designState }),
1345
+ });
1346
+ const data = await resp.json();
1347
+ designState = data.design_state;
1348
+ saveState();
1349
+ switchTab('chat');
1350
+ await sendMessage('Generate the approved design');
1351
+ } catch (err) {
1352
+ console.error('Plan approve failed:', err);
1353
+ }
1354
+ }
1355
+
1356
+ function syncWizardFromState() {
1357
+ // Sync dimension inputs
1358
+ const dims = designState.dimensions || {};
1359
+ for (const key of ['width', 'height', 'depth']) {
1360
+ const el = document.getElementById('wiz-dim-' + key);
1361
+ if (el && dims[key]) el.value = dims[key];
1362
+ }
1363
+ // Sync step checks
1364
+ if (designState.part_name) wizMarkStep(1);
1365
+ if (designState.material) wizMarkStep(2);
1366
+ if (Object.keys(dims).length > 0) wizMarkStep(3);
1367
+ if ((designState.features || []).length > 0) wizMarkStep(4);
1368
+ if ((designState.constraints || []).length > 0) wizMarkStep(5);
1369
+ if (designState.axis_recommendation) wizMarkStep(6);
1370
+ wizUpdateReview();
1371
+ }
1372
+ ```
1373
+
1374
+ - [ ] **Step 5: Test manually**
1375
+
1376
+ Run: `cd /home/daniel/NeuralCAD && python -m server.web --port 5000`
1377
+ Open browser at `http://localhost:5000`. Verify:
1378
+ - Tab switcher shows "Chat" and "Guided" tabs
1379
+ - Clicking "Guided" shows 7 wizard steps
1380
+ - Selecting chips updates designState
1381
+ - Dimension inputs work
1382
+ - Review step shows accumulated values and score
1383
+ - "Back to Chat" switches back
1384
+ - State syncs between tabs
1385
+
1386
+ - [ ] **Step 6: Commit**
1387
+
1388
+ ```bash
1389
+ git add web/index.html
1390
+ git commit -m "feat: add Chat/Guided tab switcher with 7-step wizard"
1391
+ ```
1392
+
1393
+ ---
1394
+
1395
+ ### Task 8: Plan card in chat mode
1396
+
1397
+ **Files:**
1398
+ - Modify: `web/index.html` (add plan card rendering in `sendMessage`)
1399
+
1400
+ - [ ] **Step 1: Add plan card CSS**
1401
+
1402
+ Add to the CSS section:
1403
+
1404
+ ```css
1405
+ /* ---- PLAN CARD ---- */
1406
+ .plan-card {
1407
+ background: var(--bg-surface);
1408
+ border: 1px solid var(--accent);
1409
+ border-radius: 8px;
1410
+ padding: 14px;
1411
+ margin: 8px 0;
1412
+ }
1413
+ .plan-card-title {
1414
+ font-family: var(--font-mono);
1415
+ font-size: 11px;
1416
+ font-weight: 700;
1417
+ color: var(--accent);
1418
+ margin-bottom: 10px;
1419
+ }
1420
+ .plan-card .wizard-review-field { padding: 3px 0; }
1421
+ .plan-card-score {
1422
+ font-family: var(--font-mono); font-size: 11px;
1423
+ color: var(--text-secondary); margin-top: 8px;
1424
+ }
1425
+ .plan-card-actions { display: flex; gap: 8px; margin-top: 10px; }
1426
+ .plan-card-btn {
1427
+ flex: 1; padding: 7px; border-radius: 5px;
1428
+ font-family: var(--font-mono); font-size: 11px;
1429
+ font-weight: 600; cursor: pointer; border: 1px solid var(--border);
1430
+ }
1431
+ .plan-card-approve {
1432
+ background: var(--success); color: var(--bg-void); border-color: var(--success);
1433
+ }
1434
+ .plan-card-reject {
1435
+ background: var(--bg-surface); color: var(--text-secondary);
1436
+ }
1437
+ ```
1438
+
1439
+ - [ ] **Step 2: Add plan card rendering function**
1440
+
1441
+ Add to the JavaScript section:
1442
+
1443
+ ```javascript
1444
+ function renderPlanCard(plan) {
1445
+ const fields = [
1446
+ ['Part', plan.part_name],
1447
+ ['Material', plan.material],
1448
+ ['Dimensions', Object.entries(plan.dimensions || {}).map(([k,v]) => k + '=' + v + 'mm').join(', ')],
1449
+ ['Features', (plan.features || []).join(', ')],
1450
+ ['Constraints', (plan.constraints || []).join(', ')],
1451
+ ['Axis', plan.axis_recommendation || 'Auto'],
1452
+ ];
1453
+ let html = '<div class="plan-card" id="active-plan-card">';
1454
+ html += '<div class="plan-card-title">\u25c6 PLAN READY FOR REVIEW</div>';
1455
+ for (const [label, value] of fields) {
1456
+ html += '<div class="wizard-review-field"><span class="wizard-review-label">' + label + '</span><span class="wizard-review-value">' + (value || '\u2014') + '</span></div>';
1457
+ }
1458
+ if (plan.machining_notes && plan.machining_notes.length) {
1459
+ html += '<div class="wizard-review-field"><span class="wizard-review-label">Notes</span><span class="wizard-review-value">' + plan.machining_notes.join('; ') + '</span></div>';
1460
+ }
1461
+ html += '<div class="plan-card-score">Score: ' + (plan.confidence_score || 0).toFixed(0) + '/8</div>';
1462
+ html += '<div class="plan-card-actions">';
1463
+ html += '<button class="plan-card-btn plan-card-approve" onclick="approvePlanCard()">Approve</button>';
1464
+ html += '<button class="plan-card-btn plan-card-reject" onclick="rejectPlanCard()">Reject</button>';
1465
+ html += '</div></div>';
1466
+ return html;
1467
+ }
1468
+
1469
+ async function approvePlanCard() {
1470
+ const plan = designState.plan;
1471
+ if (!plan) return;
1472
+ try {
1473
+ const resp = await fetch('/api/plan/approve', {
1474
+ method: 'POST',
1475
+ headers: { 'Content-Type': 'application/json' },
1476
+ body: JSON.stringify({ plan: plan, design_state: designState }),
1477
+ });
1478
+ const data = await resp.json();
1479
+ designState = data.design_state;
1480
+ saveState();
1481
+ const card = document.getElementById('active-plan-card');
1482
+ if (card) card.remove();
1483
+ await sendMessage('Generate the approved design');
1484
+ } catch (err) {
1485
+ console.error('Plan approve failed:', err);
1486
+ }
1487
+ }
1488
+
1489
+ async function rejectPlanCard() {
1490
+ try {
1491
+ const resp = await fetch('/api/plan/reject', {
1492
+ method: 'POST',
1493
+ headers: { 'Content-Type': 'application/json' },
1494
+ body: JSON.stringify({ design_state: designState }),
1495
+ });
1496
+ const data = await resp.json();
1497
+ designState = data.design_state;
1498
+ saveState();
1499
+ const card = document.getElementById('active-plan-card');
1500
+ if (card) card.remove();
1501
+ } catch (err) {
1502
+ console.error('Plan reject failed:', err);
1503
+ }
1504
+ }
1505
+ ```
1506
+
1507
+ - [ ] **Step 3: Update sendMessage to render plan card**
1508
+
1509
+ In the `sendMessage` function, after `if (data.design_state) { designState = data.design_state; }`, add:
1510
+
1511
+ ```javascript
1512
+ // If phase transitioned to planning, show plan card
1513
+ if (designState.phase === 'planning' && designState.plan) {
1514
+ const old = document.getElementById('active-plan-card');
1515
+ if (old) old.remove();
1516
+ const msgs = document.getElementById('chat-messages');
1517
+ const cardDiv = document.createElement('div');
1518
+ cardDiv.innerHTML = renderPlanCard(designState.plan);
1519
+ msgs.appendChild(cardDiv.firstChild);
1520
+ msgs.scrollTop = msgs.scrollHeight;
1521
+ }
1522
+ ```
1523
+
1524
+ - [ ] **Step 4: Test manually**
1525
+
1526
+ Run the dev server and test:
1527
+ - Chat enough info to cross the scoring threshold (material + dimensions + axis)
1528
+ - Verify plan card appears inline in chat
1529
+ - Click "Approve" — should trigger generation
1530
+ - On a fresh conversation, click "Reject" — card should disappear, chat continues
1531
+
1532
+ - [ ] **Step 5: Commit**
1533
+
1534
+ ```bash
1535
+ git add web/index.html
1536
+ git commit -m "feat: add inline plan card in chat with approve/reject"
1537
+ ```
1538
+
1539
+ ---
1540
+
1541
+ ### Task 9: Download panel with 3MF
1542
+
1543
+ **Files:**
1544
+ - Modify: `web/index.html:1230-1234` (add 3MF button to download panel)
1545
+ - Modify: `web/index.html:2257-2274` (update `updateDownloads` function)
1546
+ - Modify: `web/index.html:2352-2355` (update gallery card downloads)
1547
+
1548
+ - [ ] **Step 1: Add 3MF download button**
1549
+
1550
+ In the download buttons div (line 1230-1234), add the 3MF button:
1551
+
1552
+ ```html
1553
+ <div id="download-btns">
1554
+ <a class="dl-btn" id="dl-step" download>STEP</a>
1555
+ <a class="dl-btn" id="dl-stl" download>STL</a>
1556
+ <a class="dl-btn" id="dl-3mf" download>3MF</a>
1557
+ <a id="dl-gcode" class="dl-btn" download style="display:none"><span class="dl-icon">&#8615;</span> G-CODE</a>
1558
+ <a class="dl-btn" id="dl-report" download>REPORT</a>
1559
+ </div>
1560
+ ```
1561
+
1562
+ - [ ] **Step 2: Update updateDownloads function**
1563
+
1564
+ In the `updateDownloads` function, add 3MF URL:
1565
+
1566
+ ```javascript
1567
+ function updateDownloads(partName) {
1568
+ const el = document.getElementById('download-btns');
1569
+ if (!partName) { el.classList.remove('visible'); return; }
1570
+ el.classList.add('visible');
1571
+
1572
+ document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
1573
+ document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
1574
+ document.getElementById('dl-3mf').href = '/api/models/' + partName + '.3mf';
1575
+ document.getElementById('dl-report').href = '/api/models/' + partName + '_report.json';
1576
+
1577
+ const dlGcode = document.getElementById('dl-gcode');
1578
+ if (dlGcode) {
1579
+ const gcodePath = '/api/models/' + partName + '.gcode';
1580
+ fetch(gcodePath, { method: 'HEAD' }).then(r => {
1581
+ dlGcode.style.display = r.ok ? 'inline-flex' : 'none';
1582
+ dlGcode.href = gcodePath;
1583
+ }).catch(() => { dlGcode.style.display = 'none'; });
1584
+ }
1585
+ }
1586
+ ```
1587
+
1588
+ - [ ] **Step 3: Update gallery card downloads**
1589
+
1590
+ Find the gallery card download section (around line 2352) and add 3MF:
1591
+
1592
+ ```javascript
1593
+ html += '<div class="gallery-card-downloads">';
1594
+ html += '<a class="gallery-dl" href="/api/models/' + name + '.step" download>STEP</a>';
1595
+ html += '<a class="gallery-dl" href="/api/models/' + name + '.stl" download>STL</a>';
1596
+ html += '<a class="gallery-dl" href="/api/models/' + name + '.3mf" download>3MF</a>';
1597
+ html += '<a class="gallery-dl" href="/api/models/' + name + '.gcode" download>GCODE</a>';
1598
+ ```
1599
+
1600
+ - [ ] **Step 4: Test manually**
1601
+
1602
+ Run dev server, generate a model, verify:
1603
+ - STEP, STL, 3MF buttons all visible after generation
1604
+ - G-CODE button only visible when CAM has run
1605
+ - Gallery cards show all 4 download options
1606
+ - All downloads work (correct file served)
1607
+
1608
+ - [ ] **Step 5: Commit**
1609
+
1610
+ ```bash
1611
+ git add web/index.html
1612
+ git commit -m "feat: add 3MF download button and update gallery cards"
1613
+ ```
1614
+
1615
+ ---
1616
+
1617
+ ### Task 10: i18n entries for new UI
1618
+
1619
+ **Files:**
1620
+ - Modify: `web/index.html` (add translations to `I18N` object)
1621
+
1622
+ - [ ] **Step 1: Add i18n keys**
1623
+
1624
+ Add to the `I18N.en` object:
1625
+
1626
+ ```javascript
1627
+ tabChat: 'Chat',
1628
+ tabGuided: 'Guided',
1629
+ wizPartType: '1. PART TYPE',
1630
+ wizMaterial: '2. MATERIAL',
1631
+ wizDimensions: '3. DIMENSIONS (mm)',
1632
+ wizFeatures: '4. FEATURES',
1633
+ wizConstraints: '5. CONSTRAINTS',
1634
+ wizMachining: '6. MACHINING',
1635
+ wizReview: '7. REVIEW',
1636
+ wizApprove: 'Approve & Generate',
1637
+ wizBackToChat: 'Back to Chat',
1638
+ planReady: 'PLAN READY FOR REVIEW',
1639
+ planApprove: 'Approve',
1640
+ planReject: 'Reject',
1641
+ planScoreReady: 'Ready',
1642
+ planScoreNeed: 'Need more info',
1643
+ ```
1644
+
1645
+ Add corresponding entries to `I18N['zh-TW']`:
1646
+
1647
+ ```javascript
1648
+ tabChat: '\u5c0d\u8a71',
1649
+ tabGuided: '\u5f15\u5c0e',
1650
+ wizPartType: '1. \u96f6\u4ef6\u985e\u578b',
1651
+ wizMaterial: '2. \u6750\u6599',
1652
+ wizDimensions: '3. \u5c3a\u5bf8 (mm)',
1653
+ wizFeatures: '4. \u7279\u5fb5',
1654
+ wizConstraints: '5. \u7d04\u675f',
1655
+ wizMachining: '6. \u52a0\u5de5',
1656
+ wizReview: '7. \u5be9\u67e5',
1657
+ wizApprove: '\u6279\u51c6\u4e26\u751f\u6210',
1658
+ wizBackToChat: '\u8fd4\u56de\u5c0d\u8a71',
1659
+ planReady: '\u8a08\u756b\u5df2\u6e96\u5099\u5be9\u67e5',
1660
+ planApprove: '\u6279\u51c6',
1661
+ planReject: '\u62d2\u7d55',
1662
+ planScoreReady: '\u5df2\u5c31\u7dd2',
1663
+ planScoreNeed: '\u9700\u8981\u66f4\u591a\u8cc7\u8a0a',
1664
+ ```
1665
+
1666
+ Add corresponding entries to `I18N.vi`:
1667
+
1668
+ ```javascript
1669
+ tabChat: 'Tr\u00f2 Chuy\u1ec7n',
1670
+ tabGuided: 'H\u01b0\u1edbng D\u1eabn',
1671
+ wizPartType: '1. LO\u1ea0I CHI TI\u1ebET',
1672
+ wizMaterial: '2. V\u1eacT LI\u1ec6U',
1673
+ wizDimensions: '3. K\u00cdCH TH\u01af\u1edaC (mm)',
1674
+ wizFeatures: '4. T\u00cdNH N\u0102NG',
1675
+ wizConstraints: '5. R\u00c0NG BU\u1ed8C',
1676
+ wizMachining: '6. GIA C\u00d4NG',
1677
+ wizReview: '7. XEM L\u1ea0I',
1678
+ wizApprove: 'Duy\u1ec7t & T\u1ea1o',
1679
+ wizBackToChat: 'V\u1ec1 Tr\u00f2 Chuy\u1ec7n',
1680
+ planReady: 'K\u1ebe HO\u1ea0CH S\u1eb4N S\u00c0NG',
1681
+ planApprove: 'Duy\u1ec7t',
1682
+ planReject: 'T\u1eeb Ch\u1ed1i',
1683
+ planScoreReady: 'S\u1eb5n s\u00e0ng',
1684
+ planScoreNeed: 'C\u1ea7n th\u00eam th\u00f4ng tin',
1685
+ ```
1686
+
1687
+ - [ ] **Step 2: Apply `data-i18n` attributes to new HTML elements**
1688
+
1689
+ Add `data-i18n` attributes to the tab buttons and wizard step titles so `applyTranslations()` picks them up.
1690
+
1691
+ - [ ] **Step 3: Test i18n**
1692
+
1693
+ Run dev server, switch language to zh-TW and VI, verify wizard labels translate.
1694
+
1695
+ - [ ] **Step 4: Commit**
1696
+
1697
+ ```bash
1698
+ git add web/index.html
1699
+ git commit -m "feat: add i18n translations for wizard and plan card UI"
1700
+ ```
1701
+
1702
+ ---
1703
+
1704
+ ### Task 11: Full integration test
1705
+
1706
+ **Files:**
1707
+ - Run all tests and manual verification
1708
+
1709
+ - [ ] **Step 1: Run full test suite**
1710
+
1711
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
1712
+ Expected: All pass
1713
+
1714
+ - [ ] **Step 2: Manual end-to-end test — Chat flow**
1715
+
1716
+ Run: `cd /home/daniel/NeuralCAD && python -m server.web --port 5000`
1717
+
1718
+ 1. Open `http://localhost:5000`
1719
+ 2. Chat: "I need a servo bracket, 60mm wide, 40mm high, 20mm deep, aluminum 6061, 4x M6 holes, 3-axis"
1720
+ 3. Verify plan card appears (score should be >= 8)
1721
+ 4. Click "Approve" on the plan card
1722
+ 5. Verify CAD generation triggers
1723
+ 6. Verify 3D preview loads
1724
+ 7. Verify downloads: STEP, STL, 3MF all work
1725
+ 8. Verify G-CODE appears if CAM agent ran
1726
+
1727
+ - [ ] **Step 3: Manual end-to-end test — Guided flow**
1728
+
1729
+ 1. Click "NEW" to reset
1730
+ 2. Switch to "Guided" tab
1731
+ 3. Step through: Bracket → Aluminum 6061 → 60/40/20 → M6 holes → min wall 3mm → 3-axis
1732
+ 4. Review step shows all values, score >= 8
1733
+ 5. Click "Approve & Generate"
1734
+ 6. Verify switches to Chat, generation message sent
1735
+ 7. Verify 3D preview and downloads
1736
+
1737
+ - [ ] **Step 4: Manual test — Reject flow**
1738
+
1739
+ 1. In chat, accumulate enough info for plan card to appear
1740
+ 2. Click "Reject"
1741
+ 3. Verify card disappears, phase resets to "exploring"
1742
+ 4. Continue chatting normally
1743
+
1744
+ - [ ] **Step 5: Manual test — Language switching**
1745
+
1746
+ 1. Switch to zh-TW
1747
+ 2. Open Guided tab — verify labels are in Chinese
1748
+ 3. Switch to VI — verify Vietnamese translations
1749
+
1750
+ - [ ] **Step 6: Commit any fixes**
1751
+
1752
+ ```bash
1753
+ git add -A
1754
+ git commit -m "fix: integration test fixes for planning review gate"
1755
+ ```