Spaces:
Sleeping
Sleeping
Merge pull request #7 from danghoangnhan/feat/smart-gap-analysis
Browse filesfeat: smart gap analysis with question cards for NOT READY agents
- agents/crew_orchestrator.py +44 -10
- agents/gap_analyzer.py +195 -0
- config.yaml +62 -0
- config/settings.py +24 -0
- docs/superpowers/plans/2026-04-12-smart-gap-analysis.md +963 -0
- tests/test_crew_orchestrator.py +31 -0
- tests/test_gap_analyzer.py +145 -0
- tests/test_settings.py +15 -0
- web/index.html +136 -0
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\
|
| 263 |
-
"
|
| 264 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
)
|
| 266 |
else:
|
| 267 |
task_description += (
|
|
@@ -269,11 +283,12 @@ class CrewOrchestrator(BaseOrchestrator):
|
|
| 269 |
"SPECIFIC clarifying question."
|
| 270 |
)
|
| 271 |
|
| 272 |
-
|
| 273 |
-
"
|
| 274 |
-
|
| 275 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'));
|