Spaces:
Sleeping
Sleeping
Merge pull request #3 from danghoangnhan/feat/oop-config-foundation
Browse files- .env.example +9 -0
- agents/orchestrator.py +3 -17
- config.yaml +259 -0
- config/__init__.py +0 -0
- config/settings.py +89 -0
- core/backend_factory.py +64 -0
- core/backends.py +49 -100
- core/serializers.py +38 -0
- core/types.py +86 -0
- core/validator.py +19 -3
- docs/superpowers/plans/2026-04-11-oop-config-foundation.md +844 -0
- docs/superpowers/specs/2026-04-11-oop-config-crewai-design.md +1232 -0
- pyproject.toml +2 -0
- server/mcp.py +9 -53
- tests/test_backend_factory.py +29 -0
- tests/test_serializers.py +66 -0
- tests/test_settings.py +50 -0
- tests/test_types.py +88 -0
- uv.lock +4 -0
.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 |
-
|
| 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 |
-
|
| 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.
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
self.model = model
|
| 49 |
|
| 50 |
def generate(self, messages: list[dict]) -> str:
|
| 51 |
-
|
| 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=
|
| 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=
|
| 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 =
|
| 113 |
import openai
|
| 114 |
-
|
| 115 |
-
self.
|
| 116 |
-
|
|
|
|
| 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=
|
| 123 |
-
temperature=
|
| 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=
|
| 145 |
-
temperature=
|
| 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 =
|
| 154 |
from google import genai
|
| 155 |
-
|
| 156 |
-
self.
|
| 157 |
-
|
|
|
|
| 158 |
|
| 159 |
def generate(self, messages: list[dict]) -> str:
|
| 160 |
-
|
| 161 |
-
|
|
|
|
| 162 |
contents = []
|
| 163 |
-
for m in
|
| 164 |
-
if m["role"] == "
|
| 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=
|
| 179 |
-
temperature=
|
| 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
|
| 194 |
-
if m["role"] == "
|
| 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=
|
| 213 |
-
temperature=
|
| 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.
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 >
|
| 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
|
| 27 |
-
from core.
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
]
|