Daniel Tu commited on
Commit
07c128c
·
unverified ·
2 Parent(s): d7b43773d01c9a

Merge pull request #7 from danghoangnhan/feat/smart-gap-analysis

Browse files

feat: smart gap analysis with question cards for NOT READY agents

agents/crew_orchestrator.py CHANGED
@@ -15,6 +15,7 @@ from pathlib import Path
15
  from agents.base import BaseOrchestrator
16
  from agents.definitions import AGENTS
17
  from agents.design_state import DesignState, DesignPlan, extract_decisions, compute_score
 
18
  from agents.orchestrator import _format_response
19
  from agents.routing import RoutingEngine
20
  from config.settings import settings
@@ -123,6 +124,7 @@ class CrewOrchestrator(BaseOrchestrator):
123
  "responses": [],
124
  "preview": None,
125
  "design_state": state.model_dump(),
 
126
  }
127
 
128
  if not self._crew_available:
@@ -144,6 +146,7 @@ class CrewOrchestrator(BaseOrchestrator):
144
  )],
145
  "preview": None,
146
  "design_state": design_state or {},
 
147
  }
148
 
149
  def _run_crew(
@@ -259,9 +262,20 @@ class CrewOrchestrator(BaseOrchestrator):
259
  )
260
  elif agent_id == "cam":
261
  task_description += (
262
- "\n\nAnalyze the part geometry and create an optimal machining "
263
- "strategy. Select operations in order (roughing before finishing). "
264
- "Use the Generate G-code Toolpath tool to create the G-code."
 
 
 
 
 
 
 
 
 
 
 
265
  )
266
  else:
267
  task_description += (
@@ -269,11 +283,12 @@ class CrewOrchestrator(BaseOrchestrator):
269
  "SPECIFIC clarifying question."
270
  )
271
 
272
- expected_output = (
273
- "A concise response from your expert perspective (2-4 sentences)."
274
- if agent_id != "cad"
275
- else "Valid CadQuery Python code or a 'NOT READY:' message."
276
- )
 
277
 
278
  task = Task(
279
  description=task_description,
@@ -286,7 +301,7 @@ class CrewOrchestrator(BaseOrchestrator):
286
  crew_tasks.append(task)
287
 
288
  if not crew_agents:
289
- return {"responses": [], "preview": None, "design_state": state.model_dump()}
290
 
291
  crew = Crew(
292
  agents=crew_agents,
@@ -389,6 +404,13 @@ class CrewOrchestrator(BaseOrchestrator):
389
  agent_msgs = [{"message": r.get("message", "")} for r in responses]
390
  updated_state = extract_decisions(agent_msgs, state, message)
391
 
 
 
 
 
 
 
 
392
  # Auto-trigger plan if score crosses threshold
393
  if updated_state.phase == "exploring":
394
  score = compute_score(updated_state)
@@ -409,6 +431,7 @@ class CrewOrchestrator(BaseOrchestrator):
409
  "responses": responses,
410
  "preview": preview,
411
  "design_state": updated_state.model_dump(),
 
412
  }
413
 
414
  def _extract_code(self, text: str) -> str | None:
@@ -431,4 +454,15 @@ class CrewOrchestrator(BaseOrchestrator):
431
  """Fall back to MockChatBackend."""
432
  from agents.orchestrator import MockChatBackend
433
  mock = MockChatBackend(output_dir=self.output_dir)
434
- return mock.chat_turn(message, history, mentions, design_state=design_state)
 
 
 
 
 
 
 
 
 
 
 
 
15
  from agents.base import BaseOrchestrator
16
  from agents.definitions import AGENTS
17
  from agents.design_state import DesignState, DesignPlan, extract_decisions, compute_score
18
+ from agents.gap_analyzer import analyze_gaps, generate_question_cards
19
  from agents.orchestrator import _format_response
20
  from agents.routing import RoutingEngine
21
  from config.settings import settings
 
124
  "responses": [],
125
  "preview": None,
126
  "design_state": state.model_dump(),
127
+ "question_cards": [],
128
  }
129
 
130
  if not self._crew_available:
 
146
  )],
147
  "preview": None,
148
  "design_state": design_state or {},
149
+ "question_cards": [],
150
  }
151
 
152
  def _run_crew(
 
262
  )
263
  elif agent_id == "cam":
264
  task_description += (
265
+ "\n\nIf there is no CAD model generated yet or critical machining "
266
+ "info is missing (material, dimensions, surface finish requirements), "
267
+ "start with 'NOT READY:' and list missing items. "
268
+ "If enough info exists, analyze the part geometry and create an "
269
+ "optimal machining strategy. Select operations in order (roughing "
270
+ "before finishing). Use the Generate G-code Toolpath tool to create "
271
+ "the G-code."
272
+ )
273
+ elif agent_id == "cnc":
274
+ task_description += (
275
+ "\n\nIf the design lacks critical manufacturability info (material, "
276
+ "dimensions, feature access, tolerance), start with 'NOT READY:' "
277
+ "and list missing items. If enough info exists, provide your "
278
+ "manufacturability assessment."
279
  )
280
  else:
281
  task_description += (
 
283
  "SPECIFIC clarifying question."
284
  )
285
 
286
+ if agent_id == "cad":
287
+ expected_output = "Valid CadQuery Python code or a 'NOT READY:' message."
288
+ elif agent_id in ("cnc", "cam"):
289
+ expected_output = "A concise expert assessment or a 'NOT READY:' message listing missing items."
290
+ else:
291
+ expected_output = "A concise response from your expert perspective (2-4 sentences)."
292
 
293
  task = Task(
294
  description=task_description,
 
301
  crew_tasks.append(task)
302
 
303
  if not crew_agents:
304
+ return {"responses": [], "preview": None, "design_state": state.model_dump(), "question_cards": []}
305
 
306
  crew = Crew(
307
  agents=crew_agents,
 
404
  agent_msgs = [{"message": r.get("message", "")} for r in responses]
405
  updated_state = extract_decisions(agent_msgs, state, message)
406
 
407
+ # Gap analysis — detect missing info and generate question cards
408
+ gap_result = analyze_gaps(responses)
409
+ question_cards = []
410
+ if gap_result.has_gaps:
411
+ cards = generate_question_cards(gap_result, updated_state)
412
+ question_cards = [c.model_dump() for c in cards]
413
+
414
  # Auto-trigger plan if score crosses threshold
415
  if updated_state.phase == "exploring":
416
  score = compute_score(updated_state)
 
431
  "responses": responses,
432
  "preview": preview,
433
  "design_state": updated_state.model_dump(),
434
+ "question_cards": question_cards,
435
  }
436
 
437
  def _extract_code(self, text: str) -> str | None:
 
454
  """Fall back to MockChatBackend."""
455
  from agents.orchestrator import MockChatBackend
456
  mock = MockChatBackend(output_dir=self.output_dir)
457
+ result = mock.chat_turn(message, history, mentions, design_state=design_state)
458
+ if "question_cards" not in result:
459
+ from agents.design_state import DesignState
460
+ result_responses = result.get("responses", [])
461
+ gap_result = analyze_gaps(result_responses)
462
+ if gap_result.has_gaps:
463
+ state = DesignState(**(design_state or {}))
464
+ cards = generate_question_cards(gap_result, state)
465
+ result["question_cards"] = [c.model_dump() for c in cards]
466
+ else:
467
+ result["question_cards"] = []
468
+ return result
agents/gap_analyzer.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gap analyzer — detects missing design information from agent responses.
2
+
3
+ Scans for NOT READY: prefixes, classifies missing items into categories,
4
+ and generates structured question cards for the UI to present to the user.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from config.settings import settings
12
+ from agents.definitions import AGENTS
13
+
14
+
15
+ # ── Models ────────────────────────────────────────────────────────────────────
16
+
17
+
18
+ class MissingItem(BaseModel):
19
+ category: str # "dimension", "material", "feature", etc.
20
+ description: str # the keyword that matched
21
+ agent_id: str # which agent flagged it
22
+
23
+
24
+ class GapAnalysis(BaseModel):
25
+ has_gaps: bool = False
26
+ missing_items: list[MissingItem] = Field(default_factory=list)
27
+
28
+
29
+ class QuestionCard(BaseModel):
30
+ category: str
31
+ question: str
32
+ responsible_agent: str
33
+ agent_name: str
34
+ agent_color: str
35
+ suggestions: list[str] = Field(default_factory=list)
36
+ allow_custom: bool = True
37
+
38
+
39
+ # ── Question templates ────────────────────────────────────────────────────────
40
+
41
+ QUESTION_TEMPLATES: dict[str, dict] = {
42
+ "dimension": {
43
+ "question": "What are the part dimensions?",
44
+ "suggestions": [],
45
+ "allow_custom": True,
46
+ },
47
+ "material": {
48
+ "question": "What material should this be made from?",
49
+ "suggestions": ["Aluminum 6061", "Aluminum 7075", "Steel 304", "Steel 316", "Brass", "Titanium"],
50
+ "allow_custom": True,
51
+ },
52
+ "shape": {
53
+ "question": "What type of part are you designing?",
54
+ "suggestions": ["Bracket", "Enclosure", "Plate", "Shaft", "Gear", "Flange"],
55
+ "allow_custom": True,
56
+ },
57
+ "feature": {
58
+ "question": "What features does the part need?",
59
+ "suggestions": ["Mounting holes", "Fillets", "Chamfers", "Pockets", "Slots"],
60
+ "allow_custom": True,
61
+ },
62
+ "constraint": {
63
+ "question": "Are there any manufacturing constraints?",
64
+ "suggestions": ["Min wall 2mm", "Min wall 3mm", "Min wall 5mm"],
65
+ "allow_custom": True,
66
+ },
67
+ "machining": {
68
+ "question": "What machining approach?",
69
+ "suggestions": ["3-axis", "3+2-axis", "5-axis", "Auto"],
70
+ "allow_custom": False,
71
+ },
72
+ "finish": {
73
+ "question": "What surface finish is required?",
74
+ "suggestions": ["Standard", "Fine (Ra 1.6)", "Mirror (Ra 0.4)"],
75
+ "allow_custom": False,
76
+ },
77
+ "model": {
78
+ "question": "A 3D model is needed first. Provide more design details or approve the plan to generate.",
79
+ "suggestions": [],
80
+ "allow_custom": False,
81
+ },
82
+ }
83
+
84
+
85
+ # ── Core functions ────────────────────────────────────────────────────────────
86
+
87
+
88
+ def analyze_gaps(responses: list[dict]) -> GapAnalysis:
89
+ """Scan agent responses for NOT READY: prefixes and classify missing items.
90
+
91
+ Deduplicates by category — first agent to flag a category wins.
92
+ """
93
+ category_keywords: dict[str, list[str]] = settings.gap_analysis.category_keywords
94
+
95
+ # Track which categories have already been claimed (first-agent-wins)
96
+ seen_categories: set[str] = set()
97
+ missing_items: list[MissingItem] = []
98
+
99
+ for response in responses:
100
+ message: str = response.get("message", "")
101
+ agent_id: str = response.get("agent_id", "")
102
+
103
+ # Case-insensitive check for NOT READY: prefix
104
+ stripped = message.strip()
105
+ lower_stripped = stripped.lower()
106
+ if not lower_stripped.startswith("not ready:"):
107
+ continue
108
+
109
+ # Extract the text after the "NOT READY:" prefix
110
+ colon_pos = stripped.index(":") + 1
111
+ gap_text = stripped[colon_pos:].strip()
112
+ gap_lower = gap_text.lower()
113
+
114
+ # Match keywords for each category
115
+ for category, keywords in category_keywords.items():
116
+ if category in seen_categories:
117
+ continue
118
+ for keyword in keywords:
119
+ if keyword.lower() in gap_lower:
120
+ seen_categories.add(category)
121
+ missing_items.append(MissingItem(
122
+ category=category,
123
+ description=keyword,
124
+ agent_id=agent_id,
125
+ ))
126
+ break # one keyword match per category per response is enough
127
+
128
+ return GapAnalysis(
129
+ has_gaps=bool(missing_items),
130
+ missing_items=missing_items,
131
+ )
132
+
133
+
134
+ def _is_category_satisfied(category: str, state) -> bool:
135
+ """Return True if the state already has data covering this category."""
136
+ if category == "material":
137
+ return bool(state.material)
138
+ if category == "dimension":
139
+ return bool(state.dimensions)
140
+ if category == "shape":
141
+ return bool(state.part_name)
142
+ if category == "feature":
143
+ return bool(state.features)
144
+ if category == "constraint":
145
+ return bool(state.constraints)
146
+ if category == "machining":
147
+ return bool(state.axis_recommendation)
148
+ return False
149
+
150
+
151
+ def generate_question_cards(gaps: GapAnalysis, state) -> list[QuestionCard]:
152
+ """Convert a GapAnalysis into question cards, skipping already-satisfied categories.
153
+
154
+ Looks up responsible agent from settings, agent metadata from AGENTS dict,
155
+ and question text/suggestions from QUESTION_TEMPLATES.
156
+ """
157
+ if not gaps.has_gaps:
158
+ return []
159
+
160
+ category_agents: dict[str, str] = settings.gap_analysis.category_agents
161
+ cards: list[QuestionCard] = []
162
+
163
+ for item in gaps.missing_items:
164
+ category = item.category
165
+
166
+ # Skip if the state already satisfies this gap
167
+ if _is_category_satisfied(category, state):
168
+ continue
169
+
170
+ # Look up responsible agent id
171
+ responsible_agent = category_agents.get(category, item.agent_id)
172
+
173
+ # Look up agent metadata
174
+ agent_def = AGENTS.get(responsible_agent)
175
+ agent_name = agent_def.name if agent_def else responsible_agent.title()
176
+ agent_color = agent_def.color if agent_def else "#888888"
177
+
178
+ # Look up question template
179
+ template = QUESTION_TEMPLATES.get(category, {
180
+ "question": f"Please provide {category} details.",
181
+ "suggestions": [],
182
+ "allow_custom": True,
183
+ })
184
+
185
+ cards.append(QuestionCard(
186
+ category=category,
187
+ question=template["question"],
188
+ responsible_agent=responsible_agent,
189
+ agent_name=agent_name,
190
+ agent_color=agent_color,
191
+ suggestions=list(template["suggestions"]),
192
+ allow_custom=template["allow_custom"],
193
+ ))
194
+
195
+ return cards
config.yaml CHANGED
@@ -96,6 +96,68 @@ planning:
96
  - "cad"
97
  - "cnc"
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  agents:
100
  design:
101
  name: Design Agent
 
96
  - "cad"
97
  - "cnc"
98
 
99
+ gap_analysis:
100
+ category_keywords:
101
+ dimension:
102
+ - width
103
+ - height
104
+ - depth
105
+ - length
106
+ - diameter
107
+ - dimension
108
+ - size
109
+ material:
110
+ - material
111
+ - alloy
112
+ - grade
113
+ - aluminum
114
+ - steel
115
+ - metal
116
+ shape:
117
+ - shape
118
+ - form
119
+ - type
120
+ - profile
121
+ - geometry
122
+ - what kind
123
+ feature:
124
+ - hole
125
+ - fillet
126
+ - chamfer
127
+ - pocket
128
+ - slot
129
+ - feature
130
+ constraint:
131
+ - tolerance
132
+ - wall thickness
133
+ - constraint
134
+ - min wall
135
+ - max size
136
+ machining:
137
+ - axis
138
+ - machine
139
+ - setup
140
+ - fixture
141
+ - tool access
142
+ finish:
143
+ - surface finish
144
+ - roughness
145
+ - Ra
146
+ model:
147
+ - model
148
+ - CAD
149
+ - 3D
150
+ - generate
151
+ category_agents:
152
+ dimension: engineering
153
+ material: engineering
154
+ shape: design
155
+ feature: design
156
+ constraint: engineering
157
+ machining: cnc
158
+ finish: cnc
159
+ model: cad
160
+
161
  agents:
162
  design:
163
  name: Design Agent
config/settings.py CHANGED
@@ -111,6 +111,29 @@ class PlanningConfig(BaseModel):
111
  approved_agents: list[str] = Field(default_factory=lambda: ["cad", "cnc"])
112
 
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # ── Main Settings ─────────────────────────────────────────────────────────
115
 
116
 
@@ -136,6 +159,7 @@ class Settings(BaseSettings):
136
  export: ExportConfig = Field(default_factory=ExportConfig)
137
  cam: CAMConfig = Field(default_factory=CAMConfig)
138
  planning: PlanningConfig = Field(default_factory=PlanningConfig)
 
139
  agents: dict[str, AgentConfig] = Field(default_factory=dict)
140
  routing: RoutingConfig = Field(default_factory=RoutingConfig)
141
 
 
111
  approved_agents: list[str] = Field(default_factory=lambda: ["cad", "cnc"])
112
 
113
 
114
+ class GapAnalysisConfig(BaseModel):
115
+ category_keywords: dict[str, list[str]] = Field(default_factory=lambda: {
116
+ "dimension": ["width", "height", "depth", "length", "diameter", "dimension", "size"],
117
+ "material": ["material", "alloy", "grade", "aluminum", "steel", "metal"],
118
+ "shape": ["shape", "form", "type", "profile", "geometry", "what kind"],
119
+ "feature": ["hole", "fillet", "chamfer", "pocket", "slot", "feature"],
120
+ "constraint": ["tolerance", "wall thickness", "constraint", "min wall", "max size"],
121
+ "machining": ["axis", "machine", "setup", "fixture", "tool access"],
122
+ "finish": ["surface finish", "roughness", "Ra"],
123
+ "model": ["model", "CAD", "3D", "generate"],
124
+ })
125
+ category_agents: dict[str, str] = Field(default_factory=lambda: {
126
+ "dimension": "engineering",
127
+ "material": "engineering",
128
+ "shape": "design",
129
+ "feature": "design",
130
+ "constraint": "engineering",
131
+ "machining": "cnc",
132
+ "finish": "cnc",
133
+ "model": "cad",
134
+ })
135
+
136
+
137
  # ── Main Settings ─────────────────────────────────────────────────────────
138
 
139
 
 
159
  export: ExportConfig = Field(default_factory=ExportConfig)
160
  cam: CAMConfig = Field(default_factory=CAMConfig)
161
  planning: PlanningConfig = Field(default_factory=PlanningConfig)
162
+ gap_analysis: GapAnalysisConfig = Field(default_factory=GapAnalysisConfig)
163
  agents: dict[str, AgentConfig] = Field(default_factory=dict)
164
  routing: RoutingConfig = Field(default_factory=RoutingConfig)
165
 
docs/superpowers/plans/2026-04-12-smart-gap-analysis.md ADDED
@@ -0,0 +1,963 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Smart Gap Analysis & Question Cards 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:** When CAD/CNC/CAM agents report missing info via "NOT READY:", the orchestrator analyzes the gaps, categorizes them, and returns actionable question cards to the frontend.
6
+
7
+ **Architecture:** A new `agents/gap_analyzer.py` module provides pure functions for gap analysis and question card generation. The orchestrator calls these after collecting agent responses. The frontend renders interactive cards inline in chat.
8
+
9
+ **Tech Stack:** Python/Pydantic (models), regex/keyword matching (analysis), vanilla JS (frontend cards)
10
+
11
+ ---
12
+
13
+ ### Task 1: GapAnalysisConfig in settings
14
+
15
+ **Files:**
16
+ - Modify: `config/settings.py:93-111` (add `GapAnalysisConfig` after `PlanningConfig`)
17
+ - Modify: `config/settings.py:138-140` (add `gap_analysis` field to `Settings`)
18
+ - Modify: `config.yaml` (add `gap_analysis` section after `planning`)
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 TestGapAnalysisConfig:
27
+ def test_gap_analysis_defaults(self):
28
+ from config.settings import Settings
29
+ s = Settings()
30
+ assert "dimension" in s.gap_analysis.category_keywords
31
+ assert "material" in s.gap_analysis.category_keywords
32
+ assert s.gap_analysis.category_agents["dimension"] == "engineering"
33
+ assert s.gap_analysis.category_agents["shape"] == "design"
34
+
35
+ def test_gap_analysis_loaded_from_yaml(self):
36
+ from config.settings import settings
37
+ assert "width" in settings.gap_analysis.category_keywords.get("dimension", [])
38
+ assert settings.gap_analysis.category_agents["machining"] == "cnc"
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::TestGapAnalysisConfig -v`
44
+ Expected: FAIL with `AttributeError: 'Settings' object has no attribute 'gap_analysis'`
45
+
46
+ - [ ] **Step 3: Add GapAnalysisConfig to settings.py**
47
+
48
+ Add after `PlanningConfig` class (around line 112):
49
+
50
+ ```python
51
+ class GapAnalysisConfig(BaseModel):
52
+ category_keywords: dict[str, list[str]] = Field(default_factory=lambda: {
53
+ "dimension": ["width", "height", "depth", "length", "diameter", "dimension", "size"],
54
+ "material": ["material", "alloy", "grade", "aluminum", "steel", "metal"],
55
+ "shape": ["shape", "form", "type", "profile", "geometry", "what kind"],
56
+ "feature": ["hole", "fillet", "chamfer", "pocket", "slot", "feature"],
57
+ "constraint": ["tolerance", "wall thickness", "constraint", "min wall", "max size"],
58
+ "machining": ["axis", "machine", "setup", "fixture", "tool access"],
59
+ "finish": ["surface finish", "roughness", "Ra"],
60
+ "model": ["model", "CAD", "3D", "generate"],
61
+ })
62
+ category_agents: dict[str, str] = Field(default_factory=lambda: {
63
+ "dimension": "engineering",
64
+ "material": "engineering",
65
+ "shape": "design",
66
+ "feature": "design",
67
+ "constraint": "engineering",
68
+ "machining": "cnc",
69
+ "finish": "cnc",
70
+ "model": "cad",
71
+ })
72
+ ```
73
+
74
+ Add to `Settings` class after `planning` field (line 138):
75
+
76
+ ```python
77
+ gap_analysis: GapAnalysisConfig = Field(default_factory=GapAnalysisConfig)
78
+ ```
79
+
80
+ - [ ] **Step 4: Add gap_analysis section to config.yaml**
81
+
82
+ Add after the `planning` section (before `agents:`):
83
+
84
+ ```yaml
85
+ gap_analysis:
86
+ category_keywords:
87
+ dimension:
88
+ - width
89
+ - height
90
+ - depth
91
+ - length
92
+ - diameter
93
+ - dimension
94
+ - size
95
+ material:
96
+ - material
97
+ - alloy
98
+ - grade
99
+ - aluminum
100
+ - steel
101
+ - metal
102
+ shape:
103
+ - shape
104
+ - form
105
+ - type
106
+ - profile
107
+ - geometry
108
+ - what kind
109
+ feature:
110
+ - hole
111
+ - fillet
112
+ - chamfer
113
+ - pocket
114
+ - slot
115
+ - feature
116
+ constraint:
117
+ - tolerance
118
+ - wall thickness
119
+ - constraint
120
+ - min wall
121
+ - max size
122
+ machining:
123
+ - axis
124
+ - machine
125
+ - setup
126
+ - fixture
127
+ - tool access
128
+ finish:
129
+ - surface finish
130
+ - roughness
131
+ - Ra
132
+ model:
133
+ - model
134
+ - CAD
135
+ - 3D
136
+ - generate
137
+ category_agents:
138
+ dimension: engineering
139
+ material: engineering
140
+ shape: design
141
+ feature: design
142
+ constraint: engineering
143
+ machining: cnc
144
+ finish: cnc
145
+ model: cad
146
+ ```
147
+
148
+ - [ ] **Step 5: Run test to verify it passes**
149
+
150
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_settings.py -v`
151
+ Expected: All pass
152
+
153
+ - [ ] **Step 6: Run full test suite**
154
+
155
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ --tb=short`
156
+ Expected: All pass
157
+
158
+ - [ ] **Step 7: Commit**
159
+
160
+ ```bash
161
+ git add config/settings.py config.yaml tests/test_settings.py
162
+ git commit -m "feat: add GapAnalysisConfig to settings with category keywords and agents"
163
+ ```
164
+
165
+ ---
166
+
167
+ ### Task 2: Gap analyzer module — models and analyze_gaps()
168
+
169
+ **Files:**
170
+ - Create: `agents/gap_analyzer.py`
171
+ - Create: `tests/test_gap_analyzer.py`
172
+
173
+ - [ ] **Step 1: Write failing tests**
174
+
175
+ ```python
176
+ # tests/test_gap_analyzer.py
177
+
178
+ from agents.gap_analyzer import MissingItem, GapAnalysis, analyze_gaps
179
+
180
+
181
+ class TestAnalyzeGaps:
182
+ def test_no_gaps_when_no_not_ready(self):
183
+ responses = [
184
+ {"agent_id": "design", "message": "I suggest an L-bracket design."},
185
+ {"agent_id": "engineering", "message": "Aluminum 6061 would work well."},
186
+ ]
187
+ result = analyze_gaps(responses)
188
+ assert not result.has_gaps
189
+ assert result.missing_items == []
190
+
191
+ def test_detects_not_ready_from_cad(self):
192
+ responses = [
193
+ {"agent_id": "cad", "message": "NOT READY: Need dimensions (width, height) and material selection."},
194
+ ]
195
+ result = analyze_gaps(responses)
196
+ assert result.has_gaps
197
+ categories = [item.category for item in result.missing_items]
198
+ assert "dimension" in categories
199
+ assert "material" in categories
200
+
201
+ def test_detects_not_ready_from_cnc(self):
202
+ responses = [
203
+ {"agent_id": "cnc", "message": "NOT READY: Need material and tolerance info for manufacturability check."},
204
+ ]
205
+ result = analyze_gaps(responses)
206
+ assert result.has_gaps
207
+ categories = [item.category for item in result.missing_items]
208
+ assert "material" in categories
209
+ assert "constraint" in categories
210
+
211
+ def test_detects_not_ready_from_cam(self):
212
+ responses = [
213
+ {"agent_id": "cam", "message": "NOT READY: No 3D model available. Need CAD generation first."},
214
+ ]
215
+ result = analyze_gaps(responses)
216
+ assert result.has_gaps
217
+ categories = [item.category for item in result.missing_items]
218
+ assert "model" in categories
219
+
220
+ def test_deduplicates_across_agents(self):
221
+ responses = [
222
+ {"agent_id": "cad", "message": "NOT READY: Need material and dimensions."},
223
+ {"agent_id": "cnc", "message": "NOT READY: Material is required for manufacturability."},
224
+ ]
225
+ result = analyze_gaps(responses)
226
+ material_items = [i for i in result.missing_items if i.category == "material"]
227
+ assert len(material_items) == 1
228
+ assert material_items[0].agent_id == "cad" # first wins
229
+
230
+ def test_case_insensitive_not_ready(self):
231
+ responses = [
232
+ {"agent_id": "cad", "message": "not ready: missing width and height dimensions."},
233
+ ]
234
+ result = analyze_gaps(responses)
235
+ assert result.has_gaps
236
+ assert any(item.category == "dimension" for item in result.missing_items)
237
+
238
+ def test_no_false_positive_on_regular_message(self):
239
+ responses = [
240
+ {"agent_id": "cad", "message": "Model generated successfully."},
241
+ {"agent_id": "cnc", "message": "3-axis machining is ready for this part."},
242
+ ]
243
+ result = analyze_gaps(responses)
244
+ assert not result.has_gaps
245
+ ```
246
+
247
+ - [ ] **Step 2: Run tests to verify they fail**
248
+
249
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_gap_analyzer.py -v`
250
+ Expected: FAIL with `ModuleNotFoundError`
251
+
252
+ - [ ] **Step 3: Create agents/gap_analyzer.py with models and analyze_gaps()**
253
+
254
+ ```python
255
+ """Gap analyzer — detects missing info from agent NOT READY responses and generates question cards."""
256
+
257
+ from __future__ import annotations
258
+
259
+ from pydantic import BaseModel, Field
260
+
261
+ from agents.definitions import AGENTS
262
+ from config.settings import settings
263
+
264
+
265
+ # ── Data Models ──────────────────────────────────────────────────────────
266
+
267
+
268
+ class MissingItem(BaseModel):
269
+ """A single piece of missing information flagged by an agent."""
270
+ category: str
271
+ description: str
272
+ agent_id: str
273
+
274
+
275
+ class GapAnalysis(BaseModel):
276
+ """Result of scanning agent responses for missing information."""
277
+ has_gaps: bool = False
278
+ missing_items: list[MissingItem] = Field(default_factory=list)
279
+
280
+
281
+ # ── Gap Analysis ─────────────────────────────────────────────────────────
282
+
283
+
284
+ def analyze_gaps(responses: list[dict]) -> GapAnalysis:
285
+ """Scan agent responses for NOT READY prefixes and categorize missing items.
286
+
287
+ Args:
288
+ responses: List of agent response dicts with 'agent_id' and 'message' keys.
289
+
290
+ Returns:
291
+ GapAnalysis with categorized missing items, deduplicated across agents.
292
+ """
293
+ cfg = settings.gap_analysis
294
+ items: list[MissingItem] = []
295
+ seen_categories: set[str] = set()
296
+
297
+ for resp in responses:
298
+ message = resp.get("message", "")
299
+ agent_id = resp.get("agent_id", "")
300
+
301
+ if not message.upper().startswith("NOT READY:"):
302
+ continue
303
+
304
+ # Extract text after "NOT READY:"
305
+ gap_text = message.split(":", 1)[1].strip().lower()
306
+
307
+ # Match against category keywords
308
+ for category, keywords in cfg.category_keywords.items():
309
+ if category in seen_categories:
310
+ continue
311
+ for keyword in keywords:
312
+ if keyword.lower() in gap_text:
313
+ items.append(MissingItem(
314
+ category=category,
315
+ description=keyword,
316
+ agent_id=agent_id,
317
+ ))
318
+ seen_categories.add(category)
319
+ break
320
+
321
+ return GapAnalysis(
322
+ has_gaps=len(items) > 0,
323
+ missing_items=items,
324
+ )
325
+ ```
326
+
327
+ - [ ] **Step 4: Run tests to verify they pass**
328
+
329
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_gap_analyzer.py -v`
330
+ Expected: All pass
331
+
332
+ - [ ] **Step 5: Commit**
333
+
334
+ ```bash
335
+ git add agents/gap_analyzer.py tests/test_gap_analyzer.py
336
+ git commit -m "feat: add gap analyzer with MissingItem, GapAnalysis models and analyze_gaps()"
337
+ ```
338
+
339
+ ---
340
+
341
+ ### Task 3: generate_question_cards() with state filtering
342
+
343
+ **Files:**
344
+ - Modify: `agents/gap_analyzer.py` (add `QuestionCard` model and `generate_question_cards()`)
345
+ - Modify: `tests/test_gap_analyzer.py` (add tests)
346
+
347
+ - [ ] **Step 1: Write failing tests**
348
+
349
+ ```python
350
+ # tests/test_gap_analyzer.py — add to existing file
351
+
352
+ from agents.gap_analyzer import QuestionCard, generate_question_cards
353
+ from agents.design_state import DesignState
354
+
355
+
356
+ class TestGenerateQuestionCards:
357
+ def test_generates_cards_from_gaps(self):
358
+ gaps = GapAnalysis(
359
+ has_gaps=True,
360
+ missing_items=[
361
+ MissingItem(category="material", description="material", agent_id="cad"),
362
+ MissingItem(category="dimension", description="width", agent_id="cad"),
363
+ ],
364
+ )
365
+ cards = generate_question_cards(gaps, DesignState())
366
+ assert len(cards) == 2
367
+ material_card = next(c for c in cards if c.category == "material")
368
+ assert material_card.responsible_agent == "engineering"
369
+ assert len(material_card.suggestions) > 0
370
+ assert material_card.allow_custom is True
371
+
372
+ def test_filters_out_already_set_material(self):
373
+ gaps = GapAnalysis(
374
+ has_gaps=True,
375
+ missing_items=[
376
+ MissingItem(category="material", description="material", agent_id="cad"),
377
+ MissingItem(category="dimension", description="width", agent_id="cad"),
378
+ ],
379
+ )
380
+ state = DesignState(material="aluminum 6061")
381
+ cards = generate_question_cards(gaps, state)
382
+ categories = [c.category for c in cards]
383
+ assert "material" not in categories
384
+ assert "dimension" in categories
385
+
386
+ def test_filters_out_already_set_dimensions(self):
387
+ gaps = GapAnalysis(
388
+ has_gaps=True,
389
+ missing_items=[
390
+ MissingItem(category="dimension", description="width", agent_id="cad"),
391
+ ],
392
+ )
393
+ state = DesignState(dimensions={"width": 60, "height": 40})
394
+ cards = generate_question_cards(gaps, state)
395
+ assert len(cards) == 0
396
+
397
+ def test_no_cards_when_no_gaps(self):
398
+ gaps = GapAnalysis(has_gaps=False, missing_items=[])
399
+ cards = generate_question_cards(gaps, DesignState())
400
+ assert cards == []
401
+
402
+ def test_card_has_agent_metadata(self):
403
+ gaps = GapAnalysis(
404
+ has_gaps=True,
405
+ missing_items=[
406
+ MissingItem(category="machining", description="axis", agent_id="cnc"),
407
+ ],
408
+ )
409
+ cards = generate_question_cards(gaps, DesignState())
410
+ assert len(cards) == 1
411
+ assert cards[0].responsible_agent == "cnc"
412
+ assert cards[0].agent_name == "CNC Agent"
413
+ assert cards[0].agent_color == "#00e676"
414
+
415
+ def test_model_category_no_suggestions(self):
416
+ gaps = GapAnalysis(
417
+ has_gaps=True,
418
+ missing_items=[
419
+ MissingItem(category="model", description="3D", agent_id="cam"),
420
+ ],
421
+ )
422
+ cards = generate_question_cards(gaps, DesignState())
423
+ assert len(cards) == 1
424
+ assert cards[0].suggestions == []
425
+ assert cards[0].allow_custom is False
426
+ ```
427
+
428
+ - [ ] **Step 2: Run tests to verify they fail**
429
+
430
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_gap_analyzer.py::TestGenerateQuestionCards -v`
431
+ Expected: FAIL with `ImportError: cannot import name 'QuestionCard'`
432
+
433
+ - [ ] **Step 3: Add QuestionCard model and generate_question_cards()**
434
+
435
+ Add to `agents/gap_analyzer.py`:
436
+
437
+ ```python
438
+ class QuestionCard(BaseModel):
439
+ """An actionable question card for the frontend."""
440
+ category: str
441
+ question: str
442
+ responsible_agent: str
443
+ agent_name: str
444
+ agent_color: str
445
+ suggestions: list[str] = Field(default_factory=list)
446
+ allow_custom: bool = True
447
+
448
+
449
+ # ── Question Templates ───────────────────────────────────────────────────
450
+
451
+ QUESTION_TEMPLATES: dict[str, dict] = {
452
+ "dimension": {
453
+ "question": "What are the part dimensions?",
454
+ "suggestions": [],
455
+ "allow_custom": True,
456
+ },
457
+ "material": {
458
+ "question": "What material should this be made from?",
459
+ "suggestions": ["Aluminum 6061", "Aluminum 7075", "Steel 304", "Steel 316", "Brass", "Titanium"],
460
+ "allow_custom": True,
461
+ },
462
+ "shape": {
463
+ "question": "What type of part are you designing?",
464
+ "suggestions": ["Bracket", "Enclosure", "Plate", "Shaft", "Gear", "Flange"],
465
+ "allow_custom": True,
466
+ },
467
+ "feature": {
468
+ "question": "What features does the part need?",
469
+ "suggestions": ["Mounting holes", "Fillets", "Chamfers", "Pockets", "Slots"],
470
+ "allow_custom": True,
471
+ },
472
+ "constraint": {
473
+ "question": "Are there any manufacturing constraints?",
474
+ "suggestions": ["Min wall 2mm", "Min wall 3mm", "Min wall 5mm"],
475
+ "allow_custom": True,
476
+ },
477
+ "machining": {
478
+ "question": "What machining approach?",
479
+ "suggestions": ["3-axis", "3+2-axis", "5-axis", "Auto"],
480
+ "allow_custom": False,
481
+ },
482
+ "finish": {
483
+ "question": "What surface finish is required?",
484
+ "suggestions": ["Standard", "Fine (Ra 1.6)", "Mirror (Ra 0.4)"],
485
+ "allow_custom": False,
486
+ },
487
+ "model": {
488
+ "question": "A 3D model is needed first. Provide more design details or approve the plan to generate.",
489
+ "suggestions": [],
490
+ "allow_custom": False,
491
+ },
492
+ }
493
+
494
+
495
+ # ── Question Card Generation ─────────────────────────────────────────────
496
+
497
+ def _is_category_satisfied(category: str, state) -> bool:
498
+ """Check if a category's info is already present in the design state."""
499
+ if category == "material" and state.material:
500
+ return True
501
+ if category == "dimension" and state.dimensions:
502
+ return True
503
+ if category == "shape" and state.part_name:
504
+ return True
505
+ if category == "feature" and state.features:
506
+ return True
507
+ if category == "constraint" and state.constraints:
508
+ return True
509
+ if category == "machining" and state.axis_recommendation:
510
+ return True
511
+ return False
512
+
513
+
514
+ def generate_question_cards(gaps: GapAnalysis, state) -> list[QuestionCard]:
515
+ """Generate question cards from gap analysis, filtering out already-answered items.
516
+
517
+ Args:
518
+ gaps: Result of analyze_gaps().
519
+ state: Current DesignState to filter against.
520
+
521
+ Returns:
522
+ List of QuestionCard objects for the frontend.
523
+ """
524
+ if not gaps.has_gaps:
525
+ return []
526
+
527
+ cfg = settings.gap_analysis
528
+ cards: list[QuestionCard] = []
529
+
530
+ for item in gaps.missing_items:
531
+ if _is_category_satisfied(item.category, state):
532
+ continue
533
+
534
+ template = QUESTION_TEMPLATES.get(item.category)
535
+ if not template:
536
+ continue
537
+
538
+ agent_id = cfg.category_agents.get(item.category, "design")
539
+ agent_def = AGENTS.get(agent_id)
540
+ agent_name = agent_def.name if agent_def else agent_id
541
+ agent_color = agent_def.color if agent_def else "#888888"
542
+
543
+ cards.append(QuestionCard(
544
+ category=item.category,
545
+ question=template["question"],
546
+ responsible_agent=agent_id,
547
+ agent_name=agent_name,
548
+ agent_color=agent_color,
549
+ suggestions=list(template["suggestions"]),
550
+ allow_custom=template["allow_custom"],
551
+ ))
552
+
553
+ return cards
554
+ ```
555
+
556
+ - [ ] **Step 4: Run tests to verify they pass**
557
+
558
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_gap_analyzer.py -v`
559
+ Expected: All pass
560
+
561
+ - [ ] **Step 5: Run full test suite**
562
+
563
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ --tb=short`
564
+ Expected: All pass
565
+
566
+ - [ ] **Step 6: Commit**
567
+
568
+ ```bash
569
+ git add agents/gap_analyzer.py tests/test_gap_analyzer.py
570
+ git commit -m "feat: add QuestionCard model and generate_question_cards() with state filtering"
571
+ ```
572
+
573
+ ---
574
+
575
+ ### Task 4: Orchestrator integration — call gap analyzer and update CNC/CAM prompts
576
+
577
+ **Files:**
578
+ - Modify: `agents/crew_orchestrator.py:17` (add import)
579
+ - Modify: `agents/crew_orchestrator.py:260-270` (update CNC/CAM task descriptions)
580
+ - Modify: `agents/crew_orchestrator.py:388-412` (call gap analyzer, add question_cards to return)
581
+ - Test: `tests/test_crew_orchestrator.py`
582
+
583
+ - [ ] **Step 1: Write failing tests**
584
+
585
+ Read `tests/test_crew_orchestrator.py` first to understand existing patterns. Then add:
586
+
587
+ ```python
588
+ # tests/test_crew_orchestrator.py — add to existing file
589
+
590
+ class TestGapAnalysis:
591
+ """Tests for gap analysis integration in CrewOrchestrator."""
592
+
593
+ def test_not_ready_produces_question_cards(self):
594
+ """Mock backend CAD agent returns NOT READY, should produce question cards."""
595
+ orch = CrewOrchestrator(backend_name="mock")
596
+ result = orch.chat_turn(
597
+ message="generate a bracket",
598
+ history=[],
599
+ design_state={},
600
+ )
601
+ # Mock backend's CAD fallback message starts with "NOT READY:"
602
+ # Check that question_cards key exists
603
+ assert "question_cards" in result
604
+
605
+ def test_no_question_cards_when_no_gaps(self):
606
+ """Normal responses should produce empty question_cards."""
607
+ orch = CrewOrchestrator(backend_name="mock")
608
+ result = orch.chat_turn(
609
+ message="I need a bracket",
610
+ history=[],
611
+ design_state={"material": "aluminum", "dimensions": {"width": 60}},
612
+ )
613
+ assert "question_cards" in result
614
+ # Even if cards generated, state filtering should remove some
615
+ assert isinstance(result["question_cards"], list)
616
+ ```
617
+
618
+ - [ ] **Step 2: Run tests to verify they fail**
619
+
620
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_crew_orchestrator.py::TestGapAnalysis -v`
621
+ Expected: FAIL (question_cards key doesn't exist in response)
622
+
623
+ - [ ] **Step 3: Add import to crew_orchestrator.py**
624
+
625
+ Add to imports at top:
626
+
627
+ ```python
628
+ from agents.gap_analyzer import analyze_gaps, generate_question_cards
629
+ ```
630
+
631
+ - [ ] **Step 4: Update CNC and CAM task descriptions**
632
+
633
+ In `_run_crew`, find the `elif agent_id == "cam":` block (around line 260) and replace:
634
+
635
+ ```python
636
+ elif agent_id == "cam":
637
+ task_description += (
638
+ "\n\nIf there is no CAD model generated yet or critical machining "
639
+ "info is missing (material, dimensions, surface finish requirements), "
640
+ "start with 'NOT READY:' and list missing items. "
641
+ "If enough info exists, analyze the part geometry and create an "
642
+ "optimal machining strategy. Select operations in order (roughing "
643
+ "before finishing). Use the Generate G-code Toolpath tool to create "
644
+ "the G-code."
645
+ )
646
+ ```
647
+
648
+ Find the `else:` block (around line 266) that handles non-CAD agents and change it to also handle CNC:
649
+
650
+ ```python
651
+ elif agent_id == "cnc":
652
+ task_description += (
653
+ "\n\nIf the design lacks critical manufacturability info (material, "
654
+ "dimensions, feature access, tolerance), start with 'NOT READY:' "
655
+ "and list missing items. If enough info exists, provide your "
656
+ "manufacturability assessment."
657
+ )
658
+ else:
659
+ task_description += (
660
+ "\n\nIf key information is missing in YOUR domain, ask a "
661
+ "SPECIFIC clarifying question."
662
+ )
663
+ ```
664
+
665
+ Also update the `expected_output` to cover CNC and CAM:
666
+
667
+ ```python
668
+ if agent_id == "cad":
669
+ expected_output = "Valid CadQuery Python code or a 'NOT READY:' message."
670
+ elif agent_id in ("cnc", "cam"):
671
+ expected_output = "A concise expert assessment or a 'NOT READY:' message listing missing items."
672
+ else:
673
+ expected_output = "A concise response from your expert perspective (2-4 sentences)."
674
+ ```
675
+
676
+ - [ ] **Step 5: Add gap analysis call and question_cards to response**
677
+
678
+ In `_run_crew`, after `updated_state = extract_decisions(...)` (around line 390) and before the auto-trigger plan check, add:
679
+
680
+ ```python
681
+ # Gap analysis — detect missing info and generate question cards
682
+ gap_result = analyze_gaps(responses)
683
+ question_cards = []
684
+ if gap_result.has_gaps:
685
+ cards = generate_question_cards(gap_result, updated_state)
686
+ question_cards = [c.model_dump() for c in cards]
687
+ ```
688
+
689
+ Update the return dict to include `question_cards`:
690
+
691
+ ```python
692
+ return {
693
+ "responses": responses,
694
+ "preview": preview,
695
+ "design_state": updated_state.model_dump(),
696
+ "question_cards": question_cards,
697
+ }
698
+ ```
699
+
700
+ - [ ] **Step 6: Add question_cards to fallback return paths**
701
+
702
+ In `chat_turn`, find the manual plan trigger early return (around line 117-126) and the error return (around line 128-135). Add `"question_cards": []` to each return dict. Also update `_fallback` wrapper — after calling `mock.chat_turn()`, add `question_cards` key if missing:
703
+
704
+ In `_fallback` method, wrap the result:
705
+
706
+ ```python
707
+ def _fallback(self, message, history, mentions, max_history, design_state):
708
+ from agents.orchestrator import MockChatBackend
709
+ mock = MockChatBackend(output_dir=self.output_dir)
710
+ result = mock.chat_turn(message, history, mentions, design_state=design_state)
711
+ if "question_cards" not in result:
712
+ result_responses = result.get("responses", [])
713
+ gap_result = analyze_gaps(result_responses)
714
+ if gap_result.has_gaps:
715
+ from agents.design_state import DesignState
716
+ state = DesignState(**(design_state or {}))
717
+ cards = generate_question_cards(gap_result, state)
718
+ result["question_cards"] = [c.model_dump() for c in cards]
719
+ else:
720
+ result["question_cards"] = []
721
+ return result
722
+ ```
723
+
724
+ - [ ] **Step 7: Run tests to verify they pass**
725
+
726
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_crew_orchestrator.py -v`
727
+ Expected: All pass
728
+
729
+ - [ ] **Step 8: Run full test suite**
730
+
731
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ --tb=short`
732
+ Expected: All pass
733
+
734
+ - [ ] **Step 9: Commit**
735
+
736
+ ```bash
737
+ git add agents/crew_orchestrator.py tests/test_crew_orchestrator.py
738
+ git commit -m "feat: integrate gap analyzer into orchestrator, update CNC/CAM NOT READY prompts"
739
+ ```
740
+
741
+ ---
742
+
743
+ ### Task 5: Frontend question cards
744
+
745
+ **Files:**
746
+ - Modify: `web/index.html` (add CSS, JS, update sendMessage)
747
+
748
+ - [ ] **Step 1: Add gap card CSS**
749
+
750
+ Add to the `<style>` section after the existing plan card styles:
751
+
752
+ ```css
753
+ /* ---- GAP / QUESTION CARDS ---- */
754
+ .gap-cards {
755
+ background: var(--bg-surface);
756
+ border: 1px solid var(--border);
757
+ border-radius: 8px;
758
+ padding: 12px;
759
+ margin: 8px 0;
760
+ }
761
+ .gap-cards-title {
762
+ font-family: var(--font-mono);
763
+ font-size: 11px;
764
+ font-weight: 700;
765
+ color: var(--warning);
766
+ margin-bottom: 10px;
767
+ }
768
+ .gap-card {
769
+ padding: 10px;
770
+ margin-bottom: 8px;
771
+ border-left: 3px solid var(--border);
772
+ background: var(--bg-input);
773
+ border-radius: 0 6px 6px 0;
774
+ }
775
+ .gap-card:last-child { margin-bottom: 0; }
776
+ .gap-card-header {
777
+ display: flex; align-items: center; gap: 6px;
778
+ margin-bottom: 6px;
779
+ }
780
+ .gap-card-dot {
781
+ width: 8px; height: 8px; border-radius: 50%;
782
+ flex-shrink: 0;
783
+ }
784
+ .gap-card-agent {
785
+ font-family: var(--font-mono); font-size: 10px;
786
+ color: var(--text-secondary); font-weight: 600;
787
+ }
788
+ .gap-card-question {
789
+ font-family: var(--font-body); font-size: 12px;
790
+ color: var(--text-primary); margin-bottom: 8px;
791
+ }
792
+ .gap-card .wizard-chips { margin-bottom: 0; }
793
+ .gap-card .wizard-dim-row { margin-top: 4px; }
794
+ .gap-card-submit {
795
+ margin-top: 8px; padding: 5px 14px;
796
+ background: var(--accent); color: var(--bg-void);
797
+ border: none; border-radius: 4px;
798
+ font-family: var(--font-mono); font-size: 11px;
799
+ font-weight: 600; cursor: pointer;
800
+ }
801
+ ```
802
+
803
+ - [ ] **Step 2: Add renderQuestionCards() and interaction handlers**
804
+
805
+ Add to the `<script>` section:
806
+
807
+ ```javascript
808
+ // ── QUESTION CARDS ────────────────────────────────────
809
+
810
+ function renderQuestionCards(cards) {
811
+ if (!cards || cards.length === 0) return '';
812
+ let html = '<div class="gap-cards" id="active-gap-cards">';
813
+ html += '<div class="gap-cards-title">\u26a0 MISSING INFO</div>';
814
+ for (const card of cards) {
815
+ html += '<div class="gap-card" style="border-left-color:' + escapeHtml(card.agent_color) + ';">';
816
+ html += '<div class="gap-card-header">';
817
+ html += '<div class="gap-card-dot" style="background:' + escapeHtml(card.agent_color) + ';"></div>';
818
+ html += '<span class="gap-card-agent">' + escapeHtml(card.agent_name) + '</span>';
819
+ html += '</div>';
820
+ html += '<div class="gap-card-question">' + escapeHtml(card.question) + '</div>';
821
+
822
+ if (card.category === 'dimension') {
823
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Width</span><input class="wizard-dim-input gap-dim" id="gap-dim-width" type="number"><span class="wizard-dim-unit">mm</span></div>';
824
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Height</span><input class="wizard-dim-input gap-dim" id="gap-dim-height" type="number"><span class="wizard-dim-unit">mm</span></div>';
825
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Depth</span><input class="wizard-dim-input gap-dim" id="gap-dim-depth" type="number"><span class="wizard-dim-unit">mm</span></div>';
826
+ html += '<button class="gap-card-submit" onclick="gapSubmitDimensions()">Submit</button>';
827
+ } else if (card.suggestions && card.suggestions.length > 0) {
828
+ html += '<div class="wizard-chips">';
829
+ for (const s of card.suggestions) {
830
+ html += '<button class="wizard-chip" onclick="gapSelectChip(\'' + escapeHtml(s) + '\',\'' + escapeHtml(card.category) + '\')">' + escapeHtml(s) + '</button>';
831
+ }
832
+ if (card.allow_custom) {
833
+ html += '</div><input class="wizard-input" placeholder="Or type custom..." onkeydown="if(event.key===\'Enter\')gapSelectChip(this.value,\'' + escapeHtml(card.category) + '\')">';
834
+ } else {
835
+ html += '</div>';
836
+ }
837
+ }
838
+ html += '</div>';
839
+ }
840
+ html += '</div>';
841
+ return html;
842
+ }
843
+
844
+ function removeGapCards() {
845
+ const el = document.getElementById('active-gap-cards');
846
+ if (el) el.remove();
847
+ }
848
+
849
+ function gapSelectChip(value, category) {
850
+ if (!value.trim()) return;
851
+ // Update local state
852
+ if (category === 'material') designState.material = value;
853
+ else if (category === 'shape') { designState.part_name = value; designState.description = value; }
854
+ else if (category === 'machining') designState.axis_recommendation = value;
855
+ else if (category === 'constraint') {
856
+ if (!designState.constraints) designState.constraints = [];
857
+ designState.constraints.push(value);
858
+ } else if (category === 'feature') {
859
+ if (!designState.features) designState.features = [];
860
+ designState.features.push(value);
861
+ } else if (category === 'finish') {
862
+ if (!designState.constraints) designState.constraints = [];
863
+ designState.constraints.push('surface finish: ' + value);
864
+ }
865
+ saveState();
866
+ removeGapCards();
867
+ sendMessage(value);
868
+ }
869
+
870
+ function gapSubmitDimensions() {
871
+ const w = document.getElementById('gap-dim-width')?.value;
872
+ const h = document.getElementById('gap-dim-height')?.value;
873
+ const d = document.getElementById('gap-dim-depth')?.value;
874
+ if (!designState.dimensions) designState.dimensions = {};
875
+ const parts = [];
876
+ if (w) { designState.dimensions.width = parseFloat(w); parts.push(w + 'mm wide'); }
877
+ if (h) { designState.dimensions.height = parseFloat(h); parts.push(h + 'mm high'); }
878
+ if (d) { designState.dimensions.depth = parseFloat(d); parts.push(d + 'mm deep'); }
879
+ if (parts.length === 0) return;
880
+ saveState();
881
+ removeGapCards();
882
+ sendMessage(parts.join(', '));
883
+ }
884
+ ```
885
+
886
+ - [ ] **Step 3: Update sendMessage to render question cards and clear old ones**
887
+
888
+ In `sendMessage`, at the very top (after `if (!text.trim()) return;`), add:
889
+
890
+ ```javascript
891
+ removeGapCards();
892
+ ```
893
+
894
+ After the plan card insertion block (around the `if (designState.phase === 'planning' && designState.plan)` block), add:
895
+
896
+ ```javascript
897
+ // If question cards are present, render them
898
+ if (data.question_cards && data.question_cards.length > 0) {
899
+ removeGapCards();
900
+ const msgs = document.getElementById('chat-messages');
901
+ const cardDiv = document.createElement('div');
902
+ cardDiv.innerHTML = renderQuestionCards(data.question_cards);
903
+ msgs.appendChild(cardDiv.firstChild);
904
+ msgs.scrollTop = msgs.scrollHeight;
905
+ }
906
+ ```
907
+
908
+ - [ ] **Step 4: Test manually**
909
+
910
+ Run: `cd /home/daniel/NeuralCAD && python -m server.web --port 5000`
911
+ Open `http://localhost:5000`. Test:
912
+ 1. Type "@cad generate a bracket" — CAD agent should return NOT READY, question cards should appear
913
+ 2. Click a material chip (e.g., "Aluminum 6061") — card removed, message sent
914
+ 3. After providing dimensions and material, cards should stop appearing
915
+ 4. Type a free message while cards are showing — cards should be cleared
916
+
917
+ - [ ] **Step 5: Commit**
918
+
919
+ ```bash
920
+ git add web/index.html
921
+ git commit -m "feat: add frontend question cards with gap card rendering and interaction handlers"
922
+ ```
923
+
924
+ ---
925
+
926
+ ### Task 6: Full integration test
927
+
928
+ **Files:**
929
+ - Run all tests and manual verification
930
+
931
+ - [ ] **Step 1: Run full test suite**
932
+
933
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
934
+ Expected: All pass
935
+
936
+ - [ ] **Step 2: Manual end-to-end test — NOT READY flow**
937
+
938
+ 1. Open `http://localhost:5000`
939
+ 2. Type "I need a bracket" — Design + Engineering agents respond, no question cards
940
+ 3. Type "@cad generate it" — CAD should say NOT READY, question cards appear for missing items
941
+ 4. Click "Aluminum 6061" chip — message sent, card removed
942
+ 5. Enter dimensions via card — message sent, card removed
943
+ 6. Type "@cad generate it" again — if enough info, CAD generates code; if not, new cards with remaining gaps
944
+
945
+ - [ ] **Step 3: Manual test — card clearing**
946
+
947
+ 1. Trigger question cards with "@cad generate"
948
+ 2. Instead of clicking a card, type freely in the chat input
949
+ 3. Verify cards are removed on send
950
+
951
+ - [ ] **Step 4: Manual test — guided wizard interaction**
952
+
953
+ 1. Fill in material and dimensions via the Guided wizard tab
954
+ 2. Switch to Chat, type "@cad generate"
955
+ 3. Verify cards do NOT appear for material/dimension (already in state)
956
+ 4. Verify cards only appear for remaining missing items
957
+
958
+ - [ ] **Step 5: Commit any fixes**
959
+
960
+ ```bash
961
+ git add -A
962
+ git commit -m "fix: integration test fixes for gap analysis"
963
+ ```
tests/test_crew_orchestrator.py CHANGED
@@ -71,6 +71,37 @@ class TestGetOrchestrator:
71
  assert isinstance(orch, CrewOrchestrator)
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  class TestPlanningPhase:
75
  """Tests for planning phase in CrewOrchestrator."""
76
 
 
71
  assert isinstance(orch, CrewOrchestrator)
72
 
73
 
74
+ class TestGapAnalysis:
75
+ def test_not_ready_produces_question_cards(self):
76
+ orch = CrewOrchestrator(backend_name="mock")
77
+ result = orch.chat_turn(
78
+ message="generate a bracket",
79
+ history=[],
80
+ design_state={},
81
+ )
82
+ assert "question_cards" in result
83
+
84
+ def test_no_question_cards_when_no_gaps(self):
85
+ orch = CrewOrchestrator(backend_name="mock")
86
+ result = orch.chat_turn(
87
+ message="I need a bracket",
88
+ history=[],
89
+ design_state={"material": "aluminum", "dimensions": {"width": 60}},
90
+ )
91
+ assert "question_cards" in result
92
+ assert isinstance(result["question_cards"], list)
93
+
94
+ def test_plan_trigger_includes_question_cards_key(self):
95
+ orch = CrewOrchestrator(backend_name="mock")
96
+ result = orch.chat_turn(
97
+ message="show plan",
98
+ history=[],
99
+ design_state={"material": "aluminum"},
100
+ )
101
+ assert "question_cards" in result
102
+ assert result["question_cards"] == []
103
+
104
+
105
  class TestPlanningPhase:
106
  """Tests for planning phase in CrewOrchestrator."""
107
 
tests/test_gap_analyzer.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/gap_analyzer.py — gap detection and question card generation."""
2
+
3
+ from agents.gap_analyzer import (
4
+ MissingItem, GapAnalysis, QuestionCard,
5
+ analyze_gaps, generate_question_cards,
6
+ )
7
+ from agents.design_state import DesignState
8
+
9
+
10
+ class TestAnalyzeGaps:
11
+ def test_no_gaps_when_no_not_ready(self):
12
+ responses = [
13
+ {"agent_id": "design", "message": "I suggest an L-bracket design."},
14
+ {"agent_id": "engineering", "message": "Aluminum 6061 would work well."},
15
+ ]
16
+ result = analyze_gaps(responses)
17
+ assert not result.has_gaps
18
+ assert result.missing_items == []
19
+
20
+ def test_detects_not_ready_from_cad(self):
21
+ responses = [
22
+ {"agent_id": "cad", "message": "NOT READY: Need dimensions (width, height) and material selection."},
23
+ ]
24
+ result = analyze_gaps(responses)
25
+ assert result.has_gaps
26
+ categories = [item.category for item in result.missing_items]
27
+ assert "dimension" in categories
28
+ assert "material" in categories
29
+
30
+ def test_detects_not_ready_from_cnc(self):
31
+ responses = [
32
+ {"agent_id": "cnc", "message": "NOT READY: Need material and tolerance info for manufacturability check."},
33
+ ]
34
+ result = analyze_gaps(responses)
35
+ assert result.has_gaps
36
+ categories = [item.category for item in result.missing_items]
37
+ assert "material" in categories
38
+ assert "constraint" in categories
39
+
40
+ def test_detects_not_ready_from_cam(self):
41
+ responses = [
42
+ {"agent_id": "cam", "message": "NOT READY: No 3D model available. Need CAD generation first."},
43
+ ]
44
+ result = analyze_gaps(responses)
45
+ assert result.has_gaps
46
+ categories = [item.category for item in result.missing_items]
47
+ assert "model" in categories
48
+
49
+ def test_deduplicates_across_agents(self):
50
+ responses = [
51
+ {"agent_id": "cad", "message": "NOT READY: Need material and dimensions."},
52
+ {"agent_id": "cnc", "message": "NOT READY: Material is required for manufacturability."},
53
+ ]
54
+ result = analyze_gaps(responses)
55
+ material_items = [i for i in result.missing_items if i.category == "material"]
56
+ assert len(material_items) == 1
57
+ assert material_items[0].agent_id == "cad"
58
+
59
+ def test_case_insensitive_not_ready(self):
60
+ responses = [
61
+ {"agent_id": "cad", "message": "not ready: missing width and height dimensions."},
62
+ ]
63
+ result = analyze_gaps(responses)
64
+ assert result.has_gaps
65
+ assert any(item.category == "dimension" for item in result.missing_items)
66
+
67
+ def test_no_false_positive_on_regular_message(self):
68
+ responses = [
69
+ {"agent_id": "cad", "message": "Model generated successfully."},
70
+ {"agent_id": "cnc", "message": "3-axis machining is ready for this part."},
71
+ ]
72
+ result = analyze_gaps(responses)
73
+ assert not result.has_gaps
74
+
75
+
76
+ class TestGenerateQuestionCards:
77
+ def test_generates_cards_from_gaps(self):
78
+ gaps = GapAnalysis(
79
+ has_gaps=True,
80
+ missing_items=[
81
+ MissingItem(category="material", description="material", agent_id="cad"),
82
+ MissingItem(category="dimension", description="width", agent_id="cad"),
83
+ ],
84
+ )
85
+ cards = generate_question_cards(gaps, DesignState())
86
+ assert len(cards) == 2
87
+ material_card = next(c for c in cards if c.category == "material")
88
+ assert material_card.responsible_agent == "engineering"
89
+ assert len(material_card.suggestions) > 0
90
+ assert material_card.allow_custom is True
91
+
92
+ def test_filters_out_already_set_material(self):
93
+ gaps = GapAnalysis(
94
+ has_gaps=True,
95
+ missing_items=[
96
+ MissingItem(category="material", description="material", agent_id="cad"),
97
+ MissingItem(category="dimension", description="width", agent_id="cad"),
98
+ ],
99
+ )
100
+ state = DesignState(material="aluminum 6061")
101
+ cards = generate_question_cards(gaps, state)
102
+ categories = [c.category for c in cards]
103
+ assert "material" not in categories
104
+ assert "dimension" in categories
105
+
106
+ def test_filters_out_already_set_dimensions(self):
107
+ gaps = GapAnalysis(
108
+ has_gaps=True,
109
+ missing_items=[
110
+ MissingItem(category="dimension", description="width", agent_id="cad"),
111
+ ],
112
+ )
113
+ state = DesignState(dimensions={"width": 60, "height": 40})
114
+ cards = generate_question_cards(gaps, state)
115
+ assert len(cards) == 0
116
+
117
+ def test_no_cards_when_no_gaps(self):
118
+ gaps = GapAnalysis(has_gaps=False, missing_items=[])
119
+ cards = generate_question_cards(gaps, DesignState())
120
+ assert cards == []
121
+
122
+ def test_card_has_agent_metadata(self):
123
+ gaps = GapAnalysis(
124
+ has_gaps=True,
125
+ missing_items=[
126
+ MissingItem(category="machining", description="axis", agent_id="cnc"),
127
+ ],
128
+ )
129
+ cards = generate_question_cards(gaps, DesignState())
130
+ assert len(cards) == 1
131
+ assert cards[0].responsible_agent == "cnc"
132
+ assert cards[0].agent_name == "CNC Agent"
133
+ assert cards[0].agent_color == "#00e676"
134
+
135
+ def test_model_category_no_suggestions(self):
136
+ gaps = GapAnalysis(
137
+ has_gaps=True,
138
+ missing_items=[
139
+ MissingItem(category="model", description="3D", agent_id="cam"),
140
+ ],
141
+ )
142
+ cards = generate_question_cards(gaps, DesignState())
143
+ assert len(cards) == 1
144
+ assert cards[0].suggestions == []
145
+ assert cards[0].allow_custom is False
tests/test_settings.py CHANGED
@@ -63,3 +63,18 @@ class TestPlanningConfig:
63
  def test_planning_threshold_from_yaml(self):
64
  from config.settings import settings
65
  assert settings.planning.threshold == 8.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def test_planning_threshold_from_yaml(self):
64
  from config.settings import settings
65
  assert settings.planning.threshold == 8.0
66
+
67
+
68
+ class TestGapAnalysisConfig:
69
+ def test_gap_analysis_defaults(self):
70
+ from config.settings import Settings
71
+ s = Settings()
72
+ assert "dimension" in s.gap_analysis.category_keywords
73
+ assert "material" in s.gap_analysis.category_keywords
74
+ assert s.gap_analysis.category_agents["dimension"] == "engineering"
75
+ assert s.gap_analysis.category_agents["shape"] == "design"
76
+
77
+ def test_gap_analysis_loaded_from_yaml(self):
78
+ from config.settings import settings
79
+ assert "width" in settings.gap_analysis.category_keywords.get("dimension", [])
80
+ assert settings.gap_analysis.category_agents["machining"] == "cnc"
web/index.html CHANGED
@@ -1316,6 +1316,55 @@
1316
  background: var(--bg-surface); color: var(--text-secondary);
1317
  }
1318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  /* ---- RESPONSIVE ---- */
1320
 
1321
  @media (max-width: 768px) {
@@ -1850,6 +1899,82 @@ async function rejectPlanCard() {
1850
  }
1851
  }
1852
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1853
  // ── i18n ─────────────────────────────────────────────
1854
 
1855
  const I18N = {
@@ -2392,6 +2517,7 @@ function toggleChat() {
2392
 
2393
  async function sendMessage(text) {
2394
  if (!text.trim()) return;
 
2395
 
2396
  // Parse @mentions
2397
  const mentions = [];
@@ -2463,6 +2589,16 @@ async function sendMessage(text) {
2463
  msgs.scrollTop = msgs.scrollHeight;
2464
  }
2465
 
 
 
 
 
 
 
 
 
 
 
2466
  // If preview available, load 3D model
2467
  if (data.preview && data.preview.success) {
2468
  setViewerLoading(true, t('loadingModel'));
 
1316
  background: var(--bg-surface); color: var(--text-secondary);
1317
  }
1318
 
1319
+ /* ---- GAP / QUESTION CARDS ---- */
1320
+ .gap-cards {
1321
+ background: var(--bg-surface);
1322
+ border: 1px solid var(--border);
1323
+ border-radius: 8px;
1324
+ padding: 12px;
1325
+ margin: 8px 0;
1326
+ }
1327
+ .gap-cards-title {
1328
+ font-family: var(--font-mono);
1329
+ font-size: 11px;
1330
+ font-weight: 700;
1331
+ color: var(--warning);
1332
+ margin-bottom: 10px;
1333
+ }
1334
+ .gap-card {
1335
+ padding: 10px;
1336
+ margin-bottom: 8px;
1337
+ border-left: 3px solid var(--border);
1338
+ background: var(--bg-input);
1339
+ border-radius: 0 6px 6px 0;
1340
+ }
1341
+ .gap-card:last-child { margin-bottom: 0; }
1342
+ .gap-card-header {
1343
+ display: flex; align-items: center; gap: 6px;
1344
+ margin-bottom: 6px;
1345
+ }
1346
+ .gap-card-dot {
1347
+ width: 8px; height: 8px; border-radius: 50%;
1348
+ flex-shrink: 0;
1349
+ }
1350
+ .gap-card-agent {
1351
+ font-family: var(--font-mono); font-size: 10px;
1352
+ color: var(--text-secondary); font-weight: 600;
1353
+ }
1354
+ .gap-card-question {
1355
+ font-family: var(--font-body); font-size: 12px;
1356
+ color: var(--text-primary); margin-bottom: 8px;
1357
+ }
1358
+ .gap-card .wizard-chips { margin-bottom: 0; }
1359
+ .gap-card .wizard-dim-row { margin-top: 4px; }
1360
+ .gap-card-submit {
1361
+ margin-top: 8px; padding: 5px 14px;
1362
+ background: var(--accent); color: var(--bg-void);
1363
+ border: none; border-radius: 4px;
1364
+ font-family: var(--font-mono); font-size: 11px;
1365
+ font-weight: 600; cursor: pointer;
1366
+ }
1367
+
1368
  /* ---- RESPONSIVE ---- */
1369
 
1370
  @media (max-width: 768px) {
 
1899
  }
1900
  }
1901
 
1902
+ // ── QUESTION CARDS ────────────────────────────────────
1903
+
1904
+ function renderQuestionCards(cards) {
1905
+ if (!cards || cards.length === 0) return '';
1906
+ let html = '<div class="gap-cards" id="active-gap-cards">';
1907
+ html += '<div class="gap-cards-title">\u26a0 MISSING INFO</div>';
1908
+ for (const card of cards) {
1909
+ html += '<div class="gap-card" style="border-left-color:' + escapeHtml(card.agent_color) + ';">';
1910
+ html += '<div class="gap-card-header">';
1911
+ html += '<div class="gap-card-dot" style="background:' + escapeHtml(card.agent_color) + ';"></div>';
1912
+ html += '<span class="gap-card-agent">' + escapeHtml(card.agent_name) + '</span>';
1913
+ html += '</div>';
1914
+ html += '<div class="gap-card-question">' + escapeHtml(card.question) + '</div>';
1915
+
1916
+ if (card.category === 'dimension') {
1917
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Width</span><input class="wizard-dim-input gap-dim" id="gap-dim-width" type="number"><span class="wizard-dim-unit">mm</span></div>';
1918
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Height</span><input class="wizard-dim-input gap-dim" id="gap-dim-height" type="number"><span class="wizard-dim-unit">mm</span></div>';
1919
+ html += '<div class="wizard-dim-row"><span class="wizard-dim-label">Depth</span><input class="wizard-dim-input gap-dim" id="gap-dim-depth" type="number"><span class="wizard-dim-unit">mm</span></div>';
1920
+ html += '<button class="gap-card-submit" onclick="gapSubmitDimensions()">Submit</button>';
1921
+ } else if (card.suggestions && card.suggestions.length > 0) {
1922
+ html += '<div class="wizard-chips">';
1923
+ for (const s of card.suggestions) {
1924
+ html += '<button class="wizard-chip" onclick="gapSelectChip(\'' + escapeHtml(s) + '\',\'' + escapeHtml(card.category) + '\')">' + escapeHtml(s) + '</button>';
1925
+ }
1926
+ if (card.allow_custom) {
1927
+ html += '</div><input class="wizard-input" placeholder="Or type custom..." onkeydown="if(event.key===\'Enter\')gapSelectChip(this.value,\'' + escapeHtml(card.category) + '\')">';
1928
+ } else {
1929
+ html += '</div>';
1930
+ }
1931
+ }
1932
+ html += '</div>';
1933
+ }
1934
+ html += '</div>';
1935
+ return html;
1936
+ }
1937
+
1938
+ function removeGapCards() {
1939
+ const el = document.getElementById('active-gap-cards');
1940
+ if (el) el.remove();
1941
+ }
1942
+
1943
+ function gapSelectChip(value, category) {
1944
+ if (!value.trim()) return;
1945
+ if (category === 'material') designState.material = value;
1946
+ else if (category === 'shape') { designState.part_name = value; designState.description = value; }
1947
+ else if (category === 'machining') designState.axis_recommendation = value;
1948
+ else if (category === 'constraint') {
1949
+ if (!designState.constraints) designState.constraints = [];
1950
+ designState.constraints.push(value);
1951
+ } else if (category === 'feature') {
1952
+ if (!designState.features) designState.features = [];
1953
+ designState.features.push(value);
1954
+ } else if (category === 'finish') {
1955
+ if (!designState.constraints) designState.constraints = [];
1956
+ designState.constraints.push('surface finish: ' + value);
1957
+ }
1958
+ saveState();
1959
+ removeGapCards();
1960
+ sendMessage(value);
1961
+ }
1962
+
1963
+ function gapSubmitDimensions() {
1964
+ const w = document.getElementById('gap-dim-width')?.value;
1965
+ const h = document.getElementById('gap-dim-height')?.value;
1966
+ const d = document.getElementById('gap-dim-depth')?.value;
1967
+ if (!designState.dimensions) designState.dimensions = {};
1968
+ const parts = [];
1969
+ if (w) { designState.dimensions.width = parseFloat(w); parts.push(w + 'mm wide'); }
1970
+ if (h) { designState.dimensions.height = parseFloat(h); parts.push(h + 'mm high'); }
1971
+ if (d) { designState.dimensions.depth = parseFloat(d); parts.push(d + 'mm deep'); }
1972
+ if (parts.length === 0) return;
1973
+ saveState();
1974
+ removeGapCards();
1975
+ sendMessage(parts.join(', '));
1976
+ }
1977
+
1978
  // ── i18n ─────────────────────────────────────────────
1979
 
1980
  const I18N = {
 
2517
 
2518
  async function sendMessage(text) {
2519
  if (!text.trim()) return;
2520
+ removeGapCards();
2521
 
2522
  // Parse @mentions
2523
  const mentions = [];
 
2589
  msgs.scrollTop = msgs.scrollHeight;
2590
  }
2591
 
2592
+ // If question cards are present, render them
2593
+ if (data.question_cards && data.question_cards.length > 0) {
2594
+ removeGapCards();
2595
+ const msgs = document.getElementById('chat-messages');
2596
+ const cardDiv = document.createElement('div');
2597
+ cardDiv.innerHTML = renderQuestionCards(data.question_cards);
2598
+ msgs.appendChild(cardDiv.firstChild);
2599
+ msgs.scrollTop = msgs.scrollHeight;
2600
+ }
2601
+
2602
  // If preview available, load 3D model
2603
  if (data.preview && data.preview.success) {
2604
  setViewerLoading(true, t('loadingModel'));