Daniel Tu commited on
Commit
ca5b1f7
·
unverified ·
2 Parent(s): 8a01e0033f166d

Merge pull request #3 from danghoangnhan/feat/oop-config-foundation

Browse files
.env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # API keys (copy to .env and fill in)
2
+ ANTHROPIC_API_KEY=
3
+ OPENAI_API_KEY=
4
+ GOOGLE_API_KEY=
5
+
6
+ # Optional overrides
7
+ # NEURALCAD_OUTPUT_DIR=./output
8
+ # NEURALCAD_WEB_PORT=5000
9
+ # NEURALCAD_MCP_PORT=8000
agents/orchestrator.py CHANGED
@@ -28,6 +28,7 @@ from agents.prompts import (
28
  from agents.design_state import DesignState, extract_decisions
29
  from core.backends import LLMBackend, MockBackend
30
  from core.executor import execute_cadquery, export_all
 
31
  from core.validator import validate_for_cnc
32
 
33
 
@@ -112,23 +113,8 @@ def _execute_cad_code(
112
  "part_name": part_name,
113
  "stl_url": f"/api/models/{part_name}.stl",
114
  "step_url": f"/api/models/{part_name}.step",
115
- "execution": {
116
- "success": True,
117
- "volume_mm3": exec_result.volume,
118
- "bounding_box_mm": list(exec_result.bounding_box),
119
- "face_count": exec_result.face_count,
120
- "edge_count": exec_result.edge_count,
121
- },
122
- "validation": {
123
- "machinable": validation.machinable,
124
- "axis_recommendation": validation.axis_recommendation,
125
- "error_count": validation.error_count,
126
- "warning_count": validation.warning_count,
127
- "issues": [
128
- {"severity": i.severity, "category": i.category, "message": i.message}
129
- for i in validation.issues
130
- ],
131
- },
132
  }
133
 
134
 
 
28
  from agents.design_state import DesignState, extract_decisions
29
  from core.backends import LLMBackend, MockBackend
30
  from core.executor import execute_cadquery, export_all
31
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
32
  from core.validator import validate_for_cnc
33
 
34
 
 
113
  "part_name": part_name,
114
  "stl_url": f"/api/models/{part_name}.stl",
115
  "step_url": f"/api/models/{part_name}.step",
116
+ "execution": ExecutionResultSerializer.to_dict(exec_result),
117
+ "validation": ValidationResultSerializer.to_dict(validation),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  }
119
 
120
 
config.yaml ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server:
2
+ web_port: 5000
3
+ mcp_port: 8000
4
+ mcp_name: text-to-cnc
5
+ cors_origins: ["*"]
6
+ mcp_startup_wait_seconds: 2
7
+
8
+ paths:
9
+ output_dir: ./output
10
+ web_dir: ./web
11
+ prompts_dir: ./agents/prompts
12
+
13
+ backends:
14
+ default: mock
15
+ models:
16
+ anthropic: claude-sonnet-4-20250514
17
+ openai: gpt-4o
18
+ gemini: gemini-2.5-flash
19
+ max_tokens: 4096
20
+ temperature: 0.2
21
+
22
+ orchestration:
23
+ max_history: 30
24
+ max_active_agents: 3
25
+ max_retries: 2
26
+ max_decisions: 20
27
+ max_recent_decisions: 5
28
+ part_name_max_chars: 40
29
+
30
+ validation:
31
+ min_wall_thickness_mm: 1.5
32
+ min_fillet_radius_mm: 1.0
33
+ max_pocket_depth_ratio: 4.0
34
+ max_part_size_mm: 500.0
35
+ min_part_size_mm: 1.0
36
+ min_hole_diameter_mm: 1.0
37
+ complexity_thresholds:
38
+ five_axis_faces: 100
39
+ three_plus_two_faces: 50
40
+
41
+ export:
42
+ stl_tolerance: 0.01
43
+
44
+ agents:
45
+ design:
46
+ name: Design Agent
47
+ role: Industrial Designer
48
+ color: "#7c3aed"
49
+ avatar: DA
50
+ goal: >
51
+ Understand the user's intent and propose optimal form factors,
52
+ shapes, and aesthetic choices for mechanical parts.
53
+ backstory: >
54
+ You are an experienced industrial designer specializing in mechanical
55
+ parts. You think about form, function, ergonomics, and visual appeal.
56
+ You ask clarifying questions about the part's purpose, environment,
57
+ and constraints before proposing designs. You suggest shapes,
58
+ proportions, and features that balance aesthetics with manufacturability.
59
+ engineering:
60
+ name: Engineering Agent
61
+ role: Mechanical Engineer
62
+ color: "#00b4d8"
63
+ avatar: EA
64
+ goal: >
65
+ Ensure parts are structurally sound with correct dimensions,
66
+ tolerances, materials, and fastener specifications.
67
+ backstory: >
68
+ You are a senior mechanical engineer with deep knowledge of materials
69
+ science, stress analysis, and fastener standards. You specify wall
70
+ thicknesses, fillet radii, clearance holes (M3=3.4mm, M4=4.5mm,
71
+ M5=5.5mm, M6=6.6mm, M8=9.0mm), and material recommendations. You
72
+ flag structural concerns and suggest reinforcements like ribs or
73
+ gussets when loads are significant.
74
+ cnc:
75
+ name: CNC Agent
76
+ role: CNC Manufacturing Advisor
77
+ color: "#00e676"
78
+ avatar: CA
79
+ goal: >
80
+ Advise on manufacturability: tool access, wall thickness limits,
81
+ pocket ratios, axis requirements, and cost implications.
82
+ backstory: >
83
+ You are a CNC machinist with 20 years of shop floor experience.
84
+ You know what tool geometries can reach, what aspect ratios cause
85
+ chatter, and when to recommend 3-axis vs 3+2 vs 5-axis. You flag
86
+ undercuts, thin walls (<1.5mm), deep pockets (>4:1 ratio), and
87
+ features that need special fixturing. You think about setup count
88
+ and machining time.
89
+ cad:
90
+ name: CAD Coder
91
+ role: CadQuery Code Generator
92
+ color: "#ffab40"
93
+ avatar: CC
94
+ goal: >
95
+ Generate valid CadQuery Python code that produces the agreed-upon 3D model.
96
+ backstory: >
97
+ You are an expert CadQuery programmer. You only speak when asked to
98
+ generate a preview or produce code. You take the design specifications
99
+ agreed upon by the team and translate them into precise CadQuery Python
100
+ code. Your code always assigns the result to a variable called `result`
101
+ as a cq.Workplane object.
102
+
103
+ routing:
104
+ cad_trigger_keywords:
105
+ - generate
106
+ - build
107
+ - build it
108
+ - preview
109
+ - show me
110
+ - create
111
+ - create the model
112
+ - model it
113
+ - render
114
+ - code
115
+ - make it
116
+ - produce
117
+ keywords:
118
+ design:
119
+ - design
120
+ - look
121
+ - shape
122
+ - style
123
+ - form
124
+ - aesthetic
125
+ - appearance
126
+ - layout
127
+ - concept
128
+ - idea
129
+ - propose
130
+ - suggest
131
+ - bracket
132
+ - mount
133
+ - enclosure
134
+ - housing
135
+ - ergonomic
136
+ - profile
137
+ - contour
138
+ engineering:
139
+ - dimension
140
+ - tolerance
141
+ - material
142
+ - strength
143
+ - load
144
+ - stress
145
+ - thickness
146
+ - wall
147
+ - fillet
148
+ - radius
149
+ - clearance
150
+ - m2
151
+ - m3
152
+ - m4
153
+ - m5
154
+ - m6
155
+ - m8
156
+ - m10
157
+ - m12
158
+ - aluminum
159
+ - steel
160
+ - brass
161
+ - titanium
162
+ - nylon
163
+ - gear
164
+ - bearing
165
+ - flange
166
+ - heatsink
167
+ - fin
168
+ - rib
169
+ - bolt
170
+ - screw
171
+ - thread
172
+ - torque
173
+ - deflection
174
+ - hole
175
+ - bore
176
+ - shaft
177
+ - keyway
178
+ - spline
179
+ cnc:
180
+ - machine
181
+ - mill
182
+ - cnc
183
+ - manufacture
184
+ - machinable
185
+ - axis
186
+ - tool
187
+ - fixture
188
+ - setup
189
+ - pocket
190
+ - undercut
191
+ - access
192
+ - 3-axis
193
+ - 5-axis
194
+ - cost
195
+ - surface finish
196
+ - roughness
197
+ - endmill
198
+ - drill
199
+ - tap
200
+ - chamfer tool
201
+ - deburr
202
+ - setup count
203
+ - cycle time
204
+ - tolerance class
205
+
206
+ materials:
207
+ - aluminum
208
+ - aluminium
209
+ - steel
210
+ - stainless steel
211
+ - brass
212
+ - copper
213
+ - titanium
214
+ - nylon
215
+ - delrin
216
+ - acetal
217
+ - abs
218
+ - polycarbonate
219
+ - peek
220
+
221
+ material_grades:
222
+ "6061": aluminum 6061
223
+ "7075": aluminum 7075
224
+ "304": stainless steel 304
225
+ "316": stainless steel 316
226
+ t6: aluminum 6061-T6
227
+
228
+ dimension_contexts:
229
+ wide: width
230
+ width: width
231
+ tall: height
232
+ height: height
233
+ high: height
234
+ thick: thickness
235
+ thickness: thickness
236
+ deep: depth
237
+ depth: depth
238
+ long: length
239
+ length: length
240
+ diameter: diameter
241
+ dia: diameter
242
+ radius: radius
243
+ arm: arm_length
244
+
245
+ fasteners:
246
+ M2: 2.4
247
+ M3: 3.4
248
+ M4: 4.5
249
+ M5: 5.5
250
+ M6: 6.6
251
+ M8: 9.0
252
+ M10: 11.0
253
+ M12: 13.5
254
+
255
+ fallback_messages:
256
+ design: "I'd love to help shape this design. Could you describe the part's purpose and any size constraints?"
257
+ engineering: "I can help with the structural details. What material and load conditions are we working with?"
258
+ cnc: "I'll check manufacturability once we have more design details. Any machining preferences (3-axis, 5-axis)?"
259
+ cad: "I'm ready to generate the model once the design is agreed upon. Say 'preview' when you're ready."
config/__init__.py ADDED
File without changes
config/settings.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized configuration — single source of truth for all NeuralCAD settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from pydantic import Field
10
+ from pydantic_settings import BaseSettings
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ """Loads .env for secrets, then overlays config.yaml for app config."""
15
+
16
+ # .env secrets
17
+ anthropic_api_key: str = ""
18
+ openai_api_key: str = ""
19
+ google_api_key: str = ""
20
+
21
+ # Overridable via env vars
22
+ neuralcad_output_dir: str = ""
23
+ neuralcad_web_port: int = 0
24
+ neuralcad_mcp_port: int = 0
25
+
26
+ # Loaded from config.yaml
27
+ server: dict[str, Any] = Field(default_factory=dict)
28
+ paths: dict[str, Any] = Field(default_factory=dict)
29
+ backends: dict[str, Any] = Field(default_factory=dict)
30
+ orchestration: dict[str, Any] = Field(default_factory=dict)
31
+ validation: dict[str, Any] = Field(default_factory=dict)
32
+ export: dict[str, Any] = Field(default_factory=dict)
33
+ agents: dict[str, Any] = Field(default_factory=dict)
34
+ routing: dict[str, Any] = Field(default_factory=dict)
35
+ materials: list[str] = Field(default_factory=list)
36
+ material_grades: dict[str, str] = Field(default_factory=dict)
37
+ dimension_contexts: dict[str, str] = Field(default_factory=dict)
38
+ fasteners: dict[str, float] = Field(default_factory=dict)
39
+ fallback_messages: dict[str, str] = Field(default_factory=dict)
40
+
41
+ model_config = {"env_file": ".env", "extra": "ignore"}
42
+
43
+ def model_post_init(self, __context: Any) -> None:
44
+ config_path = Path(__file__).parent.parent / "config.yaml"
45
+ if config_path.exists():
46
+ with open(config_path) as f:
47
+ data = yaml.safe_load(f) or {}
48
+ for key, value in data.items():
49
+ if hasattr(self, key):
50
+ current = getattr(self, key)
51
+ if not current or (isinstance(current, (dict, list)) and len(current) == 0):
52
+ object.__setattr__(self, key, value)
53
+
54
+ @property
55
+ def output_dir(self) -> Path:
56
+ if self.neuralcad_output_dir:
57
+ return Path(self.neuralcad_output_dir)
58
+ return Path(self.paths.get("output_dir", "./output"))
59
+
60
+ @property
61
+ def web_port(self) -> int:
62
+ if self.neuralcad_web_port:
63
+ return self.neuralcad_web_port
64
+ return self.server.get("web_port", 5000)
65
+
66
+ @property
67
+ def mcp_port(self) -> int:
68
+ if self.neuralcad_mcp_port:
69
+ return self.neuralcad_mcp_port
70
+ return self.server.get("mcp_port", 8000)
71
+
72
+ @property
73
+ def default_backend(self) -> str:
74
+ return self.backends.get("default", "mock")
75
+
76
+ @property
77
+ def model_for(self) -> dict[str, str]:
78
+ return self.backends.get("models", {})
79
+
80
+ @property
81
+ def max_tokens(self) -> int:
82
+ return self.backends.get("max_tokens", 4096)
83
+
84
+ @property
85
+ def temperature(self) -> float:
86
+ return self.backends.get("temperature", 0.2)
87
+
88
+
89
+ settings = Settings()
core/backend_factory.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend factory — centralized creation of LLM backends.
2
+
3
+ Replaces scattered if/elif backend selection across mcp.py, routes.py, web.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+
10
+ from core.types import LLMBackend
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class BackendFactory:
16
+ """Registry and factory for LLM backends."""
17
+
18
+ _registry: dict[str, type[LLMBackend]] = {}
19
+
20
+ @classmethod
21
+ def register(cls, name: str, backend_cls: type[LLMBackend]) -> None:
22
+ cls._registry[name] = backend_cls
23
+
24
+ @classmethod
25
+ def create(cls, name: str, **kwargs) -> LLMBackend:
26
+ if name not in cls._registry:
27
+ raise ValueError(f"Unknown backend: {name!r}. Available: {list(cls._registry.keys())}")
28
+ return cls._registry[name](**kwargs)
29
+
30
+ @classmethod
31
+ def create_safe(cls, name: str, **kwargs) -> LLMBackend:
32
+ """Create backend, falling back to mock on failure."""
33
+ try:
34
+ return cls.create(name, **kwargs)
35
+ except Exception as exc:
36
+ logger.warning("Backend %r unavailable (%s), falling back to mock", name, exc)
37
+ return cls.create("mock")
38
+
39
+
40
+ def _register_defaults() -> None:
41
+ """Register all built-in backends. Called at module load."""
42
+ from core.backends import MockBackend
43
+ BackendFactory.register("mock", MockBackend)
44
+
45
+ try:
46
+ from core.backends import AnthropicBackend
47
+ BackendFactory.register("anthropic", AnthropicBackend)
48
+ except Exception:
49
+ pass
50
+
51
+ try:
52
+ from core.backends import OpenAIBackend
53
+ BackendFactory.register("openai", OpenAIBackend)
54
+ except Exception:
55
+ pass
56
+
57
+ try:
58
+ from core.backends import GeminiBackend
59
+ BackendFactory.register("gemini", GeminiBackend)
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ _register_defaults()
core/backends.py CHANGED
@@ -14,92 +14,49 @@ import mimetypes
14
  import os
15
  import re
16
  from pathlib import Path
17
- from typing import Optional
18
 
 
19
 
20
- # ── LLM Backends ──────────────────────────────────────────────────────────
21
-
22
-
23
- class LLMBackend:
24
- """Base class for LLM code generation backends."""
25
-
26
- def generate(self, messages: list[dict]) -> str:
27
- raise NotImplementedError
28
 
29
- def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
30
- """Generate code from messages that include an image.
31
- Override in backends that support vision."""
32
- raise NotImplementedError(
33
- f"{self.__class__.__name__} does not support image input"
34
- )
35
 
36
 
37
  class AnthropicBackend(LLMBackend):
38
  """Generate CadQuery code using Anthropic Claude."""
39
 
40
- def __init__(
41
- self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None
42
- ):
43
  import anthropic
44
-
45
- self.client = anthropic.Anthropic(
46
- api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
47
- )
48
- self.model = model
49
 
50
  def generate(self, messages: list[dict]) -> str:
51
- # Anthropic uses system param separately
52
- system_msg = ""
53
- user_messages = []
54
- for m in messages:
55
- if m["role"] == "system":
56
- system_msg = m["content"]
57
- else:
58
- user_messages.append(m)
59
-
60
  response = self.client.messages.create(
61
  model=self.model,
62
- max_tokens=4096,
63
  system=system_msg,
64
  messages=user_messages,
65
  )
66
  return response.content[0].text
67
 
68
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
 
69
  image_path = Path(image_path)
70
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
71
  image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
72
-
73
- system_msg = ""
74
- user_messages = []
75
- for m in messages:
76
- if m["role"] == "system":
77
- system_msg = m["content"]
78
- else:
79
- msg = dict(m)
80
- # Inject image into the last user message
81
- if msg["role"] == "user" and msg is not m:
82
- user_messages.append(msg)
83
- else:
84
- user_messages.append(msg)
85
-
86
  # Replace last user message content with multimodal blocks
87
  last_user = user_messages[-1]
88
  last_user["content"] = [
89
- {
90
- "type": "image",
91
- "source": {
92
- "type": "base64",
93
- "media_type": media_type,
94
- "data": image_data,
95
- },
96
- },
97
  {"type": "text", "text": last_user["content"]},
98
  ]
99
-
100
  response = self.client.messages.create(
101
  model=self.model,
102
- max_tokens=4096,
103
  system=system_msg,
104
  messages=user_messages,
105
  )
@@ -109,22 +66,25 @@ class AnthropicBackend(LLMBackend):
109
  class OpenAIBackend(LLMBackend):
110
  """Generate CadQuery code using OpenAI GPT-4o."""
111
 
112
- def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
113
  import openai
114
-
115
- self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
116
- self.model = model
 
117
 
118
  def generate(self, messages: list[dict]) -> str:
 
119
  response = self.client.chat.completions.create(
120
  model=self.model,
121
  messages=messages,
122
- max_tokens=4096,
123
- temperature=0.2,
124
  )
125
  return response.choices[0].message.content
126
 
127
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
 
128
  image_path = Path(image_path)
129
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
130
  image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
@@ -141,8 +101,8 @@ class OpenAIBackend(LLMBackend):
141
  response = self.client.chat.completions.create(
142
  model=self.model,
143
  messages=patched,
144
- max_tokens=4096,
145
- temperature=0.2,
146
  )
147
  return response.choices[0].message.content
148
 
@@ -150,50 +110,46 @@ class OpenAIBackend(LLMBackend):
150
  class GeminiBackend(LLMBackend):
151
  """Generate CadQuery code using Google Gemini (free tier available)."""
152
 
153
- def __init__(self, model: str = "gemini-2.5-flash", api_key: Optional[str] = None):
154
  from google import genai
155
-
156
- self.client = genai.Client(api_key=api_key or os.environ.get("GEMINI_API_KEY"))
157
- self.model = model
 
158
 
159
  def generate(self, messages: list[dict]) -> str:
160
- # Convert messages to Gemini format: system instruction + contents
161
- system_msg = ""
 
162
  contents = []
163
- for m in messages:
164
- if m["role"] == "system":
165
- system_msg = m["content"]
166
- elif m["role"] == "user":
167
  contents.append({"role": "user", "parts": [{"text": m["content"]}]})
168
  elif m["role"] == "assistant":
169
  contents.append({"role": "model", "parts": [{"text": m["content"]}]})
170
-
171
- from google.genai import types
172
-
173
  response = self.client.models.generate_content(
174
  model=self.model,
175
  contents=contents,
176
  config=types.GenerateContentConfig(
177
  system_instruction=system_msg,
178
- max_output_tokens=4096,
179
- temperature=0.2,
180
  ),
181
  )
182
  return response.text
183
 
184
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
 
185
  from google.genai import types
186
 
187
  image_path = Path(image_path)
188
  image_data = image_path.read_bytes()
189
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
190
 
191
- system_msg = ""
192
  contents = []
193
- for m in messages:
194
- if m["role"] == "system":
195
- system_msg = m["content"]
196
- elif m["role"] == "user":
197
  contents.append({"role": "user", "parts": [{"text": m["content"]}]})
198
  elif m["role"] == "assistant":
199
  contents.append({"role": "model", "parts": [{"text": m["content"]}]})
@@ -209,8 +165,8 @@ class GeminiBackend(LLMBackend):
209
  contents=contents,
210
  config=types.GenerateContentConfig(
211
  system_instruction=system_msg,
212
- max_output_tokens=4096,
213
- temperature=0.2,
214
  ),
215
  )
216
  return response.text
@@ -240,18 +196,6 @@ class MockBackend(LLMBackend):
240
  "twenty": 20,
241
  }
242
 
243
- # Metric thread clearance hole diameters
244
- _THREAD_CLEARANCE = {
245
- "m2": 2.4,
246
- "m3": 3.4,
247
- "m4": 4.5,
248
- "m5": 5.5,
249
- "m6": 6.6,
250
- "m8": 9.0,
251
- "m10": 11.0,
252
- "m12": 13.5,
253
- }
254
-
255
  # Shape detection patterns → base shape key
256
  _SHAPE_PATTERNS = {
257
  "cylinder": [
@@ -312,6 +256,11 @@ class MockBackend(LLMBackend):
312
  "boss": ["boss", "bosses", "standoff", "standoffs", "pillar"],
313
  }
314
 
 
 
 
 
 
315
  def _parse_prompt(self, text: str) -> dict:
316
  """Extract dimensions, shape, and features from natural language."""
317
  lower = text.lower()
@@ -325,7 +274,7 @@ class MockBackend(LLMBackend):
325
  hole_dia = None
326
  if thread_match:
327
  key = f"m{thread_match.group(1)}"
328
- hole_dia = self._THREAD_CLEARANCE.get(
329
  key, float(thread_match.group(1)) * 1.1
330
  )
331
 
 
14
  import os
15
  import re
16
  from pathlib import Path
 
17
 
18
+ from core.types import LLMBackend
19
 
 
 
 
 
 
 
 
 
20
 
21
+ # ── LLM Backends ──────────────────────────────────────────────────────────
 
 
 
 
 
22
 
23
 
24
  class AnthropicBackend(LLMBackend):
25
  """Generate CadQuery code using Anthropic Claude."""
26
 
27
+ def __init__(self, model: str | None = None, api_key: str | None = None):
 
 
28
  import anthropic
29
+ from config.settings import settings
30
+ self.model = model or settings.model_for.get("anthropic", "claude-sonnet-4-20250514")
31
+ key = api_key or settings.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")
32
+ self.client = anthropic.Anthropic(api_key=key)
 
33
 
34
  def generate(self, messages: list[dict]) -> str:
35
+ from config.settings import settings
36
+ system_msg, user_messages = self.split_system_message(messages)
 
 
 
 
 
 
 
37
  response = self.client.messages.create(
38
  model=self.model,
39
+ max_tokens=settings.max_tokens,
40
  system=system_msg,
41
  messages=user_messages,
42
  )
43
  return response.content[0].text
44
 
45
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
46
+ from config.settings import settings
47
  image_path = Path(image_path)
48
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
49
  image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
50
+ system_msg, user_messages = self.split_system_message(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  # Replace last user message content with multimodal blocks
52
  last_user = user_messages[-1]
53
  last_user["content"] = [
54
+ {"type": "image", "source": {"type": "base64", "media_type": media_type, "data": image_data}},
 
 
 
 
 
 
 
55
  {"type": "text", "text": last_user["content"]},
56
  ]
 
57
  response = self.client.messages.create(
58
  model=self.model,
59
+ max_tokens=settings.max_tokens,
60
  system=system_msg,
61
  messages=user_messages,
62
  )
 
66
  class OpenAIBackend(LLMBackend):
67
  """Generate CadQuery code using OpenAI GPT-4o."""
68
 
69
+ def __init__(self, model: str | None = None, api_key: str | None = None):
70
  import openai
71
+ from config.settings import settings
72
+ self.model = model or settings.model_for.get("openai", "gpt-4o")
73
+ key = api_key or settings.openai_api_key or os.environ.get("OPENAI_API_KEY")
74
+ self.client = openai.OpenAI(api_key=key)
75
 
76
  def generate(self, messages: list[dict]) -> str:
77
+ from config.settings import settings
78
  response = self.client.chat.completions.create(
79
  model=self.model,
80
  messages=messages,
81
+ max_tokens=settings.max_tokens,
82
+ temperature=settings.temperature,
83
  )
84
  return response.choices[0].message.content
85
 
86
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
87
+ from config.settings import settings
88
  image_path = Path(image_path)
89
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
90
  image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
 
101
  response = self.client.chat.completions.create(
102
  model=self.model,
103
  messages=patched,
104
+ max_tokens=settings.max_tokens,
105
+ temperature=settings.temperature,
106
  )
107
  return response.choices[0].message.content
108
 
 
110
  class GeminiBackend(LLMBackend):
111
  """Generate CadQuery code using Google Gemini (free tier available)."""
112
 
113
+ def __init__(self, model: str | None = None, api_key: str | None = None):
114
  from google import genai
115
+ from config.settings import settings
116
+ self.model = model or settings.model_for.get("gemini", "gemini-2.5-flash")
117
+ key = api_key or settings.google_api_key or os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
118
+ self.client = genai.Client(api_key=key)
119
 
120
  def generate(self, messages: list[dict]) -> str:
121
+ from config.settings import settings
122
+ from google.genai import types
123
+ system_msg, other_messages = self.split_system_message(messages)
124
  contents = []
125
+ for m in other_messages:
126
+ if m["role"] == "user":
 
 
127
  contents.append({"role": "user", "parts": [{"text": m["content"]}]})
128
  elif m["role"] == "assistant":
129
  contents.append({"role": "model", "parts": [{"text": m["content"]}]})
 
 
 
130
  response = self.client.models.generate_content(
131
  model=self.model,
132
  contents=contents,
133
  config=types.GenerateContentConfig(
134
  system_instruction=system_msg,
135
+ max_output_tokens=settings.max_tokens,
136
+ temperature=settings.temperature,
137
  ),
138
  )
139
  return response.text
140
 
141
  def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
142
+ from config.settings import settings
143
  from google.genai import types
144
 
145
  image_path = Path(image_path)
146
  image_data = image_path.read_bytes()
147
  media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
148
 
149
+ system_msg, other_messages = self.split_system_message(messages)
150
  contents = []
151
+ for m in other_messages:
152
+ if m["role"] == "user":
 
 
153
  contents.append({"role": "user", "parts": [{"text": m["content"]}]})
154
  elif m["role"] == "assistant":
155
  contents.append({"role": "model", "parts": [{"text": m["content"]}]})
 
165
  contents=contents,
166
  config=types.GenerateContentConfig(
167
  system_instruction=system_msg,
168
+ max_output_tokens=settings.max_tokens,
169
+ temperature=settings.temperature,
170
  ),
171
  )
172
  return response.text
 
196
  "twenty": 20,
197
  }
198
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  # Shape detection patterns → base shape key
200
  _SHAPE_PATTERNS = {
201
  "cylinder": [
 
256
  "boss": ["boss", "bosses", "standoff", "standoffs", "pillar"],
257
  }
258
 
259
+ @property
260
+ def _thread_clearance(self) -> dict[str, float]:
261
+ from config.settings import settings
262
+ return settings.fasteners
263
+
264
  def _parse_prompt(self, text: str) -> dict:
265
  """Extract dimensions, shape, and features from natural language."""
266
  lower = text.lower()
 
274
  hole_dia = None
275
  if thread_match:
276
  key = f"m{thread_match.group(1)}"
277
+ hole_dia = self._thread_clearance.get(
278
  key, float(thread_match.group(1)) * 1.1
279
  )
280
 
core/serializers.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serializers for execution and validation results.
2
+
3
+ Eliminates duplicated dict-building code across mcp.py and orchestrator.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class ExecutionResultSerializer:
10
+ """Serialize ExecutionResult to JSON-ready dict."""
11
+
12
+ @staticmethod
13
+ def to_dict(result) -> dict:
14
+ return {
15
+ "success": result.success,
16
+ "volume_mm3": result.volume,
17
+ "bounding_box_mm": list(result.bounding_box) if result.bounding_box else [],
18
+ "face_count": result.face_count,
19
+ "edge_count": result.edge_count,
20
+ "error": result.error,
21
+ }
22
+
23
+
24
+ class ValidationResultSerializer:
25
+ """Serialize CNCValidationResult to JSON-ready dict."""
26
+
27
+ @staticmethod
28
+ def to_dict(result) -> dict:
29
+ return {
30
+ "machinable": result.machinable,
31
+ "axis_recommendation": result.axis_recommendation,
32
+ "error_count": result.error_count,
33
+ "warning_count": result.warning_count,
34
+ "issues": [
35
+ {"severity": i.severity, "category": i.category, "message": i.message}
36
+ for i in result.issues
37
+ ],
38
+ }
core/types.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared types, enums, dataclasses, and ABCs for NeuralCAD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+
12
+ class BackendName(str, Enum):
13
+ MOCK = "mock"
14
+ ANTHROPIC = "anthropic"
15
+ OPENAI = "openai"
16
+ GEMINI = "gemini"
17
+
18
+
19
+ class AgentId(str, Enum):
20
+ DESIGN = "design"
21
+ ENGINEERING = "engineering"
22
+ CNC = "cnc"
23
+ CAD = "cad"
24
+
25
+
26
+ @dataclass
27
+ class AgentResponse:
28
+ """A single agent's response in a chat turn."""
29
+ agent_id: str
30
+ agent_name: str
31
+ message: str
32
+ color: str
33
+ avatar: str
34
+ code: Optional[str] = None
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "agent_id": self.agent_id,
39
+ "agent_name": self.agent_name,
40
+ "message": self.message,
41
+ "color": self.color,
42
+ "avatar": self.avatar,
43
+ "code": self.code,
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class ChatResult:
49
+ """Result of a multi-agent chat turn."""
50
+ responses: list[AgentResponse]
51
+ preview: Optional[dict] = None
52
+ design_state: dict = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> dict:
55
+ return {
56
+ "responses": [r.to_dict() for r in self.responses],
57
+ "preview": self.preview,
58
+ "design_state": self.design_state,
59
+ }
60
+
61
+
62
+ class LLMBackend(ABC):
63
+ """Abstract base class for LLM code generation backends."""
64
+
65
+ @abstractmethod
66
+ def generate(self, messages: list[dict]) -> str:
67
+ """Generate text from a list of messages."""
68
+ ...
69
+
70
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
71
+ """Generate text from messages that include an image."""
72
+ raise NotImplementedError(
73
+ f"{type(self).__name__} does not support image input"
74
+ )
75
+
76
+ @staticmethod
77
+ def split_system_message(messages: list[dict]) -> tuple[str, list[dict]]:
78
+ """Extract system message from a message list."""
79
+ system_msg = ""
80
+ user_messages = []
81
+ for m in messages:
82
+ if m["role"] == "system":
83
+ system_msg = m["content"]
84
+ else:
85
+ user_messages.append(m)
86
+ return system_msg, user_messages
core/validator.py CHANGED
@@ -61,6 +61,17 @@ DEFAULT_CONFIG = {
61
  }
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
64
  def validate_for_cnc(
65
  workplane: cq.Workplane,
66
  part_name: str = "Part",
@@ -70,7 +81,7 @@ def validate_for_cnc(
70
  Run manufacturability checks on a CadQuery solid.
71
  Returns a CNCValidationResult with issues found.
72
  """
73
- cfg = {**DEFAULT_CONFIG, **(config or {})}
74
  result = CNCValidationResult(part_name=part_name)
75
  shape = workplane.val()
76
  bb = shape.BoundingBox()
@@ -127,7 +138,12 @@ def validate_for_cnc(
127
  n_faces = len(faces)
128
  n_edges = len(edges)
129
 
130
- if n_faces > 100:
 
 
 
 
 
131
  result.issues.append(
132
  CNCIssue(
133
  "warning",
@@ -136,7 +152,7 @@ def validate_for_cnc(
136
  )
137
  )
138
  result.axis_recommendation = "5-axis"
139
- elif n_faces > 50:
140
  result.issues.append(
141
  CNCIssue(
142
  "info",
 
61
  }
62
 
63
 
64
+ def _get_validation_config(overrides: dict | None = None) -> dict:
65
+ """Build validation config from settings + optional overrides."""
66
+ from config.settings import settings
67
+ base = dict(settings.validation)
68
+ # Remove nested dicts (like complexity_thresholds) from the flat config
69
+ base.pop("complexity_thresholds", None)
70
+ if overrides:
71
+ base.update(overrides)
72
+ return base
73
+
74
+
75
  def validate_for_cnc(
76
  workplane: cq.Workplane,
77
  part_name: str = "Part",
 
81
  Run manufacturability checks on a CadQuery solid.
82
  Returns a CNCValidationResult with issues found.
83
  """
84
+ cfg = _get_validation_config(config)
85
  result = CNCValidationResult(part_name=part_name)
86
  shape = workplane.val()
87
  bb = shape.BoundingBox()
 
138
  n_faces = len(faces)
139
  n_edges = len(edges)
140
 
141
+ from config.settings import settings
142
+ complexity = settings.validation.get("complexity_thresholds", {})
143
+ five_axis_faces = complexity.get("five_axis_faces", 100)
144
+ three_plus_two_faces = complexity.get("three_plus_two_faces", 50)
145
+
146
+ if n_faces > five_axis_faces:
147
  result.issues.append(
148
  CNCIssue(
149
  "warning",
 
152
  )
153
  )
154
  result.axis_recommendation = "5-axis"
155
+ elif n_faces > three_plus_two_faces:
156
  result.issues.append(
157
  CNCIssue(
158
  "info",
docs/superpowers/plans/2026-04-11-oop-config-foundation.md ADDED
@@ -0,0 +1,844 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OOP Config Foundation 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:** Extract all hardcoded values to config.yaml/.env, add proper ABCs and type system, config-drive all backends, add backend factory, and extract serializers.
6
+
7
+ **Architecture:** A Pydantic Settings singleton loads .env secrets and config.yaml application config. All modules import from this instead of defining their own constants. LLMBackend becomes a proper ABC. BackendFactory replaces scattered if/elif backend selection. Serializers eliminate 7x duplication.
8
+
9
+ **Tech Stack:** pydantic-settings, pyyaml, abc, enum, dataclasses
10
+
11
+ ---
12
+
13
+ ### Task 1: Add Dependencies
14
+
15
+ **Files:**
16
+ - Modify: `pyproject.toml`
17
+
18
+ - [ ] **Step 1: Add pydantic-settings and pyyaml to dependencies**
19
+
20
+ Add to the dependencies list in pyproject.toml:
21
+ ```toml
22
+ "pydantic-settings>=2.0.0",
23
+ "pyyaml>=6.0",
24
+ ```
25
+
26
+ - [ ] **Step 2: Install**
27
+
28
+ Run: `cd /home/daniel/NeuralCAD && uv sync`
29
+
30
+ - [ ] **Step 3: Commit**
31
+
32
+ ```bash
33
+ git add pyproject.toml uv.lock
34
+ git commit -m "build: add pydantic-settings and pyyaml dependencies"
35
+ ```
36
+
37
+ ---
38
+
39
+ ### Task 2: Create config.yaml and .env.example
40
+
41
+ **Files:**
42
+ - Create: `config.yaml`
43
+ - Create: `.env.example`
44
+
45
+ - [ ] **Step 1: Create config.yaml**
46
+
47
+ Create `/home/daniel/NeuralCAD/config.yaml` with the full YAML content from spec section 1.2 (server, paths, backends, orchestration, validation, export, agents, routing, materials, material_grades, dimension_contexts, fasteners, fallback_messages). This is the single source of truth for all non-secret application configuration.
48
+
49
+ - [ ] **Step 2: Create .env.example**
50
+
51
+ ```
52
+ # API keys (copy to .env and fill in)
53
+ ANTHROPIC_API_KEY=
54
+ OPENAI_API_KEY=
55
+ GOOGLE_API_KEY=
56
+
57
+ # Optional overrides
58
+ # NEURALCAD_OUTPUT_DIR=./output
59
+ # NEURALCAD_WEB_PORT=5000
60
+ # NEURALCAD_MCP_PORT=8000
61
+ ```
62
+
63
+ - [ ] **Step 3: Add .env to .gitignore**
64
+
65
+ Append `.env` to `.gitignore` if not already there.
66
+
67
+ - [ ] **Step 4: Commit**
68
+
69
+ ```bash
70
+ git add config.yaml .env.example .gitignore
71
+ git commit -m "config: add config.yaml and .env.example"
72
+ ```
73
+
74
+ ---
75
+
76
+ ### Task 3: Create Settings Singleton
77
+
78
+ **Files:**
79
+ - Create: `config/__init__.py`
80
+ - Create: `config/settings.py`
81
+ - Test: `tests/test_settings.py`
82
+
83
+ - [ ] **Step 1: Write test for Settings**
84
+
85
+ ```python
86
+ """Tests for config/settings.py."""
87
+ import pytest
88
+ from pathlib import Path
89
+
90
+
91
+ class TestSettings:
92
+ def test_loads_config_yaml(self):
93
+ from config.settings import settings
94
+ assert settings.agents # Should have loaded agents from config.yaml
95
+ assert "design" in settings.agents
96
+
97
+ def test_output_dir_property(self):
98
+ from config.settings import settings
99
+ assert isinstance(settings.output_dir, Path)
100
+
101
+ def test_web_port_property(self):
102
+ from config.settings import settings
103
+ assert isinstance(settings.web_port, int)
104
+ assert settings.web_port > 0
105
+
106
+ def test_model_for_property(self):
107
+ from config.settings import settings
108
+ models = settings.model_for
109
+ assert "anthropic" in models
110
+ assert "openai" in models
111
+ assert "gemini" in models
112
+
113
+ def test_max_tokens_property(self):
114
+ from config.settings import settings
115
+ assert settings.max_tokens == 4096
116
+
117
+ def test_temperature_property(self):
118
+ from config.settings import settings
119
+ assert settings.temperature == 0.2
120
+
121
+ def test_validation_config(self):
122
+ from config.settings import settings
123
+ assert settings.validation["min_wall_thickness_mm"] == 1.5
124
+
125
+ def test_routing_keywords_loaded(self):
126
+ from config.settings import settings
127
+ assert "design" in settings.routing["keywords"]
128
+ assert "engineering" in settings.routing["keywords"]
129
+
130
+ def test_fasteners_loaded(self):
131
+ from config.settings import settings
132
+ assert settings.fasteners["M6"] == 6.6
133
+
134
+ def test_materials_loaded(self):
135
+ from config.settings import settings
136
+ assert "aluminum" in settings.materials
137
+ ```
138
+
139
+ - [ ] **Step 2: Run test to verify it fails**
140
+
141
+ Run: `python -m pytest tests/test_settings.py -v`
142
+ Expected: FAIL (config module doesn't exist)
143
+
144
+ - [ ] **Step 3: Create config/__init__.py**
145
+
146
+ Empty file.
147
+
148
+ - [ ] **Step 4: Create config/settings.py**
149
+
150
+ ```python
151
+ """Centralized configuration — single source of truth for all NeuralCAD settings."""
152
+
153
+ from __future__ import annotations
154
+
155
+ from pathlib import Path
156
+ from typing import Any
157
+
158
+ import yaml
159
+ from pydantic import Field
160
+ from pydantic_settings import BaseSettings
161
+
162
+
163
+ class Settings(BaseSettings):
164
+ """Loads .env for secrets, then overlays config.yaml for app config."""
165
+
166
+ # .env secrets
167
+ anthropic_api_key: str = ""
168
+ openai_api_key: str = ""
169
+ google_api_key: str = ""
170
+
171
+ # Overridable via env vars
172
+ neuralcad_output_dir: str = ""
173
+ neuralcad_web_port: int = 0
174
+ neuralcad_mcp_port: int = 0
175
+
176
+ # Loaded from config.yaml
177
+ server: dict[str, Any] = Field(default_factory=dict)
178
+ paths: dict[str, Any] = Field(default_factory=dict)
179
+ backends: dict[str, Any] = Field(default_factory=dict)
180
+ orchestration: dict[str, Any] = Field(default_factory=dict)
181
+ validation: dict[str, Any] = Field(default_factory=dict)
182
+ export: dict[str, Any] = Field(default_factory=dict)
183
+ agents: dict[str, Any] = Field(default_factory=dict)
184
+ routing: dict[str, Any] = Field(default_factory=dict)
185
+ materials: list[str] = Field(default_factory=list)
186
+ material_grades: dict[str, str] = Field(default_factory=dict)
187
+ dimension_contexts: dict[str, str] = Field(default_factory=dict)
188
+ fasteners: dict[str, float] = Field(default_factory=dict)
189
+ fallback_messages: dict[str, str] = Field(default_factory=dict)
190
+
191
+ model_config = {"env_file": ".env", "extra": "ignore"}
192
+
193
+ def model_post_init(self, __context: Any) -> None:
194
+ config_path = Path(__file__).parent.parent / "config.yaml"
195
+ if config_path.exists():
196
+ with open(config_path) as f:
197
+ data = yaml.safe_load(f) or {}
198
+ for key, value in data.items():
199
+ if hasattr(self, key):
200
+ current = getattr(self, key)
201
+ # Only override if current value is empty/default
202
+ if not current or (isinstance(current, (dict, list)) and len(current) == 0):
203
+ object.__setattr__(self, key, value)
204
+
205
+ # ── Convenience properties ──────────────────────────────────────────
206
+
207
+ @property
208
+ def output_dir(self) -> Path:
209
+ if self.neuralcad_output_dir:
210
+ return Path(self.neuralcad_output_dir)
211
+ return Path(self.paths.get("output_dir", "./output"))
212
+
213
+ @property
214
+ def web_port(self) -> int:
215
+ if self.neuralcad_web_port:
216
+ return self.neuralcad_web_port
217
+ return self.server.get("web_port", 5000)
218
+
219
+ @property
220
+ def mcp_port(self) -> int:
221
+ if self.neuralcad_mcp_port:
222
+ return self.neuralcad_mcp_port
223
+ return self.server.get("mcp_port", 8000)
224
+
225
+ @property
226
+ def default_backend(self) -> str:
227
+ return self.backends.get("default", "mock")
228
+
229
+ @property
230
+ def model_for(self) -> dict[str, str]:
231
+ return self.backends.get("models", {})
232
+
233
+ @property
234
+ def max_tokens(self) -> int:
235
+ return self.backends.get("max_tokens", 4096)
236
+
237
+ @property
238
+ def temperature(self) -> float:
239
+ return self.backends.get("temperature", 0.2)
240
+
241
+
242
+ settings = Settings()
243
+ ```
244
+
245
+ - [ ] **Step 5: Run tests**
246
+
247
+ Run: `python -m pytest tests/test_settings.py -v`
248
+ Expected: All PASS
249
+
250
+ - [ ] **Step 6: Commit**
251
+
252
+ ```bash
253
+ git add config/__init__.py config/settings.py tests/test_settings.py
254
+ git commit -m "feat: add Settings singleton with config.yaml + .env loading"
255
+ ```
256
+
257
+ ---
258
+
259
+ ### Task 4: Create Type System (Enums, Dataclasses, ABC)
260
+
261
+ **Files:**
262
+ - Create: `core/types.py`
263
+ - Test: `tests/test_types.py`
264
+
265
+ - [ ] **Step 1: Write tests**
266
+
267
+ ```python
268
+ """Tests for core/types.py — enums, dataclasses, ABC."""
269
+ from core.types import BackendName, AgentId, AgentResponse, ChatResult, LLMBackend
270
+
271
+
272
+ class TestEnums:
273
+ def test_backend_names(self):
274
+ assert BackendName.MOCK == "mock"
275
+ assert BackendName.ANTHROPIC == "anthropic"
276
+ assert BackendName.OPENAI == "openai"
277
+ assert BackendName.GEMINI == "gemini"
278
+
279
+ def test_agent_ids(self):
280
+ assert AgentId.DESIGN == "design"
281
+ assert AgentId.ENGINEERING == "engineering"
282
+ assert AgentId.CNC == "cnc"
283
+ assert AgentId.CAD == "cad"
284
+
285
+ def test_backend_name_is_string(self):
286
+ assert isinstance(BackendName.MOCK, str)
287
+ assert BackendName.MOCK in {"mock", "anthropic"}
288
+
289
+
290
+ class TestAgentResponse:
291
+ def test_create(self):
292
+ r = AgentResponse(agent_id="design", agent_name="Design Agent", message="hello", color="#7c3aed", avatar="DA")
293
+ assert r.agent_id == "design"
294
+ assert r.code is None
295
+
296
+ def test_with_code(self):
297
+ r = AgentResponse(agent_id="cad", agent_name="CAD", message="done", color="#ffab40", avatar="CC", code="result = cq.Workplane().box(10,10,10)")
298
+ assert r.code is not None
299
+
300
+ def test_to_dict(self):
301
+ r = AgentResponse(agent_id="design", agent_name="Design Agent", message="hi", color="#7c3aed", avatar="DA")
302
+ d = r.to_dict()
303
+ assert d["agent_id"] == "design"
304
+ assert d["message"] == "hi"
305
+ assert "code" in d
306
+
307
+
308
+ class TestChatResult:
309
+ def test_create_empty(self):
310
+ result = ChatResult(responses=[])
311
+ assert result.preview is None
312
+ assert result.design_state == {}
313
+
314
+ def test_to_dict(self):
315
+ r = AgentResponse(agent_id="design", agent_name="D", message="hi", color="#fff", avatar="D")
316
+ result = ChatResult(responses=[r])
317
+ d = result.to_dict()
318
+ assert len(d["responses"]) == 1
319
+ assert d["preview"] is None
320
+
321
+
322
+ class TestLLMBackendABC:
323
+ def test_cannot_instantiate(self):
324
+ import pytest
325
+ with pytest.raises(TypeError):
326
+ LLMBackend()
327
+
328
+ def test_subclass_must_implement_generate(self):
329
+ class Incomplete(LLMBackend):
330
+ pass
331
+ import pytest
332
+ with pytest.raises(TypeError):
333
+ Incomplete()
334
+
335
+ def test_subclass_with_generate(self):
336
+ class Complete(LLMBackend):
337
+ def generate(self, messages):
338
+ return "ok"
339
+ b = Complete()
340
+ assert b.generate([]) == "ok"
341
+ ```
342
+
343
+ - [ ] **Step 2: Run tests to verify failure**
344
+
345
+ Run: `python -m pytest tests/test_types.py -v`
346
+
347
+ - [ ] **Step 3: Create core/types.py**
348
+
349
+ ```python
350
+ """Shared types, enums, dataclasses, and ABCs for NeuralCAD."""
351
+
352
+ from __future__ import annotations
353
+
354
+ from abc import ABC, abstractmethod
355
+ from dataclasses import dataclass, field
356
+ from enum import Enum
357
+ from pathlib import Path
358
+ from typing import Optional
359
+
360
+
361
+ class BackendName(str, Enum):
362
+ MOCK = "mock"
363
+ ANTHROPIC = "anthropic"
364
+ OPENAI = "openai"
365
+ GEMINI = "gemini"
366
+
367
+
368
+ class AgentId(str, Enum):
369
+ DESIGN = "design"
370
+ ENGINEERING = "engineering"
371
+ CNC = "cnc"
372
+ CAD = "cad"
373
+
374
+
375
+ @dataclass
376
+ class AgentResponse:
377
+ """A single agent's response in a chat turn."""
378
+ agent_id: str
379
+ agent_name: str
380
+ message: str
381
+ color: str
382
+ avatar: str
383
+ code: Optional[str] = None
384
+
385
+ def to_dict(self) -> dict:
386
+ return {
387
+ "agent_id": self.agent_id,
388
+ "agent_name": self.agent_name,
389
+ "message": self.message,
390
+ "color": self.color,
391
+ "avatar": self.avatar,
392
+ "code": self.code,
393
+ }
394
+
395
+
396
+ @dataclass
397
+ class ChatResult:
398
+ """Result of a multi-agent chat turn."""
399
+ responses: list[AgentResponse]
400
+ preview: Optional[dict] = None
401
+ design_state: dict = field(default_factory=dict)
402
+
403
+ def to_dict(self) -> dict:
404
+ return {
405
+ "responses": [r.to_dict() for r in self.responses],
406
+ "preview": self.preview,
407
+ "design_state": self.design_state,
408
+ }
409
+
410
+
411
+ class LLMBackend(ABC):
412
+ """Abstract base class for LLM code generation backends."""
413
+
414
+ @abstractmethod
415
+ def generate(self, messages: list[dict]) -> str:
416
+ """Generate text from a list of messages."""
417
+ ...
418
+
419
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
420
+ """Generate text from messages that include an image."""
421
+ raise NotImplementedError(
422
+ f"{type(self).__name__} does not support image input"
423
+ )
424
+
425
+ @staticmethod
426
+ def split_system_message(messages: list[dict]) -> tuple[str, list[dict]]:
427
+ """Extract system message from a message list. Returns (system_text, remaining_messages)."""
428
+ system_msg = ""
429
+ user_messages = []
430
+ for m in messages:
431
+ if m["role"] == "system":
432
+ system_msg = m["content"]
433
+ else:
434
+ user_messages.append(m)
435
+ return system_msg, user_messages
436
+ ```
437
+
438
+ - [ ] **Step 4: Run tests**
439
+
440
+ Run: `python -m pytest tests/test_types.py -v`
441
+ Expected: All PASS
442
+
443
+ - [ ] **Step 5: Commit**
444
+
445
+ ```bash
446
+ git add core/types.py tests/test_types.py
447
+ git commit -m "feat: add type system — enums, dataclasses, LLMBackend ABC"
448
+ ```
449
+
450
+ ---
451
+
452
+ ### Task 5: Create Serializers
453
+
454
+ **Files:**
455
+ - Create: `core/serializers.py`
456
+ - Test: `tests/test_serializers.py`
457
+
458
+ - [ ] **Step 1: Write tests**
459
+
460
+ ```python
461
+ """Tests for core/serializers.py."""
462
+ import pytest
463
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
464
+
465
+
466
+ class TestExecutionResultSerializer:
467
+ def test_success(self):
468
+ """Test with a mock-like ExecutionResult."""
469
+ class FakeResult:
470
+ success = True
471
+ volume = 6000.0
472
+ bounding_box = (10.0, 20.0, 30.0)
473
+ face_count = 6
474
+ edge_count = 12
475
+ error = None
476
+
477
+ d = ExecutionResultSerializer.to_dict(FakeResult())
478
+ assert d["success"] is True
479
+ assert d["volume_mm3"] == 6000.0
480
+ assert d["bounding_box_mm"] == [10.0, 20.0, 30.0]
481
+ assert d["face_count"] == 6
482
+ assert d["error"] is None
483
+
484
+ def test_failure(self):
485
+ class FakeResult:
486
+ success = False
487
+ volume = 0.0
488
+ bounding_box = ()
489
+ face_count = 0
490
+ edge_count = 0
491
+ error = "syntax error"
492
+
493
+ d = ExecutionResultSerializer.to_dict(FakeResult())
494
+ assert d["success"] is False
495
+ assert d["error"] == "syntax error"
496
+ assert d["bounding_box_mm"] == []
497
+
498
+
499
+ class TestValidationResultSerializer:
500
+ def test_basic(self):
501
+ class FakeIssue:
502
+ severity = "warning"
503
+ category = "Size"
504
+ message = "Part is large"
505
+
506
+ class FakeResult:
507
+ machinable = True
508
+ axis_recommendation = "3-axis"
509
+ error_count = 0
510
+ warning_count = 1
511
+ issues = [FakeIssue()]
512
+
513
+ d = ValidationResultSerializer.to_dict(FakeResult())
514
+ assert d["machinable"] is True
515
+ assert d["axis_recommendation"] == "3-axis"
516
+ assert len(d["issues"]) == 1
517
+ assert d["issues"][0]["severity"] == "warning"
518
+
519
+ def test_empty_issues(self):
520
+ class FakeResult:
521
+ machinable = True
522
+ axis_recommendation = "3-axis"
523
+ error_count = 0
524
+ warning_count = 0
525
+ issues = []
526
+
527
+ d = ValidationResultSerializer.to_dict(FakeResult())
528
+ assert d["issues"] == []
529
+ ```
530
+
531
+ - [ ] **Step 2: Run tests to verify failure**
532
+
533
+ - [ ] **Step 3: Create core/serializers.py**
534
+
535
+ ```python
536
+ """Serializers for execution and validation results.
537
+
538
+ Eliminates duplicated dict-building code across mcp.py and orchestrator.py.
539
+ """
540
+
541
+ from __future__ import annotations
542
+
543
+
544
+ class ExecutionResultSerializer:
545
+ """Serialize ExecutionResult to JSON-ready dict."""
546
+
547
+ @staticmethod
548
+ def to_dict(result) -> dict:
549
+ return {
550
+ "success": result.success,
551
+ "volume_mm3": result.volume,
552
+ "bounding_box_mm": list(result.bounding_box) if result.bounding_box else [],
553
+ "face_count": result.face_count,
554
+ "edge_count": result.edge_count,
555
+ "error": result.error,
556
+ }
557
+
558
+
559
+ class ValidationResultSerializer:
560
+ """Serialize CNCValidationResult to JSON-ready dict."""
561
+
562
+ @staticmethod
563
+ def to_dict(result) -> dict:
564
+ return {
565
+ "machinable": result.machinable,
566
+ "axis_recommendation": result.axis_recommendation,
567
+ "error_count": result.error_count,
568
+ "warning_count": result.warning_count,
569
+ "issues": [
570
+ {"severity": i.severity, "category": i.category, "message": i.message}
571
+ for i in result.issues
572
+ ],
573
+ }
574
+ ```
575
+
576
+ - [ ] **Step 4: Run tests**
577
+
578
+ Run: `python -m pytest tests/test_serializers.py -v`
579
+ Expected: All PASS
580
+
581
+ - [ ] **Step 5: Commit**
582
+
583
+ ```bash
584
+ git add core/serializers.py tests/test_serializers.py
585
+ git commit -m "feat: add serializers for execution and validation results"
586
+ ```
587
+
588
+ ---
589
+
590
+ ### Task 6: Create Backend Factory
591
+
592
+ **Files:**
593
+ - Create: `core/backend_factory.py`
594
+ - Test: `tests/test_backend_factory.py`
595
+
596
+ - [ ] **Step 1: Write tests**
597
+
598
+ ```python
599
+ """Tests for core/backend_factory.py."""
600
+ import pytest
601
+ from core.backend_factory import BackendFactory
602
+ from core.types import LLMBackend
603
+
604
+
605
+ class TestBackendFactory:
606
+ def test_create_mock(self):
607
+ backend = BackendFactory.create("mock")
608
+ assert isinstance(backend, LLMBackend)
609
+
610
+ def test_create_unknown_raises(self):
611
+ with pytest.raises(ValueError, match="Unknown backend"):
612
+ BackendFactory.create("nonexistent")
613
+
614
+ def test_registry_has_mock(self):
615
+ assert "mock" in BackendFactory._registry
616
+
617
+ def test_mock_can_generate(self):
618
+ backend = BackendFactory.create("mock")
619
+ result = backend.generate([{"role": "user", "content": "a 50mm cube"}])
620
+ assert isinstance(result, str)
621
+ assert "result" in result
622
+ ```
623
+
624
+ - [ ] **Step 2: Run tests to verify failure**
625
+
626
+ - [ ] **Step 3: Create core/backend_factory.py**
627
+
628
+ ```python
629
+ """Backend factory — centralized creation of LLM backends.
630
+
631
+ Replaces scattered if/elif backend selection across mcp.py, routes.py, web.py.
632
+ """
633
+
634
+ from __future__ import annotations
635
+
636
+ import logging
637
+ from typing import TYPE_CHECKING
638
+
639
+ from core.types import LLMBackend
640
+
641
+ if TYPE_CHECKING:
642
+ from config.settings import Settings
643
+
644
+ logger = logging.getLogger(__name__)
645
+
646
+
647
+ class BackendFactory:
648
+ """Registry and factory for LLM backends."""
649
+
650
+ _registry: dict[str, type[LLMBackend]] = {}
651
+
652
+ @classmethod
653
+ def register(cls, name: str, backend_cls: type[LLMBackend]) -> None:
654
+ cls._registry[name] = backend_cls
655
+
656
+ @classmethod
657
+ def create(cls, name: str, **kwargs) -> LLMBackend:
658
+ if name not in cls._registry:
659
+ raise ValueError(f"Unknown backend: {name!r}. Available: {list(cls._registry.keys())}")
660
+ return cls._registry[name](**kwargs)
661
+
662
+ @classmethod
663
+ def create_safe(cls, name: str, **kwargs) -> LLMBackend:
664
+ """Create backend, falling back to mock on failure."""
665
+ try:
666
+ return cls.create(name, **kwargs)
667
+ except Exception as exc:
668
+ logger.warning("Backend %r unavailable (%s), falling back to mock", name, exc)
669
+ return cls.create("mock")
670
+
671
+
672
+ def _register_defaults() -> None:
673
+ """Register all built-in backends. Called at module load."""
674
+ from core.backends import MockBackend
675
+ BackendFactory.register("mock", MockBackend)
676
+
677
+ # Lazy-register API backends — they validate keys on __init__
678
+ try:
679
+ from core.backends import AnthropicBackend
680
+ BackendFactory.register("anthropic", AnthropicBackend)
681
+ except Exception:
682
+ pass
683
+
684
+ try:
685
+ from core.backends import OpenAIBackend
686
+ BackendFactory.register("openai", OpenAIBackend)
687
+ except Exception:
688
+ pass
689
+
690
+ try:
691
+ from core.backends import GeminiBackend
692
+ BackendFactory.register("gemini", GeminiBackend)
693
+ except Exception:
694
+ pass
695
+
696
+
697
+ _register_defaults()
698
+ ```
699
+
700
+ - [ ] **Step 4: Run tests**
701
+
702
+ Run: `python -m pytest tests/test_backend_factory.py -v`
703
+ Expected: All PASS
704
+
705
+ - [ ] **Step 5: Commit**
706
+
707
+ ```bash
708
+ git add core/backend_factory.py tests/test_backend_factory.py
709
+ git commit -m "feat: add BackendFactory registry for centralized backend creation"
710
+ ```
711
+
712
+ ---
713
+
714
+ ### Task 7: Config-Drive Backends + ABC Inheritance
715
+
716
+ **Files:**
717
+ - Modify: `core/backends.py`
718
+
719
+ - [ ] **Step 1: Update LLMBackend base and all backends in core/backends.py**
720
+
721
+ Changes to make:
722
+ 1. Replace `class LLMBackend:` with import from `core.types`: `from core.types import LLMBackend`
723
+ 2. Make `AnthropicBackend`, `OpenAIBackend`, `GeminiBackend` read defaults from `settings`:
724
+ - Model names: `model or settings.model_for.get("anthropic", "claude-sonnet-4-20250514")`
725
+ - API keys: `api_key or settings.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY")`
726
+ - Replace all `max_tokens=4096` with `settings.max_tokens`
727
+ - Replace all `temperature=0.2` with `settings.temperature`
728
+ 3. Use `self.split_system_message(messages)` in Anthropic/Gemini to eliminate duplicated message parsing
729
+ 4. `MockBackend` — replace hardcoded `_THREAD_CLEARANCE` dict with `settings.fasteners`
730
+
731
+ - [ ] **Step 2: Run existing tests**
732
+
733
+ Run: `python -m pytest tests/ -v --tb=short`
734
+ Expected: All 107+ tests still PASS
735
+
736
+ - [ ] **Step 3: Commit**
737
+
738
+ ```bash
739
+ git add core/backends.py
740
+ git commit -m "refactor: config-drive all backends, use LLMBackend ABC from types"
741
+ ```
742
+
743
+ ---
744
+
745
+ ### Task 8: Config-Drive Validator
746
+
747
+ **Files:**
748
+ - Modify: `core/validator.py`
749
+
750
+ - [ ] **Step 1: Replace DEFAULT_CONFIG with settings**
751
+
752
+ Replace the hardcoded `DEFAULT_CONFIG` dict with values loaded from settings:
753
+
754
+ ```python
755
+ from config.settings import settings
756
+
757
+ def _get_config(overrides: dict | None = None) -> dict:
758
+ """Build validation config from settings + optional overrides."""
759
+ base = dict(settings.validation)
760
+ if overrides:
761
+ base.update(overrides)
762
+ return base
763
+ ```
764
+
765
+ Update `validate_for_cnc()` to use `_get_config(config)` instead of `DEFAULT_CONFIG | (config or {})`.
766
+
767
+ Also replace magic numbers `100` and `50` for face count thresholds with:
768
+ ```python
769
+ complexity = cfg.get("complexity_thresholds", {})
770
+ five_axis_faces = complexity.get("five_axis_faces", 100)
771
+ three_plus_two_faces = complexity.get("three_plus_two_faces", 50)
772
+ ```
773
+
774
+ - [ ] **Step 2: Run existing validator tests**
775
+
776
+ Run: `python -m pytest tests/test_validator.py -v`
777
+ Expected: All PASS
778
+
779
+ - [ ] **Step 3: Commit**
780
+
781
+ ```bash
782
+ git add core/validator.py
783
+ git commit -m "refactor: config-drive CNC validation thresholds"
784
+ ```
785
+
786
+ ---
787
+
788
+ ### Task 9: Wire Serializers into MCP and Orchestrator
789
+
790
+ **Files:**
791
+ - Modify: `server/mcp.py`
792
+ - Modify: `agents/orchestrator.py`
793
+
794
+ - [ ] **Step 1: Replace duplicated serialization in server/mcp.py**
795
+
796
+ Add import:
797
+ ```python
798
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
799
+ ```
800
+
801
+ Replace all inline `{"volume_mm3": ..., "bounding_box_mm": ..., ...}` blocks (4 occurrences) with:
802
+ ```python
803
+ ExecutionResultSerializer.to_dict(result.execution)
804
+ ```
805
+
806
+ Replace all inline `{"machinable": ..., "axis_recommendation": ..., ...}` blocks (3 occurrences) with:
807
+ ```python
808
+ ValidationResultSerializer.to_dict(result.validation)
809
+ ```
810
+
811
+ - [ ] **Step 2: Replace duplicated serialization in agents/orchestrator.py**
812
+
813
+ In `_execute_cad_code()`, replace the inline execution/validation dicts (lines ~110-132) with serializer calls.
814
+
815
+ - [ ] **Step 3: Run full test suite**
816
+
817
+ Run: `python -m pytest tests/ -v --tb=short`
818
+ Expected: All tests PASS
819
+
820
+ - [ ] **Step 4: Commit**
821
+
822
+ ```bash
823
+ git add server/mcp.py agents/orchestrator.py
824
+ git commit -m "refactor: use serializers to eliminate duplicated result building"
825
+ ```
826
+
827
+ ---
828
+
829
+ ### Task 10: Final Verification
830
+
831
+ - [ ] **Step 1: Run full test suite**
832
+
833
+ Run: `python -m pytest tests/ -v --tb=short`
834
+ Expected: All 107+ tests PASS plus new tests (~20 new)
835
+
836
+ - [ ] **Step 2: Verify config loading works**
837
+
838
+ Run: `python -c "from config.settings import settings; print(settings.agents.keys()); print(settings.model_for)"`
839
+ Expected: Prints agent IDs and model names from config.yaml.
840
+
841
+ - [ ] **Step 3: Verify backend factory works**
842
+
843
+ Run: `python -c "from core.backend_factory import BackendFactory; b = BackendFactory.create('mock'); print(b.generate([{'role':'user','content':'a cube'}])[:50])"`
844
+ Expected: Prints first 50 chars of generated CadQuery code.
docs/superpowers/specs/2026-04-11-oop-config-crewai-design.md ADDED
@@ -0,0 +1,1232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD: OOP Refactor, Config Externalization, and CrewAI Overhaul
2
+
3
+ ## Context
4
+
5
+ NeuralCAD has a working multi-agent CAD design system with 107 tests passing. However, the codebase has systemic issues:
6
+
7
+ - **Hardcoded values everywhere**: model names, ports, thresholds, agent definitions, routing keywords, prompt templates all live in Python source code
8
+ - **No OOP discipline**: no ABCs, no interfaces, no factory patterns, anemic domain models, god classes
9
+ - **CrewAI at 8% utilization**: used as a sequential for-loop wrapper; memory, tools, delegation, hierarchical process, structured output, knowledge bases all unused
10
+ - **Duplicated logic**: OUTPUT_DIR defined 3x, serialization code 4x, backend name checks scattered across 4+ files, message parsing duplicated per backend
11
+
12
+ This refactor cleans up the architecture without changing user-facing behavior. All 107 existing tests must continue to pass.
13
+
14
+ ## Goals
15
+
16
+ 1. Every magic number, model name, threshold, and agent definition moves to `config.yaml` or `.env`
17
+ 2. Proper ABCs, factory patterns, and type safety throughout
18
+ 3. CrewAI used as intended: hierarchical process, tools, memory, structured output
19
+ 4. Eliminate all code duplication
20
+ 5. Split god classes/functions into focused units
21
+
22
+ ---
23
+
24
+ ## 1. Configuration System
25
+
26
+ ### 1.1 `.env` — Secrets and Environment Overrides
27
+
28
+ ```
29
+ # API keys (secrets — never commit)
30
+ ANTHROPIC_API_KEY=sk-ant-...
31
+ OPENAI_API_KEY=sk-...
32
+ GOOGLE_API_KEY=...
33
+
34
+ # Environment overrides (optional — config.yaml has defaults)
35
+ NEURALCAD_OUTPUT_DIR=./output
36
+ NEURALCAD_WEB_PORT=5000
37
+ NEURALCAD_MCP_PORT=8000
38
+ ```
39
+
40
+ ### 1.2 `config.yaml` — All Application Configuration
41
+
42
+ ```yaml
43
+ server:
44
+ web_port: 5000
45
+ mcp_port: 8000
46
+ mcp_name: text-to-cnc
47
+ cors_origins: ["*"]
48
+ mcp_startup_wait_seconds: 2
49
+
50
+ paths:
51
+ output_dir: ./output
52
+ web_dir: ./web
53
+ prompts_dir: ./agents/prompts
54
+
55
+ backends:
56
+ default: mock
57
+ models:
58
+ anthropic: claude-sonnet-4-20250514
59
+ openai: gpt-4o
60
+ gemini: gemini-2.5-flash
61
+ max_tokens: 4096
62
+ temperature: 0.2
63
+
64
+ orchestration:
65
+ max_history: 30
66
+ max_active_agents: 3
67
+ max_retries: 2
68
+ max_decisions: 20
69
+ max_recent_decisions: 5
70
+ part_name_max_chars: 40
71
+
72
+ validation:
73
+ min_wall_thickness_mm: 1.5
74
+ min_fillet_radius_mm: 1.0
75
+ max_pocket_depth_ratio: 4.0
76
+ max_part_size_mm: 500.0
77
+ min_part_size_mm: 1.0
78
+ min_hole_diameter_mm: 1.0
79
+ complexity_thresholds:
80
+ five_axis_faces: 100
81
+ three_plus_two_faces: 50
82
+
83
+ export:
84
+ stl_tolerance: 0.01
85
+
86
+ agents:
87
+ design:
88
+ name: Design Agent
89
+ role: Industrial Designer
90
+ color: "#7c3aed"
91
+ avatar: DA
92
+ goal: >
93
+ Understand the user's intent and propose optimal form factors,
94
+ shapes, and aesthetic choices for mechanical parts.
95
+ backstory: >
96
+ You are an experienced industrial designer specializing in mechanical
97
+ parts. You think about form, function, ergonomics, and visual appeal.
98
+ You ask clarifying questions about the part's purpose, environment,
99
+ and constraints before proposing designs.
100
+ engineering:
101
+ name: Engineering Agent
102
+ role: Mechanical Engineer
103
+ color: "#00b4d8"
104
+ avatar: EA
105
+ goal: >
106
+ Ensure parts are structurally sound with correct dimensions,
107
+ tolerances, materials, and fastener specifications.
108
+ backstory: >
109
+ You are a senior mechanical engineer with deep knowledge of materials
110
+ science, stress analysis, and fastener standards. You specify wall
111
+ thicknesses, fillet radii, clearance holes, and material recommendations.
112
+ cnc:
113
+ name: CNC Agent
114
+ role: CNC Manufacturing Advisor
115
+ color: "#00e676"
116
+ avatar: CA
117
+ goal: >
118
+ Advise on manufacturability: tool access, wall thickness limits,
119
+ pocket ratios, axis requirements, and cost implications.
120
+ backstory: >
121
+ You are a CNC machinist with 20 years of shop floor experience.
122
+ You know what tool geometries can reach, what aspect ratios cause
123
+ chatter, and when to recommend 3-axis vs 3+2 vs 5-axis.
124
+ cad:
125
+ name: CAD Coder
126
+ role: CadQuery Code Generator
127
+ color: "#ffab40"
128
+ avatar: CC
129
+ goal: >
130
+ Generate valid CadQuery Python code that produces the agreed-upon 3D model.
131
+ backstory: >
132
+ You are an expert CadQuery programmer. You take the design specifications
133
+ agreed upon by the team and translate them into precise CadQuery Python
134
+ code. Your code always assigns the result to a variable called `result`
135
+ as a cq.Workplane object.
136
+
137
+ routing:
138
+ cad_trigger_keywords:
139
+ - generate
140
+ - build
141
+ - build it
142
+ - preview
143
+ - show me
144
+ - create
145
+ - create the model
146
+ - model it
147
+ - render
148
+ - code
149
+ - make it
150
+ - produce
151
+ keywords:
152
+ design:
153
+ - design
154
+ - look
155
+ - shape
156
+ - style
157
+ - form
158
+ - aesthetic
159
+ - appearance
160
+ - layout
161
+ - concept
162
+ - idea
163
+ - propose
164
+ - suggest
165
+ - bracket
166
+ - mount
167
+ - enclosure
168
+ - housing
169
+ - ergonomic
170
+ - profile
171
+ - contour
172
+ engineering:
173
+ - dimension
174
+ - tolerance
175
+ - material
176
+ - strength
177
+ - load
178
+ - stress
179
+ - thickness
180
+ - wall
181
+ - fillet
182
+ - radius
183
+ - clearance
184
+ - m2
185
+ - m3
186
+ - m4
187
+ - m5
188
+ - m6
189
+ - m8
190
+ - m10
191
+ - m12
192
+ - aluminum
193
+ - steel
194
+ - brass
195
+ - titanium
196
+ - nylon
197
+ - gear
198
+ - bearing
199
+ - flange
200
+ - heatsink
201
+ - fin
202
+ - rib
203
+ - bolt
204
+ - screw
205
+ - thread
206
+ - torque
207
+ - deflection
208
+ - hole
209
+ - bore
210
+ - shaft
211
+ - keyway
212
+ - spline
213
+ cnc:
214
+ - machine
215
+ - mill
216
+ - cnc
217
+ - manufacture
218
+ - machinable
219
+ - axis
220
+ - tool
221
+ - fixture
222
+ - setup
223
+ - pocket
224
+ - undercut
225
+ - access
226
+ - 3-axis
227
+ - 5-axis
228
+ - cost
229
+ - surface finish
230
+ - roughness
231
+ - endmill
232
+ - drill
233
+ - tap
234
+ - chamfer tool
235
+ - deburr
236
+ - setup count
237
+ - cycle time
238
+ - tolerance class
239
+
240
+ materials:
241
+ - aluminum
242
+ - aluminium
243
+ - steel
244
+ - stainless steel
245
+ - brass
246
+ - copper
247
+ - titanium
248
+ - nylon
249
+ - delrin
250
+ - acetal
251
+ - abs
252
+ - polycarbonate
253
+ - peek
254
+
255
+ material_grades:
256
+ "6061": aluminum 6061
257
+ "7075": aluminum 7075
258
+ "304": stainless steel 304
259
+ "316": stainless steel 316
260
+ t6: aluminum 6061-T6
261
+
262
+ dimension_contexts:
263
+ wide: width
264
+ width: width
265
+ tall: height
266
+ height: height
267
+ high: height
268
+ thick: thickness
269
+ thickness: thickness
270
+ deep: depth
271
+ depth: depth
272
+ long: length
273
+ length: length
274
+ diameter: diameter
275
+ dia: diameter
276
+ radius: radius
277
+ arm: arm_length
278
+
279
+ fasteners:
280
+ M2: 2.4
281
+ M3: 3.4
282
+ M4: 4.5
283
+ M5: 5.5
284
+ M6: 6.6
285
+ M8: 9.0
286
+ M10: 11.0
287
+ M12: 13.5
288
+
289
+ fallback_messages:
290
+ design: "I'd love to help shape this design. Could you describe the part's purpose and any size constraints?"
291
+ engineering: "I can help with the structural details. What material and load conditions are we working with?"
292
+ cnc: "I'll check manufacturability once we have more design details. Any machining preferences (3-axis, 5-axis)?"
293
+ cad: "I'm ready to generate the model once the design is agreed upon. Say 'preview' when you're ready."
294
+ ```
295
+
296
+ ### 1.3 `config/settings.py` — Pydantic Settings Class
297
+
298
+ ```python
299
+ from pydantic_settings import BaseSettings
300
+ from pydantic import Field
301
+ from pathlib import Path
302
+ import yaml
303
+
304
+ class Settings(BaseSettings):
305
+ """Single source of truth for all NeuralCAD configuration."""
306
+
307
+ # .env secrets
308
+ anthropic_api_key: str = ""
309
+ openai_api_key: str = ""
310
+ google_api_key: str = ""
311
+
312
+ # Loaded from config.yaml (see model_post_init)
313
+ server: dict = Field(default_factory=dict)
314
+ paths: dict = Field(default_factory=dict)
315
+ backends: dict = Field(default_factory=dict)
316
+ orchestration: dict = Field(default_factory=dict)
317
+ validation: dict = Field(default_factory=dict)
318
+ agents: dict = Field(default_factory=dict)
319
+ routing: dict = Field(default_factory=dict)
320
+ materials: list = Field(default_factory=list)
321
+ material_grades: dict = Field(default_factory=dict)
322
+ dimension_contexts: dict = Field(default_factory=dict)
323
+ fasteners: dict = Field(default_factory=dict)
324
+ fallback_messages: dict = Field(default_factory=dict)
325
+ export: dict = Field(default_factory=dict)
326
+
327
+ model_config = {"env_prefix": "NEURALCAD_", "env_file": ".env"}
328
+
329
+ def model_post_init(self, __context):
330
+ config_path = Path(__file__).parent.parent / "config.yaml"
331
+ if config_path.exists():
332
+ with open(config_path) as f:
333
+ data = yaml.safe_load(f)
334
+ for key, value in data.items():
335
+ if hasattr(self, key) and not getattr(self, key):
336
+ setattr(self, key, value)
337
+
338
+ # Convenience accessors
339
+ @property
340
+ def output_dir(self) -> Path:
341
+ return Path(self.paths.get("output_dir", "./output"))
342
+
343
+ @property
344
+ def web_port(self) -> int:
345
+ return self.server.get("web_port", 5000)
346
+
347
+ @property
348
+ def mcp_port(self) -> int:
349
+ return self.server.get("mcp_port", 8000)
350
+
351
+ @property
352
+ def default_backend(self) -> str:
353
+ return self.backends.get("default", "mock")
354
+
355
+ @property
356
+ def model_for(self) -> dict[str, str]:
357
+ return self.backends.get("models", {})
358
+
359
+ @property
360
+ def max_tokens(self) -> int:
361
+ return self.backends.get("max_tokens", 4096)
362
+
363
+ @property
364
+ def temperature(self) -> float:
365
+ return self.backends.get("temperature", 0.2)
366
+
367
+
368
+ # Singleton — import this everywhere
369
+ settings = Settings()
370
+ ```
371
+
372
+ Every module imports `from config.settings import settings` instead of defining its own constants.
373
+
374
+ ---
375
+
376
+ ## 2. Type System and ABCs
377
+
378
+ ### 2.1 `core/types.py` — Enums, Dataclasses, ABCs
379
+
380
+ ```python
381
+ from abc import ABC, abstractmethod
382
+ from enum import Enum
383
+ from dataclasses import dataclass, field
384
+ from typing import Optional
385
+
386
+ class BackendName(str, Enum):
387
+ MOCK = "mock"
388
+ ANTHROPIC = "anthropic"
389
+ OPENAI = "openai"
390
+ GEMINI = "gemini"
391
+
392
+ class AgentId(str, Enum):
393
+ DESIGN = "design"
394
+ ENGINEERING = "engineering"
395
+ CNC = "cnc"
396
+ CAD = "cad"
397
+
398
+ @dataclass
399
+ class AgentResponse:
400
+ agent_id: str
401
+ agent_name: str
402
+ message: str
403
+ color: str
404
+ avatar: str
405
+ code: Optional[str] = None
406
+
407
+ @dataclass
408
+ class ChatResult:
409
+ responses: list[AgentResponse]
410
+ preview: Optional[dict] = None
411
+ design_state: dict = field(default_factory=dict)
412
+
413
+ class LLMBackend(ABC):
414
+ @abstractmethod
415
+ def generate(self, messages: list[dict]) -> str: ...
416
+
417
+ def generate_with_image(self, messages: list[dict], image_path) -> str:
418
+ raise NotImplementedError(f"{type(self).__name__} does not support vision")
419
+ ```
420
+
421
+ ### 2.2 `agents/base.py` — Orchestrator ABC
422
+
423
+ ```python
424
+ class BaseOrchestrator(ABC):
425
+ def __init__(self, settings: Settings, output_dir: Path):
426
+ self.settings = settings
427
+ self.output_dir = output_dir
428
+ self.output_dir.mkdir(parents=True, exist_ok=True)
429
+
430
+ @abstractmethod
431
+ def chat_turn(
432
+ self,
433
+ message: str,
434
+ history: list[dict],
435
+ mentions: list[str] | None = None,
436
+ design_state: dict | None = None,
437
+ ) -> ChatResult: ...
438
+ ```
439
+
440
+ ---
441
+
442
+ ## 3. Backend Refactoring
443
+
444
+ ### 3.1 Config-Driven Backends
445
+
446
+ Each backend reads from `settings` instead of hardcoding:
447
+
448
+ ```python
449
+ class AnthropicBackend(LLMBackend):
450
+ def __init__(self, model: str | None = None, api_key: str | None = None):
451
+ self.model = model or settings.model_for.get("anthropic", "claude-sonnet-4-20250514")
452
+ self.api_key = api_key or settings.anthropic_api_key
453
+ if not self.api_key:
454
+ raise ValueError("AnthropicBackend requires ANTHROPIC_API_KEY")
455
+ self.client = anthropic.Anthropic(api_key=self.api_key)
456
+
457
+ def generate(self, messages: list[dict]) -> str:
458
+ system, msgs = self._split_system(messages)
459
+ response = self.client.messages.create(
460
+ model=self.model,
461
+ max_tokens=settings.max_tokens,
462
+ temperature=settings.temperature,
463
+ system=system,
464
+ messages=msgs,
465
+ )
466
+ return response.content[0].text
467
+ ```
468
+
469
+ Same pattern for `OpenAIBackend` and `GeminiBackend`. The `_split_system()` helper is extracted to a shared method on `LLMBackend` base class to eliminate the 4x duplication.
470
+
471
+ ### 3.2 Backend Factory
472
+
473
+ ```python
474
+ # core/backend_factory.py
475
+ class BackendFactory:
476
+ _registry: dict[str, type[LLMBackend]] = {}
477
+
478
+ @classmethod
479
+ def register(cls, name: str, backend_cls: type[LLMBackend]):
480
+ cls._registry[name] = backend_cls
481
+
482
+ @classmethod
483
+ def create(cls, name: str) -> LLMBackend:
484
+ if name not in cls._registry:
485
+ raise ValueError(f"Unknown backend: {name}")
486
+ return cls._registry[name]()
487
+
488
+ # Registration at module load
489
+ BackendFactory.register("mock", MockBackend)
490
+ BackendFactory.register("anthropic", AnthropicBackend)
491
+ BackendFactory.register("openai", OpenAIBackend)
492
+ BackendFactory.register("gemini", GeminiBackend)
493
+ ```
494
+
495
+ This replaces every `if backend == "anthropic": ...` scattered across `mcp.py`, `routes.py`, `web.py`, `orchestrator.py`.
496
+
497
+ ### 3.3 MockBackend Strategy Pattern
498
+
499
+ Split the 204-line `_generate_code()`:
500
+
501
+ ```python
502
+ # core/mock/generators.py
503
+ class ShapeGenerator(ABC):
504
+ @abstractmethod
505
+ def generate(self, params: ParsedPrompt) -> list[str]: ...
506
+
507
+ class CylinderGenerator(ShapeGenerator): ...
508
+ class PlateGenerator(ShapeGenerator): ...
509
+ class BoxGenerator(ShapeGenerator): ...
510
+ class LBracketGenerator(ShapeGenerator): ...
511
+
512
+ # core/mock/backend.py
513
+ class MockBackend(LLMBackend):
514
+ _generators = {
515
+ "cylinder": CylinderGenerator(),
516
+ "plate": PlateGenerator(),
517
+ "l_bracket": LBracketGenerator(),
518
+ "box": BoxGenerator(),
519
+ }
520
+
521
+ def _generate_code(self, params: ParsedPrompt) -> str:
522
+ generator = self._generators.get(params.shape, self._generators["box"])
523
+ lines = generator.generate(params)
524
+ return "\n".join(lines)
525
+ ```
526
+
527
+ `ParsedPrompt` is a dataclass produced by `PromptParser` (extracted from `_parse_prompt()`). Thread clearance table loaded from `settings.fasteners`.
528
+
529
+ ---
530
+
531
+ ## 4. Serializers
532
+
533
+ ### 4.1 `core/serializers.py`
534
+
535
+ Eliminates the 4x execution result and 3x validation result duplication in `mcp.py` and `orchestrator.py`:
536
+
537
+ ```python
538
+ class ExecutionResultSerializer:
539
+ @staticmethod
540
+ def to_dict(result: ExecutionResult) -> dict:
541
+ return {
542
+ "success": result.success,
543
+ "volume_mm3": result.volume,
544
+ "bounding_box_mm": list(result.bounding_box) if result.bounding_box else [],
545
+ "face_count": result.face_count,
546
+ "edge_count": result.edge_count,
547
+ "error": result.error,
548
+ }
549
+
550
+ class ValidationResultSerializer:
551
+ @staticmethod
552
+ def to_dict(result: CNCValidationResult) -> dict:
553
+ return {
554
+ "machinable": result.machinable,
555
+ "axis_recommendation": result.axis_recommendation,
556
+ "error_count": result.error_count,
557
+ "warning_count": result.warning_count,
558
+ "issues": [
559
+ {"severity": i.severity, "category": i.category, "message": i.message}
560
+ for i in result.issues
561
+ ],
562
+ }
563
+ ```
564
+
565
+ ---
566
+
567
+ ## 5. CrewAI Overhaul
568
+
569
+ ### 5.1 Hierarchical Process with Manager
570
+
571
+ Replace `Process.sequential` with `Process.hierarchical`. The Design Agent acts as manager — it decides which specialists to involve based on the conversation:
572
+
573
+ ```python
574
+ # agents/crew_orchestrator.py
575
+ crew = Crew(
576
+ agents=crew_agents,
577
+ tasks=crew_tasks,
578
+ process=Process.hierarchical,
579
+ manager_llm=self._build_llm(),
580
+ memory=True,
581
+ verbose=settings.server.get("debug", False),
582
+ )
583
+ ```
584
+
585
+ ### 5.2 Structured Output
586
+
587
+ Replace regex parsing with `output_pydantic`:
588
+
589
+ ```python
590
+ from pydantic import BaseModel
591
+
592
+ class AgentOutput(BaseModel):
593
+ message: str
594
+ code: str | None = None
595
+ reasoning: str = ""
596
+
597
+ # In task creation:
598
+ task = Task(
599
+ description=context,
600
+ expected_output="Structured response with message and optional code",
601
+ output_pydantic=AgentOutput,
602
+ agent=agent,
603
+ )
604
+ ```
605
+
606
+ This eliminates the fragile regex-based code extraction in `_extract_code()`.
607
+
608
+ ### 5.3 CadQuery Tools for CAD Agent
609
+
610
+ ```python
611
+ # agents/tools.py
612
+ from crewai.tools import tool
613
+ from core.executor import execute_cadquery
614
+
615
+ @tool("Execute CadQuery Code")
616
+ def execute_cad_tool(code: str) -> str:
617
+ """Execute CadQuery Python code and return geometry info.
618
+ The code must assign result to a variable called `result` as cq.Workplane.
619
+ Returns: success status, volume, bounding box, face/edge counts, or error message.
620
+ """
621
+ result = execute_cadquery(code)
622
+ return ExecutionResultSerializer.to_dict(result)
623
+
624
+ @tool("Validate CNC Manufacturability")
625
+ def validate_cad_tool(code: str) -> str:
626
+ """Run CNC manufacturability checks on CadQuery code.
627
+ Returns: machinable status, axis recommendation, issues list.
628
+ """
629
+ exec_result = execute_cadquery(code)
630
+ if not exec_result.success:
631
+ return {"error": exec_result.error}
632
+ validation = validate_for_cnc(exec_result.result)
633
+ return ValidationResultSerializer.to_dict(validation)
634
+ ```
635
+
636
+ The CAD agent gets these tools, enabling it to execute code within its reasoning loop and self-correct without the external retry mechanism:
637
+
638
+ ```python
639
+ cad_agent = Agent(
640
+ role=agent_cfg["role"],
641
+ goal=agent_cfg["goal"],
642
+ backstory=agent_cfg["backstory"],
643
+ tools=[execute_cad_tool, validate_cad_tool],
644
+ llm=llm,
645
+ allow_delegation=False,
646
+ verbose=debug,
647
+ )
648
+ ```
649
+
650
+ ### 5.4 Complete LLM Adapter
651
+
652
+ ```python
653
+ class NeuralCADLLMAdapter(BaseLLM):
654
+ def __init__(self, backend: LLMBackend, model: str = "custom"):
655
+ super().__init__(model=model)
656
+ self.backend = backend
657
+
658
+ def call(self, messages, tools=None, callbacks=None, **kwargs) -> str:
659
+ if isinstance(messages, str):
660
+ messages = [{"role": "user", "content": messages}]
661
+ return self.backend.generate(messages)
662
+
663
+ def supports_function_calling(self) -> bool:
664
+ return True # Enable tool use
665
+
666
+ def supports_stop_words(self) -> bool:
667
+ return False
668
+
669
+ def supports_vision(self) -> bool:
670
+ return hasattr(self.backend, 'generate_with_image')
671
+ ```
672
+
673
+ Key change: `supports_function_calling` returns `True` so CrewAI can route tool calls through the adapter.
674
+
675
+ ### 5.5 Memory Integration
676
+
677
+ CrewAI's built-in memory replaces manual `DesignState` tracking for the crew orchestrator:
678
+
679
+ ```python
680
+ crew = Crew(
681
+ agents=crew_agents,
682
+ tasks=crew_tasks,
683
+ process=Process.hierarchical,
684
+ manager_llm=llm,
685
+ memory=True, # Enables short-term + entity memory
686
+ )
687
+ ```
688
+
689
+ `DesignState` remains for the single-call and mock orchestrators (which don't use CrewAI), but the crew orchestrator delegates state to CrewAI's memory system. The `extract_decisions()` function is still called post-hoc to populate the response's `design_state` field for frontend compatibility.
690
+
691
+ ### 5.6 Delegation
692
+
693
+ Enable delegation for engineering and CNC agents so they can hand off questions to each other:
694
+
695
+ ```python
696
+ engineering_agent = Agent(
697
+ ...
698
+ allow_delegation=True, # Can ask CNC about manufacturability
699
+ )
700
+ cnc_agent = Agent(
701
+ ...
702
+ allow_delegation=True, # Can ask engineering about specs
703
+ )
704
+ cad_agent = Agent(
705
+ ...
706
+ allow_delegation=False, # Only generates code, no delegation
707
+ )
708
+ ```
709
+
710
+ ### 5.7 Knowledge Base (RAG over CadQuery Docs)
711
+
712
+ CrewAI ships chromadb but NeuralCAD never uses it. Add a knowledge base so agents can look up CadQuery API methods, material properties, and machining guidelines instead of relying on LLM training data alone:
713
+
714
+ ```python
715
+ # agents/knowledge.py
716
+ from crewai.knowledge.source import TextFileKnowledgeSource
717
+
718
+ def build_knowledge_sources(settings: Settings) -> list:
719
+ """Load knowledge sources from docs/ directory."""
720
+ sources = []
721
+ knowledge_dir = Path(settings.paths.get("knowledge_dir", "./docs/knowledge"))
722
+ if knowledge_dir.exists():
723
+ for md_file in knowledge_dir.glob("*.md"):
724
+ sources.append(TextFileKnowledgeSource(file_paths=[str(md_file)]))
725
+ return sources
726
+ ```
727
+
728
+ Knowledge files to create:
729
+
730
+ | File | Content | Benefits |
731
+ |------|---------|----------|
732
+ | `docs/knowledge/cadquery_api.md` | CadQuery method signatures, parameter docs, common patterns | CAD agent generates correct code on first try |
733
+ | `docs/knowledge/cnc_guidelines.md` | Wall thickness limits, pocket ratios, tool access rules, axis selection criteria | CNC agent gives precise manufacturability advice |
734
+ | `docs/knowledge/materials.md` | Material properties, grades, machinability ratings, cost tiers | Engineering agent makes informed material recommendations |
735
+ | `docs/knowledge/fasteners.md` | ISO clearance/tapping hole sizes, torque specs, strength grades | Engineering agent specifies correct hole sizes |
736
+
737
+ Usage in crew creation:
738
+
739
+ ```python
740
+ from agents.knowledge import build_knowledge_sources
741
+
742
+ crew = Crew(
743
+ agents=crew_agents,
744
+ tasks=crew_tasks,
745
+ process=Process.hierarchical,
746
+ manager_llm=llm,
747
+ memory=True,
748
+ knowledge_sources=build_knowledge_sources(settings),
749
+ )
750
+ ```
751
+
752
+ Config addition to `config.yaml`:
753
+
754
+ ```yaml
755
+ paths:
756
+ knowledge_dir: ./docs/knowledge
757
+ ```
758
+
759
+ ### 5.8 Guardrails (Output Validation with Auto-Retry)
760
+
761
+ Replace the manual retry loop in `_execute_cad_code()` with CrewAI's built-in guardrail system. When the CAD agent produces invalid code, CrewAI automatically retries with the error feedback:
762
+
763
+ ```python
764
+ # agents/guardrails.py
765
+ from core.executor import execute_cadquery
766
+
767
+ def validate_cad_output(output) -> tuple[bool, str]:
768
+ """Guardrail: validate CAD agent output contains executable code."""
769
+ if not hasattr(output, 'code') or not output.code:
770
+ return (True, "") # No code in output is OK (conversational response)
771
+
772
+ code = output.code
773
+ if "result" not in code:
774
+ return (False, "Code must assign the final solid to a variable called `result` as a cq.Workplane.")
775
+
776
+ # Quick syntax check (don't execute — the tool handles that)
777
+ try:
778
+ compile(code, "<cad_output>", "exec")
779
+ except SyntaxError as e:
780
+ return (False, f"Syntax error in generated code: {e}")
781
+
782
+ return (True, "")
783
+ ```
784
+
785
+ Usage in task creation:
786
+
787
+ ```python
788
+ cad_task = Task(
789
+ description="Generate CadQuery code based on the agreed design...",
790
+ expected_output="Valid CadQuery Python code",
791
+ output_pydantic=AgentOutput,
792
+ agent=cad_agent,
793
+ guardrail=validate_cad_output, # Auto-retries on failure
794
+ context=[design_task, engineering_task],
795
+ )
796
+ ```
797
+
798
+ This replaces the manual `while not exec_result.success and retries < max_retries` loop in `orchestrator.py:76-89`. CrewAI handles the retry internally with the error message fed back to the agent.
799
+
800
+ ### 5.9 Task Context (Agent-to-Agent Data Flow)
801
+
802
+ Instead of building an identical context blob for all agents, use CrewAI's task context system so each agent sees the output of previous agents:
803
+
804
+ ```python
805
+ # Design goes first — gets only user message + history
806
+ design_task = Task(
807
+ description=f"User says: {message}\n\nPropose a design approach.",
808
+ expected_output="Design proposal with shape and form factor",
809
+ output_pydantic=AgentOutput,
810
+ agent=design_agent,
811
+ )
812
+
813
+ # Engineering sees design output
814
+ engineering_task = Task(
815
+ description=f"User says: {message}\n\nSpecify engineering details.",
816
+ expected_output="Dimensions, materials, fastener specs",
817
+ output_pydantic=AgentOutput,
818
+ agent=engineering_agent,
819
+ context=[design_task], # Receives design agent's output
820
+ )
821
+
822
+ # CNC sees both design and engineering output
823
+ cnc_task = Task(
824
+ description=f"User says: {message}\n\nAssess manufacturability.",
825
+ expected_output="CNC feasibility, axis recommendation, issues",
826
+ output_pydantic=AgentOutput,
827
+ agent=cnc_agent,
828
+ context=[design_task, engineering_task],
829
+ )
830
+
831
+ # CAD sees all three — has full agreed spec
832
+ cad_task = Task(
833
+ description=f"Generate CadQuery code for the agreed design.",
834
+ expected_output="Valid CadQuery Python code",
835
+ output_pydantic=AgentOutput,
836
+ agent=cad_agent,
837
+ guardrail=validate_cad_output,
838
+ context=[design_task, engineering_task, cnc_task],
839
+ )
840
+ ```
841
+
842
+ This replaces `_build_agent_context()` which currently dumps the same blob to everyone. Each agent now builds on the previous agent's actual output rather than reasoning in isolation.
843
+
844
+ ### 5.10 Callbacks for Observability
845
+
846
+ Replace `verbose=False` (which hides all reasoning) with structured callbacks that can be logged and optionally streamed to the frontend:
847
+
848
+ ```python
849
+ # agents/callbacks.py
850
+ import logging
851
+
852
+ logger = logging.getLogger(__name__)
853
+
854
+ def on_task_start(task):
855
+ """Called when an agent starts working on a task."""
856
+ logger.info("Agent %s starting task: %s", task.agent.role, task.description[:80])
857
+
858
+ def on_task_complete(task_output):
859
+ """Called when an agent finishes a task."""
860
+ logger.info(
861
+ "Agent %s completed — output length: %d chars",
862
+ task_output.agent,
863
+ len(str(task_output.raw)),
864
+ )
865
+
866
+ def on_step(step_output):
867
+ """Called on each reasoning step (thought/action/observation)."""
868
+ logger.debug("Step: %s", str(step_output)[:200])
869
+ ```
870
+
871
+ Usage:
872
+
873
+ ```python
874
+ crew = Crew(
875
+ agents=crew_agents,
876
+ tasks=crew_tasks,
877
+ process=Process.hierarchical,
878
+ manager_llm=llm,
879
+ memory=True,
880
+ knowledge_sources=build_knowledge_sources(settings),
881
+ task_callback=on_task_complete,
882
+ step_callback=on_step,
883
+ )
884
+ ```
885
+
886
+ Future enhancement: these callbacks could push events to the frontend via SSE for real-time per-agent typing indicators (e.g., "Engineering Agent is thinking...").
887
+
888
+ ### 5.11 Planning Mode
889
+
890
+ Enable the hierarchical manager to create an execution plan before dispatching tasks:
891
+
892
+ ```python
893
+ crew = Crew(
894
+ agents=crew_agents,
895
+ tasks=crew_tasks,
896
+ process=Process.hierarchical,
897
+ manager_llm=llm,
898
+ planning=True, # Manager creates plan first
899
+ planning_llm=llm, # LLM used for planning step
900
+ memory=True,
901
+ knowledge_sources=build_knowledge_sources(settings),
902
+ task_callback=on_task_complete,
903
+ step_callback=on_step,
904
+ )
905
+ ```
906
+
907
+ With planning enabled, the manager agent first analyzes the user's message and creates a step-by-step plan: "1. Ask Design about form factor, 2. Ask Engineering about M6 hole placement, 3. Skip CNC (simple geometry), 4. Have CAD generate code." This is smarter than always running all agents — for simple requests it may only activate 1-2 agents.
908
+
909
+ Config addition to `config.yaml`:
910
+
911
+ ```yaml
912
+ crewai:
913
+ planning: true
914
+ verbose: false
915
+ memory: true
916
+ knowledge_dir: ./docs/knowledge
917
+ ```
918
+
919
+ ### 5.12 CrewAI Utilization Summary
920
+
921
+ | Feature | Before (8%) | After (~80%) | Impact |
922
+ |---------|-------------|--------------|--------|
923
+ | Process | sequential | hierarchical + planning | Manager coordinates; skips irrelevant agents |
924
+ | Output | Regex parsing | output_pydantic + guardrails | Type-safe; auto-retry on invalid output |
925
+ | Tools | Disabled | execute_cad, validate_cad | CAD agent self-corrects within reasoning |
926
+ | Memory | Manual DesignState only | CrewAI short-term + entity memory | Agents remember context across reasoning steps |
927
+ | Knowledge | None | RAG over CadQuery docs, materials, CNC guidelines | Agents access reference data, not just training |
928
+ | Context | Identical blob to all | Task context chains (design → engineering → CNC → CAD) | Each agent builds on previous outputs |
929
+ | Delegation | Disabled | Engineering ↔ CNC | Agents consult each other when needed |
930
+ | Observability | verbose=False | Structured callbacks (logging + future SSE) | Debug agent reasoning, surface to frontend |
931
+ | Planning | None | Manager plans before executing | Smarter agent selection per turn |
932
+ | Validation | Manual retry loop | Guardrails with auto-retry | Cleaner code, CrewAI handles retry logic |
933
+
934
+ Features deliberately NOT used:
935
+
936
+ | Feature | Reason |
937
+ |---------|--------|
938
+ | LongTermMemory (SQLite) | DesignState + localStorage already covers persistence |
939
+ | Flows (multi-crew pipelines) | One crew per turn is sufficient |
940
+ | Training (human feedback) | Requires feedback infrastructure; premature |
941
+ | Telemetry | Sends data to CrewAI servers; privacy concern |
942
+
943
+ ---
944
+
945
+ ## 6. DesignState as Proper Domain Model
946
+
947
+ Move `extract_decisions()` into `DesignState` as a method:
948
+
949
+ ```python
950
+ class DesignState(BaseModel):
951
+ part_name: str = ""
952
+ description: str = ""
953
+ material: str = ""
954
+ dimensions: dict[str, float] = Field(default_factory=dict)
955
+ features: list[str] = Field(default_factory=list)
956
+ constraints: list[str] = Field(default_factory=list)
957
+ decisions: list[str] = Field(default_factory=list)
958
+ axis_recommendation: str = ""
959
+
960
+ def update_from_messages(
961
+ self, agent_responses: list[dict], user_message: str = ""
962
+ ) -> "DesignState":
963
+ """Extract decisions from agent responses and return updated state."""
964
+ # All the logic currently in extract_decisions() moves here
965
+ ...
966
+
967
+ def render(self) -> str:
968
+ """Render as concise spec block for LLM context."""
969
+ ...
970
+ ```
971
+
972
+ Materials list, material grades, dimension contexts all loaded from `settings` instead of module-level constants.
973
+
974
+ ---
975
+
976
+ ## 7. Routing Engine
977
+
978
+ ```python
979
+ # agents/routing.py
980
+ class RoutingEngine:
981
+ def __init__(self, settings: Settings):
982
+ self.keywords = settings.routing.get("keywords", {})
983
+ self.cad_triggers = settings.routing.get("cad_trigger_keywords", [])
984
+ self.max_agents = settings.orchestration.get("max_active_agents", 3)
985
+
986
+ def route(self, message: str) -> list[str]:
987
+ """Return list of agent IDs that should respond."""
988
+ lower = message.lower()
989
+ scores = {agent_id: 0 for agent_id in self.keywords}
990
+ for agent_id, kws in self.keywords.items():
991
+ for kw in kws:
992
+ if kw in lower:
993
+ scores[agent_id] += 1
994
+ active = [aid for aid, score in sorted(scores.items(), key=lambda x: -x[1]) if score > 0]
995
+ if not active:
996
+ active = [AgentId.DESIGN, AgentId.ENGINEERING]
997
+ return active[:self.max_agents]
998
+
999
+ def has_cad_trigger(self, message: str) -> bool:
1000
+ lower = message.lower()
1001
+ return any(kw in lower for kw in self.cad_triggers)
1002
+
1003
+ def parse_mentions(self, message: str) -> tuple[str, list[str]]:
1004
+ """Extract @mentions from message."""
1005
+ ...
1006
+ ```
1007
+
1008
+ This replaces the free functions `route_by_keywords()`, `parse_mentions()`, and the scattered `CAD_TRIGGER_KEYWORDS` checks.
1009
+
1010
+ ---
1011
+
1012
+ ## 8. Prompt System
1013
+
1014
+ Move prompt templates from Python string literals to Jinja2 files:
1015
+
1016
+ ### `agents/prompts/orchestrator.j2`
1017
+
1018
+ ```jinja2
1019
+ You are the orchestrator for a multi-agent CAD design team.
1020
+ You control multiple specialist agents who collaborate with a user
1021
+ to design mechanical parts for CNC machining.
1022
+
1023
+ ## Your Agents
1024
+ {% for agent_id in active_agents %}
1025
+ ### {{ agents[agent_id].name }} (id: "{{ agent_id }}")
1026
+ Role: {{ agents[agent_id].role }}
1027
+ Goal: {{ agents[agent_id].goal }}
1028
+ Personality: {{ agents[agent_id].backstory }}
1029
+ {% endfor %}
1030
+
1031
+ ## Instructions
1032
+ ...
1033
+ ```
1034
+
1035
+ ### `agents/prompt_builder.py`
1036
+
1037
+ ```python
1038
+ class PromptBuilder:
1039
+ def __init__(self, settings: Settings):
1040
+ self.env = jinja2.Environment(
1041
+ loader=jinja2.FileSystemLoader(settings.paths.get("prompts_dir", "./agents/prompts"))
1042
+ )
1043
+ self.agents = settings.agents
1044
+
1045
+ def build_orchestrator_prompt(self, active_agents: list[str], include_cad: bool = False) -> str:
1046
+ template = self.env.get_template("orchestrator.j2")
1047
+ return template.render(active_agents=active_agents, agents=self.agents, include_cad=include_cad)
1048
+ ```
1049
+
1050
+ ---
1051
+
1052
+ ## 9. Server Cleanup
1053
+
1054
+ ### 9.1 Single OUTPUT_DIR
1055
+
1056
+ All three files (`web.py`, `mcp.py`, `routes.py`) import from settings:
1057
+
1058
+ ```python
1059
+ from config.settings import settings
1060
+ OUTPUT_DIR = settings.output_dir
1061
+ ```
1062
+
1063
+ ### 9.2 MCPClient Class
1064
+
1065
+ ```python
1066
+ # server/mcp_client.py
1067
+ class MCPClient:
1068
+ def __init__(self, url: str):
1069
+ self.url = url
1070
+
1071
+ async def call_tool(self, name: str, arguments: dict) -> dict:
1072
+ ...
1073
+
1074
+ async def read_resource(self, uri: str) -> str:
1075
+ ...
1076
+ ```
1077
+
1078
+ ### 9.3 Pydantic Models for All Endpoints
1079
+
1080
+ Replace `body: dict` in `web.py` with proper Pydantic models:
1081
+
1082
+ ```python
1083
+ class GenerateRequest(BaseModel):
1084
+ prompt: str
1085
+ backend: str = "mock"
1086
+ part_name: str = ""
1087
+ max_retries: int = 2
1088
+ ```
1089
+
1090
+ ### 9.4 Backend Selection via Factory
1091
+
1092
+ Replace scattered `if backend_name in ("anthropic", "openai", "gemini"):` with:
1093
+
1094
+ ```python
1095
+ orchestrator = OrchestratorFactory.create(backend_name, settings)
1096
+ ```
1097
+
1098
+ ---
1099
+
1100
+ ## 10. File Structure After Refactor
1101
+
1102
+ ```
1103
+ NeuralCAD/
1104
+ ├── config.yaml # All non-secret config
1105
+ ├── .env.example # Template for secrets
1106
+ ├── config/
1107
+ │ └── settings.py # Pydantic Settings singleton
1108
+ ├── core/
1109
+ │ ├── types.py # BackendName, AgentId enums; AgentResponse, ChatResult dataclasses; LLMBackend ABC
1110
+ │ ├── backends.py # Anthropic/OpenAI/Gemini backends (config-driven)
1111
+ │ ├── backend_factory.py # BackendFactory registry
1112
+ │ ├── mock/
1113
+ │ │ ├── __init__.py
1114
+ │ │ ├── backend.py # MockBackend (thin, delegates to parser + generators)
1115
+ │ │ ├── parser.py # PromptParser (extracted from _parse_prompt)
1116
+ │ │ └── generators.py # ShapeGenerator ABC + Cylinder/Plate/Box/LBracket
1117
+ │ ├── executor.py # (cleaned, tolerance from config)
1118
+ │ ├── validator.py # (thresholds from config)
1119
+ │ ├── serializers.py # ExecutionResultSerializer, ValidationResultSerializer
1120
+ │ ├── cadquery_prompts.py # (unchanged — few-shot examples stay here)
1121
+ │ └── pipeline.py # (uses factory + config)
1122
+ ├── agents/
1123
+ │ ├── base.py # BaseOrchestrator ABC
1124
+ │ ├── orchestrator.py # SingleCallOrchestrator + MockChatBackend (inherit BaseOrchestrator)
1125
+ │ ├── crew_orchestrator.py # CrewOrchestrator: hierarchical, tools, memory, structured output
1126
+ │ ├── llm_adapter.py # Complete NeuralCADLLMAdapter
1127
+ │ ├── tools.py # @tool decorated execute_cad_tool, validate_cad_tool
1128
+ │ ├── design_state.py # DesignState with update_from_messages() method
1129
+ │ ├── routing.py # RoutingEngine class
1130
+ │ ├── prompt_builder.py # PromptBuilder (Jinja2)
1131
+ │ └── prompts/
1132
+ │ ├── orchestrator.j2 # Orchestrator system prompt template
1133
+ │ └── agent_persona.j2 # Per-agent persona template
1134
+ ├── server/
1135
+ │ ├── web.py # (uses settings, MCPClient, Pydantic models)
1136
+ │ ├── routes.py # (uses factory, settings)
1137
+ │ ├── mcp.py # (uses serializers, factory)
1138
+ │ └── mcp_client.py # MCPClient class
1139
+ ├── web/
1140
+ │ └── index.html # (unchanged)
1141
+ └── tests/ # (updated to match new structure)
1142
+ ```
1143
+
1144
+ ### New Files
1145
+
1146
+ | File | Purpose |
1147
+ |------|---------|
1148
+ | `config.yaml` | All non-secret application configuration |
1149
+ | `.env.example` | Template showing required environment variables |
1150
+ | `config/settings.py` | Pydantic Settings singleton |
1151
+ | `core/types.py` | Enums, dataclasses, ABCs |
1152
+ | `core/backend_factory.py` | Backend registry + factory |
1153
+ | `core/mock/__init__.py` | Mock package init |
1154
+ | `core/mock/backend.py` | Thin MockBackend |
1155
+ | `core/mock/parser.py` | PromptParser |
1156
+ | `core/mock/generators.py` | Shape strategy classes |
1157
+ | `core/serializers.py` | Result serializers |
1158
+ | `agents/base.py` | BaseOrchestrator ABC |
1159
+ | `agents/tools.py` | CrewAI tool definitions |
1160
+ | `agents/routing.py` | RoutingEngine class |
1161
+ | `agents/prompt_builder.py` | Jinja2 prompt builder |
1162
+ | `agents/prompts/orchestrator.j2` | System prompt template |
1163
+ | `agents/prompts/agent_persona.j2` | Agent persona template |
1164
+ | `server/mcp_client.py` | MCPClient class |
1165
+
1166
+ ### Modified Files
1167
+
1168
+ | File | Changes |
1169
+ |------|---------|
1170
+ | `core/backends.py` | Remove LLMBackend (moved to types.py), config-drive all backends, extract message helpers to base |
1171
+ | `core/executor.py` | STL tolerance from config |
1172
+ | `core/validator.py` | All thresholds from config |
1173
+ | `core/pipeline.py` | Use BackendFactory, config |
1174
+ | `agents/orchestrator.py` | Inherit BaseOrchestrator, use RoutingEngine, PromptBuilder, config |
1175
+ | `agents/crew_orchestrator.py` | Hierarchical process, tools, memory, structured output, config |
1176
+ | `agents/llm_adapter.py` | Complete interface, enable function calling |
1177
+ | `agents/design_state.py` | Move extract_decisions into method, load domain data from config |
1178
+ | `agents/definitions.py` | Remove (replaced by config.yaml agents section) |
1179
+ | `agents/prompts.py` | Remove (replaced by routing.py + prompt_builder.py) |
1180
+ | `server/web.py` | Use settings, MCPClient, Pydantic models |
1181
+ | `server/routes.py` | Use settings, factory |
1182
+ | `server/mcp.py` | Use serializers, factory, settings |
1183
+ | `pyproject.toml` | Add pydantic-settings, jinja2, pyyaml deps |
1184
+
1185
+ ### Deleted Files
1186
+
1187
+ | File | Replaced By |
1188
+ |------|-------------|
1189
+ | `agents/definitions.py` | `config.yaml` agents section + `core/types.py` AgentId enum |
1190
+ | `agents/prompts.py` | `agents/routing.py` + `agents/prompt_builder.py` + `agents/prompts/*.j2` |
1191
+
1192
+ ---
1193
+
1194
+ ## 11. Testing Strategy
1195
+
1196
+ All 107 existing tests must pass after refactor. Changes needed:
1197
+
1198
+ - Tests that import from `agents.definitions` update to import from `config.settings`
1199
+ - Tests that import `parse_mentions`, `route_by_keywords` from `agents.prompts` update to use `RoutingEngine`
1200
+ - Tests that import `_format_response` update to use `AgentResponse` dataclass
1201
+ - `FakeLLMBackend` in conftest updated to inherit from `LLMBackend` ABC
1202
+ - New tests added for: `Settings`, `BackendFactory`, `RoutingEngine`, `PromptBuilder`, serializers, CrewAI tools
1203
+
1204
+ ### New Tests
1205
+
1206
+ | File | Tests |
1207
+ |------|-------|
1208
+ | `tests/test_settings.py` | Config loading, env override, defaults, property accessors |
1209
+ | `tests/test_backend_factory.py` | Registration, creation, unknown backend error |
1210
+ | `tests/test_routing.py` | RoutingEngine.route(), has_cad_trigger(), parse_mentions() |
1211
+ | `tests/test_serializers.py` | ExecutionResultSerializer, ValidationResultSerializer |
1212
+ | `tests/test_tools.py` | execute_cad_tool, validate_cad_tool (requires CadQuery) |
1213
+ | `tests/test_mock_generators.py` | Each shape generator independently |
1214
+
1215
+ ---
1216
+
1217
+ ## 12. Migration Strategy
1218
+
1219
+ The refactor is done in phases to keep tests green at each step:
1220
+
1221
+ 1. **Config foundation**: Add `config.yaml`, `config/settings.py`, `.env.example`. All existing code still works — settings just aren't used yet.
1222
+ 2. **Types and ABCs**: Add `core/types.py`, `agents/base.py`. Existing classes don't inherit yet.
1223
+ 3. **Backend refactor**: Config-drive backends, add factory, add ABC inheritance. Update imports.
1224
+ 4. **Serializers**: Extract duplicated serialization. Update `mcp.py` and `orchestrator.py`.
1225
+ 5. **Mock strategy**: Split MockBackend into parser + generators.
1226
+ 6. **Routing + prompts**: Extract RoutingEngine and PromptBuilder. Delete `agents/prompts.py` and `agents/definitions.py`.
1227
+ 7. **Orchestrator refactor**: Inherit BaseOrchestrator. Use RoutingEngine, PromptBuilder, config.
1228
+ 8. **DesignState refactor**: Move extract_decisions into method.
1229
+ 9. **CrewAI overhaul**: Hierarchical process, tools, memory, structured output, complete adapter.
1230
+ 10. **Server cleanup**: MCPClient, Pydantic models, settings everywhere.
1231
+ 11. **Test migration**: Update all test imports, add new tests.
1232
+ 12. **Final verification**: Full suite green, manual smoke test.
pyproject.toml CHANGED
@@ -16,6 +16,8 @@ dependencies = [
16
  "fastapi>=0.110.0",
17
  "uvicorn>=0.29.0",
18
  "python-multipart>=0.0.9",
 
 
19
  ]
20
 
21
  [dependency-groups]
 
16
  "fastapi>=0.110.0",
17
  "uvicorn>=0.29.0",
18
  "python-multipart>=0.0.9",
19
+ "pydantic-settings>=2.0.0",
20
+ "pyyaml>=6.0",
21
  ]
22
 
23
  [dependency-groups]
server/mcp.py CHANGED
@@ -17,14 +17,14 @@ Usage:
17
 
18
  import json
19
  import os
20
- import sys
21
  from pathlib import Path
22
 
23
  from mcp.server.fastmcp import FastMCP
24
 
25
  from core.cadquery_prompts import build_messages, CADQUERY_SYSTEM_PROMPT
26
- from core.executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
27
- from core.validator import validate_for_cnc, CNCValidationResult
 
28
 
29
  # ── Server Setup ──────────────────────────────────────────────────────────
30
 
@@ -117,27 +117,11 @@ def generate_cnc_model(
117
  "part_name": part_name,
118
  "retries": result.retry_count,
119
  "generated_code": result.generated_code,
120
- "execution": {
121
- "success": result.execution.success,
122
- "volume_mm3": result.execution.volume,
123
- "bounding_box_mm": list(result.execution.bounding_box) if result.execution.bounding_box else [],
124
- "face_count": result.execution.face_count,
125
- "edge_count": result.execution.edge_count,
126
- "error": result.execution.error,
127
- },
128
  }
129
 
130
  if result.validation:
131
- response["validation"] = {
132
- "machinable": result.validation.machinable,
133
- "axis_recommendation": result.validation.axis_recommendation,
134
- "error_count": result.validation.error_count,
135
- "warning_count": result.validation.warning_count,
136
- "issues": [
137
- {"severity": i.severity, "category": i.category, "message": i.message}
138
- for i in result.validation.issues
139
- ],
140
- }
141
 
142
  if result.exported_files:
143
  response["exported_files"] = {
@@ -189,14 +173,7 @@ def validate_cnc_model(
189
  }
190
  validation = validate_for_cnc(exec_result.result, part_name=part_name, config=config)
191
  response["validation"] = {
192
- "machinable": validation.machinable,
193
- "axis_recommendation": validation.axis_recommendation,
194
- "error_count": validation.error_count,
195
- "warning_count": validation.warning_count,
196
- "issues": [
197
- {"severity": i.severity, "category": i.category, "message": i.message}
198
- for i in validation.issues
199
- ],
200
  "summary": validation.summary(),
201
  }
202
 
@@ -231,13 +208,8 @@ def execute_cadquery_code(
231
  exec_result = execute_cadquery(code)
232
 
233
  response = {
234
- "success": exec_result.success,
235
- "error": exec_result.error,
236
  "stdout": exec_result.stdout,
237
- "volume_mm3": exec_result.volume,
238
- "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
239
- "face_count": exec_result.face_count,
240
- "edge_count": exec_result.edge_count,
241
  }
242
 
243
  if exec_result.success and export_path:
@@ -358,28 +330,12 @@ def generate_from_image(
358
  "backend": backend,
359
  "retries": retry_count,
360
  "generated_code": generated_code,
361
- "execution": {
362
- "success": exec_result.success,
363
- "volume_mm3": exec_result.volume,
364
- "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
365
- "face_count": exec_result.face_count,
366
- "edge_count": exec_result.edge_count,
367
- "error": exec_result.error,
368
- },
369
  }
370
 
371
  if exec_result.success:
372
  validation = validate_for_cnc(exec_result.result, part_name=part_name)
373
- response["validation"] = {
374
- "machinable": validation.machinable,
375
- "axis_recommendation": validation.axis_recommendation,
376
- "error_count": validation.error_count,
377
- "warning_count": validation.warning_count,
378
- "issues": [
379
- {"severity": i.severity, "category": i.category, "message": i.message}
380
- for i in validation.issues
381
- ],
382
- }
383
 
384
  base_path = DEFAULT_OUTPUT_DIR / part_name
385
  try:
 
17
 
18
  import json
19
  import os
 
20
  from pathlib import Path
21
 
22
  from mcp.server.fastmcp import FastMCP
23
 
24
  from core.cadquery_prompts import build_messages, CADQUERY_SYSTEM_PROMPT
25
+ from core.executor import execute_cadquery, export_all
26
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
27
+ from core.validator import validate_for_cnc
28
 
29
  # ── Server Setup ──────────────────────────────────────────────────────────
30
 
 
117
  "part_name": part_name,
118
  "retries": result.retry_count,
119
  "generated_code": result.generated_code,
120
+ "execution": ExecutionResultSerializer.to_dict(result.execution),
 
 
 
 
 
 
 
121
  }
122
 
123
  if result.validation:
124
+ response["validation"] = ValidationResultSerializer.to_dict(result.validation)
 
 
 
 
 
 
 
 
 
125
 
126
  if result.exported_files:
127
  response["exported_files"] = {
 
173
  }
174
  validation = validate_for_cnc(exec_result.result, part_name=part_name, config=config)
175
  response["validation"] = {
176
+ **ValidationResultSerializer.to_dict(validation),
 
 
 
 
 
 
 
177
  "summary": validation.summary(),
178
  }
179
 
 
208
  exec_result = execute_cadquery(code)
209
 
210
  response = {
211
+ **ExecutionResultSerializer.to_dict(exec_result),
 
212
  "stdout": exec_result.stdout,
 
 
 
 
213
  }
214
 
215
  if exec_result.success and export_path:
 
330
  "backend": backend,
331
  "retries": retry_count,
332
  "generated_code": generated_code,
333
+ "execution": ExecutionResultSerializer.to_dict(exec_result),
 
 
 
 
 
 
 
334
  }
335
 
336
  if exec_result.success:
337
  validation = validate_for_cnc(exec_result.result, part_name=part_name)
338
+ response["validation"] = ValidationResultSerializer.to_dict(validation)
 
 
 
 
 
 
 
 
 
339
 
340
  base_path = DEFAULT_OUTPUT_DIR / part_name
341
  try:
tests/test_backend_factory.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/backend_factory.py."""
2
+ import pytest
3
+ from core.backend_factory import BackendFactory
4
+ from core.types import LLMBackend
5
+
6
+
7
+ class TestBackendFactory:
8
+ def test_create_mock(self):
9
+ backend = BackendFactory.create("mock")
10
+ # MockBackend inherits from old LLMBackend, not new ABC yet
11
+ # Just check it has generate method
12
+ assert hasattr(backend, "generate")
13
+
14
+ def test_create_unknown_raises(self):
15
+ with pytest.raises(ValueError, match="Unknown backend"):
16
+ BackendFactory.create("nonexistent")
17
+
18
+ def test_registry_has_mock(self):
19
+ assert "mock" in BackendFactory._registry
20
+
21
+ def test_mock_can_generate(self):
22
+ backend = BackendFactory.create("mock")
23
+ result = backend.generate([{"role": "user", "content": "a 50mm cube"}])
24
+ assert isinstance(result, str)
25
+ assert "result" in result
26
+
27
+ def test_create_safe_fallback(self):
28
+ backend = BackendFactory.create_safe("nonexistent_backend_xyz")
29
+ assert hasattr(backend, "generate")
tests/test_serializers.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/serializers.py."""
2
+ from core.serializers import ExecutionResultSerializer, ValidationResultSerializer
3
+
4
+
5
+ class TestExecutionResultSerializer:
6
+ def test_success(self):
7
+ class FakeResult:
8
+ success = True
9
+ volume = 6000.0
10
+ bounding_box = (10.0, 20.0, 30.0)
11
+ face_count = 6
12
+ edge_count = 12
13
+ error = None
14
+
15
+ d = ExecutionResultSerializer.to_dict(FakeResult())
16
+ assert d["success"] is True
17
+ assert d["volume_mm3"] == 6000.0
18
+ assert d["bounding_box_mm"] == [10.0, 20.0, 30.0]
19
+ assert d["face_count"] == 6
20
+ assert d["error"] is None
21
+
22
+ def test_failure(self):
23
+ class FakeResult:
24
+ success = False
25
+ volume = 0.0
26
+ bounding_box = ()
27
+ face_count = 0
28
+ edge_count = 0
29
+ error = "syntax error"
30
+
31
+ d = ExecutionResultSerializer.to_dict(FakeResult())
32
+ assert d["success"] is False
33
+ assert d["error"] == "syntax error"
34
+ assert d["bounding_box_mm"] == []
35
+
36
+
37
+ class TestValidationResultSerializer:
38
+ def test_basic(self):
39
+ class FakeIssue:
40
+ severity = "warning"
41
+ category = "Size"
42
+ message = "Part is large"
43
+
44
+ class FakeResult:
45
+ machinable = True
46
+ axis_recommendation = "3-axis"
47
+ error_count = 0
48
+ warning_count = 1
49
+ issues = [FakeIssue()]
50
+
51
+ d = ValidationResultSerializer.to_dict(FakeResult())
52
+ assert d["machinable"] is True
53
+ assert d["axis_recommendation"] == "3-axis"
54
+ assert len(d["issues"]) == 1
55
+ assert d["issues"][0]["severity"] == "warning"
56
+
57
+ def test_empty_issues(self):
58
+ class FakeResult:
59
+ machinable = True
60
+ axis_recommendation = "3-axis"
61
+ error_count = 0
62
+ warning_count = 0
63
+ issues = []
64
+
65
+ d = ValidationResultSerializer.to_dict(FakeResult())
66
+ assert d["issues"] == []
tests/test_settings.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for config/settings.py."""
2
+ from pathlib import Path
3
+
4
+
5
+ class TestSettings:
6
+ def test_loads_config_yaml(self):
7
+ from config.settings import settings
8
+ assert settings.agents
9
+ assert "design" in settings.agents
10
+
11
+ def test_output_dir_property(self):
12
+ from config.settings import settings
13
+ assert isinstance(settings.output_dir, Path)
14
+
15
+ def test_web_port_property(self):
16
+ from config.settings import settings
17
+ assert isinstance(settings.web_port, int)
18
+ assert settings.web_port > 0
19
+
20
+ def test_model_for_property(self):
21
+ from config.settings import settings
22
+ models = settings.model_for
23
+ assert "anthropic" in models
24
+ assert "openai" in models
25
+ assert "gemini" in models
26
+
27
+ def test_max_tokens_property(self):
28
+ from config.settings import settings
29
+ assert settings.max_tokens == 4096
30
+
31
+ def test_temperature_property(self):
32
+ from config.settings import settings
33
+ assert settings.temperature == 0.2
34
+
35
+ def test_validation_config(self):
36
+ from config.settings import settings
37
+ assert settings.validation["min_wall_thickness_mm"] == 1.5
38
+
39
+ def test_routing_keywords_loaded(self):
40
+ from config.settings import settings
41
+ assert "design" in settings.routing["keywords"]
42
+ assert "engineering" in settings.routing["keywords"]
43
+
44
+ def test_fasteners_loaded(self):
45
+ from config.settings import settings
46
+ assert settings.fasteners["M6"] == 6.6
47
+
48
+ def test_materials_loaded(self):
49
+ from config.settings import settings
50
+ assert "aluminum" in settings.materials
tests/test_types.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/types.py — enums, dataclasses, ABC."""
2
+ import pytest
3
+ from core.types import BackendName, AgentId, AgentResponse, ChatResult, LLMBackend
4
+
5
+
6
+ class TestEnums:
7
+ def test_backend_names(self):
8
+ assert BackendName.MOCK == "mock"
9
+ assert BackendName.ANTHROPIC == "anthropic"
10
+ assert BackendName.OPENAI == "openai"
11
+ assert BackendName.GEMINI == "gemini"
12
+
13
+ def test_agent_ids(self):
14
+ assert AgentId.DESIGN == "design"
15
+ assert AgentId.ENGINEERING == "engineering"
16
+ assert AgentId.CNC == "cnc"
17
+ assert AgentId.CAD == "cad"
18
+
19
+ def test_backend_name_is_string(self):
20
+ assert isinstance(BackendName.MOCK, str)
21
+ assert BackendName.MOCK in {"mock", "anthropic"}
22
+
23
+
24
+ class TestAgentResponse:
25
+ def test_create(self):
26
+ r = AgentResponse(agent_id="design", agent_name="Design Agent", message="hello", color="#7c3aed", avatar="DA")
27
+ assert r.agent_id == "design"
28
+ assert r.code is None
29
+
30
+ def test_with_code(self):
31
+ r = AgentResponse(agent_id="cad", agent_name="CAD", message="done", color="#ffab40", avatar="CC", code="result = cq.Workplane().box(10,10,10)")
32
+ assert r.code is not None
33
+
34
+ def test_to_dict(self):
35
+ r = AgentResponse(agent_id="design", agent_name="Design Agent", message="hi", color="#7c3aed", avatar="DA")
36
+ d = r.to_dict()
37
+ assert d["agent_id"] == "design"
38
+ assert d["message"] == "hi"
39
+ assert "code" in d
40
+
41
+
42
+ class TestChatResult:
43
+ def test_create_empty(self):
44
+ result = ChatResult(responses=[])
45
+ assert result.preview is None
46
+ assert result.design_state == {}
47
+
48
+ def test_to_dict(self):
49
+ r = AgentResponse(agent_id="design", agent_name="D", message="hi", color="#fff", avatar="D")
50
+ result = ChatResult(responses=[r])
51
+ d = result.to_dict()
52
+ assert len(d["responses"]) == 1
53
+ assert d["preview"] is None
54
+
55
+
56
+ class TestLLMBackendABC:
57
+ def test_cannot_instantiate(self):
58
+ with pytest.raises(TypeError):
59
+ LLMBackend()
60
+
61
+ def test_subclass_must_implement_generate(self):
62
+ class Incomplete(LLMBackend):
63
+ pass
64
+ with pytest.raises(TypeError):
65
+ Incomplete()
66
+
67
+ def test_subclass_with_generate(self):
68
+ class Complete(LLMBackend):
69
+ def generate(self, messages):
70
+ return "ok"
71
+ b = Complete()
72
+ assert b.generate([]) == "ok"
73
+
74
+ def test_split_system_message(self):
75
+ msgs = [
76
+ {"role": "system", "content": "You are a bot"},
77
+ {"role": "user", "content": "hello"},
78
+ ]
79
+ system, rest = LLMBackend.split_system_message(msgs)
80
+ assert system == "You are a bot"
81
+ assert len(rest) == 1
82
+ assert rest[0]["role"] == "user"
83
+
84
+ def test_split_system_message_no_system(self):
85
+ msgs = [{"role": "user", "content": "hello"}]
86
+ system, rest = LLMBackend.split_system_message(msgs)
87
+ assert system == ""
88
+ assert len(rest) == 1
uv.lock CHANGED
@@ -2552,7 +2552,9 @@ dependencies = [
2552
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
2553
  { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
2554
  { name = "openai" },
 
2555
  { name = "python-multipart" },
 
2556
  { name = "trimesh" },
2557
  { name = "uvicorn" },
2558
  ]
@@ -2574,7 +2576,9 @@ requires-dist = [
2574
  { name = "mcp", specifier = ">=1.0.0" },
2575
  { name = "numpy", specifier = ">=1.24.0" },
2576
  { name = "openai", specifier = ">=1.30.0" },
 
2577
  { name = "python-multipart", specifier = ">=0.0.9" },
 
2578
  { name = "trimesh", specifier = ">=4.0.0" },
2579
  { name = "uvicorn", specifier = ">=0.29.0" },
2580
  ]
 
2552
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
2553
  { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
2554
  { name = "openai" },
2555
+ { name = "pydantic-settings" },
2556
  { name = "python-multipart" },
2557
+ { name = "pyyaml" },
2558
  { name = "trimesh" },
2559
  { name = "uvicorn" },
2560
  ]
 
2576
  { name = "mcp", specifier = ">=1.0.0" },
2577
  { name = "numpy", specifier = ">=1.24.0" },
2578
  { name = "openai", specifier = ">=1.30.0" },
2579
+ { name = "pydantic-settings", specifier = ">=2.0.0" },
2580
  { name = "python-multipart", specifier = ">=0.0.9" },
2581
+ { name = "pyyaml", specifier = ">=6.0" },
2582
  { name = "trimesh", specifier = ">=4.0.0" },
2583
  { name = "uvicorn", specifier = ">=0.29.0" },
2584
  ]