Spaces:
Sleeping
Sleeping
Merge pull request #1 from danghoangnhan/feat/tests-readme-ai-quality
Browse files- .dockerignore +8 -0
- .gitignore +4 -0
- Dockerfile +46 -0
- README.md +145 -82
- agents/__init__.py +0 -0
- agents/crew_orchestrator.py +311 -0
- agents/definitions.py +83 -0
- agents/design_state.py +175 -0
- agents/llm_adapter.py +48 -0
- agents/orchestrator.py +410 -0
- agents/prompts.py +245 -0
- core/__init__.py +0 -0
- core/backends.py +740 -0
- cadquery_system_prompt.py → core/cadquery_prompts.py +0 -0
- code_executor.py → core/executor.py +1 -2
- pipeline.py → core/pipeline.py +16 -162
- cnc_validator.py → core/validator.py +75 -46
- docker-compose.yml +24 -0
- docs/superpowers/plans/2026-04-08-uv-docker-deploy.md +376 -0
- docs/superpowers/plans/2026-04-11-tests-readme-ai-quality.md +1538 -0
- docs/superpowers/specs/2026-04-08-multi-agent-chat-design.md +390 -0
- docs/superpowers/specs/2026-04-08-uv-docker-deploy-design.md +187 -0
- docs/superpowers/specs/2026-04-08-web-demo-design.md +178 -0
- entrypoint.sh +25 -0
- pyproject.toml +29 -0
- requirements.txt +6 -0
- server/__init__.py +0 -0
- mcp_server.py → server/mcp.py +175 -13
- server/routes.py +153 -0
- server/web.py +223 -0
- tests/__init__.py +0 -0
- tests/conftest.py +61 -0
- tests/test_api_routes.py +127 -0
- tests/test_design_state.py +90 -0
- tests/test_executor.py +104 -0
- tests/test_mock_orchestrator.py +87 -0
- tests/test_pipeline.py +78 -0
- tests/test_prompts.py +216 -0
- tests/test_single_call_orchestrator.py +78 -0
- tests/test_validator.py +76 -0
- uv.lock +0 -0
- web/index.html +1983 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
output/
|
| 5 |
+
.superpowers/
|
| 6 |
+
.venv/
|
| 7 |
+
docs/
|
| 8 |
+
.env
|
.gitignore
CHANGED
|
@@ -1,3 +1,7 @@
|
|
| 1 |
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
output/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
*.pyc
|
| 3 |
output/
|
| 4 |
+
.superpowers/
|
| 5 |
+
.venv/
|
| 6 |
+
.worktrees/
|
| 7 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ── Stage 1: Builder ─────────────────────────────────────────────────────
|
| 2 |
+
FROM python:3.11-slim AS builder
|
| 3 |
+
|
| 4 |
+
# Install uv
|
| 5 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Install dependencies (cached layer — only rebuilds when deps change)
|
| 10 |
+
COPY pyproject.toml uv.lock ./
|
| 11 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 12 |
+
|
| 13 |
+
# Copy source code
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# ── Stage 2: Runtime ─────────────────────────────────────────────────────
|
| 17 |
+
FROM python:3.11-slim
|
| 18 |
+
|
| 19 |
+
# Install runtime system dependencies required by OpenCascade (CadQuery)
|
| 20 |
+
RUN --mount=type=cache,target=/var/cache/apt \
|
| 21 |
+
--mount=type=cache,target=/var/lib/apt/lists \
|
| 22 |
+
apt-get update && apt-get install -y --no-install-recommends \
|
| 23 |
+
libgl1 libglib2.0-0 libx11-6 libxrender1
|
| 24 |
+
|
| 25 |
+
WORKDIR /app
|
| 26 |
+
|
| 27 |
+
# Copy virtual environment from builder
|
| 28 |
+
COPY --from=builder /app/.venv /app/.venv
|
| 29 |
+
|
| 30 |
+
# Copy application source
|
| 31 |
+
COPY --from=builder /app/core /app/core/
|
| 32 |
+
COPY --from=builder /app/server /app/server/
|
| 33 |
+
COPY --from=builder /app/agents /app/agents/
|
| 34 |
+
COPY --from=builder /app/web /app/web/
|
| 35 |
+
COPY --from=builder /app/entrypoint.sh /app/
|
| 36 |
+
|
| 37 |
+
# Put venv on PATH
|
| 38 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 39 |
+
ENV PYTHONUNBUFFERED=1
|
| 40 |
+
|
| 41 |
+
# Create output directory
|
| 42 |
+
RUN mkdir -p /app/output
|
| 43 |
+
|
| 44 |
+
EXPOSE 7860
|
| 45 |
+
|
| 46 |
+
ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
|
README.md
CHANGED
|
@@ -1,131 +1,194 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
```
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
```
|
| 18 |
|
| 19 |
-
##
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
## Quick Start
|
| 28 |
|
| 29 |
```bash
|
|
|
|
| 30 |
pip install -r requirements.txt
|
| 31 |
|
| 32 |
-
#
|
| 33 |
-
python
|
| 34 |
|
| 35 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
export ANTHROPIC_API_KEY=sk-ant-...
|
| 37 |
-
|
| 38 |
|
| 39 |
-
#
|
| 40 |
export OPENAI_API_KEY=sk-...
|
| 41 |
-
python pipeline.py "A motor mount plate" --backend openai
|
| 42 |
```
|
| 43 |
|
| 44 |
-
##
|
| 45 |
|
| 46 |
-
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
#
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|------|-------------|
|
| 52 |
-
| `generate_cnc_model` | Text prompt → CadQuery code → 3D solid → STEP/STL with CNC validation |
|
| 53 |
-
| `validate_cnc_model` | Run manufacturability checks on existing CadQuery code |
|
| 54 |
-
| `execute_cadquery_code` | Execute arbitrary CadQuery code and get geometry info |
|
| 55 |
-
| `list_models` | List previously generated models in the output directory |
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
```json
|
| 62 |
{
|
| 63 |
-
"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
}
|
| 68 |
-
}
|
| 69 |
}
|
| 70 |
```
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
-
claude mcp add text-to-cnc python3 /path/to/text-to-cnc/mcp_server.py
|
| 76 |
-
```
|
| 77 |
|
| 78 |
-
##
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
#
|
| 85 |
-
python mcp_server.py --transport sse --port 8000
|
| 86 |
-
```
|
| 87 |
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
| `pipeline.py` | Main orchestrator + CLI entry point |
|
| 94 |
-
| `cadquery_system_prompt.py` | LLM system prompt + few-shot examples |
|
| 95 |
-
| `code_executor.py` | Sandboxed CadQuery execution + STEP/STL export |
|
| 96 |
-
| `cnc_validator.py` | CNC manufacturability checker |
|
| 97 |
-
| `claude_desktop_config.json` | Example Claude Desktop config |
|
| 98 |
-
| `requirements.txt` | Python dependencies |
|
| 99 |
|
| 100 |
-
##
|
| 101 |
|
| 102 |
-
|
| 103 |
-
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
##
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
|
| 111 |
-
-
|
| 112 |
-
- **Deep pockets** — aspect ratios requiring long-reach tooling
|
| 113 |
-
- **Surface complexity** — freeform surfaces needing 3D contouring
|
| 114 |
-
- **Face/edge count** — complexity proxy for axis recommendation
|
| 115 |
-
- **Fill ratio** — material removal estimate
|
| 116 |
|
| 117 |
-
#
|
|
|
|
| 118 |
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
## Key Research
|
| 126 |
|
| 127 |
- **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
|
| 128 |
-
- **GenCAD** (2024) — Transformer + diffusion for image
|
| 129 |
- **NURBGen** (2025) — NURBS-based B-rep from text via LLM
|
| 130 |
-
- **STEP-LLM** (2026) — Direct STEP file generation from natural language
|
| 131 |
-
- **SldprtNet** (2026) — Large-scale multimodal industrial parts dataset
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: NeuralCAD
|
| 3 |
+
emoji: ⚙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
|
| 10 |
+
# NeuralCAD — Multi-Agent CAD Design
|
| 11 |
|
| 12 |
+
A multi-agent AI system that converts natural language descriptions of mechanical parts into CNC-machinable 3D models (STEP/STL). Four specialized AI agents collaborate with you in a shared chat to design, engineer, validate, and generate CadQuery code.
|
| 13 |
+
|
| 14 |
+
## How It Works
|
| 15 |
|
| 16 |
```
|
| 17 |
+
User ──→ Chat Interface ──→ Agent Orchestrator
|
| 18 |
+
│
|
| 19 |
+
┌───────────────┼───────────────┐
|
| 20 |
+
│ │ │
|
| 21 |
+
Design Agent Engineering CNC Agent
|
| 22 |
+
(form/shape) Agent (manufacturability)
|
| 23 |
+
│ (specs/dims) │
|
| 24 |
+
└───────────────┼───────────────┘
|
| 25 |
+
│
|
| 26 |
+
CAD Coder Agent
|
| 27 |
+
(CadQuery code)
|
| 28 |
+
│
|
| 29 |
+
Execute in Sandbox
|
| 30 |
+
│
|
| 31 |
+
3D Solid (B-rep)
|
| 32 |
+
╱ ╲
|
| 33 |
+
CNC Validator Exporter
|
| 34 |
+
(machinability (STEP + STL)
|
| 35 |
+
checks)
|
| 36 |
```
|
| 37 |
|
| 38 |
+
## Agents
|
| 39 |
|
| 40 |
+
| Agent | Role | Expertise |
|
| 41 |
+
|-------|------|-----------|
|
| 42 |
+
| **Design Agent** | Industrial Designer | Form, aesthetics, ergonomics, shape proposals |
|
| 43 |
+
| **Engineering Agent** | Mechanical Engineer | Dimensions, tolerances, materials, fastener specs |
|
| 44 |
+
| **CNC Agent** | Manufacturing Advisor | Tool access, wall thickness, axis requirements, cost |
|
| 45 |
+
| **CAD Coder** | CadQuery Programmer | Generates valid CadQuery Python code on demand |
|
| 46 |
|
| 47 |
## Quick Start
|
| 48 |
|
| 49 |
```bash
|
| 50 |
+
# Install dependencies
|
| 51 |
pip install -r requirements.txt
|
| 52 |
|
| 53 |
+
# Run the web app (mock backend, no API key needed)
|
| 54 |
+
python -m server.web --port 5000
|
| 55 |
|
| 56 |
+
# Open http://localhost:5000 in your browser
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### With LLM Backends
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
# Gemini (free tier)
|
| 63 |
+
export GOOGLE_API_KEY=...
|
| 64 |
+
# Select GEMINI in the web UI backend toggle
|
| 65 |
+
|
| 66 |
+
# Claude (recommended for quality)
|
| 67 |
export ANTHROPIC_API_KEY=sk-ant-...
|
| 68 |
+
# Select CLAUDE in the web UI backend toggle
|
| 69 |
|
| 70 |
+
# GPT-4o
|
| 71 |
export OPENAI_API_KEY=sk-...
|
|
|
|
| 72 |
```
|
| 73 |
|
| 74 |
+
### CLI Pipeline (Direct)
|
| 75 |
|
| 76 |
+
```bash
|
| 77 |
+
# Mock backend
|
| 78 |
+
python -m core.pipeline "A mounting bracket with four M6 holes"
|
| 79 |
|
| 80 |
+
# With Claude
|
| 81 |
+
python -m core.pipeline "A flanged bearing housing" --backend anthropic
|
| 82 |
+
```
|
| 83 |
|
| 84 |
+
## Architecture
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
```
|
| 87 |
+
NeuralCAD/
|
| 88 |
+
├── agents/ # Multi-agent orchestration
|
| 89 |
+
│ ├── definitions.py # Agent roles, colors, personas
|
| 90 |
+
│ ├── orchestrator.py # Single-call + Mock orchestrators
|
| 91 |
+
│ ├── crew_orchestrator.py # CrewAI multi-call orchestrator
|
| 92 |
+
│ ├── prompts.py # System prompts, routing, JSON parsing
|
| 93 |
+
│ ├── design_state.py # Design decision accumulator
|
| 94 |
+
│ └── llm_adapter.py # CrewAI LLM adapter
|
| 95 |
+
├── core/ # CAD generation pipeline
|
| 96 |
+
│ ├── backends.py # LLM backends (Mock, Anthropic, OpenAI, Gemini)
|
| 97 |
+
│ ├── pipeline.py # Text-to-CNC orchestrator + CLI
|
| 98 |
+
│ ├── executor.py # Sandboxed CadQuery execution + export
|
| 99 |
+
│ ├── validator.py # CNC manufacturability checker
|
| 100 |
+
│ └── cadquery_prompts.py # CadQuery system prompt + few-shot examples
|
| 101 |
+
├── server/ # Web + MCP servers
|
| 102 |
+
│ ├── web.py # FastAPI app, static serving
|
| 103 |
+
│ ├── routes.py # Chat API endpoints
|
| 104 |
+
│ └── mcp.py # MCP server (Claude Desktop / Claude Code)
|
| 105 |
+
├── web/
|
| 106 |
+
│ └── index.html # Frontend: Three.js viewer + chat panel
|
| 107 |
+
└── tests/ # Test suite
|
| 108 |
+
```
|
| 109 |
|
| 110 |
+
### Orchestration Modes
|
| 111 |
+
|
| 112 |
+
| Backend | Mode | API Calls/Turn | Use Case |
|
| 113 |
+
|---------|------|----------------|----------|
|
| 114 |
+
| Mock | Template-based | 0 | UI development, demos |
|
| 115 |
+
| Gemini | Single-call | 1 | Free tier, rate-limited |
|
| 116 |
+
| Anthropic | CrewAI multi-call | 2-4 | Best quality |
|
| 117 |
+
| OpenAI | CrewAI multi-call | 2-4 | Best quality |
|
| 118 |
+
|
| 119 |
+
### Chat API
|
| 120 |
+
|
| 121 |
+
**POST /api/chat** — Multi-agent chat turn
|
| 122 |
|
| 123 |
```json
|
| 124 |
{
|
| 125 |
+
"message": "Make it 60mm wide with M4 base mounting",
|
| 126 |
+
"history": [{"role": "user", "content": "I need a servo bracket"}],
|
| 127 |
+
"mentions": [],
|
| 128 |
+
"backend": "mock"
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
```
|
| 131 |
|
| 132 |
+
**POST /api/report** — Generate design report from conversation
|
| 133 |
|
| 134 |
+
**GET /api/agents** — List available agents and metadata
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
## Features
|
| 137 |
|
| 138 |
+
- **Multi-agent chat** — 4 specialist agents collaborate on part design
|
| 139 |
+
- **@mention system** — Direct messages to specific agents (`@design`, `@engineering`, `@cnc`, `@cad`)
|
| 140 |
+
- **3D preview** — Real-time STL rendering with Three.js (orbit, zoom, pan)
|
| 141 |
+
- **Design state tracking** — Accumulates decisions across turns (localStorage persistence)
|
| 142 |
+
- **CNC validation** — Checks wall thickness, pocket ratios, tool access, axis requirements
|
| 143 |
+
- **Model gallery** — Browse and reload previously generated models
|
| 144 |
+
- **STEP + STL export** — Download CAM-ready files
|
| 145 |
+
- **MCP server** — Use from Claude Desktop or Claude Code
|
| 146 |
|
| 147 |
+
## MCP Server
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
```bash
|
| 150 |
+
# Connect to Claude Code
|
| 151 |
+
claude mcp add text-to-cnc python3 -m server.mcp
|
| 152 |
|
| 153 |
+
# Run standalone (SSE for remote integrations)
|
| 154 |
+
python -m server.mcp --transport sse --port 8000
|
| 155 |
+
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
+
### MCP Tools
|
| 158 |
|
| 159 |
+
| Tool | Description |
|
| 160 |
+
|------|-------------|
|
| 161 |
+
| `generate_cnc_model` | Text to CadQuery code to 3D solid to STEP/STL |
|
| 162 |
+
| `validate_cnc_model` | Run manufacturability checks on CadQuery code |
|
| 163 |
+
| `execute_cadquery_code` | Execute arbitrary CadQuery code |
|
| 164 |
+
| `chat_turn` | Multi-agent chat turn |
|
| 165 |
+
| `list_models` | List generated models |
|
| 166 |
|
| 167 |
+
## Testing
|
| 168 |
|
| 169 |
+
```bash
|
| 170 |
+
# All tests
|
| 171 |
+
python -m pytest
|
| 172 |
|
| 173 |
+
# Pure logic tests only (no CadQuery needed)
|
| 174 |
+
python -m pytest -m "not requires_cadquery"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
+
# Integration tests
|
| 177 |
+
python -m pytest -m requires_cadquery
|
| 178 |
|
| 179 |
+
# Verbose
|
| 180 |
+
python -m pytest -v
|
| 181 |
+
```
|
| 182 |
|
| 183 |
+
## Docker
|
| 184 |
|
| 185 |
+
```bash
|
| 186 |
+
docker compose up --build
|
| 187 |
+
# Open http://localhost:7860
|
| 188 |
+
```
|
| 189 |
|
| 190 |
+
## Key Research
|
| 191 |
|
| 192 |
- **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
|
| 193 |
+
- **GenCAD** (2024) — Transformer + diffusion for image to CAD
|
| 194 |
- **NURBGen** (2025) — NURBS-based B-rep from text via LLM
|
|
|
|
|
|
agents/__init__.py
ADDED
|
File without changes
|
agents/crew_orchestrator.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI multi-call orchestrator for paid API backends (Anthropic/OpenAI).
|
| 2 |
+
|
| 3 |
+
Uses CrewAI's sequential process where each specialist agent gets its own
|
| 4 |
+
focused LLM call. A routing step selects which agents respond, then each
|
| 5 |
+
agent reasons independently with its own context.
|
| 6 |
+
|
| 7 |
+
Better quality than single-call (agents can truly disagree / specialize)
|
| 8 |
+
but uses 2-4 API calls per turn. Used for paid backends only.
|
| 9 |
+
|
| 10 |
+
Falls back to SingleCallOrchestrator if CrewAI is not installed.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import re
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from agents.definitions import AGENTS
|
| 20 |
+
from agents.design_state import DesignState, extract_decisions
|
| 21 |
+
from agents.prompts import CAD_TRIGGER_KEYWORDS, route_by_keywords
|
| 22 |
+
from agents.orchestrator import _format_response, _execute_cad_code
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _build_agent_context(
|
| 30 |
+
message: str,
|
| 31 |
+
history: list[dict],
|
| 32 |
+
design_state: DesignState,
|
| 33 |
+
max_history: int = 20,
|
| 34 |
+
) -> str:
|
| 35 |
+
"""Build a shared context string that each CrewAI agent receives."""
|
| 36 |
+
parts = []
|
| 37 |
+
|
| 38 |
+
# Design state
|
| 39 |
+
spec = design_state.render()
|
| 40 |
+
if spec:
|
| 41 |
+
parts.append(f"## Current Design Spec\n{spec}")
|
| 42 |
+
|
| 43 |
+
# Recent conversation (compact)
|
| 44 |
+
recent = history[-max_history:] if len(history) > max_history else history
|
| 45 |
+
if recent:
|
| 46 |
+
lines = []
|
| 47 |
+
for msg in recent:
|
| 48 |
+
if msg.get("role") == "user":
|
| 49 |
+
lines.append(f"USER: {msg.get('content', '')}")
|
| 50 |
+
else:
|
| 51 |
+
aid = msg.get("agent_id", "unknown")
|
| 52 |
+
name = AGENTS.get(aid, AGENTS["design"]).name
|
| 53 |
+
lines.append(f"{name.upper()}: {msg.get('content', '')}")
|
| 54 |
+
parts.append("## Recent conversation\n" + "\n".join(lines))
|
| 55 |
+
|
| 56 |
+
parts.append(f"## User's latest message\n{message}")
|
| 57 |
+
return "\n\n".join(parts)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class CrewOrchestrator:
|
| 61 |
+
"""Multi-call orchestrator using CrewAI.
|
| 62 |
+
|
| 63 |
+
Each selected agent gets its own LLM call with focused context and
|
| 64 |
+
persona, producing genuinely independent reasoning.
|
| 65 |
+
|
| 66 |
+
Falls back to SingleCallOrchestrator if CrewAI is not installed.
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
backend_name: str = "anthropic",
|
| 72 |
+
output_dir: Path | str = DEFAULT_OUTPUT_DIR,
|
| 73 |
+
):
|
| 74 |
+
self.backend_name = backend_name
|
| 75 |
+
self.output_dir = Path(output_dir)
|
| 76 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
self._crew_available = self._check_crewai()
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def _check_crewai() -> bool:
|
| 81 |
+
try:
|
| 82 |
+
import importlib.util
|
| 83 |
+
return importlib.util.find_spec("crewai") is not None
|
| 84 |
+
except (ImportError, ModuleNotFoundError):
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
# ── Public interface ───────────────────────────────────────────────────
|
| 88 |
+
|
| 89 |
+
def chat_turn(
|
| 90 |
+
self,
|
| 91 |
+
message: str,
|
| 92 |
+
history: list[dict],
|
| 93 |
+
mentions: list[str] | None = None,
|
| 94 |
+
max_history: int = 30,
|
| 95 |
+
design_state: dict | None = None,
|
| 96 |
+
) -> dict:
|
| 97 |
+
"""Run one chat turn. Returns the standard response envelope."""
|
| 98 |
+
if not self._crew_available:
|
| 99 |
+
return self._fallback(message, history, mentions, max_history, design_state)
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
return self._run_crew(message, history, mentions, max_history, design_state)
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
logger.warning("CrewAI run failed (%s), falling back to single-call", exc)
|
| 105 |
+
return self._fallback(message, history, mentions, max_history, design_state)
|
| 106 |
+
|
| 107 |
+
# ── CrewAI implementation ──────────────────────────────────────────────
|
| 108 |
+
|
| 109 |
+
def _run_crew(
|
| 110 |
+
self,
|
| 111 |
+
message: str,
|
| 112 |
+
history: list[dict],
|
| 113 |
+
mentions: list[str] | None,
|
| 114 |
+
max_history: int,
|
| 115 |
+
design_state_dict: dict | None,
|
| 116 |
+
) -> dict:
|
| 117 |
+
from crewai import Agent, Task, Crew, Process
|
| 118 |
+
|
| 119 |
+
state = DesignState(**(design_state_dict or {}))
|
| 120 |
+
context = _build_agent_context(message, history, state, max_history)
|
| 121 |
+
|
| 122 |
+
# Select which agents should respond
|
| 123 |
+
if mentions:
|
| 124 |
+
active_ids = mentions
|
| 125 |
+
else:
|
| 126 |
+
active_ids = route_by_keywords(message)
|
| 127 |
+
|
| 128 |
+
# Check CAD trigger
|
| 129 |
+
include_cad = "cad" in active_ids
|
| 130 |
+
if not include_cad:
|
| 131 |
+
include_cad = any(kw in message.lower() for kw in CAD_TRIGGER_KEYWORDS)
|
| 132 |
+
if include_cad and "cad" not in active_ids:
|
| 133 |
+
active_ids.append("cad")
|
| 134 |
+
|
| 135 |
+
# Build the LLM adapter
|
| 136 |
+
llm = self._build_llm()
|
| 137 |
+
|
| 138 |
+
# Create CrewAI agents + tasks for selected agents
|
| 139 |
+
crew_agents = []
|
| 140 |
+
crew_tasks = []
|
| 141 |
+
|
| 142 |
+
for agent_id in active_ids:
|
| 143 |
+
if agent_id not in AGENTS:
|
| 144 |
+
continue
|
| 145 |
+
agent_def = AGENTS[agent_id]
|
| 146 |
+
|
| 147 |
+
# Special instructions for CAD Coder
|
| 148 |
+
extra = ""
|
| 149 |
+
if agent_id == "cad":
|
| 150 |
+
from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
|
| 151 |
+
extra = (
|
| 152 |
+
"\n\nWhen generating code, output ONLY valid CadQuery Python. "
|
| 153 |
+
"The code must assign the result to a variable called `result` "
|
| 154 |
+
"as a cq.Workplane object. Import cadquery as cq.\n\n"
|
| 155 |
+
f"CadQuery reference:\n{CADQUERY_SYSTEM_PROMPT}"
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
crew_agent = Agent(
|
| 159 |
+
role=agent_def.role,
|
| 160 |
+
goal=agent_def.goal,
|
| 161 |
+
backstory=agent_def.backstory + extra,
|
| 162 |
+
llm=llm,
|
| 163 |
+
verbose=False,
|
| 164 |
+
allow_delegation=False,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
task_description = (
|
| 168 |
+
f"{context}\n\n"
|
| 169 |
+
f"As the {agent_def.role}, respond to the user's latest message. "
|
| 170 |
+
f"Keep your response concise (2-4 sentences). "
|
| 171 |
+
f"Do NOT repeat anything from the conversation history. "
|
| 172 |
+
f"Add NEW information from your expertise."
|
| 173 |
+
)
|
| 174 |
+
if agent_id == "cad":
|
| 175 |
+
task_description += (
|
| 176 |
+
"\n\nGenerate CadQuery Python code based on the design spec "
|
| 177 |
+
"and conversation. Output ONLY the Python code, nothing else."
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
task = Task(
|
| 181 |
+
description=task_description,
|
| 182 |
+
expected_output=(
|
| 183 |
+
"A concise response from your expert perspective (2-4 sentences)."
|
| 184 |
+
if agent_id != "cad"
|
| 185 |
+
else "Valid CadQuery Python code that assigns result to a cq.Workplane."
|
| 186 |
+
),
|
| 187 |
+
agent=crew_agent,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
crew_agents.append(crew_agent)
|
| 191 |
+
crew_tasks.append(task)
|
| 192 |
+
|
| 193 |
+
if not crew_agents:
|
| 194 |
+
return {"responses": [], "preview": None, "design_state": state.model_dump()}
|
| 195 |
+
|
| 196 |
+
# Run the crew — sequential process so each agent runs independently
|
| 197 |
+
crew = Crew(
|
| 198 |
+
agents=crew_agents,
|
| 199 |
+
tasks=crew_tasks,
|
| 200 |
+
process=Process.sequential,
|
| 201 |
+
verbose=False,
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
crew_result = crew.kickoff()
|
| 205 |
+
|
| 206 |
+
# Parse results into standard response format
|
| 207 |
+
responses = []
|
| 208 |
+
preview = None
|
| 209 |
+
|
| 210 |
+
# crew_result.tasks_output gives per-task results
|
| 211 |
+
task_outputs = crew_result.tasks_output if hasattr(crew_result, 'tasks_output') else []
|
| 212 |
+
|
| 213 |
+
for i, agent_id in enumerate(active_ids):
|
| 214 |
+
if agent_id not in AGENTS:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
if i < len(task_outputs):
|
| 218 |
+
raw_output = str(task_outputs[i])
|
| 219 |
+
else:
|
| 220 |
+
raw_output = str(crew_result) if i == 0 else ""
|
| 221 |
+
|
| 222 |
+
if not raw_output.strip():
|
| 223 |
+
continue
|
| 224 |
+
|
| 225 |
+
if agent_id == "cad":
|
| 226 |
+
# Extract code from the output
|
| 227 |
+
code = self._extract_code(raw_output)
|
| 228 |
+
responses.append(_format_response(agent_id, "Model generated.", code=code))
|
| 229 |
+
|
| 230 |
+
if code:
|
| 231 |
+
backend = self._build_backend()
|
| 232 |
+
preview = _execute_cad_code(
|
| 233 |
+
code, message, self.output_dir, backend=backend,
|
| 234 |
+
)
|
| 235 |
+
else:
|
| 236 |
+
responses.append(_format_response(agent_id, raw_output.strip()))
|
| 237 |
+
|
| 238 |
+
# Update design state
|
| 239 |
+
agent_msgs = [{"message": r.get("message", "")} for r in responses]
|
| 240 |
+
updated_state = extract_decisions(agent_msgs, state, message)
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"responses": responses,
|
| 244 |
+
"preview": preview,
|
| 245 |
+
"design_state": updated_state.model_dump(),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
def _extract_code(self, text: str) -> str | None:
|
| 249 |
+
"""Extract Python code from LLM output, handling code fences."""
|
| 250 |
+
# Try to extract from code fences
|
| 251 |
+
match = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
|
| 252 |
+
if match:
|
| 253 |
+
return match.group(1).strip()
|
| 254 |
+
|
| 255 |
+
# If the whole output looks like code (has 'import' or 'cq.' or 'result =')
|
| 256 |
+
if any(marker in text for marker in ["import cadquery", "cq.", "result ="]):
|
| 257 |
+
return text.strip()
|
| 258 |
+
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
def _build_llm(self):
|
| 262 |
+
"""Build the CrewAI-compatible LLM from our backend."""
|
| 263 |
+
from agents.llm_adapter import NeuralCADLLMAdapter
|
| 264 |
+
|
| 265 |
+
backend = self._build_backend()
|
| 266 |
+
model_names = {
|
| 267 |
+
"anthropic": "claude-sonnet-4-20250514",
|
| 268 |
+
"openai": "gpt-4o",
|
| 269 |
+
"gemini": "gemini-2.5-flash",
|
| 270 |
+
}
|
| 271 |
+
return NeuralCADLLMAdapter(
|
| 272 |
+
backend=backend,
|
| 273 |
+
model=model_names.get(self.backend_name, "custom"),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def _build_backend(self):
|
| 277 |
+
"""Build the underlying LLM backend."""
|
| 278 |
+
from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
|
| 279 |
+
|
| 280 |
+
backends = {
|
| 281 |
+
"anthropic": AnthropicBackend,
|
| 282 |
+
"openai": OpenAIBackend,
|
| 283 |
+
"gemini": GeminiBackend,
|
| 284 |
+
}
|
| 285 |
+
backend_cls = backends.get(self.backend_name, AnthropicBackend)
|
| 286 |
+
return backend_cls()
|
| 287 |
+
|
| 288 |
+
# ── Fallback ───────────────────────────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
def _fallback(
|
| 291 |
+
self,
|
| 292 |
+
message: str,
|
| 293 |
+
history: list[dict],
|
| 294 |
+
mentions: list[str] | None,
|
| 295 |
+
max_history: int,
|
| 296 |
+
design_state: dict | None,
|
| 297 |
+
) -> dict:
|
| 298 |
+
"""Fall back to SingleCallOrchestrator."""
|
| 299 |
+
from agents.orchestrator import SingleCallOrchestrator, MockChatBackend
|
| 300 |
+
|
| 301 |
+
try:
|
| 302 |
+
backend = self._build_backend()
|
| 303 |
+
except Exception:
|
| 304 |
+
logger.warning("Backend %r unavailable, falling back to mock", self.backend_name)
|
| 305 |
+
mock = MockChatBackend()
|
| 306 |
+
return mock.chat_turn(message, history, mentions, design_state=design_state)
|
| 307 |
+
|
| 308 |
+
orchestrator = SingleCallOrchestrator(backend=backend, output_dir=self.output_dir)
|
| 309 |
+
return orchestrator.chat_turn(
|
| 310 |
+
message, history, mentions, max_history, design_state=design_state,
|
| 311 |
+
)
|
agents/definitions.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-agent definitions for NeuralCAD collaborative design chat."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class AgentDef:
|
| 8 |
+
"""Definition of a chat agent."""
|
| 9 |
+
id: str
|
| 10 |
+
name: str
|
| 11 |
+
role: str
|
| 12 |
+
color: str
|
| 13 |
+
avatar: str
|
| 14 |
+
goal: str
|
| 15 |
+
backstory: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
AGENTS: dict[str, AgentDef] = {
|
| 19 |
+
"design": AgentDef(
|
| 20 |
+
id="design",
|
| 21 |
+
name="Design Agent",
|
| 22 |
+
role="Industrial Designer",
|
| 23 |
+
color="#7c3aed",
|
| 24 |
+
avatar="DA",
|
| 25 |
+
goal="Understand the user's intent and propose optimal form factors, shapes, and aesthetic choices for mechanical parts.",
|
| 26 |
+
backstory=(
|
| 27 |
+
"You are an experienced industrial designer specializing in mechanical parts. "
|
| 28 |
+
"You think about form, function, ergonomics, and visual appeal. You ask clarifying "
|
| 29 |
+
"questions about the part's purpose, environment, and constraints before proposing "
|
| 30 |
+
"designs. You suggest shapes, proportions, and features that balance aesthetics with "
|
| 31 |
+
"manufacturability."
|
| 32 |
+
),
|
| 33 |
+
),
|
| 34 |
+
"engineering": AgentDef(
|
| 35 |
+
id="engineering",
|
| 36 |
+
name="Engineering Agent",
|
| 37 |
+
role="Mechanical Engineer",
|
| 38 |
+
color="#00b4d8",
|
| 39 |
+
avatar="EA",
|
| 40 |
+
goal="Ensure parts are structurally sound with correct dimensions, tolerances, materials, and fastener specifications.",
|
| 41 |
+
backstory=(
|
| 42 |
+
"You are a senior mechanical engineer with deep knowledge of materials science, "
|
| 43 |
+
"stress analysis, and fastener standards. You specify wall thicknesses, fillet radii, "
|
| 44 |
+
"clearance holes (M3=3.4mm, M4=4.5mm, M5=5.5mm, M6=6.6mm, M8=9.0mm), and material "
|
| 45 |
+
"recommendations. You flag structural concerns and suggest reinforcements like ribs "
|
| 46 |
+
"or gussets when loads are significant."
|
| 47 |
+
),
|
| 48 |
+
),
|
| 49 |
+
"cnc": AgentDef(
|
| 50 |
+
id="cnc",
|
| 51 |
+
name="CNC Agent",
|
| 52 |
+
role="CNC Manufacturing Advisor",
|
| 53 |
+
color="#00e676",
|
| 54 |
+
avatar="CA",
|
| 55 |
+
goal="Advise on manufacturability: tool access, wall thickness limits, pocket ratios, axis requirements, and cost implications.",
|
| 56 |
+
backstory=(
|
| 57 |
+
"You are a CNC machinist with 20 years of shop floor experience. You know what "
|
| 58 |
+
"tool geometries can reach, what aspect ratios cause chatter, and when to recommend "
|
| 59 |
+
"3-axis vs 3+2 vs 5-axis. You flag undercuts, thin walls (<1.5mm), deep pockets "
|
| 60 |
+
"(>4:1 ratio), and features that need special fixturing. You think about setup count "
|
| 61 |
+
"and machining time."
|
| 62 |
+
),
|
| 63 |
+
),
|
| 64 |
+
"cad": AgentDef(
|
| 65 |
+
id="cad",
|
| 66 |
+
name="CAD Coder",
|
| 67 |
+
role="CadQuery Code Generator",
|
| 68 |
+
color="#ffab40",
|
| 69 |
+
avatar="CC",
|
| 70 |
+
goal="Generate valid CadQuery Python code that produces the agreed-upon 3D model.",
|
| 71 |
+
backstory=(
|
| 72 |
+
"You are an expert CadQuery programmer. You only speak when asked to generate "
|
| 73 |
+
"a preview or produce code. You take the design specifications agreed upon by the "
|
| 74 |
+
"team and translate them into precise CadQuery Python code. Your code always assigns "
|
| 75 |
+
"the result to a variable called `result` as a cq.Workplane object."
|
| 76 |
+
),
|
| 77 |
+
),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# Agent metadata for frontend rendering
|
| 81 |
+
AGENT_COLORS = {agent.id: agent.color for agent in AGENTS.values()}
|
| 82 |
+
AGENT_AVATARS = {agent.id: agent.avatar for agent in AGENTS.values()}
|
| 83 |
+
AGENT_NAMES = {agent.id: agent.name for agent in AGENTS.values()}
|
agents/design_state.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Design state accumulator — extracts and persists key decisions from agent messages."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class DesignState(BaseModel):
|
| 10 |
+
"""Structured state tracking design decisions across chat turns."""
|
| 11 |
+
part_name: str = ""
|
| 12 |
+
description: str = ""
|
| 13 |
+
material: str = ""
|
| 14 |
+
dimensions: dict[str, float] = Field(default_factory=dict)
|
| 15 |
+
features: list[str] = Field(default_factory=list)
|
| 16 |
+
constraints: list[str] = Field(default_factory=list)
|
| 17 |
+
decisions: list[str] = Field(default_factory=list)
|
| 18 |
+
axis_recommendation: str = ""
|
| 19 |
+
|
| 20 |
+
def render(self) -> str:
|
| 21 |
+
"""Render non-empty fields as a concise spec block for LLM context."""
|
| 22 |
+
lines = []
|
| 23 |
+
if self.part_name:
|
| 24 |
+
lines.append(f"Part: {self.part_name}")
|
| 25 |
+
if self.description:
|
| 26 |
+
lines.append(f"Description: {self.description}")
|
| 27 |
+
if self.material:
|
| 28 |
+
lines.append(f"Material: {self.material}")
|
| 29 |
+
if self.dimensions:
|
| 30 |
+
dims = ", ".join(f"{k}={v}mm" for k, v in self.dimensions.items())
|
| 31 |
+
lines.append(f"Dimensions: {dims}")
|
| 32 |
+
if self.features:
|
| 33 |
+
lines.append(f"Features: {'; '.join(self.features)}")
|
| 34 |
+
if self.constraints:
|
| 35 |
+
lines.append(f"Constraints: {'; '.join(self.constraints)}")
|
| 36 |
+
if self.axis_recommendation:
|
| 37 |
+
lines.append(f"Axis: {self.axis_recommendation}")
|
| 38 |
+
if self.decisions:
|
| 39 |
+
lines.append("Decisions:")
|
| 40 |
+
for d in self.decisions[-5:]: # Last 5 decisions to keep it concise
|
| 41 |
+
lines.append(f" - {d}")
|
| 42 |
+
return "\n".join(lines) if lines else ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Material patterns ──────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
_MATERIALS = [
|
| 48 |
+
"aluminum", "aluminium", "steel", "stainless steel", "brass", "copper",
|
| 49 |
+
"titanium", "nylon", "delrin", "acetal", "abs", "polycarbonate", "peek",
|
| 50 |
+
]
|
| 51 |
+
_MATERIAL_GRADES = {
|
| 52 |
+
"6061": "aluminum 6061", "7075": "aluminum 7075",
|
| 53 |
+
"304": "stainless steel 304", "316": "stainless steel 316",
|
| 54 |
+
"t6": "aluminum 6061-T6",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# ── Dimension context words ────────────────────────────────────────────────
|
| 58 |
+
|
| 59 |
+
_DIM_CONTEXTS = {
|
| 60 |
+
"wide": "width", "width": "width",
|
| 61 |
+
"tall": "height", "height": "height", "high": "height",
|
| 62 |
+
"thick": "thickness", "thickness": "thickness",
|
| 63 |
+
"deep": "depth", "depth": "depth",
|
| 64 |
+
"long": "length", "length": "length",
|
| 65 |
+
"diameter": "diameter", "dia": "diameter",
|
| 66 |
+
"radius": "radius",
|
| 67 |
+
"arm": "arm_length",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def extract_decisions(
|
| 72 |
+
agent_responses: list[dict],
|
| 73 |
+
current_state: DesignState,
|
| 74 |
+
user_message: str = "",
|
| 75 |
+
) -> DesignState:
|
| 76 |
+
"""Extract design decisions from agent responses and update state.
|
| 77 |
+
|
| 78 |
+
Uses regex/keyword matching — no extra LLM call.
|
| 79 |
+
"""
|
| 80 |
+
state = current_state.model_copy(deep=True)
|
| 81 |
+
|
| 82 |
+
# Combine all text for scanning
|
| 83 |
+
all_text = user_message + " " + " ".join(r.get("message", "") for r in agent_responses)
|
| 84 |
+
lower = all_text.lower()
|
| 85 |
+
|
| 86 |
+
# Extract material
|
| 87 |
+
for grade, full_name in _MATERIAL_GRADES.items():
|
| 88 |
+
if grade in lower:
|
| 89 |
+
state.material = full_name
|
| 90 |
+
break
|
| 91 |
+
else:
|
| 92 |
+
for mat in _MATERIALS:
|
| 93 |
+
if mat in lower:
|
| 94 |
+
state.material = mat
|
| 95 |
+
break
|
| 96 |
+
|
| 97 |
+
# Extract dimensions: "60mm wide", "width of 60mm", "60 mm thick"
|
| 98 |
+
dim_pattern = re.compile(
|
| 99 |
+
r'(\d+\.?\d*)\s*mm\s+(' + '|'.join(_DIM_CONTEXTS.keys()) + r')',
|
| 100 |
+
re.IGNORECASE,
|
| 101 |
+
)
|
| 102 |
+
for match in dim_pattern.finditer(all_text):
|
| 103 |
+
value = float(match.group(1))
|
| 104 |
+
word = match.group(2).lower()
|
| 105 |
+
dim_name = _DIM_CONTEXTS.get(word, word)
|
| 106 |
+
state.dimensions[dim_name] = value
|
| 107 |
+
|
| 108 |
+
# Also match "width: 60mm" or "width of 60mm" patterns
|
| 109 |
+
dim_pattern2 = re.compile(
|
| 110 |
+
r'(' + '|'.join(_DIM_CONTEXTS.keys()) + r')\s*(?:of|:|\s)\s*(\d+\.?\d*)\s*mm',
|
| 111 |
+
re.IGNORECASE,
|
| 112 |
+
)
|
| 113 |
+
for match in dim_pattern2.finditer(all_text):
|
| 114 |
+
word = match.group(1).lower()
|
| 115 |
+
value = float(match.group(2))
|
| 116 |
+
dim_name = _DIM_CONTEXTS.get(word, word)
|
| 117 |
+
state.dimensions[dim_name] = value
|
| 118 |
+
|
| 119 |
+
# Extract fastener features: "4x M6 holes", "M4 clearance holes"
|
| 120 |
+
fastener_pattern = re.compile(r'(\d+)\s*[x\u00d7]\s*(M\d+)\s+\w*\s*hole', re.IGNORECASE)
|
| 121 |
+
for match in fastener_pattern.finditer(all_text):
|
| 122 |
+
feature = f"{match.group(1)}x {match.group(2).upper()} holes"
|
| 123 |
+
if feature not in state.features:
|
| 124 |
+
state.features.append(feature)
|
| 125 |
+
|
| 126 |
+
# Single fastener mention: "M6 holes", "M3 clearance holes"
|
| 127 |
+
single_fastener = re.compile(r'(M\d+)\s+(?:clearance\s+)?(?:hole|bolt|screw)', re.IGNORECASE)
|
| 128 |
+
for match in single_fastener.finditer(all_text):
|
| 129 |
+
feature = f"{match.group(1).upper()} holes"
|
| 130 |
+
if feature not in state.features and not any(feature.split()[0] in f for f in state.features):
|
| 131 |
+
state.features.append(feature)
|
| 132 |
+
|
| 133 |
+
# Extract axis recommendation
|
| 134 |
+
axis_pattern = re.compile(r'(3-axis|3\+2[\s-]*axis|5-axis)', re.IGNORECASE)
|
| 135 |
+
axis_match = axis_pattern.search(all_text)
|
| 136 |
+
if axis_match:
|
| 137 |
+
state.axis_recommendation = axis_match.group(1).lower()
|
| 138 |
+
|
| 139 |
+
# Extract constraint keywords
|
| 140 |
+
constraint_patterns = [
|
| 141 |
+
(r'min(?:imum)?\s+wall\s+(?:thickness\s+)?(\d+\.?\d*)\s*mm', "min wall {}mm"),
|
| 142 |
+
(r'max(?:imum)?\s+(?:part\s+)?size\s+(\d+\.?\d*)\s*mm', "max size {}mm"),
|
| 143 |
+
]
|
| 144 |
+
for pattern, template in constraint_patterns:
|
| 145 |
+
match = re.search(pattern, all_text, re.IGNORECASE)
|
| 146 |
+
if match:
|
| 147 |
+
constraint = template.format(match.group(1))
|
| 148 |
+
if constraint not in state.constraints:
|
| 149 |
+
state.constraints.append(constraint)
|
| 150 |
+
|
| 151 |
+
# Extract decisions: sentences with agreement language from agent messages only
|
| 152 |
+
for resp in agent_responses:
|
| 153 |
+
msg = resp.get("message", "")
|
| 154 |
+
sentences = re.split(r'[.!?]+', msg)
|
| 155 |
+
for sentence in sentences:
|
| 156 |
+
s = sentence.strip()
|
| 157 |
+
if len(s) > 15 and any(kw in s.lower() for kw in [
|
| 158 |
+
"recommend", "suggest", "should use", "let's go with",
|
| 159 |
+
"i'd use", "best to", "we'll need", "i'll specify",
|
| 160 |
+
]):
|
| 161 |
+
if s not in state.decisions and len(state.decisions) < 20:
|
| 162 |
+
state.decisions.append(s)
|
| 163 |
+
|
| 164 |
+
# Extract part name from user message if not set
|
| 165 |
+
if not state.part_name and user_message:
|
| 166 |
+
name_patterns = [
|
| 167 |
+
r'(?:need|want|design|make|create)\s+(?:a|an)\s+(.{5,40}?)\s*(?:with|for|that|,|$)',
|
| 168 |
+
]
|
| 169 |
+
for pattern in name_patterns:
|
| 170 |
+
match = re.search(pattern, user_message, re.IGNORECASE)
|
| 171 |
+
if match:
|
| 172 |
+
state.part_name = match.group(1).strip()
|
| 173 |
+
break
|
| 174 |
+
|
| 175 |
+
return state
|
agents/llm_adapter.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CrewAI BaseLLM adapter for NeuralCAD's LLMBackend interface."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from crewai import LLM as BaseLLM
|
| 8 |
+
except ImportError:
|
| 9 |
+
# Fallback if crewai not installed — allows import without dependency
|
| 10 |
+
class BaseLLM:
|
| 11 |
+
def __init__(self, model: str, **kwargs):
|
| 12 |
+
self.model = model
|
| 13 |
+
def call(self, messages, **kwargs) -> str:
|
| 14 |
+
raise NotImplementedError
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class NeuralCADLLMAdapter(BaseLLM):
|
| 18 |
+
"""Adapter that wraps NeuralCAD's LLMBackend for CrewAI compatibility.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
from core.backends import GeminiBackend
|
| 22 |
+
backend = GeminiBackend()
|
| 23 |
+
adapter = NeuralCADLLMAdapter(backend, model="gemini-2.5-flash")
|
| 24 |
+
# Now usable as CrewAI agent's llm parameter
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, backend, model: str = "custom", **kwargs):
|
| 28 |
+
super().__init__(model=model, **kwargs)
|
| 29 |
+
self.backend = backend
|
| 30 |
+
|
| 31 |
+
def call(
|
| 32 |
+
self,
|
| 33 |
+
messages: str | list[dict],
|
| 34 |
+
tools: Any = None,
|
| 35 |
+
callbacks: Any = None,
|
| 36 |
+
available_functions: Any = None,
|
| 37 |
+
**kwargs,
|
| 38 |
+
) -> str:
|
| 39 |
+
# If messages is a string, wrap it in standard format
|
| 40 |
+
if isinstance(messages, str):
|
| 41 |
+
messages = [{"role": "user", "content": messages}]
|
| 42 |
+
return self.backend.generate(messages)
|
| 43 |
+
|
| 44 |
+
def supports_function_calling(self) -> bool:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
def supports_stop_words(self) -> bool:
|
| 48 |
+
return False
|
agents/orchestrator.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Single-call orchestrator for multi-agent chat (Gemini/Mock mode).
|
| 2 |
+
|
| 3 |
+
One LLM call per user turn. The orchestrator builds a system prompt containing
|
| 4 |
+
all agent personas, sends a single request, and parses the JSON response into
|
| 5 |
+
individual agent messages. For mock mode no LLM call is made at all — canned
|
| 6 |
+
responses are returned based on keyword matching.
|
| 7 |
+
|
| 8 |
+
Both ``MockChatBackend`` and ``SingleCallOrchestrator`` return the same shape::
|
| 9 |
+
|
| 10 |
+
{
|
| 11 |
+
"responses": [{"agent_id", "agent_name", "message", "color", "avatar", "code"}, ...],
|
| 12 |
+
"preview": None | { ... execution + validation data ... }
|
| 13 |
+
}
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
|
| 21 |
+
from agents.prompts import (
|
| 22 |
+
build_orchestrator_system_prompt,
|
| 23 |
+
build_chat_messages,
|
| 24 |
+
route_by_keywords,
|
| 25 |
+
parse_orchestrator_response,
|
| 26 |
+
CAD_TRIGGER_KEYWORDS,
|
| 27 |
+
)
|
| 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 |
+
|
| 34 |
+
DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 35 |
+
|
| 36 |
+
# Role-appropriate fallback messages when the LLM call fails.
|
| 37 |
+
_FALLBACK_MESSAGES: dict[str, str] = {
|
| 38 |
+
"design": "I'd love to help shape this design. Could you describe the part's purpose and any size constraints?",
|
| 39 |
+
"engineering": "I can help with the structural details. What material and load conditions are we working with?",
|
| 40 |
+
"cnc": "I'll check manufacturability once we have more design details. Any machining preferences (3-axis, 5-axis)?",
|
| 41 |
+
"cad": "I'm ready to generate the model once the design is agreed upon. Say 'preview' when you're ready.",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Helpers
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
def _format_response(agent_id: str, message: str, code: str | None = None) -> dict:
|
| 50 |
+
"""Wrap a raw agent reply into the standard response envelope."""
|
| 51 |
+
return {
|
| 52 |
+
"agent_id": agent_id,
|
| 53 |
+
"agent_name": AGENT_NAMES[agent_id],
|
| 54 |
+
"message": message,
|
| 55 |
+
"color": AGENT_COLORS[agent_id],
|
| 56 |
+
"avatar": AGENT_AVATARS[agent_id],
|
| 57 |
+
"code": code,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _execute_cad_code(
|
| 62 |
+
code: str,
|
| 63 |
+
prompt: str,
|
| 64 |
+
output_dir: Path,
|
| 65 |
+
backend: LLMBackend | None = None,
|
| 66 |
+
max_retries: int = 2,
|
| 67 |
+
) -> dict | None:
|
| 68 |
+
"""Execute CadQuery *code* and return preview data (or error dict).
|
| 69 |
+
|
| 70 |
+
If execution fails and a *backend* is provided, feed the error back to
|
| 71 |
+
the LLM for self-correction (up to *max_retries* attempts).
|
| 72 |
+
"""
|
| 73 |
+
exec_result = execute_cadquery(code)
|
| 74 |
+
retries = 0
|
| 75 |
+
|
| 76 |
+
while not exec_result.success and backend is not None and retries < max_retries:
|
| 77 |
+
retries += 1
|
| 78 |
+
from core.cadquery_prompts import build_messages
|
| 79 |
+
error_feedback = (
|
| 80 |
+
f"The CadQuery code failed with this error:\n"
|
| 81 |
+
f"```\n{exec_result.error}\n```\n\n"
|
| 82 |
+
f"Original code:\n```python\n{code}\n```\n\n"
|
| 83 |
+
f"Fix the code and return ONLY the corrected Python. Original request: {prompt}"
|
| 84 |
+
)
|
| 85 |
+
try:
|
| 86 |
+
code = backend.generate(build_messages(error_feedback))
|
| 87 |
+
exec_result = execute_cadquery(code)
|
| 88 |
+
except Exception:
|
| 89 |
+
break
|
| 90 |
+
|
| 91 |
+
if not exec_result.success:
|
| 92 |
+
return {"success": False, "error": exec_result.error}
|
| 93 |
+
|
| 94 |
+
# Derive a filesystem-safe part name from the prompt
|
| 95 |
+
part_name = prompt[:40].strip().replace(" ", "_").lower()
|
| 96 |
+
part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
|
| 97 |
+
if not part_name:
|
| 98 |
+
part_name = "part"
|
| 99 |
+
|
| 100 |
+
# Export STL + STEP
|
| 101 |
+
base_path = output_dir / part_name
|
| 102 |
+
try:
|
| 103 |
+
export_all(exec_result.result, base_path)
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
return {"success": False, "error": f"Export failed: {exc}"}
|
| 106 |
+
|
| 107 |
+
# CNC validation
|
| 108 |
+
validation = validate_for_cnc(exec_result.result, part_name=part_name)
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"success": True,
|
| 112 |
+
"part_name": part_name,
|
| 113 |
+
"stl_url": f"/api/models/{part_name}.stl",
|
| 114 |
+
"step_url": f"/api/models/{part_name}.step",
|
| 115 |
+
"execution": {
|
| 116 |
+
"success": True,
|
| 117 |
+
"volume_mm3": exec_result.volume,
|
| 118 |
+
"bounding_box_mm": list(exec_result.bounding_box),
|
| 119 |
+
"face_count": exec_result.face_count,
|
| 120 |
+
"edge_count": exec_result.edge_count,
|
| 121 |
+
},
|
| 122 |
+
"validation": {
|
| 123 |
+
"machinable": validation.machinable,
|
| 124 |
+
"axis_recommendation": validation.axis_recommendation,
|
| 125 |
+
"error_count": validation.error_count,
|
| 126 |
+
"warning_count": validation.warning_count,
|
| 127 |
+
"issues": [
|
| 128 |
+
{"severity": i.severity, "category": i.category, "message": i.message}
|
| 129 |
+
for i in validation.issues
|
| 130 |
+
],
|
| 131 |
+
},
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
# MockChatBackend — template-based, no LLM call
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
class MockChatBackend:
|
| 140 |
+
"""Template-based chat responses for mock mode (no LLM call).
|
| 141 |
+
|
| 142 |
+
Generates canned agent responses based on keyword matching.
|
| 143 |
+
For the CAD Coder agent, delegates to ``MockBackend`` for code generation.
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
def __init__(self, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
|
| 147 |
+
self.output_dir = Path(output_dir)
|
| 148 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 149 |
+
|
| 150 |
+
# -- public interface ----------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def chat_turn(
|
| 153 |
+
self,
|
| 154 |
+
message: str,
|
| 155 |
+
history: list[dict],
|
| 156 |
+
mentions: list[str] | None = None,
|
| 157 |
+
max_history: int = 30,
|
| 158 |
+
design_state: dict | None = None,
|
| 159 |
+
) -> dict:
|
| 160 |
+
"""Return ``{"responses": [...], "preview": ..., "design_state": ...}``."""
|
| 161 |
+
state = DesignState(**(design_state or {}))
|
| 162 |
+
lower = message.lower()
|
| 163 |
+
|
| 164 |
+
# Determine which agents respond
|
| 165 |
+
if mentions:
|
| 166 |
+
active = mentions
|
| 167 |
+
else:
|
| 168 |
+
active = route_by_keywords(message)
|
| 169 |
+
|
| 170 |
+
responses: list[dict] = []
|
| 171 |
+
preview = None
|
| 172 |
+
|
| 173 |
+
if "design" in active:
|
| 174 |
+
responses.append(
|
| 175 |
+
_format_response("design", self._design_response(lower))
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
if "engineering" in active:
|
| 179 |
+
responses.append(
|
| 180 |
+
_format_response("engineering", self._engineering_response(lower))
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
if "cnc" in active:
|
| 184 |
+
responses.append(
|
| 185 |
+
_format_response("cnc", self._cnc_response(lower))
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
if "cad" in active:
|
| 189 |
+
# Use MockBackend for actual code generation
|
| 190 |
+
from core.cadquery_prompts import build_messages
|
| 191 |
+
|
| 192 |
+
mock = MockBackend()
|
| 193 |
+
code = mock.generate(build_messages(message))
|
| 194 |
+
responses.append(
|
| 195 |
+
_format_response(
|
| 196 |
+
"cad",
|
| 197 |
+
"Model generated. Click the 3D viewer to inspect it.",
|
| 198 |
+
code=code,
|
| 199 |
+
)
|
| 200 |
+
)
|
| 201 |
+
preview = _execute_cad_code(code, message, self.output_dir)
|
| 202 |
+
|
| 203 |
+
# Update design state from responses
|
| 204 |
+
updated_state = extract_decisions(responses, state, message)
|
| 205 |
+
|
| 206 |
+
return {"responses": responses, "preview": preview, "design_state": updated_state.model_dump()}
|
| 207 |
+
|
| 208 |
+
# -- canned response templates -------------------------------------------
|
| 209 |
+
|
| 210 |
+
@staticmethod
|
| 211 |
+
def _design_response(lower: str) -> str:
|
| 212 |
+
if any(w in lower for w in ("bracket", "mount")):
|
| 213 |
+
return (
|
| 214 |
+
"For a mounting bracket, I'd suggest an L-shaped profile with "
|
| 215 |
+
"filleted corners for rigidity. What's the intended load direction?"
|
| 216 |
+
)
|
| 217 |
+
if any(w in lower for w in ("gear", "spur")):
|
| 218 |
+
return (
|
| 219 |
+
"For a spur gear, we'll need to define the module, tooth count, "
|
| 220 |
+
"and bore diameter. What's the mating gear specification?"
|
| 221 |
+
)
|
| 222 |
+
if any(w in lower for w in ("enclosure", "box", "housing")):
|
| 223 |
+
return (
|
| 224 |
+
"For an enclosure, I'd recommend rounded external corners for "
|
| 225 |
+
"aesthetics and a pocket on the top face for the lid. What "
|
| 226 |
+
"components go inside?"
|
| 227 |
+
)
|
| 228 |
+
return (
|
| 229 |
+
"I can help design that. Could you tell me more about the part's "
|
| 230 |
+
"purpose and any dimensional constraints?"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
@staticmethod
|
| 234 |
+
def _engineering_response(lower: str) -> str:
|
| 235 |
+
if any(w in lower for w in ("m3", "m4", "m5", "m6", "m8")):
|
| 236 |
+
return (
|
| 237 |
+
"Good fastener choice. I'll specify the clearance holes per ISO "
|
| 238 |
+
"standards. Shall I add counterbores or keep them as through-holes?"
|
| 239 |
+
)
|
| 240 |
+
if any(w in lower for w in ("load", "stress", "strength")):
|
| 241 |
+
return (
|
| 242 |
+
"For the expected loads, I'd recommend 3mm minimum wall thickness "
|
| 243 |
+
"in aluminum 6061-T6. Adding reinforcement ribs would increase "
|
| 244 |
+
"stiffness significantly."
|
| 245 |
+
)
|
| 246 |
+
return (
|
| 247 |
+
"I'll specify the critical dimensions and tolerances. What material "
|
| 248 |
+
"are you planning to machine this from?"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
@staticmethod
|
| 252 |
+
def _cnc_response(lower: str) -> str:
|
| 253 |
+
if any(w in lower for w in ("pocket", "deep", "slot")):
|
| 254 |
+
return (
|
| 255 |
+
"Keep pocket depth-to-width ratio under 4:1 for clean machining. "
|
| 256 |
+
"I'd recommend a 6mm endmill for this geometry."
|
| 257 |
+
)
|
| 258 |
+
if any(w in lower for w in ("5-axis", "undercut")):
|
| 259 |
+
return (
|
| 260 |
+
"That feature would require 5-axis machining. Consider redesigning "
|
| 261 |
+
"to avoid undercuts for 3-axis compatibility."
|
| 262 |
+
)
|
| 263 |
+
return (
|
| 264 |
+
"This looks achievable with standard 3-axis milling. No undercuts or "
|
| 265 |
+
"access issues detected so far."
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ---------------------------------------------------------------------------
|
| 270 |
+
# SingleCallOrchestrator — one LLM call per turn
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
|
| 273 |
+
class SingleCallOrchestrator:
|
| 274 |
+
"""Orchestrator that uses a single LLM call per chat turn.
|
| 275 |
+
|
| 276 |
+
Builds a system prompt containing all agent personas, sends one LLM call,
|
| 277 |
+
and parses the JSON response into individual agent messages.
|
| 278 |
+
Used for Gemini free tier and other rate-limited backends.
|
| 279 |
+
"""
|
| 280 |
+
|
| 281 |
+
def __init__(self, backend: LLMBackend, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
|
| 282 |
+
self.backend = backend
|
| 283 |
+
self.output_dir = Path(output_dir)
|
| 284 |
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 285 |
+
|
| 286 |
+
def chat_turn(
|
| 287 |
+
self,
|
| 288 |
+
message: str,
|
| 289 |
+
history: list[dict],
|
| 290 |
+
mentions: list[str] | None = None,
|
| 291 |
+
max_history: int = 30,
|
| 292 |
+
design_state: dict | None = None,
|
| 293 |
+
) -> dict:
|
| 294 |
+
"""Run one chat turn: user message -> agent responses.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
message: The user's message text (with @mentions already stripped).
|
| 298 |
+
history: Previous messages [{role, agent_id, content}, ...].
|
| 299 |
+
mentions: Agent IDs explicitly mentioned by user. ``None`` = auto-route.
|
| 300 |
+
max_history: Max history messages to include in context.
|
| 301 |
+
design_state: Persisted design state dict from previous turns.
|
| 302 |
+
|
| 303 |
+
Returns:
|
| 304 |
+
``{"responses": [...], "preview": None | {...}, "design_state": {...}}``
|
| 305 |
+
"""
|
| 306 |
+
state = DesignState(**(design_state or {}))
|
| 307 |
+
|
| 308 |
+
# Determine which agents are active
|
| 309 |
+
active_agents = mentions if mentions else None # None lets orchestrator decide
|
| 310 |
+
|
| 311 |
+
# Check if CAD context is needed
|
| 312 |
+
include_cad = mentions is not None and "cad" in mentions
|
| 313 |
+
if not include_cad:
|
| 314 |
+
include_cad = any(kw in message.lower() for kw in CAD_TRIGGER_KEYWORDS)
|
| 315 |
+
|
| 316 |
+
# Build orchestrator prompt
|
| 317 |
+
system_prompt = build_orchestrator_system_prompt(
|
| 318 |
+
active_agents=active_agents,
|
| 319 |
+
include_cad_context=include_cad,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Build message list
|
| 323 |
+
messages = build_chat_messages(
|
| 324 |
+
user_message=message,
|
| 325 |
+
history=history,
|
| 326 |
+
system_prompt=system_prompt,
|
| 327 |
+
max_history=max_history,
|
| 328 |
+
design_state_text=state.render(),
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Single LLM call
|
| 332 |
+
try:
|
| 333 |
+
raw_response = self.backend.generate(messages)
|
| 334 |
+
agent_responses = parse_orchestrator_response(raw_response)
|
| 335 |
+
except Exception as exc:
|
| 336 |
+
import logging
|
| 337 |
+
logging.warning("Orchestrator LLM call failed: %s", exc)
|
| 338 |
+
# Fallback: keyword routing with role-appropriate replies
|
| 339 |
+
fallback_agents = route_by_keywords(message)
|
| 340 |
+
agent_responses = [
|
| 341 |
+
{"id": aid, "message": _FALLBACK_MESSAGES.get(aid, "Let me look into that."), "code": None}
|
| 342 |
+
for aid in fallback_agents
|
| 343 |
+
]
|
| 344 |
+
|
| 345 |
+
# Format responses with metadata
|
| 346 |
+
formatted: list[dict] = []
|
| 347 |
+
preview = None
|
| 348 |
+
|
| 349 |
+
for resp in agent_responses:
|
| 350 |
+
agent_id = resp["id"]
|
| 351 |
+
if agent_id not in AGENTS:
|
| 352 |
+
continue
|
| 353 |
+
|
| 354 |
+
formatted.append(
|
| 355 |
+
_format_response(agent_id, resp["message"], code=resp.get("code"))
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# If CAD Coder responded with code, execute it (with retry)
|
| 359 |
+
if agent_id == "cad" and resp.get("code"):
|
| 360 |
+
preview = _execute_cad_code(
|
| 361 |
+
resp["code"], message, self.output_dir, backend=self.backend,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Update design state from responses
|
| 365 |
+
updated_state = extract_decisions(formatted, state, message)
|
| 366 |
+
|
| 367 |
+
return {"responses": formatted, "preview": preview, "design_state": updated_state.model_dump()}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ---------------------------------------------------------------------------
|
| 371 |
+
# Factory
|
| 372 |
+
# ---------------------------------------------------------------------------
|
| 373 |
+
|
| 374 |
+
def get_orchestrator(
|
| 375 |
+
backend_name: str = "mock",
|
| 376 |
+
output_dir: str | Path = DEFAULT_OUTPUT_DIR,
|
| 377 |
+
) -> MockChatBackend | SingleCallOrchestrator:
|
| 378 |
+
"""Create the appropriate orchestrator for the given backend.
|
| 379 |
+
|
| 380 |
+
Args:
|
| 381 |
+
backend_name: ``"mock"``, ``"gemini"``, ``"anthropic"``, or ``"openai"``.
|
| 382 |
+
output_dir: Directory for exported model files.
|
| 383 |
+
"""
|
| 384 |
+
if backend_name == "mock":
|
| 385 |
+
return MockChatBackend(output_dir=output_dir)
|
| 386 |
+
|
| 387 |
+
# For all LLM backends, use SingleCallOrchestrator.
|
| 388 |
+
# (CrewAI multi-call variant can be added later for anthropic/openai.)
|
| 389 |
+
from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
|
| 390 |
+
|
| 391 |
+
backends = {
|
| 392 |
+
"gemini": GeminiBackend,
|
| 393 |
+
"anthropic": AnthropicBackend,
|
| 394 |
+
"openai": OpenAIBackend,
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
backend_cls = backends.get(backend_name)
|
| 398 |
+
if backend_cls is None:
|
| 399 |
+
return MockChatBackend(output_dir=output_dir)
|
| 400 |
+
|
| 401 |
+
try:
|
| 402 |
+
backend = backend_cls()
|
| 403 |
+
except Exception as exc:
|
| 404 |
+
import logging
|
| 405 |
+
logging.warning(
|
| 406 |
+
"Backend %r unavailable (%s), falling back to mock", backend_name, exc
|
| 407 |
+
)
|
| 408 |
+
return MockChatBackend(output_dir=output_dir)
|
| 409 |
+
|
| 410 |
+
return SingleCallOrchestrator(backend=backend, output_dir=output_dir)
|
agents/prompts.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orchestrator prompts and routing logic for multi-agent chat."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from agents.definitions import AGENTS, AgentDef
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# Single source of truth for CAD Coder activation keywords.
|
| 13 |
+
# Used by: system prompt, keyword routing, and orchestrator CAD-context check.
|
| 14 |
+
CAD_TRIGGER_KEYWORDS: list[str] = [
|
| 15 |
+
"generate", "build", "build it", "preview", "show me", "create",
|
| 16 |
+
"create the model", "model it", "render", "code", "make it", "produce",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def build_orchestrator_system_prompt(
|
| 21 |
+
active_agents: list[str] | None = None,
|
| 22 |
+
include_cad_context: bool = False,
|
| 23 |
+
) -> str:
|
| 24 |
+
"""Build the orchestrator system prompt for single-call mode.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
active_agents: List of agent IDs to include. None = all except 'cad'.
|
| 28 |
+
include_cad_context: Whether to include CadQuery reference for the CAD agent.
|
| 29 |
+
"""
|
| 30 |
+
if active_agents is None:
|
| 31 |
+
active_agents = ["design", "engineering", "cnc"]
|
| 32 |
+
|
| 33 |
+
prompt_parts = [
|
| 34 |
+
"You are the orchestrator for a multi-agent CAD design team. "
|
| 35 |
+
"You control multiple specialist agents who collaborate with a user "
|
| 36 |
+
"to design mechanical parts for CNC machining.\n",
|
| 37 |
+
"## Your Agents\n",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
for agent_id in active_agents:
|
| 41 |
+
agent = AGENTS[agent_id]
|
| 42 |
+
prompt_parts.append(
|
| 43 |
+
f"### {agent.name} (id: \"{agent.id}\")\n"
|
| 44 |
+
f"Role: {agent.role}\n"
|
| 45 |
+
f"Goal: {agent.goal}\n"
|
| 46 |
+
f"Personality: {agent.backstory}\n"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
prompt_parts.append(
|
| 50 |
+
"## Instructions\n"
|
| 51 |
+
"Given the conversation history and the user's latest message, "
|
| 52 |
+
"decide which agents should respond and generate their messages.\n\n"
|
| 53 |
+
"Rules:\n"
|
| 54 |
+
"- Select 1-3 agents that are most relevant to the user's LATEST message.\n"
|
| 55 |
+
"- Each agent should respond in character with their expertise.\n"
|
| 56 |
+
"- Keep responses concise and actionable (2-4 sentences each).\n"
|
| 57 |
+
"- Use the conversation history for context (know what was decided), "
|
| 58 |
+
"but ONLY respond to the user's latest message. Do NOT repeat or "
|
| 59 |
+
"paraphrase anything already said — always advance the discussion.\n"
|
| 60 |
+
"- Each agent should add DIFFERENT information. If one agent covers "
|
| 61 |
+
"dimensions, another should cover materials or tooling, not restate dimensions.\n"
|
| 62 |
+
f"- Do NOT include the CAD Coder agent unless the user explicitly uses one of "
|
| 63 |
+
f"these trigger words: {', '.join(repr(k) for k in CAD_TRIGGER_KEYWORDS)}.\n"
|
| 64 |
+
"- When the CAD Coder responds, include a 'code' field with valid CadQuery Python "
|
| 65 |
+
"that assigns the result to a variable called `result` as a cq.Workplane.\n"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if include_cad_context and "cad" in active_agents:
|
| 69 |
+
from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
|
| 70 |
+
prompt_parts.append(
|
| 71 |
+
"\n## CadQuery Reference (for CAD Coder agent)\n"
|
| 72 |
+
f"{CADQUERY_SYSTEM_PROMPT}\n"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
prompt_parts.append(
|
| 76 |
+
"\n## Response Format\n"
|
| 77 |
+
"Respond with ONLY valid JSON in this exact format:\n"
|
| 78 |
+
"```json\n"
|
| 79 |
+
'{"agents": [\n'
|
| 80 |
+
' {"id": "design", "message": "Your design suggestion here..."},\n'
|
| 81 |
+
' {"id": "engineering", "message": "Your engineering input here..."}\n'
|
| 82 |
+
"]}\n"
|
| 83 |
+
"```\n\n"
|
| 84 |
+
"When the CAD Coder agent responds, add a 'code' field:\n"
|
| 85 |
+
"```json\n"
|
| 86 |
+
'{"agents": [\n'
|
| 87 |
+
' {"id": "cad", "message": "Model generated.", '
|
| 88 |
+
'"code": "import cadquery as cq\\nresult = cq.Workplane(\'XY\').box(10,10,10)"}\n'
|
| 89 |
+
"]}\n"
|
| 90 |
+
"```\n\n"
|
| 91 |
+
"Output ONLY the JSON. No other text."
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
return "\n".join(prompt_parts)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def build_chat_messages(
|
| 98 |
+
user_message: str,
|
| 99 |
+
history: list[dict],
|
| 100 |
+
system_prompt: str,
|
| 101 |
+
max_history: int = 30,
|
| 102 |
+
design_state_text: str = "",
|
| 103 |
+
) -> list[dict]:
|
| 104 |
+
"""Build the message list for the orchestrator LLM call.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
user_message: The user's current message.
|
| 108 |
+
history: Previous messages [{role, agent_id, content}, ...].
|
| 109 |
+
system_prompt: The orchestrator system prompt.
|
| 110 |
+
max_history: Maximum number of history messages to include.
|
| 111 |
+
design_state_text: Rendered design state spec to inject as context.
|
| 112 |
+
"""
|
| 113 |
+
messages = [{"role": "system", "content": system_prompt}]
|
| 114 |
+
|
| 115 |
+
# Truncate history to last N messages
|
| 116 |
+
recent = history[-max_history:] if len(history) > max_history else history
|
| 117 |
+
|
| 118 |
+
# Bundle history into a single context block to avoid Gemini
|
| 119 |
+
# treating prior agent messages as its own output and repeating them.
|
| 120 |
+
content_parts = []
|
| 121 |
+
if design_state_text:
|
| 122 |
+
content_parts.append(f"## Current Design Spec (agreed so far)\n{design_state_text}\n")
|
| 123 |
+
if recent:
|
| 124 |
+
history_lines = []
|
| 125 |
+
for msg in recent:
|
| 126 |
+
if msg.get("role") == "user":
|
| 127 |
+
history_lines.append(f"USER: {msg['content']}")
|
| 128 |
+
else:
|
| 129 |
+
agent_id = msg.get("agent_id", "unknown")
|
| 130 |
+
agent_name = AGENTS.get(agent_id, AGENTS["design"]).name
|
| 131 |
+
history_lines.append(f"{agent_name.upper()}: {msg['content']}")
|
| 132 |
+
|
| 133 |
+
history_block = "\n".join(history_lines)
|
| 134 |
+
content_parts.append(f"## Conversation so far:\n{history_block}\n")
|
| 135 |
+
content_parts.append(f"## User's new message:\n{user_message}\n\nRespond to the user's NEW message above. Do NOT repeat prior responses.")
|
| 136 |
+
|
| 137 |
+
messages.append({"role": "user", "content": "\n".join(content_parts)})
|
| 138 |
+
|
| 139 |
+
return messages
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def parse_mentions(message: str) -> tuple[str, list[str]]:
|
| 143 |
+
"""Extract @mentions from a message and return cleaned message + mention list.
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
(cleaned_message, mentions) where mentions is list of agent IDs.
|
| 147 |
+
"""
|
| 148 |
+
mentions = []
|
| 149 |
+
cleaned = message
|
| 150 |
+
|
| 151 |
+
for agent_id in AGENTS:
|
| 152 |
+
pattern = rf"@{agent_id}\b"
|
| 153 |
+
if re.search(pattern, message, re.IGNORECASE):
|
| 154 |
+
mentions.append(agent_id)
|
| 155 |
+
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE).strip()
|
| 156 |
+
|
| 157 |
+
return cleaned, mentions
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ── Keyword-based fallback routing ────────────────────────────────────────
|
| 161 |
+
|
| 162 |
+
_ROUTING_KEYWORDS: dict[str, list[str]] = {
|
| 163 |
+
"design": [
|
| 164 |
+
"design", "look", "shape", "style", "form", "aesthetic", "appearance",
|
| 165 |
+
"layout", "concept", "idea", "propose", "suggest", "bracket", "mount",
|
| 166 |
+
"enclosure", "housing", "ergonomic", "profile", "contour",
|
| 167 |
+
],
|
| 168 |
+
"engineering": [
|
| 169 |
+
"dimension", "tolerance", "material", "strength", "load", "stress",
|
| 170 |
+
"thickness", "wall", "fillet", "radius", "clearance",
|
| 171 |
+
"m2", "m3", "m4", "m5", "m6", "m8", "m10", "m12",
|
| 172 |
+
"aluminum", "steel", "brass", "titanium", "nylon",
|
| 173 |
+
"gear", "bearing", "flange", "heatsink", "fin", "rib",
|
| 174 |
+
"bolt", "screw", "thread", "torque", "deflection",
|
| 175 |
+
"hole", "bore", "shaft", "keyway", "spline",
|
| 176 |
+
],
|
| 177 |
+
"cnc": [
|
| 178 |
+
"machine", "mill", "cnc", "manufacture", "machinable", "axis",
|
| 179 |
+
"tool", "fixture", "setup", "pocket", "undercut", "access",
|
| 180 |
+
"3-axis", "5-axis", "cost", "surface finish", "roughness",
|
| 181 |
+
"endmill", "drill", "tap", "chamfer tool", "deburr",
|
| 182 |
+
"setup count", "cycle time", "tolerance class",
|
| 183 |
+
],
|
| 184 |
+
"cad": CAD_TRIGGER_KEYWORDS,
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def route_by_keywords(message: str) -> list[str]:
|
| 189 |
+
"""Fallback agent routing based on keyword matching.
|
| 190 |
+
|
| 191 |
+
Returns list of agent IDs that should respond.
|
| 192 |
+
"""
|
| 193 |
+
lower = message.lower()
|
| 194 |
+
scores: dict[str, int] = {agent_id: 0 for agent_id in AGENTS}
|
| 195 |
+
|
| 196 |
+
for agent_id, keywords in _ROUTING_KEYWORDS.items():
|
| 197 |
+
for kw in keywords:
|
| 198 |
+
if kw in lower:
|
| 199 |
+
scores[agent_id] += 1
|
| 200 |
+
|
| 201 |
+
# Select agents with score > 0, sorted by score descending
|
| 202 |
+
active = [aid for aid, score in sorted(scores.items(), key=lambda x: -x[1]) if score > 0]
|
| 203 |
+
|
| 204 |
+
# Default: design + engineering for general discussion
|
| 205 |
+
if not active:
|
| 206 |
+
active = ["design", "engineering"]
|
| 207 |
+
|
| 208 |
+
# Cap at 3 agents
|
| 209 |
+
return active[:3]
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def parse_orchestrator_response(response_text: str) -> list[dict]:
|
| 213 |
+
"""Parse the orchestrator's JSON response into agent messages.
|
| 214 |
+
|
| 215 |
+
Returns list of dicts: [{"id": str, "message": str, "code": str|None}, ...]
|
| 216 |
+
Falls back to treating entire response as design agent message if JSON fails.
|
| 217 |
+
"""
|
| 218 |
+
text = response_text.strip()
|
| 219 |
+
|
| 220 |
+
# Try to extract JSON from markdown code fences
|
| 221 |
+
json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
| 222 |
+
if json_match:
|
| 223 |
+
text = json_match.group(1)
|
| 224 |
+
|
| 225 |
+
try:
|
| 226 |
+
data = json.loads(text)
|
| 227 |
+
agents = data.get("agents", [])
|
| 228 |
+
|
| 229 |
+
# Validate structure
|
| 230 |
+
result = []
|
| 231 |
+
for agent in agents:
|
| 232 |
+
if isinstance(agent, dict) and "id" in agent and "message" in agent:
|
| 233 |
+
result.append({
|
| 234 |
+
"id": agent["id"],
|
| 235 |
+
"message": agent["message"],
|
| 236 |
+
"code": agent.get("code"),
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
if result:
|
| 240 |
+
return result
|
| 241 |
+
except (json.JSONDecodeError, KeyError, TypeError):
|
| 242 |
+
pass
|
| 243 |
+
|
| 244 |
+
# Fallback: treat entire response as design agent message
|
| 245 |
+
return [{"id": "design", "message": response_text, "code": None}]
|
core/__init__.py
ADDED
|
File without changes
|
core/backends.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM backend implementations for CadQuery code generation.
|
| 3 |
+
|
| 4 |
+
Supports multiple backends:
|
| 5 |
+
- Anthropic Claude
|
| 6 |
+
- OpenAI GPT-4o
|
| 7 |
+
- Google Gemini (free tier available)
|
| 8 |
+
- Mock (dynamic generation, no API key required)
|
| 9 |
+
- NeuralCAD (local neural pipeline, not yet implemented)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import mimetypes
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ── LLM Backends ──────────────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LLMBackend:
|
| 24 |
+
"""Base class for LLM code generation backends."""
|
| 25 |
+
|
| 26 |
+
def generate(self, messages: list[dict]) -> str:
|
| 27 |
+
raise NotImplementedError
|
| 28 |
+
|
| 29 |
+
def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
|
| 30 |
+
"""Generate code from messages that include an image.
|
| 31 |
+
Override in backends that support vision."""
|
| 32 |
+
raise NotImplementedError(
|
| 33 |
+
f"{self.__class__.__name__} does not support image input"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class AnthropicBackend(LLMBackend):
|
| 38 |
+
"""Generate CadQuery code using Anthropic Claude."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None
|
| 42 |
+
):
|
| 43 |
+
import anthropic
|
| 44 |
+
|
| 45 |
+
self.client = anthropic.Anthropic(
|
| 46 |
+
api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
|
| 47 |
+
)
|
| 48 |
+
self.model = model
|
| 49 |
+
|
| 50 |
+
def generate(self, messages: list[dict]) -> str:
|
| 51 |
+
# Anthropic uses system param separately
|
| 52 |
+
system_msg = ""
|
| 53 |
+
user_messages = []
|
| 54 |
+
for m in messages:
|
| 55 |
+
if m["role"] == "system":
|
| 56 |
+
system_msg = m["content"]
|
| 57 |
+
else:
|
| 58 |
+
user_messages.append(m)
|
| 59 |
+
|
| 60 |
+
response = self.client.messages.create(
|
| 61 |
+
model=self.model,
|
| 62 |
+
max_tokens=4096,
|
| 63 |
+
system=system_msg,
|
| 64 |
+
messages=user_messages,
|
| 65 |
+
)
|
| 66 |
+
return response.content[0].text
|
| 67 |
+
|
| 68 |
+
def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
|
| 69 |
+
image_path = Path(image_path)
|
| 70 |
+
media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
|
| 71 |
+
image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
|
| 72 |
+
|
| 73 |
+
system_msg = ""
|
| 74 |
+
user_messages = []
|
| 75 |
+
for m in messages:
|
| 76 |
+
if m["role"] == "system":
|
| 77 |
+
system_msg = m["content"]
|
| 78 |
+
else:
|
| 79 |
+
msg = dict(m)
|
| 80 |
+
# Inject image into the last user message
|
| 81 |
+
if msg["role"] == "user" and msg is not m:
|
| 82 |
+
user_messages.append(msg)
|
| 83 |
+
else:
|
| 84 |
+
user_messages.append(msg)
|
| 85 |
+
|
| 86 |
+
# Replace last user message content with multimodal blocks
|
| 87 |
+
last_user = user_messages[-1]
|
| 88 |
+
last_user["content"] = [
|
| 89 |
+
{
|
| 90 |
+
"type": "image",
|
| 91 |
+
"source": {
|
| 92 |
+
"type": "base64",
|
| 93 |
+
"media_type": media_type,
|
| 94 |
+
"data": image_data,
|
| 95 |
+
},
|
| 96 |
+
},
|
| 97 |
+
{"type": "text", "text": last_user["content"]},
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
response = self.client.messages.create(
|
| 101 |
+
model=self.model,
|
| 102 |
+
max_tokens=4096,
|
| 103 |
+
system=system_msg,
|
| 104 |
+
messages=user_messages,
|
| 105 |
+
)
|
| 106 |
+
return response.content[0].text
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class OpenAIBackend(LLMBackend):
|
| 110 |
+
"""Generate CadQuery code using OpenAI GPT-4o."""
|
| 111 |
+
|
| 112 |
+
def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
|
| 113 |
+
import openai
|
| 114 |
+
|
| 115 |
+
self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
| 116 |
+
self.model = model
|
| 117 |
+
|
| 118 |
+
def generate(self, messages: list[dict]) -> str:
|
| 119 |
+
response = self.client.chat.completions.create(
|
| 120 |
+
model=self.model,
|
| 121 |
+
messages=messages,
|
| 122 |
+
max_tokens=4096,
|
| 123 |
+
temperature=0.2,
|
| 124 |
+
)
|
| 125 |
+
return response.choices[0].message.content
|
| 126 |
+
|
| 127 |
+
def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
|
| 128 |
+
image_path = Path(image_path)
|
| 129 |
+
media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
|
| 130 |
+
image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
|
| 131 |
+
data_url = f"data:{media_type};base64,{image_data}"
|
| 132 |
+
|
| 133 |
+
# Copy messages, replace last user message with multimodal content
|
| 134 |
+
patched = [dict(m) for m in messages]
|
| 135 |
+
last_user = patched[-1]
|
| 136 |
+
last_user["content"] = [
|
| 137 |
+
{"type": "image_url", "image_url": {"url": data_url}},
|
| 138 |
+
{"type": "text", "text": last_user["content"]},
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
response = self.client.chat.completions.create(
|
| 142 |
+
model=self.model,
|
| 143 |
+
messages=patched,
|
| 144 |
+
max_tokens=4096,
|
| 145 |
+
temperature=0.2,
|
| 146 |
+
)
|
| 147 |
+
return response.choices[0].message.content
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class GeminiBackend(LLMBackend):
|
| 151 |
+
"""Generate CadQuery code using Google Gemini (free tier available)."""
|
| 152 |
+
|
| 153 |
+
def __init__(self, model: str = "gemini-2.5-flash", api_key: Optional[str] = None):
|
| 154 |
+
from google import genai
|
| 155 |
+
|
| 156 |
+
self.client = genai.Client(api_key=api_key or os.environ.get("GEMINI_API_KEY"))
|
| 157 |
+
self.model = model
|
| 158 |
+
|
| 159 |
+
def generate(self, messages: list[dict]) -> str:
|
| 160 |
+
# Convert messages to Gemini format: system instruction + contents
|
| 161 |
+
system_msg = ""
|
| 162 |
+
contents = []
|
| 163 |
+
for m in messages:
|
| 164 |
+
if m["role"] == "system":
|
| 165 |
+
system_msg = m["content"]
|
| 166 |
+
elif m["role"] == "user":
|
| 167 |
+
contents.append({"role": "user", "parts": [{"text": m["content"]}]})
|
| 168 |
+
elif m["role"] == "assistant":
|
| 169 |
+
contents.append({"role": "model", "parts": [{"text": m["content"]}]})
|
| 170 |
+
|
| 171 |
+
from google.genai import types
|
| 172 |
+
|
| 173 |
+
response = self.client.models.generate_content(
|
| 174 |
+
model=self.model,
|
| 175 |
+
contents=contents,
|
| 176 |
+
config=types.GenerateContentConfig(
|
| 177 |
+
system_instruction=system_msg,
|
| 178 |
+
max_output_tokens=4096,
|
| 179 |
+
temperature=0.2,
|
| 180 |
+
),
|
| 181 |
+
)
|
| 182 |
+
return response.text
|
| 183 |
+
|
| 184 |
+
def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
|
| 185 |
+
from google.genai import types
|
| 186 |
+
|
| 187 |
+
image_path = Path(image_path)
|
| 188 |
+
image_data = image_path.read_bytes()
|
| 189 |
+
media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
|
| 190 |
+
|
| 191 |
+
system_msg = ""
|
| 192 |
+
contents = []
|
| 193 |
+
for m in messages:
|
| 194 |
+
if m["role"] == "system":
|
| 195 |
+
system_msg = m["content"]
|
| 196 |
+
elif m["role"] == "user":
|
| 197 |
+
contents.append({"role": "user", "parts": [{"text": m["content"]}]})
|
| 198 |
+
elif m["role"] == "assistant":
|
| 199 |
+
contents.append({"role": "model", "parts": [{"text": m["content"]}]})
|
| 200 |
+
|
| 201 |
+
# Add image to the last user message
|
| 202 |
+
if contents and contents[-1]["role"] == "user":
|
| 203 |
+
contents[-1]["parts"].insert(0, {
|
| 204 |
+
"inline_data": {"mime_type": media_type, "data": image_data}
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
response = self.client.models.generate_content(
|
| 208 |
+
model=self.model,
|
| 209 |
+
contents=contents,
|
| 210 |
+
config=types.GenerateContentConfig(
|
| 211 |
+
system_instruction=system_msg,
|
| 212 |
+
max_output_tokens=4096,
|
| 213 |
+
temperature=0.2,
|
| 214 |
+
),
|
| 215 |
+
)
|
| 216 |
+
return response.text
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class MockBackend(LLMBackend):
|
| 220 |
+
"""
|
| 221 |
+
Mock backend that dynamically generates CadQuery code from any prompt.
|
| 222 |
+
Parses dimensions, shape type, and features from the text, then assembles
|
| 223 |
+
parametric code. No API key required.
|
| 224 |
+
"""
|
| 225 |
+
|
| 226 |
+
# Word-to-number mapping for natural language counts
|
| 227 |
+
_WORD_NUMS = {
|
| 228 |
+
"one": 1,
|
| 229 |
+
"two": 2,
|
| 230 |
+
"three": 3,
|
| 231 |
+
"four": 4,
|
| 232 |
+
"five": 5,
|
| 233 |
+
"six": 6,
|
| 234 |
+
"seven": 7,
|
| 235 |
+
"eight": 8,
|
| 236 |
+
"nine": 9,
|
| 237 |
+
"ten": 10,
|
| 238 |
+
"twelve": 12,
|
| 239 |
+
"sixteen": 16,
|
| 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": [
|
| 258 |
+
"cylinder",
|
| 259 |
+
"rod",
|
| 260 |
+
"shaft",
|
| 261 |
+
"axle",
|
| 262 |
+
"spacer",
|
| 263 |
+
"washer",
|
| 264 |
+
"bushing",
|
| 265 |
+
"sleeve",
|
| 266 |
+
"tube",
|
| 267 |
+
"pipe",
|
| 268 |
+
"dowel",
|
| 269 |
+
"pin",
|
| 270 |
+
],
|
| 271 |
+
"plate": [
|
| 272 |
+
"plate",
|
| 273 |
+
"bracket",
|
| 274 |
+
"mount",
|
| 275 |
+
"flange",
|
| 276 |
+
"baseplate",
|
| 277 |
+
"panel",
|
| 278 |
+
"shim",
|
| 279 |
+
"cover",
|
| 280 |
+
"lid",
|
| 281 |
+
],
|
| 282 |
+
"box": [
|
| 283 |
+
"box",
|
| 284 |
+
"block",
|
| 285 |
+
"enclosure",
|
| 286 |
+
"housing",
|
| 287 |
+
"case",
|
| 288 |
+
"cube",
|
| 289 |
+
"container",
|
| 290 |
+
"shell",
|
| 291 |
+
],
|
| 292 |
+
"l_bracket": [
|
| 293 |
+
"l-bracket",
|
| 294 |
+
"l bracket",
|
| 295 |
+
"angle bracket",
|
| 296 |
+
"corner bracket",
|
| 297 |
+
"l-shaped",
|
| 298 |
+
],
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
# Feature detection keywords
|
| 302 |
+
_FEATURE_KEYWORDS = {
|
| 303 |
+
"holes": ["hole", "holes", "bolt", "bolts", "screw", "screws", "bore", "bores"],
|
| 304 |
+
"pocket": ["pocket", "recess", "cavity", "cutout", "mortise"],
|
| 305 |
+
"slot": ["slot", "slots", "groove", "channel", "keyway"],
|
| 306 |
+
"fillet": ["fillet", "fillets", "round", "rounded"],
|
| 307 |
+
"chamfer": ["chamfer", "chamfers", "bevel", "beveled"],
|
| 308 |
+
"through_hole": ["through hole", "through-hole", "thru hole", "thru-hole"],
|
| 309 |
+
"counterbore": ["counterbore", "counterbored", "cbore"],
|
| 310 |
+
"fins": ["fin", "fins", "cooling", "heatsink", "heat sink", "radiator"],
|
| 311 |
+
"ribs": ["rib", "ribs", "stiffener", "stiffeners", "web"],
|
| 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()
|
| 318 |
+
|
| 319 |
+
# Extract all numbers with optional units
|
| 320 |
+
raw_nums = re.findall(r"(\d+\.?\d*)\s*(?:mm|cm|m\b)?", lower)
|
| 321 |
+
dimensions = [float(n) for n in raw_nums if 0.1 < float(n) < 2000]
|
| 322 |
+
|
| 323 |
+
# Detect metric thread sizes (M3, M6, etc.)
|
| 324 |
+
thread_match = re.search(r"\bm(\d+)\b", lower)
|
| 325 |
+
hole_dia = None
|
| 326 |
+
if thread_match:
|
| 327 |
+
key = f"m{thread_match.group(1)}"
|
| 328 |
+
hole_dia = self._THREAD_CLEARANCE.get(
|
| 329 |
+
key, float(thread_match.group(1)) * 1.1
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
# Detect hole diameter from "Xmm hole"
|
| 333 |
+
hole_dim_match = re.search(
|
| 334 |
+
r"(\d+\.?\d*)\s*mm\s*(?:hole|bore|holes|bores)", lower
|
| 335 |
+
)
|
| 336 |
+
if hole_dim_match and not hole_dia:
|
| 337 |
+
hole_dia = float(hole_dim_match.group(1))
|
| 338 |
+
|
| 339 |
+
# Detect count (numeric or word)
|
| 340 |
+
count = None
|
| 341 |
+
count_match = re.search(
|
| 342 |
+
r"(\d+)\s*(?:hole|bolt|screw|bore|fin|rib|slot|boss)", lower
|
| 343 |
+
)
|
| 344 |
+
if count_match:
|
| 345 |
+
count = int(count_match.group(1))
|
| 346 |
+
else:
|
| 347 |
+
for word, num in self._WORD_NUMS.items():
|
| 348 |
+
if re.search(rf"\b{word}\b.*(?:hole|bolt|screw|bore|fin|slot)", lower):
|
| 349 |
+
count = num
|
| 350 |
+
break
|
| 351 |
+
|
| 352 |
+
# Detect base shape
|
| 353 |
+
shape = "box"
|
| 354 |
+
for shape_key, keywords in self._SHAPE_PATTERNS.items():
|
| 355 |
+
if any(kw in lower for kw in keywords):
|
| 356 |
+
shape = shape_key
|
| 357 |
+
break
|
| 358 |
+
|
| 359 |
+
# Detect features
|
| 360 |
+
features = set()
|
| 361 |
+
for feat, keywords in self._FEATURE_KEYWORDS.items():
|
| 362 |
+
if any(kw in lower for kw in keywords):
|
| 363 |
+
features.add(feat)
|
| 364 |
+
|
| 365 |
+
# If holes mentioned but no specific feature, add generic holes
|
| 366 |
+
if (
|
| 367 |
+
any(w in lower for w in ["hole", "holes", "bolt", "screw"])
|
| 368 |
+
and "holes" not in features
|
| 369 |
+
):
|
| 370 |
+
features.add("holes")
|
| 371 |
+
|
| 372 |
+
return {
|
| 373 |
+
"dimensions": dimensions,
|
| 374 |
+
"shape": shape,
|
| 375 |
+
"features": features,
|
| 376 |
+
"hole_dia": hole_dia or 5.5,
|
| 377 |
+
"count": count or 4,
|
| 378 |
+
"prompt": text,
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
def _generate_code(self, p: dict) -> str:
|
| 382 |
+
"""Build CadQuery code from parsed parameters."""
|
| 383 |
+
dims = p["dimensions"]
|
| 384 |
+
shape = p["shape"]
|
| 385 |
+
features = p["features"]
|
| 386 |
+
prompt = p["prompt"]
|
| 387 |
+
|
| 388 |
+
lines = ["import cadquery as cq"]
|
| 389 |
+
if shape == "cylinder" and "fins" in features:
|
| 390 |
+
lines.append("import math")
|
| 391 |
+
lines.append(f"")
|
| 392 |
+
lines.append(f"# Generated from: {prompt}")
|
| 393 |
+
|
| 394 |
+
if shape == "cylinder":
|
| 395 |
+
radius = dims[0] / 2 if dims else 15.0
|
| 396 |
+
height = dims[1] if len(dims) > 1 else radius * 2
|
| 397 |
+
lines.append(f"# Cylinder: radius={radius}mm, height={height}mm")
|
| 398 |
+
lines.append(f"result = (")
|
| 399 |
+
lines.append(f" cq.Workplane('XY')")
|
| 400 |
+
lines.append(f" .cylinder({height}, {radius})")
|
| 401 |
+
|
| 402 |
+
if "holes" in features or "through_hole" in features:
|
| 403 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 404 |
+
lines.append(f" .hole({p['hole_dia']})")
|
| 405 |
+
|
| 406 |
+
if "chamfer" in features or "fillet" not in features:
|
| 407 |
+
lines.append(f" .edges('>Z or <Z').chamfer(0.5)")
|
| 408 |
+
|
| 409 |
+
if "fillet" in features:
|
| 410 |
+
lines.append(f" .edges('>Z or <Z').fillet(1.0)")
|
| 411 |
+
|
| 412 |
+
lines.append(f")")
|
| 413 |
+
|
| 414 |
+
if "fins" in features:
|
| 415 |
+
n_fins = p["count"] if p["count"] > 4 else 8
|
| 416 |
+
fin_h = max(height * 0.8, 5)
|
| 417 |
+
fin_w = 1.5
|
| 418 |
+
lines.append(f"")
|
| 419 |
+
lines.append(f"# Add {n_fins} cooling fins")
|
| 420 |
+
lines.append(f"for i in range({n_fins}):")
|
| 421 |
+
lines.append(f" angle = i * 360 / {n_fins}")
|
| 422 |
+
lines.append(f" rad = math.radians(angle)")
|
| 423 |
+
lines.append(f" fx = {radius + 3} * math.cos(rad)")
|
| 424 |
+
lines.append(f" fy = {radius + 3} * math.sin(rad)")
|
| 425 |
+
lines.append(f" fin = (")
|
| 426 |
+
lines.append(f" cq.Workplane('XY')")
|
| 427 |
+
lines.append(
|
| 428 |
+
f" .transformed(offset=(fx, fy, 0), rotate=(0, 0, angle))"
|
| 429 |
+
)
|
| 430 |
+
lines.append(f" .rect({fin_w}, {radius * 0.6})")
|
| 431 |
+
lines.append(f" .extrude({fin_h})")
|
| 432 |
+
lines.append(f" )")
|
| 433 |
+
lines.append(f" result = result.union(fin)")
|
| 434 |
+
|
| 435 |
+
elif shape == "plate":
|
| 436 |
+
w = dims[0] if dims else 80.0
|
| 437 |
+
h = dims[1] if len(dims) > 1 else w * 0.6
|
| 438 |
+
t = dims[2] if len(dims) > 2 else 5.0
|
| 439 |
+
lines.append(f"# Plate: {w}x{h}x{t}mm")
|
| 440 |
+
lines.append(f"result = (")
|
| 441 |
+
lines.append(f" cq.Workplane('XY')")
|
| 442 |
+
lines.append(f" .box({w}, {h}, {t})")
|
| 443 |
+
|
| 444 |
+
if "holes" in features or "through_hole" in features:
|
| 445 |
+
n = p["count"]
|
| 446 |
+
dia = p["hole_dia"]
|
| 447 |
+
# Distribute holes in a grid or circle
|
| 448 |
+
if "flange" in p["prompt"].lower() or n >= 6:
|
| 449 |
+
# Bolt circle pattern
|
| 450 |
+
r = min(w, h) * 0.35
|
| 451 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 452 |
+
lines.append(f" .polarArray({r}, 0, 360, {n})")
|
| 453 |
+
lines.append(f" .hole({dia})")
|
| 454 |
+
if "bore" in p["prompt"].lower() or "flange" in p["prompt"].lower():
|
| 455 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 456 |
+
lines.append(f" .hole({dia * 3}) # Center bore")
|
| 457 |
+
else:
|
| 458 |
+
# Rectangular pattern
|
| 459 |
+
ox = w * 0.35
|
| 460 |
+
oy = h * 0.35
|
| 461 |
+
pts = []
|
| 462 |
+
if n == 1:
|
| 463 |
+
pts = [(0, 0)]
|
| 464 |
+
elif n == 2:
|
| 465 |
+
pts = [(-ox, 0), (ox, 0)]
|
| 466 |
+
elif n == 4:
|
| 467 |
+
pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
|
| 468 |
+
else:
|
| 469 |
+
pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
|
| 470 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 471 |
+
lines.append(f" .pushPoints({pts})")
|
| 472 |
+
lines.append(f" .hole({dia})")
|
| 473 |
+
|
| 474 |
+
if "pocket" in features:
|
| 475 |
+
pw = w * 0.4
|
| 476 |
+
ph = h * 0.35
|
| 477 |
+
pd = t * 0.6
|
| 478 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 479 |
+
lines.append(f" .rect({pw}, {ph})")
|
| 480 |
+
lines.append(f" .cutBlind(-{pd}) # Central pocket")
|
| 481 |
+
|
| 482 |
+
if "slot" in features:
|
| 483 |
+
sl = w * 0.35
|
| 484 |
+
sw = max(t * 0.8, 4)
|
| 485 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 486 |
+
lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{t})")
|
| 487 |
+
|
| 488 |
+
if "fillet" in features:
|
| 489 |
+
lines.append(f" .edges('|Z').fillet({max(t * 0.4, 1.5)})")
|
| 490 |
+
else:
|
| 491 |
+
lines.append(f" .edges('>Z').chamfer(0.5)")
|
| 492 |
+
|
| 493 |
+
lines.append(f")")
|
| 494 |
+
|
| 495 |
+
elif shape == "l_bracket":
|
| 496 |
+
arm = dims[0] if dims else 50.0
|
| 497 |
+
width = dims[1] if len(dims) > 1 else 20.0
|
| 498 |
+
t = dims[2] if len(dims) > 2 else 4.0
|
| 499 |
+
lines.append(f"# L-bracket: {arm}mm arms, {width}mm wide, {t}mm thick")
|
| 500 |
+
lines.append(f"result = (")
|
| 501 |
+
lines.append(f" cq.Workplane('XZ')")
|
| 502 |
+
lines.append(f" .moveTo(0, 0)")
|
| 503 |
+
lines.append(f" .lineTo({arm}, 0)")
|
| 504 |
+
lines.append(f" .lineTo({arm}, {t})")
|
| 505 |
+
lines.append(f" .lineTo({t}, {t})")
|
| 506 |
+
lines.append(f" .lineTo({t}, {arm})")
|
| 507 |
+
lines.append(f" .lineTo(0, {arm})")
|
| 508 |
+
lines.append(f" .close()")
|
| 509 |
+
lines.append(f" .extrude({width})")
|
| 510 |
+
lines.append(f" .edges('|Y').fillet({max(t * 0.5, 1.5)})")
|
| 511 |
+
|
| 512 |
+
if "holes" in features:
|
| 513 |
+
lines.append(
|
| 514 |
+
f" .faces('>Z').workplane(centerOption='CenterOfBoundBox')"
|
| 515 |
+
)
|
| 516 |
+
lines.append(f" .center({arm * 0.5}, 0)")
|
| 517 |
+
lines.append(f" .hole({p['hole_dia']})")
|
| 518 |
+
lines.append(
|
| 519 |
+
f" .faces('>X').workplane(centerOption='CenterOfBoundBox')"
|
| 520 |
+
)
|
| 521 |
+
lines.append(f" .center(0, {arm * 0.5})")
|
| 522 |
+
lines.append(f" .hole({p['hole_dia']})")
|
| 523 |
+
|
| 524 |
+
lines.append(f" .edges().chamfer(0.5)")
|
| 525 |
+
lines.append(f")")
|
| 526 |
+
|
| 527 |
+
else: # box / enclosure / housing
|
| 528 |
+
w = dims[0] if dims else 60.0
|
| 529 |
+
h = dims[1] if len(dims) > 1 else w * 0.65
|
| 530 |
+
d = dims[2] if len(dims) > 2 else 20.0
|
| 531 |
+
lines.append(f"# Box: {w}x{h}x{d}mm")
|
| 532 |
+
lines.append(f"result = (")
|
| 533 |
+
lines.append(f" cq.Workplane('XY')")
|
| 534 |
+
lines.append(f" .box({w}, {h}, {d})")
|
| 535 |
+
|
| 536 |
+
if "holes" in features or "through_hole" in features:
|
| 537 |
+
ox = w * 0.35
|
| 538 |
+
oy = h * 0.35
|
| 539 |
+
pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
|
| 540 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 541 |
+
lines.append(f" .pushPoints({pts})")
|
| 542 |
+
lines.append(f" .hole({p['hole_dia']})")
|
| 543 |
+
|
| 544 |
+
if "pocket" in features:
|
| 545 |
+
pw = w * 0.5
|
| 546 |
+
ph = h * 0.4
|
| 547 |
+
pd = d * 0.4
|
| 548 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 549 |
+
lines.append(f" .rect({pw}, {ph})")
|
| 550 |
+
lines.append(f" .cutBlind(-{pd})")
|
| 551 |
+
|
| 552 |
+
if "slot" in features:
|
| 553 |
+
sl = w * 0.4
|
| 554 |
+
sw = 6
|
| 555 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 556 |
+
lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{d})")
|
| 557 |
+
|
| 558 |
+
if "boss" in features:
|
| 559 |
+
n = min(p["count"], 4)
|
| 560 |
+
bx = w * 0.3
|
| 561 |
+
by = h * 0.3
|
| 562 |
+
boss_pts = [(-bx, -by), (-bx, by), (bx, -by), (bx, by)][:n]
|
| 563 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 564 |
+
lines.append(f" .pushPoints({boss_pts})")
|
| 565 |
+
lines.append(f" .circle(4).extrude(6) # Mounting bosses")
|
| 566 |
+
|
| 567 |
+
if "ribs" in features:
|
| 568 |
+
n_ribs = p["count"] if p["count"] <= 8 else 4
|
| 569 |
+
spacing = w / (n_ribs + 1)
|
| 570 |
+
lines.append(f" .faces('>Z').workplane()")
|
| 571 |
+
for i in range(n_ribs):
|
| 572 |
+
rx = -w / 2 + spacing * (i + 1)
|
| 573 |
+
lines.append(f" .center({rx if i == 0 else spacing}, 0)")
|
| 574 |
+
lines.append(f" .rect(2, {h * 0.8}).extrude({d * 0.3})")
|
| 575 |
+
|
| 576 |
+
if "fillet" in features:
|
| 577 |
+
lines.append(f" .edges('|Z').fillet({min(d * 0.2, 3)})")
|
| 578 |
+
elif "chamfer" in features:
|
| 579 |
+
lines.append(f" .edges('>Z').chamfer(1.0)")
|
| 580 |
+
else:
|
| 581 |
+
lines.append(f" .edges('>Z').chamfer(0.5)")
|
| 582 |
+
|
| 583 |
+
lines.append(f")")
|
| 584 |
+
|
| 585 |
+
return "\n".join(lines) + "\n"
|
| 586 |
+
|
| 587 |
+
# Curated hero responses for specific prompts
|
| 588 |
+
_CURATED = {
|
| 589 |
+
"gear": """\
|
| 590 |
+
import cadquery as cq
|
| 591 |
+
import math
|
| 592 |
+
|
| 593 |
+
# Simple spur gear approximation: 20 teeth, module 2, 10mm thick
|
| 594 |
+
module = 2
|
| 595 |
+
teeth = 20
|
| 596 |
+
pitch_radius = module * teeth / 2
|
| 597 |
+
outer_radius = pitch_radius + module
|
| 598 |
+
tooth_angle = 360 / teeth
|
| 599 |
+
|
| 600 |
+
result = (
|
| 601 |
+
cq.Workplane("XY")
|
| 602 |
+
.cylinder(10, outer_radius)
|
| 603 |
+
.faces(">Z").workplane()
|
| 604 |
+
.hole(12)
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
for i in range(teeth):
|
| 608 |
+
angle = i * tooth_angle
|
| 609 |
+
rad = math.radians(angle)
|
| 610 |
+
gap_x = pitch_radius * math.cos(rad)
|
| 611 |
+
gap_y = pitch_radius * math.sin(rad)
|
| 612 |
+
cutter = (
|
| 613 |
+
cq.Workplane("XY")
|
| 614 |
+
.transformed(offset=(gap_x, gap_y, 0), rotate=(0, 0, angle))
|
| 615 |
+
.rect(module * 0.8, module * 2.5)
|
| 616 |
+
.extrude(12)
|
| 617 |
+
)
|
| 618 |
+
result = result.cut(cutter)
|
| 619 |
+
|
| 620 |
+
result = result.edges(">Z or <Z").chamfer(0.3)
|
| 621 |
+
""",
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
def generate(self, messages: list[dict]) -> str:
|
| 625 |
+
user_msg = messages[-1]["content"]
|
| 626 |
+
lower = user_msg.lower()
|
| 627 |
+
|
| 628 |
+
# Check curated responses first
|
| 629 |
+
for key, code in self._CURATED.items():
|
| 630 |
+
if key in lower:
|
| 631 |
+
return code
|
| 632 |
+
|
| 633 |
+
# Dynamic generation for everything else
|
| 634 |
+
params = self._parse_prompt(user_msg)
|
| 635 |
+
return self._generate_code(params)
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
class NeuralCADBackend(LLMBackend):
|
| 639 |
+
"""
|
| 640 |
+
Neural CAD pipeline backend.
|
| 641 |
+
|
| 642 |
+
Runs trained models locally:
|
| 643 |
+
Text/Image → CLIP encoder → contrastive latent
|
| 644 |
+
→ Diffusion prior → latent
|
| 645 |
+
→ Transformer decoder → CAD command sequence
|
| 646 |
+
→ OpenCascade kernel → B-rep solid
|
| 647 |
+
|
| 648 |
+
Unlike LLM backends, this does not generate CadQuery code strings.
|
| 649 |
+
Instead it produces CAD command sequences decoded directly into geometry.
|
| 650 |
+
"""
|
| 651 |
+
|
| 652 |
+
def __init__(
|
| 653 |
+
self,
|
| 654 |
+
model_dir: str | Path = "./models",
|
| 655 |
+
device: str = "cuda",
|
| 656 |
+
clip_model: str = "clip_encoder.pt",
|
| 657 |
+
prior_model: str = "diffusion_prior.pt",
|
| 658 |
+
decoder_model: str = "transformer_decoder.pt",
|
| 659 |
+
):
|
| 660 |
+
self.model_dir = Path(model_dir)
|
| 661 |
+
self.device = device
|
| 662 |
+
self.clip_encoder = None
|
| 663 |
+
self.diffusion_prior = None
|
| 664 |
+
self.transformer_decoder = None
|
| 665 |
+
self._model_config = {
|
| 666 |
+
"clip": clip_model,
|
| 667 |
+
"prior": prior_model,
|
| 668 |
+
"decoder": decoder_model,
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
def load_models(self):
|
| 672 |
+
"""Load all model weights from disk. Call once before inference."""
|
| 673 |
+
raise NotImplementedError(
|
| 674 |
+
f"Model loading not yet implemented. "
|
| 675 |
+
f"Expected model files in: {self.model_dir}"
|
| 676 |
+
)
|
| 677 |
+
|
| 678 |
+
def encode_text(self, text: str):
|
| 679 |
+
"""Encode text prompt to CLIP latent vector."""
|
| 680 |
+
raise NotImplementedError("CLIP text encoder not yet implemented")
|
| 681 |
+
|
| 682 |
+
def encode_image(self, image_path: str | Path):
|
| 683 |
+
"""Encode image (photo/sketch) to CLIP latent vector."""
|
| 684 |
+
raise NotImplementedError("CLIP image encoder not yet implemented")
|
| 685 |
+
|
| 686 |
+
def run_diffusion_prior(self, clip_embedding):
|
| 687 |
+
"""Map CLIP embedding to CAD latent via diffusion prior."""
|
| 688 |
+
raise NotImplementedError("Diffusion prior not yet implemented")
|
| 689 |
+
|
| 690 |
+
def decode_to_cad_sequence(self, latent):
|
| 691 |
+
"""Decode latent to CAD command sequence."""
|
| 692 |
+
raise NotImplementedError("Transformer decoder not yet implemented")
|
| 693 |
+
|
| 694 |
+
def cad_sequence_to_solid(self, cad_commands: list[dict]):
|
| 695 |
+
"""Execute CAD command sequence through OpenCascade kernel → B-rep solid."""
|
| 696 |
+
raise NotImplementedError("CAD kernel execution not yet implemented")
|
| 697 |
+
|
| 698 |
+
def generate(self, messages: list[dict]) -> str:
|
| 699 |
+
"""
|
| 700 |
+
LLMBackend-compatible interface.
|
| 701 |
+
|
| 702 |
+
Extracts the text prompt from messages, runs the full neural pipeline,
|
| 703 |
+
and returns CadQuery-equivalent code as a string for compatibility
|
| 704 |
+
with the existing execution/validation/export pipeline.
|
| 705 |
+
"""
|
| 706 |
+
user_msg = messages[-1]["content"]
|
| 707 |
+
|
| 708 |
+
clip_emb = self.encode_text(user_msg)
|
| 709 |
+
latent = self.run_diffusion_prior(clip_emb)
|
| 710 |
+
cad_commands = self.decode_to_cad_sequence(latent)
|
| 711 |
+
return self._cad_commands_to_code(cad_commands)
|
| 712 |
+
|
| 713 |
+
def generate_from_image(self, image_path: str | Path, text_hint: str = "") -> str:
|
| 714 |
+
"""
|
| 715 |
+
Image-conditioned generation (not available on LLM backends).
|
| 716 |
+
|
| 717 |
+
Args:
|
| 718 |
+
image_path: Path to photo or sketch of the desired part.
|
| 719 |
+
text_hint: Optional text to guide generation alongside the image.
|
| 720 |
+
|
| 721 |
+
Returns:
|
| 722 |
+
CadQuery code string for pipeline compatibility.
|
| 723 |
+
"""
|
| 724 |
+
img_emb = self.encode_image(image_path)
|
| 725 |
+
if text_hint:
|
| 726 |
+
txt_emb = self.encode_text(text_hint)
|
| 727 |
+
# Fuse text + image embeddings (strategy TBD — average, concat, cross-attn)
|
| 728 |
+
clip_emb = (img_emb + txt_emb) / 2 # placeholder fusion
|
| 729 |
+
else:
|
| 730 |
+
clip_emb = img_emb
|
| 731 |
+
|
| 732 |
+
latent = self.run_diffusion_prior(clip_emb)
|
| 733 |
+
cad_commands = self.decode_to_cad_sequence(latent)
|
| 734 |
+
return self._cad_commands_to_code(cad_commands)
|
| 735 |
+
|
| 736 |
+
def _cad_commands_to_code(self, cad_commands: list[dict]) -> str:
|
| 737 |
+
"""Convert internal CAD command sequence to CadQuery Python code string."""
|
| 738 |
+
raise NotImplementedError(
|
| 739 |
+
"CAD command → CadQuery code serializer not yet implemented"
|
| 740 |
+
)
|
cadquery_system_prompt.py → core/cadquery_prompts.py
RENAMED
|
File without changes
|
code_executor.py → core/executor.py
RENAMED
|
@@ -7,7 +7,7 @@ validates the result, and exports to STEP/STL.
|
|
| 7 |
import io
|
| 8 |
import sys
|
| 9 |
import traceback
|
| 10 |
-
from dataclasses import dataclass
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Optional
|
| 13 |
|
|
@@ -60,7 +60,6 @@ SAFE_NAMESPACE = {
|
|
| 60 |
"print": print,
|
| 61 |
"enumerate": enumerate,
|
| 62 |
"zip": zip,
|
| 63 |
-
"__import__": __import__,
|
| 64 |
},
|
| 65 |
}
|
| 66 |
|
|
|
|
| 7 |
import io
|
| 8 |
import sys
|
| 9 |
import traceback
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Optional
|
| 13 |
|
|
|
|
| 60 |
"print": print,
|
| 61 |
"enumerate": enumerate,
|
| 62 |
"zip": zip,
|
|
|
|
| 63 |
},
|
| 64 |
}
|
| 65 |
|
pipeline.py → core/pipeline.py
RENAMED
|
@@ -7,176 +7,24 @@ Pipeline stages:
|
|
| 7 |
3. 3D Solid → CNC Validation
|
| 8 |
4. 3D Solid → STEP / STL export
|
| 9 |
5. (Optional) Auto-retry with error feedback if execution fails
|
| 10 |
-
|
| 11 |
-
Supports multiple LLM backends:
|
| 12 |
-
- Anthropic Claude (default)
|
| 13 |
-
- OpenAI GPT-4o
|
| 14 |
-
- Local / mock (for testing without API keys)
|
| 15 |
"""
|
| 16 |
|
| 17 |
-
import json
|
| 18 |
-
import os
|
| 19 |
from dataclasses import dataclass
|
| 20 |
from pathlib import Path
|
| 21 |
from typing import Optional
|
| 22 |
|
| 23 |
-
from
|
| 24 |
-
from
|
| 25 |
-
from
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class LLMBackend:
|
| 31 |
-
"""Base class for LLM code generation backends."""
|
| 32 |
-
|
| 33 |
-
def generate(self, messages: list[dict]) -> str:
|
| 34 |
-
raise NotImplementedError
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class AnthropicBackend(LLMBackend):
|
| 38 |
-
"""Generate CadQuery code using Anthropic Claude."""
|
| 39 |
-
|
| 40 |
-
def __init__(self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None):
|
| 41 |
-
import anthropic
|
| 42 |
-
self.client = anthropic.Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"))
|
| 43 |
-
self.model = model
|
| 44 |
-
|
| 45 |
-
def generate(self, messages: list[dict]) -> str:
|
| 46 |
-
# Anthropic uses system param separately
|
| 47 |
-
system_msg = ""
|
| 48 |
-
user_messages = []
|
| 49 |
-
for m in messages:
|
| 50 |
-
if m["role"] == "system":
|
| 51 |
-
system_msg = m["content"]
|
| 52 |
-
else:
|
| 53 |
-
user_messages.append(m)
|
| 54 |
-
|
| 55 |
-
response = self.client.messages.create(
|
| 56 |
-
model=self.model,
|
| 57 |
-
max_tokens=4096,
|
| 58 |
-
system=system_msg,
|
| 59 |
-
messages=user_messages,
|
| 60 |
-
)
|
| 61 |
-
return response.content[0].text
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
class OpenAIBackend(LLMBackend):
|
| 65 |
-
"""Generate CadQuery code using OpenAI GPT-4o."""
|
| 66 |
-
|
| 67 |
-
def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
|
| 68 |
-
import openai
|
| 69 |
-
self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
|
| 70 |
-
self.model = model
|
| 71 |
-
|
| 72 |
-
def generate(self, messages: list[dict]) -> str:
|
| 73 |
-
response = self.client.chat.completions.create(
|
| 74 |
-
model=self.model,
|
| 75 |
-
messages=messages,
|
| 76 |
-
max_tokens=4096,
|
| 77 |
-
temperature=0.2,
|
| 78 |
-
)
|
| 79 |
-
return response.choices[0].message.content
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
class MockBackend(LLMBackend):
|
| 83 |
-
"""
|
| 84 |
-
Mock backend for testing without API keys.
|
| 85 |
-
Returns pre-written CadQuery code for common prompts,
|
| 86 |
-
or a parametric box as fallback.
|
| 87 |
-
"""
|
| 88 |
-
|
| 89 |
-
MOCK_RESPONSES = {
|
| 90 |
-
"bracket": """\
|
| 91 |
-
import cadquery as cq
|
| 92 |
-
|
| 93 |
-
# Mounting bracket: 80x50x5mm plate with four M6 holes and central slot
|
| 94 |
-
result = (
|
| 95 |
-
cq.Workplane("XY")
|
| 96 |
-
.box(80, 50, 5)
|
| 97 |
-
.faces(">Z").workplane()
|
| 98 |
-
.pushPoints([(-30, -15), (-30, 15), (30, -15), (30, 15)])
|
| 99 |
-
.hole(6.5) # M6 clearance
|
| 100 |
-
.faces(">Z").workplane()
|
| 101 |
-
.slot2D(30, 8).cutBlind(-5) # Central slot through the plate
|
| 102 |
-
.edges("|Z").fillet(3)
|
| 103 |
-
)
|
| 104 |
-
""",
|
| 105 |
-
"gear": """\
|
| 106 |
-
import cadquery as cq
|
| 107 |
-
import math
|
| 108 |
-
|
| 109 |
-
# Simple spur gear approximation: 20 teeth, module 2, 10mm thick
|
| 110 |
-
module = 2
|
| 111 |
-
teeth = 20
|
| 112 |
-
pitch_radius = module * teeth / 2
|
| 113 |
-
outer_radius = pitch_radius + module
|
| 114 |
-
root_radius = pitch_radius - 1.25 * module
|
| 115 |
-
tooth_angle = 360 / teeth
|
| 116 |
-
|
| 117 |
-
# Start with outer cylinder, cut tooth gaps
|
| 118 |
-
result = (
|
| 119 |
-
cq.Workplane("XY")
|
| 120 |
-
.cylinder(10, outer_radius)
|
| 121 |
-
.faces(">Z").workplane()
|
| 122 |
-
.hole(12) # Bore hole
|
| 123 |
)
|
| 124 |
|
| 125 |
-
# Cut tooth gaps as rectangular slots (simplified)
|
| 126 |
-
for i in range(teeth):
|
| 127 |
-
angle = i * tooth_angle
|
| 128 |
-
rad = math.radians(angle)
|
| 129 |
-
gap_x = pitch_radius * math.cos(rad)
|
| 130 |
-
gap_y = pitch_radius * math.sin(rad)
|
| 131 |
-
|
| 132 |
-
cutter = (
|
| 133 |
-
cq.Workplane("XY")
|
| 134 |
-
.transformed(offset=(gap_x, gap_y, 0), rotate=(0, 0, angle))
|
| 135 |
-
.rect(module * 0.8, module * 2.5)
|
| 136 |
-
.extrude(12)
|
| 137 |
-
)
|
| 138 |
-
result = result.cut(cutter)
|
| 139 |
-
|
| 140 |
-
# Chamfer top/bottom edges
|
| 141 |
-
result = result.edges(">Z or <Z").chamfer(0.3)
|
| 142 |
-
""",
|
| 143 |
-
"default": """\
|
| 144 |
-
import cadquery as cq
|
| 145 |
-
|
| 146 |
-
# Parametric box with holes and fillets — default demo part
|
| 147 |
-
# 60x40x20mm block with 4 corner holes and a central pocket
|
| 148 |
-
|
| 149 |
-
base = (
|
| 150 |
-
cq.Workplane("XY")
|
| 151 |
-
.box(60, 40, 20)
|
| 152 |
-
# Four M5 corner holes
|
| 153 |
-
.faces(">Z").workplane()
|
| 154 |
-
.pushPoints([(22, 12), (22, -12), (-22, 12), (-22, -12)])
|
| 155 |
-
.hole(5.5)
|
| 156 |
-
# Central rectangular pocket, 8mm deep
|
| 157 |
-
.faces(">Z").workplane()
|
| 158 |
-
.rect(25, 15)
|
| 159 |
-
.cutBlind(-8)
|
| 160 |
-
)
|
| 161 |
-
|
| 162 |
-
# Chamfer top external edges
|
| 163 |
-
result = base.edges(">Z").chamfer(0.5)
|
| 164 |
-
""",
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
def generate(self, messages: list[dict]) -> str:
|
| 168 |
-
# Extract user prompt (last message)
|
| 169 |
-
user_msg = messages[-1]["content"].lower()
|
| 170 |
-
|
| 171 |
-
for key, code in self.MOCK_RESPONSES.items():
|
| 172 |
-
if key in user_msg:
|
| 173 |
-
return code
|
| 174 |
-
|
| 175 |
-
return self.MOCK_RESPONSES["default"]
|
| 176 |
-
|
| 177 |
|
| 178 |
# ── Pipeline ──────────────────────────────────────────────────────────────
|
| 179 |
|
|
|
|
| 180 |
@dataclass
|
| 181 |
class PipelineResult:
|
| 182 |
prompt: str
|
|
@@ -189,7 +37,7 @@ class PipelineResult:
|
|
| 189 |
def summary(self) -> str:
|
| 190 |
lines = [
|
| 191 |
"=" * 60,
|
| 192 |
-
|
| 193 |
"=" * 60,
|
| 194 |
f"Prompt: {self.prompt}",
|
| 195 |
f"Retries: {self.retry_count}",
|
|
@@ -297,7 +145,9 @@ if __name__ == "__main__":
|
|
| 297 |
|
| 298 |
parser = argparse.ArgumentParser(description="Text-to-CNC Model Generator")
|
| 299 |
parser.add_argument("prompt", nargs="?", default=None, help="Part description")
|
| 300 |
-
parser.add_argument(
|
|
|
|
|
|
|
| 301 |
parser.add_argument("--output-dir", default="./output")
|
| 302 |
parser.add_argument("--retries", type=int, default=2)
|
| 303 |
parser.add_argument("--name", default=None, help="Part name for file output")
|
|
@@ -309,10 +159,14 @@ if __name__ == "__main__":
|
|
| 309 |
args.prompt = "A simple mounting bracket with two M5 bolt holes"
|
| 310 |
|
| 311 |
# Select backend
|
| 312 |
-
if args.backend == "
|
|
|
|
|
|
|
| 313 |
backend = AnthropicBackend()
|
| 314 |
elif args.backend == "openai":
|
| 315 |
backend = OpenAIBackend()
|
|
|
|
|
|
|
| 316 |
else:
|
| 317 |
backend = MockBackend()
|
| 318 |
|
|
|
|
| 7 |
3. 3D Solid → CNC Validation
|
| 8 |
4. 3D Solid → STEP / STL export
|
| 9 |
5. (Optional) Auto-retry with error feedback if execution fails
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
|
|
|
|
|
|
| 12 |
from dataclasses import dataclass
|
| 13 |
from pathlib import Path
|
| 14 |
from typing import Optional
|
| 15 |
|
| 16 |
+
from core.cadquery_prompts import build_messages
|
| 17 |
+
from core.executor import ExecutionResult, execute_cadquery, export_all
|
| 18 |
+
from core.validator import validate_for_cnc, CNCValidationResult
|
| 19 |
+
from core.backends import (
|
| 20 |
+
LLMBackend, MockBackend, AnthropicBackend, OpenAIBackend,
|
| 21 |
+
GeminiBackend, NeuralCADBackend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# ── Pipeline ──────────────────────────────────────────────────────────────
|
| 26 |
|
| 27 |
+
|
| 28 |
@dataclass
|
| 29 |
class PipelineResult:
|
| 30 |
prompt: str
|
|
|
|
| 37 |
def summary(self) -> str:
|
| 38 |
lines = [
|
| 39 |
"=" * 60,
|
| 40 |
+
"TEXT-TO-CNC PIPELINE RESULT",
|
| 41 |
"=" * 60,
|
| 42 |
f"Prompt: {self.prompt}",
|
| 43 |
f"Retries: {self.retry_count}",
|
|
|
|
| 145 |
|
| 146 |
parser = argparse.ArgumentParser(description="Text-to-CNC Model Generator")
|
| 147 |
parser.add_argument("prompt", nargs="?", default=None, help="Part description")
|
| 148 |
+
parser.add_argument(
|
| 149 |
+
"--backend", choices=["mock", "anthropic", "openai", "gemini", "neural"], default="mock"
|
| 150 |
+
)
|
| 151 |
parser.add_argument("--output-dir", default="./output")
|
| 152 |
parser.add_argument("--retries", type=int, default=2)
|
| 153 |
parser.add_argument("--name", default=None, help="Part name for file output")
|
|
|
|
| 159 |
args.prompt = "A simple mounting bracket with two M5 bolt holes"
|
| 160 |
|
| 161 |
# Select backend
|
| 162 |
+
if args.backend == "neural":
|
| 163 |
+
backend = NeuralCADBackend()
|
| 164 |
+
elif args.backend == "anthropic":
|
| 165 |
backend = AnthropicBackend()
|
| 166 |
elif args.backend == "openai":
|
| 167 |
backend = OpenAIBackend()
|
| 168 |
+
elif args.backend == "gemini":
|
| 169 |
+
backend = GeminiBackend()
|
| 170 |
else:
|
| 171 |
backend = MockBackend()
|
| 172 |
|
cnc_validator.py → core/validator.py
RENAMED
|
@@ -12,7 +12,6 @@ from dataclasses import dataclass, field
|
|
| 12 |
from typing import Optional
|
| 13 |
|
| 14 |
import cadquery as cq
|
| 15 |
-
import math
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass
|
|
@@ -54,9 +53,9 @@ class CNCValidationResult:
|
|
| 54 |
|
| 55 |
DEFAULT_CONFIG = {
|
| 56 |
"min_wall_thickness_mm": 1.5,
|
| 57 |
-
"min_fillet_radius_mm": 1.0,
|
| 58 |
"max_pocket_depth_ratio": 4.0, # depth / width ratio
|
| 59 |
-
"max_part_size_mm": 500.0,
|
| 60 |
"min_part_size_mm": 1.0,
|
| 61 |
"min_hole_diameter_mm": 1.0,
|
| 62 |
}
|
|
@@ -82,17 +81,23 @@ def validate_for_cnc(
|
|
| 82 |
min_dim = dims[0]
|
| 83 |
|
| 84 |
if max_dim > cfg["max_part_size_mm"]:
|
| 85 |
-
result.issues.append(
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
result.machinable = False
|
| 90 |
|
| 91 |
if min_dim < cfg["min_part_size_mm"]:
|
| 92 |
-
result.issues.append(
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
# --- 2. Volume sanity check ---
|
| 98 |
volume = shape.Volume()
|
|
@@ -100,14 +105,20 @@ def validate_for_cnc(
|
|
| 100 |
if bb_volume > 0:
|
| 101 |
fill_ratio = volume / bb_volume
|
| 102 |
if fill_ratio < 0.05:
|
| 103 |
-
result.issues.append(
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# --- 3. Face and edge complexity ---
|
| 113 |
faces = workplane.faces().vals()
|
|
@@ -117,16 +128,22 @@ def validate_for_cnc(
|
|
| 117 |
n_edges = len(edges)
|
| 118 |
|
| 119 |
if n_faces > 100:
|
| 120 |
-
result.issues.append(
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
result.axis_recommendation = "5-axis"
|
| 125 |
elif n_faces > 50:
|
| 126 |
-
result.issues.append(
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
result.axis_recommendation = "3+2 axis"
|
| 131 |
|
| 132 |
# --- 4. Edge length analysis (thin feature proxy) ---
|
|
@@ -140,22 +157,28 @@ def validate_for_cnc(
|
|
| 140 |
if edge_lengths:
|
| 141 |
min_edge = min(edge_lengths)
|
| 142 |
if min_edge < cfg["min_wall_thickness_mm"]:
|
| 143 |
-
result.issues.append(
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
# --- 5. Aspect ratio check (deep pocket heuristic) ---
|
| 150 |
# Only flag if the narrowest dimension is small enough to be a pocket/slot
|
| 151 |
if dims[0] > 0 and dims[0] < 20:
|
| 152 |
aspect = dims[2] / dims[0] # tallest / narrowest
|
| 153 |
if aspect > cfg["max_pocket_depth_ratio"]:
|
| 154 |
-
result.issues.append(
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
# --- 6. Surface type analysis ---
|
| 161 |
has_freeform = False
|
|
@@ -175,18 +198,24 @@ def validate_for_cnc(
|
|
| 175 |
pass
|
| 176 |
|
| 177 |
if has_freeform:
|
| 178 |
-
result.issues.append(
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
if result.axis_recommendation == "3-axis":
|
| 183 |
result.axis_recommendation = "3-axis (with 3D finishing)"
|
| 184 |
|
| 185 |
-
result.issues.append(
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
# --- 7. Set final machinable flag ---
|
| 192 |
if result.error_count > 0:
|
|
|
|
| 12 |
from typing import Optional
|
| 13 |
|
| 14 |
import cadquery as cq
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
@dataclass
|
|
|
|
| 53 |
|
| 54 |
DEFAULT_CONFIG = {
|
| 55 |
"min_wall_thickness_mm": 1.5,
|
| 56 |
+
"min_fillet_radius_mm": 1.0, # Typical smallest endmill radius
|
| 57 |
"max_pocket_depth_ratio": 4.0, # depth / width ratio
|
| 58 |
+
"max_part_size_mm": 500.0, # Typical CNC work envelope
|
| 59 |
"min_part_size_mm": 1.0,
|
| 60 |
"min_hole_diameter_mm": 1.0,
|
| 61 |
}
|
|
|
|
| 81 |
min_dim = dims[0]
|
| 82 |
|
| 83 |
if max_dim > cfg["max_part_size_mm"]:
|
| 84 |
+
result.issues.append(
|
| 85 |
+
CNCIssue(
|
| 86 |
+
"error",
|
| 87 |
+
"Size",
|
| 88 |
+
f"Part too large: {max_dim:.1f}mm exceeds {cfg['max_part_size_mm']}mm work envelope",
|
| 89 |
+
)
|
| 90 |
+
)
|
| 91 |
result.machinable = False
|
| 92 |
|
| 93 |
if min_dim < cfg["min_part_size_mm"]:
|
| 94 |
+
result.issues.append(
|
| 95 |
+
CNCIssue(
|
| 96 |
+
"warning",
|
| 97 |
+
"Size",
|
| 98 |
+
f"Very small dimension: {min_dim:.2f}mm — may be difficult to fixture",
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
|
| 102 |
# --- 2. Volume sanity check ---
|
| 103 |
volume = shape.Volume()
|
|
|
|
| 105 |
if bb_volume > 0:
|
| 106 |
fill_ratio = volume / bb_volume
|
| 107 |
if fill_ratio < 0.05:
|
| 108 |
+
result.issues.append(
|
| 109 |
+
CNCIssue(
|
| 110 |
+
"warning",
|
| 111 |
+
"Geometry",
|
| 112 |
+
f"Very low fill ratio ({fill_ratio:.1%}) — complex geometry, high machining time",
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
result.issues.append(
|
| 116 |
+
CNCIssue(
|
| 117 |
+
"info",
|
| 118 |
+
"Geometry",
|
| 119 |
+
f"Fill ratio: {fill_ratio:.1%} (volume/bounding box)",
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
|
| 123 |
# --- 3. Face and edge complexity ---
|
| 124 |
faces = workplane.faces().vals()
|
|
|
|
| 128 |
n_edges = len(edges)
|
| 129 |
|
| 130 |
if n_faces > 100:
|
| 131 |
+
result.issues.append(
|
| 132 |
+
CNCIssue(
|
| 133 |
+
"warning",
|
| 134 |
+
"Complexity",
|
| 135 |
+
f"{n_faces} faces detected — may require multi-setup or 5-axis",
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
result.axis_recommendation = "5-axis"
|
| 139 |
elif n_faces > 50:
|
| 140 |
+
result.issues.append(
|
| 141 |
+
CNCIssue(
|
| 142 |
+
"info",
|
| 143 |
+
"Complexity",
|
| 144 |
+
f"{n_faces} faces — consider 4-axis or indexed 5-axis",
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
result.axis_recommendation = "3+2 axis"
|
| 148 |
|
| 149 |
# --- 4. Edge length analysis (thin feature proxy) ---
|
|
|
|
| 157 |
if edge_lengths:
|
| 158 |
min_edge = min(edge_lengths)
|
| 159 |
if min_edge < cfg["min_wall_thickness_mm"]:
|
| 160 |
+
result.issues.append(
|
| 161 |
+
CNCIssue(
|
| 162 |
+
"warning",
|
| 163 |
+
"Thin Feature",
|
| 164 |
+
f"Shortest edge: {min_edge:.2f}mm — below min wall thickness "
|
| 165 |
+
f"({cfg['min_wall_thickness_mm']}mm)",
|
| 166 |
+
)
|
| 167 |
+
)
|
| 168 |
|
| 169 |
# --- 5. Aspect ratio check (deep pocket heuristic) ---
|
| 170 |
# Only flag if the narrowest dimension is small enough to be a pocket/slot
|
| 171 |
if dims[0] > 0 and dims[0] < 20:
|
| 172 |
aspect = dims[2] / dims[0] # tallest / narrowest
|
| 173 |
if aspect > cfg["max_pocket_depth_ratio"]:
|
| 174 |
+
result.issues.append(
|
| 175 |
+
CNCIssue(
|
| 176 |
+
"warning",
|
| 177 |
+
"Deep Feature",
|
| 178 |
+
f"Aspect ratio {aspect:.1f}:1 — may require long-reach tooling or "
|
| 179 |
+
f"special fixturing",
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
|
| 183 |
# --- 6. Surface type analysis ---
|
| 184 |
has_freeform = False
|
|
|
|
| 198 |
pass
|
| 199 |
|
| 200 |
if has_freeform:
|
| 201 |
+
result.issues.append(
|
| 202 |
+
CNCIssue(
|
| 203 |
+
"warning",
|
| 204 |
+
"Surface",
|
| 205 |
+
"Freeform/spline surfaces detected — requires 3D contouring toolpaths",
|
| 206 |
+
)
|
| 207 |
+
)
|
| 208 |
if result.axis_recommendation == "3-axis":
|
| 209 |
result.axis_recommendation = "3-axis (with 3D finishing)"
|
| 210 |
|
| 211 |
+
result.issues.append(
|
| 212 |
+
CNCIssue(
|
| 213 |
+
"info",
|
| 214 |
+
"Surface",
|
| 215 |
+
f"Faces: {planar_count} planar, {cylindrical_count} cylindrical, "
|
| 216 |
+
f"{n_faces - planar_count - cylindrical_count} other",
|
| 217 |
+
)
|
| 218 |
+
)
|
| 219 |
|
| 220 |
# --- 7. Set final machinable flag ---
|
| 221 |
if result.error_count > 0:
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
mcp-server:
|
| 3 |
+
build: .
|
| 4 |
+
command: python -m server.mcp --transport sse --port 8000
|
| 5 |
+
ports:
|
| 6 |
+
- "8000:8000"
|
| 7 |
+
environment:
|
| 8 |
+
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
| 9 |
+
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
| 10 |
+
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
| 11 |
+
volumes:
|
| 12 |
+
- ./output:/app/output
|
| 13 |
+
|
| 14 |
+
web:
|
| 15 |
+
build: .
|
| 16 |
+
command: python -m server.web --host 0.0.0.0 --port 5000
|
| 17 |
+
ports:
|
| 18 |
+
- "5000:5000"
|
| 19 |
+
environment:
|
| 20 |
+
MCP_SERVER_URL: http://mcp-server:8000/sse
|
| 21 |
+
depends_on:
|
| 22 |
+
- mcp-server
|
| 23 |
+
volumes:
|
| 24 |
+
- ./output:/app/output
|
docs/superpowers/plans/2026-04-08-uv-docker-deploy.md
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# uv, Docker, and HF Spaces Deployment — 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:** Set up uv package management, Docker containerization, and deploy to Hugging Face Spaces for a free investor demo.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Replace requirements.txt with pyproject.toml managed by uv. Multi-stage Dockerfile builds a slim image with CadQuery. Container runs both MCP CAD server and web server. HF Spaces hosts it free at a public URL.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** uv 0.8+, Docker multi-stage, docker-compose, Hugging Face Spaces (Docker SDK)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
### Task 1: Create pyproject.toml
|
| 14 |
+
|
| 15 |
+
**Files:**
|
| 16 |
+
- Create: `pyproject.toml`
|
| 17 |
+
- Modify: `requirements.txt`
|
| 18 |
+
|
| 19 |
+
- [ ] **Step 1: Create pyproject.toml**
|
| 20 |
+
|
| 21 |
+
```toml
|
| 22 |
+
[project]
|
| 23 |
+
name = "neuralcad"
|
| 24 |
+
version = "1.0.0"
|
| 25 |
+
description = "Text-to-CNC pipeline: natural language to machinable 3D models"
|
| 26 |
+
requires-python = ">=3.10"
|
| 27 |
+
dependencies = [
|
| 28 |
+
"cadquery>=2.7.0",
|
| 29 |
+
"cadquery-ocp>=7.8.0",
|
| 30 |
+
"numpy>=1.24.0",
|
| 31 |
+
"trimesh>=4.0.0",
|
| 32 |
+
"anthropic>=0.25.0",
|
| 33 |
+
"openai>=1.30.0",
|
| 34 |
+
"mcp>=1.0.0",
|
| 35 |
+
"fastapi>=0.110.0",
|
| 36 |
+
"uvicorn>=0.29.0",
|
| 37 |
+
"python-multipart>=0.0.9",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
[dependency-groups]
|
| 41 |
+
dev = ["ruff", "pytest"]
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
- [ ] **Step 2: Add backward-compat comment to requirements.txt**
|
| 45 |
+
|
| 46 |
+
Replace the contents of `requirements.txt` with:
|
| 47 |
+
|
| 48 |
+
```
|
| 49 |
+
# Dependency source of truth is pyproject.toml — use `uv sync` to install.
|
| 50 |
+
# This file is kept for environments that don't use uv.
|
| 51 |
+
cadquery>=2.7.0
|
| 52 |
+
cadquery-ocp>=7.8.0
|
| 53 |
+
numpy>=1.24.0
|
| 54 |
+
trimesh>=4.0.0
|
| 55 |
+
anthropic>=0.25.0
|
| 56 |
+
openai>=1.30.0
|
| 57 |
+
mcp>=1.0.0
|
| 58 |
+
fastapi>=0.110.0
|
| 59 |
+
uvicorn>=0.29.0
|
| 60 |
+
python-multipart>=0.0.9
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
- [ ] **Step 3: Generate lockfile**
|
| 64 |
+
|
| 65 |
+
Run: `uv lock`
|
| 66 |
+
|
| 67 |
+
Expected: `uv.lock` file is created in project root.
|
| 68 |
+
|
| 69 |
+
- [ ] **Step 4: Update .gitignore**
|
| 70 |
+
|
| 71 |
+
Add these lines to `.gitignore`:
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
.venv/
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
- [ ] **Step 5: Verify uv sync works**
|
| 78 |
+
|
| 79 |
+
Run: `uv sync`
|
| 80 |
+
|
| 81 |
+
Expected: Virtual environment created in `.venv/`, all dependencies installed. Output shows resolved packages.
|
| 82 |
+
|
| 83 |
+
- [ ] **Step 6: Commit**
|
| 84 |
+
|
| 85 |
+
```bash
|
| 86 |
+
git add pyproject.toml uv.lock requirements.txt .gitignore
|
| 87 |
+
git commit -m "build: migrate to uv with pyproject.toml and lockfile"
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
### Task 2: Create Dockerfile
|
| 93 |
+
|
| 94 |
+
**Files:**
|
| 95 |
+
- Create: `Dockerfile`
|
| 96 |
+
|
| 97 |
+
- [ ] **Step 1: Create Dockerfile**
|
| 98 |
+
|
| 99 |
+
```dockerfile
|
| 100 |
+
# ── Stage 1: Builder ─────────────────────────────────────────────────────
|
| 101 |
+
FROM python:3.11-slim AS builder
|
| 102 |
+
|
| 103 |
+
# Install uv
|
| 104 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 105 |
+
|
| 106 |
+
WORKDIR /app
|
| 107 |
+
|
| 108 |
+
# Install dependencies (cached layer — only rebuilds when deps change)
|
| 109 |
+
COPY pyproject.toml uv.lock ./
|
| 110 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 111 |
+
|
| 112 |
+
# Copy source code
|
| 113 |
+
COPY . .
|
| 114 |
+
|
| 115 |
+
# ── Stage 2: Runtime ─────────────────────────────────────────────────────
|
| 116 |
+
FROM python:3.11-slim
|
| 117 |
+
|
| 118 |
+
WORKDIR /app
|
| 119 |
+
|
| 120 |
+
# Copy virtual environment from builder
|
| 121 |
+
COPY --from=builder /app/.venv /app/.venv
|
| 122 |
+
|
| 123 |
+
# Copy application source
|
| 124 |
+
COPY --from=builder /app/*.py /app/
|
| 125 |
+
COPY --from=builder /app/web /app/web/
|
| 126 |
+
COPY --from=builder /app/entrypoint.sh /app/
|
| 127 |
+
|
| 128 |
+
# Put venv on PATH
|
| 129 |
+
ENV PATH="/app/.venv/bin:$PATH"
|
| 130 |
+
ENV PYTHONUNBUFFERED=1
|
| 131 |
+
|
| 132 |
+
# Create output directory
|
| 133 |
+
RUN mkdir -p /app/output
|
| 134 |
+
|
| 135 |
+
EXPOSE 7860
|
| 136 |
+
|
| 137 |
+
ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
- [ ] **Step 2: Verify syntax**
|
| 141 |
+
|
| 142 |
+
Run: `docker build --check -f Dockerfile . 2>&1 || echo "syntax check done"`
|
| 143 |
+
|
| 144 |
+
Expected: No syntax errors. (May fail if Docker not available — that's OK, the file is valid.)
|
| 145 |
+
|
| 146 |
+
- [ ] **Step 3: Commit**
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
git add Dockerfile
|
| 150 |
+
git commit -m "build: add multi-stage Dockerfile"
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
### Task 3: Create entrypoint.sh
|
| 156 |
+
|
| 157 |
+
**Files:**
|
| 158 |
+
- Create: `entrypoint.sh`
|
| 159 |
+
|
| 160 |
+
- [ ] **Step 1: Create entrypoint.sh**
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
#!/bin/bash
|
| 164 |
+
set -e
|
| 165 |
+
|
| 166 |
+
echo "=== NeuralCAD Container Starting ==="
|
| 167 |
+
|
| 168 |
+
# Start MCP CAD server in background
|
| 169 |
+
echo "Starting MCP CAD server on port 8000..."
|
| 170 |
+
python mcp_server.py --transport sse --port 8000 &
|
| 171 |
+
MCP_PID=$!
|
| 172 |
+
|
| 173 |
+
# Wait for MCP server to be ready
|
| 174 |
+
sleep 3
|
| 175 |
+
|
| 176 |
+
if ! kill -0 $MCP_PID 2>/dev/null; then
|
| 177 |
+
echo "ERROR: MCP server failed to start"
|
| 178 |
+
exit 1
|
| 179 |
+
fi
|
| 180 |
+
|
| 181 |
+
echo "MCP server running (PID $MCP_PID)"
|
| 182 |
+
|
| 183 |
+
# Start web server in foreground
|
| 184 |
+
export MCP_SERVER_URL=http://localhost:8000/sse
|
| 185 |
+
PORT=${PORT:-7860}
|
| 186 |
+
echo "Starting web server on port $PORT..."
|
| 187 |
+
exec python web_server.py --host 0.0.0.0 --port "$PORT"
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
- [ ] **Step 2: Make executable**
|
| 191 |
+
|
| 192 |
+
Run: `chmod +x entrypoint.sh`
|
| 193 |
+
|
| 194 |
+
- [ ] **Step 3: Commit**
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
git add entrypoint.sh
|
| 198 |
+
git commit -m "build: add container entrypoint script"
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
---
|
| 202 |
+
|
| 203 |
+
### Task 4: Create .dockerignore
|
| 204 |
+
|
| 205 |
+
**Files:**
|
| 206 |
+
- Create: `.dockerignore`
|
| 207 |
+
|
| 208 |
+
- [ ] **Step 1: Create .dockerignore**
|
| 209 |
+
|
| 210 |
+
```
|
| 211 |
+
.git
|
| 212 |
+
__pycache__
|
| 213 |
+
*.pyc
|
| 214 |
+
output/
|
| 215 |
+
.superpowers/
|
| 216 |
+
.venv/
|
| 217 |
+
docs/
|
| 218 |
+
.env
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
- [ ] **Step 2: Commit**
|
| 222 |
+
|
| 223 |
+
```bash
|
| 224 |
+
git add .dockerignore
|
| 225 |
+
git commit -m "build: add .dockerignore"
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
### Task 5: Create docker-compose.yml
|
| 231 |
+
|
| 232 |
+
**Files:**
|
| 233 |
+
- Create: `docker-compose.yml`
|
| 234 |
+
|
| 235 |
+
- [ ] **Step 1: Create docker-compose.yml**
|
| 236 |
+
|
| 237 |
+
```yaml
|
| 238 |
+
services:
|
| 239 |
+
mcp-server:
|
| 240 |
+
build: .
|
| 241 |
+
command: python mcp_server.py --transport sse --port 8000
|
| 242 |
+
ports:
|
| 243 |
+
- "8000:8000"
|
| 244 |
+
volumes:
|
| 245 |
+
- ./output:/app/output
|
| 246 |
+
|
| 247 |
+
web:
|
| 248 |
+
build: .
|
| 249 |
+
command: python web_server.py --host 0.0.0.0 --port 5000
|
| 250 |
+
ports:
|
| 251 |
+
- "5000:5000"
|
| 252 |
+
environment:
|
| 253 |
+
MCP_SERVER_URL: http://mcp-server:8000/sse
|
| 254 |
+
depends_on:
|
| 255 |
+
- mcp-server
|
| 256 |
+
volumes:
|
| 257 |
+
- ./output:/app/output
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
- [ ] **Step 2: Commit**
|
| 261 |
+
|
| 262 |
+
```bash
|
| 263 |
+
git add docker-compose.yml
|
| 264 |
+
git commit -m "build: add docker-compose for local dev"
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
---
|
| 268 |
+
|
| 269 |
+
### Task 6: Add HF Spaces metadata to README.md
|
| 270 |
+
|
| 271 |
+
**Files:**
|
| 272 |
+
- Modify: `README.md`
|
| 273 |
+
|
| 274 |
+
- [ ] **Step 1: Prepend HF Spaces YAML header to README.md**
|
| 275 |
+
|
| 276 |
+
Add this YAML front matter at the very top of `README.md` (before the `# Text-to-CNC` heading):
|
| 277 |
+
|
| 278 |
+
```yaml
|
| 279 |
+
---
|
| 280 |
+
title: NeuralCAD
|
| 281 |
+
emoji: ⚙️
|
| 282 |
+
colorFrom: blue
|
| 283 |
+
colorTo: cyan
|
| 284 |
+
sdk: docker
|
| 285 |
+
app_port: 7860
|
| 286 |
+
---
|
| 287 |
+
```
|
| 288 |
+
|
| 289 |
+
The rest of the README content stays unchanged below the `---` closing marker.
|
| 290 |
+
|
| 291 |
+
- [ ] **Step 2: Commit**
|
| 292 |
+
|
| 293 |
+
```bash
|
| 294 |
+
git add README.md
|
| 295 |
+
git commit -m "build: add HF Spaces metadata to README"
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
---
|
| 299 |
+
|
| 300 |
+
### Task 7: Docker build and local verification
|
| 301 |
+
|
| 302 |
+
**Files:** None (verification only)
|
| 303 |
+
|
| 304 |
+
- [ ] **Step 1: Build Docker image**
|
| 305 |
+
|
| 306 |
+
Run: `docker build -t neuralcad .`
|
| 307 |
+
|
| 308 |
+
Expected: Multi-stage build completes. Final image is created. Look for `Successfully tagged neuralcad:latest`.
|
| 309 |
+
|
| 310 |
+
- [ ] **Step 2: Run container**
|
| 311 |
+
|
| 312 |
+
Run: `docker run --rm -p 7860:7860 --name neuralcad-test neuralcad`
|
| 313 |
+
|
| 314 |
+
Expected output:
|
| 315 |
+
```
|
| 316 |
+
=== NeuralCAD Container Starting ===
|
| 317 |
+
Starting MCP CAD server on port 8000...
|
| 318 |
+
MCP server running (PID ...)
|
| 319 |
+
Starting web server on port 7860...
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
- [ ] **Step 3: Test in browser**
|
| 323 |
+
|
| 324 |
+
Open: `http://localhost:7860`
|
| 325 |
+
|
| 326 |
+
Verify:
|
| 327 |
+
1. Page loads with NeuralCAD UI
|
| 328 |
+
2. Status dot is green (MCP server connected)
|
| 329 |
+
3. Click "Mounting bracket" quick example
|
| 330 |
+
4. 3D model renders in viewer
|
| 331 |
+
5. Code tab shows CadQuery code
|
| 332 |
+
6. Validation tab shows CNC report
|
| 333 |
+
|
| 334 |
+
- [ ] **Step 4: Stop container**
|
| 335 |
+
|
| 336 |
+
Run: `docker stop neuralcad-test` (or Ctrl+C in the terminal running it)
|
| 337 |
+
|
| 338 |
+
---
|
| 339 |
+
|
| 340 |
+
### Task 8: Deploy to Hugging Face Spaces
|
| 341 |
+
|
| 342 |
+
**Files:** None (deployment only)
|
| 343 |
+
|
| 344 |
+
- [ ] **Step 1: Create HF Space**
|
| 345 |
+
|
| 346 |
+
Run:
|
| 347 |
+
```bash
|
| 348 |
+
huggingface-cli login
|
| 349 |
+
huggingface-cli repo create neuralcad --type space --space-sdk docker
|
| 350 |
+
```
|
| 351 |
+
|
| 352 |
+
If `huggingface-cli` is not installed: `uv tool install huggingface-hub[cli]`
|
| 353 |
+
|
| 354 |
+
- [ ] **Step 2: Add HF remote and push**
|
| 355 |
+
|
| 356 |
+
```bash
|
| 357 |
+
git remote add hf https://huggingface.co/spaces/CallMeDaniel/neuralcad
|
| 358 |
+
git push hf main
|
| 359 |
+
```
|
| 360 |
+
|
| 361 |
+
- [ ] **Step 3: Set API key secrets (optional)**
|
| 362 |
+
|
| 363 |
+
In the HF Space settings (https://huggingface.co/spaces/CallMeDaniel/neuralcad/settings), add secrets:
|
| 364 |
+
- `ANTHROPIC_API_KEY` — for live Claude generation
|
| 365 |
+
- `OPENAI_API_KEY` — for live GPT-4o generation
|
| 366 |
+
|
| 367 |
+
These are optional. Mock backend works without them.
|
| 368 |
+
|
| 369 |
+
- [ ] **Step 4: Verify deployment**
|
| 370 |
+
|
| 371 |
+
Open: `https://callmedaniel-neuralcad.hf.space`
|
| 372 |
+
|
| 373 |
+
Wait for container to build and start (~2-5 min first time). Verify:
|
| 374 |
+
1. Page loads
|
| 375 |
+
2. Quick examples work (mock backend)
|
| 376 |
+
3. If API keys set: toggle to API mode, type custom prompt, verify live generation
|
docs/superpowers/plans/2026-04-11-tests-readme-ai-quality.md
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuralCAD: Tests, README, and AI Quality 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:** Add comprehensive test coverage to NeuralCAD, update the README to reflect the current multi-agent architecture, and improve AI quality (prompt routing and report endpoint).
|
| 6 |
+
|
| 7 |
+
**Architecture:** Tests are split into two tiers: (1) pure-logic tests that run without CadQuery (prompts, routing, design state, mock orchestrator, API routes) and (2) integration tests that require CadQuery (executor, validator, pipeline, CAD code generation). The README is rewritten to document the multi-agent chat system. AI quality improvements target prompt routing accuracy and the report endpoint.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** pytest, FastAPI TestClient, httpx (for async), CadQuery (integration tests only)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## File Structure
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
tests/
|
| 17 |
+
├── conftest.py # Shared fixtures (tmp output dir, mock backend, sample history)
|
| 18 |
+
├── test_prompts.py # Prompt building, @mention parsing, keyword routing, JSON parsing
|
| 19 |
+
├── test_design_state.py # DesignState model + extract_decisions()
|
| 20 |
+
├── test_mock_orchestrator.py # MockChatBackend.chat_turn() — response shape, routing, canned messages
|
| 21 |
+
├── test_single_call_orchestrator.py # SingleCallOrchestrator with a fake LLM backend
|
| 22 |
+
├── test_api_routes.py # FastAPI /api/chat, /api/report, /api/agents via TestClient
|
| 23 |
+
├── test_executor.py # execute_cadquery(), sanitize_code(), exports (requires CadQuery)
|
| 24 |
+
├── test_validator.py # validate_for_cnc() (requires CadQuery)
|
| 25 |
+
└── test_pipeline.py # run_pipeline() end-to-end with MockBackend (requires CadQuery)
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
**Modified files:**
|
| 29 |
+
- `README.md` — full rewrite
|
| 30 |
+
- `pyproject.toml` — add `[tool.pytest.ini_options]`
|
| 31 |
+
- `agents/prompts.py` — improve routing keyword coverage
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
### Task 1: Test Infrastructure (conftest + pytest config)
|
| 36 |
+
|
| 37 |
+
**Files:**
|
| 38 |
+
- Create: `tests/__init__.py`
|
| 39 |
+
- Create: `tests/conftest.py`
|
| 40 |
+
- Modify: `pyproject.toml`
|
| 41 |
+
|
| 42 |
+
- [ ] **Step 1: Add pytest configuration to pyproject.toml**
|
| 43 |
+
|
| 44 |
+
Add after the `[dependency-groups]` section in `pyproject.toml`:
|
| 45 |
+
|
| 46 |
+
```toml
|
| 47 |
+
[tool.pytest.ini_options]
|
| 48 |
+
testpaths = ["tests"]
|
| 49 |
+
pythonpath = ["."]
|
| 50 |
+
markers = [
|
| 51 |
+
"requires_cadquery: marks tests that need CadQuery installed",
|
| 52 |
+
]
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
- [ ] **Step 2: Create tests/__init__.py**
|
| 56 |
+
|
| 57 |
+
```python
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
(Empty file to make `tests` a package.)
|
| 61 |
+
|
| 62 |
+
- [ ] **Step 3: Create tests/conftest.py with shared fixtures**
|
| 63 |
+
|
| 64 |
+
```python
|
| 65 |
+
"""Shared fixtures for NeuralCAD tests."""
|
| 66 |
+
|
| 67 |
+
import pytest
|
| 68 |
+
from pathlib import Path
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture
|
| 72 |
+
def tmp_output_dir(tmp_path):
|
| 73 |
+
"""Temporary output directory for model files."""
|
| 74 |
+
out = tmp_path / "output"
|
| 75 |
+
out.mkdir()
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@pytest.fixture
|
| 80 |
+
def sample_history():
|
| 81 |
+
"""A typical multi-turn conversation history."""
|
| 82 |
+
return [
|
| 83 |
+
{"role": "user", "content": "I need a servo bracket for an MG996R"},
|
| 84 |
+
{"role": "agent", "agent_id": "design", "content": "I'd suggest an L-bracket with a servo pocket on the vertical face."},
|
| 85 |
+
{"role": "agent", "agent_id": "engineering", "content": "3mm wall thickness in aluminum 6061-T6 should handle the load."},
|
| 86 |
+
{"role": "user", "content": "Make it 60mm wide with M4 base mounting holes"},
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@pytest.fixture
|
| 91 |
+
def empty_design_state():
|
| 92 |
+
"""Empty design state dict."""
|
| 93 |
+
return {}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.fixture
|
| 97 |
+
def populated_design_state():
|
| 98 |
+
"""Design state with some decisions already made."""
|
| 99 |
+
return {
|
| 100 |
+
"part_name": "servo_bracket",
|
| 101 |
+
"material": "aluminum 6061",
|
| 102 |
+
"dimensions": {"width": 60.0},
|
| 103 |
+
"features": ["4x M4 holes"],
|
| 104 |
+
"decisions": ["L-bracket form factor"],
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class FakeLLMBackend:
|
| 109 |
+
"""A controllable fake LLM backend for testing orchestrators."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, response: str = '{"agents": []}'):
|
| 112 |
+
self.response = response
|
| 113 |
+
self.calls: list[list[dict]] = []
|
| 114 |
+
|
| 115 |
+
def generate(self, messages: list[dict]) -> str:
|
| 116 |
+
self.calls.append(messages)
|
| 117 |
+
return self.response
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@pytest.fixture
|
| 121 |
+
def fake_backend():
|
| 122 |
+
"""FakeLLMBackend factory — call with desired JSON response."""
|
| 123 |
+
def _make(response: str = '{"agents": []}'):
|
| 124 |
+
return FakeLLMBackend(response)
|
| 125 |
+
return _make
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
- [ ] **Step 4: Run pytest to verify configuration**
|
| 129 |
+
|
| 130 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest --co -q 2>&1 | head -5`
|
| 131 |
+
Expected: `no tests ran` (no test files yet, but no errors)
|
| 132 |
+
|
| 133 |
+
- [ ] **Step 5: Commit**
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
git add tests/__init__.py tests/conftest.py pyproject.toml
|
| 137 |
+
git commit -m "test: add pytest config and shared test fixtures"
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
### Task 2: Test Prompt Building, @Mentions, Keyword Routing, JSON Parsing
|
| 143 |
+
|
| 144 |
+
**Files:**
|
| 145 |
+
- Create: `tests/test_prompts.py`
|
| 146 |
+
|
| 147 |
+
- [ ] **Step 1: Write tests for parse_mentions()**
|
| 148 |
+
|
| 149 |
+
```python
|
| 150 |
+
"""Tests for agents/prompts.py — prompt building, routing, parsing."""
|
| 151 |
+
|
| 152 |
+
from agents.prompts import (
|
| 153 |
+
parse_mentions,
|
| 154 |
+
route_by_keywords,
|
| 155 |
+
parse_orchestrator_response,
|
| 156 |
+
build_orchestrator_system_prompt,
|
| 157 |
+
build_chat_messages,
|
| 158 |
+
CAD_TRIGGER_KEYWORDS,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── parse_mentions ────────────────────────────────────────────────────────
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class TestParseMentions:
|
| 166 |
+
def test_no_mentions(self):
|
| 167 |
+
cleaned, mentions = parse_mentions("I need a bracket")
|
| 168 |
+
assert cleaned == "I need a bracket"
|
| 169 |
+
assert mentions == []
|
| 170 |
+
|
| 171 |
+
def test_single_mention(self):
|
| 172 |
+
cleaned, mentions = parse_mentions("@design what shape?")
|
| 173 |
+
assert "design" in mentions
|
| 174 |
+
assert "@design" not in cleaned
|
| 175 |
+
|
| 176 |
+
def test_multiple_mentions(self):
|
| 177 |
+
cleaned, mentions = parse_mentions("@design @engineering check this")
|
| 178 |
+
assert "design" in mentions
|
| 179 |
+
assert "engineering" in mentions
|
| 180 |
+
assert "@design" not in cleaned
|
| 181 |
+
assert "@engineering" not in cleaned
|
| 182 |
+
|
| 183 |
+
def test_cad_mention(self):
|
| 184 |
+
cleaned, mentions = parse_mentions("@cad generate a preview")
|
| 185 |
+
assert "cad" in mentions
|
| 186 |
+
|
| 187 |
+
def test_case_insensitive(self):
|
| 188 |
+
cleaned, mentions = parse_mentions("@Design what do you think?")
|
| 189 |
+
assert "design" in mentions
|
| 190 |
+
|
| 191 |
+
def test_mention_mid_sentence(self):
|
| 192 |
+
cleaned, mentions = parse_mentions("Can @engineering check the wall thickness?")
|
| 193 |
+
assert "engineering" in mentions
|
| 194 |
+
assert "Can" in cleaned
|
| 195 |
+
assert "check the wall thickness?" in cleaned
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
- [ ] **Step 2: Run tests to verify they pass**
|
| 199 |
+
|
| 200 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestParseMentions -v`
|
| 201 |
+
Expected: All 6 tests PASS
|
| 202 |
+
|
| 203 |
+
- [ ] **Step 3: Write tests for route_by_keywords()**
|
| 204 |
+
|
| 205 |
+
Append to `tests/test_prompts.py`:
|
| 206 |
+
|
| 207 |
+
```python
|
| 208 |
+
# ── route_by_keywords ─────────────────────────────────────────────────────
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TestRouteByKeywords:
|
| 212 |
+
def test_design_keywords(self):
|
| 213 |
+
agents = route_by_keywords("I want a sleek design with smooth shape")
|
| 214 |
+
assert "design" in agents
|
| 215 |
+
|
| 216 |
+
def test_engineering_keywords(self):
|
| 217 |
+
agents = route_by_keywords("Use M6 bolts with 3mm wall thickness in aluminum")
|
| 218 |
+
assert "engineering" in agents
|
| 219 |
+
|
| 220 |
+
def test_cnc_keywords(self):
|
| 221 |
+
agents = route_by_keywords("Can this be machined on a 3-axis CNC mill?")
|
| 222 |
+
assert "cnc" in agents
|
| 223 |
+
|
| 224 |
+
def test_cad_trigger(self):
|
| 225 |
+
agents = route_by_keywords("Generate a preview of the part")
|
| 226 |
+
assert "cad" in agents
|
| 227 |
+
|
| 228 |
+
def test_default_when_no_match(self):
|
| 229 |
+
agents = route_by_keywords("hello there")
|
| 230 |
+
assert agents == ["design", "engineering"]
|
| 231 |
+
|
| 232 |
+
def test_max_three_agents(self):
|
| 233 |
+
agents = route_by_keywords(
|
| 234 |
+
"design shape in aluminum for CNC machining, generate preview"
|
| 235 |
+
)
|
| 236 |
+
assert len(agents) <= 3
|
| 237 |
+
|
| 238 |
+
def test_sorted_by_relevance(self):
|
| 239 |
+
agents = route_by_keywords("M4 M6 tolerance clearance aluminum steel wall")
|
| 240 |
+
assert agents[0] == "engineering"
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
- [ ] **Step 4: Run tests**
|
| 244 |
+
|
| 245 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywords -v`
|
| 246 |
+
Expected: All 7 tests PASS
|
| 247 |
+
|
| 248 |
+
- [ ] **Step 5: Write tests for parse_orchestrator_response()**
|
| 249 |
+
|
| 250 |
+
Append to `tests/test_prompts.py`:
|
| 251 |
+
|
| 252 |
+
```python
|
| 253 |
+
# ── parse_orchestrator_response ───────────────────────────────────────────
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class TestParseOrchestratorResponse:
|
| 257 |
+
def test_valid_json(self):
|
| 258 |
+
resp = '{"agents": [{"id": "design", "message": "Nice bracket."}]}'
|
| 259 |
+
parsed = parse_orchestrator_response(resp)
|
| 260 |
+
assert len(parsed) == 1
|
| 261 |
+
assert parsed[0]["id"] == "design"
|
| 262 |
+
assert parsed[0]["message"] == "Nice bracket."
|
| 263 |
+
assert parsed[0]["code"] is None
|
| 264 |
+
|
| 265 |
+
def test_json_with_code(self):
|
| 266 |
+
resp = '{"agents": [{"id": "cad", "message": "Done.", "code": "result = cq.Workplane().box(10,10,10)"}]}'
|
| 267 |
+
parsed = parse_orchestrator_response(resp)
|
| 268 |
+
assert parsed[0]["code"] == "result = cq.Workplane().box(10,10,10)"
|
| 269 |
+
|
| 270 |
+
def test_json_in_markdown_fence(self):
|
| 271 |
+
resp = '```json\n{"agents": [{"id": "engineering", "message": "Use 3mm walls."}]}\n```'
|
| 272 |
+
parsed = parse_orchestrator_response(resp)
|
| 273 |
+
assert len(parsed) == 1
|
| 274 |
+
assert parsed[0]["id"] == "engineering"
|
| 275 |
+
|
| 276 |
+
def test_multiple_agents(self):
|
| 277 |
+
resp = '{"agents": [{"id": "design", "message": "A"}, {"id": "cnc", "message": "B"}]}'
|
| 278 |
+
parsed = parse_orchestrator_response(resp)
|
| 279 |
+
assert len(parsed) == 2
|
| 280 |
+
assert parsed[0]["id"] == "design"
|
| 281 |
+
assert parsed[1]["id"] == "cnc"
|
| 282 |
+
|
| 283 |
+
def test_invalid_json_fallback(self):
|
| 284 |
+
resp = "I think you should use aluminum."
|
| 285 |
+
parsed = parse_orchestrator_response(resp)
|
| 286 |
+
assert len(parsed) == 1
|
| 287 |
+
assert parsed[0]["id"] == "design"
|
| 288 |
+
assert parsed[0]["message"] == resp
|
| 289 |
+
|
| 290 |
+
def test_empty_agents_fallback(self):
|
| 291 |
+
resp = '{"agents": []}'
|
| 292 |
+
parsed = parse_orchestrator_response(resp)
|
| 293 |
+
# Empty agents list falls back to treating as design message
|
| 294 |
+
assert len(parsed) == 1
|
| 295 |
+
assert parsed[0]["id"] == "design"
|
| 296 |
+
|
| 297 |
+
def test_missing_fields_skipped(self):
|
| 298 |
+
resp = '{"agents": [{"id": "design"}, {"id": "cnc", "message": "OK"}]}'
|
| 299 |
+
parsed = parse_orchestrator_response(resp)
|
| 300 |
+
# First agent missing "message" is skipped
|
| 301 |
+
assert len(parsed) == 1
|
| 302 |
+
assert parsed[0]["id"] == "cnc"
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
- [ ] **Step 6: Run tests**
|
| 306 |
+
|
| 307 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestParseOrchestratorResponse -v`
|
| 308 |
+
Expected: All 7 tests PASS
|
| 309 |
+
|
| 310 |
+
- [ ] **Step 7: Write tests for build_orchestrator_system_prompt() and build_chat_messages()**
|
| 311 |
+
|
| 312 |
+
Append to `tests/test_prompts.py`:
|
| 313 |
+
|
| 314 |
+
```python
|
| 315 |
+
# ── build_orchestrator_system_prompt ──────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
class TestBuildOrchestratorSystemPrompt:
|
| 319 |
+
def test_default_agents(self):
|
| 320 |
+
prompt = build_orchestrator_system_prompt()
|
| 321 |
+
assert "Design Agent" in prompt
|
| 322 |
+
assert "Engineering Agent" in prompt
|
| 323 |
+
assert "CNC Agent" in prompt
|
| 324 |
+
assert "CAD Coder" not in prompt
|
| 325 |
+
|
| 326 |
+
def test_specific_agents(self):
|
| 327 |
+
prompt = build_orchestrator_system_prompt(active_agents=["cad"])
|
| 328 |
+
assert "CAD Coder" in prompt
|
| 329 |
+
assert "Design Agent" not in prompt
|
| 330 |
+
|
| 331 |
+
def test_includes_json_format(self):
|
| 332 |
+
prompt = build_orchestrator_system_prompt()
|
| 333 |
+
assert '"agents"' in prompt
|
| 334 |
+
assert "JSON" in prompt
|
| 335 |
+
|
| 336 |
+
def test_cad_context_included(self):
|
| 337 |
+
prompt = build_orchestrator_system_prompt(
|
| 338 |
+
active_agents=["cad"], include_cad_context=True
|
| 339 |
+
)
|
| 340 |
+
assert "CadQuery" in prompt
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ── build_chat_messages ───────────────────────────────────────────────────
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
class TestBuildChatMessages:
|
| 347 |
+
def test_returns_system_and_user(self):
|
| 348 |
+
msgs = build_chat_messages("hello", [], "You are a bot.")
|
| 349 |
+
assert len(msgs) == 2
|
| 350 |
+
assert msgs[0]["role"] == "system"
|
| 351 |
+
assert msgs[0]["content"] == "You are a bot."
|
| 352 |
+
assert msgs[1]["role"] == "user"
|
| 353 |
+
|
| 354 |
+
def test_history_included_in_user_message(self, sample_history):
|
| 355 |
+
msgs = build_chat_messages("new msg", sample_history, "system prompt")
|
| 356 |
+
user_content = msgs[1]["content"]
|
| 357 |
+
assert "servo bracket" in user_content
|
| 358 |
+
assert "new msg" in user_content
|
| 359 |
+
|
| 360 |
+
def test_design_state_included(self):
|
| 361 |
+
msgs = build_chat_messages(
|
| 362 |
+
"make it wider", [], "system prompt",
|
| 363 |
+
design_state_text="Part: bracket\nMaterial: aluminum"
|
| 364 |
+
)
|
| 365 |
+
user_content = msgs[1]["content"]
|
| 366 |
+
assert "bracket" in user_content
|
| 367 |
+
assert "aluminum" in user_content
|
| 368 |
+
|
| 369 |
+
def test_history_truncation(self):
|
| 370 |
+
long_history = [
|
| 371 |
+
{"role": "user", "content": f"msg {i}"}
|
| 372 |
+
for i in range(50)
|
| 373 |
+
]
|
| 374 |
+
msgs = build_chat_messages("latest", long_history, "sys", max_history=5)
|
| 375 |
+
user_content = msgs[1]["content"]
|
| 376 |
+
# Should include msg 45-49 but not msg 0
|
| 377 |
+
assert "msg 49" in user_content
|
| 378 |
+
assert "msg 0" not in user_content
|
| 379 |
+
```
|
| 380 |
+
|
| 381 |
+
- [ ] **Step 8: Run all prompt tests**
|
| 382 |
+
|
| 383 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py -v`
|
| 384 |
+
Expected: All tests PASS (approximately 25 tests)
|
| 385 |
+
|
| 386 |
+
- [ ] **Step 9: Commit**
|
| 387 |
+
|
| 388 |
+
```bash
|
| 389 |
+
git add tests/test_prompts.py
|
| 390 |
+
git commit -m "test: add prompt building, routing, and JSON parsing tests"
|
| 391 |
+
```
|
| 392 |
+
|
| 393 |
+
---
|
| 394 |
+
|
| 395 |
+
### Task 3: Test DesignState and Decision Extraction
|
| 396 |
+
|
| 397 |
+
**Files:**
|
| 398 |
+
- Create: `tests/test_design_state.py`
|
| 399 |
+
|
| 400 |
+
- [ ] **Step 1: Write tests for DesignState model**
|
| 401 |
+
|
| 402 |
+
```python
|
| 403 |
+
"""Tests for agents/design_state.py — state tracking and decision extraction."""
|
| 404 |
+
|
| 405 |
+
from agents.design_state import DesignState, extract_decisions
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
class TestDesignState:
|
| 409 |
+
def test_empty_render(self):
|
| 410 |
+
state = DesignState()
|
| 411 |
+
assert state.render() == ""
|
| 412 |
+
|
| 413 |
+
def test_render_with_fields(self):
|
| 414 |
+
state = DesignState(
|
| 415 |
+
part_name="bracket",
|
| 416 |
+
material="aluminum 6061",
|
| 417 |
+
dimensions={"width": 60.0, "height": 40.0},
|
| 418 |
+
)
|
| 419 |
+
rendered = state.render()
|
| 420 |
+
assert "bracket" in rendered
|
| 421 |
+
assert "aluminum 6061" in rendered
|
| 422 |
+
assert "width=60.0mm" in rendered
|
| 423 |
+
|
| 424 |
+
def test_render_features(self):
|
| 425 |
+
state = DesignState(features=["4x M6 holes", "fillet"])
|
| 426 |
+
rendered = state.render()
|
| 427 |
+
assert "4x M6 holes" in rendered
|
| 428 |
+
|
| 429 |
+
def test_render_decisions_capped_at_5(self):
|
| 430 |
+
state = DesignState(decisions=[f"decision {i}" for i in range(10)])
|
| 431 |
+
rendered = state.render()
|
| 432 |
+
assert "decision 9" in rendered
|
| 433 |
+
assert "decision 4" not in rendered
|
| 434 |
+
```
|
| 435 |
+
|
| 436 |
+
- [ ] **Step 2: Run tests**
|
| 437 |
+
|
| 438 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py::TestDesignState -v`
|
| 439 |
+
Expected: All 4 tests PASS
|
| 440 |
+
|
| 441 |
+
- [ ] **Step 3: Write tests for extract_decisions()**
|
| 442 |
+
|
| 443 |
+
Append to `tests/test_design_state.py`:
|
| 444 |
+
|
| 445 |
+
```python
|
| 446 |
+
class TestExtractDecisions:
|
| 447 |
+
def test_extracts_material(self):
|
| 448 |
+
responses = [
|
| 449 |
+
{"agent_id": "engineering", "message": "I recommend aluminum 6061 for this application."}
|
| 450 |
+
]
|
| 451 |
+
state = extract_decisions(responses, DesignState())
|
| 452 |
+
assert "aluminum" in state.material.lower()
|
| 453 |
+
|
| 454 |
+
def test_extracts_dimensions_from_user(self):
|
| 455 |
+
responses = []
|
| 456 |
+
state = extract_decisions(responses, DesignState(), user_message="Make it 60mm wide and 40mm tall")
|
| 457 |
+
assert state.dimensions.get("width") == 60.0
|
| 458 |
+
assert state.dimensions.get("height") == 40.0
|
| 459 |
+
|
| 460 |
+
def test_extracts_fastener_features(self):
|
| 461 |
+
responses = [
|
| 462 |
+
{"agent_id": "engineering", "message": "I'll add 4x M6 clearance holes for mounting."}
|
| 463 |
+
]
|
| 464 |
+
state = extract_decisions(responses, DesignState())
|
| 465 |
+
assert any("M6" in f for f in state.features)
|
| 466 |
+
|
| 467 |
+
def test_extracts_axis_recommendation(self):
|
| 468 |
+
responses = [
|
| 469 |
+
{"agent_id": "cnc", "message": "This part needs 5-axis machining due to the undercut."}
|
| 470 |
+
]
|
| 471 |
+
state = extract_decisions(responses, DesignState())
|
| 472 |
+
assert "5-axis" in state.axis_recommendation
|
| 473 |
+
|
| 474 |
+
def test_extracts_part_name(self):
|
| 475 |
+
responses = []
|
| 476 |
+
state = extract_decisions(responses, DesignState(), user_message="I need a servo bracket")
|
| 477 |
+
assert "servo bracket" in state.part_name.lower() or "servo_bracket" in state.part_name.lower()
|
| 478 |
+
|
| 479 |
+
def test_preserves_existing_state(self):
|
| 480 |
+
existing = DesignState(material="steel", dimensions={"width": 50.0})
|
| 481 |
+
responses = [
|
| 482 |
+
{"agent_id": "engineering", "message": "Height should be 30mm."}
|
| 483 |
+
]
|
| 484 |
+
updated = extract_decisions(responses, existing, user_message="add height")
|
| 485 |
+
# Material preserved, new dimension added
|
| 486 |
+
assert updated.material == "steel"
|
| 487 |
+
assert updated.dimensions.get("width") == 50.0
|
| 488 |
+
|
| 489 |
+
def test_extracts_decisions_from_agreement(self):
|
| 490 |
+
responses = [
|
| 491 |
+
{"agent_id": "design", "message": "I'd recommend an L-bracket form factor for this."}
|
| 492 |
+
]
|
| 493 |
+
state = extract_decisions(responses, DesignState())
|
| 494 |
+
assert len(state.decisions) > 0
|
| 495 |
+
|
| 496 |
+
def test_no_duplicate_features(self):
|
| 497 |
+
existing = DesignState(features=["4x M6 holes"])
|
| 498 |
+
responses = [
|
| 499 |
+
{"agent_id": "engineering", "message": "The 4x M6 holes are properly specified."}
|
| 500 |
+
]
|
| 501 |
+
updated = extract_decisions(responses, existing)
|
| 502 |
+
m6_count = sum(1 for f in updated.features if "M6" in f)
|
| 503 |
+
assert m6_count == 1
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
- [ ] **Step 4: Run tests**
|
| 507 |
+
|
| 508 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py -v`
|
| 509 |
+
Expected: All 12 tests PASS
|
| 510 |
+
|
| 511 |
+
- [ ] **Step 5: Commit**
|
| 512 |
+
|
| 513 |
+
```bash
|
| 514 |
+
git add tests/test_design_state.py
|
| 515 |
+
git commit -m "test: add design state and decision extraction tests"
|
| 516 |
+
```
|
| 517 |
+
|
| 518 |
+
---
|
| 519 |
+
|
| 520 |
+
### Task 4: Test MockChatBackend
|
| 521 |
+
|
| 522 |
+
**Files:**
|
| 523 |
+
- Create: `tests/test_mock_orchestrator.py`
|
| 524 |
+
|
| 525 |
+
- [ ] **Step 1: Write tests for MockChatBackend**
|
| 526 |
+
|
| 527 |
+
```python
|
| 528 |
+
"""Tests for agents/orchestrator.py — MockChatBackend and helpers."""
|
| 529 |
+
|
| 530 |
+
from agents.orchestrator import MockChatBackend, _format_response
|
| 531 |
+
from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
class TestFormatResponse:
|
| 535 |
+
def test_returns_all_fields(self):
|
| 536 |
+
resp = _format_response("design", "Hello")
|
| 537 |
+
assert resp["agent_id"] == "design"
|
| 538 |
+
assert resp["agent_name"] == AGENT_NAMES["design"]
|
| 539 |
+
assert resp["message"] == "Hello"
|
| 540 |
+
assert resp["color"] == AGENT_COLORS["design"]
|
| 541 |
+
assert resp["avatar"] == AGENT_AVATARS["design"]
|
| 542 |
+
assert resp["code"] is None
|
| 543 |
+
|
| 544 |
+
def test_includes_code(self):
|
| 545 |
+
resp = _format_response("cad", "Done.", code="result = cq.Workplane().box(10,10,10)")
|
| 546 |
+
assert resp["code"] == "result = cq.Workplane().box(10,10,10)"
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
class TestMockChatBackend:
|
| 550 |
+
def test_response_shape(self, tmp_output_dir):
|
| 551 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 552 |
+
result = mock.chat_turn("I need a bracket", history=[])
|
| 553 |
+
assert "responses" in result
|
| 554 |
+
assert "preview" in result
|
| 555 |
+
assert "design_state" in result
|
| 556 |
+
assert isinstance(result["responses"], list)
|
| 557 |
+
assert len(result["responses"]) > 0
|
| 558 |
+
|
| 559 |
+
def test_bracket_routes_to_design(self, tmp_output_dir):
|
| 560 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 561 |
+
result = mock.chat_turn("Design a mounting bracket", history=[])
|
| 562 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 563 |
+
assert "design" in agent_ids
|
| 564 |
+
|
| 565 |
+
def test_mention_overrides_routing(self, tmp_output_dir):
|
| 566 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 567 |
+
result = mock.chat_turn(
|
| 568 |
+
"What do you think?",
|
| 569 |
+
history=[],
|
| 570 |
+
mentions=["cnc"],
|
| 571 |
+
)
|
| 572 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 573 |
+
assert agent_ids == ["cnc"]
|
| 574 |
+
|
| 575 |
+
def test_cad_mention_generates_code(self, tmp_output_dir):
|
| 576 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 577 |
+
result = mock.chat_turn(
|
| 578 |
+
"Generate a 50mm cube",
|
| 579 |
+
history=[],
|
| 580 |
+
mentions=["cad"],
|
| 581 |
+
)
|
| 582 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 583 |
+
assert "cad" in agent_ids
|
| 584 |
+
cad_resp = next(r for r in result["responses"] if r["agent_id"] == "cad")
|
| 585 |
+
assert cad_resp["code"] is not None
|
| 586 |
+
assert "result" in cad_resp["code"]
|
| 587 |
+
|
| 588 |
+
def test_design_state_updated(self, tmp_output_dir):
|
| 589 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 590 |
+
result = mock.chat_turn(
|
| 591 |
+
"Make it 60mm wide in aluminum",
|
| 592 |
+
history=[],
|
| 593 |
+
)
|
| 594 |
+
ds = result["design_state"]
|
| 595 |
+
assert isinstance(ds, dict)
|
| 596 |
+
|
| 597 |
+
def test_engineering_keywords_trigger_engineering(self, tmp_output_dir):
|
| 598 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 599 |
+
result = mock.chat_turn("Use M6 bolts with 3mm wall thickness", history=[])
|
| 600 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 601 |
+
assert "engineering" in agent_ids
|
| 602 |
+
|
| 603 |
+
def test_cnc_keywords_trigger_cnc(self, tmp_output_dir):
|
| 604 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 605 |
+
result = mock.chat_turn("Can this be machined on a CNC mill?", history=[])
|
| 606 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 607 |
+
assert "cnc" in agent_ids
|
| 608 |
+
|
| 609 |
+
def test_generic_message_default_agents(self, tmp_output_dir):
|
| 610 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 611 |
+
result = mock.chat_turn("Hello there", history=[])
|
| 612 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 613 |
+
# Default: design + engineering
|
| 614 |
+
assert "design" in agent_ids
|
| 615 |
+
assert "engineering" in agent_ids
|
| 616 |
+
```
|
| 617 |
+
|
| 618 |
+
- [ ] **Step 2: Run tests**
|
| 619 |
+
|
| 620 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_mock_orchestrator.py -v`
|
| 621 |
+
Expected: All 10 tests PASS
|
| 622 |
+
|
| 623 |
+
- [ ] **Step 3: Commit**
|
| 624 |
+
|
| 625 |
+
```bash
|
| 626 |
+
git add tests/test_mock_orchestrator.py
|
| 627 |
+
git commit -m "test: add MockChatBackend tests"
|
| 628 |
+
```
|
| 629 |
+
|
| 630 |
+
---
|
| 631 |
+
|
| 632 |
+
### Task 5: Test SingleCallOrchestrator with Fake Backend
|
| 633 |
+
|
| 634 |
+
**Files:**
|
| 635 |
+
- Create: `tests/test_single_call_orchestrator.py`
|
| 636 |
+
|
| 637 |
+
- [ ] **Step 1: Write tests**
|
| 638 |
+
|
| 639 |
+
```python
|
| 640 |
+
"""Tests for SingleCallOrchestrator using a fake LLM backend."""
|
| 641 |
+
|
| 642 |
+
import json
|
| 643 |
+
from agents.orchestrator import SingleCallOrchestrator
|
| 644 |
+
from tests.conftest import FakeLLMBackend
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
class TestSingleCallOrchestrator:
|
| 648 |
+
def _make_orchestrator(self, response_json: str, tmp_output_dir):
|
| 649 |
+
backend = FakeLLMBackend(response_json)
|
| 650 |
+
return SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir), backend
|
| 651 |
+
|
| 652 |
+
def test_response_shape(self, tmp_output_dir):
|
| 653 |
+
resp = json.dumps({"agents": [
|
| 654 |
+
{"id": "design", "message": "An L-bracket would work."},
|
| 655 |
+
]})
|
| 656 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 657 |
+
result = orch.chat_turn("I need a bracket", history=[])
|
| 658 |
+
assert "responses" in result
|
| 659 |
+
assert "preview" in result
|
| 660 |
+
assert "design_state" in result
|
| 661 |
+
|
| 662 |
+
def test_passes_message_to_backend(self, tmp_output_dir):
|
| 663 |
+
resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
|
| 664 |
+
orch, backend = self._make_orchestrator(resp, tmp_output_dir)
|
| 665 |
+
orch.chat_turn("Test message", history=[])
|
| 666 |
+
assert len(backend.calls) == 1
|
| 667 |
+
last_user_msg = backend.calls[0][-1]["content"]
|
| 668 |
+
assert "Test message" in last_user_msg
|
| 669 |
+
|
| 670 |
+
def test_mentions_restrict_agents(self, tmp_output_dir):
|
| 671 |
+
resp = json.dumps({"agents": [{"id": "cnc", "message": "3-axis OK"}]})
|
| 672 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 673 |
+
result = orch.chat_turn("check this", history=[], mentions=["cnc"])
|
| 674 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 675 |
+
assert "cnc" in agent_ids
|
| 676 |
+
|
| 677 |
+
def test_invalid_json_fallback(self, tmp_output_dir):
|
| 678 |
+
orch, _ = self._make_orchestrator("not json at all", tmp_output_dir)
|
| 679 |
+
result = orch.chat_turn("help", history=[])
|
| 680 |
+
# Fallback treats entire response as design agent message
|
| 681 |
+
assert len(result["responses"]) > 0
|
| 682 |
+
assert result["responses"][0]["agent_id"] == "design"
|
| 683 |
+
|
| 684 |
+
def test_llm_exception_fallback(self, tmp_output_dir):
|
| 685 |
+
backend = FakeLLMBackend("")
|
| 686 |
+
backend.generate = lambda msgs: (_ for _ in ()).throw(RuntimeError("API error"))
|
| 687 |
+
orch = SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir)
|
| 688 |
+
result = orch.chat_turn("Design a part", history=[])
|
| 689 |
+
# Should return fallback messages, not crash
|
| 690 |
+
assert len(result["responses"]) > 0
|
| 691 |
+
|
| 692 |
+
def test_unknown_agent_id_filtered(self, tmp_output_dir):
|
| 693 |
+
resp = json.dumps({"agents": [
|
| 694 |
+
{"id": "nonexistent", "message": "I don't exist"},
|
| 695 |
+
{"id": "design", "message": "Real agent"},
|
| 696 |
+
]})
|
| 697 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 698 |
+
result = orch.chat_turn("test", history=[])
|
| 699 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 700 |
+
assert "nonexistent" not in agent_ids
|
| 701 |
+
assert "design" in agent_ids
|
| 702 |
+
|
| 703 |
+
def test_history_forwarded_to_backend(self, tmp_output_dir, sample_history):
|
| 704 |
+
resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
|
| 705 |
+
orch, backend = self._make_orchestrator(resp, tmp_output_dir)
|
| 706 |
+
orch.chat_turn("continue", history=sample_history)
|
| 707 |
+
user_content = backend.calls[0][-1]["content"]
|
| 708 |
+
assert "servo bracket" in user_content.lower() or "MG996R" in user_content
|
| 709 |
+
|
| 710 |
+
def test_design_state_returned(self, tmp_output_dir):
|
| 711 |
+
resp = json.dumps({"agents": [
|
| 712 |
+
{"id": "engineering", "message": "Use aluminum 6061 with 3mm walls."},
|
| 713 |
+
]})
|
| 714 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 715 |
+
result = orch.chat_turn("material?", history=[])
|
| 716 |
+
assert "design_state" in result
|
| 717 |
+
assert isinstance(result["design_state"], dict)
|
| 718 |
+
```
|
| 719 |
+
|
| 720 |
+
- [ ] **Step 2: Run tests**
|
| 721 |
+
|
| 722 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_single_call_orchestrator.py -v`
|
| 723 |
+
Expected: All 8 tests PASS
|
| 724 |
+
|
| 725 |
+
- [ ] **Step 3: Commit**
|
| 726 |
+
|
| 727 |
+
```bash
|
| 728 |
+
git add tests/test_single_call_orchestrator.py
|
| 729 |
+
git commit -m "test: add SingleCallOrchestrator tests with fake backend"
|
| 730 |
+
```
|
| 731 |
+
|
| 732 |
+
---
|
| 733 |
+
|
| 734 |
+
### Task 6: Test API Routes
|
| 735 |
+
|
| 736 |
+
**Files:**
|
| 737 |
+
- Create: `tests/test_api_routes.py`
|
| 738 |
+
|
| 739 |
+
- [ ] **Step 1: Write tests for /api/chat, /api/report, /api/agents**
|
| 740 |
+
|
| 741 |
+
```python
|
| 742 |
+
"""Tests for server/routes.py — FastAPI chat API endpoints."""
|
| 743 |
+
|
| 744 |
+
import pytest
|
| 745 |
+
from fastapi.testclient import TestClient
|
| 746 |
+
from server.web import app
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
client = TestClient(app)
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
class TestChatEndpoint:
|
| 753 |
+
def test_basic_chat(self):
|
| 754 |
+
resp = client.post("/api/chat", json={
|
| 755 |
+
"message": "I need a bracket",
|
| 756 |
+
"history": [],
|
| 757 |
+
"backend": "mock",
|
| 758 |
+
})
|
| 759 |
+
assert resp.status_code == 200
|
| 760 |
+
data = resp.json()
|
| 761 |
+
assert "responses" in data
|
| 762 |
+
assert len(data["responses"]) > 0
|
| 763 |
+
|
| 764 |
+
def test_chat_with_mentions(self):
|
| 765 |
+
resp = client.post("/api/chat", json={
|
| 766 |
+
"message": "What do you think?",
|
| 767 |
+
"history": [],
|
| 768 |
+
"mentions": ["cnc"],
|
| 769 |
+
"backend": "mock",
|
| 770 |
+
})
|
| 771 |
+
assert resp.status_code == 200
|
| 772 |
+
data = resp.json()
|
| 773 |
+
agent_ids = [r["agent_id"] for r in data["responses"]]
|
| 774 |
+
assert "cnc" in agent_ids
|
| 775 |
+
|
| 776 |
+
def test_chat_with_history(self):
|
| 777 |
+
resp = client.post("/api/chat", json={
|
| 778 |
+
"message": "Make it wider",
|
| 779 |
+
"history": [
|
| 780 |
+
{"role": "user", "content": "I need a bracket"},
|
| 781 |
+
{"role": "agent", "agent_id": "design", "content": "L-bracket suggestion."},
|
| 782 |
+
],
|
| 783 |
+
"backend": "mock",
|
| 784 |
+
})
|
| 785 |
+
assert resp.status_code == 200
|
| 786 |
+
data = resp.json()
|
| 787 |
+
assert "responses" in data
|
| 788 |
+
|
| 789 |
+
def test_chat_empty_message_rejected(self):
|
| 790 |
+
resp = client.post("/api/chat", json={
|
| 791 |
+
"message": "",
|
| 792 |
+
"history": [],
|
| 793 |
+
"backend": "mock",
|
| 794 |
+
})
|
| 795 |
+
assert resp.status_code == 422 # Pydantic validation error
|
| 796 |
+
|
| 797 |
+
def test_chat_returns_design_state(self):
|
| 798 |
+
resp = client.post("/api/chat", json={
|
| 799 |
+
"message": "60mm wide aluminum bracket",
|
| 800 |
+
"history": [],
|
| 801 |
+
"backend": "mock",
|
| 802 |
+
})
|
| 803 |
+
assert resp.status_code == 200
|
| 804 |
+
data = resp.json()
|
| 805 |
+
assert "design_state" in data
|
| 806 |
+
|
| 807 |
+
def test_chat_at_mention_in_message(self):
|
| 808 |
+
resp = client.post("/api/chat", json={
|
| 809 |
+
"message": "@engineering what thickness?",
|
| 810 |
+
"history": [],
|
| 811 |
+
"backend": "mock",
|
| 812 |
+
})
|
| 813 |
+
assert resp.status_code == 200
|
| 814 |
+
data = resp.json()
|
| 815 |
+
agent_ids = [r["agent_id"] for r in data["responses"]]
|
| 816 |
+
assert "engineering" in agent_ids
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
class TestReportEndpoint:
|
| 820 |
+
def test_basic_report(self):
|
| 821 |
+
resp = client.post("/api/report", json={
|
| 822 |
+
"part_name": "test_bracket",
|
| 823 |
+
"history": [
|
| 824 |
+
{"role": "agent", "agent_id": "design", "content": "L-bracket design."},
|
| 825 |
+
{"role": "agent", "agent_id": "engineering", "content": "3mm aluminum."},
|
| 826 |
+
{"role": "agent", "agent_id": "cnc", "content": "3-axis OK."},
|
| 827 |
+
],
|
| 828 |
+
"backend": "mock",
|
| 829 |
+
})
|
| 830 |
+
assert resp.status_code == 200
|
| 831 |
+
data = resp.json()
|
| 832 |
+
assert "report" in data
|
| 833 |
+
assert "test_bracket" in data["report"]
|
| 834 |
+
assert "Design Decisions" in data["report"]
|
| 835 |
+
assert "Engineering Specifications" in data["report"]
|
| 836 |
+
assert "Manufacturing Notes" in data["report"]
|
| 837 |
+
|
| 838 |
+
def test_empty_history(self):
|
| 839 |
+
resp = client.post("/api/report", json={
|
| 840 |
+
"part_name": "empty_part",
|
| 841 |
+
"history": [],
|
| 842 |
+
"backend": "mock",
|
| 843 |
+
})
|
| 844 |
+
assert resp.status_code == 200
|
| 845 |
+
data = resp.json()
|
| 846 |
+
assert "report" in data
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
class TestAgentsEndpoint:
|
| 850 |
+
def test_list_agents(self):
|
| 851 |
+
resp = client.get("/api/agents")
|
| 852 |
+
assert resp.status_code == 200
|
| 853 |
+
data = resp.json()
|
| 854 |
+
assert "agents" in data
|
| 855 |
+
agent_ids = [a["id"] for a in data["agents"]]
|
| 856 |
+
assert "design" in agent_ids
|
| 857 |
+
assert "engineering" in agent_ids
|
| 858 |
+
assert "cnc" in agent_ids
|
| 859 |
+
assert "cad" in agent_ids
|
| 860 |
+
|
| 861 |
+
def test_agent_has_metadata(self):
|
| 862 |
+
resp = client.get("/api/agents")
|
| 863 |
+
data = resp.json()
|
| 864 |
+
agent = data["agents"][0]
|
| 865 |
+
assert "id" in agent
|
| 866 |
+
assert "name" in agent
|
| 867 |
+
assert "role" in agent
|
| 868 |
+
assert "color" in agent
|
| 869 |
+
assert "avatar" in agent
|
| 870 |
+
```
|
| 871 |
+
|
| 872 |
+
- [ ] **Step 2: Run tests**
|
| 873 |
+
|
| 874 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_api_routes.py -v`
|
| 875 |
+
Expected: All 10 tests PASS
|
| 876 |
+
|
| 877 |
+
- [ ] **Step 3: Commit**
|
| 878 |
+
|
| 879 |
+
```bash
|
| 880 |
+
git add tests/test_api_routes.py
|
| 881 |
+
git commit -m "test: add FastAPI route tests for chat, report, and agents endpoints"
|
| 882 |
+
```
|
| 883 |
+
|
| 884 |
+
---
|
| 885 |
+
|
| 886 |
+
### Task 7: Test CadQuery Executor and Validator (Integration)
|
| 887 |
+
|
| 888 |
+
**Files:**
|
| 889 |
+
- Create: `tests/test_executor.py`
|
| 890 |
+
- Create: `tests/test_validator.py`
|
| 891 |
+
|
| 892 |
+
- [ ] **Step 1: Write executor tests**
|
| 893 |
+
|
| 894 |
+
```python
|
| 895 |
+
"""Tests for core/executor.py — CadQuery code execution and export.
|
| 896 |
+
|
| 897 |
+
These tests require CadQuery to be installed.
|
| 898 |
+
"""
|
| 899 |
+
|
| 900 |
+
import pytest
|
| 901 |
+
from pathlib import Path
|
| 902 |
+
from core.executor import sanitize_code, execute_cadquery, export_step, export_stl, export_all
|
| 903 |
+
|
| 904 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
class TestSanitizeCode:
|
| 908 |
+
def test_strips_markdown_fences(self):
|
| 909 |
+
code = "```python\nresult = 1\n```"
|
| 910 |
+
assert "```" not in sanitize_code(code)
|
| 911 |
+
|
| 912 |
+
def test_strips_plain_fences(self):
|
| 913 |
+
code = "```\nresult = 1\n```"
|
| 914 |
+
assert "```" not in sanitize_code(code)
|
| 915 |
+
|
| 916 |
+
def test_removes_cadquery_imports(self):
|
| 917 |
+
code = "import cadquery as cq\nresult = cq.Workplane('XY').box(10,10,10)"
|
| 918 |
+
cleaned = sanitize_code(code)
|
| 919 |
+
assert "import cadquery" not in cleaned
|
| 920 |
+
assert "result" in cleaned
|
| 921 |
+
|
| 922 |
+
def test_removes_math_import(self):
|
| 923 |
+
code = "import math\nresult = cq.Workplane('XY').box(10,10,10)"
|
| 924 |
+
cleaned = sanitize_code(code)
|
| 925 |
+
assert "import math" not in cleaned
|
| 926 |
+
|
| 927 |
+
def test_preserves_valid_code(self):
|
| 928 |
+
code = "result = cq.Workplane('XY').box(10, 20, 30)"
|
| 929 |
+
assert sanitize_code(code) == code
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
class TestExecuteCadquery:
|
| 933 |
+
def test_simple_box(self):
|
| 934 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
|
| 935 |
+
assert result.success is True
|
| 936 |
+
assert result.volume > 0
|
| 937 |
+
assert result.face_count == 6
|
| 938 |
+
assert result.edge_count == 12
|
| 939 |
+
assert len(result.bounding_box) == 3
|
| 940 |
+
|
| 941 |
+
def test_cylinder(self):
|
| 942 |
+
result = execute_cadquery("result = cq.Workplane('XY').cylinder(20, 10)")
|
| 943 |
+
assert result.success is True
|
| 944 |
+
assert result.volume > 0
|
| 945 |
+
|
| 946 |
+
def test_missing_result_variable(self):
|
| 947 |
+
result = execute_cadquery("x = cq.Workplane('XY').box(10,10,10)")
|
| 948 |
+
assert result.success is False
|
| 949 |
+
assert "result" in result.error
|
| 950 |
+
|
| 951 |
+
def test_syntax_error(self):
|
| 952 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 10,")
|
| 953 |
+
assert result.success is False
|
| 954 |
+
assert result.error is not None
|
| 955 |
+
|
| 956 |
+
def test_wrong_type(self):
|
| 957 |
+
result = execute_cadquery("result = 42")
|
| 958 |
+
assert result.success is False
|
| 959 |
+
assert "Workplane" in result.error
|
| 960 |
+
|
| 961 |
+
def test_code_with_markdown_fences(self):
|
| 962 |
+
code = "```python\nimport cadquery as cq\nresult = cq.Workplane('XY').box(5,5,5)\n```"
|
| 963 |
+
result = execute_cadquery(code)
|
| 964 |
+
assert result.success is True
|
| 965 |
+
|
| 966 |
+
def test_summary_on_success(self):
|
| 967 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
|
| 968 |
+
summary = result.summary()
|
| 969 |
+
assert "OK" in summary
|
| 970 |
+
assert "Volume" in summary
|
| 971 |
+
|
| 972 |
+
def test_summary_on_failure(self):
|
| 973 |
+
result = execute_cadquery("result = bad_code")
|
| 974 |
+
summary = result.summary()
|
| 975 |
+
assert "FAILED" in summary
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
class TestExport:
|
| 979 |
+
def test_export_step(self, tmp_path):
|
| 980 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 981 |
+
assert exec_result.success
|
| 982 |
+
path = export_step(exec_result.result, tmp_path / "test.step")
|
| 983 |
+
assert path.exists()
|
| 984 |
+
assert path.suffix == ".step"
|
| 985 |
+
|
| 986 |
+
def test_export_stl(self, tmp_path):
|
| 987 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 988 |
+
assert exec_result.success
|
| 989 |
+
path = export_stl(exec_result.result, tmp_path / "test.stl")
|
| 990 |
+
assert path.exists()
|
| 991 |
+
assert path.suffix == ".stl"
|
| 992 |
+
|
| 993 |
+
def test_export_all(self, tmp_path):
|
| 994 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 995 |
+
assert exec_result.success
|
| 996 |
+
files = export_all(exec_result.result, tmp_path / "part")
|
| 997 |
+
assert files["step"].exists()
|
| 998 |
+
assert files["stl"].exists()
|
| 999 |
+
```
|
| 1000 |
+
|
| 1001 |
+
- [ ] **Step 2: Run executor tests**
|
| 1002 |
+
|
| 1003 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_executor.py -v`
|
| 1004 |
+
Expected: All 13 tests PASS (if CadQuery installed), or all SKIPPED
|
| 1005 |
+
|
| 1006 |
+
- [ ] **Step 3: Write validator tests**
|
| 1007 |
+
|
| 1008 |
+
```python
|
| 1009 |
+
"""Tests for core/validator.py — CNC manufacturability validation.
|
| 1010 |
+
|
| 1011 |
+
These tests require CadQuery to be installed.
|
| 1012 |
+
"""
|
| 1013 |
+
|
| 1014 |
+
import pytest
|
| 1015 |
+
from core.executor import execute_cadquery
|
| 1016 |
+
from core.validator import validate_for_cnc, CNCValidationResult, CNCIssue
|
| 1017 |
+
|
| 1018 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
def _make_solid(code: str):
|
| 1022 |
+
"""Helper to create a CadQuery Workplane from code."""
|
| 1023 |
+
result = execute_cadquery(code)
|
| 1024 |
+
assert result.success, f"Code failed: {result.error}"
|
| 1025 |
+
return result.result
|
| 1026 |
+
|
| 1027 |
+
|
| 1028 |
+
class TestValidateForCnc:
|
| 1029 |
+
def test_simple_box_is_machinable(self):
|
| 1030 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1031 |
+
val = validate_for_cnc(solid, "test_box")
|
| 1032 |
+
assert val.machinable is True
|
| 1033 |
+
assert val.error_count == 0
|
| 1034 |
+
|
| 1035 |
+
def test_result_has_part_name(self):
|
| 1036 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1037 |
+
val = validate_for_cnc(solid, "my_part")
|
| 1038 |
+
assert val.part_name == "my_part"
|
| 1039 |
+
|
| 1040 |
+
def test_axis_recommendation_default_3axis(self):
|
| 1041 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1042 |
+
val = validate_for_cnc(solid)
|
| 1043 |
+
assert "3-axis" in val.axis_recommendation or "3" in val.axis_recommendation
|
| 1044 |
+
|
| 1045 |
+
def test_complex_part_gets_higher_axis(self):
|
| 1046 |
+
# A part with many faces should get a higher axis recommendation
|
| 1047 |
+
code = """
|
| 1048 |
+
result = cq.Workplane('XY').box(50, 50, 50)
|
| 1049 |
+
for i in range(5):
|
| 1050 |
+
result = result.faces('>Z').workplane().pushPoints([(i*8-16, 0)]).hole(3)
|
| 1051 |
+
for i in range(5):
|
| 1052 |
+
result = result.faces('>X').workplane().pushPoints([(i*8-16, 0)]).hole(3)
|
| 1053 |
+
"""
|
| 1054 |
+
solid = _make_solid(code)
|
| 1055 |
+
val = validate_for_cnc(solid)
|
| 1056 |
+
# Should have many faces due to holes
|
| 1057 |
+
assert val.part_name is not None
|
| 1058 |
+
|
| 1059 |
+
def test_oversized_part_flagged(self):
|
| 1060 |
+
solid = _make_solid("result = cq.Workplane('XY').box(600, 600, 600)")
|
| 1061 |
+
val = validate_for_cnc(solid, config={"max_part_size_mm": 500.0})
|
| 1062 |
+
assert any(i.category == "Size" for i in val.issues)
|
| 1063 |
+
|
| 1064 |
+
def test_tiny_part_flagged(self):
|
| 1065 |
+
solid = _make_solid("result = cq.Workplane('XY').box(0.5, 0.5, 0.5)")
|
| 1066 |
+
val = validate_for_cnc(solid, config={"min_part_size_mm": 1.0})
|
| 1067 |
+
assert any(i.category == "Size" for i in val.issues)
|
| 1068 |
+
|
| 1069 |
+
def test_summary_format(self):
|
| 1070 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1071 |
+
val = validate_for_cnc(solid, "test")
|
| 1072 |
+
summary = val.summary()
|
| 1073 |
+
assert isinstance(summary, str)
|
| 1074 |
+
assert "test" in summary
|
| 1075 |
+
|
| 1076 |
+
def test_custom_config(self):
|
| 1077 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1078 |
+
val = validate_for_cnc(solid, config={"min_wall_thickness_mm": 0.5})
|
| 1079 |
+
assert isinstance(val, CNCValidationResult)
|
| 1080 |
+
|
| 1081 |
+
def test_error_and_warning_counts(self):
|
| 1082 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 1083 |
+
val = validate_for_cnc(solid)
|
| 1084 |
+
assert val.error_count >= 0
|
| 1085 |
+
assert val.warning_count >= 0
|
| 1086 |
+
assert val.error_count + val.warning_count <= len(val.issues)
|
| 1087 |
+
```
|
| 1088 |
+
|
| 1089 |
+
- [ ] **Step 4: Run validator tests**
|
| 1090 |
+
|
| 1091 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_validator.py -v`
|
| 1092 |
+
Expected: All 9 tests PASS
|
| 1093 |
+
|
| 1094 |
+
- [ ] **Step 5: Commit**
|
| 1095 |
+
|
| 1096 |
+
```bash
|
| 1097 |
+
git add tests/test_executor.py tests/test_validator.py
|
| 1098 |
+
git commit -m "test: add CadQuery executor and CNC validator integration tests"
|
| 1099 |
+
```
|
| 1100 |
+
|
| 1101 |
+
---
|
| 1102 |
+
|
| 1103 |
+
### Task 8: Test Pipeline End-to-End
|
| 1104 |
+
|
| 1105 |
+
**Files:**
|
| 1106 |
+
- Create: `tests/test_pipeline.py`
|
| 1107 |
+
|
| 1108 |
+
- [ ] **Step 1: Write pipeline integration tests**
|
| 1109 |
+
|
| 1110 |
+
```python
|
| 1111 |
+
"""Tests for core/pipeline.py — end-to-end text-to-CNC pipeline.
|
| 1112 |
+
|
| 1113 |
+
These tests require CadQuery to be installed.
|
| 1114 |
+
"""
|
| 1115 |
+
|
| 1116 |
+
import pytest
|
| 1117 |
+
from pathlib import Path
|
| 1118 |
+
from core.pipeline import run_pipeline, PipelineResult
|
| 1119 |
+
from core.backends import MockBackend
|
| 1120 |
+
|
| 1121 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
class TestRunPipeline:
|
| 1125 |
+
def test_basic_box(self, tmp_output_dir):
|
| 1126 |
+
result = run_pipeline(
|
| 1127 |
+
"A simple 50mm cube",
|
| 1128 |
+
backend=MockBackend(),
|
| 1129 |
+
output_dir=tmp_output_dir,
|
| 1130 |
+
)
|
| 1131 |
+
assert isinstance(result, PipelineResult)
|
| 1132 |
+
assert result.execution.success is True
|
| 1133 |
+
assert result.execution.volume > 0
|
| 1134 |
+
|
| 1135 |
+
def test_exports_files(self, tmp_output_dir):
|
| 1136 |
+
result = run_pipeline(
|
| 1137 |
+
"A 60x40x5mm mounting plate",
|
| 1138 |
+
backend=MockBackend(),
|
| 1139 |
+
output_dir=tmp_output_dir,
|
| 1140 |
+
part_name="test_plate",
|
| 1141 |
+
)
|
| 1142 |
+
assert result.exported_files is not None
|
| 1143 |
+
assert result.exported_files["step"].exists()
|
| 1144 |
+
assert result.exported_files["stl"].exists()
|
| 1145 |
+
|
| 1146 |
+
def test_validation_runs(self, tmp_output_dir):
|
| 1147 |
+
result = run_pipeline(
|
| 1148 |
+
"A 50mm cylinder",
|
| 1149 |
+
backend=MockBackend(),
|
| 1150 |
+
output_dir=tmp_output_dir,
|
| 1151 |
+
validate=True,
|
| 1152 |
+
)
|
| 1153 |
+
assert result.validation is not None
|
| 1154 |
+
assert hasattr(result.validation, "machinable")
|
| 1155 |
+
|
| 1156 |
+
def test_skip_validation(self, tmp_output_dir):
|
| 1157 |
+
result = run_pipeline(
|
| 1158 |
+
"A simple box",
|
| 1159 |
+
backend=MockBackend(),
|
| 1160 |
+
output_dir=tmp_output_dir,
|
| 1161 |
+
validate=False,
|
| 1162 |
+
)
|
| 1163 |
+
assert result.validation is None
|
| 1164 |
+
|
| 1165 |
+
def test_skip_export(self, tmp_output_dir):
|
| 1166 |
+
result = run_pipeline(
|
| 1167 |
+
"A simple box",
|
| 1168 |
+
backend=MockBackend(),
|
| 1169 |
+
output_dir=tmp_output_dir,
|
| 1170 |
+
export=False,
|
| 1171 |
+
)
|
| 1172 |
+
assert result.exported_files is None or len(result.exported_files) == 0
|
| 1173 |
+
|
| 1174 |
+
def test_summary(self, tmp_output_dir):
|
| 1175 |
+
result = run_pipeline(
|
| 1176 |
+
"A 30mm cube",
|
| 1177 |
+
backend=MockBackend(),
|
| 1178 |
+
output_dir=tmp_output_dir,
|
| 1179 |
+
)
|
| 1180 |
+
summary = result.summary()
|
| 1181 |
+
assert isinstance(summary, str)
|
| 1182 |
+
|
| 1183 |
+
def test_default_backend_is_mock(self, tmp_output_dir):
|
| 1184 |
+
# Should work without specifying backend
|
| 1185 |
+
result = run_pipeline(
|
| 1186 |
+
"A basic plate",
|
| 1187 |
+
output_dir=tmp_output_dir,
|
| 1188 |
+
)
|
| 1189 |
+
assert result.execution.success is True
|
| 1190 |
+
```
|
| 1191 |
+
|
| 1192 |
+
- [ ] **Step 2: Run pipeline tests**
|
| 1193 |
+
|
| 1194 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_pipeline.py -v`
|
| 1195 |
+
Expected: All 7 tests PASS
|
| 1196 |
+
|
| 1197 |
+
- [ ] **Step 3: Commit**
|
| 1198 |
+
|
| 1199 |
+
```bash
|
| 1200 |
+
git add tests/test_pipeline.py
|
| 1201 |
+
git commit -m "test: add end-to-end pipeline integration tests"
|
| 1202 |
+
```
|
| 1203 |
+
|
| 1204 |
+
---
|
| 1205 |
+
|
| 1206 |
+
### Task 9: Update README
|
| 1207 |
+
|
| 1208 |
+
**Files:**
|
| 1209 |
+
- Modify: `README.md`
|
| 1210 |
+
|
| 1211 |
+
- [ ] **Step 1: Rewrite README.md**
|
| 1212 |
+
|
| 1213 |
+
Replace the entire contents of `README.md` with:
|
| 1214 |
+
|
| 1215 |
+
```markdown
|
| 1216 |
+
---
|
| 1217 |
+
title: NeuralCAD
|
| 1218 |
+
emoji: ⚙️
|
| 1219 |
+
colorFrom: blue
|
| 1220 |
+
colorTo: indigo
|
| 1221 |
+
sdk: docker
|
| 1222 |
+
app_port: 7860
|
| 1223 |
+
---
|
| 1224 |
+
|
| 1225 |
+
# NeuralCAD — Multi-Agent CAD Design
|
| 1226 |
+
|
| 1227 |
+
A multi-agent AI system that converts natural language descriptions of mechanical parts into CNC-machinable 3D models (STEP/STL). Four specialized AI agents collaborate with you in a shared chat to design, engineer, validate, and generate CadQuery code.
|
| 1228 |
+
|
| 1229 |
+
## How It Works
|
| 1230 |
+
|
| 1231 |
+
```
|
| 1232 |
+
User ──→ Chat Interface ──→ Agent Orchestrator
|
| 1233 |
+
│
|
| 1234 |
+
┌───────────────┼───────────────┐
|
| 1235 |
+
│ │ │
|
| 1236 |
+
Design Agent Engineering CNC Agent
|
| 1237 |
+
(form/shape) Agent (manufacturability)
|
| 1238 |
+
│ (specs/dims) │
|
| 1239 |
+
└───────────────┼───────────────┘
|
| 1240 |
+
│
|
| 1241 |
+
CAD Coder Agent
|
| 1242 |
+
(CadQuery code)
|
| 1243 |
+
│
|
| 1244 |
+
Execute in Sandbox
|
| 1245 |
+
│
|
| 1246 |
+
3D Solid (B-rep)
|
| 1247 |
+
╱ ╲
|
| 1248 |
+
CNC Validator Exporter
|
| 1249 |
+
(machinability (STEP + STL)
|
| 1250 |
+
checks)
|
| 1251 |
+
```
|
| 1252 |
+
|
| 1253 |
+
## Agents
|
| 1254 |
+
|
| 1255 |
+
| Agent | Role | Expertise |
|
| 1256 |
+
|-------|------|-----------|
|
| 1257 |
+
| **Design Agent** | Industrial Designer | Form, aesthetics, ergonomics, shape proposals |
|
| 1258 |
+
| **Engineering Agent** | Mechanical Engineer | Dimensions, tolerances, materials, fastener specs |
|
| 1259 |
+
| **CNC Agent** | Manufacturing Advisor | Tool access, wall thickness, axis requirements, cost |
|
| 1260 |
+
| **CAD Coder** | CadQuery Programmer | Generates valid CadQuery Python code on demand |
|
| 1261 |
+
|
| 1262 |
+
## Quick Start
|
| 1263 |
+
|
| 1264 |
+
```bash
|
| 1265 |
+
# Install dependencies
|
| 1266 |
+
pip install -r requirements.txt
|
| 1267 |
+
|
| 1268 |
+
# Run the web app (mock backend, no API key needed)
|
| 1269 |
+
python -m server.web --port 5000
|
| 1270 |
+
|
| 1271 |
+
# Open http://localhost:5000 in your browser
|
| 1272 |
+
```
|
| 1273 |
+
|
| 1274 |
+
### With LLM Backends
|
| 1275 |
+
|
| 1276 |
+
```bash
|
| 1277 |
+
# Gemini (free tier)
|
| 1278 |
+
export GOOGLE_API_KEY=...
|
| 1279 |
+
# Select GEMINI in the web UI backend toggle
|
| 1280 |
+
|
| 1281 |
+
# Claude (recommended for quality)
|
| 1282 |
+
export ANTHROPIC_API_KEY=sk-ant-...
|
| 1283 |
+
# Select CLAUDE in the web UI backend toggle
|
| 1284 |
+
|
| 1285 |
+
# GPT-4o
|
| 1286 |
+
export OPENAI_API_KEY=sk-...
|
| 1287 |
+
```
|
| 1288 |
+
|
| 1289 |
+
### CLI Pipeline (Direct)
|
| 1290 |
+
|
| 1291 |
+
```bash
|
| 1292 |
+
# Mock backend
|
| 1293 |
+
python -m core.pipeline "A mounting bracket with four M6 holes"
|
| 1294 |
+
|
| 1295 |
+
# With Claude
|
| 1296 |
+
python -m core.pipeline "A flanged bearing housing" --backend anthropic
|
| 1297 |
+
```
|
| 1298 |
+
|
| 1299 |
+
## Architecture
|
| 1300 |
+
|
| 1301 |
+
```
|
| 1302 |
+
NeuralCAD/
|
| 1303 |
+
├── agents/ # Multi-agent orchestration
|
| 1304 |
+
│ ├── definitions.py # Agent roles, colors, personas
|
| 1305 |
+
│ ├── orchestrator.py # Single-call + Mock orchestrators
|
| 1306 |
+
│ ├── crew_orchestrator.py # CrewAI multi-call orchestrator
|
| 1307 |
+
│ ├── prompts.py # System prompts, routing, JSON parsing
|
| 1308 |
+
│ ├── design_state.py # Design decision accumulator
|
| 1309 |
+
│ └── llm_adapter.py # CrewAI LLM adapter
|
| 1310 |
+
├── core/ # CAD generation pipeline
|
| 1311 |
+
│ ├── backends.py # LLM backends (Mock, Anthropic, OpenAI, Gemini)
|
| 1312 |
+
│ ├── pipeline.py # Text-to-CNC orchestrator + CLI
|
| 1313 |
+
│ ├── executor.py # Sandboxed CadQuery execution + export
|
| 1314 |
+
│ ├── validator.py # CNC manufacturability checker
|
| 1315 |
+
│ └── cadquery_prompts.py # CadQuery system prompt + few-shot examples
|
| 1316 |
+
├── server/ # Web + MCP servers
|
| 1317 |
+
│ ├── web.py # FastAPI app, static serving
|
| 1318 |
+
│ ├── routes.py # Chat API endpoints
|
| 1319 |
+
│ └── mcp.py # MCP server (Claude Desktop / Claude Code)
|
| 1320 |
+
├── web/
|
| 1321 |
+
│ └── index.html # Frontend: Three.js viewer + chat panel
|
| 1322 |
+
└── tests/ # Test suite
|
| 1323 |
+
```
|
| 1324 |
+
|
| 1325 |
+
### Orchestration Modes
|
| 1326 |
+
|
| 1327 |
+
| Backend | Mode | API Calls/Turn | Use Case |
|
| 1328 |
+
|---------|------|----------------|----------|
|
| 1329 |
+
| Mock | Template-based | 0 | UI development, demos |
|
| 1330 |
+
| Gemini | Single-call | 1 | Free tier, rate-limited |
|
| 1331 |
+
| Anthropic | CrewAI multi-call | 2-4 | Best quality |
|
| 1332 |
+
| OpenAI | CrewAI multi-call | 2-4 | Best quality |
|
| 1333 |
+
|
| 1334 |
+
### Chat API
|
| 1335 |
+
|
| 1336 |
+
**POST /api/chat** — Multi-agent chat turn
|
| 1337 |
+
|
| 1338 |
+
```json
|
| 1339 |
+
{
|
| 1340 |
+
"message": "Make it 60mm wide with M4 base mounting",
|
| 1341 |
+
"history": [{"role": "user", "content": "I need a servo bracket"}],
|
| 1342 |
+
"mentions": [],
|
| 1343 |
+
"backend": "mock"
|
| 1344 |
+
}
|
| 1345 |
+
```
|
| 1346 |
+
|
| 1347 |
+
**POST /api/report** — Generate design report from conversation
|
| 1348 |
+
|
| 1349 |
+
**GET /api/agents** — List available agents and metadata
|
| 1350 |
+
|
| 1351 |
+
## Features
|
| 1352 |
+
|
| 1353 |
+
- **Multi-agent chat** — 4 specialist agents collaborate on part design
|
| 1354 |
+
- **@mention system** — Direct messages to specific agents (`@design`, `@engineering`, `@cnc`, `@cad`)
|
| 1355 |
+
- **3D preview** — Real-time STL rendering with Three.js (orbit, zoom, pan)
|
| 1356 |
+
- **Design state tracking** — Accumulates decisions across turns (localStorage persistence)
|
| 1357 |
+
- **CNC validation** — Checks wall thickness, pocket ratios, tool access, axis requirements
|
| 1358 |
+
- **Model gallery** — Browse and reload previously generated models
|
| 1359 |
+
- **STEP + STL export** — Download CAM-ready files
|
| 1360 |
+
- **MCP server** — Use from Claude Desktop or Claude Code
|
| 1361 |
+
|
| 1362 |
+
## MCP Server
|
| 1363 |
+
|
| 1364 |
+
```bash
|
| 1365 |
+
# Connect to Claude Code
|
| 1366 |
+
claude mcp add text-to-cnc python3 -m server.mcp
|
| 1367 |
+
|
| 1368 |
+
# Run standalone (SSE for remote integrations)
|
| 1369 |
+
python -m server.mcp --transport sse --port 8000
|
| 1370 |
+
```
|
| 1371 |
+
|
| 1372 |
+
### MCP Tools
|
| 1373 |
+
|
| 1374 |
+
| Tool | Description |
|
| 1375 |
+
|------|-------------|
|
| 1376 |
+
| `generate_cnc_model` | Text → CadQuery code → 3D solid → STEP/STL |
|
| 1377 |
+
| `validate_cnc_model` | Run manufacturability checks on CadQuery code |
|
| 1378 |
+
| `execute_cadquery_code` | Execute arbitrary CadQuery code |
|
| 1379 |
+
| `chat_turn` | Multi-agent chat turn |
|
| 1380 |
+
| `list_models` | List generated models |
|
| 1381 |
+
|
| 1382 |
+
## Testing
|
| 1383 |
+
|
| 1384 |
+
```bash
|
| 1385 |
+
# All tests
|
| 1386 |
+
python -m pytest
|
| 1387 |
+
|
| 1388 |
+
# Pure logic tests only (no CadQuery needed)
|
| 1389 |
+
python -m pytest -m "not requires_cadquery"
|
| 1390 |
+
|
| 1391 |
+
# Integration tests
|
| 1392 |
+
python -m pytest -m requires_cadquery
|
| 1393 |
+
|
| 1394 |
+
# Verbose
|
| 1395 |
+
python -m pytest -v
|
| 1396 |
+
```
|
| 1397 |
+
|
| 1398 |
+
## Docker
|
| 1399 |
+
|
| 1400 |
+
```bash
|
| 1401 |
+
docker compose up --build
|
| 1402 |
+
# Open http://localhost:7860
|
| 1403 |
+
```
|
| 1404 |
+
|
| 1405 |
+
## Key Research
|
| 1406 |
+
|
| 1407 |
+
- **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
|
| 1408 |
+
- **GenCAD** (2024) — Transformer + diffusion for image to CAD
|
| 1409 |
+
- **NURBGen** (2025) — NURBS-based B-rep from text via LLM
|
| 1410 |
+
```
|
| 1411 |
+
|
| 1412 |
+
- [ ] **Step 2: Verify README renders correctly**
|
| 1413 |
+
|
| 1414 |
+
Run: `cd /home/daniel/NeuralCAD && head -20 README.md`
|
| 1415 |
+
Expected: See the HF Spaces frontmatter and title
|
| 1416 |
+
|
| 1417 |
+
- [ ] **Step 3: Commit**
|
| 1418 |
+
|
| 1419 |
+
```bash
|
| 1420 |
+
git add README.md
|
| 1421 |
+
git commit -m "docs: rewrite README to document multi-agent architecture"
|
| 1422 |
+
```
|
| 1423 |
+
|
| 1424 |
+
---
|
| 1425 |
+
|
| 1426 |
+
### Task 10: Improve Keyword Routing Accuracy
|
| 1427 |
+
|
| 1428 |
+
**Files:**
|
| 1429 |
+
- Modify: `agents/prompts.py:162-179`
|
| 1430 |
+
|
| 1431 |
+
- [ ] **Step 1: Write a failing test for weak routing**
|
| 1432 |
+
|
| 1433 |
+
Append to `tests/test_prompts.py`:
|
| 1434 |
+
|
| 1435 |
+
```python
|
| 1436 |
+
class TestRouteByKeywordsImproved:
|
| 1437 |
+
"""Tests for improved keyword routing coverage."""
|
| 1438 |
+
|
| 1439 |
+
def test_gear_routes_to_engineering(self):
|
| 1440 |
+
agents = route_by_keywords("I need a spur gear with 20 teeth")
|
| 1441 |
+
assert "engineering" in agents
|
| 1442 |
+
|
| 1443 |
+
def test_bearing_routes_to_engineering(self):
|
| 1444 |
+
agents = route_by_keywords("Design a bearing housing")
|
| 1445 |
+
assert "engineering" in agents
|
| 1446 |
+
|
| 1447 |
+
def test_heatsink_routes_to_engineering(self):
|
| 1448 |
+
agents = route_by_keywords("Create a heatsink with fins")
|
| 1449 |
+
assert "engineering" in agents
|
| 1450 |
+
|
| 1451 |
+
def test_flange_routes_to_engineering(self):
|
| 1452 |
+
agents = route_by_keywords("A pipe flange with bolt holes")
|
| 1453 |
+
assert "engineering" in agents
|
| 1454 |
+
|
| 1455 |
+
def test_servo_bracket_routes_to_design(self):
|
| 1456 |
+
agents = route_by_keywords("Design a servo bracket for a camera gimbal")
|
| 1457 |
+
assert "design" in agents
|
| 1458 |
+
|
| 1459 |
+
def test_cost_routes_to_cnc(self):
|
| 1460 |
+
agents = route_by_keywords("How much would this cost to machine?")
|
| 1461 |
+
assert "cnc" in agents
|
| 1462 |
+
|
| 1463 |
+
def test_surface_finish_routes_to_cnc(self):
|
| 1464 |
+
agents = route_by_keywords("What surface finish can we achieve?")
|
| 1465 |
+
assert "cnc" in agents
|
| 1466 |
+
```
|
| 1467 |
+
|
| 1468 |
+
- [ ] **Step 2: Run new routing tests to see which fail**
|
| 1469 |
+
|
| 1470 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywordsImproved -v`
|
| 1471 |
+
Expected: Some tests FAIL (gear, bearing, heatsink, flange, surface finish not in keywords)
|
| 1472 |
+
|
| 1473 |
+
- [ ] **Step 3: Add missing keywords to _ROUTING_KEYWORDS**
|
| 1474 |
+
|
| 1475 |
+
In `agents/prompts.py`, replace the `_ROUTING_KEYWORDS` dict (lines 162-179) with:
|
| 1476 |
+
|
| 1477 |
+
```python
|
| 1478 |
+
_ROUTING_KEYWORDS: dict[str, list[str]] = {
|
| 1479 |
+
"design": [
|
| 1480 |
+
"design", "look", "shape", "style", "form", "aesthetic", "appearance",
|
| 1481 |
+
"layout", "concept", "idea", "propose", "suggest", "bracket", "mount",
|
| 1482 |
+
"enclosure", "housing", "ergonomic", "profile", "contour",
|
| 1483 |
+
],
|
| 1484 |
+
"engineering": [
|
| 1485 |
+
"dimension", "tolerance", "material", "strength", "load", "stress",
|
| 1486 |
+
"thickness", "wall", "fillet", "radius", "clearance",
|
| 1487 |
+
"m2", "m3", "m4", "m5", "m6", "m8", "m10", "m12",
|
| 1488 |
+
"aluminum", "steel", "brass", "titanium", "nylon",
|
| 1489 |
+
"gear", "bearing", "flange", "heatsink", "fin", "rib",
|
| 1490 |
+
"bolt", "screw", "thread", "torque", "deflection",
|
| 1491 |
+
"hole", "bore", "shaft", "keyway", "spline",
|
| 1492 |
+
],
|
| 1493 |
+
"cnc": [
|
| 1494 |
+
"machine", "mill", "cnc", "manufacture", "machinable", "axis",
|
| 1495 |
+
"tool", "fixture", "setup", "pocket", "undercut", "access",
|
| 1496 |
+
"3-axis", "5-axis", "cost", "surface finish", "roughness",
|
| 1497 |
+
"endmill", "drill", "tap", "chamfer tool", "deburr",
|
| 1498 |
+
"setup count", "cycle time", "tolerance class",
|
| 1499 |
+
],
|
| 1500 |
+
"cad": CAD_TRIGGER_KEYWORDS,
|
| 1501 |
+
}
|
| 1502 |
+
```
|
| 1503 |
+
|
| 1504 |
+
- [ ] **Step 4: Run improved routing tests**
|
| 1505 |
+
|
| 1506 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywordsImproved -v`
|
| 1507 |
+
Expected: All 7 tests PASS
|
| 1508 |
+
|
| 1509 |
+
- [ ] **Step 5: Run full test suite to check for regressions**
|
| 1510 |
+
|
| 1511 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v`
|
| 1512 |
+
Expected: All tests PASS
|
| 1513 |
+
|
| 1514 |
+
- [ ] **Step 6: Commit**
|
| 1515 |
+
|
| 1516 |
+
```bash
|
| 1517 |
+
git add agents/prompts.py tests/test_prompts.py
|
| 1518 |
+
git commit -m "feat: expand keyword routing vocabulary for better agent selection"
|
| 1519 |
+
```
|
| 1520 |
+
|
| 1521 |
+
---
|
| 1522 |
+
|
| 1523 |
+
### Task 11: Run Full Test Suite and Verify
|
| 1524 |
+
|
| 1525 |
+
- [ ] **Step 1: Run the complete test suite**
|
| 1526 |
+
|
| 1527 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
|
| 1528 |
+
Expected: All tests PASS. Target count: ~70+ tests.
|
| 1529 |
+
|
| 1530 |
+
- [ ] **Step 2: Run with marker filter**
|
| 1531 |
+
|
| 1532 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -m "not requires_cadquery" -v`
|
| 1533 |
+
Expected: Pure-logic tests pass independently.
|
| 1534 |
+
|
| 1535 |
+
- [ ] **Step 3: Check test coverage summary**
|
| 1536 |
+
|
| 1537 |
+
Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short 2>&1 | tail -5`
|
| 1538 |
+
Expected: Summary line showing total pass count.
|
docs/superpowers/specs/2026-04-08-multi-agent-chat-design.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuralCAD Multi-Agent Chat Design
|
| 2 |
+
|
| 3 |
+
## Context
|
| 4 |
+
|
| 5 |
+
NeuralCAD currently uses a single-prompt flow: user describes a part, one LLM call generates CadQuery code, and the result renders in a 3D viewer. This works for simple parts but doesn't support iterative design refinement.
|
| 6 |
+
|
| 7 |
+
The goal is to replace this with a **multi-agent chat experience** where 4 specialized AI agents (Design, Engineering, CNC, CAD Coder) collaborate with the user in a shared conversation to plan and refine a mechanical part before generating the 3D model. The user drives the conversation, agents contribute their expertise, and the user can request a 3D preview on demand.
|
| 8 |
+
|
| 9 |
+
## Agent Definitions
|
| 10 |
+
|
| 11 |
+
Four agents participate in a shared group chat. Each has a distinct role, color, and expertise:
|
| 12 |
+
|
| 13 |
+
| Agent | ID | Color | Avatar | Role |
|
| 14 |
+
|-------|----|-------|--------|------|
|
| 15 |
+
| Design Agent | `design` | `#7c3aed` (purple) | DA | Industrial/product design: shape, form, aesthetics, ergonomics. Asks about intent, proposes form factors, considers user experience. |
|
| 16 |
+
| Engineering Agent | `engineering` | `#00b4d8` (cyan) | EA | Structural/mechanical: dimensions, tolerances, materials, stress analysis, fastener specs (M3/M4/M6 clearance holes). |
|
| 17 |
+
| CNC/Manufacturing Agent | `cnc` | `#00e676` (green) | CA | Manufacturability: tool access, wall thickness, pocket aspect ratios, axis requirements, fixturing, cost implications. |
|
| 18 |
+
| CAD Coder Agent | `cad` | `#ffab40` (amber) | CC | Code generation: takes the agreed design and produces CadQuery Python code. Only responds when a preview is requested. |
|
| 19 |
+
|
| 20 |
+
## Orchestration Architecture
|
| 21 |
+
|
| 22 |
+
### Hybrid Approach: CrewAI Agents + Custom Orchestrator
|
| 23 |
+
|
| 24 |
+
Use **CrewAI** for agent definitions (roles, goals, backstories) and the `BaseLLM` adapter pattern, but implement **two orchestration modes**:
|
| 25 |
+
|
| 26 |
+
#### Single-Call Mode (Gemini Free Tier / Mock)
|
| 27 |
+
|
| 28 |
+
One LLM call per user turn. The system prompt contains all agent personas and routing rules. The LLM returns a structured JSON response:
|
| 29 |
+
|
| 30 |
+
```json
|
| 31 |
+
{
|
| 32 |
+
"agents": [
|
| 33 |
+
{"id": "design", "message": "For an MG996R servo, I'd suggest an L-bracket..."},
|
| 34 |
+
{"id": "engineering", "message": "3mm wall thickness in aluminum 6061..."}
|
| 35 |
+
]
|
| 36 |
+
}
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
The orchestrator system prompt instructs the LLM to:
|
| 40 |
+
- Analyze the user's message and conversation context
|
| 41 |
+
- Select 1-3 relevant agents to respond (never all four unless appropriate)
|
| 42 |
+
- Generate each agent's response in character
|
| 43 |
+
- Only include the `cad` agent when the user explicitly requests a preview
|
| 44 |
+
- When `cad` responds, include a `code` field with valid CadQuery Python
|
| 45 |
+
|
| 46 |
+
If the user @mentions specific agents, the system prompt is modified to only include those agents.
|
| 47 |
+
|
| 48 |
+
**Fallback**: If JSON parsing fails, use rule-based keyword matching to select agents and re-call the LLM with a simpler prompt for just those agents.
|
| 49 |
+
|
| 50 |
+
#### Multi-Call Mode (Anthropic / OpenAI)
|
| 51 |
+
|
| 52 |
+
CrewAI's hierarchical process with a manager agent. Each agent gets its own LLM call with focused context. The manager routes based on conversation state. Better quality but uses 2-4 API calls per turn.
|
| 53 |
+
|
| 54 |
+
#### Mode Selection
|
| 55 |
+
|
| 56 |
+
| Backend | Mode | Reason |
|
| 57 |
+
|---------|------|--------|
|
| 58 |
+
| `mock` | Template-based | No LLM call. Returns canned agent responses based on keyword matching. For the CAD Coder agent, delegates to the existing MockBackend which generates CadQuery code from prompt parsing. Useful for UI development and demos without API keys. |
|
| 59 |
+
| `gemini` | Single-call | Free tier rate limits (15 RPM) |
|
| 60 |
+
| `anthropic` | Multi-call | Paid API, better quality |
|
| 61 |
+
| `openai` | Multi-call | Paid API, better quality |
|
| 62 |
+
|
| 63 |
+
### @Mention System
|
| 64 |
+
|
| 65 |
+
Users can direct messages to specific agents by typing `@design`, `@engineering`, `@cnc`, or `@cad` in their message.
|
| 66 |
+
|
| 67 |
+
- Frontend parses @mentions from the message text before sending
|
| 68 |
+
- @mentions are sent as a `mentions` array in the API request
|
| 69 |
+
- When mentions are present:
|
| 70 |
+
- Single-call mode: system prompt only includes mentioned agents' personas
|
| 71 |
+
- Multi-call mode: only mentioned agents are activated in the crew
|
| 72 |
+
- When no mentions: orchestrator decides which agents respond
|
| 73 |
+
- `@cad` triggers CAD code generation (same as clicking the preview button)
|
| 74 |
+
|
| 75 |
+
### Agent Prompt Structure
|
| 76 |
+
|
| 77 |
+
Each agent has:
|
| 78 |
+
- **System persona**: role description, expertise, communication style
|
| 79 |
+
- **Conversation context**: last N messages from the chat history
|
| 80 |
+
- **User message**: the current message with @mention context
|
| 81 |
+
|
| 82 |
+
The CAD Coder agent additionally receives:
|
| 83 |
+
- The full CadQuery system prompt (from `cadquery_system_prompt.py`)
|
| 84 |
+
- Few-shot examples of CadQuery code
|
| 85 |
+
- A summary of design decisions from the conversation so far
|
| 86 |
+
|
| 87 |
+
## Chat API
|
| 88 |
+
|
| 89 |
+
### Endpoint: `POST /api/chat`
|
| 90 |
+
|
| 91 |
+
**Request:**
|
| 92 |
+
```json
|
| 93 |
+
{
|
| 94 |
+
"history": [
|
| 95 |
+
{"role": "user", "content": "I need a servo bracket"},
|
| 96 |
+
{"role": "design", "content": "What type of servo?"},
|
| 97 |
+
{"role": "user", "content": "MG996R, for a camera gimbal"}
|
| 98 |
+
],
|
| 99 |
+
"message": "Make it 60mm wide with M4 base mounting",
|
| 100 |
+
"mentions": [],
|
| 101 |
+
"backend": "gemini"
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
**Response:**
|
| 106 |
+
```json
|
| 107 |
+
{
|
| 108 |
+
"responses": [
|
| 109 |
+
{
|
| 110 |
+
"agent_id": "design",
|
| 111 |
+
"agent_name": "Design Agent",
|
| 112 |
+
"message": "L-bracket with servo pocket on vertical face...",
|
| 113 |
+
"color": "#7c3aed",
|
| 114 |
+
"avatar": "DA"
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"agent_id": "engineering",
|
| 118 |
+
"agent_name": "Engineering Agent",
|
| 119 |
+
"message": "3mm walls, 5mm fillet on the L-bend...",
|
| 120 |
+
"color": "#00b4d8",
|
| 121 |
+
"avatar": "EA"
|
| 122 |
+
}
|
| 123 |
+
],
|
| 124 |
+
"preview": null
|
| 125 |
+
}
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
**Response with preview (when CAD Coder responds):**
|
| 129 |
+
```json
|
| 130 |
+
{
|
| 131 |
+
"responses": [
|
| 132 |
+
{
|
| 133 |
+
"agent_id": "cad",
|
| 134 |
+
"agent_name": "CAD Coder",
|
| 135 |
+
"message": "Model generated successfully.",
|
| 136 |
+
"color": "#ffab40",
|
| 137 |
+
"avatar": "CC",
|
| 138 |
+
"code": "import cadquery as cq\nresult = cq.Workplane('XY')..."
|
| 139 |
+
}
|
| 140 |
+
],
|
| 141 |
+
"preview": {
|
| 142 |
+
"part_name": "servo_bracket",
|
| 143 |
+
"stl_url": "/api/models/servo_bracket.stl",
|
| 144 |
+
"step_url": "/api/models/servo_bracket.step",
|
| 145 |
+
"execution": {
|
| 146 |
+
"success": true,
|
| 147 |
+
"volume_mm3": 4230.5,
|
| 148 |
+
"bounding_box_mm": [60.0, 43.0, 25.0],
|
| 149 |
+
"face_count": 34,
|
| 150 |
+
"edge_count": 52
|
| 151 |
+
},
|
| 152 |
+
"validation": {
|
| 153 |
+
"machinable": true,
|
| 154 |
+
"axis_recommendation": "3-axis",
|
| 155 |
+
"issues": []
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
### State Management
|
| 162 |
+
|
| 163 |
+
- **Stateless backend**: frontend sends full conversation history with each request
|
| 164 |
+
- **Backend truncation**: history truncated to last 30 messages to stay within token limits
|
| 165 |
+
- **No sessions**: no server-side session storage needed
|
| 166 |
+
- **Gallery persistence**: saved models stored in the `output/` directory with metadata JSON files
|
| 167 |
+
|
| 168 |
+
### Existing Endpoints (Preserved)
|
| 169 |
+
|
| 170 |
+
- `GET /api/models` — list generated models
|
| 171 |
+
- `GET /api/models/{name}.stl` — download STL
|
| 172 |
+
- `GET /api/models/{name}.step` — download STEP
|
| 173 |
+
- `GET /api/capabilities` — server status
|
| 174 |
+
|
| 175 |
+
### New Endpoint: `POST /api/report`
|
| 176 |
+
|
| 177 |
+
Generates a design report document. Requires conversation history since the backend is stateless.
|
| 178 |
+
|
| 179 |
+
**Request:**
|
| 180 |
+
```json
|
| 181 |
+
{
|
| 182 |
+
"part_name": "servo_bracket",
|
| 183 |
+
"history": [...],
|
| 184 |
+
"backend": "gemini"
|
| 185 |
+
}
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
The LLM summarizes the conversation into a report containing:
|
| 189 |
+
- Design decisions extracted from conversation
|
| 190 |
+
- Final dimensions and specifications
|
| 191 |
+
- CNC validation results and axis recommendation
|
| 192 |
+
- Agent recommendations summary
|
| 193 |
+
|
| 194 |
+
For `mock` backend, the report is assembled from the last CAD Coder response metadata without an LLM call.
|
| 195 |
+
|
| 196 |
+
## Frontend Design
|
| 197 |
+
|
| 198 |
+
### Layout: Fullscreen 3D Viewer + Slide-out Chat
|
| 199 |
+
|
| 200 |
+
The 3D viewer occupies the **entire viewport** as the primary element. The chat panel slides in/out from the right side.
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
+--[TopBar: Logo | Backend Toggle | Status]------------------+
|
| 204 |
+
| | CHAT |
|
| 205 |
+
| | PANEL |
|
| 206 |
+
| FULLSCREEN 3D VIEWER | (340px)|
|
| 207 |
+
| (Three.js WebGL) | |
|
| 208 |
+
| | [msgs] |
|
| 209 |
+
| [Geo Stats] [CNC Badge] | [msgs] |
|
| 210 |
+
| | [msgs] |
|
| 211 |
+
| |--------|
|
| 212 |
+
| [STEP] [STL] [Report] |[input] |
|
| 213 |
+
+----------------------------------------------------+--------+
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
### 3D Viewer
|
| 217 |
+
|
| 218 |
+
- Same Three.js setup as current (STLLoader, OrbitControls, MeshPhongMaterial)
|
| 219 |
+
- Fullscreen, edge-to-edge behind the chat panel
|
| 220 |
+
- Semi-transparent overlays for geo stats (top-left), CNC badge (top-right of viewer area), downloads (bottom-left)
|
| 221 |
+
- Empty state shows a subtle prompt: "Start a conversation to design your part"
|
| 222 |
+
- When model is loaded: auto-centers, fits camera to bounding box, slow auto-rotate when idle
|
| 223 |
+
|
| 224 |
+
### Chat Panel
|
| 225 |
+
|
| 226 |
+
- **Width**: 340px, slides in from right
|
| 227 |
+
- **Background**: semi-transparent (`rgba(10,14,20,0.92)`) with `backdrop-filter: blur(16px)` so the 3D model is visible behind
|
| 228 |
+
- **Collapse/expand**: toggle button (chevron) in chat header, or floating pill at bottom center when collapsed
|
| 229 |
+
- **Agent dots**: row of 4 colored dots in the header showing active agents
|
| 230 |
+
|
| 231 |
+
#### Message Rendering
|
| 232 |
+
|
| 233 |
+
- **User messages**: right-aligned, dark blue bubble (`#1a2a3a`), rounded corners
|
| 234 |
+
- **Agent messages**: left-aligned with colored avatar circle (24px), agent label above message text in agent's color
|
| 235 |
+
- **CAD Coder messages**: distinct background (`rgba(255,171,64,0.08)`) with "View CadQuery code" link
|
| 236 |
+
|
| 237 |
+
#### Input Area
|
| 238 |
+
|
| 239 |
+
- Text input with placeholder "Type your message..."
|
| 240 |
+
- **@mention autocomplete**: typing `@` shows a dropdown with agent names, selecting inserts `@design` etc.
|
| 241 |
+
- **Preview button** (eye icon, amber `#ffab40`): triggers CAD Coder to generate 3D model from current conversation
|
| 242 |
+
- **Send button** (arrow icon, cyan `#00b4d8`): sends the message
|
| 243 |
+
- **Ctrl/Cmd+Enter**: keyboard shortcut to send
|
| 244 |
+
|
| 245 |
+
#### Quick Examples
|
| 246 |
+
|
| 247 |
+
On first load (empty chat), show example conversation starters as clickable chips:
|
| 248 |
+
- "Design a mounting bracket for an MG996R servo"
|
| 249 |
+
- "I need a spur gear with 20 teeth"
|
| 250 |
+
- "Create a heatsink for a 30mm cylinder"
|
| 251 |
+
- "Design a pipe flange with M8 bolt holes"
|
| 252 |
+
|
| 253 |
+
These insert the text into the chat input and auto-send.
|
| 254 |
+
|
| 255 |
+
### Gallery
|
| 256 |
+
|
| 257 |
+
- Accessed via a button in the top bar (not a tab)
|
| 258 |
+
- Opens as a modal/dropdown overlay
|
| 259 |
+
- Shows previously generated models as cards with thumbnail, name, face count, CNC status
|
| 260 |
+
- Click to load model into the 3D viewer
|
| 261 |
+
|
| 262 |
+
### Backend Toggle
|
| 263 |
+
|
| 264 |
+
- Same as current: MOCK / GEMINI / CLAUDE radio buttons in the top bar
|
| 265 |
+
- Changing backend affects which orchestration mode is used (single-call vs multi-call)
|
| 266 |
+
|
| 267 |
+
## Refactored File Structure
|
| 268 |
+
|
| 269 |
+
```
|
| 270 |
+
NeuralCAD/
|
| 271 |
+
├── agents/
|
| 272 |
+
│ ├── __init__.py
|
| 273 |
+
│ ├── definitions.py # CrewAI Agent + Task definitions for all 4 agents
|
| 274 |
+
│ ├── orchestrator.py # Single-call JSON orchestrator (Gemini/Mock mode)
|
| 275 |
+
│ ├── crew_orchestrator.py # Multi-call CrewAI hierarchical process (Anthropic/OpenAI)
|
| 276 |
+
│ ├── llm_adapter.py # CrewAI BaseLLM wrapper around LLMBackend
|
| 277 |
+
│ └── prompts.py # Agent system prompts, personas, routing rules
|
| 278 |
+
├── core/
|
| 279 |
+
│ ├── __init__.py
|
| 280 |
+
│ ├── backends.py # LLMBackend base + AnthropicBackend, OpenAIBackend,
|
| 281 |
+
│ │ # GeminiBackend, MockBackend (extracted from pipeline.py)
|
| 282 |
+
│ ├── executor.py # Sandboxed CadQuery execution (from code_executor.py)
|
| 283 |
+
│ ├── validator.py # CNC validation (from cnc_validator.py)
|
| 284 |
+
│ ├── cadquery_prompts.py # CadQuery system prompt + few-shot examples
|
| 285 |
+
│ │ # (from cadquery_system_prompt.py)
|
| 286 |
+
│ └── pipeline.py # run_pipeline() for CAD generation
|
| 287 |
+
│ │ # (simplified, called by CAD Coder agent)
|
| 288 |
+
├── server/
|
| 289 |
+
│ ├── __init__.py
|
| 290 |
+
│ ├── web.py # FastAPI app, static file serving (from web_server.py)
|
| 291 |
+
│ ├── mcp.py # MCP server + tools (from mcp_server.py)
|
| 292 |
+
│ └── routes.py # /api/chat, /api/report endpoints
|
| 293 |
+
├── web/
|
| 294 |
+
│ └── index.html # Complete rewrite: fullscreen 3D viewer + slide-out chat
|
| 295 |
+
├── pyproject.toml # + crewai dependency
|
| 296 |
+
├── Dockerfile # Updated for new structure
|
| 297 |
+
├── docker-compose.yml # Same services, updated paths
|
| 298 |
+
└── entrypoint.sh # Updated entry point
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
### Key Refactoring Notes
|
| 302 |
+
|
| 303 |
+
- `pipeline.py` (922 lines) is split into `core/backends.py` (LLM backends), `core/pipeline.py` (run_pipeline), and `agents/` (orchestration)
|
| 304 |
+
- `code_executor.py` → `core/executor.py` (unchanged logic)
|
| 305 |
+
- `cnc_validator.py` → `core/validator.py` (unchanged logic)
|
| 306 |
+
- `cadquery_system_prompt.py` → `core/cadquery_prompts.py` (unchanged logic)
|
| 307 |
+
- `web_server.py` → `server/web.py` + `server/routes.py`
|
| 308 |
+
- `mcp_server.py` → `server/mcp.py` (add `chat_turn` MCP tool for Claude Desktop)
|
| 309 |
+
- `web/index.html` → complete rewrite
|
| 310 |
+
|
| 311 |
+
## Data Flow: Chat Turn
|
| 312 |
+
|
| 313 |
+
```
|
| 314 |
+
1. User types message in chat UI
|
| 315 |
+
2. Frontend parses @mentions from message text
|
| 316 |
+
3. POST /api/chat { history, message, mentions, backend }
|
| 317 |
+
|
|
| 318 |
+
4. Backend selects orchestration mode:
|
| 319 |
+
├── gemini/mock → Single-call orchestrator
|
| 320 |
+
│ ├── Build system prompt with agent personas + routing rules
|
| 321 |
+
│ ├── Include conversation history (last 30 msgs)
|
| 322 |
+
│ ├── If @mentions: only include mentioned agent personas
|
| 323 |
+
│ ├── LLMBackend.generate(messages) → JSON string
|
| 324 |
+
│ ├── Parse JSON → list of agent responses
|
| 325 |
+
│ └── Fallback: keyword routing + simpler re-call if JSON fails
|
| 326 |
+
│
|
| 327 |
+
└── anthropic/openai → CrewAI hierarchical process
|
| 328 |
+
├── Manager agent routes to relevant agents
|
| 329 |
+
├── Each agent gets own LLM call via NeuralCADLLMAdapter
|
| 330 |
+
└── Collect responses from all activated agents
|
| 331 |
+
|
|
| 332 |
+
5. If CAD Coder agent responded with code:
|
| 333 |
+
├── execute_cadquery(code) → ExecutionResult
|
| 334 |
+
├── export_step() + export_stl() → files in output/
|
| 335 |
+
├── validate_for_cnc() → CNCValidationResult
|
| 336 |
+
└── Build preview object with URLs and metadata
|
| 337 |
+
|
|
| 338 |
+
6. Return JSON response → Frontend renders agent messages
|
| 339 |
+
|
|
| 340 |
+
7. If preview present:
|
| 341 |
+
├── Load STL into Three.js viewer
|
| 342 |
+
├── Show geo stats overlay
|
| 343 |
+
├── Show CNC badge
|
| 344 |
+
└── Enable download buttons
|
| 345 |
+
```
|
| 346 |
+
|
| 347 |
+
## MCP Compatibility
|
| 348 |
+
|
| 349 |
+
Add a new `chat_turn` MCP tool alongside existing tools:
|
| 350 |
+
|
| 351 |
+
```python
|
| 352 |
+
@mcp.tool()
|
| 353 |
+
async def chat_turn(
|
| 354 |
+
message: str,
|
| 355 |
+
history: list[dict] | None = None,
|
| 356 |
+
mentions: list[str] | None = None,
|
| 357 |
+
backend: str = "gemini"
|
| 358 |
+
) -> dict:
|
| 359 |
+
"""Multi-agent chat turn for collaborative CAD design."""
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
Existing MCP tools (`generate_cnc_model`, `validate_cnc_model`, `execute_cadquery_code`, `list_models`) remain unchanged for backward compatibility with Claude Desktop.
|
| 363 |
+
|
| 364 |
+
## Verification Plan
|
| 365 |
+
|
| 366 |
+
### Unit Tests
|
| 367 |
+
- Test single-call orchestrator JSON parsing with valid and malformed responses
|
| 368 |
+
- Test @mention parsing in frontend
|
| 369 |
+
- Test keyword-based fallback routing
|
| 370 |
+
- Test LLM adapter wraps LLMBackend correctly
|
| 371 |
+
|
| 372 |
+
### Integration Tests
|
| 373 |
+
- Full chat turn: user message → agent responses → verify correct agents selected
|
| 374 |
+
- Preview generation: chat with `@cad` → verify STL/STEP files created
|
| 375 |
+
- Backend switching: verify single-call mode for Gemini, multi-call for Anthropic
|
| 376 |
+
- Conversation history truncation at 30 messages
|
| 377 |
+
|
| 378 |
+
### Manual Testing
|
| 379 |
+
- Open web UI, start a conversation about a servo bracket
|
| 380 |
+
- Verify agents respond with appropriate expertise
|
| 381 |
+
- Use @mentions to direct messages to specific agents
|
| 382 |
+
- Click preview button → verify 3D model loads
|
| 383 |
+
- Download STEP and STL files
|
| 384 |
+
- Collapse/expand chat panel
|
| 385 |
+
- Test with Gemini free tier (rate limit behavior)
|
| 386 |
+
- Test quick example conversation starters
|
| 387 |
+
|
| 388 |
+
### MCP Testing
|
| 389 |
+
- Call `chat_turn` tool from Claude Desktop
|
| 390 |
+
- Verify existing MCP tools still work
|
docs/superpowers/specs/2026-04-08-uv-docker-deploy-design.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuralCAD — uv, Docker, and HF Spaces Deployment
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Set up astral uv as the Python package manager, containerize with Docker, and deploy the full pipeline (web server + MCP CAD server) to Hugging Face Spaces for a free investor demo.
|
| 6 |
+
|
| 7 |
+
## 1. uv Setup
|
| 8 |
+
|
| 9 |
+
### pyproject.toml
|
| 10 |
+
|
| 11 |
+
Replace `requirements.txt` with `pyproject.toml` as the single source of truth for dependencies:
|
| 12 |
+
|
| 13 |
+
```toml
|
| 14 |
+
[project]
|
| 15 |
+
name = "neuralcad"
|
| 16 |
+
version = "1.0.0"
|
| 17 |
+
description = "Text-to-CNC pipeline: natural language to machinable 3D models"
|
| 18 |
+
requires-python = ">=3.10"
|
| 19 |
+
dependencies = [
|
| 20 |
+
"cadquery>=2.7.0",
|
| 21 |
+
"cadquery-ocp>=7.8.0",
|
| 22 |
+
"numpy>=1.24.0",
|
| 23 |
+
"trimesh>=4.0.0",
|
| 24 |
+
"anthropic>=0.25.0",
|
| 25 |
+
"openai>=1.30.0",
|
| 26 |
+
"mcp>=1.0.0",
|
| 27 |
+
"fastapi>=0.110.0",
|
| 28 |
+
"uvicorn>=0.29.0",
|
| 29 |
+
"python-multipart>=0.0.9",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
[dependency-groups]
|
| 33 |
+
dev = ["ruff", "pytest"]
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Lockfile
|
| 37 |
+
|
| 38 |
+
Run `uv lock` to generate `uv.lock` for reproducible installs. Commit `uv.lock` to git.
|
| 39 |
+
|
| 40 |
+
### Workflow
|
| 41 |
+
|
| 42 |
+
- `uv sync` — install all dependencies
|
| 43 |
+
- `uv run python web_server.py` — run web server
|
| 44 |
+
- `uv run python mcp_server.py --transport sse` — run MCP server
|
| 45 |
+
- `uv sync --group dev` — install dev tools
|
| 46 |
+
|
| 47 |
+
### Migration
|
| 48 |
+
|
| 49 |
+
- `requirements.txt` is kept for backward compatibility but marked with a comment pointing to `pyproject.toml` as the source of truth.
|
| 50 |
+
|
| 51 |
+
## 2. Docker
|
| 52 |
+
|
| 53 |
+
### Dockerfile (multi-stage)
|
| 54 |
+
|
| 55 |
+
**Stage 1: builder**
|
| 56 |
+
- Base: `python:3.11-slim`
|
| 57 |
+
- Install uv via the official installer
|
| 58 |
+
- Copy `pyproject.toml` + `uv.lock`
|
| 59 |
+
- Run `uv sync --frozen --no-dev` to install dependencies into `.venv`
|
| 60 |
+
- This stage is cached — only rebuilds when dependencies change
|
| 61 |
+
|
| 62 |
+
**Stage 2: runtime**
|
| 63 |
+
- Base: `python:3.11-slim`
|
| 64 |
+
- Copy `.venv` from builder (contains all installed packages)
|
| 65 |
+
- Copy application source code
|
| 66 |
+
- Set `PATH` to include `.venv/bin`
|
| 67 |
+
- Expose port 7860 (HF Spaces convention)
|
| 68 |
+
- Run `entrypoint.sh`
|
| 69 |
+
|
| 70 |
+
### entrypoint.sh
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
#!/bin/bash
|
| 74 |
+
# Start MCP CAD server in background
|
| 75 |
+
python mcp_server.py --transport sse --port 8000 &
|
| 76 |
+
|
| 77 |
+
# Wait for MCP server to be ready
|
| 78 |
+
sleep 3
|
| 79 |
+
|
| 80 |
+
# Start web server in foreground on HF Spaces port
|
| 81 |
+
export MCP_SERVER_URL=http://localhost:8000/sse
|
| 82 |
+
exec python web_server.py --host 0.0.0.0 --port ${PORT:-7860}
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### docker-compose.yml (local dev)
|
| 86 |
+
|
| 87 |
+
Two services for development:
|
| 88 |
+
|
| 89 |
+
```yaml
|
| 90 |
+
services:
|
| 91 |
+
mcp-server:
|
| 92 |
+
build: .
|
| 93 |
+
command: python mcp_server.py --transport sse --port 8000
|
| 94 |
+
ports:
|
| 95 |
+
- "8000:8000"
|
| 96 |
+
volumes:
|
| 97 |
+
- ./output:/app/output
|
| 98 |
+
|
| 99 |
+
web:
|
| 100 |
+
build: .
|
| 101 |
+
command: python web_server.py --host 0.0.0.0 --port 5000
|
| 102 |
+
ports:
|
| 103 |
+
- "5000:5000"
|
| 104 |
+
environment:
|
| 105 |
+
MCP_SERVER_URL: http://mcp-server:8000/sse
|
| 106 |
+
depends_on:
|
| 107 |
+
- mcp-server
|
| 108 |
+
volumes:
|
| 109 |
+
- ./output:/app/output
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### .dockerignore
|
| 113 |
+
|
| 114 |
+
```
|
| 115 |
+
.git
|
| 116 |
+
.gitignore
|
| 117 |
+
__pycache__
|
| 118 |
+
*.pyc
|
| 119 |
+
output/
|
| 120 |
+
.superpowers/
|
| 121 |
+
docs/
|
| 122 |
+
*.md
|
| 123 |
+
.env
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
## 3. Hugging Face Spaces Deployment
|
| 127 |
+
|
| 128 |
+
### Space Configuration
|
| 129 |
+
|
| 130 |
+
HF Spaces reads metadata from a YAML header in `README.md`:
|
| 131 |
+
|
| 132 |
+
```yaml
|
| 133 |
+
---
|
| 134 |
+
title: NeuralCAD
|
| 135 |
+
emoji: ⚙️
|
| 136 |
+
colorFrom: blue
|
| 137 |
+
colorTo: cyan
|
| 138 |
+
sdk: docker
|
| 139 |
+
app_port: 7860
|
| 140 |
+
---
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
This tells HF to build the Dockerfile and route traffic to port 7860.
|
| 144 |
+
|
| 145 |
+
### How it works
|
| 146 |
+
|
| 147 |
+
1. Push repo to HF (or link GitHub repo)
|
| 148 |
+
2. HF builds the Docker image
|
| 149 |
+
3. Container starts: `entrypoint.sh` launches MCP + web servers
|
| 150 |
+
4. Public URL: `https://callmedaniel-neuralcad.hf.space`
|
| 151 |
+
|
| 152 |
+
### Free tier constraints
|
| 153 |
+
|
| 154 |
+
- 16GB RAM, 2 vCPU (sufficient for CadQuery)
|
| 155 |
+
- 50GB disk (sufficient for OpenCascade)
|
| 156 |
+
- Sleeps after ~15min inactivity
|
| 157 |
+
- Wakes on HTTP request (~30s cold start for CadQuery container)
|
| 158 |
+
- No persistent storage across rebuilds (output/ is ephemeral)
|
| 159 |
+
|
| 160 |
+
### Environment variables
|
| 161 |
+
|
| 162 |
+
For live LLM generation (optional), set as HF Space secrets:
|
| 163 |
+
- `ANTHROPIC_API_KEY` — enables Claude backend
|
| 164 |
+
- `OPENAI_API_KEY` — enables GPT-4o backend
|
| 165 |
+
|
| 166 |
+
Mock backend always works without API keys.
|
| 167 |
+
|
| 168 |
+
## New/Modified Files
|
| 169 |
+
|
| 170 |
+
| File | Action | Purpose |
|
| 171 |
+
|------|--------|---------|
|
| 172 |
+
| `pyproject.toml` | Create | Project metadata + dependencies for uv |
|
| 173 |
+
| `uv.lock` | Generate | Lockfile (via `uv lock`) |
|
| 174 |
+
| `Dockerfile` | Create | Multi-stage production build |
|
| 175 |
+
| `docker-compose.yml` | Create | Local dev with two services |
|
| 176 |
+
| `.dockerignore` | Create | Exclude files from Docker build |
|
| 177 |
+
| `entrypoint.sh` | Create | Container startup (MCP bg + web fg) |
|
| 178 |
+
| `README.md` | Modify | Add HF Spaces YAML header |
|
| 179 |
+
| `.gitignore` | Modify | Add `.venv/`, `uv.lock` pattern notes |
|
| 180 |
+
| `requirements.txt` | Modify | Add comment pointing to pyproject.toml |
|
| 181 |
+
|
| 182 |
+
## Verification
|
| 183 |
+
|
| 184 |
+
1. **uv**: `uv sync && uv run python -c "import cadquery; print('ok')"` — deps install and CadQuery loads
|
| 185 |
+
2. **Docker local**: `docker compose up --build` → open http://localhost:5000 → click quick example → 3D model renders
|
| 186 |
+
3. **Docker single**: `docker build -t neuralcad . && docker run -p 7860:7860 neuralcad` → open http://localhost:7860
|
| 187 |
+
4. **HF Spaces**: push to HF repo → Space builds → open public URL → demo works
|
docs/superpowers/specs/2026-04-08-web-demo-design.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NeuralCAD Web Demo — Design Spec
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
A web-based investor demo for the NeuralCAD text-to-CNC pipeline. Users type a part description, the system generates CadQuery code via an LLM, executes it, validates for CNC manufacturability, and displays the resulting 3D model in an interactive viewer — all in the browser.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
Browser (index.html)
|
| 11 |
+
│ fetch() REST
|
| 12 |
+
▼
|
| 13 |
+
FastAPI web server (web_server.py, port 5000)
|
| 14 |
+
│ MCP SSE client
|
| 15 |
+
▼
|
| 16 |
+
MCP CAD server (mcp_server.py --transport sse, port 8000)
|
| 17 |
+
│ Python imports
|
| 18 |
+
▼
|
| 19 |
+
Pipeline (pipeline.py → code_executor → cnc_validator)
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
**Two separate processes:**
|
| 23 |
+
1. `mcp_server.py --transport sse --port 8000` — the CAD engine
|
| 24 |
+
2. `web_server.py` — FastAPI server that proxies browser requests to the MCP server and serves the frontend
|
| 25 |
+
|
| 26 |
+
The web server uses the `mcp` Python SDK's SSE client to call MCP tools on the CAD server. This decouples the web layer from the CAD environment (CadQuery/OpenCascade).
|
| 27 |
+
|
| 28 |
+
## New Files
|
| 29 |
+
|
| 30 |
+
| File | Purpose |
|
| 31 |
+
|------|---------|
|
| 32 |
+
| `web_server.py` | FastAPI app: REST endpoints, MCP SSE client, static file serving |
|
| 33 |
+
| `web/index.html` | Single-file frontend: Tailwind CDN + Three.js CDN + vanilla JS |
|
| 34 |
+
|
| 35 |
+
No changes to existing pipeline files.
|
| 36 |
+
|
| 37 |
+
## API Endpoints (web_server.py)
|
| 38 |
+
|
| 39 |
+
| Method | Path | Description |
|
| 40 |
+
|--------|------|-------------|
|
| 41 |
+
| `GET /` | Serves `web/index.html` | |
|
| 42 |
+
| `POST /api/generate` | `{ prompt, part_name?, backend? }` → calls MCP `generate_cnc_model` tool → returns JSON result | |
|
| 43 |
+
| `POST /api/generate-image` | `{ image (multipart), text_hint?, part_name?, backend? }` → calls MCP `generate_from_image` tool → returns JSON result | |
|
| 44 |
+
| `POST /api/validate` | `{ code, part_name? }` → calls MCP `validate_cnc_model` tool → returns JSON result | |
|
| 45 |
+
| `GET /api/models` | Calls MCP `list_models` tool → returns JSON list | |
|
| 46 |
+
| `GET /api/models/{name}.stl` | Serves the STL file from `output/` directory so Three.js can load it | |
|
| 47 |
+
| `GET /api/models/{name}.step` | Serves the STEP file for download | |
|
| 48 |
+
| `GET /api/capabilities` | Reads MCP `text-to-cnc://capabilities` resource → returns available backends | |
|
| 49 |
+
|
| 50 |
+
### MCP Client Integration
|
| 51 |
+
|
| 52 |
+
The web server connects to the MCP SSE server at startup. Each API endpoint translates the REST request into an MCP `call_tool` invocation:
|
| 53 |
+
|
| 54 |
+
```python
|
| 55 |
+
# Pseudocode
|
| 56 |
+
async def generate(request):
|
| 57 |
+
result = await mcp_client.call_tool("generate_cnc_model", {
|
| 58 |
+
"prompt": request.prompt,
|
| 59 |
+
"backend": request.backend or "mock",
|
| 60 |
+
"part_name": request.part_name or "",
|
| 61 |
+
})
|
| 62 |
+
return JSONResponse(json.loads(result))
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
### Configuration
|
| 66 |
+
|
| 67 |
+
- `MCP_SERVER_URL` env var (default: `http://localhost:8000/sse`) — MCP SSE endpoint
|
| 68 |
+
- The MCP server URL is configurable so the CAD server can run on a different host
|
| 69 |
+
|
| 70 |
+
## Frontend (web/index.html)
|
| 71 |
+
|
| 72 |
+
### Layout: Stacked Hero
|
| 73 |
+
|
| 74 |
+
Single HTML file, no build step. Dependencies via CDN:
|
| 75 |
+
- Tailwind CSS (styling)
|
| 76 |
+
- Three.js + STLLoader + OrbitControls (3D viewer)
|
| 77 |
+
- highlight.js (code syntax highlighting, optional)
|
| 78 |
+
|
| 79 |
+
### Page Structure
|
| 80 |
+
|
| 81 |
+
1. **Top Bar** — NeuralCAD logo, backend toggle (Mock / API), version
|
| 82 |
+
2. **Hero 3D Viewer** (~60% viewport height)
|
| 83 |
+
- Three.js scene with dark background and subtle grid
|
| 84 |
+
- OrbitControls for rotate/zoom/pan
|
| 85 |
+
- Geometry stats overlay (volume, faces, edges, bounding box)
|
| 86 |
+
- CNC status badge (machinable/not, axis recommendation)
|
| 87 |
+
- STEP/STL download buttons
|
| 88 |
+
3. **Bottom Tabbed Panel** (~40% viewport height)
|
| 89 |
+
- **Generate tab**: text input, "Generate Model" button, image upload button, quick example buttons (bracket, gear, default)
|
| 90 |
+
- **Code tab**: syntax-highlighted CadQuery code output
|
| 91 |
+
- **Validation tab**: CNC manufacturability report (issues list, severity icons)
|
| 92 |
+
- **Gallery tab**: previously generated models (click to load in viewer)
|
| 93 |
+
|
| 94 |
+
### Interaction Flow
|
| 95 |
+
|
| 96 |
+
1. User types a part description (or clicks a quick example)
|
| 97 |
+
2. Clicks "Generate Model"
|
| 98 |
+
3. Frontend shows loading state (spinner in 3D viewer, "Generating..." on button)
|
| 99 |
+
4. `POST /api/generate` with prompt + selected backend
|
| 100 |
+
5. Response arrives with: generated_code, execution results, validation, exported_files
|
| 101 |
+
6. Frontend updates all panels:
|
| 102 |
+
- 3D viewer loads STL from `/api/models/{name}.stl`
|
| 103 |
+
- Code tab shows generated CadQuery code
|
| 104 |
+
- Validation tab shows CNC report
|
| 105 |
+
- Geometry overlay updates with volume/faces/edges
|
| 106 |
+
- Download buttons activate with STEP/STL links
|
| 107 |
+
7. Model is added to Gallery tab
|
| 108 |
+
|
| 109 |
+
### Image Upload Flow
|
| 110 |
+
|
| 111 |
+
1. User clicks the image/camera button
|
| 112 |
+
2. File picker opens (accept: image/*)
|
| 113 |
+
3. Selected image is sent as multipart form to `POST /api/generate-image`
|
| 114 |
+
4. Same response handling as text generation
|
| 115 |
+
|
| 116 |
+
### Quick Examples
|
| 117 |
+
|
| 118 |
+
Pre-defined prompts that demonstrate the system reliably (using mock backend):
|
| 119 |
+
- "A mounting bracket with four M6 bolt holes" → bracket mock
|
| 120 |
+
- "A spur gear with 20 teeth" → gear mock
|
| 121 |
+
- "A parametric box with holes and fillets" → default mock
|
| 122 |
+
|
| 123 |
+
### Theming
|
| 124 |
+
|
| 125 |
+
Dark theme (matches the CAD/engineering aesthetic):
|
| 126 |
+
- Background: near-black (#0a0a1a)
|
| 127 |
+
- Panels: dark navy (#12122a)
|
| 128 |
+
- Accent: blue (#3b82f6)
|
| 129 |
+
- Success: green (#4ade80)
|
| 130 |
+
- Text: light gray (#e2e8f0) / muted (#8b8ba7)
|
| 131 |
+
|
| 132 |
+
### 3D Viewer Details
|
| 133 |
+
|
| 134 |
+
- **Renderer**: Three.js WebGLRenderer with antialiasing
|
| 135 |
+
- **Loader**: STLLoader fetches from `/api/models/{name}.stl`
|
| 136 |
+
- **Material**: MeshPhongMaterial with blue-gray tone, slight metallic feel
|
| 137 |
+
- **Lighting**: Ambient + two directional lights for good depth perception
|
| 138 |
+
- **Controls**: OrbitControls (drag = rotate, scroll = zoom, right-drag = pan)
|
| 139 |
+
- **Camera**: Auto-fit to bounding box after model load
|
| 140 |
+
- **Background**: Dark gradient with faint grid overlay
|
| 141 |
+
|
| 142 |
+
## Startup
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
# Terminal 1: Start the CAD server
|
| 146 |
+
python mcp_server.py --transport sse --port 8000
|
| 147 |
+
|
| 148 |
+
# Terminal 2: Start the web server
|
| 149 |
+
python web_server.py
|
| 150 |
+
# → Serving at http://localhost:5000
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
Or a convenience script:
|
| 154 |
+
```bash
|
| 155 |
+
python web_server.py --start-mcp
|
| 156 |
+
# Launches MCP server as subprocess, then starts FastAPI
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
## Dependencies (additions to requirements.txt)
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
fastapi>=0.110.0
|
| 163 |
+
uvicorn>=0.29.0
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
The `mcp` package (already in requirements.txt) includes the SSE client via `httpx` and `httpx-sse` as transitive dependencies. No additional SSE libraries needed.
|
| 167 |
+
|
| 168 |
+
## Verification
|
| 169 |
+
|
| 170 |
+
1. Start MCP server: `python mcp_server.py --transport sse --port 8000`
|
| 171 |
+
2. Start web server: `python web_server.py`
|
| 172 |
+
3. Open `http://localhost:5000` in browser
|
| 173 |
+
4. Click "Mounting bracket" quick example → 3D model appears in viewer
|
| 174 |
+
5. Switch to Code tab → CadQuery code is displayed
|
| 175 |
+
6. Switch to Validation tab → CNC report shows "Machinable"
|
| 176 |
+
7. Click STEP/STL download buttons → files download
|
| 177 |
+
8. Toggle backend to "API", type a custom prompt, click Generate → live LLM generation works
|
| 178 |
+
9. Upload an image → image-to-model flow works
|
entrypoint.sh
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -e
|
| 3 |
+
|
| 4 |
+
echo "=== NeuralCAD Container Starting ==="
|
| 5 |
+
|
| 6 |
+
# Start MCP CAD server in background
|
| 7 |
+
echo "Starting MCP CAD server on port 8000..."
|
| 8 |
+
python -m server.mcp --transport sse --port 8000 &
|
| 9 |
+
MCP_PID=$!
|
| 10 |
+
|
| 11 |
+
# Wait for MCP server to be ready
|
| 12 |
+
sleep 3
|
| 13 |
+
|
| 14 |
+
if ! kill -0 $MCP_PID 2>/dev/null; then
|
| 15 |
+
echo "ERROR: MCP server failed to start"
|
| 16 |
+
exit 1
|
| 17 |
+
fi
|
| 18 |
+
|
| 19 |
+
echo "MCP server running (PID $MCP_PID)"
|
| 20 |
+
|
| 21 |
+
# Start web server in foreground
|
| 22 |
+
export MCP_SERVER_URL=http://localhost:8000/sse
|
| 23 |
+
PORT=${PORT:-7860}
|
| 24 |
+
echo "Starting web server on port $PORT..."
|
| 25 |
+
exec python -m server.web --host 0.0.0.0 --port "$PORT"
|
pyproject.toml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "neuralcad"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "Text-to-CNC pipeline: natural language to machinable 3D models"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"cadquery>=2.7.0",
|
| 8 |
+
"cadquery-ocp>=7.8.0",
|
| 9 |
+
"numpy>=1.24.0",
|
| 10 |
+
"trimesh>=4.0.0",
|
| 11 |
+
"anthropic>=0.25.0",
|
| 12 |
+
"openai>=1.30.0",
|
| 13 |
+
"google-genai>=1.0.0",
|
| 14 |
+
"crewai>=0.100.0",
|
| 15 |
+
"mcp>=1.0.0",
|
| 16 |
+
"fastapi>=0.110.0",
|
| 17 |
+
"uvicorn>=0.29.0",
|
| 18 |
+
"python-multipart>=0.0.9",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
[dependency-groups]
|
| 22 |
+
dev = ["ruff", "pytest"]
|
| 23 |
+
|
| 24 |
+
[tool.pytest.ini_options]
|
| 25 |
+
testpaths = ["tests"]
|
| 26 |
+
pythonpath = ["."]
|
| 27 |
+
markers = [
|
| 28 |
+
"requires_cadquery: marks tests that need CadQuery installed",
|
| 29 |
+
]
|
requirements.txt
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
|
|
|
|
|
| 1 |
cadquery>=2.7.0
|
| 2 |
cadquery-ocp>=7.8.0
|
| 3 |
numpy>=1.24.0
|
| 4 |
trimesh>=4.0.0
|
| 5 |
anthropic>=0.25.0
|
| 6 |
openai>=1.30.0
|
|
|
|
| 7 |
mcp>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependency source of truth is pyproject.toml — use `uv sync` to install.
|
| 2 |
+
# This file is kept for environments that don't use uv.
|
| 3 |
cadquery>=2.7.0
|
| 4 |
cadquery-ocp>=7.8.0
|
| 5 |
numpy>=1.24.0
|
| 6 |
trimesh>=4.0.0
|
| 7 |
anthropic>=0.25.0
|
| 8 |
openai>=1.30.0
|
| 9 |
+
google-genai>=1.0.0
|
| 10 |
mcp>=1.0.0
|
| 11 |
+
fastapi>=0.110.0
|
| 12 |
+
uvicorn>=0.29.0
|
| 13 |
+
python-multipart>=0.0.9
|
server/__init__.py
ADDED
|
File without changes
|
mcp_server.py → server/mcp.py
RENAMED
|
@@ -11,8 +11,8 @@ Tools:
|
|
| 11 |
- list_models: List previously generated models in the output dir
|
| 12 |
|
| 13 |
Usage:
|
| 14 |
-
python
|
| 15 |
-
python
|
| 16 |
"""
|
| 17 |
|
| 18 |
import json
|
|
@@ -22,12 +22,9 @@ from pathlib import Path
|
|
| 22 |
|
| 23 |
from mcp.server.fastmcp import FastMCP
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
from cadquery_system_prompt import build_messages, CADQUERY_SYSTEM_PROMPT
|
| 29 |
-
from code_executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
|
| 30 |
-
from cnc_validator import validate_for_cnc, CNCValidationResult
|
| 31 |
|
| 32 |
# ── Server Setup ──────────────────────────────────────────────────────────
|
| 33 |
|
|
@@ -40,7 +37,7 @@ mcp = FastMCP(
|
|
| 40 |
),
|
| 41 |
)
|
| 42 |
|
| 43 |
-
DEFAULT_OUTPUT_DIR = Path(__file__).parent / "output"
|
| 44 |
DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
|
| 45 |
|
| 46 |
|
|
@@ -48,12 +45,16 @@ DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
|
|
| 48 |
|
| 49 |
def get_backend(backend_name: str = "mock"):
|
| 50 |
"""Get the appropriate LLM backend."""
|
| 51 |
-
from
|
| 52 |
|
| 53 |
-
if backend_name == "
|
|
|
|
|
|
|
| 54 |
return AnthropicBackend()
|
| 55 |
elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
|
| 56 |
return OpenAIBackend()
|
|
|
|
|
|
|
| 57 |
else:
|
| 58 |
return MockBackend()
|
| 59 |
|
|
@@ -91,7 +92,7 @@ def generate_cnc_model(
|
|
| 91 |
- validation: CNC manufacturability analysis
|
| 92 |
- exported_files: Paths to generated STEP/STL files
|
| 93 |
"""
|
| 94 |
-
from pipeline import run_pipeline
|
| 95 |
|
| 96 |
if not part_name:
|
| 97 |
part_name = prompt[:40].strip().replace(" ", "_").lower()
|
|
@@ -287,6 +288,165 @@ def list_models(output_dir: str = "") -> str:
|
|
| 287 |
}, indent=2)
|
| 288 |
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
# ── Resource: System prompt (for transparency) ───────────────────────────
|
| 291 |
|
| 292 |
@mcp.resource("text-to-cnc://system-prompt")
|
|
@@ -298,11 +458,13 @@ def get_system_prompt() -> str:
|
|
| 298 |
@mcp.resource("text-to-cnc://capabilities")
|
| 299 |
def get_capabilities() -> str:
|
| 300 |
"""Server capabilities and configuration."""
|
| 301 |
-
backends = ["mock (always available)"]
|
| 302 |
if os.environ.get("ANTHROPIC_API_KEY"):
|
| 303 |
backends.append("anthropic (API key detected)")
|
| 304 |
if os.environ.get("OPENAI_API_KEY"):
|
| 305 |
backends.append("openai (API key detected)")
|
|
|
|
|
|
|
| 306 |
|
| 307 |
return json.dumps({
|
| 308 |
"name": "text-to-cnc",
|
|
|
|
| 11 |
- list_models: List previously generated models in the output dir
|
| 12 |
|
| 13 |
Usage:
|
| 14 |
+
python -m server.mcp # stdio transport (default)
|
| 15 |
+
python -m server.mcp --transport sse # SSE transport on port 8000
|
| 16 |
"""
|
| 17 |
|
| 18 |
import json
|
|
|
|
| 22 |
|
| 23 |
from mcp.server.fastmcp import FastMCP
|
| 24 |
|
| 25 |
+
from core.cadquery_prompts import build_messages, CADQUERY_SYSTEM_PROMPT
|
| 26 |
+
from core.executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
|
| 27 |
+
from core.validator import validate_for_cnc, CNCValidationResult
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
# ── Server Setup ──────────────────────────────────────────────────────────
|
| 30 |
|
|
|
|
| 37 |
),
|
| 38 |
)
|
| 39 |
|
| 40 |
+
DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 41 |
DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
|
| 42 |
|
| 43 |
|
|
|
|
| 45 |
|
| 46 |
def get_backend(backend_name: str = "mock"):
|
| 47 |
"""Get the appropriate LLM backend."""
|
| 48 |
+
from core.backends import MockBackend, AnthropicBackend, OpenAIBackend, GeminiBackend, NeuralCADBackend
|
| 49 |
|
| 50 |
+
if backend_name == "neural":
|
| 51 |
+
return NeuralCADBackend()
|
| 52 |
+
elif backend_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
|
| 53 |
return AnthropicBackend()
|
| 54 |
elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
|
| 55 |
return OpenAIBackend()
|
| 56 |
+
elif backend_name == "gemini" and os.environ.get("GEMINI_API_KEY"):
|
| 57 |
+
return GeminiBackend()
|
| 58 |
else:
|
| 59 |
return MockBackend()
|
| 60 |
|
|
|
|
| 92 |
- validation: CNC manufacturability analysis
|
| 93 |
- exported_files: Paths to generated STEP/STL files
|
| 94 |
"""
|
| 95 |
+
from core.pipeline import run_pipeline
|
| 96 |
|
| 97 |
if not part_name:
|
| 98 |
part_name = prompt[:40].strip().replace(" ", "_").lower()
|
|
|
|
| 288 |
}, indent=2)
|
| 289 |
|
| 290 |
|
| 291 |
+
# ── Tool: generate_from_image ───────────────────────────────────────────
|
| 292 |
+
|
| 293 |
+
@mcp.tool()
|
| 294 |
+
def generate_from_image(
|
| 295 |
+
image_path: str,
|
| 296 |
+
text_hint: str = "",
|
| 297 |
+
part_name: str = "",
|
| 298 |
+
backend: str = "anthropic",
|
| 299 |
+
max_retries: int = 2,
|
| 300 |
+
) -> str:
|
| 301 |
+
"""
|
| 302 |
+
Generate a CNC-machinable 3D model from a photo or sketch image.
|
| 303 |
+
|
| 304 |
+
Sends the image to a vision-capable LLM (Claude or GPT-4o) along with
|
| 305 |
+
the CadQuery system prompt to generate code, then executes, validates,
|
| 306 |
+
and exports the result.
|
| 307 |
+
|
| 308 |
+
Args:
|
| 309 |
+
image_path: Path to an image file (photo, sketch, or CAD screenshot).
|
| 310 |
+
text_hint: Optional text to guide generation alongside the image.
|
| 311 |
+
Example: "This is a mounting bracket — add M6 bolt holes"
|
| 312 |
+
part_name: Optional name for the part (used in filenames).
|
| 313 |
+
backend: LLM backend: "anthropic" or "openai". Must support vision.
|
| 314 |
+
max_retries: Number of retry attempts if code execution fails (0-3).
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
JSON string with generation results including generated code,
|
| 318 |
+
execution status, validation, and exported file paths.
|
| 319 |
+
"""
|
| 320 |
+
if not Path(image_path).exists():
|
| 321 |
+
return json.dumps({"success": False, "error": f"Image not found: {image_path}"})
|
| 322 |
+
|
| 323 |
+
if not part_name:
|
| 324 |
+
part_name = Path(image_path).stem
|
| 325 |
+
|
| 326 |
+
llm_backend = get_backend(backend)
|
| 327 |
+
|
| 328 |
+
# Build prompt with optional text hint
|
| 329 |
+
prompt = "Generate CadQuery code for the mechanical part shown in this image."
|
| 330 |
+
if text_hint:
|
| 331 |
+
prompt += f"\n\nAdditional context: {text_hint}"
|
| 332 |
+
|
| 333 |
+
messages = build_messages(prompt)
|
| 334 |
+
|
| 335 |
+
# Use vision-capable generate_with_image
|
| 336 |
+
generated_code = llm_backend.generate_with_image(messages, image_path)
|
| 337 |
+
|
| 338 |
+
# Run through standard execution/validation/export
|
| 339 |
+
exec_result = execute_cadquery(generated_code)
|
| 340 |
+
retry_count = 0
|
| 341 |
+
|
| 342 |
+
while not exec_result.success and retry_count < min(max_retries, 3):
|
| 343 |
+
retry_count += 1
|
| 344 |
+
error_feedback = (
|
| 345 |
+
f"The previous code failed with this error:\n"
|
| 346 |
+
f"```\n{exec_result.error}\n```\n\n"
|
| 347 |
+
f"Please fix the code and return only the corrected Python code."
|
| 348 |
+
)
|
| 349 |
+
retry_messages = build_messages(error_feedback)
|
| 350 |
+
generated_code = llm_backend.generate_with_image(retry_messages, image_path)
|
| 351 |
+
exec_result = execute_cadquery(generated_code)
|
| 352 |
+
|
| 353 |
+
response = {
|
| 354 |
+
"success": exec_result.success,
|
| 355 |
+
"image_path": image_path,
|
| 356 |
+
"text_hint": text_hint,
|
| 357 |
+
"part_name": part_name,
|
| 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:
|
| 386 |
+
exported = export_all(exec_result.result, base_path)
|
| 387 |
+
response["exported_files"] = {fmt: str(p) for fmt, p in exported.items()}
|
| 388 |
+
except Exception as e:
|
| 389 |
+
response["export_error"] = str(e)
|
| 390 |
+
|
| 391 |
+
return json.dumps(response, indent=2)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ── Tool: chat_turn ─────────────────────────────────────────────────────
|
| 395 |
+
|
| 396 |
+
@mcp.tool()
|
| 397 |
+
def chat_turn(
|
| 398 |
+
message: str,
|
| 399 |
+
history: str = "[]",
|
| 400 |
+
mentions: str = "[]",
|
| 401 |
+
backend: str = "mock",
|
| 402 |
+
) -> str:
|
| 403 |
+
"""
|
| 404 |
+
Multi-agent chat turn for collaborative CAD design.
|
| 405 |
+
|
| 406 |
+
Send a message to the design team agents (Design, Engineering, CNC, CAD Coder).
|
| 407 |
+
Agents collaborate to help you design a mechanical part step by step.
|
| 408 |
+
|
| 409 |
+
Args:
|
| 410 |
+
message: Your message to the design team.
|
| 411 |
+
Use @design, @engineering, @cnc, or @cad to address specific agents.
|
| 412 |
+
history: JSON string of previous messages. Format:
|
| 413 |
+
[{"role": "user"|"agent", "agent_id": "design", "content": "..."}]
|
| 414 |
+
mentions: JSON string of agent IDs to address. Format: ["design", "engineering"]
|
| 415 |
+
Empty list = auto-route based on message content.
|
| 416 |
+
backend: LLM backend: "mock", "gemini", "anthropic", "openai".
|
| 417 |
+
|
| 418 |
+
Returns:
|
| 419 |
+
JSON string with agent responses and optional 3D preview data.
|
| 420 |
+
"""
|
| 421 |
+
import json as json_mod
|
| 422 |
+
|
| 423 |
+
from agents.orchestrator import get_orchestrator
|
| 424 |
+
from agents.crew_orchestrator import CrewOrchestrator
|
| 425 |
+
from agents.prompts import parse_mentions
|
| 426 |
+
|
| 427 |
+
history_list = json_mod.loads(history) if isinstance(history, str) else history
|
| 428 |
+
mentions_list = json_mod.loads(mentions) if isinstance(mentions, str) else mentions
|
| 429 |
+
|
| 430 |
+
# Parse @mentions from message if not provided
|
| 431 |
+
if not mentions_list:
|
| 432 |
+
message, mentions_list = parse_mentions(message)
|
| 433 |
+
|
| 434 |
+
mentions_or_none = mentions_list if mentions_list else None
|
| 435 |
+
|
| 436 |
+
if backend in ("anthropic", "openai"):
|
| 437 |
+
orchestrator = CrewOrchestrator(backend_name=backend, output_dir=DEFAULT_OUTPUT_DIR)
|
| 438 |
+
else:
|
| 439 |
+
orchestrator = get_orchestrator(backend, output_dir=DEFAULT_OUTPUT_DIR)
|
| 440 |
+
|
| 441 |
+
result = orchestrator.chat_turn(
|
| 442 |
+
message=message,
|
| 443 |
+
history=history_list,
|
| 444 |
+
mentions=mentions_or_none,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
return json_mod.dumps(result, indent=2)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
# ── Resource: System prompt (for transparency) ───────────────────────────
|
| 451 |
|
| 452 |
@mcp.resource("text-to-cnc://system-prompt")
|
|
|
|
| 458 |
@mcp.resource("text-to-cnc://capabilities")
|
| 459 |
def get_capabilities() -> str:
|
| 460 |
"""Server capabilities and configuration."""
|
| 461 |
+
backends = ["mock (always available)", "neural (local models — requires trained weights)"]
|
| 462 |
if os.environ.get("ANTHROPIC_API_KEY"):
|
| 463 |
backends.append("anthropic (API key detected)")
|
| 464 |
if os.environ.get("OPENAI_API_KEY"):
|
| 465 |
backends.append("openai (API key detected)")
|
| 466 |
+
if os.environ.get("GEMINI_API_KEY"):
|
| 467 |
+
backends.append("gemini (API key detected)")
|
| 468 |
|
| 469 |
return json.dumps({
|
| 470 |
"name": "text-to-cnc",
|
server/routes.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat API routes for multi-agent design conversation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from agents.orchestrator import get_orchestrator
|
| 13 |
+
from agents.crew_orchestrator import CrewOrchestrator
|
| 14 |
+
from agents.prompts import parse_mentions
|
| 15 |
+
from agents.definitions import AGENTS
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ── Request / response models ─────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ChatMessage(BaseModel):
|
| 26 |
+
role: str # "user" or "agent"
|
| 27 |
+
agent_id: str = ""
|
| 28 |
+
content: str = ""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ChatRequest(BaseModel):
|
| 32 |
+
message: str = Field(..., min_length=1)
|
| 33 |
+
history: list[ChatMessage] = Field(default_factory=list)
|
| 34 |
+
mentions: list[str] = Field(default_factory=list)
|
| 35 |
+
backend: str = "mock"
|
| 36 |
+
design_state: dict = Field(default_factory=dict)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ReportRequest(BaseModel):
|
| 40 |
+
part_name: str = "part"
|
| 41 |
+
history: list[ChatMessage] = Field(default_factory=list)
|
| 42 |
+
backend: str = "mock"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Endpoints ──────────────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.post("/api/chat")
|
| 49 |
+
async def chat(body: ChatRequest):
|
| 50 |
+
"""Multi-agent chat turn."""
|
| 51 |
+
message = body.message.strip()
|
| 52 |
+
|
| 53 |
+
# Convert validated models back to dicts for the orchestrator
|
| 54 |
+
history = [m.model_dump() for m in body.history]
|
| 55 |
+
backend_name = body.backend
|
| 56 |
+
|
| 57 |
+
# Parse @mentions from message if not provided
|
| 58 |
+
raw_mentions = body.mentions
|
| 59 |
+
if not raw_mentions:
|
| 60 |
+
message, raw_mentions = parse_mentions(message)
|
| 61 |
+
|
| 62 |
+
mentions = raw_mentions if raw_mentions else None
|
| 63 |
+
|
| 64 |
+
# Select orchestrator based on backend
|
| 65 |
+
if backend_name in ("anthropic", "openai", "gemini"):
|
| 66 |
+
orchestrator = CrewOrchestrator(
|
| 67 |
+
backend_name=backend_name, output_dir=OUTPUT_DIR
|
| 68 |
+
)
|
| 69 |
+
else:
|
| 70 |
+
orchestrator = get_orchestrator(backend_name, output_dir=OUTPUT_DIR)
|
| 71 |
+
|
| 72 |
+
# Run chat turn
|
| 73 |
+
try:
|
| 74 |
+
result = orchestrator.chat_turn(
|
| 75 |
+
message=message,
|
| 76 |
+
history=history,
|
| 77 |
+
mentions=mentions,
|
| 78 |
+
design_state=body.design_state,
|
| 79 |
+
)
|
| 80 |
+
return JSONResponse(result)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return JSONResponse(
|
| 83 |
+
{"error": "An internal error occurred. Please try again."},
|
| 84 |
+
status_code=500,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/api/report")
|
| 89 |
+
async def report(body: ReportRequest):
|
| 90 |
+
"""Generate a design report from conversation history."""
|
| 91 |
+
part_name = body.part_name
|
| 92 |
+
history = [m.model_dump() for m in body.history]
|
| 93 |
+
|
| 94 |
+
# Build report from conversation
|
| 95 |
+
report_sections = [f"# Design Report: {part_name}\n"]
|
| 96 |
+
|
| 97 |
+
design_decisions = []
|
| 98 |
+
engineering_specs = []
|
| 99 |
+
cnc_notes = []
|
| 100 |
+
|
| 101 |
+
for msg in history:
|
| 102 |
+
agent_id = msg.get("agent_id", "")
|
| 103 |
+
content = msg.get("content", "")
|
| 104 |
+
if agent_id == "design":
|
| 105 |
+
design_decisions.append(content)
|
| 106 |
+
elif agent_id == "engineering":
|
| 107 |
+
engineering_specs.append(content)
|
| 108 |
+
elif agent_id == "cnc":
|
| 109 |
+
cnc_notes.append(content)
|
| 110 |
+
|
| 111 |
+
if design_decisions:
|
| 112 |
+
report_sections.append("## Design Decisions")
|
| 113 |
+
for d in design_decisions:
|
| 114 |
+
report_sections.append(f"- {d}")
|
| 115 |
+
|
| 116 |
+
if engineering_specs:
|
| 117 |
+
report_sections.append("\n## Engineering Specifications")
|
| 118 |
+
for s in engineering_specs:
|
| 119 |
+
report_sections.append(f"- {s}")
|
| 120 |
+
|
| 121 |
+
if cnc_notes:
|
| 122 |
+
report_sections.append("\n## Manufacturing Notes")
|
| 123 |
+
for n in cnc_notes:
|
| 124 |
+
report_sections.append(f"- {n}")
|
| 125 |
+
|
| 126 |
+
stl_path = OUTPUT_DIR / f"{part_name}.stl"
|
| 127 |
+
step_path = OUTPUT_DIR / f"{part_name}.step"
|
| 128 |
+
|
| 129 |
+
report_sections.append("\n## Exported Files")
|
| 130 |
+
report_sections.append(f"- STEP: {'Available' if step_path.exists() else 'Not generated'}")
|
| 131 |
+
report_sections.append(f"- STL: {'Available' if stl_path.exists() else 'Not generated'}")
|
| 132 |
+
|
| 133 |
+
return JSONResponse({
|
| 134 |
+
"part_name": part_name,
|
| 135 |
+
"report": "\n".join(report_sections),
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.get("/api/agents")
|
| 140 |
+
async def list_agents():
|
| 141 |
+
"""List available agents and their metadata."""
|
| 142 |
+
return JSONResponse({
|
| 143 |
+
"agents": [
|
| 144 |
+
{
|
| 145 |
+
"id": agent.id,
|
| 146 |
+
"name": agent.name,
|
| 147 |
+
"role": agent.role,
|
| 148 |
+
"color": agent.color,
|
| 149 |
+
"avatar": agent.avatar,
|
| 150 |
+
}
|
| 151 |
+
for agent in AGENTS.values()
|
| 152 |
+
]
|
| 153 |
+
})
|
server/web.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
NeuralCAD Web Demo Server
|
| 4 |
+
=========================
|
| 5 |
+
FastAPI server that proxies REST requests to the MCP CAD server (SSE transport)
|
| 6 |
+
and serves the web frontend.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
# Start MCP server first:
|
| 10 |
+
python -m server.mcp --transport sse --port 8000
|
| 11 |
+
|
| 12 |
+
# Then start web server:
|
| 13 |
+
python -m server.web
|
| 14 |
+
|
| 15 |
+
# Or auto-launch MCP server:
|
| 16 |
+
python -m server.web --start-mcp
|
| 17 |
+
|
| 18 |
+
# Open http://localhost:5000
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import subprocess
|
| 24 |
+
import sys
|
| 25 |
+
import tempfile
|
| 26 |
+
import time
|
| 27 |
+
from contextlib import asynccontextmanager
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 31 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 32 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 33 |
+
|
| 34 |
+
from server.routes import router
|
| 35 |
+
|
| 36 |
+
from mcp import ClientSession
|
| 37 |
+
from mcp.client.sse import sse_client
|
| 38 |
+
|
| 39 |
+
# ── Config ───────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
|
| 42 |
+
OUTPUT_DIR = Path(__file__).parent.parent / "output"
|
| 43 |
+
WEB_DIR = Path(__file__).parent.parent / "web"
|
| 44 |
+
PORT = int(os.environ.get("WEB_PORT", "5000"))
|
| 45 |
+
|
| 46 |
+
# ── MCP Client Management ───────────────────────────────────────────────
|
| 47 |
+
|
| 48 |
+
_mcp_process = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def call_mcp_tool(tool_name: str, arguments: dict) -> dict:
|
| 52 |
+
"""Connect to MCP server, call a tool, return parsed JSON result."""
|
| 53 |
+
async with sse_client(url=MCP_SERVER_URL) as streams:
|
| 54 |
+
async with ClientSession(*streams) as session:
|
| 55 |
+
await session.initialize()
|
| 56 |
+
result = await session.call_tool(name=tool_name, arguments=arguments)
|
| 57 |
+
if result.content:
|
| 58 |
+
return json.loads(result.content[0].text)
|
| 59 |
+
return {"error": "Empty response from MCP server"}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def read_mcp_resource(uri: str) -> str:
|
| 63 |
+
"""Connect to MCP server and read a resource."""
|
| 64 |
+
async with sse_client(url=MCP_SERVER_URL) as streams:
|
| 65 |
+
async with ClientSession(*streams) as session:
|
| 66 |
+
await session.initialize()
|
| 67 |
+
result = await session.read_resource(uri=uri)
|
| 68 |
+
if result.contents:
|
| 69 |
+
return result.contents[0].text
|
| 70 |
+
return "{}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def start_mcp_server(port: int = 8000):
|
| 74 |
+
"""Launch mcp.py as a subprocess with SSE transport."""
|
| 75 |
+
global _mcp_process
|
| 76 |
+
mcp_script = Path(__file__).parent / "mcp.py"
|
| 77 |
+
_mcp_process = subprocess.Popen(
|
| 78 |
+
[sys.executable, str(mcp_script), "--transport", "sse", "--port", str(port)],
|
| 79 |
+
stdout=subprocess.PIPE,
|
| 80 |
+
stderr=subprocess.PIPE,
|
| 81 |
+
)
|
| 82 |
+
# Give it a moment to start
|
| 83 |
+
time.sleep(2)
|
| 84 |
+
if _mcp_process.poll() is not None:
|
| 85 |
+
stderr = _mcp_process.stderr.read().decode() if _mcp_process.stderr else ""
|
| 86 |
+
raise RuntimeError(f"MCP server failed to start: {stderr}")
|
| 87 |
+
print(f" MCP server started (PID {_mcp_process.pid}) on port {port}")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ── FastAPI App ──────────────────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
@asynccontextmanager
|
| 93 |
+
async def lifespan(app: FastAPI):
|
| 94 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 95 |
+
yield
|
| 96 |
+
global _mcp_process
|
| 97 |
+
if _mcp_process:
|
| 98 |
+
_mcp_process.terminate()
|
| 99 |
+
_mcp_process.wait()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
app = FastAPI(title="NeuralCAD Web Demo", lifespan=lifespan)
|
| 103 |
+
|
| 104 |
+
app.add_middleware(
|
| 105 |
+
CORSMiddleware,
|
| 106 |
+
allow_origins=["*"],
|
| 107 |
+
allow_methods=["*"],
|
| 108 |
+
allow_headers=["*"],
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
app.include_router(router)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ── Routes ───────────────────────────────────────────────────────────────
|
| 115 |
+
|
| 116 |
+
@app.get("/", response_class=HTMLResponse)
|
| 117 |
+
async def index():
|
| 118 |
+
index_file = WEB_DIR / "index.html"
|
| 119 |
+
return HTMLResponse(index_file.read_text())
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@app.post("/api/generate")
|
| 123 |
+
async def generate(body: dict):
|
| 124 |
+
result = await call_mcp_tool("generate_cnc_model", {
|
| 125 |
+
"prompt": body.get("prompt", ""),
|
| 126 |
+
"part_name": body.get("part_name", ""),
|
| 127 |
+
"backend": body.get("backend", "mock"),
|
| 128 |
+
"max_retries": body.get("max_retries", 2),
|
| 129 |
+
})
|
| 130 |
+
return JSONResponse(result)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@app.post("/api/generate-image")
|
| 134 |
+
async def generate_image(
|
| 135 |
+
image: UploadFile = File(...),
|
| 136 |
+
text_hint: str = Form(""),
|
| 137 |
+
part_name: str = Form(""),
|
| 138 |
+
backend: str = Form("anthropic"),
|
| 139 |
+
):
|
| 140 |
+
# Save uploaded image to temp file
|
| 141 |
+
suffix = Path(image.filename or "upload.png").suffix
|
| 142 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
| 143 |
+
tmp.write(await image.read())
|
| 144 |
+
tmp_path = tmp.name
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
result = await call_mcp_tool("generate_from_image", {
|
| 148 |
+
"image_path": tmp_path,
|
| 149 |
+
"text_hint": text_hint,
|
| 150 |
+
"part_name": part_name,
|
| 151 |
+
"backend": backend,
|
| 152 |
+
})
|
| 153 |
+
return JSONResponse(result)
|
| 154 |
+
finally:
|
| 155 |
+
os.unlink(tmp_path)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@app.post("/api/validate")
|
| 159 |
+
async def validate(body: dict):
|
| 160 |
+
result = await call_mcp_tool("validate_cnc_model", {
|
| 161 |
+
"cadquery_code": body.get("code", ""),
|
| 162 |
+
"part_name": body.get("part_name", "Part"),
|
| 163 |
+
})
|
| 164 |
+
return JSONResponse(result)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@app.get("/api/models")
|
| 168 |
+
async def list_models():
|
| 169 |
+
result = await call_mcp_tool("list_models", {
|
| 170 |
+
"output_dir": str(OUTPUT_DIR),
|
| 171 |
+
})
|
| 172 |
+
return JSONResponse(result)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@app.get("/api/models/{name}.stl")
|
| 176 |
+
async def get_stl(name: str):
|
| 177 |
+
path = OUTPUT_DIR / f"{name}.stl"
|
| 178 |
+
if not path.exists():
|
| 179 |
+
return JSONResponse({"error": f"STL not found: {name}"}, status_code=404)
|
| 180 |
+
return FileResponse(path, media_type="model/stl", filename=f"{name}.stl")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.get("/api/models/{name}.step")
|
| 184 |
+
async def get_step(name: str):
|
| 185 |
+
path = OUTPUT_DIR / f"{name}.step"
|
| 186 |
+
if not path.exists():
|
| 187 |
+
return JSONResponse({"error": f"STEP not found: {name}"}, status_code=404)
|
| 188 |
+
return FileResponse(path, media_type="application/step", filename=f"{name}.step")
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@app.get("/api/capabilities")
|
| 192 |
+
async def capabilities():
|
| 193 |
+
try:
|
| 194 |
+
text = await read_mcp_resource("text-to-cnc://capabilities")
|
| 195 |
+
return JSONResponse(json.loads(text))
|
| 196 |
+
except Exception as e:
|
| 197 |
+
return JSONResponse({"error": str(e)}, status_code=502)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ── Entry Point ──────────────────────────────────────────────────────────
|
| 201 |
+
|
| 202 |
+
if __name__ == "__main__":
|
| 203 |
+
import argparse
|
| 204 |
+
import uvicorn
|
| 205 |
+
|
| 206 |
+
parser = argparse.ArgumentParser(description="NeuralCAD Web Demo Server")
|
| 207 |
+
parser.add_argument("--port", type=int, default=PORT, help="Web server port (default: 5000)")
|
| 208 |
+
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
|
| 209 |
+
parser.add_argument(
|
| 210 |
+
"--start-mcp", action="store_true",
|
| 211 |
+
help="Auto-launch MCP server as subprocess before starting web server"
|
| 212 |
+
)
|
| 213 |
+
parser.add_argument("--mcp-port", type=int, default=8000, help="MCP server port (default: 8000)")
|
| 214 |
+
args = parser.parse_args()
|
| 215 |
+
|
| 216 |
+
if args.start_mcp:
|
| 217 |
+
MCP_SERVER_URL = f"http://localhost:{args.mcp_port}/sse"
|
| 218 |
+
print(f"Starting MCP CAD server on port {args.mcp_port}...")
|
| 219 |
+
start_mcp_server(args.mcp_port)
|
| 220 |
+
|
| 221 |
+
print(f"Starting NeuralCAD Web Demo on http://localhost:{args.port}")
|
| 222 |
+
print(f"MCP server: {MCP_SERVER_URL}")
|
| 223 |
+
uvicorn.run(app, host=args.host, port=args.port)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for NeuralCAD tests."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@pytest.fixture
|
| 8 |
+
def tmp_output_dir(tmp_path):
|
| 9 |
+
"""Temporary output directory for model files."""
|
| 10 |
+
out = tmp_path / "output"
|
| 11 |
+
out.mkdir()
|
| 12 |
+
return out
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@pytest.fixture
|
| 16 |
+
def sample_history():
|
| 17 |
+
"""A typical multi-turn conversation history."""
|
| 18 |
+
return [
|
| 19 |
+
{"role": "user", "content": "I need a servo bracket for an MG996R"},
|
| 20 |
+
{"role": "agent", "agent_id": "design", "content": "I'd suggest an L-bracket with a servo pocket on the vertical face."},
|
| 21 |
+
{"role": "agent", "agent_id": "engineering", "content": "3mm wall thickness in aluminum 6061-T6 should handle the load."},
|
| 22 |
+
{"role": "user", "content": "Make it 60mm wide with M4 base mounting holes"},
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def empty_design_state():
|
| 28 |
+
"""Empty design state dict."""
|
| 29 |
+
return {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@pytest.fixture
|
| 33 |
+
def populated_design_state():
|
| 34 |
+
"""Design state with some decisions already made."""
|
| 35 |
+
return {
|
| 36 |
+
"part_name": "servo_bracket",
|
| 37 |
+
"material": "aluminum 6061",
|
| 38 |
+
"dimensions": {"width": 60.0},
|
| 39 |
+
"features": ["4x M4 holes"],
|
| 40 |
+
"decisions": ["L-bracket form factor"],
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class FakeLLMBackend:
|
| 45 |
+
"""A controllable fake LLM backend for testing orchestrators."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, response: str = '{"agents": []}'):
|
| 48 |
+
self.response = response
|
| 49 |
+
self.calls: list[list[dict]] = []
|
| 50 |
+
|
| 51 |
+
def generate(self, messages: list[dict]) -> str:
|
| 52 |
+
self.calls.append(messages)
|
| 53 |
+
return self.response
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@pytest.fixture
|
| 57 |
+
def fake_backend():
|
| 58 |
+
"""FakeLLMBackend factory — call with desired JSON response."""
|
| 59 |
+
def _make(response: str = '{"agents": []}'):
|
| 60 |
+
return FakeLLMBackend(response)
|
| 61 |
+
return _make
|
tests/test_api_routes.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for server/routes.py — FastAPI chat API endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
from server.web import app
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
client = TestClient(app)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestChatEndpoint:
|
| 11 |
+
def test_basic_chat(self):
|
| 12 |
+
resp = client.post("/api/chat", json={
|
| 13 |
+
"message": "I need a bracket",
|
| 14 |
+
"history": [],
|
| 15 |
+
"backend": "mock",
|
| 16 |
+
})
|
| 17 |
+
assert resp.status_code == 200
|
| 18 |
+
data = resp.json()
|
| 19 |
+
assert "responses" in data
|
| 20 |
+
assert len(data["responses"]) > 0
|
| 21 |
+
|
| 22 |
+
def test_chat_with_mentions(self):
|
| 23 |
+
resp = client.post("/api/chat", json={
|
| 24 |
+
"message": "What do you think?",
|
| 25 |
+
"history": [],
|
| 26 |
+
"mentions": ["cnc"],
|
| 27 |
+
"backend": "mock",
|
| 28 |
+
})
|
| 29 |
+
assert resp.status_code == 200
|
| 30 |
+
data = resp.json()
|
| 31 |
+
agent_ids = [r["agent_id"] for r in data["responses"]]
|
| 32 |
+
assert "cnc" in agent_ids
|
| 33 |
+
|
| 34 |
+
def test_chat_with_history(self):
|
| 35 |
+
resp = client.post("/api/chat", json={
|
| 36 |
+
"message": "Make it wider",
|
| 37 |
+
"history": [
|
| 38 |
+
{"role": "user", "content": "I need a bracket"},
|
| 39 |
+
{"role": "agent", "agent_id": "design", "content": "L-bracket suggestion."},
|
| 40 |
+
],
|
| 41 |
+
"backend": "mock",
|
| 42 |
+
})
|
| 43 |
+
assert resp.status_code == 200
|
| 44 |
+
data = resp.json()
|
| 45 |
+
assert "responses" in data
|
| 46 |
+
|
| 47 |
+
def test_chat_empty_message_rejected(self):
|
| 48 |
+
resp = client.post("/api/chat", json={
|
| 49 |
+
"message": "",
|
| 50 |
+
"history": [],
|
| 51 |
+
"backend": "mock",
|
| 52 |
+
})
|
| 53 |
+
assert resp.status_code == 422
|
| 54 |
+
|
| 55 |
+
def test_chat_returns_design_state(self):
|
| 56 |
+
resp = client.post("/api/chat", json={
|
| 57 |
+
"message": "60mm wide aluminum bracket",
|
| 58 |
+
"history": [],
|
| 59 |
+
"backend": "mock",
|
| 60 |
+
})
|
| 61 |
+
assert resp.status_code == 200
|
| 62 |
+
data = resp.json()
|
| 63 |
+
assert "design_state" in data
|
| 64 |
+
|
| 65 |
+
def test_chat_at_mention_in_message(self):
|
| 66 |
+
resp = client.post("/api/chat", json={
|
| 67 |
+
"message": "@engineering what thickness?",
|
| 68 |
+
"history": [],
|
| 69 |
+
"backend": "mock",
|
| 70 |
+
})
|
| 71 |
+
assert resp.status_code == 200
|
| 72 |
+
data = resp.json()
|
| 73 |
+
agent_ids = [r["agent_id"] for r in data["responses"]]
|
| 74 |
+
assert "engineering" in agent_ids
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class TestReportEndpoint:
|
| 78 |
+
def test_basic_report(self):
|
| 79 |
+
resp = client.post("/api/report", json={
|
| 80 |
+
"part_name": "test_bracket",
|
| 81 |
+
"history": [
|
| 82 |
+
{"role": "agent", "agent_id": "design", "content": "L-bracket design."},
|
| 83 |
+
{"role": "agent", "agent_id": "engineering", "content": "3mm aluminum."},
|
| 84 |
+
{"role": "agent", "agent_id": "cnc", "content": "3-axis OK."},
|
| 85 |
+
],
|
| 86 |
+
"backend": "mock",
|
| 87 |
+
})
|
| 88 |
+
assert resp.status_code == 200
|
| 89 |
+
data = resp.json()
|
| 90 |
+
assert "report" in data
|
| 91 |
+
assert "test_bracket" in data["report"]
|
| 92 |
+
assert "Design Decisions" in data["report"]
|
| 93 |
+
assert "Engineering Specifications" in data["report"]
|
| 94 |
+
assert "Manufacturing Notes" in data["report"]
|
| 95 |
+
|
| 96 |
+
def test_empty_history(self):
|
| 97 |
+
resp = client.post("/api/report", json={
|
| 98 |
+
"part_name": "empty_part",
|
| 99 |
+
"history": [],
|
| 100 |
+
"backend": "mock",
|
| 101 |
+
})
|
| 102 |
+
assert resp.status_code == 200
|
| 103 |
+
data = resp.json()
|
| 104 |
+
assert "report" in data
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class TestAgentsEndpoint:
|
| 108 |
+
def test_list_agents(self):
|
| 109 |
+
resp = client.get("/api/agents")
|
| 110 |
+
assert resp.status_code == 200
|
| 111 |
+
data = resp.json()
|
| 112 |
+
assert "agents" in data
|
| 113 |
+
agent_ids = [a["id"] for a in data["agents"]]
|
| 114 |
+
assert "design" in agent_ids
|
| 115 |
+
assert "engineering" in agent_ids
|
| 116 |
+
assert "cnc" in agent_ids
|
| 117 |
+
assert "cad" in agent_ids
|
| 118 |
+
|
| 119 |
+
def test_agent_has_metadata(self):
|
| 120 |
+
resp = client.get("/api/agents")
|
| 121 |
+
data = resp.json()
|
| 122 |
+
agent = data["agents"][0]
|
| 123 |
+
assert "id" in agent
|
| 124 |
+
assert "name" in agent
|
| 125 |
+
assert "role" in agent
|
| 126 |
+
assert "color" in agent
|
| 127 |
+
assert "avatar" in agent
|
tests/test_design_state.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for agents/design_state.py — state tracking and decision extraction."""
|
| 2 |
+
|
| 3 |
+
from agents.design_state import DesignState, extract_decisions
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class TestDesignState:
|
| 7 |
+
def test_empty_render(self):
|
| 8 |
+
state = DesignState()
|
| 9 |
+
assert state.render() == ""
|
| 10 |
+
|
| 11 |
+
def test_render_with_fields(self):
|
| 12 |
+
state = DesignState(
|
| 13 |
+
part_name="bracket",
|
| 14 |
+
material="aluminum 6061",
|
| 15 |
+
dimensions={"width": 60.0, "height": 40.0},
|
| 16 |
+
)
|
| 17 |
+
rendered = state.render()
|
| 18 |
+
assert "bracket" in rendered
|
| 19 |
+
assert "aluminum 6061" in rendered
|
| 20 |
+
assert "width=60.0mm" in rendered
|
| 21 |
+
|
| 22 |
+
def test_render_features(self):
|
| 23 |
+
state = DesignState(features=["4x M6 holes", "fillet"])
|
| 24 |
+
rendered = state.render()
|
| 25 |
+
assert "4x M6 holes" in rendered
|
| 26 |
+
|
| 27 |
+
def test_render_decisions_capped_at_5(self):
|
| 28 |
+
state = DesignState(decisions=[f"decision {i}" for i in range(10)])
|
| 29 |
+
rendered = state.render()
|
| 30 |
+
assert "decision 9" in rendered
|
| 31 |
+
assert "decision 4" not in rendered
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class TestExtractDecisions:
|
| 35 |
+
def test_extracts_material(self):
|
| 36 |
+
responses = [
|
| 37 |
+
{"agent_id": "engineering", "message": "I recommend aluminum 6061 for this application."}
|
| 38 |
+
]
|
| 39 |
+
state = extract_decisions(responses, DesignState())
|
| 40 |
+
assert "aluminum" in state.material.lower()
|
| 41 |
+
|
| 42 |
+
def test_extracts_dimensions_from_user(self):
|
| 43 |
+
responses = []
|
| 44 |
+
state = extract_decisions(responses, DesignState(), user_message="Make it 60mm wide and 40mm high")
|
| 45 |
+
assert state.dimensions.get("width") == 60.0
|
| 46 |
+
assert state.dimensions.get("height") == 40.0
|
| 47 |
+
|
| 48 |
+
def test_extracts_fastener_features(self):
|
| 49 |
+
responses = [
|
| 50 |
+
{"agent_id": "engineering", "message": "I'll add 4x M6 clearance holes for mounting."}
|
| 51 |
+
]
|
| 52 |
+
state = extract_decisions(responses, DesignState())
|
| 53 |
+
assert any("M6" in f for f in state.features)
|
| 54 |
+
|
| 55 |
+
def test_extracts_axis_recommendation(self):
|
| 56 |
+
responses = [
|
| 57 |
+
{"agent_id": "cnc", "message": "This part needs 5-axis machining due to the undercut."}
|
| 58 |
+
]
|
| 59 |
+
state = extract_decisions(responses, DesignState())
|
| 60 |
+
assert "5-axis" in state.axis_recommendation
|
| 61 |
+
|
| 62 |
+
def test_extracts_part_name(self):
|
| 63 |
+
responses = []
|
| 64 |
+
state = extract_decisions(responses, DesignState(), user_message="I need a servo bracket with M4 holes")
|
| 65 |
+
assert "servo bracket" in state.part_name.lower()
|
| 66 |
+
|
| 67 |
+
def test_preserves_existing_state(self):
|
| 68 |
+
existing = DesignState(material="steel", dimensions={"width": 50.0})
|
| 69 |
+
responses = [
|
| 70 |
+
{"agent_id": "engineering", "message": "Height should be 30mm."}
|
| 71 |
+
]
|
| 72 |
+
updated = extract_decisions(responses, existing, user_message="add height")
|
| 73 |
+
assert updated.material == "steel"
|
| 74 |
+
assert updated.dimensions.get("width") == 50.0
|
| 75 |
+
|
| 76 |
+
def test_extracts_decisions_from_agreement(self):
|
| 77 |
+
responses = [
|
| 78 |
+
{"agent_id": "design", "message": "I'd recommend an L-bracket form factor for this."}
|
| 79 |
+
]
|
| 80 |
+
state = extract_decisions(responses, DesignState())
|
| 81 |
+
assert len(state.decisions) > 0
|
| 82 |
+
|
| 83 |
+
def test_no_duplicate_features(self):
|
| 84 |
+
existing = DesignState(features=["4x M6 holes"])
|
| 85 |
+
responses = [
|
| 86 |
+
{"agent_id": "engineering", "message": "The 4x M6 holes are properly specified."}
|
| 87 |
+
]
|
| 88 |
+
updated = extract_decisions(responses, existing)
|
| 89 |
+
m6_count = sum(1 for f in updated.features if "M6" in f)
|
| 90 |
+
assert m6_count == 1
|
tests/test_executor.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for core/executor.py — CadQuery code execution and export.
|
| 2 |
+
|
| 3 |
+
These tests require CadQuery to be installed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from core.executor import sanitize_code, execute_cadquery, export_step, export_stl, export_all
|
| 9 |
+
|
| 10 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestSanitizeCode:
|
| 14 |
+
def test_strips_markdown_fences(self):
|
| 15 |
+
code = "```python\nresult = 1\n```"
|
| 16 |
+
assert "```" not in sanitize_code(code)
|
| 17 |
+
|
| 18 |
+
def test_strips_plain_fences(self):
|
| 19 |
+
code = "```\nresult = 1\n```"
|
| 20 |
+
assert "```" not in sanitize_code(code)
|
| 21 |
+
|
| 22 |
+
def test_removes_cadquery_imports(self):
|
| 23 |
+
code = "import cadquery as cq\nresult = cq.Workplane('XY').box(10,10,10)"
|
| 24 |
+
cleaned = sanitize_code(code)
|
| 25 |
+
assert "import cadquery" not in cleaned
|
| 26 |
+
assert "result" in cleaned
|
| 27 |
+
|
| 28 |
+
def test_removes_math_import(self):
|
| 29 |
+
code = "import math\nresult = cq.Workplane('XY').box(10,10,10)"
|
| 30 |
+
cleaned = sanitize_code(code)
|
| 31 |
+
assert "import math" not in cleaned
|
| 32 |
+
|
| 33 |
+
def test_preserves_valid_code(self):
|
| 34 |
+
code = "result = cq.Workplane('XY').box(10, 20, 30)"
|
| 35 |
+
assert sanitize_code(code) == code
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestExecuteCadquery:
|
| 39 |
+
def test_simple_box(self):
|
| 40 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
|
| 41 |
+
assert result.success is True
|
| 42 |
+
assert result.volume > 0
|
| 43 |
+
assert result.face_count == 6
|
| 44 |
+
assert result.edge_count == 12
|
| 45 |
+
assert len(result.bounding_box) == 3
|
| 46 |
+
|
| 47 |
+
def test_cylinder(self):
|
| 48 |
+
result = execute_cadquery("result = cq.Workplane('XY').cylinder(20, 10)")
|
| 49 |
+
assert result.success is True
|
| 50 |
+
assert result.volume > 0
|
| 51 |
+
|
| 52 |
+
def test_missing_result_variable(self):
|
| 53 |
+
result = execute_cadquery("x = cq.Workplane('XY').box(10,10,10)")
|
| 54 |
+
assert result.success is False
|
| 55 |
+
assert "result" in result.error
|
| 56 |
+
|
| 57 |
+
def test_syntax_error(self):
|
| 58 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 10,")
|
| 59 |
+
assert result.success is False
|
| 60 |
+
assert result.error is not None
|
| 61 |
+
|
| 62 |
+
def test_wrong_type(self):
|
| 63 |
+
result = execute_cadquery("result = 42")
|
| 64 |
+
assert result.success is False
|
| 65 |
+
assert "Workplane" in result.error
|
| 66 |
+
|
| 67 |
+
def test_code_with_markdown_fences(self):
|
| 68 |
+
code = "```python\nimport cadquery as cq\nresult = cq.Workplane('XY').box(5,5,5)\n```"
|
| 69 |
+
result = execute_cadquery(code)
|
| 70 |
+
assert result.success is True
|
| 71 |
+
|
| 72 |
+
def test_summary_on_success(self):
|
| 73 |
+
result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
|
| 74 |
+
summary = result.summary()
|
| 75 |
+
assert "OK" in summary
|
| 76 |
+
assert "Volume" in summary
|
| 77 |
+
|
| 78 |
+
def test_summary_on_failure(self):
|
| 79 |
+
result = execute_cadquery("result = bad_code")
|
| 80 |
+
summary = result.summary()
|
| 81 |
+
assert "FAILED" in summary
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TestExport:
|
| 85 |
+
def test_export_step(self, tmp_path):
|
| 86 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 87 |
+
assert exec_result.success
|
| 88 |
+
path = export_step(exec_result.result, tmp_path / "test.step")
|
| 89 |
+
assert path.exists()
|
| 90 |
+
assert path.suffix == ".step"
|
| 91 |
+
|
| 92 |
+
def test_export_stl(self, tmp_path):
|
| 93 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 94 |
+
assert exec_result.success
|
| 95 |
+
path = export_stl(exec_result.result, tmp_path / "test.stl")
|
| 96 |
+
assert path.exists()
|
| 97 |
+
assert path.suffix == ".stl"
|
| 98 |
+
|
| 99 |
+
def test_export_all(self, tmp_path):
|
| 100 |
+
exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
|
| 101 |
+
assert exec_result.success
|
| 102 |
+
files = export_all(exec_result.result, tmp_path / "part")
|
| 103 |
+
assert files["step"].exists()
|
| 104 |
+
assert files["stl"].exists()
|
tests/test_mock_orchestrator.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for agents/orchestrator.py — MockChatBackend and helpers."""
|
| 2 |
+
|
| 3 |
+
from agents.orchestrator import MockChatBackend, _format_response
|
| 4 |
+
from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TestFormatResponse:
|
| 8 |
+
def test_returns_all_fields(self):
|
| 9 |
+
resp = _format_response("design", "Hello")
|
| 10 |
+
assert resp["agent_id"] == "design"
|
| 11 |
+
assert resp["agent_name"] == AGENT_NAMES["design"]
|
| 12 |
+
assert resp["message"] == "Hello"
|
| 13 |
+
assert resp["color"] == AGENT_COLORS["design"]
|
| 14 |
+
assert resp["avatar"] == AGENT_AVATARS["design"]
|
| 15 |
+
assert resp["code"] is None
|
| 16 |
+
|
| 17 |
+
def test_includes_code(self):
|
| 18 |
+
resp = _format_response("cad", "Done.", code="result = cq.Workplane().box(10,10,10)")
|
| 19 |
+
assert resp["code"] == "result = cq.Workplane().box(10,10,10)"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TestMockChatBackend:
|
| 23 |
+
def test_response_shape(self, tmp_output_dir):
|
| 24 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 25 |
+
result = mock.chat_turn("I need a bracket", history=[])
|
| 26 |
+
assert "responses" in result
|
| 27 |
+
assert "preview" in result
|
| 28 |
+
assert "design_state" in result
|
| 29 |
+
assert isinstance(result["responses"], list)
|
| 30 |
+
assert len(result["responses"]) > 0
|
| 31 |
+
|
| 32 |
+
def test_bracket_routes_to_design(self, tmp_output_dir):
|
| 33 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 34 |
+
result = mock.chat_turn("Design a mounting bracket", history=[])
|
| 35 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 36 |
+
assert "design" in agent_ids
|
| 37 |
+
|
| 38 |
+
def test_mention_overrides_routing(self, tmp_output_dir):
|
| 39 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 40 |
+
result = mock.chat_turn(
|
| 41 |
+
"What do you think?",
|
| 42 |
+
history=[],
|
| 43 |
+
mentions=["cnc"],
|
| 44 |
+
)
|
| 45 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 46 |
+
assert agent_ids == ["cnc"]
|
| 47 |
+
|
| 48 |
+
def test_cad_mention_generates_code(self, tmp_output_dir):
|
| 49 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 50 |
+
result = mock.chat_turn(
|
| 51 |
+
"Generate a 50mm cube",
|
| 52 |
+
history=[],
|
| 53 |
+
mentions=["cad"],
|
| 54 |
+
)
|
| 55 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 56 |
+
assert "cad" in agent_ids
|
| 57 |
+
cad_resp = next(r for r in result["responses"] if r["agent_id"] == "cad")
|
| 58 |
+
assert cad_resp["code"] is not None
|
| 59 |
+
assert "result" in cad_resp["code"]
|
| 60 |
+
|
| 61 |
+
def test_design_state_updated(self, tmp_output_dir):
|
| 62 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 63 |
+
result = mock.chat_turn(
|
| 64 |
+
"Make it 60mm wide in aluminum",
|
| 65 |
+
history=[],
|
| 66 |
+
)
|
| 67 |
+
ds = result["design_state"]
|
| 68 |
+
assert isinstance(ds, dict)
|
| 69 |
+
|
| 70 |
+
def test_engineering_keywords_trigger_engineering(self, tmp_output_dir):
|
| 71 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 72 |
+
result = mock.chat_turn("Use M6 bolts with 3mm wall thickness", history=[])
|
| 73 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 74 |
+
assert "engineering" in agent_ids
|
| 75 |
+
|
| 76 |
+
def test_cnc_keywords_trigger_cnc(self, tmp_output_dir):
|
| 77 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 78 |
+
result = mock.chat_turn("Can this be machined on a CNC mill?", history=[])
|
| 79 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 80 |
+
assert "cnc" in agent_ids
|
| 81 |
+
|
| 82 |
+
def test_generic_message_default_agents(self, tmp_output_dir):
|
| 83 |
+
mock = MockChatBackend(output_dir=tmp_output_dir)
|
| 84 |
+
result = mock.chat_turn("Hello there", history=[])
|
| 85 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 86 |
+
assert "design" in agent_ids
|
| 87 |
+
assert "engineering" in agent_ids
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for core/pipeline.py — end-to-end text-to-CNC pipeline.
|
| 2 |
+
|
| 3 |
+
These tests require CadQuery to be installed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from core.pipeline import run_pipeline, PipelineResult
|
| 9 |
+
from core.backends import MockBackend
|
| 10 |
+
|
| 11 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestRunPipeline:
|
| 15 |
+
def test_basic_box(self, tmp_output_dir):
|
| 16 |
+
result = run_pipeline(
|
| 17 |
+
"A simple 50mm cube",
|
| 18 |
+
backend=MockBackend(),
|
| 19 |
+
output_dir=tmp_output_dir,
|
| 20 |
+
)
|
| 21 |
+
assert isinstance(result, PipelineResult)
|
| 22 |
+
assert result.execution.success is True
|
| 23 |
+
assert result.execution.volume > 0
|
| 24 |
+
|
| 25 |
+
def test_exports_files(self, tmp_output_dir):
|
| 26 |
+
result = run_pipeline(
|
| 27 |
+
"A 60x40x5mm mounting plate",
|
| 28 |
+
backend=MockBackend(),
|
| 29 |
+
output_dir=tmp_output_dir,
|
| 30 |
+
part_name="test_plate",
|
| 31 |
+
)
|
| 32 |
+
assert result.exported_files is not None
|
| 33 |
+
assert result.exported_files["step"].exists()
|
| 34 |
+
assert result.exported_files["stl"].exists()
|
| 35 |
+
|
| 36 |
+
def test_validation_runs(self, tmp_output_dir):
|
| 37 |
+
result = run_pipeline(
|
| 38 |
+
"A 50mm cylinder",
|
| 39 |
+
backend=MockBackend(),
|
| 40 |
+
output_dir=tmp_output_dir,
|
| 41 |
+
validate=True,
|
| 42 |
+
)
|
| 43 |
+
assert result.validation is not None
|
| 44 |
+
assert hasattr(result.validation, "machinable")
|
| 45 |
+
|
| 46 |
+
def test_skip_validation(self, tmp_output_dir):
|
| 47 |
+
result = run_pipeline(
|
| 48 |
+
"A simple box",
|
| 49 |
+
backend=MockBackend(),
|
| 50 |
+
output_dir=tmp_output_dir,
|
| 51 |
+
validate=False,
|
| 52 |
+
)
|
| 53 |
+
assert result.validation is None
|
| 54 |
+
|
| 55 |
+
def test_skip_export(self, tmp_output_dir):
|
| 56 |
+
result = run_pipeline(
|
| 57 |
+
"A simple box",
|
| 58 |
+
backend=MockBackend(),
|
| 59 |
+
output_dir=tmp_output_dir,
|
| 60 |
+
export=False,
|
| 61 |
+
)
|
| 62 |
+
assert result.exported_files is None or len(result.exported_files) == 0
|
| 63 |
+
|
| 64 |
+
def test_summary(self, tmp_output_dir):
|
| 65 |
+
result = run_pipeline(
|
| 66 |
+
"A 30mm cube",
|
| 67 |
+
backend=MockBackend(),
|
| 68 |
+
output_dir=tmp_output_dir,
|
| 69 |
+
)
|
| 70 |
+
summary = result.summary()
|
| 71 |
+
assert isinstance(summary, str)
|
| 72 |
+
|
| 73 |
+
def test_default_backend_is_mock(self, tmp_output_dir):
|
| 74 |
+
result = run_pipeline(
|
| 75 |
+
"A basic plate",
|
| 76 |
+
output_dir=tmp_output_dir,
|
| 77 |
+
)
|
| 78 |
+
assert result.execution.success is True
|
tests/test_prompts.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for agents/prompts.py — prompt building, routing, parsing."""
|
| 2 |
+
|
| 3 |
+
from agents.prompts import (
|
| 4 |
+
parse_mentions,
|
| 5 |
+
route_by_keywords,
|
| 6 |
+
parse_orchestrator_response,
|
| 7 |
+
build_orchestrator_system_prompt,
|
| 8 |
+
build_chat_messages,
|
| 9 |
+
CAD_TRIGGER_KEYWORDS,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TestParseMentions:
|
| 14 |
+
def test_no_mentions(self):
|
| 15 |
+
cleaned, mentions = parse_mentions("I need a bracket")
|
| 16 |
+
assert cleaned == "I need a bracket"
|
| 17 |
+
assert mentions == []
|
| 18 |
+
|
| 19 |
+
def test_single_mention(self):
|
| 20 |
+
cleaned, mentions = parse_mentions("@design what shape?")
|
| 21 |
+
assert "design" in mentions
|
| 22 |
+
assert "@design" not in cleaned
|
| 23 |
+
|
| 24 |
+
def test_multiple_mentions(self):
|
| 25 |
+
cleaned, mentions = parse_mentions("@design @engineering check this")
|
| 26 |
+
assert "design" in mentions
|
| 27 |
+
assert "engineering" in mentions
|
| 28 |
+
assert "@design" not in cleaned
|
| 29 |
+
assert "@engineering" not in cleaned
|
| 30 |
+
|
| 31 |
+
def test_cad_mention(self):
|
| 32 |
+
cleaned, mentions = parse_mentions("@cad generate a preview")
|
| 33 |
+
assert "cad" in mentions
|
| 34 |
+
|
| 35 |
+
def test_case_insensitive(self):
|
| 36 |
+
cleaned, mentions = parse_mentions("@Design what do you think?")
|
| 37 |
+
assert "design" in mentions
|
| 38 |
+
|
| 39 |
+
def test_mention_mid_sentence(self):
|
| 40 |
+
cleaned, mentions = parse_mentions("Can @engineering check the wall thickness?")
|
| 41 |
+
assert "engineering" in mentions
|
| 42 |
+
assert "Can" in cleaned
|
| 43 |
+
assert "check the wall thickness?" in cleaned
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TestRouteByKeywords:
|
| 47 |
+
def test_design_keywords(self):
|
| 48 |
+
agents = route_by_keywords("I want a sleek design with smooth shape")
|
| 49 |
+
assert "design" in agents
|
| 50 |
+
|
| 51 |
+
def test_engineering_keywords(self):
|
| 52 |
+
agents = route_by_keywords("Use M6 bolts with 3mm wall thickness in aluminum")
|
| 53 |
+
assert "engineering" in agents
|
| 54 |
+
|
| 55 |
+
def test_cnc_keywords(self):
|
| 56 |
+
agents = route_by_keywords("Can this be machined on a 3-axis CNC mill?")
|
| 57 |
+
assert "cnc" in agents
|
| 58 |
+
|
| 59 |
+
def test_cad_trigger(self):
|
| 60 |
+
agents = route_by_keywords("Generate a preview of the part")
|
| 61 |
+
assert "cad" in agents
|
| 62 |
+
|
| 63 |
+
def test_default_when_no_match(self):
|
| 64 |
+
agents = route_by_keywords("hello there")
|
| 65 |
+
assert agents == ["design", "engineering"]
|
| 66 |
+
|
| 67 |
+
def test_max_three_agents(self):
|
| 68 |
+
agents = route_by_keywords(
|
| 69 |
+
"design shape in aluminum for CNC machining, generate preview"
|
| 70 |
+
)
|
| 71 |
+
assert len(agents) <= 3
|
| 72 |
+
|
| 73 |
+
def test_sorted_by_relevance(self):
|
| 74 |
+
agents = route_by_keywords("M4 M6 tolerance clearance aluminum steel wall")
|
| 75 |
+
assert agents[0] == "engineering"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class TestParseOrchestratorResponse:
|
| 79 |
+
def test_valid_json(self):
|
| 80 |
+
resp = '{"agents": [{"id": "design", "message": "Nice bracket."}]}'
|
| 81 |
+
parsed = parse_orchestrator_response(resp)
|
| 82 |
+
assert len(parsed) == 1
|
| 83 |
+
assert parsed[0]["id"] == "design"
|
| 84 |
+
assert parsed[0]["message"] == "Nice bracket."
|
| 85 |
+
assert parsed[0]["code"] is None
|
| 86 |
+
|
| 87 |
+
def test_json_with_code(self):
|
| 88 |
+
resp = '{"agents": [{"id": "cad", "message": "Done.", "code": "result = cq.Workplane().box(10,10,10)"}]}'
|
| 89 |
+
parsed = parse_orchestrator_response(resp)
|
| 90 |
+
assert parsed[0]["code"] == "result = cq.Workplane().box(10,10,10)"
|
| 91 |
+
|
| 92 |
+
def test_json_in_markdown_fence(self):
|
| 93 |
+
resp = '```json\n{"agents": [{"id": "engineering", "message": "Use 3mm walls."}]}\n```'
|
| 94 |
+
parsed = parse_orchestrator_response(resp)
|
| 95 |
+
assert len(parsed) == 1
|
| 96 |
+
assert parsed[0]["id"] == "engineering"
|
| 97 |
+
|
| 98 |
+
def test_multiple_agents(self):
|
| 99 |
+
resp = '{"agents": [{"id": "design", "message": "A"}, {"id": "cnc", "message": "B"}]}'
|
| 100 |
+
parsed = parse_orchestrator_response(resp)
|
| 101 |
+
assert len(parsed) == 2
|
| 102 |
+
assert parsed[0]["id"] == "design"
|
| 103 |
+
assert parsed[1]["id"] == "cnc"
|
| 104 |
+
|
| 105 |
+
def test_invalid_json_fallback(self):
|
| 106 |
+
resp = "I think you should use aluminum."
|
| 107 |
+
parsed = parse_orchestrator_response(resp)
|
| 108 |
+
assert len(parsed) == 1
|
| 109 |
+
assert parsed[0]["id"] == "design"
|
| 110 |
+
assert parsed[0]["message"] == resp
|
| 111 |
+
|
| 112 |
+
def test_empty_agents_fallback(self):
|
| 113 |
+
resp = '{"agents": []}'
|
| 114 |
+
parsed = parse_orchestrator_response(resp)
|
| 115 |
+
assert len(parsed) == 1
|
| 116 |
+
assert parsed[0]["id"] == "design"
|
| 117 |
+
|
| 118 |
+
def test_missing_fields_skipped(self):
|
| 119 |
+
resp = '{"agents": [{"id": "design"}, {"id": "cnc", "message": "OK"}]}'
|
| 120 |
+
parsed = parse_orchestrator_response(resp)
|
| 121 |
+
assert len(parsed) == 1
|
| 122 |
+
assert parsed[0]["id"] == "cnc"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class TestBuildOrchestratorSystemPrompt:
|
| 126 |
+
def test_default_agents(self):
|
| 127 |
+
prompt = build_orchestrator_system_prompt()
|
| 128 |
+
assert "Design Agent" in prompt
|
| 129 |
+
assert "Engineering Agent" in prompt
|
| 130 |
+
assert "CNC Agent" in prompt
|
| 131 |
+
assert '### CAD Coder' not in prompt # persona block excluded
|
| 132 |
+
|
| 133 |
+
def test_specific_agents(self):
|
| 134 |
+
prompt = build_orchestrator_system_prompt(active_agents=["cad"])
|
| 135 |
+
assert "CAD Coder" in prompt
|
| 136 |
+
assert "Design Agent" not in prompt
|
| 137 |
+
|
| 138 |
+
def test_includes_json_format(self):
|
| 139 |
+
prompt = build_orchestrator_system_prompt()
|
| 140 |
+
assert '"agents"' in prompt
|
| 141 |
+
assert "JSON" in prompt
|
| 142 |
+
|
| 143 |
+
def test_cad_context_included(self):
|
| 144 |
+
prompt = build_orchestrator_system_prompt(
|
| 145 |
+
active_agents=["cad"], include_cad_context=True
|
| 146 |
+
)
|
| 147 |
+
assert "CadQuery" in prompt
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestBuildChatMessages:
|
| 151 |
+
def test_returns_system_and_user(self):
|
| 152 |
+
msgs = build_chat_messages("hello", [], "You are a bot.")
|
| 153 |
+
assert len(msgs) == 2
|
| 154 |
+
assert msgs[0]["role"] == "system"
|
| 155 |
+
assert msgs[0]["content"] == "You are a bot."
|
| 156 |
+
assert msgs[1]["role"] == "user"
|
| 157 |
+
|
| 158 |
+
def test_history_included_in_user_message(self, sample_history):
|
| 159 |
+
msgs = build_chat_messages("new msg", sample_history, "system prompt")
|
| 160 |
+
user_content = msgs[1]["content"]
|
| 161 |
+
assert "servo bracket" in user_content
|
| 162 |
+
assert "new msg" in user_content
|
| 163 |
+
|
| 164 |
+
def test_design_state_included(self):
|
| 165 |
+
msgs = build_chat_messages(
|
| 166 |
+
"make it wider", [], "system prompt",
|
| 167 |
+
design_state_text="Part: bracket\nMaterial: aluminum"
|
| 168 |
+
)
|
| 169 |
+
user_content = msgs[1]["content"]
|
| 170 |
+
assert "bracket" in user_content
|
| 171 |
+
assert "aluminum" in user_content
|
| 172 |
+
|
| 173 |
+
def test_history_truncation(self):
|
| 174 |
+
long_history = [
|
| 175 |
+
{"role": "user", "content": f"msg {i}"}
|
| 176 |
+
for i in range(50)
|
| 177 |
+
]
|
| 178 |
+
msgs = build_chat_messages("latest", long_history, "sys", max_history=5)
|
| 179 |
+
user_content = msgs[1]["content"]
|
| 180 |
+
assert "msg 49" in user_content
|
| 181 |
+
assert "msg 0" not in user_content
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ── Improved routing coverage ───────────────────────���─────────────────────
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TestRouteByKeywordsImproved:
|
| 188 |
+
"""Tests for expanded keyword routing vocabulary."""
|
| 189 |
+
|
| 190 |
+
def test_gear_routes_to_engineering(self):
|
| 191 |
+
agents = route_by_keywords("I need a spur gear with 20 teeth")
|
| 192 |
+
assert "engineering" in agents
|
| 193 |
+
|
| 194 |
+
def test_bearing_routes_to_engineering(self):
|
| 195 |
+
agents = route_by_keywords("Design a bearing housing")
|
| 196 |
+
assert "engineering" in agents
|
| 197 |
+
|
| 198 |
+
def test_heatsink_routes_to_engineering(self):
|
| 199 |
+
agents = route_by_keywords("Create a heatsink with fins")
|
| 200 |
+
assert "engineering" in agents
|
| 201 |
+
|
| 202 |
+
def test_flange_routes_to_engineering(self):
|
| 203 |
+
agents = route_by_keywords("A pipe flange with bolt holes")
|
| 204 |
+
assert "engineering" in agents
|
| 205 |
+
|
| 206 |
+
def test_servo_bracket_routes_to_design(self):
|
| 207 |
+
agents = route_by_keywords("Design a servo bracket for a camera gimbal")
|
| 208 |
+
assert "design" in agents
|
| 209 |
+
|
| 210 |
+
def test_cost_routes_to_cnc(self):
|
| 211 |
+
agents = route_by_keywords("How much would this cost to machine?")
|
| 212 |
+
assert "cnc" in agents
|
| 213 |
+
|
| 214 |
+
def test_surface_finish_routes_to_cnc(self):
|
| 215 |
+
agents = route_by_keywords("What surface finish can we achieve?")
|
| 216 |
+
assert "cnc" in agents
|
tests/test_single_call_orchestrator.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for SingleCallOrchestrator using a fake LLM backend."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from agents.orchestrator import SingleCallOrchestrator
|
| 5 |
+
from tests.conftest import FakeLLMBackend
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TestSingleCallOrchestrator:
|
| 9 |
+
def _make_orchestrator(self, response_json: str, tmp_output_dir):
|
| 10 |
+
backend = FakeLLMBackend(response_json)
|
| 11 |
+
return SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir), backend
|
| 12 |
+
|
| 13 |
+
def test_response_shape(self, tmp_output_dir):
|
| 14 |
+
resp = json.dumps({"agents": [
|
| 15 |
+
{"id": "design", "message": "An L-bracket would work."},
|
| 16 |
+
]})
|
| 17 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 18 |
+
result = orch.chat_turn("I need a bracket", history=[])
|
| 19 |
+
assert "responses" in result
|
| 20 |
+
assert "preview" in result
|
| 21 |
+
assert "design_state" in result
|
| 22 |
+
|
| 23 |
+
def test_passes_message_to_backend(self, tmp_output_dir):
|
| 24 |
+
resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
|
| 25 |
+
orch, backend = self._make_orchestrator(resp, tmp_output_dir)
|
| 26 |
+
orch.chat_turn("Test message", history=[])
|
| 27 |
+
assert len(backend.calls) == 1
|
| 28 |
+
last_user_msg = backend.calls[0][-1]["content"]
|
| 29 |
+
assert "Test message" in last_user_msg
|
| 30 |
+
|
| 31 |
+
def test_mentions_restrict_agents(self, tmp_output_dir):
|
| 32 |
+
resp = json.dumps({"agents": [{"id": "cnc", "message": "3-axis OK"}]})
|
| 33 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 34 |
+
result = orch.chat_turn("check this", history=[], mentions=["cnc"])
|
| 35 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 36 |
+
assert "cnc" in agent_ids
|
| 37 |
+
|
| 38 |
+
def test_invalid_json_fallback(self, tmp_output_dir):
|
| 39 |
+
orch, _ = self._make_orchestrator("not json at all", tmp_output_dir)
|
| 40 |
+
result = orch.chat_turn("help", history=[])
|
| 41 |
+
assert len(result["responses"]) > 0
|
| 42 |
+
assert result["responses"][0]["agent_id"] == "design"
|
| 43 |
+
|
| 44 |
+
def test_llm_exception_fallback(self, tmp_output_dir):
|
| 45 |
+
class RaisingBackend:
|
| 46 |
+
def generate(self, messages):
|
| 47 |
+
raise RuntimeError("API error")
|
| 48 |
+
|
| 49 |
+
orch = SingleCallOrchestrator(backend=RaisingBackend(), output_dir=tmp_output_dir)
|
| 50 |
+
result = orch.chat_turn("Design a part", history=[])
|
| 51 |
+
assert len(result["responses"]) > 0
|
| 52 |
+
|
| 53 |
+
def test_unknown_agent_id_filtered(self, tmp_output_dir):
|
| 54 |
+
resp = json.dumps({"agents": [
|
| 55 |
+
{"id": "nonexistent", "message": "I don't exist"},
|
| 56 |
+
{"id": "design", "message": "Real agent"},
|
| 57 |
+
]})
|
| 58 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 59 |
+
result = orch.chat_turn("test", history=[])
|
| 60 |
+
agent_ids = [r["agent_id"] for r in result["responses"]]
|
| 61 |
+
assert "nonexistent" not in agent_ids
|
| 62 |
+
assert "design" in agent_ids
|
| 63 |
+
|
| 64 |
+
def test_history_forwarded_to_backend(self, tmp_output_dir, sample_history):
|
| 65 |
+
resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
|
| 66 |
+
orch, backend = self._make_orchestrator(resp, tmp_output_dir)
|
| 67 |
+
orch.chat_turn("continue", history=sample_history)
|
| 68 |
+
user_content = backend.calls[0][-1]["content"]
|
| 69 |
+
assert "servo bracket" in user_content.lower() or "MG996R" in user_content
|
| 70 |
+
|
| 71 |
+
def test_design_state_returned(self, tmp_output_dir):
|
| 72 |
+
resp = json.dumps({"agents": [
|
| 73 |
+
{"id": "engineering", "message": "Use aluminum 6061 with 3mm walls."},
|
| 74 |
+
]})
|
| 75 |
+
orch, _ = self._make_orchestrator(resp, tmp_output_dir)
|
| 76 |
+
result = orch.chat_turn("material?", history=[])
|
| 77 |
+
assert "design_state" in result
|
| 78 |
+
assert isinstance(result["design_state"], dict)
|
tests/test_validator.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for core/validator.py — CNC manufacturability validation.
|
| 2 |
+
|
| 3 |
+
These tests require CadQuery to be installed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from core.executor import execute_cadquery
|
| 8 |
+
from core.validator import validate_for_cnc, CNCValidationResult, CNCIssue
|
| 9 |
+
|
| 10 |
+
pytestmark = pytest.mark.requires_cadquery
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _make_solid(code: str):
|
| 14 |
+
"""Helper to create a CadQuery Workplane from code."""
|
| 15 |
+
result = execute_cadquery(code)
|
| 16 |
+
assert result.success, f"Code failed: {result.error}"
|
| 17 |
+
return result.result
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class TestValidateForCnc:
|
| 21 |
+
def test_simple_box_is_machinable(self):
|
| 22 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 23 |
+
val = validate_for_cnc(solid, "test_box")
|
| 24 |
+
assert val.machinable is True
|
| 25 |
+
assert val.error_count == 0
|
| 26 |
+
|
| 27 |
+
def test_result_has_part_name(self):
|
| 28 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 29 |
+
val = validate_for_cnc(solid, "my_part")
|
| 30 |
+
assert val.part_name == "my_part"
|
| 31 |
+
|
| 32 |
+
def test_axis_recommendation_default_3axis(self):
|
| 33 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 34 |
+
val = validate_for_cnc(solid)
|
| 35 |
+
assert "3-axis" in val.axis_recommendation or "3" in val.axis_recommendation
|
| 36 |
+
|
| 37 |
+
def test_complex_part_gets_higher_axis(self):
|
| 38 |
+
code = '''
|
| 39 |
+
result = cq.Workplane('XY').box(50, 50, 50)
|
| 40 |
+
for i in range(5):
|
| 41 |
+
result = result.faces('>Z').workplane().pushPoints([(i*8-16, 0)]).hole(3)
|
| 42 |
+
for i in range(5):
|
| 43 |
+
result = result.faces('>X').workplane().pushPoints([(i*8-16, 0)]).hole(3)
|
| 44 |
+
'''
|
| 45 |
+
solid = _make_solid(code)
|
| 46 |
+
val = validate_for_cnc(solid)
|
| 47 |
+
assert val.part_name is not None
|
| 48 |
+
|
| 49 |
+
def test_oversized_part_flagged(self):
|
| 50 |
+
solid = _make_solid("result = cq.Workplane('XY').box(600, 600, 600)")
|
| 51 |
+
val = validate_for_cnc(solid, config={"max_part_size_mm": 500.0})
|
| 52 |
+
assert any(i.category == "Size" for i in val.issues)
|
| 53 |
+
|
| 54 |
+
def test_tiny_part_flagged(self):
|
| 55 |
+
solid = _make_solid("result = cq.Workplane('XY').box(0.5, 0.5, 0.5)")
|
| 56 |
+
val = validate_for_cnc(solid, config={"min_part_size_mm": 1.0})
|
| 57 |
+
assert any(i.category == "Size" for i in val.issues)
|
| 58 |
+
|
| 59 |
+
def test_summary_format(self):
|
| 60 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 61 |
+
val = validate_for_cnc(solid, "test")
|
| 62 |
+
summary = val.summary()
|
| 63 |
+
assert isinstance(summary, str)
|
| 64 |
+
assert "test" in summary
|
| 65 |
+
|
| 66 |
+
def test_custom_config(self):
|
| 67 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 68 |
+
val = validate_for_cnc(solid, config={"min_wall_thickness_mm": 0.5})
|
| 69 |
+
assert isinstance(val, CNCValidationResult)
|
| 70 |
+
|
| 71 |
+
def test_error_and_warning_counts(self):
|
| 72 |
+
solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
|
| 73 |
+
val = validate_for_cnc(solid)
|
| 74 |
+
assert val.error_count >= 0
|
| 75 |
+
assert val.warning_count >= 0
|
| 76 |
+
assert val.error_count + val.warning_count <= len(val.issues)
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/index.html
ADDED
|
@@ -0,0 +1,1983 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>NeuralCAD — Multi-Agent Design</title>
|
| 7 |
+
|
| 8 |
+
<!-- Three.js -->
|
| 9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
| 12 |
+
|
| 13 |
+
<!-- Fonts -->
|
| 14 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 15 |
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 16 |
+
|
| 17 |
+
<style>
|
| 18 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 19 |
+
|
| 20 |
+
:root {
|
| 21 |
+
--bg-void: #06080c;
|
| 22 |
+
--bg-panel: #0c1018;
|
| 23 |
+
--bg-surface: #111822;
|
| 24 |
+
--bg-input: #0a0f16;
|
| 25 |
+
--border: #1c2636;
|
| 26 |
+
--border-active: #2a3a52;
|
| 27 |
+
--text-primary: #c8d6e5;
|
| 28 |
+
--text-secondary: #5a7089;
|
| 29 |
+
--text-muted: #3a4d63;
|
| 30 |
+
--accent: #00b4d8;
|
| 31 |
+
--accent-glow: rgba(0, 180, 216, 0.15);
|
| 32 |
+
--accent-dim: #007a94;
|
| 33 |
+
--success: #00e676;
|
| 34 |
+
--success-glow: rgba(0, 230, 118, 0.12);
|
| 35 |
+
--warning: #ffab40;
|
| 36 |
+
--error: #ff5252;
|
| 37 |
+
--machined-steel: #8899aa;
|
| 38 |
+
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
|
| 39 |
+
--font-body: 'DM Sans', system-ui, sans-serif;
|
| 40 |
+
--agent-design: #7c3aed;
|
| 41 |
+
--agent-engineering: #00b4d8;
|
| 42 |
+
--agent-cnc: #00e676;
|
| 43 |
+
--agent-cad: #ffab40;
|
| 44 |
+
--chat-width: 340px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
html, body {
|
| 48 |
+
height: 100%;
|
| 49 |
+
overflow: hidden;
|
| 50 |
+
background: var(--bg-void);
|
| 51 |
+
color: var(--text-primary);
|
| 52 |
+
font-family: var(--font-body);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* ---- Scrollbar ---- */
|
| 56 |
+
::-webkit-scrollbar { width: 5px; }
|
| 57 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 58 |
+
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
| 59 |
+
::-webkit-scrollbar-thumb:hover { background: var(--border-active); }
|
| 60 |
+
|
| 61 |
+
/* ---- LAYOUT ---- */
|
| 62 |
+
|
| 63 |
+
#app {
|
| 64 |
+
display: flex;
|
| 65 |
+
flex-direction: column;
|
| 66 |
+
height: 100vh;
|
| 67 |
+
width: 100vw;
|
| 68 |
+
overflow: hidden;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* ---- TOP BAR ---- */
|
| 72 |
+
|
| 73 |
+
#topbar {
|
| 74 |
+
flex: 0 0 44px;
|
| 75 |
+
background: var(--bg-panel);
|
| 76 |
+
border-bottom: 1px solid var(--border);
|
| 77 |
+
display: flex;
|
| 78 |
+
align-items: center;
|
| 79 |
+
justify-content: space-between;
|
| 80 |
+
padding: 0 16px;
|
| 81 |
+
z-index: 100;
|
| 82 |
+
position: relative;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
#topbar::after {
|
| 86 |
+
content: '';
|
| 87 |
+
position: absolute;
|
| 88 |
+
bottom: -1px;
|
| 89 |
+
left: 0; right: 0;
|
| 90 |
+
height: 1px;
|
| 91 |
+
background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
|
| 92 |
+
opacity: 0.4;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.logo {
|
| 96 |
+
display: flex;
|
| 97 |
+
align-items: center;
|
| 98 |
+
gap: 10px;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.logo-diamond {
|
| 102 |
+
color: var(--accent);
|
| 103 |
+
font-size: 18px;
|
| 104 |
+
line-height: 1;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.logo-text {
|
| 108 |
+
font-family: var(--font-mono);
|
| 109 |
+
font-weight: 600;
|
| 110 |
+
font-size: 14px;
|
| 111 |
+
letter-spacing: 2px;
|
| 112 |
+
color: var(--text-primary);
|
| 113 |
+
text-transform: uppercase;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.logo-sub {
|
| 117 |
+
font-family: var(--font-mono);
|
| 118 |
+
font-size: 9px;
|
| 119 |
+
color: var(--text-muted);
|
| 120 |
+
letter-spacing: 3px;
|
| 121 |
+
text-transform: uppercase;
|
| 122 |
+
margin-left: 8px;
|
| 123 |
+
padding-left: 8px;
|
| 124 |
+
border-left: 1px solid var(--border);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.topbar-center {
|
| 128 |
+
display: flex;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 12px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.topbar-right {
|
| 134 |
+
display: flex;
|
| 135 |
+
align-items: center;
|
| 136 |
+
gap: 12px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.backend-toggle {
|
| 140 |
+
display: flex;
|
| 141 |
+
align-items: center;
|
| 142 |
+
gap: 0;
|
| 143 |
+
background: var(--bg-void);
|
| 144 |
+
border: 1px solid var(--border);
|
| 145 |
+
border-radius: 4px;
|
| 146 |
+
overflow: hidden;
|
| 147 |
+
font-family: var(--font-mono);
|
| 148 |
+
font-size: 11px;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.backend-toggle button {
|
| 152 |
+
all: unset;
|
| 153 |
+
padding: 4px 12px;
|
| 154 |
+
cursor: pointer;
|
| 155 |
+
color: var(--text-muted);
|
| 156 |
+
transition: all 0.2s;
|
| 157 |
+
border-right: 1px solid var(--border);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.backend-toggle button:last-child { border-right: none; }
|
| 161 |
+
|
| 162 |
+
.backend-toggle button.active {
|
| 163 |
+
background: var(--accent-glow);
|
| 164 |
+
color: var(--accent);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.backend-toggle button:hover:not(.active) {
|
| 168 |
+
color: var(--text-secondary);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.gallery-btn {
|
| 172 |
+
all: unset;
|
| 173 |
+
display: flex;
|
| 174 |
+
align-items: center;
|
| 175 |
+
gap: 6px;
|
| 176 |
+
padding: 4px 12px;
|
| 177 |
+
font-family: var(--font-mono);
|
| 178 |
+
font-size: 11px;
|
| 179 |
+
color: var(--text-secondary);
|
| 180 |
+
border: 1px solid var(--border);
|
| 181 |
+
border-radius: 4px;
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
transition: all 0.2s;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.gallery-btn:hover {
|
| 187 |
+
border-color: var(--accent-dim);
|
| 188 |
+
color: var(--accent);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.status-dot {
|
| 192 |
+
width: 7px; height: 7px;
|
| 193 |
+
border-radius: 50%;
|
| 194 |
+
background: var(--success);
|
| 195 |
+
box-shadow: 0 0 6px var(--success);
|
| 196 |
+
animation: pulse-dot 2s ease-in-out infinite;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
@keyframes pulse-dot {
|
| 200 |
+
0%, 100% { opacity: 1; }
|
| 201 |
+
50% { opacity: 0.4; }
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/* ---- MAIN AREA ---- */
|
| 205 |
+
|
| 206 |
+
#main {
|
| 207 |
+
flex: 1;
|
| 208 |
+
display: flex;
|
| 209 |
+
position: relative;
|
| 210 |
+
min-height: 0;
|
| 211 |
+
overflow: hidden;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/* ---- 3D VIEWER ---- */
|
| 215 |
+
|
| 216 |
+
#viewer-container {
|
| 217 |
+
flex: 1;
|
| 218 |
+
position: relative;
|
| 219 |
+
background: var(--bg-void);
|
| 220 |
+
overflow: hidden;
|
| 221 |
+
min-height: 0;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
#viewer-canvas {
|
| 225 |
+
width: 100%;
|
| 226 |
+
height: 100%;
|
| 227 |
+
display: block;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* Geo stats overlay - top left */
|
| 231 |
+
#geo-stats {
|
| 232 |
+
position: absolute;
|
| 233 |
+
top: 14px;
|
| 234 |
+
left: 14px;
|
| 235 |
+
z-index: 10;
|
| 236 |
+
background: rgba(6, 8, 12, 0.85);
|
| 237 |
+
border: 1px solid var(--border);
|
| 238 |
+
border-radius: 4px;
|
| 239 |
+
padding: 10px 14px;
|
| 240 |
+
font-family: var(--font-mono);
|
| 241 |
+
font-size: 11px;
|
| 242 |
+
line-height: 1.7;
|
| 243 |
+
backdrop-filter: blur(8px);
|
| 244 |
+
display: none;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
#geo-stats.visible { display: block; }
|
| 248 |
+
|
| 249 |
+
.stat-label { color: var(--text-muted); }
|
| 250 |
+
.stat-value { color: var(--accent); }
|
| 251 |
+
|
| 252 |
+
/* CNC badge - top right of viewer (NOT behind chat) */
|
| 253 |
+
#cnc-badge {
|
| 254 |
+
position: absolute;
|
| 255 |
+
top: 14px;
|
| 256 |
+
right: 14px;
|
| 257 |
+
z-index: 10;
|
| 258 |
+
display: none;
|
| 259 |
+
gap: 6px;
|
| 260 |
+
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
#cnc-badge.visible { display: flex; }
|
| 264 |
+
body.chat-open #cnc-badge { right: calc(var(--chat-width) + 14px); }
|
| 265 |
+
|
| 266 |
+
.badge {
|
| 267 |
+
font-family: var(--font-mono);
|
| 268 |
+
font-size: 10px;
|
| 269 |
+
font-weight: 500;
|
| 270 |
+
padding: 4px 10px;
|
| 271 |
+
border-radius: 3px;
|
| 272 |
+
letter-spacing: 0.5px;
|
| 273 |
+
backdrop-filter: blur(8px);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.badge-success {
|
| 277 |
+
background: var(--success-glow);
|
| 278 |
+
border: 1px solid rgba(0, 230, 118, 0.3);
|
| 279 |
+
color: var(--success);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.badge-warning {
|
| 283 |
+
background: rgba(255, 171, 64, 0.1);
|
| 284 |
+
border: 1px solid rgba(255, 171, 64, 0.3);
|
| 285 |
+
color: var(--warning);
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.badge-error {
|
| 289 |
+
background: rgba(255, 82, 82, 0.1);
|
| 290 |
+
border: 1px solid rgba(255, 82, 82, 0.3);
|
| 291 |
+
color: var(--error);
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.badge-info {
|
| 295 |
+
background: var(--accent-glow);
|
| 296 |
+
border: 1px solid rgba(0, 180, 216, 0.3);
|
| 297 |
+
color: var(--accent);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/* Download buttons - bottom left */
|
| 301 |
+
#download-btns {
|
| 302 |
+
position: absolute;
|
| 303 |
+
bottom: 14px;
|
| 304 |
+
left: 14px;
|
| 305 |
+
z-index: 10;
|
| 306 |
+
display: none;
|
| 307 |
+
gap: 6px;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
#download-btns.visible { display: flex; }
|
| 311 |
+
|
| 312 |
+
.dl-btn {
|
| 313 |
+
font-family: var(--font-mono);
|
| 314 |
+
font-size: 10px;
|
| 315 |
+
font-weight: 500;
|
| 316 |
+
padding: 5px 14px;
|
| 317 |
+
border-radius: 3px;
|
| 318 |
+
background: rgba(6, 8, 12, 0.85);
|
| 319 |
+
border: 1px solid var(--border);
|
| 320 |
+
color: var(--text-secondary);
|
| 321 |
+
cursor: pointer;
|
| 322 |
+
text-decoration: none;
|
| 323 |
+
transition: all 0.2s;
|
| 324 |
+
backdrop-filter: blur(8px);
|
| 325 |
+
letter-spacing: 0.5px;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.dl-btn:hover {
|
| 329 |
+
border-color: var(--accent-dim);
|
| 330 |
+
color: var(--accent);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* Viewer hint */
|
| 334 |
+
#viewer-hint {
|
| 335 |
+
position: absolute;
|
| 336 |
+
bottom: 16px;
|
| 337 |
+
right: 16px;
|
| 338 |
+
z-index: 10;
|
| 339 |
+
font-family: var(--font-mono);
|
| 340 |
+
font-size: 10px;
|
| 341 |
+
color: var(--text-muted);
|
| 342 |
+
letter-spacing: 0.5px;
|
| 343 |
+
pointer-events: none;
|
| 344 |
+
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
body.chat-open #viewer-hint { right: calc(var(--chat-width) + 16px); }
|
| 348 |
+
|
| 349 |
+
/* Loading spinner */
|
| 350 |
+
#viewer-loading {
|
| 351 |
+
position: absolute;
|
| 352 |
+
inset: 0;
|
| 353 |
+
z-index: 20;
|
| 354 |
+
display: none;
|
| 355 |
+
align-items: center;
|
| 356 |
+
justify-content: center;
|
| 357 |
+
flex-direction: column;
|
| 358 |
+
gap: 16px;
|
| 359 |
+
background: rgba(6, 8, 12, 0.7);
|
| 360 |
+
backdrop-filter: blur(4px);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
#viewer-loading.visible { display: flex; }
|
| 364 |
+
|
| 365 |
+
.spinner {
|
| 366 |
+
width: 36px; height: 36px;
|
| 367 |
+
border: 2px solid var(--border);
|
| 368 |
+
border-top-color: var(--accent);
|
| 369 |
+
border-radius: 50%;
|
| 370 |
+
animation: spin 0.8s linear infinite;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 374 |
+
|
| 375 |
+
.loading-text {
|
| 376 |
+
font-family: var(--font-mono);
|
| 377 |
+
font-size: 11px;
|
| 378 |
+
color: var(--text-secondary);
|
| 379 |
+
letter-spacing: 1px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* Empty state */
|
| 383 |
+
#viewer-empty {
|
| 384 |
+
position: absolute;
|
| 385 |
+
inset: 0;
|
| 386 |
+
z-index: 5;
|
| 387 |
+
display: flex;
|
| 388 |
+
align-items: center;
|
| 389 |
+
justify-content: center;
|
| 390 |
+
flex-direction: column;
|
| 391 |
+
gap: 16px;
|
| 392 |
+
pointer-events: none;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.empty-icon {
|
| 396 |
+
width: 64px; height: 64px;
|
| 397 |
+
border: 2px solid var(--border);
|
| 398 |
+
border-radius: 8px;
|
| 399 |
+
display: flex;
|
| 400 |
+
align-items: center;
|
| 401 |
+
justify-content: center;
|
| 402 |
+
transform: rotate(45deg);
|
| 403 |
+
opacity: 0.5;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.empty-icon-inner {
|
| 407 |
+
width: 20px; height: 20px;
|
| 408 |
+
border: 2px solid var(--text-muted);
|
| 409 |
+
border-radius: 2px;
|
| 410 |
+
transform: rotate(-45deg);
|
| 411 |
+
opacity: 0.3;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.empty-text {
|
| 415 |
+
font-family: var(--font-mono);
|
| 416 |
+
font-size: 12px;
|
| 417 |
+
color: var(--text-muted);
|
| 418 |
+
letter-spacing: 1px;
|
| 419 |
+
text-align: center;
|
| 420 |
+
line-height: 1.6;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
/* ---- CHAT PANEL ---- */
|
| 424 |
+
|
| 425 |
+
#chat-panel {
|
| 426 |
+
position: absolute;
|
| 427 |
+
top: 0;
|
| 428 |
+
right: 0;
|
| 429 |
+
width: var(--chat-width);
|
| 430 |
+
height: 100%;
|
| 431 |
+
background: rgba(10, 14, 20, 0.92);
|
| 432 |
+
backdrop-filter: blur(16px);
|
| 433 |
+
border-left: 1px solid var(--border);
|
| 434 |
+
display: flex;
|
| 435 |
+
flex-direction: column;
|
| 436 |
+
z-index: 50;
|
| 437 |
+
transform: translateX(0);
|
| 438 |
+
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
#chat-panel.collapsed {
|
| 442 |
+
transform: translateX(100%);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
/* Collapse toggle */
|
| 446 |
+
#chat-toggle {
|
| 447 |
+
all: unset;
|
| 448 |
+
position: absolute;
|
| 449 |
+
top: 50%;
|
| 450 |
+
left: -28px;
|
| 451 |
+
transform: translateY(-50%);
|
| 452 |
+
width: 28px;
|
| 453 |
+
height: 56px;
|
| 454 |
+
background: rgba(10, 14, 20, 0.92);
|
| 455 |
+
backdrop-filter: blur(16px);
|
| 456 |
+
border: 1px solid var(--border);
|
| 457 |
+
border-right: none;
|
| 458 |
+
border-radius: 6px 0 0 6px;
|
| 459 |
+
display: flex;
|
| 460 |
+
align-items: center;
|
| 461 |
+
justify-content: center;
|
| 462 |
+
cursor: pointer;
|
| 463 |
+
color: var(--text-secondary);
|
| 464 |
+
font-size: 14px;
|
| 465 |
+
transition: all 0.2s;
|
| 466 |
+
z-index: 51;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
#chat-toggle:hover {
|
| 470 |
+
color: var(--accent);
|
| 471 |
+
background: rgba(10, 14, 20, 0.98);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* Floating open pill */
|
| 475 |
+
#chat-open-pill {
|
| 476 |
+
position: fixed;
|
| 477 |
+
bottom: 20px;
|
| 478 |
+
left: 50%;
|
| 479 |
+
transform: translateX(-50%);
|
| 480 |
+
z-index: 60;
|
| 481 |
+
display: none;
|
| 482 |
+
align-items: center;
|
| 483 |
+
gap: 10px;
|
| 484 |
+
padding: 10px 20px;
|
| 485 |
+
background: rgba(10, 14, 20, 0.95);
|
| 486 |
+
backdrop-filter: blur(16px);
|
| 487 |
+
border: 1px solid var(--border);
|
| 488 |
+
border-radius: 24px;
|
| 489 |
+
cursor: pointer;
|
| 490 |
+
font-family: var(--font-mono);
|
| 491 |
+
font-size: 12px;
|
| 492 |
+
color: var(--text-primary);
|
| 493 |
+
letter-spacing: 0.5px;
|
| 494 |
+
transition: all 0.3s;
|
| 495 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
#chat-open-pill:hover {
|
| 499 |
+
border-color: var(--accent-dim);
|
| 500 |
+
box-shadow: 0 4px 32px rgba(0, 180, 216, 0.15);
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
#chat-open-pill.visible { display: flex; }
|
| 504 |
+
|
| 505 |
+
.pill-dots {
|
| 506 |
+
display: flex;
|
| 507 |
+
gap: 4px;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.pill-dot {
|
| 511 |
+
width: 8px; height: 8px;
|
| 512 |
+
border-radius: 50%;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
/* Chat header */
|
| 516 |
+
.chat-header {
|
| 517 |
+
flex: 0 0 48px;
|
| 518 |
+
display: flex;
|
| 519 |
+
align-items: center;
|
| 520 |
+
justify-content: space-between;
|
| 521 |
+
padding: 0 16px;
|
| 522 |
+
border-bottom: 1px solid var(--border);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.chat-header-left {
|
| 526 |
+
display: flex;
|
| 527 |
+
align-items: center;
|
| 528 |
+
gap: 10px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.chat-header-title {
|
| 532 |
+
font-family: var(--font-mono);
|
| 533 |
+
font-size: 11px;
|
| 534 |
+
font-weight: 600;
|
| 535 |
+
letter-spacing: 2px;
|
| 536 |
+
color: var(--text-secondary);
|
| 537 |
+
text-transform: uppercase;
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
.agent-dots {
|
| 541 |
+
display: flex;
|
| 542 |
+
gap: 5px;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.agent-dot {
|
| 546 |
+
width: 8px; height: 8px;
|
| 547 |
+
border-radius: 50%;
|
| 548 |
+
opacity: 0.8;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/* Messages area */
|
| 552 |
+
#chat-messages {
|
| 553 |
+
flex: 1;
|
| 554 |
+
overflow-y: auto;
|
| 555 |
+
padding: 16px 12px;
|
| 556 |
+
display: flex;
|
| 557 |
+
flex-direction: column;
|
| 558 |
+
gap: 12px;
|
| 559 |
+
min-height: 0;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
/* Quick examples */
|
| 563 |
+
.quick-examples {
|
| 564 |
+
display: flex;
|
| 565 |
+
flex-direction: column;
|
| 566 |
+
align-items: center;
|
| 567 |
+
gap: 12px;
|
| 568 |
+
padding: 40px 16px 20px;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
.quick-examples-label {
|
| 572 |
+
font-family: var(--font-mono);
|
| 573 |
+
font-size: 10px;
|
| 574 |
+
color: var(--text-muted);
|
| 575 |
+
letter-spacing: 2px;
|
| 576 |
+
text-transform: uppercase;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
.quick-chips {
|
| 580 |
+
display: flex;
|
| 581 |
+
flex-wrap: wrap;
|
| 582 |
+
gap: 6px;
|
| 583 |
+
justify-content: center;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.quick-chip {
|
| 587 |
+
all: unset;
|
| 588 |
+
padding: 6px 12px;
|
| 589 |
+
font-family: var(--font-mono);
|
| 590 |
+
font-size: 11px;
|
| 591 |
+
color: var(--text-secondary);
|
| 592 |
+
background: var(--bg-surface);
|
| 593 |
+
border: 1px solid var(--border);
|
| 594 |
+
border-radius: 16px;
|
| 595 |
+
cursor: pointer;
|
| 596 |
+
transition: all 0.2s;
|
| 597 |
+
white-space: nowrap;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
.quick-chip:hover {
|
| 601 |
+
border-color: var(--accent-dim);
|
| 602 |
+
color: var(--accent);
|
| 603 |
+
background: var(--accent-glow);
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
/* Message bubbles */
|
| 607 |
+
.msg {
|
| 608 |
+
display: flex;
|
| 609 |
+
gap: 8px;
|
| 610 |
+
max-width: 100%;
|
| 611 |
+
animation: msg-in 0.25s ease-out both;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
@keyframes msg-in {
|
| 615 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 616 |
+
to { opacity: 1; transform: translateY(0); }
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.msg-user {
|
| 620 |
+
justify-content: flex-end;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.msg-user .msg-bubble {
|
| 624 |
+
background: #1a2a3a;
|
| 625 |
+
border: 1px solid rgba(0, 180, 216, 0.15);
|
| 626 |
+
border-radius: 12px 12px 4px 12px;
|
| 627 |
+
padding: 8px 12px;
|
| 628 |
+
font-size: 13px;
|
| 629 |
+
line-height: 1.5;
|
| 630 |
+
color: var(--text-primary);
|
| 631 |
+
max-width: 85%;
|
| 632 |
+
word-wrap: break-word;
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
.msg-agent {
|
| 636 |
+
align-items: flex-start;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.msg-avatar {
|
| 640 |
+
flex-shrink: 0;
|
| 641 |
+
width: 24px; height: 24px;
|
| 642 |
+
border-radius: 50%;
|
| 643 |
+
display: flex;
|
| 644 |
+
align-items: center;
|
| 645 |
+
justify-content: center;
|
| 646 |
+
font-size: 11px;
|
| 647 |
+
font-weight: 700;
|
| 648 |
+
color: rgba(0, 0, 0, 0.7);
|
| 649 |
+
margin-top: 2px;
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.msg-agent-body {
|
| 653 |
+
flex: 1;
|
| 654 |
+
min-width: 0;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.msg-agent-name {
|
| 658 |
+
font-family: var(--font-mono);
|
| 659 |
+
font-size: 10px;
|
| 660 |
+
font-weight: 600;
|
| 661 |
+
letter-spacing: 0.5px;
|
| 662 |
+
margin-bottom: 3px;
|
| 663 |
+
text-transform: uppercase;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
.msg-agent-bubble {
|
| 667 |
+
background: var(--bg-surface);
|
| 668 |
+
border: 1px solid var(--border);
|
| 669 |
+
border-radius: 4px 12px 12px 12px;
|
| 670 |
+
padding: 8px 12px;
|
| 671 |
+
font-size: 13px;
|
| 672 |
+
line-height: 1.5;
|
| 673 |
+
color: var(--text-primary);
|
| 674 |
+
word-wrap: break-word;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
/* CAD Coder special styling */
|
| 678 |
+
.msg-agent-bubble.cad-bubble {
|
| 679 |
+
background: rgba(255, 171, 64, 0.08);
|
| 680 |
+
border-color: rgba(255, 171, 64, 0.2);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.msg-view-code {
|
| 684 |
+
display: inline-block;
|
| 685 |
+
margin-top: 6px;
|
| 686 |
+
font-family: var(--font-mono);
|
| 687 |
+
font-size: 10px;
|
| 688 |
+
color: var(--warning);
|
| 689 |
+
cursor: pointer;
|
| 690 |
+
text-decoration: none;
|
| 691 |
+
letter-spacing: 0.5px;
|
| 692 |
+
transition: opacity 0.2s;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
.msg-view-code:hover { opacity: 0.7; }
|
| 696 |
+
|
| 697 |
+
/* Typing indicator */
|
| 698 |
+
.typing-indicator {
|
| 699 |
+
display: flex;
|
| 700 |
+
align-items: center;
|
| 701 |
+
gap: 8px;
|
| 702 |
+
padding: 8px 12px;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.typing-dots {
|
| 706 |
+
display: flex;
|
| 707 |
+
gap: 4px;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.typing-dots span {
|
| 711 |
+
width: 6px; height: 6px;
|
| 712 |
+
border-radius: 50%;
|
| 713 |
+
background: var(--text-muted);
|
| 714 |
+
animation: typing-bounce 1.2s ease-in-out infinite;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.typing-dots span:nth-child(2) { animation-delay: 0.15s; }
|
| 718 |
+
.typing-dots span:nth-child(3) { animation-delay: 0.3s; }
|
| 719 |
+
|
| 720 |
+
@keyframes typing-bounce {
|
| 721 |
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
| 722 |
+
30% { transform: translateY(-4px); opacity: 1; }
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.typing-label {
|
| 726 |
+
font-family: var(--font-mono);
|
| 727 |
+
font-size: 10px;
|
| 728 |
+
color: var(--text-muted);
|
| 729 |
+
letter-spacing: 0.5px;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
/* Chat input area */
|
| 733 |
+
.chat-input-area {
|
| 734 |
+
flex: 0 0 auto;
|
| 735 |
+
padding: 12px;
|
| 736 |
+
border-top: 1px solid var(--border);
|
| 737 |
+
display: flex;
|
| 738 |
+
flex-direction: column;
|
| 739 |
+
gap: 8px;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.chat-input-row {
|
| 743 |
+
display: flex;
|
| 744 |
+
gap: 6px;
|
| 745 |
+
align-items: flex-end;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
#chat-input {
|
| 749 |
+
flex: 1;
|
| 750 |
+
min-height: 38px;
|
| 751 |
+
max-height: 120px;
|
| 752 |
+
background: var(--bg-input);
|
| 753 |
+
border: 1px solid var(--border);
|
| 754 |
+
border-radius: 8px;
|
| 755 |
+
padding: 8px 12px;
|
| 756 |
+
color: var(--text-primary);
|
| 757 |
+
font-family: var(--font-body);
|
| 758 |
+
font-size: 13px;
|
| 759 |
+
line-height: 1.4;
|
| 760 |
+
resize: none;
|
| 761 |
+
outline: none;
|
| 762 |
+
transition: border-color 0.2s;
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
#chat-input::placeholder { color: var(--text-muted); }
|
| 766 |
+
#chat-input:focus { border-color: var(--accent-dim); }
|
| 767 |
+
|
| 768 |
+
.chat-btn {
|
| 769 |
+
all: unset;
|
| 770 |
+
flex-shrink: 0;
|
| 771 |
+
width: 34px; height: 34px;
|
| 772 |
+
border-radius: 8px;
|
| 773 |
+
display: flex;
|
| 774 |
+
align-items: center;
|
| 775 |
+
justify-content: center;
|
| 776 |
+
cursor: pointer;
|
| 777 |
+
transition: all 0.2s;
|
| 778 |
+
font-size: 16px;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
.chat-btn-preview {
|
| 782 |
+
background: rgba(255, 171, 64, 0.1);
|
| 783 |
+
border: 1px solid rgba(255, 171, 64, 0.25);
|
| 784 |
+
color: var(--warning);
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.chat-btn-preview:hover {
|
| 788 |
+
background: rgba(255, 171, 64, 0.2);
|
| 789 |
+
border-color: var(--warning);
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
.chat-btn-send {
|
| 793 |
+
background: var(--accent-glow);
|
| 794 |
+
border: 1px solid rgba(0, 180, 216, 0.3);
|
| 795 |
+
color: var(--accent);
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
.chat-btn-send:hover {
|
| 799 |
+
background: rgba(0, 180, 216, 0.25);
|
| 800 |
+
border-color: var(--accent);
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
.chat-shortcut-hint {
|
| 804 |
+
font-family: var(--font-mono);
|
| 805 |
+
font-size: 9px;
|
| 806 |
+
color: var(--text-muted);
|
| 807 |
+
text-align: right;
|
| 808 |
+
letter-spacing: 0.3px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
/* @mention autocomplete */
|
| 812 |
+
#mention-dropdown {
|
| 813 |
+
display: none;
|
| 814 |
+
position: absolute;
|
| 815 |
+
bottom: 100%;
|
| 816 |
+
left: 12px;
|
| 817 |
+
right: 12px;
|
| 818 |
+
margin-bottom: 4px;
|
| 819 |
+
background: var(--bg-panel);
|
| 820 |
+
border: 1px solid var(--border);
|
| 821 |
+
border-radius: 8px;
|
| 822 |
+
overflow: hidden;
|
| 823 |
+
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
| 824 |
+
z-index: 55;
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
#mention-dropdown.visible { display: block; }
|
| 828 |
+
|
| 829 |
+
.mention-option {
|
| 830 |
+
display: flex;
|
| 831 |
+
align-items: center;
|
| 832 |
+
gap: 10px;
|
| 833 |
+
padding: 8px 12px;
|
| 834 |
+
cursor: pointer;
|
| 835 |
+
transition: background 0.15s;
|
| 836 |
+
font-size: 12px;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.mention-option:hover,
|
| 840 |
+
.mention-option.active {
|
| 841 |
+
background: var(--bg-surface);
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
.mention-dot {
|
| 845 |
+
width: 10px; height: 10px;
|
| 846 |
+
border-radius: 50%;
|
| 847 |
+
flex-shrink: 0;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
.mention-name {
|
| 851 |
+
font-family: var(--font-mono);
|
| 852 |
+
font-weight: 500;
|
| 853 |
+
color: var(--text-primary);
|
| 854 |
+
font-size: 12px;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.mention-role {
|
| 858 |
+
font-family: var(--font-mono);
|
| 859 |
+
font-size: 10px;
|
| 860 |
+
color: var(--text-muted);
|
| 861 |
+
margin-left: auto;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
/* ---- CODE VIEWER MODAL ---- */
|
| 865 |
+
|
| 866 |
+
#code-modal {
|
| 867 |
+
display: none;
|
| 868 |
+
position: fixed;
|
| 869 |
+
inset: 0;
|
| 870 |
+
z-index: 200;
|
| 871 |
+
align-items: center;
|
| 872 |
+
justify-content: center;
|
| 873 |
+
background: rgba(6, 8, 12, 0.85);
|
| 874 |
+
backdrop-filter: blur(8px);
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
#code-modal.visible { display: flex; }
|
| 878 |
+
|
| 879 |
+
.code-modal-inner {
|
| 880 |
+
width: min(720px, 90vw);
|
| 881 |
+
max-height: 80vh;
|
| 882 |
+
background: var(--bg-panel);
|
| 883 |
+
border: 1px solid var(--border);
|
| 884 |
+
border-radius: 8px;
|
| 885 |
+
display: flex;
|
| 886 |
+
flex-direction: column;
|
| 887 |
+
overflow: hidden;
|
| 888 |
+
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
|
| 889 |
+
animation: modal-in 0.25s ease-out;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
@keyframes modal-in {
|
| 893 |
+
from { opacity: 0; transform: scale(0.96) translateY(12px); }
|
| 894 |
+
to { opacity: 1; transform: scale(1) translateY(0); }
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
.code-modal-header {
|
| 898 |
+
display: flex;
|
| 899 |
+
align-items: center;
|
| 900 |
+
justify-content: space-between;
|
| 901 |
+
padding: 12px 16px;
|
| 902 |
+
border-bottom: 1px solid var(--border);
|
| 903 |
+
}
|
| 904 |
+
|
| 905 |
+
.code-modal-title {
|
| 906 |
+
font-family: var(--font-mono);
|
| 907 |
+
font-size: 11px;
|
| 908 |
+
font-weight: 600;
|
| 909 |
+
color: var(--text-secondary);
|
| 910 |
+
letter-spacing: 1px;
|
| 911 |
+
text-transform: uppercase;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
.code-modal-close {
|
| 915 |
+
all: unset;
|
| 916 |
+
width: 28px; height: 28px;
|
| 917 |
+
display: flex;
|
| 918 |
+
align-items: center;
|
| 919 |
+
justify-content: center;
|
| 920 |
+
border-radius: 4px;
|
| 921 |
+
cursor: pointer;
|
| 922 |
+
color: var(--text-muted);
|
| 923 |
+
font-size: 18px;
|
| 924 |
+
transition: all 0.15s;
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
.code-modal-close:hover {
|
| 928 |
+
background: var(--bg-surface);
|
| 929 |
+
color: var(--text-primary);
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
#code-display {
|
| 933 |
+
flex: 1;
|
| 934 |
+
margin: 0;
|
| 935 |
+
padding: 16px;
|
| 936 |
+
background: var(--bg-input);
|
| 937 |
+
color: var(--machined-steel);
|
| 938 |
+
font-family: var(--font-mono);
|
| 939 |
+
font-size: 12px;
|
| 940 |
+
line-height: 1.7;
|
| 941 |
+
overflow: auto;
|
| 942 |
+
white-space: pre;
|
| 943 |
+
tab-size: 4;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
/* Syntax coloring */
|
| 947 |
+
.kw { color: #c792ea; }
|
| 948 |
+
.fn { color: #82aaff; }
|
| 949 |
+
.cm { color: #546e7a; }
|
| 950 |
+
.st { color: #c3e88d; }
|
| 951 |
+
.nu { color: #f78c6c; }
|
| 952 |
+
.op { color: #89ddff; }
|
| 953 |
+
|
| 954 |
+
/* ---- GALLERY MODAL ---- */
|
| 955 |
+
|
| 956 |
+
#gallery-modal {
|
| 957 |
+
display: none;
|
| 958 |
+
position: fixed;
|
| 959 |
+
inset: 0;
|
| 960 |
+
z-index: 200;
|
| 961 |
+
align-items: center;
|
| 962 |
+
justify-content: center;
|
| 963 |
+
background: rgba(6, 8, 12, 0.85);
|
| 964 |
+
backdrop-filter: blur(8px);
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
#gallery-modal.visible { display: flex; }
|
| 968 |
+
|
| 969 |
+
.gallery-modal-inner {
|
| 970 |
+
width: min(800px, 90vw);
|
| 971 |
+
max-height: 80vh;
|
| 972 |
+
background: var(--bg-panel);
|
| 973 |
+
border: 1px solid var(--border);
|
| 974 |
+
border-radius: 8px;
|
| 975 |
+
display: flex;
|
| 976 |
+
flex-direction: column;
|
| 977 |
+
overflow: hidden;
|
| 978 |
+
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
|
| 979 |
+
animation: modal-in 0.25s ease-out;
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
.gallery-modal-header {
|
| 983 |
+
display: flex;
|
| 984 |
+
align-items: center;
|
| 985 |
+
justify-content: space-between;
|
| 986 |
+
padding: 12px 16px;
|
| 987 |
+
border-bottom: 1px solid var(--border);
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
.gallery-modal-title {
|
| 991 |
+
font-family: var(--font-mono);
|
| 992 |
+
font-size: 11px;
|
| 993 |
+
font-weight: 600;
|
| 994 |
+
color: var(--text-secondary);
|
| 995 |
+
letter-spacing: 1px;
|
| 996 |
+
text-transform: uppercase;
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
.gallery-grid {
|
| 1000 |
+
flex: 1;
|
| 1001 |
+
overflow-y: auto;
|
| 1002 |
+
padding: 16px;
|
| 1003 |
+
display: flex;
|
| 1004 |
+
flex-wrap: wrap;
|
| 1005 |
+
gap: 12px;
|
| 1006 |
+
align-content: flex-start;
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
.gallery-empty {
|
| 1010 |
+
width: 100%;
|
| 1011 |
+
text-align: center;
|
| 1012 |
+
padding: 40px;
|
| 1013 |
+
font-family: var(--font-mono);
|
| 1014 |
+
font-size: 11px;
|
| 1015 |
+
color: var(--text-muted);
|
| 1016 |
+
letter-spacing: 0.5px;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.gallery-card {
|
| 1020 |
+
all: unset;
|
| 1021 |
+
flex: 0 0 auto;
|
| 1022 |
+
width: 180px;
|
| 1023 |
+
background: var(--bg-surface);
|
| 1024 |
+
border: 1px solid var(--border);
|
| 1025 |
+
border-radius: 6px;
|
| 1026 |
+
padding: 12px;
|
| 1027 |
+
cursor: pointer;
|
| 1028 |
+
transition: all 0.2s;
|
| 1029 |
+
display: flex;
|
| 1030 |
+
flex-direction: column;
|
| 1031 |
+
gap: 8px;
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
.gallery-card:hover {
|
| 1035 |
+
border-color: var(--accent-dim);
|
| 1036 |
+
background: var(--bg-input);
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
.gallery-card-name {
|
| 1040 |
+
font-family: var(--font-mono);
|
| 1041 |
+
font-size: 11px;
|
| 1042 |
+
font-weight: 500;
|
| 1043 |
+
color: var(--text-primary);
|
| 1044 |
+
white-space: nowrap;
|
| 1045 |
+
overflow: hidden;
|
| 1046 |
+
text-overflow: ellipsis;
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
.gallery-card-meta {
|
| 1050 |
+
font-family: var(--font-mono);
|
| 1051 |
+
font-size: 9px;
|
| 1052 |
+
color: var(--text-muted);
|
| 1053 |
+
display: flex;
|
| 1054 |
+
gap: 8px;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
/* ---- ANIMATIONS ---- */
|
| 1058 |
+
|
| 1059 |
+
@keyframes fade-in-up {
|
| 1060 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 1061 |
+
to { opacity: 1; transform: translateY(0); }
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
.fade-in {
|
| 1065 |
+
animation: fade-in-up 0.3s ease-out both;
|
| 1066 |
+
}
|
| 1067 |
+
|
| 1068 |
+
/* ---- RESPONSIVE ---- */
|
| 1069 |
+
|
| 1070 |
+
@media (max-width: 768px) {
|
| 1071 |
+
.logo-sub { display: none; }
|
| 1072 |
+
:root { --chat-width: 100vw; }
|
| 1073 |
+
#chat-toggle { display: none; }
|
| 1074 |
+
.gallery-btn span { display: none; }
|
| 1075 |
+
}
|
| 1076 |
+
</style>
|
| 1077 |
+
</head>
|
| 1078 |
+
<body class="chat-open">
|
| 1079 |
+
<div id="app">
|
| 1080 |
+
|
| 1081 |
+
<!-- ---- TOP BAR ---- -->
|
| 1082 |
+
<div id="topbar">
|
| 1083 |
+
<div class="logo">
|
| 1084 |
+
<span class="logo-diamond">◆</span>
|
| 1085 |
+
<span class="logo-text">NeuralCAD</span>
|
| 1086 |
+
<span class="logo-sub">Multi-Agent Design</span>
|
| 1087 |
+
</div>
|
| 1088 |
+
<div class="topbar-right">
|
| 1089 |
+
<div class="backend-toggle">
|
| 1090 |
+
<button id="btn-mock" class="active" onclick="setBackend('mock')">MOCK</button>
|
| 1091 |
+
<button id="btn-gemini" onclick="setBackend('gemini')">GEMINI</button>
|
| 1092 |
+
<button id="btn-claude" onclick="setBackend('anthropic')">CLAUDE</button>
|
| 1093 |
+
</div>
|
| 1094 |
+
<button class="gallery-btn" onclick="openGallery()">
|
| 1095 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
| 1096 |
+
<span>GALLERY</span>
|
| 1097 |
+
</button>
|
| 1098 |
+
<div class="status-dot" id="status-dot" title="Server Connected"></div>
|
| 1099 |
+
</div>
|
| 1100 |
+
</div>
|
| 1101 |
+
|
| 1102 |
+
<!-- ---- MAIN AREA ---- -->
|
| 1103 |
+
<div id="main">
|
| 1104 |
+
|
| 1105 |
+
<!-- 3D Viewer -->
|
| 1106 |
+
<div id="viewer-container">
|
| 1107 |
+
<canvas id="viewer-canvas"></canvas>
|
| 1108 |
+
|
| 1109 |
+
<div id="geo-stats">
|
| 1110 |
+
<div><span class="stat-label">VOL </span><span class="stat-value" id="stat-volume">—</span></div>
|
| 1111 |
+
<div><span class="stat-label">BBOX </span><span class="stat-value" id="stat-bbox">—</span></div>
|
| 1112 |
+
<div><span class="stat-label">FACES </span><span class="stat-value" id="stat-faces">—</span><span class="stat-label"> EDGES </span><span class="stat-value" id="stat-edges">—</span></div>
|
| 1113 |
+
</div>
|
| 1114 |
+
|
| 1115 |
+
<div id="cnc-badge">
|
| 1116 |
+
<div class="badge badge-success" id="badge-cnc"></div>
|
| 1117 |
+
<div class="badge badge-info" id="badge-axis"></div>
|
| 1118 |
+
</div>
|
| 1119 |
+
|
| 1120 |
+
<div id="download-btns">
|
| 1121 |
+
<a class="dl-btn" id="dl-step" download>STEP</a>
|
| 1122 |
+
<a class="dl-btn" id="dl-stl" download>STL</a>
|
| 1123 |
+
<a class="dl-btn" id="dl-report" download>REPORT</a>
|
| 1124 |
+
</div>
|
| 1125 |
+
|
| 1126 |
+
<div id="viewer-hint">DRAG ROTATE · SCROLL ZOOM · RIGHT-DRAG PAN</div>
|
| 1127 |
+
|
| 1128 |
+
<div id="viewer-loading">
|
| 1129 |
+
<div class="spinner"></div>
|
| 1130 |
+
<div class="loading-text" id="loading-msg">GENERATING MODEL...</div>
|
| 1131 |
+
</div>
|
| 1132 |
+
|
| 1133 |
+
<div id="viewer-empty">
|
| 1134 |
+
<div class="empty-icon"><div class="empty-icon-inner"></div></div>
|
| 1135 |
+
<div class="empty-text">Start a conversation to<br>design your part</div>
|
| 1136 |
+
</div>
|
| 1137 |
+
</div>
|
| 1138 |
+
|
| 1139 |
+
<!-- Chat Panel -->
|
| 1140 |
+
<div id="chat-panel">
|
| 1141 |
+
<button id="chat-toggle" onclick="toggleChat()" title="Toggle chat panel">◀</button>
|
| 1142 |
+
|
| 1143 |
+
<div class="chat-header">
|
| 1144 |
+
<div class="chat-header-left">
|
| 1145 |
+
<span class="chat-header-title">Design Chat</span>
|
| 1146 |
+
<button onclick="newDesign()" title="New Design" style="background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);padding:2px 8px;font-size:10px;cursor:pointer;margin-left:8px;">NEW</button>
|
| 1147 |
+
<div class="agent-dots">
|
| 1148 |
+
<div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
|
| 1149 |
+
<div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
|
| 1150 |
+
<div class="agent-dot" style="background: var(--agent-cnc);" title="CNC Agent"></div>
|
| 1151 |
+
<div class="agent-dot" style="background: var(--agent-cad);" title="CAD Coder Agent"></div>
|
| 1152 |
+
</div>
|
| 1153 |
+
</div>
|
| 1154 |
+
</div>
|
| 1155 |
+
|
| 1156 |
+
<div id="chat-messages">
|
| 1157 |
+
<div class="quick-examples" id="quick-examples">
|
| 1158 |
+
<div class="quick-examples-label">Quick Start</div>
|
| 1159 |
+
<div class="quick-chips">
|
| 1160 |
+
<button class="quick-chip" onclick="quickSend('Design a servo bracket')">Design a servo bracket</button>
|
| 1161 |
+
<button class="quick-chip" onclick="quickSend('I need a spur gear')">I need a spur gear</button>
|
| 1162 |
+
<button class="quick-chip" onclick="quickSend('Create a heatsink')">Create a heatsink</button>
|
| 1163 |
+
<button class="quick-chip" onclick="quickSend('Design a pipe flange')">Design a pipe flange</button>
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
</div>
|
| 1167 |
+
|
| 1168 |
+
<div class="chat-input-area" style="position: relative;">
|
| 1169 |
+
<div id="mention-dropdown">
|
| 1170 |
+
<div class="mention-option" data-agent="design" onclick="insertMention('design')">
|
| 1171 |
+
<div class="mention-dot" style="background: var(--agent-design);"></div>
|
| 1172 |
+
<span class="mention-name">@design</span>
|
| 1173 |
+
<span class="mention-role">Design Agent</span>
|
| 1174 |
+
</div>
|
| 1175 |
+
<div class="mention-option" data-agent="engineering" onclick="insertMention('engineering')">
|
| 1176 |
+
<div class="mention-dot" style="background: var(--agent-engineering);"></div>
|
| 1177 |
+
<span class="mention-name">@engineering</span>
|
| 1178 |
+
<span class="mention-role">Engineering Agent</span>
|
| 1179 |
+
</div>
|
| 1180 |
+
<div class="mention-option" data-agent="cnc" onclick="insertMention('cnc')">
|
| 1181 |
+
<div class="mention-dot" style="background: var(--agent-cnc);"></div>
|
| 1182 |
+
<span class="mention-name">@cnc</span>
|
| 1183 |
+
<span class="mention-role">CNC Agent</span>
|
| 1184 |
+
</div>
|
| 1185 |
+
<div class="mention-option" data-agent="cad" onclick="insertMention('cad')">
|
| 1186 |
+
<div class="mention-dot" style="background: var(--agent-cad);"></div>
|
| 1187 |
+
<span class="mention-name">@cad</span>
|
| 1188 |
+
<span class="mention-role">CAD Coder</span>
|
| 1189 |
+
</div>
|
| 1190 |
+
</div>
|
| 1191 |
+
<div class="chat-input-row">
|
| 1192 |
+
<textarea id="chat-input" rows="1" placeholder="Type your message..."></textarea>
|
| 1193 |
+
<button class="chat-btn chat-btn-preview" onclick="sendPreview()" title="Generate 3D preview">
|
| 1194 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
| 1195 |
+
</button>
|
| 1196 |
+
<button class="chat-btn chat-btn-send" onclick="sendFromInput()" title="Send message">
|
| 1197 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
| 1198 |
+
</button>
|
| 1199 |
+
</div>
|
| 1200 |
+
<div class="chat-shortcut-hint">Ctrl+Enter to send</div>
|
| 1201 |
+
</div>
|
| 1202 |
+
</div>
|
| 1203 |
+
|
| 1204 |
+
</div>
|
| 1205 |
+
</div>
|
| 1206 |
+
|
| 1207 |
+
<!-- Floating open pill (when chat collapsed) -->
|
| 1208 |
+
<div id="chat-open-pill" onclick="toggleChat()">
|
| 1209 |
+
<span>Open Chat</span>
|
| 1210 |
+
<div class="pill-dots">
|
| 1211 |
+
<div class="pill-dot" style="background: var(--agent-design);"></div>
|
| 1212 |
+
<div class="pill-dot" style="background: var(--agent-engineering);"></div>
|
| 1213 |
+
<div class="pill-dot" style="background: var(--agent-cnc);"></div>
|
| 1214 |
+
<div class="pill-dot" style="background: var(--agent-cad);"></div>
|
| 1215 |
+
</div>
|
| 1216 |
+
<span>▶</span>
|
| 1217 |
+
</div>
|
| 1218 |
+
|
| 1219 |
+
<!-- Code Viewer Modal -->
|
| 1220 |
+
<div id="code-modal">
|
| 1221 |
+
<div class="code-modal-inner">
|
| 1222 |
+
<div class="code-modal-header">
|
| 1223 |
+
<span class="code-modal-title">CadQuery Code</span>
|
| 1224 |
+
<button class="code-modal-close" onclick="closeCodeModal()">×</button>
|
| 1225 |
+
</div>
|
| 1226 |
+
<pre id="code-display"></pre>
|
| 1227 |
+
</div>
|
| 1228 |
+
</div>
|
| 1229 |
+
|
| 1230 |
+
<!-- Gallery Modal -->
|
| 1231 |
+
<div id="gallery-modal">
|
| 1232 |
+
<div class="gallery-modal-inner">
|
| 1233 |
+
<div class="gallery-modal-header">
|
| 1234 |
+
<span class="gallery-modal-title">Model Gallery</span>
|
| 1235 |
+
<button class="code-modal-close" onclick="closeGallery()">×</button>
|
| 1236 |
+
</div>
|
| 1237 |
+
<div class="gallery-grid" id="gallery-grid">
|
| 1238 |
+
<div class="gallery-empty">No models generated yet.</div>
|
| 1239 |
+
</div>
|
| 1240 |
+
</div>
|
| 1241 |
+
</div>
|
| 1242 |
+
|
| 1243 |
+
<script>
|
| 1244 |
+
// ── STATE ─────────────────────────────────────────────
|
| 1245 |
+
|
| 1246 |
+
let currentBackend = 'mock';
|
| 1247 |
+
let chatHistory = [];
|
| 1248 |
+
let designState = {};
|
| 1249 |
+
let chatPanelOpen = true;
|
| 1250 |
+
let currentPartName = '';
|
| 1251 |
+
let currentCode = '';
|
| 1252 |
+
let scene, camera, renderer, controls, currentMesh, gridHelper;
|
| 1253 |
+
const galleryItems = [];
|
| 1254 |
+
let mentionActive = false;
|
| 1255 |
+
let mentionIndex = 0;
|
| 1256 |
+
|
| 1257 |
+
// Persist/restore from localStorage
|
| 1258 |
+
function saveState() {
|
| 1259 |
+
try {
|
| 1260 |
+
localStorage.setItem('neuralcad_history', JSON.stringify(chatHistory));
|
| 1261 |
+
localStorage.setItem('neuralcad_state', JSON.stringify(designState));
|
| 1262 |
+
} catch (e) { /* quota exceeded, ignore */ }
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
function loadState() {
|
| 1266 |
+
try {
|
| 1267 |
+
const h = localStorage.getItem('neuralcad_history');
|
| 1268 |
+
const s = localStorage.getItem('neuralcad_state');
|
| 1269 |
+
if (h) chatHistory = JSON.parse(h);
|
| 1270 |
+
if (s) designState = JSON.parse(s);
|
| 1271 |
+
} catch (e) { /* corrupted, ignore */ }
|
| 1272 |
+
}
|
| 1273 |
+
|
| 1274 |
+
function clearState() {
|
| 1275 |
+
chatHistory = [];
|
| 1276 |
+
designState = {};
|
| 1277 |
+
localStorage.removeItem('neuralcad_history');
|
| 1278 |
+
localStorage.removeItem('neuralcad_state');
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
function newDesign() {
|
| 1282 |
+
if (!confirm('Start a new design? Current conversation will be cleared.')) return;
|
| 1283 |
+
clearState();
|
| 1284 |
+
// Clear chat UI
|
| 1285 |
+
const msgs = document.getElementById('chat-messages');
|
| 1286 |
+
if (msgs) msgs.innerHTML = '';
|
| 1287 |
+
// Show examples again
|
| 1288 |
+
const examples = document.getElementById('quick-examples');
|
| 1289 |
+
if (examples) examples.style.display = '';
|
| 1290 |
+
// Clear 3D viewer
|
| 1291 |
+
if (currentMesh) {
|
| 1292 |
+
scene.remove(currentMesh);
|
| 1293 |
+
currentMesh.geometry.dispose();
|
| 1294 |
+
currentMesh.material.dispose();
|
| 1295 |
+
currentMesh = null;
|
| 1296 |
+
}
|
| 1297 |
+
// Hide overlays
|
| 1298 |
+
const geo = document.getElementById('geo-stats');
|
| 1299 |
+
if (geo) geo.classList.remove('visible');
|
| 1300 |
+
const cnc = document.getElementById('cnc-badge');
|
| 1301 |
+
if (cnc) cnc.classList.remove('visible');
|
| 1302 |
+
const dl = document.getElementById('download-btns');
|
| 1303 |
+
if (dl) dl.classList.remove('visible');
|
| 1304 |
+
// Show empty state
|
| 1305 |
+
const empty = document.getElementById('viewer-empty');
|
| 1306 |
+
if (empty) empty.style.display = '';
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
+
const AGENTS = {
|
| 1310 |
+
design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
|
| 1311 |
+
engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
|
| 1312 |
+
cnc: { name: 'CNC', color: '#00e676', avatar: 'C' },
|
| 1313 |
+
cad: { name: 'CAD Coder', color: '#ffab40', avatar: '{}' },
|
| 1314 |
+
};
|
| 1315 |
+
|
| 1316 |
+
// ── THREE.JS SETUP ────────────────────────────────────
|
| 1317 |
+
|
| 1318 |
+
function initViewer() {
|
| 1319 |
+
const canvas = document.getElementById('viewer-canvas');
|
| 1320 |
+
const container = document.getElementById('viewer-container');
|
| 1321 |
+
|
| 1322 |
+
scene = new THREE.Scene();
|
| 1323 |
+
|
| 1324 |
+
// Camera
|
| 1325 |
+
camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 10000);
|
| 1326 |
+
camera.position.set(120, 80, 120);
|
| 1327 |
+
|
| 1328 |
+
// Renderer
|
| 1329 |
+
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
| 1330 |
+
renderer.setPixelRatio(window.devicePixelRatio);
|
| 1331 |
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
| 1332 |
+
renderer.setClearColor(0x06080c, 1);
|
| 1333 |
+
renderer.shadowMap.enabled = true;
|
| 1334 |
+
|
| 1335 |
+
// Lights
|
| 1336 |
+
const ambient = new THREE.AmbientLight(0x334466, 0.6);
|
| 1337 |
+
scene.add(ambient);
|
| 1338 |
+
|
| 1339 |
+
const dirLight1 = new THREE.DirectionalLight(0xddeeff, 0.8);
|
| 1340 |
+
dirLight1.position.set(100, 150, 100);
|
| 1341 |
+
dirLight1.castShadow = true;
|
| 1342 |
+
scene.add(dirLight1);
|
| 1343 |
+
|
| 1344 |
+
const dirLight2 = new THREE.DirectionalLight(0x8899bb, 0.4);
|
| 1345 |
+
dirLight2.position.set(-80, 60, -60);
|
| 1346 |
+
scene.add(dirLight2);
|
| 1347 |
+
|
| 1348 |
+
const rimLight = new THREE.DirectionalLight(0x00b4d8, 0.15);
|
| 1349 |
+
rimLight.position.set(0, -50, 100);
|
| 1350 |
+
scene.add(rimLight);
|
| 1351 |
+
|
| 1352 |
+
// Grid helper
|
| 1353 |
+
gridHelper = new THREE.GridHelper(400, 40, 0x1a2636, 0x111822);
|
| 1354 |
+
gridHelper.position.y = -0.5;
|
| 1355 |
+
scene.add(gridHelper);
|
| 1356 |
+
|
| 1357 |
+
// Controls
|
| 1358 |
+
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
| 1359 |
+
controls.enableDamping = true;
|
| 1360 |
+
controls.dampingFactor = 0.08;
|
| 1361 |
+
controls.rotateSpeed = 0.6;
|
| 1362 |
+
controls.minDistance = 10;
|
| 1363 |
+
controls.maxDistance = 2000;
|
| 1364 |
+
|
| 1365 |
+
// Handle resize
|
| 1366 |
+
const ro = new ResizeObserver(() => {
|
| 1367 |
+
const w = container.clientWidth;
|
| 1368 |
+
const h = container.clientHeight;
|
| 1369 |
+
camera.aspect = w / h;
|
| 1370 |
+
camera.updateProjectionMatrix();
|
| 1371 |
+
renderer.setSize(w, h);
|
| 1372 |
+
});
|
| 1373 |
+
ro.observe(container);
|
| 1374 |
+
|
| 1375 |
+
animate();
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
function animate() {
|
| 1379 |
+
requestAnimationFrame(animate);
|
| 1380 |
+
controls.update();
|
| 1381 |
+
renderer.render(scene, camera);
|
| 1382 |
+
}
|
| 1383 |
+
|
| 1384 |
+
function loadSTL(url) {
|
| 1385 |
+
return new Promise((resolve, reject) => {
|
| 1386 |
+
const loader = new THREE.STLLoader();
|
| 1387 |
+
loader.load(url, (geometry) => {
|
| 1388 |
+
if (currentMesh) {
|
| 1389 |
+
scene.remove(currentMesh);
|
| 1390 |
+
currentMesh.geometry.dispose();
|
| 1391 |
+
currentMesh.material.dispose();
|
| 1392 |
+
}
|
| 1393 |
+
|
| 1394 |
+
const material = new THREE.MeshPhongMaterial({
|
| 1395 |
+
color: 0x7799aa,
|
| 1396 |
+
specular: 0x445566,
|
| 1397 |
+
shininess: 60,
|
| 1398 |
+
flatShading: false,
|
| 1399 |
+
});
|
| 1400 |
+
|
| 1401 |
+
const mesh = new THREE.Mesh(geometry, material);
|
| 1402 |
+
mesh.castShadow = true;
|
| 1403 |
+
mesh.receiveShadow = true;
|
| 1404 |
+
|
| 1405 |
+
geometry.computeBoundingBox();
|
| 1406 |
+
const center = new THREE.Vector3();
|
| 1407 |
+
geometry.boundingBox.getCenter(center);
|
| 1408 |
+
mesh.position.sub(center);
|
| 1409 |
+
|
| 1410 |
+
scene.add(mesh);
|
| 1411 |
+
currentMesh = mesh;
|
| 1412 |
+
|
| 1413 |
+
// Fit camera
|
| 1414 |
+
const size = new THREE.Vector3();
|
| 1415 |
+
geometry.boundingBox.getSize(size);
|
| 1416 |
+
const maxDim = Math.max(size.x, size.y, size.z);
|
| 1417 |
+
const dist = maxDim * 2.5;
|
| 1418 |
+
camera.position.set(dist * 0.7, dist * 0.5, dist * 0.7);
|
| 1419 |
+
controls.target.set(0, 0, 0);
|
| 1420 |
+
controls.update();
|
| 1421 |
+
|
| 1422 |
+
// Update grid to match model scale
|
| 1423 |
+
if (gridHelper) {
|
| 1424 |
+
gridHelper.position.y = -size.y / 2 - 0.5;
|
| 1425 |
+
}
|
| 1426 |
+
|
| 1427 |
+
document.getElementById('viewer-empty').style.display = 'none';
|
| 1428 |
+
resolve();
|
| 1429 |
+
}, undefined, reject);
|
| 1430 |
+
});
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
// ── BACKEND TOGGLE ────────────────────────────────────
|
| 1434 |
+
|
| 1435 |
+
function setBackend(name) {
|
| 1436 |
+
currentBackend = name;
|
| 1437 |
+
document.getElementById('btn-mock').classList.toggle('active', name === 'mock');
|
| 1438 |
+
document.getElementById('btn-gemini').classList.toggle('active', name === 'gemini');
|
| 1439 |
+
document.getElementById('btn-claude').classList.toggle('active', name === 'anthropic');
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
// ── CHAT PANEL TOGGLE ─────────────────────────────────
|
| 1443 |
+
|
| 1444 |
+
function toggleChat() {
|
| 1445 |
+
chatPanelOpen = !chatPanelOpen;
|
| 1446 |
+
const panel = document.getElementById('chat-panel');
|
| 1447 |
+
const pill = document.getElementById('chat-open-pill');
|
| 1448 |
+
const toggle = document.getElementById('chat-toggle');
|
| 1449 |
+
|
| 1450 |
+
if (chatPanelOpen) {
|
| 1451 |
+
panel.classList.remove('collapsed');
|
| 1452 |
+
pill.classList.remove('visible');
|
| 1453 |
+
toggle.innerHTML = '◀';
|
| 1454 |
+
document.body.classList.add('chat-open');
|
| 1455 |
+
} else {
|
| 1456 |
+
panel.classList.add('collapsed');
|
| 1457 |
+
pill.classList.add('visible');
|
| 1458 |
+
toggle.innerHTML = '▶';
|
| 1459 |
+
document.body.classList.remove('chat-open');
|
| 1460 |
+
}
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
// ── CHAT MESSAGING ────────────────────────────────────
|
| 1464 |
+
|
| 1465 |
+
async function sendMessage(text) {
|
| 1466 |
+
if (!text.trim()) return;
|
| 1467 |
+
|
| 1468 |
+
// Parse @mentions
|
| 1469 |
+
const mentions = [];
|
| 1470 |
+
const mentionRegex = /@(design|engineering|cnc|cad)\b/gi;
|
| 1471 |
+
let match;
|
| 1472 |
+
while ((match = mentionRegex.exec(text)) !== null) {
|
| 1473 |
+
mentions.push(match[1].toLowerCase());
|
| 1474 |
+
}
|
| 1475 |
+
const cleanedText = text.replace(mentionRegex, '').trim();
|
| 1476 |
+
|
| 1477 |
+
// Hide quick examples
|
| 1478 |
+
const examples = document.getElementById('quick-examples');
|
| 1479 |
+
if (examples) examples.style.display = 'none';
|
| 1480 |
+
|
| 1481 |
+
// Add user message to UI
|
| 1482 |
+
addMessage({ role: 'user', content: text });
|
| 1483 |
+
|
| 1484 |
+
// Show typing
|
| 1485 |
+
showTyping();
|
| 1486 |
+
|
| 1487 |
+
try {
|
| 1488 |
+
// Send history WITHOUT the current message (backend appends it)
|
| 1489 |
+
const resp = await fetch('/api/chat', {
|
| 1490 |
+
method: 'POST',
|
| 1491 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1492 |
+
body: JSON.stringify({
|
| 1493 |
+
message: cleanedText,
|
| 1494 |
+
history: chatHistory,
|
| 1495 |
+
mentions: mentions,
|
| 1496 |
+
backend: currentBackend,
|
| 1497 |
+
design_state: designState,
|
| 1498 |
+
}),
|
| 1499 |
+
});
|
| 1500 |
+
|
| 1501 |
+
// Add to history AFTER sending (so it's included in future turns)
|
| 1502 |
+
chatHistory.push({ role: 'user', content: text });
|
| 1503 |
+
saveState();
|
| 1504 |
+
const data = await resp.json();
|
| 1505 |
+
|
| 1506 |
+
hideTyping();
|
| 1507 |
+
|
| 1508 |
+
// Add agent responses
|
| 1509 |
+
for (const r of data.responses) {
|
| 1510 |
+
addMessage({
|
| 1511 |
+
role: 'agent',
|
| 1512 |
+
agent_id: r.agent_id,
|
| 1513 |
+
agent_name: r.agent_name,
|
| 1514 |
+
content: r.message,
|
| 1515 |
+
color: r.color,
|
| 1516 |
+
avatar: r.avatar,
|
| 1517 |
+
code: r.code,
|
| 1518 |
+
});
|
| 1519 |
+
chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
|
| 1520 |
+
}
|
| 1521 |
+
|
| 1522 |
+
if (data.design_state) {
|
| 1523 |
+
designState = data.design_state;
|
| 1524 |
+
}
|
| 1525 |
+
saveState();
|
| 1526 |
+
|
| 1527 |
+
// If preview available, load 3D model
|
| 1528 |
+
if (data.preview && data.preview.success) {
|
| 1529 |
+
setViewerLoading(true, 'LOADING 3D MODEL...');
|
| 1530 |
+
try {
|
| 1531 |
+
await loadSTL(data.preview.stl_url);
|
| 1532 |
+
} catch (e) {
|
| 1533 |
+
console.warn('STL load failed:', e);
|
| 1534 |
+
}
|
| 1535 |
+
setViewerLoading(false);
|
| 1536 |
+
updateGeoStats(data.preview.execution);
|
| 1537 |
+
updateCNCBadge(data.preview.validation);
|
| 1538 |
+
updateDownloads(data.preview.part_name);
|
| 1539 |
+
|
| 1540 |
+
if (data.preview.part_name) {
|
| 1541 |
+
currentPartName = data.preview.part_name;
|
| 1542 |
+
addToGallery(data.preview);
|
| 1543 |
+
}
|
| 1544 |
+
}
|
| 1545 |
+
} catch (err) {
|
| 1546 |
+
hideTyping();
|
| 1547 |
+
addMessage({
|
| 1548 |
+
role: 'agent',
|
| 1549 |
+
agent_id: 'system',
|
| 1550 |
+
agent_name: 'System',
|
| 1551 |
+
content: 'Error: ' + err.message,
|
| 1552 |
+
color: '#ff5252',
|
| 1553 |
+
avatar: '!',
|
| 1554 |
+
});
|
| 1555 |
+
}
|
| 1556 |
+
}
|
| 1557 |
+
|
| 1558 |
+
function sendFromInput() {
|
| 1559 |
+
const input = document.getElementById('chat-input');
|
| 1560 |
+
const text = input.value.trim();
|
| 1561 |
+
if (!text) return;
|
| 1562 |
+
input.value = '';
|
| 1563 |
+
input.style.height = 'auto';
|
| 1564 |
+
closeMentionDropdown();
|
| 1565 |
+
sendMessage(text);
|
| 1566 |
+
}
|
| 1567 |
+
|
| 1568 |
+
function sendPreview() {
|
| 1569 |
+
sendMessage('@cad Generate a 3D preview based on our discussion');
|
| 1570 |
+
}
|
| 1571 |
+
|
| 1572 |
+
function quickSend(text) {
|
| 1573 |
+
const examples = document.getElementById('quick-examples');
|
| 1574 |
+
if (examples) examples.style.display = 'none';
|
| 1575 |
+
sendMessage(text);
|
| 1576 |
+
}
|
| 1577 |
+
|
| 1578 |
+
// ── MESSAGE RENDERING ─────────────────────────────────
|
| 1579 |
+
|
| 1580 |
+
function addMessage(msg) {
|
| 1581 |
+
const container = document.getElementById('chat-messages');
|
| 1582 |
+
|
| 1583 |
+
const el = document.createElement('div');
|
| 1584 |
+
|
| 1585 |
+
if (msg.role === 'user') {
|
| 1586 |
+
el.className = 'msg msg-user';
|
| 1587 |
+
el.innerHTML = '<div class="msg-bubble">' + escapeHtml(msg.content) + '</div>';
|
| 1588 |
+
} else {
|
| 1589 |
+
const agentId = msg.agent_id || 'system';
|
| 1590 |
+
const agentInfo = AGENTS[agentId] || { name: msg.agent_name || 'Agent', color: msg.color || '#5a7089', avatar: '?' };
|
| 1591 |
+
const color = msg.color || agentInfo.color;
|
| 1592 |
+
const avatar = msg.avatar || agentInfo.avatar;
|
| 1593 |
+
const name = msg.agent_name || agentInfo.name;
|
| 1594 |
+
const isCad = agentId === 'cad';
|
| 1595 |
+
|
| 1596 |
+
el.className = 'msg msg-agent';
|
| 1597 |
+
|
| 1598 |
+
let html = '<div class="msg-avatar" style="background: ' + color + ';">' + avatar + '</div>';
|
| 1599 |
+
html += '<div class="msg-agent-body">';
|
| 1600 |
+
html += '<div class="msg-agent-name" style="color: ' + color + ';">' + escapeHtml(name) + '</div>';
|
| 1601 |
+
html += '<div class="msg-agent-bubble' + (isCad ? ' cad-bubble' : '') + '">' + escapeHtml(msg.content);
|
| 1602 |
+
|
| 1603 |
+
if (msg.code) {
|
| 1604 |
+
currentCode = msg.code;
|
| 1605 |
+
html += '<br><a class="msg-view-code" onclick="openCodeModal()">▶ View code</a>';
|
| 1606 |
+
}
|
| 1607 |
+
|
| 1608 |
+
html += '</div></div>';
|
| 1609 |
+
el.innerHTML = html;
|
| 1610 |
+
}
|
| 1611 |
+
|
| 1612 |
+
container.appendChild(el);
|
| 1613 |
+
scrollChatToBottom();
|
| 1614 |
+
}
|
| 1615 |
+
|
| 1616 |
+
function showTyping() {
|
| 1617 |
+
const container = document.getElementById('chat-messages');
|
| 1618 |
+
const el = document.createElement('div');
|
| 1619 |
+
el.className = 'typing-indicator';
|
| 1620 |
+
el.id = 'typing-indicator';
|
| 1621 |
+
el.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div><span class="typing-label">Agents are thinking...</span>';
|
| 1622 |
+
container.appendChild(el);
|
| 1623 |
+
scrollChatToBottom();
|
| 1624 |
+
}
|
| 1625 |
+
|
| 1626 |
+
function hideTyping() {
|
| 1627 |
+
const el = document.getElementById('typing-indicator');
|
| 1628 |
+
if (el) el.remove();
|
| 1629 |
+
}
|
| 1630 |
+
|
| 1631 |
+
function scrollChatToBottom() {
|
| 1632 |
+
const container = document.getElementById('chat-messages');
|
| 1633 |
+
requestAnimationFrame(() => {
|
| 1634 |
+
container.scrollTop = container.scrollHeight;
|
| 1635 |
+
});
|
| 1636 |
+
}
|
| 1637 |
+
|
| 1638 |
+
// ── @MENTION AUTOCOMPLETE ─────────────────────────────
|
| 1639 |
+
|
| 1640 |
+
const mentionAgents = ['design', 'engineering', 'cnc', 'cad'];
|
| 1641 |
+
|
| 1642 |
+
function handleInputForMention(e) {
|
| 1643 |
+
const input = document.getElementById('chat-input');
|
| 1644 |
+
const val = input.value;
|
| 1645 |
+
const pos = input.selectionStart;
|
| 1646 |
+
|
| 1647 |
+
// Find @ before cursor
|
| 1648 |
+
const before = val.substring(0, pos);
|
| 1649 |
+
const atMatch = before.match(/@(\w*)$/);
|
| 1650 |
+
|
| 1651 |
+
if (atMatch) {
|
| 1652 |
+
const query = atMatch[1].toLowerCase();
|
| 1653 |
+
const filtered = mentionAgents.filter(a => a.startsWith(query));
|
| 1654 |
+
|
| 1655 |
+
if (filtered.length > 0) {
|
| 1656 |
+
showMentionDropdown(filtered);
|
| 1657 |
+
mentionActive = true;
|
| 1658 |
+
return;
|
| 1659 |
+
}
|
| 1660 |
+
}
|
| 1661 |
+
|
| 1662 |
+
closeMentionDropdown();
|
| 1663 |
+
}
|
| 1664 |
+
|
| 1665 |
+
function showMentionDropdown(filtered) {
|
| 1666 |
+
const dropdown = document.getElementById('mention-dropdown');
|
| 1667 |
+
const options = dropdown.querySelectorAll('.mention-option');
|
| 1668 |
+
let visibleCount = 0;
|
| 1669 |
+
|
| 1670 |
+
options.forEach(opt => {
|
| 1671 |
+
const agent = opt.dataset.agent;
|
| 1672 |
+
if (filtered.includes(agent)) {
|
| 1673 |
+
opt.style.display = 'flex';
|
| 1674 |
+
visibleCount++;
|
| 1675 |
+
} else {
|
| 1676 |
+
opt.style.display = 'none';
|
| 1677 |
+
}
|
| 1678 |
+
});
|
| 1679 |
+
|
| 1680 |
+
if (visibleCount > 0) {
|
| 1681 |
+
dropdown.classList.add('visible');
|
| 1682 |
+
mentionIndex = 0;
|
| 1683 |
+
updateMentionHighlight();
|
| 1684 |
+
}
|
| 1685 |
+
}
|
| 1686 |
+
|
| 1687 |
+
function closeMentionDropdown() {
|
| 1688 |
+
document.getElementById('mention-dropdown').classList.remove('visible');
|
| 1689 |
+
mentionActive = false;
|
| 1690 |
+
}
|
| 1691 |
+
|
| 1692 |
+
function updateMentionHighlight() {
|
| 1693 |
+
const options = Array.from(document.querySelectorAll('#mention-dropdown .mention-option'))
|
| 1694 |
+
.filter(o => o.style.display !== 'none');
|
| 1695 |
+
options.forEach((o, i) => o.classList.toggle('active', i === mentionIndex));
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
function insertMention(agent) {
|
| 1699 |
+
const input = document.getElementById('chat-input');
|
| 1700 |
+
const val = input.value;
|
| 1701 |
+
const pos = input.selectionStart;
|
| 1702 |
+
const before = val.substring(0, pos);
|
| 1703 |
+
const after = val.substring(pos);
|
| 1704 |
+
const atPos = before.lastIndexOf('@');
|
| 1705 |
+
|
| 1706 |
+
input.value = before.substring(0, atPos) + '@' + agent + ' ' + after;
|
| 1707 |
+
input.focus();
|
| 1708 |
+
const newPos = atPos + agent.length + 2;
|
| 1709 |
+
input.setSelectionRange(newPos, newPos);
|
| 1710 |
+
closeMentionDropdown();
|
| 1711 |
+
}
|
| 1712 |
+
|
| 1713 |
+
// ── UI UPDATES ────────────────────────────────────────
|
| 1714 |
+
|
| 1715 |
+
function setViewerLoading(on, msg) {
|
| 1716 |
+
const el = document.getElementById('viewer-loading');
|
| 1717 |
+
if (on) {
|
| 1718 |
+
el.classList.add('visible');
|
| 1719 |
+
document.getElementById('loading-msg').textContent = msg || 'GENERATING...';
|
| 1720 |
+
} else {
|
| 1721 |
+
el.classList.remove('visible');
|
| 1722 |
+
}
|
| 1723 |
+
}
|
| 1724 |
+
|
| 1725 |
+
function updateGeoStats(exec) {
|
| 1726 |
+
if (!exec || !exec.success) return;
|
| 1727 |
+
const el = document.getElementById('geo-stats');
|
| 1728 |
+
el.classList.add('visible');
|
| 1729 |
+
|
| 1730 |
+
const vol = exec.volume_mm3;
|
| 1731 |
+
document.getElementById('stat-volume').textContent =
|
| 1732 |
+
vol > 1000 ? (vol / 1000).toFixed(1) + ' cm\u00B3' : vol.toFixed(1) + ' mm\u00B3';
|
| 1733 |
+
|
| 1734 |
+
const bbox = exec.bounding_box_mm;
|
| 1735 |
+
if (bbox && bbox.length === 3) {
|
| 1736 |
+
document.getElementById('stat-bbox').textContent =
|
| 1737 |
+
bbox.map(v => v.toFixed(1)).join(' \u00D7 ') + ' mm';
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
document.getElementById('stat-faces').textContent = exec.face_count || '\u2014';
|
| 1741 |
+
document.getElementById('stat-edges').textContent = exec.edge_count || '\u2014';
|
| 1742 |
+
}
|
| 1743 |
+
|
| 1744 |
+
function updateCNCBadge(validation) {
|
| 1745 |
+
const el = document.getElementById('cnc-badge');
|
| 1746 |
+
if (!validation) { el.classList.remove('visible'); return; }
|
| 1747 |
+
el.classList.add('visible');
|
| 1748 |
+
|
| 1749 |
+
const cncBadge = document.getElementById('badge-cnc');
|
| 1750 |
+
if (validation.machinable) {
|
| 1751 |
+
cncBadge.className = 'badge badge-success';
|
| 1752 |
+
cncBadge.textContent = '\u2713 CNC MACHINABLE';
|
| 1753 |
+
} else {
|
| 1754 |
+
cncBadge.className = 'badge badge-error';
|
| 1755 |
+
cncBadge.textContent = '\u2717 NOT MACHINABLE';
|
| 1756 |
+
}
|
| 1757 |
+
|
| 1758 |
+
const axisBadge = document.getElementById('badge-axis');
|
| 1759 |
+
axisBadge.textContent = (validation.axis_recommendation || '').toUpperCase();
|
| 1760 |
+
}
|
| 1761 |
+
|
| 1762 |
+
function updateDownloads(partName) {
|
| 1763 |
+
const el = document.getElementById('download-btns');
|
| 1764 |
+
if (!partName) { el.classList.remove('visible'); return; }
|
| 1765 |
+
el.classList.add('visible');
|
| 1766 |
+
|
| 1767 |
+
document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
|
| 1768 |
+
document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
|
| 1769 |
+
document.getElementById('dl-report').href = '/api/models/' + partName + '_report.json';
|
| 1770 |
+
}
|
| 1771 |
+
|
| 1772 |
+
// ── CODE MODAL ────────────────────────────────────────
|
| 1773 |
+
|
| 1774 |
+
function openCodeModal() {
|
| 1775 |
+
const modal = document.getElementById('code-modal');
|
| 1776 |
+
const display = document.getElementById('code-display');
|
| 1777 |
+
|
| 1778 |
+
if (currentCode) {
|
| 1779 |
+
display.innerHTML = highlightPython(currentCode);
|
| 1780 |
+
} else {
|
| 1781 |
+
display.textContent = 'No code available.';
|
| 1782 |
+
}
|
| 1783 |
+
|
| 1784 |
+
modal.classList.add('visible');
|
| 1785 |
+
}
|
| 1786 |
+
|
| 1787 |
+
function closeCodeModal() {
|
| 1788 |
+
document.getElementById('code-modal').classList.remove('visible');
|
| 1789 |
+
}
|
| 1790 |
+
|
| 1791 |
+
function highlightPython(code) {
|
| 1792 |
+
let escaped = code
|
| 1793 |
+
.replace(/&/g, '&')
|
| 1794 |
+
.replace(/</g, '<')
|
| 1795 |
+
.replace(/>/g, '>');
|
| 1796 |
+
|
| 1797 |
+
escaped = escaped.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
|
| 1798 |
+
escaped = escaped.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"\n]*"|'[^'\n]*')/g, '<span class="st">$1</span>');
|
| 1799 |
+
|
| 1800 |
+
const kw = /\b(import|from|as|def|class|return|if|else|elif|for|while|in|not|and|or|True|False|None|with|try|except|finally|raise|pass|break|continue|lambda|yield)\b/g;
|
| 1801 |
+
escaped = escaped.replace(kw, '<span class="kw">$1</span>');
|
| 1802 |
+
escaped = escaped.replace(/\b(\d+\.?\d*)\b/g, '<span class="nu">$1</span>');
|
| 1803 |
+
escaped = escaped.replace(/\.([a-zA-Z_]\w*)\(/g, '.<span class="fn">$1</span>(');
|
| 1804 |
+
|
| 1805 |
+
return escaped;
|
| 1806 |
+
}
|
| 1807 |
+
|
| 1808 |
+
// ── GALLERY ───────────────────────────────────────────
|
| 1809 |
+
|
| 1810 |
+
function addToGallery(data) {
|
| 1811 |
+
galleryItems.unshift({
|
| 1812 |
+
name: data.part_name,
|
| 1813 |
+
volume: data.execution?.volume_mm3,
|
| 1814 |
+
faces: data.execution?.face_count,
|
| 1815 |
+
machinable: data.validation?.machinable,
|
| 1816 |
+
});
|
| 1817 |
+
}
|
| 1818 |
+
|
| 1819 |
+
function openGallery() {
|
| 1820 |
+
renderGallery();
|
| 1821 |
+
document.getElementById('gallery-modal').classList.add('visible');
|
| 1822 |
+
}
|
| 1823 |
+
|
| 1824 |
+
function closeGallery() {
|
| 1825 |
+
document.getElementById('gallery-modal').classList.remove('visible');
|
| 1826 |
+
}
|
| 1827 |
+
|
| 1828 |
+
function renderGallery() {
|
| 1829 |
+
const grid = document.getElementById('gallery-grid');
|
| 1830 |
+
|
| 1831 |
+
if (galleryItems.length === 0) {
|
| 1832 |
+
grid.innerHTML = '<div class="gallery-empty">No models generated yet.</div>';
|
| 1833 |
+
return;
|
| 1834 |
+
}
|
| 1835 |
+
|
| 1836 |
+
let html = '';
|
| 1837 |
+
for (const item of galleryItems) {
|
| 1838 |
+
html += '<button class="gallery-card fade-in" onclick="loadGalleryItem(\'' + escapeHtml(item.name) + '\')">';
|
| 1839 |
+
html += '<div class="gallery-card-name">' + escapeHtml(item.name) + '</div>';
|
| 1840 |
+
html += '<div class="gallery-card-meta">';
|
| 1841 |
+
if (item.faces) html += '<span>' + item.faces + ' faces</span>';
|
| 1842 |
+
if (item.machinable !== undefined) {
|
| 1843 |
+
html += '<span style="color:' + (item.machinable ? 'var(--success)' : 'var(--error)') + '">'
|
| 1844 |
+
+ (item.machinable ? '\u2713 CNC' : '\u2717 CNC') + '</span>';
|
| 1845 |
+
}
|
| 1846 |
+
html += '</div></button>';
|
| 1847 |
+
}
|
| 1848 |
+
|
| 1849 |
+
grid.innerHTML = html;
|
| 1850 |
+
}
|
| 1851 |
+
|
| 1852 |
+
async function loadGalleryItem(name) {
|
| 1853 |
+
closeGallery();
|
| 1854 |
+
setViewerLoading(true, 'LOADING MODEL...');
|
| 1855 |
+
try {
|
| 1856 |
+
await loadSTL('/api/models/' + name + '.stl');
|
| 1857 |
+
} catch (e) {
|
| 1858 |
+
console.warn('Failed to load:', e);
|
| 1859 |
+
}
|
| 1860 |
+
setViewerLoading(false);
|
| 1861 |
+
}
|
| 1862 |
+
|
| 1863 |
+
// ── UTILS ─────────────────────────────────────────────
|
| 1864 |
+
|
| 1865 |
+
function escapeHtml(str) {
|
| 1866 |
+
const div = document.createElement('div');
|
| 1867 |
+
div.textContent = str;
|
| 1868 |
+
return div.innerHTML;
|
| 1869 |
+
}
|
| 1870 |
+
|
| 1871 |
+
// ── SERVER STATUS CHECK ───────────────────────────────
|
| 1872 |
+
|
| 1873 |
+
async function checkServer() {
|
| 1874 |
+
try {
|
| 1875 |
+
const resp = await fetch('/api/capabilities');
|
| 1876 |
+
const dot = document.getElementById('status-dot');
|
| 1877 |
+
if (resp.ok) {
|
| 1878 |
+
dot.style.background = 'var(--success)';
|
| 1879 |
+
dot.style.boxShadow = '0 0 6px var(--success)';
|
| 1880 |
+
dot.title = 'Server Connected';
|
| 1881 |
+
} else {
|
| 1882 |
+
dot.style.background = 'var(--warning)';
|
| 1883 |
+
dot.style.boxShadow = '0 0 6px var(--warning)';
|
| 1884 |
+
dot.title = 'Server Error';
|
| 1885 |
+
}
|
| 1886 |
+
} catch {
|
| 1887 |
+
const dot = document.getElementById('status-dot');
|
| 1888 |
+
dot.style.background = 'var(--error)';
|
| 1889 |
+
dot.style.boxShadow = '0 0 6px var(--error)';
|
| 1890 |
+
dot.title = 'Server Offline';
|
| 1891 |
+
}
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
+
// ── KEYBOARD / INPUT EVENTS ──────────────────────────
|
| 1895 |
+
|
| 1896 |
+
const chatInput = document.getElementById('chat-input');
|
| 1897 |
+
|
| 1898 |
+
chatInput.addEventListener('input', (e) => {
|
| 1899 |
+
// Auto-resize
|
| 1900 |
+
chatInput.style.height = 'auto';
|
| 1901 |
+
chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
|
| 1902 |
+
|
| 1903 |
+
// Check for @mention
|
| 1904 |
+
handleInputForMention(e);
|
| 1905 |
+
});
|
| 1906 |
+
|
| 1907 |
+
chatInput.addEventListener('keydown', (e) => {
|
| 1908 |
+
if (mentionActive) {
|
| 1909 |
+
const dropdown = document.getElementById('mention-dropdown');
|
| 1910 |
+
const visibleOptions = Array.from(dropdown.querySelectorAll('.mention-option'))
|
| 1911 |
+
.filter(o => o.style.display !== 'none');
|
| 1912 |
+
|
| 1913 |
+
if (e.key === 'ArrowDown') {
|
| 1914 |
+
e.preventDefault();
|
| 1915 |
+
mentionIndex = (mentionIndex + 1) % visibleOptions.length;
|
| 1916 |
+
updateMentionHighlight();
|
| 1917 |
+
return;
|
| 1918 |
+
}
|
| 1919 |
+
if (e.key === 'ArrowUp') {
|
| 1920 |
+
e.preventDefault();
|
| 1921 |
+
mentionIndex = (mentionIndex - 1 + visibleOptions.length) % visibleOptions.length;
|
| 1922 |
+
updateMentionHighlight();
|
| 1923 |
+
return;
|
| 1924 |
+
}
|
| 1925 |
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
| 1926 |
+
e.preventDefault();
|
| 1927 |
+
const agent = visibleOptions[mentionIndex]?.dataset.agent;
|
| 1928 |
+
if (agent) insertMention(agent);
|
| 1929 |
+
return;
|
| 1930 |
+
}
|
| 1931 |
+
if (e.key === 'Escape') {
|
| 1932 |
+
closeMentionDropdown();
|
| 1933 |
+
return;
|
| 1934 |
+
}
|
| 1935 |
+
}
|
| 1936 |
+
|
| 1937 |
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| 1938 |
+
e.preventDefault();
|
| 1939 |
+
sendFromInput();
|
| 1940 |
+
}
|
| 1941 |
+
|
| 1942 |
+
// Regular enter sends (without shift)
|
| 1943 |
+
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
| 1944 |
+
e.preventDefault();
|
| 1945 |
+
sendFromInput();
|
| 1946 |
+
}
|
| 1947 |
+
});
|
| 1948 |
+
|
| 1949 |
+
// Close modals on backdrop click
|
| 1950 |
+
document.getElementById('code-modal').addEventListener('click', (e) => {
|
| 1951 |
+
if (e.target === document.getElementById('code-modal')) closeCodeModal();
|
| 1952 |
+
});
|
| 1953 |
+
|
| 1954 |
+
document.getElementById('gallery-modal').addEventListener('click', (e) => {
|
| 1955 |
+
if (e.target === document.getElementById('gallery-modal')) closeGallery();
|
| 1956 |
+
});
|
| 1957 |
+
|
| 1958 |
+
// Escape to close modals
|
| 1959 |
+
document.addEventListener('keydown', (e) => {
|
| 1960 |
+
if (e.key === 'Escape') {
|
| 1961 |
+
closeCodeModal();
|
| 1962 |
+
closeGallery();
|
| 1963 |
+
}
|
| 1964 |
+
});
|
| 1965 |
+
|
| 1966 |
+
// ── INIT ──────────────────────────────────────────────
|
| 1967 |
+
|
| 1968 |
+
initViewer();
|
| 1969 |
+
checkServer();
|
| 1970 |
+
setInterval(checkServer, 15000);
|
| 1971 |
+
|
| 1972 |
+
loadState();
|
| 1973 |
+
// Re-render restored messages
|
| 1974 |
+
if (chatHistory.length > 0) {
|
| 1975 |
+
const examples = document.getElementById('quick-examples');
|
| 1976 |
+
if (examples) examples.style.display = 'none';
|
| 1977 |
+
for (const msg of chatHistory) {
|
| 1978 |
+
addMessage(msg);
|
| 1979 |
+
}
|
| 1980 |
+
}
|
| 1981 |
+
</script>
|
| 1982 |
+
</body>
|
| 1983 |
+
</html>
|