neuralcad / config /settings.py
CallMeDaniel's picture
feat: add model selector dropdown to UI
1f27d6a
"""Centralized configuration — single source of truth for all NeuralCAD settings.
All config sections use typed Pydantic models for attribute-style access.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings
# ── Typed sub-models ──────────────────────────────────────────────────────
class ServerConfig(BaseModel):
web_port: int = 5000
mcp_port: int = 8000
mcp_name: str = "text-to-cnc"
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
mcp_startup_wait_seconds: int = 2
class PathsConfig(BaseModel):
output_dir: str = "./output"
web_dir: str = "./web"
prompts_dir: str = "./agents/prompts"
class BackendsConfig(BaseModel):
default: str = "gemini"
models: dict[str, str] = Field(default_factory=dict)
crewai_models: dict[str, str] = Field(default_factory=dict)
model_options: dict[str, list[str]] = Field(default_factory=dict)
max_tokens: int = 8192
temperature: float = 0.2
class OrchestrationConfig(BaseModel):
max_history: int = 30
max_active_agents: int = 3
max_retries: int = 2
max_decisions: int = 20
max_recent_decisions: int = 5
part_name_max_chars: int = 40
class ComplexityThresholds(BaseModel):
five_axis_faces: int = 100
three_plus_two_faces: int = 50
class ValidationConfig(BaseModel):
min_wall_thickness_mm: float = 1.5
min_fillet_radius_mm: float = 1.0
max_pocket_depth_ratio: float = 4.0
max_part_size_mm: float = 500.0
min_part_size_mm: float = 1.0
min_hole_diameter_mm: float = 1.0
complexity_thresholds: ComplexityThresholds = Field(default_factory=ComplexityThresholds)
class ExportConfig(BaseModel):
stl_tolerance: float = 0.01
class ToolConfig(BaseModel):
diameter: float = 6
h_feed: float = 800
v_feed: float = 200
speed: float = 18000
class CAMConfig(BaseModel):
default_post_processor: str = "grbl"
stock_offset_mm: float = 2.0
default_step_over_percent: float = 40
tools: dict[str, ToolConfig] = Field(default_factory=dict)
post_processors: list[str] = Field(default_factory=lambda: ["grbl"])
class AgentConfig(BaseModel):
name: str = ""
role: str = ""
color: str = "#888888"
avatar: str = ""
goal: str = ""
backstory: str = ""
class RoutingConfig(BaseModel):
cad_trigger_keywords: list[str] = Field(default_factory=list)
keywords: dict[str, list[str]] = Field(default_factory=dict)
class PlanningConfig(BaseModel):
threshold: float = 8.0
weights: dict[str, float] = Field(default_factory=lambda: {
"material": 3, "dimension": 1, "feature": 1,
"constraint": 1, "part_name": 1, "description": 1,
"axis_recommendation": 2,
})
caps: dict[str, int] = Field(default_factory=lambda: {
"dimension": 4, "feature": 4, "constraint": 2,
})
trigger_keywords: list[str] = Field(default_factory=lambda: [
"plan", "review", "ready", "show plan", "summarize", "what do we have",
])
approved_agents: list[str] = Field(default_factory=lambda: ["cad", "cnc"])
field_agents: dict[str, str] = Field(default_factory=lambda: {
"material": "engineering",
"dimensions": "engineering",
"features": "design",
"constraints": "cnc",
"axis_recommendation": "cnc",
"machining_notes": "cnc",
"part_name": "design",
})
class GapAnalysisConfig(BaseModel):
model: str = "gemini/gemini-2.5-flash"
temperature: float = 0.1
max_tokens: int = 2048
class MemoryConfig(BaseModel):
enabled: bool = True
embedder_provider: str = "google-generativeai"
embedder_model: str = "gemini-embedding-001"
recency_weight: float = 0.4
semantic_weight: float = 0.4
importance_weight: float = 0.2
recency_half_life_days: float = 1.0
recall_limit: int = 5
recall_depth: str = "shallow"
class CrewConfig(BaseModel):
planning: bool = True
collaboration: bool = True
# ── Main Settings ─────────────────────────────────────────────────────────
class Settings(BaseSettings):
"""Loads .env for secrets, then overlays config.yaml for app config."""
# .env secrets
anthropic_api_key: str = ""
openai_api_key: str = ""
google_api_key: str = ""
# Overridable via env vars
neuralcad_output_dir: str = ""
neuralcad_web_port: int = 0
neuralcad_mcp_port: int = 0
# Typed config sections
server: ServerConfig = Field(default_factory=ServerConfig)
paths: PathsConfig = Field(default_factory=PathsConfig)
backends: BackendsConfig = Field(default_factory=BackendsConfig)
orchestration: OrchestrationConfig = Field(default_factory=OrchestrationConfig)
validation: ValidationConfig = Field(default_factory=ValidationConfig)
export: ExportConfig = Field(default_factory=ExportConfig)
cam: CAMConfig = Field(default_factory=CAMConfig)
planning: PlanningConfig = Field(default_factory=PlanningConfig)
gap_analysis: GapAnalysisConfig = Field(default_factory=GapAnalysisConfig)
agents: dict[str, AgentConfig] = Field(default_factory=dict)
routing: RoutingConfig = Field(default_factory=RoutingConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
crew: CrewConfig = Field(default_factory=CrewConfig)
# Simple config (no sub-model needed)
materials: list[str] = Field(default_factory=list)
material_grades: dict[str, str] = Field(default_factory=dict)
dimension_contexts: dict[str, str] = Field(default_factory=dict)
fasteners: dict[str, float] = Field(default_factory=dict)
fallback_messages: dict[str, str] = Field(default_factory=dict)
model_config = {"env_file": ".env", "extra": "ignore"}
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: Any,
env_settings: Any,
dotenv_settings: Any,
file_secret_settings: Any,
) -> tuple[Any, ...]:
"""Skip raw env source to avoid collisions with system env vars.
Platforms like HuggingFace Spaces inject env vars (e.g. MEMORY)
that clash with our complex config field names. We keep dotenv
(.env file) and read the few env vars we need manually in
model_post_init.
"""
return (init_settings, dotenv_settings, file_secret_settings)
def model_post_init(self, __context: Any) -> None:
# Read API keys from real env vars (Docker / HF Spaces secrets)
for key in ("anthropic_api_key", "openai_api_key", "google_api_key"):
if not getattr(self, key):
val = os.environ.get(key.upper(), "")
if val:
object.__setattr__(self, key, val)
# Read NEURALCAD_* overrides from env
for attr, env_key, conv in (
("neuralcad_output_dir", "NEURALCAD_OUTPUT_DIR", str),
("neuralcad_web_port", "NEURALCAD_WEB_PORT", int),
("neuralcad_mcp_port", "NEURALCAD_MCP_PORT", int),
):
val = os.environ.get(env_key, "")
if val and not getattr(self, attr):
object.__setattr__(self, attr, conv(val))
config_path = Path(__file__).parent.parent / "config.yaml"
if config_path.exists():
with open(config_path) as f:
data = yaml.safe_load(f) or {}
for key, value in data.items():
if hasattr(self, key) and value is not None:
current = getattr(self, key)
if isinstance(current, BaseModel):
# Merge dict into typed sub-model
if isinstance(value, dict):
object.__setattr__(self, key, type(current)(**value))
elif isinstance(current, dict) and isinstance(value, dict):
# For agents: convert each entry to AgentConfig
if key == "agents":
object.__setattr__(self, key, {
k: AgentConfig(**v) if isinstance(v, dict) else v
for k, v in value.items()
})
elif not current:
object.__setattr__(self, key, value)
elif isinstance(current, list) and not current:
object.__setattr__(self, key, value)
elif not current:
object.__setattr__(self, key, value)
# ── Convenience properties ────────────────────────────────────────────
@property
def output_dir(self) -> Path:
if self.neuralcad_output_dir:
return Path(self.neuralcad_output_dir)
return Path(self.paths.output_dir)
@property
def web_port(self) -> int:
if self.neuralcad_web_port:
return self.neuralcad_web_port
return self.server.web_port
@property
def mcp_port(self) -> int:
if self.neuralcad_mcp_port:
return self.neuralcad_mcp_port
return self.server.mcp_port
@property
def default_backend(self) -> str:
return self.backends.default
@property
def model_for(self) -> dict[str, str]:
return self.backends.models
@property
def max_tokens(self) -> int:
return self.backends.max_tokens
@property
def temperature(self) -> float:
return self.backends.temperature
settings = Settings()