"""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()