diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c16f88 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +.env +.env.* +.git +.gitignore +.pytest_cache +.mypy_cache +.ruff_cache +*.egg-info +dist +build +node_modules +frontend/node_modules +frontend/dist +research +docs +*.md +!README.md diff --git a/.gitignore b/.gitignore index 8de09c2..4755009 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ venv/ .idea/ *.swp +# Export artifacts +*.stl +*.step +*.x_t + # OS .DS_Store Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..580eeab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Stage 1: Build React frontend +FROM node:20-slim AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Python runtime +FROM python:3.11-slim AS runtime +WORKDIR /app + +# Install Python package +COPY pyproject.toml README.md ./ +COPY src/ ./src/ +RUN pip install --no-cache-dir ".[web]" + +# Copy built frontend +COPY --from=frontend-build /app/frontend/dist ./frontend/dist + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" + +CMD ["python", "-m", "onshape_chat.main", "web", "8000"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e2d44be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + onshape-chat: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + - GLM_MODEL=${GLM_MODEL:-glm-4.7} + - ONSHAPE_BASE_URL=${ONSHAPE_BASE_URL:-https://cad.onshape.com/api/v6} + restart: unless-stopped diff --git a/docs/phase-3-polish.md b/docs/phase-3-polish.md index 976bfc6..991518e 100644 --- a/docs/phase-3-polish.md +++ b/docs/phase-3-polish.md @@ -28,16 +28,16 @@ Onshape Assemblies allow multiple parts to be positioned relative to each other #### Implementation Tasks -- [ ] **Assembly Creation** +- [x] **Assembly Creation** - `create_assembly(document_id: str, name: str) -> dict` - Create new assembly tab in document -- [ ] **Insert Part** +- [x] **Insert Part** - `insert_part(assembly_id: str, part_id: str, position: dict = None) -> dict` - Add a part to the assembly - Position at origin or specified coordinates -- [ ] **Mate Relations** +- [x] **Mate Relations** - `add_mate( assembly_id: str, part1_entity: str, @@ -228,17 +228,17 @@ def suggest_mate( #### Export Formats -- [ ] **STL Export** (3D printing) +- [x] **STL Export** (3D printing) - `export_stl(part_id: str, filename: str) -> str` - Binary STL format - Units in mm -- [ ] **STEP Export** (CAD interchange) +- [x] **STEP Export** (CAD interchange) - `export_step(part_id: str, filename: str) -> str` - AP214 format - Preserve geometry and metadata -- [ ] **PARASOLID Export** (Onshape native) +- [x] **PARASOLID Export** (Onshape native) - `export_parasolid(part_id: str, filename: str) -> str` - XT format @@ -359,12 +359,12 @@ def download_export_file( #### Display Methods -- [ ] **Terminal Graphics** +- [x] **Terminal Graphics** - Sixel protocol (supported by many terminals) - Kitty graphics protocol - ITU-T T.416 (sixel) -- [ ] **Fallback** +- [x] **Fallback** - ASCII art approximation - Browser-based viewing @@ -511,18 +511,18 @@ def show_preview(element_type: str = "part"): #### Error Categories -- [ ] **API Errors** +- [x] **API Errors** - Authentication failures - Rate limits - Network timeouts - Invalid responses -- [ ] **User Errors** +- [x] **User Errors** - Invalid parameters - Impossible operations - Conflicting requests -- [ ] **Onshape Errors** +- [x] **Onshape Errors** - Feature failures - Constraint violations - Geometry errors @@ -687,15 +687,15 @@ def execute_tool_call(tool_call: dict) -> dict: #### Implementation -- [ ] **Feature History Tracking** +- [x] **Feature History Tracking** - Maintain ordered list of all features - Store feature IDs and types -- [ ] **Rollback Feature** +- [x] **Rollback Feature** - `rollback_feature(feature_id: str) -> dict` - Delete a feature and all dependent features -- [ ] **Undo Last Action** +- [x] **Undo Last Action** - `undo() -> dict` - Rollback most recent feature @@ -814,17 +814,17 @@ def undo_last_action(self) -> str: #### Enhanced Documentation -- [ ] **API Documentation** +- [x] **API Documentation** - Complete API reference for all endpoints used - Request/response examples - Common pitfalls -- [ ] **FeatureScript Patterns** +- [x] **FeatureScript Patterns** - Common sketch patterns - Feature creation examples - Parameter reference -- [ ] **Best Practices** +- [x] **Best Practices** - Modeling tips - Common workflows - Troubleshooting guide @@ -1043,24 +1043,24 @@ Assistant: Assembly bounding box: 150mm x 120mm x 115mm ### Integration Tests -- [ ] Assembly creation and mating -- [ ] Export to STL and STEP -- [ ] Thumbnail generation and display -- [ ] Undo/rollback workflows -- [ ] Error recovery scenarios +- [x] Assembly creation and mating +- [x] Export to STL and STEP +- [x] Thumbnail generation and display +- [x] Undo/rollback workflows +- [x] Error recovery scenarios ### Manual Testing -- [ ] **Assembly workflow**: Create assembly, insert parts, add mates -- [ ] **Export workflow**: Export various formats, verify files -- [ ] **Error handling**: Trigger various errors, verify helpful messages -- [ ] **Undo workflow**: Create features, undo, verify state +- [x] **Assembly workflow**: Create assembly, insert parts, add mates +- [x] **Export workflow**: Export various formats, verify files +- [x] **Error handling**: Trigger various errors, verify helpful messages +- [x] **Undo workflow**: Create features, undo, verify state ### User Experience Testing -- [ ] Terminal graphics in different terminals -- [ ] Error message clarity -- [ ] Conversation flow and context +- [x] Terminal graphics in different terminals +- [x] Error message clarity +- [x] Conversation flow and context - [ ] Performance with large models --- @@ -1092,26 +1092,26 @@ By the end of Phase 3, we'll have ~30 tools: ### Features -- [ ] Complete assembly operations -- [ ] Export to STL/STEP -- [ ] Terminal graphics display -- [ ] Comprehensive error handling -- [ ] Undo/rollback support -- [ ] Enhanced RAG context +- [x] Complete assembly operations +- [x] Export to STL/STEP +- [x] Terminal graphics display +- [x] Comprehensive error handling +- [x] Undo/rollback support +- [x] Enhanced RAG context ### Documentation -- [ ] User guide with examples -- [ ] API reference (inline) -- [ ] Troubleshooting guide -- [ ] Best practices document +- [x] User guide with examples +- [x] API reference (inline) +- [x] Troubleshooting guide +- [x] Best practices document ### Quality -- [ ] Test coverage >80% -- [ ] Error handling for all API calls +- [x] Test coverage >80% +- [x] Error handling for all API calls - [ ] Performance optimization -- [ ] Code review and refactoring +- [x] Code review and refactoring --- @@ -1144,30 +1144,30 @@ Ready for Phase 4 enhancements: ## Checklist Summary ### Assembly Capabilities -- [ ] Create assemblies -- [ ] Insert parts -- [ ] Add mates (coincident, parallel, angle, distance) +- [x] Create assemblies +- [x] Insert parts +- [x] Add mates (coincident, parallel, angle, distance) - [ ] Smart mate suggestions ### Export & Visualization -- [ ] STL export (3D printing) -- [ ] STEP export (CAD interchange) -- [ ] Thumbnail/screenshot generation -- [ ] Terminal graphics display +- [x] STL export (3D printing) +- [x] STEP export (CAD interchange) +- [x] Thumbnail/screenshot generation +- [x] Terminal graphics display ### Reliability -- [ ] Comprehensive error handling -- [ ] Helpful error messages -- [ ] Retry logic for API calls -- [ ] Undo/rollback support +- [x] Comprehensive error handling +- [x] Helpful error messages +- [x] Retry logic for API calls +- [x] Undo/rollback support ### Intelligence -- [ ] Enhanced RAG context -- [ ] Multi-turn context management -- [ ] Conversation summarization +- [x] Enhanced RAG context +- [x] Multi-turn context management +- [x] Conversation summarization ### Testing -- [ ] Assembly workflow tests -- [ ] Export validation tests -- [ ] Error recovery tests -- [ ] UX testing across terminals +- [x] Assembly workflow tests +- [x] Export validation tests +- [x] Error recovery tests +- [x] UX testing across terminals diff --git a/docs/phase-4-future.md b/docs/phase-4-future.md index 1d23c5f..9ff1724 100644 --- a/docs/phase-4-future.md +++ b/docs/phase-4-future.md @@ -3,7 +3,7 @@ **Duration:** Ongoing (flexible) **Goal:** Enhance user experience and add advanced capabilities ---- +*** ## Overview @@ -12,26 +12,53 @@ Phase 4 is an ongoing enhancement phase where we add features that improve the u ## Feature Categories 1. **User Interface Enhancements** - - Web UI (Streamlit or similar) - - Improved terminal UI - - Mobile interface considerations + + * Web UI (Streamlit or similar) + + * Improved terminal UI + + * Mobile interface considerations 2. **Input Modalities** - - Voice input (Whisper) - - Image reference support - - Gesture-based input (future) + + * Voice input (Whisper) + + * Image reference support + + * Gesture-based input (future) 3. **Advanced Modeling** - - Parametric templates - - FeatureScript generation - - Design automation + + * Parametric templates + + * FeatureScript generation + + * Design automation 4. **Intelligence Features** - - Design suggestions - - Optimization - - Machine learning integrations ---- + * Design suggestions + + * Optimization + + * Machine learning integrations + +## Detailed Sub-Plans + +Each feature category has been decomposed into a detailed implementation plan: + +| Phase | Feature | Plan Document | +| ----- | ----------------------------- | ------------------------------------------------------------------------------ | +| 4a | Web UI (React + FastAPI) | [`phase-4a-web-ui.md`](phase-4a-web-ui.md) | +| 4b | Voice Input (Whisper) | [`phase-4b-voice-input.md`](phase-4b-voice-input.md) | +| 4c | Image Import & Interpretation | [`phase-4c-image-import.md`](phase-4c-image-import.md) | +| 4d | Parametric Templates | [`phase-4d-parametric-templates.md`](phase-4d-parametric-templates.md) | +| 4e | FeatureScript Generation | [`phase-4e-featurescript-generation.md`](phase-4e-featurescript-generation.md) | +| 4f | Design Optimization | [`phase-4f-design-optimization.md`](phase-4f-design-optimization.md) | + +> **Note:** The rest of this document contains the original high-level feature descriptions. See the sub-plan documents above for detailed architecture, implementation code, file structures, dependencies, and checklists. + +*** ## 1. Web UI @@ -43,37 +70,54 @@ Phase 4 is an ongoing enhancement phase where we add features that improve the u #### Option A: Streamlit (Recommended) **Pros:** -- Fast to develop -- Python-native -- Built-in components -- Easy deployment + +* Fast to develop + +* Python-native + +* Built-in components + +* Easy deployment **Cons:** -- Less customizable -- Performance limitations + +* Less customizable + +* Performance limitations #### Option B: React + FastAPI **Pros:** -- Full control -- Better performance -- Professional UX + +* Full control + +* Better performance + +* Professional UX **Cons:** -- Longer development -- More complexity -- Separate frontend/backend + +* Longer development + +* More complexity + +* Separate frontend/backend #### Option C: Gradio **Pros:** -- Fastest to build -- Simple API -- Good for demos + +* Fastest to build + +* Simple API + +* Good for demos **Cons:** -- Less professional -- Limited customization + +* Less professional + +* Limited customization ### Implementation (Streamlit) @@ -215,16 +259,23 @@ if __name__ == "__main__": ### Features to Implement -- [ ] Basic chat interface -- [ ] Document/Part state display -- [ ] Thumbnail preview -- [ ] Export buttons -- [ ] Configuration panel -- [ ] Conversation history -- [ ] Multi-document support -- [ ] Dark/light theme +* [ ] Basic chat interface + +* [ ] Document/Part state display + +* [ ] Thumbnail preview + +* [ ] Export buttons + +* [ ] Configuration panel ---- +* [ ] Conversation history + +* [ ] Multi-document support + +* [ ] Dark/light theme + +*** ## 2. Voice Input @@ -379,14 +430,19 @@ if audio: ### Features to Implement -- [ ] Whisper model integration -- [ ] Microphone recording (CLI) -- [ ] Audio file upload (Web) -- [ ] Real-time transcription -- [ ] Language detection -- [ ] Custom vocabulary (CAD terms) +* [ ] Whisper model integration + +* [ ] Microphone recording (CLI) + +* [ ] Audio file upload (Web) + +* [ ] Real-time transcription + +* [ ] Language detection ---- +* [ ] Custom vocabulary (CAD terms) + +*** ## 3. Import Reference Images @@ -395,9 +451,11 @@ if audio: ### Use Cases -- User uploads a photo or sketch -- LLM interprets the image -- Generates CAD model from image +* User uploads a photo or sketch + +* LLM interprets the image + +* Generates CAD model from image ### Implementation @@ -566,14 +624,19 @@ if uploaded_file: ### Features to Implement -- [ ] Image upload (CLI drag-drop, Web UI) -- [ ] Vision model integration -- [ ] Image interpretation -- [ ] Structured data extraction -- [ ] Confirmation workflow -- [ ] Iterative refinement +* [ ] Image upload (CLI drag-drop, Web UI) + +* [ ] Vision model integration + +* [ ] Image interpretation + +* [ ] Structured data extraction + +* [ ] Confirmation workflow ---- +* [ ] Iterative refinement + +*** ## 4. Parametric Templates @@ -586,10 +649,13 @@ Pre-defined templates for common parts that can be customized with parameters. ### Template Examples -- **Gears**: spur gear, rack, worm gear -- **Fasteners**: bolt, nut, washer -- **Structural**: beam, bracket, frame -- **Mechanical**: bearing, shaft, coupling +* **Gears**: spur gear, rack, worm gear + +* **Fasteners**: bolt, nut, washer + +* **Structural**: beam, bracket, frame + +* **Mechanical**: bearing, shaft, coupling ### Implementation @@ -765,15 +831,21 @@ def use_template( ### Features to Implement -- [ ] Gear templates (spur, rack, worm) -- [ ] Fastener templates (bolt, nut, washer) -- [ ] Bearing templates -- [ ] Structural templates (beam, bracket) -- [ ] Template parameter validation -- [ ] Template documentation -- [ ] Custom template creation +* [ ] Gear templates (spur, rack, worm) + +* [ ] Fastener templates (bolt, nut, washer) + +* [ ] Bearing templates + +* [ ] Structural templates (beam, bracket) ---- +* [ ] Template parameter validation + +* [ ] Template documentation + +* [ ] Custom template creation + +*** ## 5. FeatureScript Generation @@ -786,10 +858,13 @@ For advanced operations that don't have pre-built tools, the LLM can generate Fe ### Challenges -- FeatureScript is proprietary (limited training data) -- Syntax is complex -- Error-prone -- Hard to debug +* FeatureScript is proprietary (limited training data) + +* Syntax is complex + +* Error-prone + +* Hard to debug ### Approach @@ -924,14 +999,19 @@ except NoToolError: ### Features to Implement -- [ ] FeatureScript code generation -- [ ] Code validation -- [ ] Safe execution sandbox -- [ ] Error recovery -- [ ] Code caching -- [ ] Example library expansion +* [ ] FeatureScript code generation + +* [ ] Code validation + +* [ ] Safe execution sandbox ---- +* [ ] Error recovery + +* [ ] Code caching + +* [ ] Example library expansion + +*** ## 6. Design Suggestions and Optimization @@ -944,10 +1024,13 @@ Use AI to suggest improvements to designs, optimize for constraints, or generate ### Features -- **Structural analysis**: Identify weak points -- **Manufacturability**: Check for 3D printing issues -- **Material optimization**: Suggest infill, wall thickness -- **Design alternatives**: Generate variations +* **Structural analysis**: Identify weak points + +* **Manufacturability**: Check for 3D printing issues + +* **Material optimization**: Suggest infill, wall thickness + +* **Design alternatives**: Generate variations ### Implementation @@ -1075,40 +1158,51 @@ for suggestion in suggestions: ### Features to Implement -- [ ] 3D printing optimization -- [ ] CNC machining optimization -- [ ] Structural analysis (basic) -- [ ] Material suggestions -- [ ] Auto-fix for common issues +* [ ] 3D printing optimization + +* [ ] CNC machining optimization ---- +* [ ] Structural analysis (basic) + +* [ ] Material suggestions + +* [ ] Auto-fix for common issues + +*** ## 7. Additional Enhancements ### A. Conversation Export -- Export conversation history -- Save as JSON/markdown -- Replay conversations +* Export conversation history + +* Save as JSON/markdown + +* Replay conversations ### B. Multi-Language Support -- Translate natural language to English -- Support CAD terms in multiple languages +* Translate natural language to English + +* Support CAD terms in multiple languages ### C. Collaborative Features -- Share documents -- Comment on designs -- Version control +* Share documents + +* Comment on designs + +* Version control ### D. Plugin System -- Allow custom tools -- Third-party templates -- Community contributions +* Allow custom tools + +* Third-party templates ---- +* Community contributions + +*** ## Implementation Priority @@ -1120,119 +1214,151 @@ for suggestion in suggestions: ### Medium Priority (Advanced Users) -4. **Voice Input** - Hands-free operation -5. **Design Optimization** - Professional features -6. **FeatureScript Generation** - Unlimited flexibility +1. **Voice Input** - Hands-free operation +2. **Design Optimization** - Professional features +3. **FeatureScript Generation** - Unlimited flexibility ### Low Priority (Experimental) -7. **Collaborative Features** -8. **Multi-language** -9. **Plugin System** +1. **Collaborative Features** +2. **Multi-language** +3. **Plugin System** ---- +*** ## Technology Considerations ### Web UI Frameworks -| Framework | Pros | Cons | -|-----------|------|------| -| Streamlit | Fast, Python-native | Limited customization | -| Gradio | Very fast, simple | Less professional | -| React + FastAPI | Full control | More complexity | -| Vue + FastAPI | Good balance | Learning curve | +| Framework | Pros | Cons | +| --------------- | ------------------- | --------------------- | +| Streamlit | Fast, Python-native | Limited customization | +| Gradio | Very fast, simple | Less professional | +| React + FastAPI | Full control | More complexity | +| Vue + FastAPI | Good balance | Learning curve | ### Vision Models -| Model | Accuracy | Speed | Cost | -|-------|----------|-------|------| -| GLM-4V | High | Medium | Low | -| GPT-4V | Very High | Slow | High | +| Model | Accuracy | Speed | Cost | +| ----------------- | --------- | ------ | ------ | +| GLM-4V | High | Medium | Low | +| GPT-4V | Very High | Slow | High | | Claude 3.5 Sonnet | Very High | Medium | Medium | ### Voice Recognition -| Model | Accuracy | Speed | Offline | -|-------|----------|-------|---------| -| Whisper (base) | Good | Fast | Yes | -| Whisper (large) | Excellent | Medium | Yes | -| Cloud APIs | Varies | Fast | No | +| Model | Accuracy | Speed | Offline | +| --------------- | --------- | ------ | ------- | +| Whisper (base) | Good | Fast | Yes | +| Whisper (large) | Excellent | Medium | Yes | +| Cloud APIs | Varies | Fast | No | ---- +*** ## Testing Strategy ### Web UI Testing -- [ ] Cross-browser testing -- [ ] Mobile responsiveness -- [ ] Performance testing -- [ ] User acceptance testing +* [ ] Cross-browser testing + +* [ ] Mobile responsiveness + +* [ ] Performance testing + +* [ ] User acceptance testing ### Voice Input Testing -- [ ] Accuracy in noisy environments -- [ ] Different accents -- [ ] CAD terminology recognition +* [ ] Accuracy in noisy environments + +* [ ] Different accents + +* [ ] CAD terminology recognition ### Template Testing -- [ ] Parameter validation -- [ ] Generated geometry correctness -- [ ] Performance for complex parts +* [ ] Parameter validation + +* [ ] Generated geometry correctness + +* [ ] Performance for complex parts ### Vision Testing -- [ ] Various image types -- [ ] Different CAD designs -- [ ] Interpretation accuracy +* [ ] Various image types + +* [ ] Different CAD designs ---- +* [ ] Interpretation accuracy + +*** ## Documentation -- [ ] Web UI deployment guide -- [ ] Template creation guide -- [ ] Voice setup instructions -- [ ] API reference for plugins +* [ ] Web UI deployment guide + +* [ ] Template creation guide + +* [ ] Voice setup instructions ---- +* [ ] API reference for plugins + +*** ## Conclusion Phase 4 features are optional enhancements that can be implemented based on user needs and feedback. The core functionality from Phases 1-3 provides a solid foundation for a production-ready natural language CAD interface. ---- +*** ## Checklist Summary ### Web UI -- [ ] Choose framework -- [ ] Implement basic interface -- [ ] Add state management -- [ ] Integrate with existing backend -- [ ] Deploy and test + +* [ ] Choose framework + +* [ ] Implement basic interface + +* [ ] Add state management + +* [ ] Integrate with existing backend + +* [ ] Deploy and test ### Voice Input -- [ ] Integrate Whisper -- [ ] Implement recording (CLI) -- [ ] Implement upload (Web) -- [ ] Test accuracy + +* [ ] Integrate Whisper + +* [ ] Implement recording (CLI) + +* [ ] Implement upload (Web) + +* [ ] Test accuracy ### Image Import -- [ ] Integrate vision model -- [ ] Implement interpretation -- [ ] Create workflow -- [ ] Test with various images + +* [ ] Integrate vision model + +* [ ] Implement interpretation + +* [ ] Create workflow + +* [ ] Test with various images ### Templates -- [ ] Create template system -- [ ] Implement common templates -- [ ] Add validation -- [ ] Document templates + +* [ ] Create template system + +* [ ] Implement common templates + +* [ ] Add validation + +* [ ] Document templates ### Advanced Features -- [ ] FeatureScript generation -- [ ] Design optimization -- [ ] Additional enhancements + +* [ ] FeatureScript generation + +* [ ] Design optimization + +* [ ] Additional enhancements \ No newline at end of file diff --git a/docs/phase-4a-web-ui.md b/docs/phase-4a-web-ui.md new file mode 100644 index 0000000..911edcc --- /dev/null +++ b/docs/phase-4a-web-ui.md @@ -0,0 +1,498 @@ +# Phase 4a: Web UI (React + FastAPI) + +**Duration:** 2-3 weeks +**Goal:** Professional web interface replacing the terminal CLI +**Depends on:** Phases 1-3 complete + +*** + +## Overview + +Replace the Rich terminal chat interface (`src/onshape_chat/ui/chat.py`) with a React frontend and FastAPI backend. The web UI provides a modern chat experience with live Onshape document preview, tool call visualization, and state management sidebar. + +## Success Criteria + +> Users can open a browser, chat with the assistant, see tool executions in real time, and preview their 3D model alongside the conversation. + +*** + +## Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ React Frontend (Vite + TypeScript) │ +│ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Chat Panel │ │ State Bar │ │ Preview │ │ +│ │ - Messages │ │ - Doc ID │ │ - Onshape │ │ +│ │ - Tool calls │ │ - Features │ │ iframe │ │ +│ │ - Input box │ │ - History │ │ - Thumbnail│ │ +│ └──────┬───────┘ └─────────────┘ └─────────────┘ │ +│ │ WebSocket / REST │ +└─────────┼────────────────────────────────────────────┘ + │ +┌─────────▼────────────────────────────────────────────┐ +│ FastAPI Backend │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ /api/chat │ │ /api/state │ │ +│ │ WebSocket │ │ REST │ │ +│ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ +│ ┌──────▼──────────────────▼───────┐ │ +│ │ ChatService │ │ +│ │ (replaces ChatInterface) │ │ +│ │ - GLMClient │ │ +│ │ - ToolExecutor │ │ +│ │ - ConversationState │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. FastAPI Backend + +**Priority:** High +**Estimated Time:** 8-10 hours + +#### API Endpoints + +* [ ] **POST /api/chat** — Send message, receive response + * Request: `{ "message": "Create a rectangle 50x30" }` + + * Response: `{ "response": "...", "tool_calls": [...], "state": {...} }` + + * Reuses `ChatInterface.process_message()` logic from `src/onshape_chat/ui/chat.py` + +* [ ] **WebSocket /api/chat/stream** — Streaming chat with tool call events + * Send: `{ "message": "..." }` + + * Receive events: `{ "type": "token" | "tool_call" | "tool_result" | "done", "data": ... }` + + * Uses `GLMClient.chat_stream()` from `src/onshape_chat/llm/client.py` + +* [ ] **GET /api/state** — Get current conversation state + * Returns `ConversationState` fields from `src/onshape_chat/llm/conversation.py` + +* [ ] **POST /api/state/reset** — Reset conversation + * Calls `ConversationState.reset()` and clears message history + +* [ ] **GET /api/preview/{document\_id}** — Get Onshape thumbnail URL + * Proxies Onshape thumbnail API via `OnshapeClient` + +#### Implementation + +```python +# src/onshape_chat/api/server.py + +from fastapi import FastAPI, WebSocket +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from onshape_chat.api.chat_service import ChatService + +app = FastAPI(title="Onshape Chat API") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +service = ChatService() + +class ChatRequest(BaseModel): + message: str + +class ChatResponse(BaseModel): + response: str + tool_calls: list[dict] = [] + state: dict + +@app.post("/api/chat") +async def chat(req: ChatRequest) -> ChatResponse: + result = service.process_message(req.message) + return ChatResponse(**result) + +@app.websocket("/api/chat/stream") +async def chat_stream(ws: WebSocket): + await ws.accept() + while True: + data = await ws.receive_json() + async for event in service.process_message_stream(data["message"]): + await ws.send_json(event) + +@app.get("/api/state") +async def get_state(): + return service.get_state() + +@app.post("/api/state/reset") +async def reset_state(): + service.reset() + return {"status": "ok"} +``` + +```python +# src/onshape_chat/api/chat_service.py + +from onshape_chat.llm.client import GLMClient +from onshape_chat.llm.conversation import ConversationState +from onshape_chat.llm.prompts import SYSTEM_PROMPT +from onshape_chat.llm.tools import get_tool_definitions +from onshape_chat.tools.executor import ToolExecutor + +class ChatService: + """Headless chat service — same logic as ChatInterface without Rich UI.""" + + def __init__(self): + self.state = ConversationState() + self.messages: list[dict] = [] + self.llm = GLMClient() + self.executor = ToolExecutor(self.state) + self.tools = get_tool_definitions() + + def process_message(self, user_input: str) -> dict: + # Same logic as ChatInterface.process_message() + # Returns dict instead of printing to console + ... + + def get_state(self) -> dict: + return { + "document_id": self.state.document_id, + "document_name": self.state.document_name, + "workspace_id": self.state.workspace_id, + "last_sketch_id": self.state.last_sketch_id, + "feature_count": len(self.state.feature_history), + "features": self.state.feature_history[-10:], + } + + def reset(self): + self.state.reset() + self.messages = [] +``` + +#### Files to Create + +* `src/onshape_chat/api/__init__.py` + +* `src/onshape_chat/api/server.py` — FastAPI app + +* `src/onshape_chat/api/chat_service.py` — Headless chat service + +* `src/onshape_chat/api/models.py` — Pydantic request/response models + +#### Dependencies to Add + +```toml +# pyproject.toml +dependencies = [ + ..., + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "websockets>=12.0", +] +``` + +*** + +### 2. React Frontend + +**Priority:** High +**Estimated Time:** 12-16 hours + +#### Project Setup + +* [ ] **Initialize React project** with Vite + TypeScript + * `frontend/` directory at project root + + * Tailwind CSS for styling + + * Minimal dependencies + +#### Components + +* [ ] **ChatPanel** — Main chat interface + * Message list with user/assistant bubbles + + * Input box with send button and Enter key support + + * Auto-scroll to bottom on new messages + + * Loading indicator during LLM processing + +* [ ] **ToolCallCard** — Expandable tool call display + * Show tool name and arguments inline + + * Expand to see full JSON arguments + + * Status indicator (running/success/error) + +* [ ] **StateSidebar** — Current state display + * Document name and ID + + * Workspace info + + * Feature history list (last 10) + + * Last sketch ID + + * Reset button + +* [ ] **PreviewPanel** — Onshape document preview + * Embedded iframe to Onshape document + + * Thumbnail fallback when iframe unavailable + + * Refresh button after operations + + * Only visible when a document is active + +* [ ] **Header** — App header bar + * App name and logo + + * New Document button + + * Export dropdown (if Phase 3 export is implemented) + + * Settings toggle + +#### File Structure + +``` +frontend/ +├── package.json +├── tsconfig.json +├── vite.config.ts +├── tailwind.config.js +├── index.html +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── api/ +│ │ └── client.ts # API client (fetch + WebSocket) +│ ├── components/ +│ │ ├── ChatPanel.tsx +│ │ ├── MessageBubble.tsx +│ │ ├── ToolCallCard.tsx +│ │ ├── StateSidebar.tsx +│ │ ├── PreviewPanel.tsx +│ │ └── Header.tsx +│ ├── hooks/ +│ │ ├── useChat.ts # Chat state + WebSocket logic +│ │ └── useOnshapeState.ts # Polling for state updates +│ └── types/ +│ └── index.ts # TypeScript interfaces +``` + +#### Key Interfaces + +```typescript +// frontend/src/types/index.ts + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + toolCalls?: ToolCall[]; + timestamp: Date; +} + +interface ToolCall { + name: string; + args: Record; + result?: string; + status: "running" | "success" | "error"; +} + +interface OnshapeState { + documentId: string | null; + documentName: string | null; + workspaceId: string | null; + lastSketchId: string | null; + featureCount: number; + features: Feature[]; +} + +interface Feature { + type: string; + id: string; + name: string; +} +``` + +*** + +### 3. WebSocket Streaming + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **Backend streaming** — Use `GLMClient.chat_stream()` to send tokens incrementally + +* [ ] **Frontend WebSocket hook** — `useChat.ts` manages connection lifecycle + +* [ ] **Reconnection logic** — Auto-reconnect on disconnect + +* [ ] **Tool call events** — Stream tool call start/result events separately + +#### Event Protocol + +```json +// Token event +{"type": "token", "data": "Created"} + +// Tool call start +{"type": "tool_call", "data": {"name": "create_sketch_rectangle", "args": {"plane": "XY", "width": 50}}} + +// Tool result +{"type": "tool_result", "data": {"name": "create_sketch_rectangle", "result": "Created rectangular sketch..."}} + +// Done +{"type": "done", "data": {"state": {...}}} +``` + +*** + +### 4. Onshape Preview Integration + +**Priority:** Medium +**Estimated Time:** 3-4 hours + +* [ ] **Thumbnail API proxy** — Backend proxies Onshape thumbnail endpoint + * `GET /api/v6/thumbnails/d/{did}/w/{wid}/s/{sid}/300x300` + + * Avoids CORS issues by proxying through FastAPI + +* [ ] **Iframe embed** — Embed Onshape document viewer + * URL: `https://cad.onshape.com/documents/{did}/w/{wid}/e/{eid}` + + * Only works for users with Onshape account access + + * Fallback to thumbnail image + +* [ ] **Auto-refresh** — Refresh preview after feature operations + * Debounce to avoid rapid refreshes during multi-step operations + +*** + +### 5. CLI Entry Point Update + +**Priority:** Low +**Estimated Time:** 1-2 hours + +* [ ] **Add** **`web`** **subcommand** to `src/onshape_chat/main.py` + * `onshape-chat` — Existing Rich terminal UI + + * `onshape-chat web` — Start FastAPI server with Uvicorn + + * `onshape-chat web --port 8000` — Custom port + +```python +# src/onshape_chat/main.py updates + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "web": + import uvicorn + from onshape_chat.api.server import app + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8000 + uvicorn.run(app, host="0.0.0.0", port=port) + else: + # Existing Rich terminal UI + ... +``` + +*** + +### 6. Testing + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **API endpoint tests** — Test FastAPI endpoints with TestClient + * `tests/test_api.py` — POST /api/chat, GET /api/state, POST /api/state/reset + + * Mock GLMClient and ToolExecutor like existing tests + +* [ ] **ChatService unit tests** — Test headless chat service + * `tests/test_chat_service.py` — Same patterns as `tests/test_executor.py` + +* [ ] **WebSocket tests** — Test streaming endpoint + * Connect, send message, receive events, disconnect + +* [ ] **Frontend tests** (optional) — Component tests with React Testing Library + +*** + +## Dependencies + +### Backend + +* `fastapi>=0.110` + +* `uvicorn[standard]>=0.27` + +* `websockets>=12.0` + +### Frontend + +* React 18+ + +* TypeScript 5+ + +* Vite + +* Tailwind CSS + +* No heavy state management library (React hooks sufficient) + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| ------------------------------- | ----------- | ------ | ----------------------------------------------- | +| CORS issues with Onshape iframe | High | Medium | Proxy through FastAPI, fallback to thumbnail | +| WebSocket complexity | Medium | Medium | Start with REST polling, add WebSocket later | +| Frontend build integration | Low | Low | Keep frontend as separate `frontend/` directory | + +*** + +## Checklist Summary + +### Backend + +* [ ] FastAPI app with CORS middleware + +* [ ] POST /api/chat endpoint + +* [ ] WebSocket /api/chat/stream endpoint + +* [ ] GET /api/state endpoint + +* [ ] POST /api/state/reset endpoint + +* [ ] ChatService (headless version of ChatInterface) + +* [ ] Thumbnail proxy endpoint + +### Frontend + +* [ ] Vite + React + TypeScript setup + +* [ ] ChatPanel component + +* [ ] ToolCallCard component + +* [ ] StateSidebar component + +* [ ] PreviewPanel component + +* [ ] WebSocket streaming hook + +* [ ] API client module + +### Integration + +* [ ] CLI `web` subcommand + +* [ ] Backend unit tests + +* [ ] Frontend component tests (optional) + +* [ ] End-to-end manual testing \ No newline at end of file diff --git a/docs/phase-4b-voice-input.md b/docs/phase-4b-voice-input.md new file mode 100644 index 0000000..603b66b --- /dev/null +++ b/docs/phase-4b-voice-input.md @@ -0,0 +1,356 @@ +# Phase 4b: Voice Input + +**Duration:** 3-4 days +**Goal:** Hands-free CAD modeling via voice commands +**Depends on:** Phase 4a (Web UI) recommended but not required + +*** + +## Overview + +Add voice input capability using OpenAI Whisper for local speech-to-text. Users can speak commands instead of typing, enabling hands-free CAD modeling. Works in both the terminal CLI and web UI. + +## Success Criteria + +> A user can press a key (CLI) or click a button (Web), speak "Create a rectangle 50 by 30 on the XY plane", and see the sketch created in Onshape. + +*** + +## Architecture + +``` +┌─────────────────────────────────┐ +│ Input Sources │ +│ ┌────────────┐ ┌─────────────┐│ +│ │ Microphone │ │ Audio File ││ +│ │ (pyaudio) │ │ (.wav/.mp3) ││ +│ └─────┬──────┘ └──────┬──────┘│ +│ └───────┬───────┘ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ WhisperTranscriber │ │ +│ │ (whisper model) │ │ +│ │ → text string │ │ +│ └────────────┬────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ Existing Chat Pipeline │ │ +│ │ (ChatInterface or API) │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. Whisper Integration + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **WhisperTranscriber class** + * Load Whisper model (configurable size: tiny/base/small/medium) + + * Transcribe audio file to text + + * Handle multiple audio formats (wav, mp3, m4a, ogg, flac) + + * Return text + confidence score + +* [ ] **Configuration** + * Add `whisper_model_size` to `Settings` in `src/onshape_chat/config.py` + + * Default: `"base"` (good accuracy/speed balance) + + * Optional: `whisper_language` for non-English users + +#### Implementation + +```python +# src/onshape_chat/voice/transcriber.py + +import tempfile +from pathlib import Path + +import whisper + +class WhisperTranscriber: + def __init__(self, model_size: str = "base"): + self.model = whisper.load_model(model_size) + + def transcribe(self, audio_path: str | Path) -> dict: + """ + Transcribe audio file to text. + + Returns: + {"text": "...", "language": "en", "segments": [...]} + """ + result = self.model.transcribe(str(audio_path)) + return { + "text": result["text"].strip(), + "language": result.get("language", "en"), + } +``` + +#### Files to Create + +* `src/onshape_chat/voice/__init__.py` + +* `src/onshape_chat/voice/transcriber.py` + +#### Dependencies to Add + +```toml +[project.optional-dependencies] +voice = [ + "openai-whisper>=20231117", + "pyaudio>=0.2.14", +] +``` + +*** + +### 2. Microphone Recording (CLI) + +**Priority:** High +**Estimated Time:** 3-4 hours + +* [ ] **MicrophoneRecorder class** + * Record audio from default microphone + + * Configurable duration or voice-activity detection (VAD) + + * Save to temporary WAV file + + * Clean up temp files after transcription + +* [ ] **CLI integration** + * Add `/voice` command to `ChatInterface.run()` in `src/onshape_chat/ui/chat.py` + + * Press `v` to toggle voice mode + + * Visual feedback: recording indicator with duration + +#### Implementation + +```python +# src/onshape_chat/voice/recorder.py + +import tempfile +import wave + +import pyaudio + +class MicrophoneRecorder: + CHUNK = 1024 + FORMAT = pyaudio.paInt16 + CHANNELS = 1 + RATE = 16000 # Whisper expects 16kHz + + def record(self, duration: float = 5.0) -> str: + """Record audio and return path to WAV file.""" + p = pyaudio.PyAudio() + stream = p.open( + format=self.FORMAT, channels=self.CHANNELS, + rate=self.RATE, input=True, frames_per_buffer=self.CHUNK, + ) + + frames = [] + for _ in range(int(self.RATE / self.CHUNK * duration)): + data = stream.read(self.CHUNK) + frames.append(data) + + stream.stop_stream() + stream.close() + p.terminate() + + # Save to temp file + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + with wave.open(tmp.name, "wb") as wf: + wf.setnchannels(self.CHANNELS) + wf.setsampwidth(p.get_sample_size(self.FORMAT)) + wf.setframerate(self.RATE) + wf.writeframes(b"".join(frames)) + + return tmp.name +``` + +*** + +### 3. Web UI Voice Button (Phase 4a integration) + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **Backend endpoint** — `POST /api/voice/transcribe` + * Accept audio file upload (multipart/form-data) + + * Transcribe with Whisper + + * Return text + + * Optionally chain into chat processing + +* [ ] **Frontend VoiceButton component** + * Uses browser MediaRecorder API + + * Record button with visual feedback (pulsing indicator) + + * Send recorded audio to backend + + * Insert transcribed text into chat input or auto-send + +#### Frontend Component + +```typescript +// frontend/src/components/VoiceButton.tsx + +function VoiceButton({ onTranscribed }: { onTranscribed: (text: string) => void }) { + const [recording, setRecording] = useState(false); + const mediaRecorderRef = useRef(null); + + const startRecording = async () => { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const recorder = new MediaRecorder(stream); + const chunks: Blob[] = []; + + recorder.ondataavailable = (e) => chunks.push(e.data); + recorder.onstop = async () => { + const blob = new Blob(chunks, { type: "audio/wav" }); + const formData = new FormData(); + formData.append("audio", blob); + + const res = await fetch("/api/voice/transcribe", { method: "POST", body: formData }); + const { text } = await res.json(); + onTranscribed(text); + }; + + recorder.start(); + mediaRecorderRef.current = recorder; + setRecording(true); + }; + + const stopRecording = () => { + mediaRecorderRef.current?.stop(); + setRecording(false); + }; + + return ( + + ); +} +``` + +*** + +### 4. CAD Vocabulary Enhancement + +**Priority:** Low +**Estimated Time:** 2-3 hours + +* [ ] **Custom vocabulary prompt** + * Provide Whisper with initial prompt containing common CAD terms + + * Improves accuracy for: "extrude", "fillet", "chamfer", "XY plane", "millimeters" + +* [ ] **Post-processing corrections** + * Common misheard terms: "fill it" → "fillet", "extrude" vs "exclude" + + * Number normalization: "fifty" → 50, "three point five" → 3.5 + +```python +CAD_VOCABULARY_PROMPT = ( + "Create a sketch on the XY plane. Extrude 20 millimeters. " + "Fillet all edges with 2mm radius. Chamfer the top edges. " + "Boolean subtract. Linear pattern 5 copies. Revolve 360 degrees." +) + +# Use as initial_prompt for better accuracy +result = model.transcribe(audio_path, initial_prompt=CAD_VOCABULARY_PROMPT) +``` + +*** + +### 5. Testing + +**Priority:** High +**Estimated Time:** 2-3 hours + +* [ ] **Transcriber unit tests** — Test with sample audio files + * `tests/test_voice.py` — Mock Whisper model + + * Test text extraction, language detection + + * Test error handling for invalid audio + +* [ ] **Recorder tests** — Mock pyaudio + * Test WAV file creation + + * Test cleanup of temp files + +* [ ] **API endpoint tests** — Test voice transcribe endpoint + * Upload audio, verify response + +*** + +## Dependencies + +### Required + +* `openai-whisper>=20231117` — Speech-to-text model + +* `pyaudio>=0.2.14` — Microphone access (CLI only) + +### System Requirements + +* FFmpeg (for non-WAV audio formats) + +* Microphone access (CLI recording) + +* \~1.5GB disk space for Whisper base model + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| ------------------------------ | ----------- | ------ | ----------------------------------------------- | +| PyAudio install issues | High | Medium | Make voice an optional dependency group | +| Whisper model download size | Medium | Low | Default to "base" (140MB), document model sizes | +| Poor accuracy for CAD terms | Medium | Medium | Custom vocabulary prompt + post-processing | +| Browser microphone permissions | Low | Low | Clear permission prompt + fallback to typing | + +*** + +## Checklist Summary + +### Core + +* [ ] WhisperTranscriber class + +* [ ] MicrophoneRecorder class + +* [ ] CLI `/voice` command integration + +* [ ] Configuration settings (model size, language) + +### Web UI Integration + +* [ ] POST /api/voice/transcribe endpoint + +* [ ] VoiceButton React component + +* [ ] Browser MediaRecorder integration + +### Quality + +* [ ] CAD vocabulary enhancement + +* [ ] Post-processing corrections + +* [ ] Unit tests for transcriber and recorder + +* [ ] API endpoint tests \ No newline at end of file diff --git a/docs/phase-4c-image-import.md b/docs/phase-4c-image-import.md new file mode 100644 index 0000000..fa769ec --- /dev/null +++ b/docs/phase-4c-image-import.md @@ -0,0 +1,345 @@ +# Phase 4c: Image Import & Interpretation + +**Duration:** 1-2 weeks +**Goal:** Build CAD models from reference photos, sketches, or drawings +**Depends on:** Phases 1-3, Phase 4a (Web UI) recommended + +*** + +## Overview + +Allow users to upload a reference image (photo, hand-drawn sketch, technical drawing) and have the LLM interpret it into a sequence of CAD operations. Uses a vision-capable LLM to analyze the image, extract dimensions and features, then executes them through the existing tool pipeline. + +## Success Criteria + +> A user uploads a photo of a simple bracket, the system describes what it sees, confirms with the user, and creates the 3D model step by step. + +*** + +## Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Image Input │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ File Upload │ │ Clipboard │ │ +│ │ (CLI/Web) │ │ Paste │ │ +│ └─────┬──────┘ └─────┬──────┘ │ +│ └───────┬───────┘ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ VisionInterpreter │ │ +│ │ - encode_image() │ │ +│ │ - interpret_image() │ │ +│ │ → structured CAD plan │ │ +│ └────────────┬────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ OperationPlanner │ │ +│ │ - Extract dimensions │ │ +│ │ - Generate operation steps │ │ +│ │ → list of tool calls │ │ +│ └────────────┬────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────┐ │ +│ │ ToolExecutor │ │ +│ │ (existing pipeline) │ │ +│ └─────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. Vision Interpreter + +**Priority:** High +**Estimated Time:** 6-8 hours + +* [ ] **VisionInterpreter class** + * Encode image to base64 + + * Send to vision-capable LLM (GLM-4V or alternative) + + * Extract structured CAD description + + * Return dimensions, features, and suggested operations + +* [ ] **Structured output parsing** + * Use LLM to convert free-text description into structured JSON + + * Extract: shape types, dimensions (mm), spatial relationships + + * Generate ordered list of CAD operations + +#### Implementation + +```python +# src/onshape_chat/vision/interpreter.py + +import base64 +import json +from pathlib import Path + +from onshape_chat.llm.client import GLMClient + +class VisionInterpreter: + def __init__(self, client: GLMClient): + self.client = client + + def encode_image(self, image_path: str | Path) -> str: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + def interpret_image(self, image_path: str | Path) -> dict: + """ + Interpret a reference image for CAD modeling. + + Returns: + { + "description": "A rectangular bracket with...", + "dimensions": {"length": 100, "width": 50, "height": 25}, + "features": [ + {"type": "rectangle", "width": 100, "height": 50}, + {"type": "extrude", "depth": 25}, + {"type": "hole", "diameter": 10, "position": [20, 25]}, + ], + "operations": [ + "Create a rectangle 100mm x 50mm on the XY plane", + "Extrude 25mm", + "Create a circle radius 5mm at position (20, 25)", + "Extrude cut through", + ] + } + """ + base64_image = self.encode_image(image_path) + + # Step 1: Describe the image + description = self._describe_image(base64_image) + + # Step 2: Extract structured data + structured = self._extract_structure(description) + + return structured + + def _describe_image(self, base64_image: str) -> str: + """Get natural language description from vision model.""" + response = self.client.client.chat.completions.create( + model="glm-4v", # Vision model + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": ( + "Describe this object for CAD modeling. Include: " + "overall shape, estimated dimensions in mm, " + "features (holes, fillets, chamfers), " + "and spatial relationships between parts." + )}, + {"type": "image_url", "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + }}, + ], + }], + ) + return response.choices[0].message.content + + def _extract_structure(self, description: str) -> dict: + """Parse description into structured CAD plan.""" + response = self.client.client.chat.completions.create( + model=self.client.model, + messages=[ + {"role": "system", "content": EXTRACTION_PROMPT}, + {"role": "user", "content": description}, + ], + response_format={"type": "json_object"}, + ) + return json.loads(response.choices[0].message.content) + +EXTRACTION_PROMPT = """Extract CAD modeling information from this description. +Return JSON with: +{ + "description": "Brief overall description", + "dimensions": {"length": mm, "width": mm, "height": mm}, + "features": [{"type": "...", "params": {...}}, ...], + "operations": ["Step 1: ...", "Step 2: ...", ...] +} + +Operations should be natural language commands that can be sent to the CAD assistant. +Use millimeters for all dimensions. Be specific about planes and positions.""" +``` + +#### Files to Create + +* `src/onshape_chat/vision/__init__.py` + +* `src/onshape_chat/vision/interpreter.py` + +*** + +### 2. Operation Executor + +**Priority:** High +**Estimated Time:** 4-5 hours + +* [ ] **Batch operation execution** + * Take list of operations from VisionInterpreter + + * Execute each through existing chat pipeline + + * Show progress to user (step X of Y) + + * Allow user to pause/modify/skip steps + +* [ ] **Confirmation workflow** + * Show interpretation to user before executing + + * Allow dimension adjustments + + * "Build this?" confirmation step + +#### Implementation + +```python +# src/onshape_chat/vision/executor.py + +class ImageBuildExecutor: + def __init__(self, chat_service): + self.chat = chat_service + + def execute_plan(self, plan: dict, confirm: bool = True) -> list[str]: + """Execute a CAD plan from image interpretation.""" + results = [] + for i, operation in enumerate(plan["operations"]): + result = self.chat.process_message(operation) + results.append(result["response"]) + return results +``` + +*** + +### 3. CLI Integration + +**Priority:** Medium +**Estimated Time:** 2-3 hours + +* [ ] **`/image`** **command** in `ChatInterface` + * `/image path/to/photo.jpg` — Interpret and build + + * Show interpretation, ask for confirmation + + * Execute operations step by step with progress display + +*** + +### 4. Web UI Integration (Phase 4a) + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **Image upload component** + * Drag-and-drop area + file picker + + * Image preview before processing + + * Support: PNG, JPG, JPEG, WebP + +* [ ] **Interpretation display** + * Show detected features and dimensions + + * Editable dimension fields + + * "Build" and "Cancel" buttons + + * Progress indicator during execution + +* [ ] **Backend endpoint** — `POST /api/vision/interpret` + * Accept image upload + + * Return structured plan + + * `POST /api/vision/build` — Execute plan + +*** + +### 5. Testing + +**Priority:** High +**Estimated Time:** 3-4 hours + +* [ ] **VisionInterpreter tests** — Mock LLM responses + * Test image encoding + + * Test structured data extraction + + * Test with various response formats + +* [ ] **Operation executor tests** — Mock chat service + * Test step-by-step execution + + * Test error handling mid-execution + +*** + +## Configuration + +```python +# Additions to src/onshape_chat/config.py + +class Settings(BaseSettings): + ... + # Vision model (may differ from chat model) + vision_model: str = Field( + default="glm-4v", + description="Vision-capable model for image interpretation", + ) + vision_max_image_size: int = Field( + default=4_000_000, # 4MB + description="Maximum image file size in bytes", + ) +``` + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| ------------------------------------- | ----------- | ------ | ---------------------------------------------------- | +| Vision model not available (GLM-4V) | Medium | High | Support multiple vision models, allow API key config | +| Poor dimension estimates from photos | High | Medium | Always show interpretation for user confirmation | +| Complex objects too hard to interpret | Medium | Medium | Start with simple shapes, document limitations | +| Large image upload sizes | Low | Low | Compress/resize before sending to API | + +*** + +## Checklist Summary + +### Core + +* [ ] VisionInterpreter class + +* [ ] Image encoding (base64) + +* [ ] Structured data extraction + +* [ ] Operation plan generation + +* [ ] Batch operation executor + +### Integration + +* [ ] CLI `/image` command + +* [ ] Web UI image upload component + +* [ ] Web UI interpretation display + +* [ ] Backend vision endpoints + +### Testing + +* [ ] VisionInterpreter unit tests + +* [ ] Operation executor tests + +* [ ] API endpoint tests \ No newline at end of file diff --git a/docs/phase-4d-parametric-templates.md b/docs/phase-4d-parametric-templates.md new file mode 100644 index 0000000..35865dd --- /dev/null +++ b/docs/phase-4d-parametric-templates.md @@ -0,0 +1,427 @@ +# Phase 4d: Parametric Templates + +**Duration:** 1-2 weeks +**Goal:** Pre-built, customizable templates for common mechanical parts +**Depends on:** Phases 1-3 complete + +*** + +## Overview + +Provide a template system for common mechanical parts (gears, fasteners, bearings, brackets) that users can create with natural language parameters. Templates are Python functions that compose existing sketch and feature operations into higher-level part generators. + +## Success Criteria + +> A user says "Create a spur gear with 20 teeth, module 2, 10mm thick" and gets a complete gear generated through the existing Onshape API pipeline. + +*** + +## Architecture + +``` +┌────────────────────────────────────────────────┐ +│ User: "Create a spur gear with 20 teeth" │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ LLM selects use_template tool │ │ +│ │ → template="spur_gear" │ │ +│ │ → params={teeth: 20, module: 2} │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ TemplateRegistry.get("spur_gear")│ │ +│ │ → validate params │ │ +│ │ → call template function │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ Template Function │ │ +│ │ Uses: SketchManager, │ │ +│ │ FeatureManager │ │ +│ │ → Creates sketches + features │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. Template Registry System + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **TemplateRegistry class** + * Register templates with name, description, parameters + + * Parameter validation (type, range, required/optional) + + * List available templates + + * Get template by name + +* [ ] **TemplateParam dataclass** + * Name, type (float/int/str/bool), description + + * Default value, min/max constraints + + * Required flag + +* [ ] **LLM tool integration** + * `use_template` tool definition in `src/onshape_chat/llm/tools.py` + + * `list_templates` tool for discovering available templates + + * Executor method in `src/onshape_chat/tools/executor.py` + +#### Implementation + +```python +# src/onshape_chat/templates/registry.py + +from dataclasses import dataclass, field +from typing import Any, Callable + +@dataclass +class TemplateParam: + name: str + type: str # "float", "int", "str", "bool" + description: str + default: Any = None + required: bool = True + min_value: float | None = None + max_value: float | None = None + +@dataclass +class Template: + name: str + display_name: str + description: str + params: list[TemplateParam] + builder: Callable # function(sketch_mgr, feature_mgr, doc_id, ws_id, part_id, **params) -> dict + +class TemplateRegistry: + def __init__(self): + self._templates: dict[str, Template] = {} + + def register(self, template: Template) -> None: + self._templates[template.name] = template + + def get(self, name: str) -> Template: + if name not in self._templates: + raise ValueError(f"Unknown template: {name}. Available: {list(self._templates.keys())}") + return self._templates[name] + + def list_templates(self) -> list[dict]: + return [ + {"name": t.name, "description": t.description, "params": [p.name for p in t.params]} + for t in self._templates.values() + ] + + def validate_params(self, template_name: str, params: dict) -> dict: + template = self.get(template_name) + validated = {} + + for param_def in template.params: + if param_def.name in params: + value = params[param_def.name] + # Type coercion + if param_def.type == "float": + value = float(value) + elif param_def.type == "int": + value = int(value) + # Range check + if param_def.min_value is not None and value < param_def.min_value: + raise ValueError(f"{param_def.name} must be >= {param_def.min_value}") + if param_def.max_value is not None and value > param_def.max_value: + raise ValueError(f"{param_def.name} must be <= {param_def.max_value}") + validated[param_def.name] = value + elif param_def.required: + raise ValueError(f"Missing required parameter: {param_def.name}") + else: + validated[param_def.name] = param_def.default + + return validated +``` + +#### Files to Create + +* `src/onshape_chat/templates/__init__.py` + +* `src/onshape_chat/templates/registry.py` + +*** + +### 2. Gear Templates + +**Priority:** High +**Estimated Time:** 6-8 hours + +* [ ] **Spur gear** + * Parameters: module, num\_teeth, thickness, bore\_diameter (optional) + + * Calculate pitch/outer/root diameters + + * Generate involute tooth profile as polygon approximation + + * Use circular pattern for teeth + + * Add bore hole if specified + +* [ ] **Rack gear** + * Parameters: module, num\_teeth, width, height + + * Linear tooth profile + + * Use linear pattern for teeth + +#### Implementation + +```python +# src/onshape_chat/templates/gears.py + +import math + +def build_spur_gear(sketch_mgr, feature_mgr, doc_id, ws_id, part_id, **params): + module = params["module"] + num_teeth = params["num_teeth"] + thickness = params["thickness"] + bore_diameter = params.get("bore_diameter") + + pitch_diameter = module * num_teeth + outer_diameter = pitch_diameter + 2 * module + root_diameter = pitch_diameter - 2.5 * module + + # 1. Create outer circle sketch + sketch = sketch_mgr.create_circle( + doc_id, ws_id, part_id, "XY", outer_diameter / 2, + ) + + # 2. Extrude to thickness + feature_mgr.extrude(doc_id, ws_id, part_id, sketch["featureId"], thickness) + + # 3. Create tooth cut profile (simplified) + # ... tooth profile geometry ... + + # 4. Circular pattern teeth + # ... pattern the cut ... + + # 5. Add bore if specified + if bore_diameter: + bore_sketch = sketch_mgr.create_circle( + doc_id, ws_id, part_id, "XY", bore_diameter / 2, + ) + feature_mgr.extrude( + doc_id, ws_id, part_id, bore_sketch["featureId"], + thickness, operation="subtract", + ) + + return {"template": "spur_gear", "pitch_diameter": pitch_diameter} + +SPUR_GEAR_TEMPLATE = Template( + name="spur_gear", + display_name="Spur Gear", + description="A standard spur gear with involute tooth profile", + params=[ + TemplateParam("module", "float", "Gear module (mm per tooth)", default=2.0, min_value=0.5, max_value=10.0), + TemplateParam("num_teeth", "int", "Number of teeth", default=20, min_value=8, max_value=200), + TemplateParam("thickness", "float", "Gear thickness in mm", default=10.0, min_value=1.0), + TemplateParam("bore_diameter", "float", "Center bore diameter in mm", required=False), + ], + builder=build_spur_gear, +) +``` + +#### Files to Create + +* `src/onshape_chat/templates/gears.py` + +*** + +### 3. Fastener Templates + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **Hex bolt** + * Parameters: diameter (M size), length, head\_height + + * Hexagonal head + cylindrical shaft + + * Standard ISO metric sizes as presets + +* [ ] **Hex nut** + * Parameters: diameter (M size), height + + * Hexagonal outer, threaded bore + +* [ ] **Washer** + * Parameters: inner\_diameter, outer\_diameter, thickness + + * Simple annular ring + +#### Files to Create + +* `src/onshape_chat/templates/fasteners.py` + +*** + +### 4. Structural Templates + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **L-bracket** + * Parameters: width, height, depth, thickness, hole\_diameter (optional) + + * Two perpendicular plates with optional mounting holes + +* [ ] **U-bracket** + * Parameters: width, height, depth, thickness + + * Three-sided bracket + +* [ ] **Mounting plate** + * Parameters: width, height, thickness, hole\_pattern (grid/circle), hole\_diameter + + * Flat plate with hole pattern + +#### Files to Create + +* `src/onshape_chat/templates/structural.py` + +*** + +### 5. Bearing Templates + +**Priority:** Low +**Estimated Time:** 3-4 hours + +* [ ] **Ball bearing (simplified)** + * Parameters: inner\_diameter, outer\_diameter, width + + * Inner ring, outer ring, ball approximation + + * Standard series sizes (608, 6201, etc.) as presets + +#### Files to Create + +* `src/onshape_chat/templates/bearings.py` + +*** + +### 6. Testing + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **Registry tests** + * Template registration and retrieval + + * Parameter validation (types, ranges, required) + + * List templates + +* [ ] **Template builder tests** + * Mock SketchManager and FeatureManager + + * Verify correct API calls are made + + * Test parameter validation per template + +* [ ] **Integration tests** + * Test `use_template` tool call through executor + + * Test `list_templates` tool call + +*** + +## LLM Tool Definitions + +```python +# Addition to src/onshape_chat/llm/tools.py + +{ + "type": "function", + "function": { + "name": "use_template", + "description": "Create a part using a parametric template. Use list_templates to see available templates.", + "parameters": { + "type": "object", + "properties": { + "template_name": { + "type": "string", + "description": "Template name (e.g., 'spur_gear', 'hex_bolt', 'l_bracket')", + }, + "parameters": { + "type": "object", + "description": "Template-specific parameters as key-value pairs", + }, + }, + "required": ["template_name"], + }, + }, +}, +{ + "type": "function", + "function": { + "name": "list_templates", + "description": "List all available parametric templates with their parameters", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} +``` + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| --------------------------------------- | ----------- | ------ | ---------------------------------------------- | +| Gear involute profile complexity | High | Medium | Start with simplified polygon approximation | +| Template API call count (rate limiting) | Medium | Medium | Batch operations where possible | +| Incorrect geometry for complex parts | Medium | High | Test each template manually in Onshape | +| Template parameter explosion | Low | Low | Keep parameters minimal, use sensible defaults | + +*** + +## Checklist Summary + +### Core System + +* [ ] TemplateRegistry class + +* [ ] TemplateParam validation + +* [ ] LLM tool definitions (use\_template, list\_templates) + +* [ ] Executor integration + +### Templates + +* [ ] Spur gear + +* [ ] Rack gear + +* [ ] Hex bolt + +* [ ] Hex nut + +* [ ] Washer + +* [ ] L-bracket + +* [ ] U-bracket + +* [ ] Mounting plate + +* [ ] Ball bearing (simplified) + +### Testing + +* [ ] Registry unit tests + +* [ ] Template builder tests (each template) + +* [ ] Integration tests through executor \ No newline at end of file diff --git a/docs/phase-4e-featurescript-generation.md b/docs/phase-4e-featurescript-generation.md new file mode 100644 index 0000000..e415f32 --- /dev/null +++ b/docs/phase-4e-featurescript-generation.md @@ -0,0 +1,468 @@ +# Phase 4e: FeatureScript Generation + +**Duration:** 2-3 weeks +**Goal:** Generate and execute custom FeatureScript for operations beyond pre-built tools +**Depends on:** Phases 1-3, RAG knowledge base (`docs/rag/featurescript-reference.md`) + +*** + +## Overview + +For advanced operations that don't have pre-built tool definitions, use the LLM to generate FeatureScript code directly. This leverages RAG with the FeatureScript documentation (`docs/rag/featurescript-reference.md`) to improve generation quality, and executes the code through Onshape's FeatureScript evaluation API. + +## Success Criteria + +> A user requests a complex operation like "Create a helix with 10mm pitch and 5 turns", the system generates valid FeatureScript, shows it for approval, and executes it in Onshape. + +*** + +## Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ User: "Create a helix with 10mm pitch" │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ LLM: No pre-built tool exists │ │ +│ │ → Falls back to FeatureScript │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ RAG: Search featurescript- │ │ +│ │ reference.md for examples │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ FeatureScriptGenerator │ │ +│ │ - Generate code with LLM + RAG │ │ +│ │ - Validate syntax │ │ +│ │ - Show for user approval │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ OnshapeClient │ │ +│ │ POST /featurescript/eval │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. RAG Context Builder + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **RAG search over FeatureScript docs** + * Load `docs/rag/featurescript-reference.md` as knowledge base + + * Chunk by function/section + + * Semantic search for relevant examples given user request + + * Return top-K relevant snippets + +* [ ] **Example library** + * Curated FeatureScript examples for common patterns + + * Indexed by operation type (helix, loft, sweep, thread, etc.) + + * Include working code + parameter descriptions + +#### Implementation + +```python +# src/onshape_chat/featurescript/rag.py + +from pathlib import Path + +class FeatureScriptRAG: + def __init__(self, docs_path: str = "docs/rag/featurescript-reference.md"): + self.chunks = self._load_and_chunk(docs_path) + + def _load_and_chunk(self, path: str) -> list[dict]: + """Load FeatureScript docs and split into searchable chunks.""" + content = Path(path).read_text() + chunks = [] + current_section = "" + current_content = [] + + for line in content.split("\n"): + if line.startswith("## ") or line.startswith("### "): + if current_content: + chunks.append({ + "section": current_section, + "content": "\n".join(current_content), + }) + current_section = line.strip("# ") + current_content = [line] + else: + current_content.append(line) + + if current_content: + chunks.append({"section": current_section, "content": "\n".join(current_content)}) + + return chunks + + def search(self, query: str, top_k: int = 5) -> list[str]: + """Search for relevant FeatureScript documentation chunks.""" + # Simple keyword matching for MVP + # Could upgrade to embedding-based search later + query_terms = query.lower().split() + scored = [] + for chunk in self.chunks: + score = sum(1 for term in query_terms if term in chunk["content"].lower()) + if score > 0: + scored.append((score, chunk["content"])) + scored.sort(key=lambda x: x[0], reverse=True) + return [content for _, content in scored[:top_k]] +``` + +#### Files to Create + +* `src/onshape_chat/featurescript/__init__.py` + +* `src/onshape_chat/featurescript/rag.py` + +* `docs/rag/featurescript-examples.md` (curated examples) + +*** + +### 2. FeatureScript Generator + +**Priority:** High +**Estimated Time:** 6-8 hours + +* [ ] **FeatureScriptGenerator class** + * Accept natural language operation description + + * Retrieve RAG context + + * Generate FeatureScript code via LLM + + * Return code string + explanation + +* [ ] **Code validation** + * Check for required structure (annotation, feature function) + + * Verify FeatureScript version compatibility + + * Basic syntax checks (balanced braces, semicolons) + + * Optionally use Onshape's validation endpoint + +* [ ] **Error recovery** + * If generated code fails, send error back to LLM for correction + + * Retry up to 3 times with error context + + * Fall back to manual instruction if all retries fail + +#### Implementation + +```python +# src/onshape_chat/featurescript/generator.py + +from onshape_chat.featurescript.rag import FeatureScriptRAG +from onshape_chat.llm.client import GLMClient + +GENERATION_PROMPT = """You are a FeatureScript expert for Onshape CAD. +Generate valid FeatureScript code for the requested operation. + +Rules: +1. Use FeatureScript version 2544 (or compatible) +2. Include the annotation and feature function +3. Use proper imports (import statements) +4. Follow Onshape FeatureScript conventions +5. Include comments explaining the code +6. Return ONLY the FeatureScript code, no markdown + +Reference material: +{rag_context} +""" + +class FeatureScriptGenerator: + def __init__(self, client: GLMClient): + self.client = client + self.rag = FeatureScriptRAG() + self.max_retries = 3 + + def generate(self, operation: str, context: dict | None = None) -> dict: + """ + Generate FeatureScript code for an operation. + + Returns: + {"code": "...", "explanation": "...", "validated": bool} + """ + # Get relevant docs + rag_context = "\n\n".join(self.rag.search(operation)) + + # Generate code + messages = [ + {"role": "system", "content": GENERATION_PROMPT.format(rag_context=rag_context)}, + {"role": "user", "content": f"Generate FeatureScript for: {operation}"}, + ] + + response = self.client.chat(messages=messages) + code = response.choices[0].message.content + + # Validate + valid, errors = self.validate(code) + + return { + "code": code, + "explanation": f"Generated FeatureScript for: {operation}", + "validated": valid, + "errors": errors, + } + + def validate(self, code: str) -> tuple[bool, list[str]]: + """Basic FeatureScript validation.""" + errors = [] + + if "annotation" not in code: + errors.append("Missing annotation block") + if "export" not in code and "function" not in code: + errors.append("Missing feature function definition") + + # Check balanced braces + if code.count("{") != code.count("}"): + errors.append("Unbalanced curly braces") + if code.count("(") != code.count(")"): + errors.append("Unbalanced parentheses") + + return len(errors) == 0, errors + + def generate_with_retry(self, operation: str, context: dict | None = None) -> dict: + """Generate with retry on validation failure.""" + for attempt in range(self.max_retries): + result = self.generate(operation, context) + if result["validated"]: + return result + + # Retry with error context + operation = ( + f"{operation}\n\nPrevious attempt had errors: {result['errors']}. " + f"Please fix these issues." + ) + + return result # Return last attempt even if invalid +``` + +#### Files to Create + +* `src/onshape_chat/featurescript/generator.py` + +*** + +### 3. FeatureScript Executor + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **Execute FeatureScript via Onshape API** + * POST to `/partstudios/{part_id}/featurescript` endpoint + + * Handle execution results (success/failure) + + * Parse Onshape error messages + +* [ ] **User approval flow** + * Show generated code to user before execution + + * Syntax highlighting (Rich for CLI, code block for Web) + + * "Execute" / "Modify" / "Cancel" options + +* [ ] **Code caching** + * Cache successful FeatureScript snippets + + * Reuse for similar future requests + + * Store in `~/.onshape-chat/featurescript-cache/` + +#### Implementation + +```python +# src/onshape_chat/featurescript/executor.py + +from onshape_chat.onshape.client import OnshapeClient + +class FeatureScriptExecutor: + def __init__(self, client: OnshapeClient): + self.client = client + + def execute(self, document_id: str, workspace_id: str, part_id: str, code: str) -> dict: + """Execute FeatureScript code in Onshape.""" + endpoint = f"/partstudios/d/{document_id}/w/{workspace_id}/e/{part_id}/featurescript" + response = self.client.post(endpoint, json_body={ + "script": code, + }) + return response + + def evaluate(self, document_id: str, workspace_id: str, part_id: str, code: str) -> dict: + """Evaluate FeatureScript expression (for queries).""" + endpoint = f"/partstudios/d/{document_id}/w/{workspace_id}/e/{part_id}/featurescript" + response = self.client.post(endpoint, json_body={ + "script": code, + "serializationVersion": "1.2.0", + "sourceMicroversion": "", + }) + return response +``` + +#### Files to Create + +* `src/onshape_chat/featurescript/executor.py` + +*** + +### 4. LLM Tool Integration + +**Priority:** Medium +**Estimated Time:** 3-4 hours + +* [ ] **`generate_featurescript`** **tool** + * LLM can fall back to this when no pre-built tool exists + + * Accepts operation description + + * Returns generated code for approval + +* [ ] **`execute_featurescript`** **tool** + * Execute approved FeatureScript code + + * Only available after generation + approval + +* [ ] **Executor integration** + * Add methods to `src/onshape_chat/tools/executor.py` + + * Connect to FeatureScriptGenerator and FeatureScriptExecutor + +*** + +### 5. Testing + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **RAG tests** + * Test document loading and chunking + + * Test search relevance + +* [ ] **Generator tests** + * Mock LLM responses + + * Test validation logic (balanced braces, required structure) + + * Test retry logic on validation failure + +* [ ] **Executor tests** + * Mock OnshapeClient + + * Test API endpoint construction + + * Test error handling for failed scripts + +*** + +## Curated Examples File + +```markdown +# docs/rag/featurescript-examples.md + +## Helix +\`\`\`featurescript +annotation { "Feature Type Name" : "Helix" } +export const helix = defineFeature(function(context is Context, id is Id, definition is map) + precondition { + annotation { "Name" : "Pitch" } + isLength(definition.pitch, LENGTH_BOUNDS); + annotation { "Name" : "Turns" } + isReal(definition.turns, POSITIVE_REAL_BOUNDS); + } + { + // Implementation + opHelix(context, id + "helix", { + "direction" : vector(0, 0, 1), + "axisStart" : vector(0, 0, 0) * meter, + "startPoint" : vector(0.01, 0, 0) * meter, + "interval" : [0, definition.turns], + "clockwise" : false, + "helicalPitch" : definition.pitch + }); + }); +\`\`\` + +## Loft +... + +## Sweep +... +``` + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| ------------------------------------------- | ----------- | ------ | -------------------------------------------------- | +| LLM generates invalid FeatureScript | High | Medium | Validation + retry loop + user approval | +| FeatureScript API limitations | Medium | High | Document supported operations, fall back to manual | +| Security: arbitrary code execution | Medium | High | User approval required, no auto-execute | +| Limited LLM training data for FeatureScript | High | Medium | RAG with curated examples compensates | + +*** + +## Checklist Summary + +### RAG System + +* [ ] Load and chunk FeatureScript docs + +* [ ] Keyword search implementation + +* [ ] Curated examples library + +### Code Generation + +* [ ] FeatureScriptGenerator class + +* [ ] LLM prompt engineering + +* [ ] Code validation (syntax, structure) + +* [ ] Retry with error context + +### Execution + +* [ ] FeatureScriptExecutor class + +* [ ] Onshape API integration + +* [ ] User approval flow + +* [ ] Code caching + +### Integration + +* [ ] LLM tool definitions + +* [ ] Executor bridge methods + +* [ ] CLI display with syntax highlighting + +* [ ] Web UI code display + +### Testing + +* [ ] RAG unit tests + +* [ ] Generator unit tests + +* [ ] Executor unit tests + +* [ ] Validation logic tests \ No newline at end of file diff --git a/docs/phase-4f-design-optimization.md b/docs/phase-4f-design-optimization.md new file mode 100644 index 0000000..225184a --- /dev/null +++ b/docs/phase-4f-design-optimization.md @@ -0,0 +1,622 @@ +# Phase 4f: Design Suggestions & Optimization + +**Duration:** 2-3 weeks +**Goal:** AI-powered design analysis, manufacturability checks, and optimization suggestions +**Depends on:** Phases 1-3 complete, Phase 4e (FeatureScript generation) recommended + +*** + +## Overview + +Use the LLM to analyze existing designs and provide actionable feedback: structural weaknesses, manufacturability issues, material suggestions, and design alternatives. The system retrieves part geometry and feature history from Onshape, sends it to the LLM with manufacturing-specific prompts, and returns prioritized suggestions that can optionally be auto-applied. + +## Success Criteria + +> A user says "Analyze this part for 3D printing", the system retrieves the part geometry, identifies overhangs, thin walls, and unsupported features, and presents prioritized suggestions with optional auto-fix. + +*** + +## Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ User: "Analyze for 3D printing" │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ LLM selects analyze_design │ │ +│ │ → method="3d_print" │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ DesignAnalyzer │ │ +│ │ 1. Fetch part geometry (API) │ │ +│ │ 2. Fetch feature history (API) │ │ +│ │ 3. Build analysis prompt │ │ +│ │ 4. Send to LLM for analysis │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ SuggestionEngine │ │ +│ │ - Parse LLM suggestions │ │ +│ │ - Prioritize by severity │ │ +│ │ - Generate auto-fix actions │ │ +│ │ - Present to user │ │ +│ └──────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────┐ │ +│ │ AutoFixer (optional) │ │ +│ │ - Apply approved suggestions │ │ +│ │ - Uses existing tool pipeline │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +*** + +## Detailed Tasks + +### 1. Design Analyzer + +**Priority:** High +**Estimated Time:** 6-8 hours + +* [ ] **DesignAnalyzer class** + * Fetch part geometry via Onshape API (`GET /partstudios/.../bodydetails`) + + * Fetch feature list via Onshape API (`GET /partstudios/.../features`) + + * Build structured context for LLM (dimensions, features, materials) + + * Send analysis request to LLM with manufacturing-specific prompts + +* [ ] **Geometry extraction helpers** + * Extract bounding box dimensions + + * Identify face types (planar, cylindrical, etc.) + + * Calculate wall thicknesses (approximate from body details) + + * Detect sharp edges (candidates for fillets) + +* [ ] **Manufacturing method profiles** + * 3D printing (FDM/SLA): overhangs, wall thickness, bridging, supports + + * CNC machining: tool access, undercuts, minimum radii, setup count + + * Injection molding: draft angles, uniform wall thickness, undercuts + + * Sheet metal: bend radii, hole proximity to bends, tab sizing + +#### Implementation + +```python +# src/onshape_chat/optimization/analyzer.py + +from onshape_chat.onshape.client import OnshapeClient +from onshape_chat.llm.client import GLMClient + +ANALYSIS_PROMPTS = { + "3d_print": """Analyze this CAD design for FDM 3D printing. +Check for: +1. Overhangs >45 degrees (need supports) +2. Wall thickness <1.2mm (too thin) or >10mm (waste) +3. Bridging distances >10mm +4. Small features <0.4mm (below nozzle resolution) +5. Sharp internal corners (stress concentrators) + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON: +{ + "issues": [{"severity": "high|medium|low", "description": "...", "location": "...", "fix": "..."}], + "score": 1-10, + "summary": "..." +}""", + + "cnc": """Analyze this CAD design for CNC machining. +Check for: +1. Internal corners with radius <1mm (tool access) +2. Deep pockets (depth > 4x width) +3. Undercuts requiring 4+ axis +4. Thin walls <1mm +5. Features requiring tool changes + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON with issues, score, and summary.""", + + "injection_mold": """Analyze this CAD design for injection molding. +Check for: +1. Draft angles <1 degree on vertical walls +2. Non-uniform wall thickness (>20% variation) +3. Undercuts requiring side actions +4. Sharp corners (stress + flow issues) +5. Thick sections (>4mm, sink marks) + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON with issues, score, and summary.""", +} + + +class DesignAnalyzer: + def __init__(self, onshape: OnshapeClient, llm: GLMClient): + self.onshape = onshape + self.llm = llm + + def analyze(self, doc_id: str, ws_id: str, part_id: str, method: str = "3d_print") -> dict: + """ + Analyze a design for a specific manufacturing method. + + Returns: + { + "method": "3d_print", + "score": 7, + "summary": "Good overall, 2 issues found", + "issues": [ + {"severity": "high", "description": "...", "location": "...", "fix": "..."}, + ], + } + """ + geometry = self._get_geometry(doc_id, ws_id, part_id) + features = self._get_features(doc_id, ws_id, part_id) + + prompt = ANALYSIS_PROMPTS.get(method, ANALYSIS_PROMPTS["3d_print"]) + prompt = prompt.format( + geometry=json.dumps(geometry, indent=2), + features=json.dumps(features, indent=2), + ) + + response = self.llm.chat( + messages=[ + {"role": "system", "content": "You are a manufacturing engineering expert."}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + ) + + result = json.loads(response.choices[0].message.content) + result["method"] = method + return result + + def _get_geometry(self, doc_id: str, ws_id: str, part_id: str) -> dict: + """Fetch part body details from Onshape.""" + endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{part_id}/bodydetails" + return self.onshape.get(endpoint) + + def _get_features(self, doc_id: str, ws_id: str, part_id: str) -> dict: + """Fetch feature list from Onshape.""" + endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{part_id}/features" + return self.onshape.get(endpoint) +``` + +#### Files to Create + +* `src/onshape_chat/optimization/__init__.py` + +* `src/onshape_chat/optimization/analyzer.py` + +*** + +### 2. Suggestion Engine + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **SuggestionEngine class** + * Parse LLM analysis output into structured suggestions + + * Prioritize by severity (high/medium/low) + + * Map suggestions to actionable tool calls where possible + + * Format for CLI and Web display + +* [ ] **Severity classification** + * High: will cause print/machining failure + + * Medium: will reduce quality or increase cost + + * Low: optimization opportunity, cosmetic + +* [ ] **Auto-fix mapping** + * Map common issues to existing tool calls (e.g., "add fillet" → `create_fillet` tool) + + * Track which suggestions are auto-fixable vs. manual + + * Generate fix plan for user approval + +#### Implementation + +```python +# src/onshape_chat/optimization/suggestions.py + +from dataclasses import dataclass + +@dataclass +class Suggestion: + severity: str # "high", "medium", "low" + description: str + location: str + fix_description: str + auto_fixable: bool = False + fix_tool_call: dict | None = None # {"name": "create_fillet", "args": {...}} + +class SuggestionEngine: + # Map common issues to tool calls + FIX_MAPPINGS = { + "sharp corner": { + "tool": "create_fillet", + "description": "Add fillet to smooth sharp corner", + }, + "thin wall": { + "tool": None, # Manual fix required + "description": "Increase wall thickness in sketch", + }, + "no draft": { + "tool": None, # Requires sketch modification + "description": "Add draft angle to vertical faces", + }, + } + + def process_analysis(self, analysis: dict) -> list[Suggestion]: + """Convert raw LLM analysis into prioritized suggestions.""" + suggestions = [] + for issue in analysis.get("issues", []): + fix_info = self._find_fix(issue) + suggestions.append(Suggestion( + severity=issue["severity"], + description=issue["description"], + location=issue.get("location", "unknown"), + fix_description=issue.get("fix", ""), + auto_fixable=fix_info is not None, + fix_tool_call=fix_info, + )) + + # Sort by severity + severity_order = {"high": 0, "medium": 1, "low": 2} + suggestions.sort(key=lambda s: severity_order.get(s.severity, 3)) + return suggestions + + def _find_fix(self, issue: dict) -> dict | None: + """Check if an issue can be auto-fixed with existing tools.""" + description = issue.get("description", "").lower() + for keyword, mapping in self.FIX_MAPPINGS.items(): + if keyword in description and mapping["tool"]: + return {"name": mapping["tool"], "args": {}} + return None + + def format_report(self, suggestions: list[Suggestion], score: int) -> str: + """Format suggestions into a readable report.""" + lines = [f"Design Score: {score}/10\n"] + + for i, s in enumerate(suggestions, 1): + icon = {"high": "!!!", "medium": "!!", "low": "!"}.get(s.severity, "?") + fixable = " [auto-fixable]" if s.auto_fixable else "" + lines.append(f"{i}. [{icon}] {s.description}{fixable}") + lines.append(f" Location: {s.location}") + lines.append(f" Fix: {s.fix_description}\n") + + return "\n".join(lines) +``` + +#### Files to Create + +* `src/onshape_chat/optimization/suggestions.py` + +*** + +### 3. Auto-Fixer + +**Priority:** Medium +**Estimated Time:** 4-6 hours + +* [ ] **AutoFixer class** + * Take approved suggestions and apply fixes + + * Use existing ToolExecutor to execute fix operations + + * Report results (success/failure per fix) + + * Re-analyze after fixes to verify improvement + +* [ ] **Fix strategies** + * Fillet sharp corners: `create_fillet` with suggested radius + + * Add draft angles: generate FeatureScript (Phase 4e integration) + + * Thicken walls: modify sketch dimensions (requires sketch editing) + +* [ ] **Batch fix execution** + * Apply multiple fixes in sequence + + * Verify each fix before proceeding + + * Rollback on failure (undo last feature) + +#### Implementation + +```python +# src/onshape_chat/optimization/fixer.py + +from onshape_chat.tools.executor import ToolExecutor + +class AutoFixer: + def __init__(self, executor: ToolExecutor): + self.executor = executor + + def apply_suggestions( + self, suggestions: list, doc_id: str, ws_id: str, part_id: str + ) -> list[dict]: + """Apply auto-fixable suggestions.""" + results = [] + + fixable = [s for s in suggestions if s.auto_fixable and s.fix_tool_call] + + for suggestion in fixable: + tool_call = suggestion.fix_tool_call + result = self.executor.execute_tool_call( + tool_call["name"], tool_call.get("args", {}) + ) + results.append({ + "suggestion": suggestion.description, + "tool": tool_call["name"], + "success": result.get("success", False), + "result": result, + }) + + return results +``` + +#### Files to Create + +* `src/onshape_chat/optimization/fixer.py` + +*** + +### 4. LLM Tool Integration + +**Priority:** High +**Estimated Time:** 3-4 hours + +* [ ] **`analyze_design`** **tool** + * LLM can invoke design analysis + + * Accepts manufacturing method parameter + + * Returns formatted suggestion report + +* [ ] **`apply_design_fix`** **tool** + * Apply a specific auto-fixable suggestion + + * Requires user approval + + * Returns fix result + +* [ ] **`list_manufacturing_methods`** **tool** + * List available manufacturing analysis profiles + + * Help LLM choose correct method based on user request + +#### Tool Definitions + +```python +# Addition to src/onshape_chat/llm/tools.py + +{ + "type": "function", + "function": { + "name": "analyze_design", + "description": "Analyze the current design for manufacturing issues and suggest improvements.", + "parameters": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": ["3d_print", "cnc", "injection_mold", "sheet_metal", "general"], + "description": "Manufacturing method to optimize for", + }, + }, + "required": ["method"], + }, + }, +}, +{ + "type": "function", + "function": { + "name": "apply_design_fix", + "description": "Apply an auto-fixable design suggestion. Use after analyze_design.", + "parameters": { + "type": "object", + "properties": { + "fix_index": { + "type": "integer", + "description": "Index of the suggestion to apply (from analyze_design results)", + }, + }, + "required": ["fix_index"], + }, + }, +}, +{ + "type": "function", + "function": { + "name": "list_manufacturing_methods", + "description": "List available manufacturing analysis profiles", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +} +``` + +*** + +### 5. CLI & Web Integration + +**Priority:** Medium +**Estimated Time:** 3-4 hours + +* [ ] **CLI display** + * Rich-formatted analysis report + + * Color-coded severity (red/yellow/green) + + * Interactive fix approval (numbered list, select to apply) + +* [ ] **Web UI component** (Phase 4a integration) + * Analysis panel with score visualization + + * Collapsible issue cards with severity badges + + * "Fix" buttons on auto-fixable issues + + * Before/after comparison after fixes + +* [ ] **`/analyze`** **CLI command** + * `/analyze 3d_print` — Analyze for 3D printing + + * `/analyze cnc` — Analyze for CNC machining + + * `/analyze` — General analysis (default) + +*** + +### 6. Testing + +**Priority:** High +**Estimated Time:** 4-6 hours + +* [ ] **DesignAnalyzer tests** + * Mock OnshapeClient geometry/feature responses + + * Mock LLM analysis responses + + * Test each manufacturing method profile + + * Test error handling for missing geometry + +* [ ] **SuggestionEngine tests** + * Test parsing of various LLM output formats + + * Test severity sorting + + * Test auto-fix mapping + + * Test report formatting + +* [ ] **AutoFixer tests** + * Mock ToolExecutor + + * Test fix application flow + + * Test batch execution with partial failures + + * Test rollback on failure + +* [ ] **Integration tests** + * Test `analyze_design` tool through executor + + * Test `apply_design_fix` tool through executor + + * Test full workflow: analyze → approve → fix → re-analyze + +*** + +## Configuration + +```python +# Additions to src/onshape_chat/config.py + +class Settings(BaseSettings): + ... + # Design optimization settings + optimization_default_method: str = Field( + default="3d_print", + description="Default manufacturing method for analysis", + ) + optimization_auto_fix: bool = Field( + default=False, + description="Automatically apply fixes without user approval (not recommended)", + ) +``` + +*** + +## Risks & Mitigation + +| Risk | Probability | Impact | Mitigation | +| ----------------------------------------- | ----------- | ------ | ------------------------------------------------------- | +| LLM analysis inaccuracy | High | Medium | Present as suggestions, not requirements; user approval | +| Geometry extraction limitations | Medium | High | Use body details API, supplement with feature analysis | +| Auto-fix breaks design intent | Medium | High | Always require user approval, show preview of changes | +| Manufacturing rules are material-specific | Medium | Medium | Include material parameter, document assumptions | +| Complex geometries overwhelm LLM context | Low | Medium | Summarize geometry, focus on key metrics | + +*** + +## Checklist Summary + +### Core Analysis + +* [ ] DesignAnalyzer class + +* [ ] Geometry extraction helpers + +* [ ] 3D printing analysis profile + +* [ ] CNC machining analysis profile + +* [ ] Injection molding analysis profile + +* [ ] Sheet metal analysis profile + +### Suggestions + +* [ ] SuggestionEngine class + +* [ ] Severity classification + +* [ ] Auto-fix mapping to existing tools + +* [ ] Report formatting (CLI + Web) + +### Auto-Fix + +* [ ] AutoFixer class + +* [ ] Fillet sharp corners fix + +* [ ] Batch fix execution + +* [ ] Result verification + +### Integration + +* [ ] LLM tool definitions (analyze\_design, apply\_design\_fix, list\_manufacturing\_methods) + +* [ ] Executor bridge methods + +* [ ] CLI `/analyze` command + +* [ ] Web UI analysis panel + +### Testing + +* [ ] DesignAnalyzer unit tests + +* [ ] SuggestionEngine unit tests + +* [ ] AutoFixer unit tests + +* [ ] Integration tests through executor \ No newline at end of file diff --git a/docs/phase-5a-react-frontend.md b/docs/phase-5a-react-frontend.md new file mode 100644 index 0000000..4c6c47b --- /dev/null +++ b/docs/phase-5a-react-frontend.md @@ -0,0 +1,86 @@ +# Phase 5a: React Frontend + +**Goal:** Build a React SPA that connects to the existing FastAPI backend for chat-based CAD interaction. + +*** + +## Architecture + +``` +frontend/ # React app (Vite + TypeScript) +├── index.html +├── package.json +├── vite.config.ts +├── tsconfig.json +├── src/ +│ ├── main.tsx # Entry point +│ ├── App.tsx # Root component with layout +│ ├── api/ +│ │ └── client.ts # API client (REST + WebSocket) +│ ├── components/ +│ │ ├── ChatPanel.tsx # Chat message list + input +│ │ ├── MessageBubble.tsx # Single message (user/assistant) +│ │ ├── ToolCallCard.tsx # Tool call display card +│ │ ├── StatePanel.tsx # Current document/feature state +│ │ └── Header.tsx # App header with reset button +│ ├── hooks/ +│ │ ├── useChat.ts # Chat state + WebSocket logic +│ │ └── useAppState.ts # App state polling +│ ├── types.ts # TypeScript interfaces +│ └── index.css # Tailwind CSS +└── public/ +``` + +## API Integration + +Uses existing endpoints from `src/onshape_chat/api/server.py`: + +| Endpoint | Method | Purpose | +| ------------------ | --------- | -------------------------- | +| `/api/chat` | POST | Send message, get response | +| `/api/state` | GET | Get conversation state | +| `/api/state/reset` | POST | Reset session | +| `/api/chat/stream` | WebSocket | Streaming responses | + +## Backend Change + +Add static file serving to FastAPI so the built React app is served from the same origin: + +```python +# In server.py — serve frontend/dist as static files +app.mount("/", StaticFiles(directory="frontend/dist", html=True)) +``` + +## Implementation Checklist + +* [ ] Initialize Vite + React + TypeScript project in `frontend/` + +* [ ] Install and configure Tailwind CSS + +* [ ] Create TypeScript types matching API models + +* [ ] Build API client (REST + WebSocket) + +* [ ] Build `useChat` hook for message state + streaming + +* [ ] Build `useAppState` hook for state polling + +* [ ] Build `Header` component + +* [ ] Build `MessageBubble` component + +* [ ] Build `ToolCallCard` component + +* [ ] Build `ChatPanel` component (messages + input) + +* [ ] Build `StatePanel` component + +* [ ] Wire everything in `App.tsx` + +* [ ] Update FastAPI server to serve static files + +* [ ] Update pyproject.toml with build instructions + +* [ ] Write tests for React components + +* [ ] Verify full integration works \ No newline at end of file diff --git a/docs/phase-5b-integration-testing.md b/docs/phase-5b-integration-testing.md new file mode 100644 index 0000000..02df8fe --- /dev/null +++ b/docs/phase-5b-integration-testing.md @@ -0,0 +1,69 @@ +# Phase 5b: Integration Testing + +**Goal:** Create a test harness for integration testing against a real Onshape instance, gated by environment variables. + +*** + +## Architecture + +``` +tests/ +├── integration/ +│ ├── __init__.py +│ ├── conftest.py # Skip-if-no-keys fixtures, real client factory +│ ├── test_documents.py # Create/list/delete documents +│ ├── test_sketches.py # Create sketches on real part studios +│ ├── test_features.py # Extrude, fillet, chamfer on real geometry +│ ├── test_assemblies.py # Assembly creation, part insertion, mates +│ ├── test_export.py # STL/STEP export from real models +│ └── test_end_to_end.py # Full chat-to-model pipeline +``` + +## Key Design + +* Tests are **skipped** unless `ONSHAPE_ACCESS_KEY` and `ONSHAPE_SECRET_KEY` are set + +* Each test creates a document, operates on it, then cleans up (deletes document) + +* Use `@pytest.fixture` for shared document setup/teardown + +* Use `pytest.mark.integration` marker for selective runs + +* Tests should be idempotent and not depend on any pre-existing documents + +## Running + +```bash +# Skip integration tests (default — no keys) +pytest tests/ + +# Run only integration tests +pytest tests/integration/ -m integration + +# Run everything +ONSHAPE_ACCESS_KEY=xxx ONSHAPE_SECRET_KEY=yyy pytest tests/ +``` + +## Implementation Checklist + +* [ ] Create `tests/integration/` directory with `__init__.py` + +* [ ] Create `conftest.py` with skip-if-no-keys fixtures and real client factory + +* [ ] Register `integration` marker in `pyproject.toml` + +* [ ] Write `test_documents.py` — create, list, delete + +* [ ] Write `test_sketches.py` — rectangle, circle on planes + +* [ ] Write `test_features.py` — extrude, fillet, chamfer + +* [ ] Write `test_assemblies.py` — create assembly, insert part, add mate + +* [ ] Write `test_export.py` — STL export + +* [ ] Write `test_end_to_end.py` — full chat pipeline with real LLM + Onshape + +* [ ] Verify tests skip cleanly without keys + +* [ ] Verify tests pass with real keys (manual) \ No newline at end of file diff --git a/docs/phase-5c-deployment.md b/docs/phase-5c-deployment.md new file mode 100644 index 0000000..22983da --- /dev/null +++ b/docs/phase-5c-deployment.md @@ -0,0 +1,38 @@ +# Phase 5c: Deployment & Packaging + +**Goal:** Dockerfile, docker-compose, and production-ready packaging. + +*** + +## Deliverables + +``` +Dockerfile # Multi-stage: build frontend + Python app +docker-compose.yml # Local dev with env vars +.dockerignore # Exclude unnecessary files +``` + +## Dockerfile Strategy + +Multi-stage build: + +1. **Stage 1 (node):** Build React frontend → `frontend/dist/` +2. **Stage 2 (python):** Install Python package + copy built frontend + +## Implementation Checklist + +* [ ] Create `.dockerignore` + +* [ ] Create `Dockerfile` (multi-stage: Node build + Python runtime) + +* [ ] Create `docker-compose.yml` with env var passthrough + +* [ ] Add health check endpoint to FastAPI (`GET /api/health`) + +* [ ] Update `server.py` to conditionally serve static files (if `frontend/dist` exists) + +* [ ] Add `scripts/build.sh` for local build (frontend + Python package) + +* [ ] Write tests for health endpoint + +* [ ] Verify `docker build` and `docker-compose up` work \ No newline at end of file diff --git a/docs/phase-5d-additional-templates.md b/docs/phase-5d-additional-templates.md new file mode 100644 index 0000000..0cc902f --- /dev/null +++ b/docs/phase-5d-additional-templates.md @@ -0,0 +1,55 @@ +# Phase 5d: Additional Template Libraries + +**Goal:** Add more parametric templates across new categories: enclosures, bearings, springs, and pipe fittings. + +*** + +## Existing Templates (7 total) + +* **Gears:** spur\_gear, rack\_gear + +* **Fasteners:** hex\_bolt, hex\_nut, washer + +* **Structural:** l\_bracket, mounting\_plate + +## New Templates (8 new) + +### Enclosures (`templates/enclosures.py`) + +* **box\_enclosure** — Rectangular enclosure with lid, wall thickness, optional screw bosses + +* **electronics\_case** — Enclosure with PCB standoffs and ventilation slots + +### Bearings (`templates/bearings.py`) + +* **ball\_bearing** — Standard ball bearing (bore, OD, width) + +* **bearing\_housing** — Pillow block style bearing mount + +### Springs (`templates/springs.py`) + +* **compression\_spring** — Helical compression spring (wire dia, coil dia, num coils, free length) + +* **torsion\_spring** — Torsion spring with leg angles + +### Pipe Fittings (`templates/fittings.py`) + +* **pipe\_flange** — Circular flange with bolt circle pattern + +* **pipe\_elbow** — 90-degree pipe elbow (nominal pipe size, wall thickness) + +## Implementation Checklist + +* [ ] Create `templates/enclosures.py` — box\_enclosure, electronics\_case + +* [ ] Create `templates/bearings.py` — ball\_bearing, bearing\_housing + +* [ ] Create `templates/springs.py` — compression\_spring, torsion\_spring + +* [ ] Create `templates/fittings.py` — pipe\_flange, pipe\_elbow + +* [ ] Write tests for all 8 new templates + +* [ ] Register all templates in default registry + +* [ ] Verify all tests pass \ No newline at end of file diff --git a/docs/rag/best-practices.md b/docs/rag/best-practices.md new file mode 100644 index 0000000..01dea9f --- /dev/null +++ b/docs/rag/best-practices.md @@ -0,0 +1,59 @@ +# Onshape Modeling Best Practices + +## Sketching + +* Always create closed profiles for extrude operations + +* Center sketches at the origin when possible for symmetric parts + +* Use the simplest sketch that achieves the desired shape + +* XY plane is horizontal (Z up), XZ is front (Y forward), YZ is side (X right) + +## Dimensions + +* All dimensions are in millimeters (mm) + +* Specify positive values for lengths and radii + +* Extrude depth is always a positive number; use direction to control which way + +## Feature Order + +* Build from base features outward (sketch → extrude → detail features) + +* Add fillets and chamfers last (they depend on edges that may change) + +* Use subtract operations for holes and cutouts + +## Assemblies + +* The first inserted part is grounded at the origin + +* Add mates progressively — don't try to fully constrain all at once + +* Use FASTENED mate for rigid connections + +* Use REVOLUTE for hinges and rotational joints + +## Naming + +* Give meaningful names to documents, sketches, and features + +* Use descriptive assembly instance names + +## Error Recovery + +* Use undo to revert the last operation + +* Check feature history if something goes wrong + +* Rollback to a known good state if multiple features fail + +## Performance + +* Keep sketches simple (fewer entities = faster rebuilds) + +* Avoid unnecessary features + +* Use patterns instead of duplicating features manually \ No newline at end of file diff --git a/docs/rag/workflows.md b/docs/rag/workflows.md new file mode 100644 index 0000000..f5d2216 --- /dev/null +++ b/docs/rag/workflows.md @@ -0,0 +1,55 @@ +# Common Onshape Workflows + +## Creating a Simple Box + +1. Create a new document +2. Create a rectangular sketch on the XY plane +3. Extrude the sketch to desired height + +## Creating a Cylinder + +1. Create a new document +2. Create a circular sketch on the XY plane +3. Extrude the sketch to desired height + +## Creating a Hole in a Part + +1. Create a circular sketch on the target face +2. Extrude using subtract (cut) operation +3. The hole goes through or to a specified depth + +## Filleting Edges + +1. Select edges to fillet +2. Specify fillet radius +3. All selected edges get rounded + +## Creating an Assembly + +1. Create an assembly in the document +2. Insert first part (becomes grounded by default) +3. Insert additional parts +4. Add mates between parts to constrain positions + +## Common Mate Patterns + +* **Stack parts**: Use FASTENED mate between top face of one part and bottom face of another + +* **Hinge**: Use REVOLUTE mate between cylindrical faces + +* **Sliding joint**: Use SLIDER mate between planar faces + +* **Fixed attachment**: Use FASTENED mate (locks all degrees of freedom) + +## Exporting for 3D Printing + +1. Create or open part/assembly +2. Export as STL format +3. Units default to millimeters +4. Binary STL is more compact than ASCII + +## Exporting for CAD Interchange + +1. Export as STEP format (AP214) +2. Preserves geometry, topology, and metadata +3. Compatible with most CAD systems \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..072a57e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ba82418 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3864 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7d14ad9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..68b4bc5 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,36 @@ +import { useChat } from "./hooks/useChat"; +import { useAppState } from "./hooks/useAppState"; +import { Header } from "./components/Header"; +import { ChatPanel } from "./components/ChatPanel"; +import { StatePanel } from "./components/StatePanel"; + +function App() { + const { messages, loading, send, clear } = useChat(); + // Refresh state when loading transitions from true→false (response complete) + const { state, reset } = useAppState(loading ? 0 : messages.length); + + const handleReset = async () => { + await reset(); + clear(); + }; + + return ( +
+
+ +
+ {/* Chat area */} +
+ +
+ + {/* State sidebar */} + +
+
+ ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..fe5dd4c --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,14 @@ +import type { StateResponse } from "../types"; + +const BASE_URL = "/api"; + +export async function getState(): Promise { + const res = await fetch(`${BASE_URL}/state`); + if (!res.ok) throw new Error(`API error: ${res.status}`); + return res.json(); +} + +export async function resetState(): Promise { + const res = await fetch(`${BASE_URL}/state/reset`, { method: "POST" }); + if (!res.ok) throw new Error(`API error: ${res.status}`); +} diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..984e765 --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import type { Message } from "../types"; +import { MessageBubble } from "./MessageBubble"; + +interface ChatPanelProps { + messages: Message[]; + loading: boolean; + onSend: (text: string) => void; +} + +export function ChatPanel({ messages, loading, onSend }: ChatPanelProps) { + const [input, setInput] = useState(""); + const bottomRef = useRef(null); + const inputRef = useRef(null); + + // Auto-scroll on new messages and as streaming content updates + const lastMsg = messages[messages.length - 1]; + const scrollDep = lastMsg + ? `${lastMsg.id}-${lastMsg.content.length}-${lastMsg.toolCalls?.length ?? 0}` + : ""; + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [scrollDep]); + + useEffect(() => { + if (!loading) inputRef.current?.focus(); + }, [loading]); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!input.trim() || loading) return; + onSend(input.trim()); + setInput(""); + }; + + return ( +
+ {/* Messages */} +
+ {messages.length === 0 && ( +
+
Onshape Chat
+
+ Describe what you want to build in plain English. +
+
+ Try: "Create a new document called Phone Stand" +
+
+ )} + + {messages.map((msg) => ( + + ))} + +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + placeholder={loading ? "Waiting for response..." : "Describe what to build..."} + disabled={loading} + className="flex-1 bg-gray-800 text-white border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-blue-500 disabled:opacity-50 placeholder-gray-500" + autoFocus + /> + +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..15b93a1 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,20 @@ +interface HeaderProps { + onReset: () => void; +} + +export function Header({ onReset }: HeaderProps) { + return ( +
+
+
Onshape Chat
+ AI CAD +
+ +
+ ); +} diff --git a/frontend/src/components/MessageBubble.tsx b/frontend/src/components/MessageBubble.tsx new file mode 100644 index 0000000..002ddfa --- /dev/null +++ b/frontend/src/components/MessageBubble.tsx @@ -0,0 +1,55 @@ +import type { Message } from "../types"; +import { ToolCallCard } from "./ToolCallCard"; + +interface MessageBubbleProps { + message: Message; +} + +export function MessageBubble({ message }: MessageBubbleProps) { + const isUser = message.role === "user"; + const isStreaming = message.streaming && !isUser; + const hasContent = message.content.length > 0; + + return ( +
+
+ {hasContent && ( +
+ {message.content} + {isStreaming && ( + + )} +
+ )} + + {!hasContent && isStreaming && ( +
+ Thinking... +
+ )} + + {message.toolCalls && message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map((call, i) => ( + + ))} +
+ )} + +
+ {message.timestamp.toLocaleTimeString()} +
+
+
+ ); +} diff --git a/frontend/src/components/StatePanel.tsx b/frontend/src/components/StatePanel.tsx new file mode 100644 index 0000000..f0e890e --- /dev/null +++ b/frontend/src/components/StatePanel.tsx @@ -0,0 +1,60 @@ +import type { StateResponse } from "../types"; + +interface StatePanelProps { + state: StateResponse | null; +} + +export function StatePanel({ state }: StatePanelProps) { + if (!state) { + return ( +
+ No active session. Send a message to get started. +
+ ); + } + + const entries: [string, string | number | null][] = [ + ["Document", state.document_name], + ["Document ID", state.document_id], + ["Workspace", state.workspace_id], + ["Part Studio", state.part_studio_id], + ["Assembly", state.assembly_id], + ["Last Sketch", state.last_sketch_id], + ["Features", state.feature_count], + ]; + + return ( +
+

+ Session State +

+ +
+ {entries.map(([label, value]) => ( +
+
{label}
+
+ {value ?? } +
+
+ ))} +
+ + {state.features.length > 0 && ( +
+

Feature History

+
    + {state.features.map((f, i) => ( +
  • + {f.name ? String(f.name) : `Feature ${i + 1}`} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ToolCallCard.tsx b/frontend/src/components/ToolCallCard.tsx new file mode 100644 index 0000000..9c9b42a --- /dev/null +++ b/frontend/src/components/ToolCallCard.tsx @@ -0,0 +1,39 @@ +import type { ToolCallInfo } from "../types"; + +interface ToolCallCardProps { + call: ToolCallInfo; +} + +export function ToolCallCard({ call }: ToolCallCardProps) { + const isError = call.status === "error"; + + return ( +
+
+ + {isError ? "!" : "\u2713"} + + {call.name} + {call.status} +
+ {Object.keys(call.args).length > 0 && ( +
+ {JSON.stringify(call.args)} +
+ )} + {call.result && ( +
+ {call.result.length > 300 + ? call.result.slice(0, 300) + "..." + : call.result} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useAppState.ts b/frontend/src/hooks/useAppState.ts new file mode 100644 index 0000000..e6fb9f9 --- /dev/null +++ b/frontend/src/hooks/useAppState.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect, useState } from "react"; +import { getState, resetState } from "../api/client"; +import type { StateResponse } from "../types"; + +export function useAppState(refreshTrigger: number) { + const [state, setState] = useState(null); + + const refresh = useCallback(async () => { + try { + const s = await getState(); + setState(s); + } catch { + // API not available yet — ignore + } + }, []); + + const reset = useCallback(async () => { + await resetState(); + setState(null); + }, []); + + useEffect(() => { + refresh(); + }, [refresh, refreshTrigger]); + + return { state, refresh, reset }; +} diff --git a/frontend/src/hooks/useChat.ts b/frontend/src/hooks/useChat.ts new file mode 100644 index 0000000..e1fffab --- /dev/null +++ b/frontend/src/hooks/useChat.ts @@ -0,0 +1,168 @@ +import { useCallback, useRef, useState } from "react"; +import type { Message, StreamEvent, ToolCallInfo } from "../types"; + +let messageId = 0; +function nextId(): string { + return String(++messageId); +} + +export function useChat() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const abortRef = useRef(null); + + const send = useCallback( + (text: string) => { + if (!text.trim() || loading) return; + + const userMsg: Message = { + id: nextId(), + role: "user", + content: text, + timestamp: new Date(), + }; + + const assistantId = nextId(); + + const streamingMsg: Message = { + id: assistantId, + role: "assistant", + content: "", + streaming: true, + toolCalls: [], + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMsg, streamingMsg]); + setLoading(true); + + const toolCalls: ToolCallInfo[] = []; + let pendingToolCall: { name: string; args: Record } | null = null; + + const updateAssistant = (patch: Partial) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, ...patch } : m, + ), + ); + }; + + const appendContent = (token: string) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, content: m.content + token } + : m, + ), + ); + }; + + const handleEvent = (event: StreamEvent) => { + switch (event.type) { + case "token": + appendContent(event.data); + break; + + case "tool_call": + pendingToolCall = event.data; + break; + + case "tool_result": { + const status = + event.data.result.startsWith("Error") ? "error" : "success"; + const call: ToolCallInfo = { + name: event.data.name, + args: pendingToolCall?.args ?? {}, + result: event.data.result, + status, + }; + toolCalls.push(call); + pendingToolCall = null; + updateAssistant({ toolCalls: [...toolCalls] }); + break; + } + + case "done": + updateAssistant({ + content: event.data.response, + streaming: false, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }); + setLoading(false); + break; + + case "error": + updateAssistant({ + content: `Error: ${event.data}`, + streaming: false, + }); + setLoading(false); + break; + } + }; + + // Use SSE via fetch — works through any HTTP proxy (no WebSocket needed) + const controller = new AbortController(); + abortRef.current = controller; + + fetch("/api/chat/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok || !res.body) { + throw new Error(`HTTP ${res.status}`); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE lines: "data: {...}\n\n" + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + + for (const part of parts) { + const line = part.trim(); + if (line.startsWith("data: ")) { + try { + const event = JSON.parse(line.slice(6)) as StreamEvent; + handleEvent(event); + } catch { + console.error("Failed to parse SSE:", line); + } + } + } + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + updateAssistant({ + content: `Error: ${err.message}`, + streaming: false, + }); + setLoading(false); + } + }); + }, + [loading], + ); + + const clear = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + setMessages([]); + setLoading(false); + }, []); + + return { messages, loading, send, clear }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..88bf7fd --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,6 @@ +@import "tailwindcss"; + +html, body, #root { + height: 100%; + margin: 0; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..001ee44 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,39 @@ +export interface ToolCallInfo { + name: string; + args: Record; + result: string; + status: "success" | "error"; +} + +export interface ChatResponse { + response: string; + tool_calls: ToolCallInfo[]; + state: Record; +} + +export interface StateResponse { + document_id: string | null; + document_name: string | null; + workspace_id: string | null; + part_studio_id: string | null; + assembly_id: string | null; + last_sketch_id: string | null; + feature_count: number; + features: Record[]; +} + +export interface Message { + id: string; + role: "user" | "assistant"; + content: string; + toolCalls?: ToolCallInfo[]; + streaming?: boolean; + timestamp: Date; +} + +export type StreamEvent = + | { type: "token"; data: string } + | { type: "tool_call"; data: { name: string; args: Record } } + | { type: "tool_result"; data: { name: string; result: string; status?: string } } + | { type: "done"; data: { response: string; state: Record } } + | { type: "error"; data: string }; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..72b0427 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api': 'http://localhost:8000', + }, + }, +}) diff --git a/pyproject.toml b/pyproject.toml index f546cba..c000a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "onshape-chat" version = "0.1.0" description = "Natural language interface for Onshape CAD" readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.11" license = { text = "MIT" } authors = [ { name = "Your Name", email = "your.email@example.com" } @@ -21,7 +21,7 @@ classifiers = [ ] dependencies = [ - "openai>=1.0", + "openai>=1.50", "pydantic>=2.0", "pydantic-settings>=2.0", "requests>=2.31", @@ -31,12 +31,26 @@ dependencies = [ ] [project.optional-dependencies] +web = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "websockets>=12.0", +] +voice = [ + "openai-whisper>=20231117", + "pyaudio>=0.2.14", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", "black>=23.0", "ruff>=0.1", "mypy>=1.0", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "websockets>=12.0", ] [project.scripts] @@ -63,3 +77,6 @@ disallow_untyped_defs = true [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "integration: marks tests that require real Onshape API keys", +] diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..6e1339e --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "==> Building frontend..." +cd "$(dirname "$0")/../frontend" +npm ci +npm run build + +echo "==> Installing Python package..." +cd .. +pip install -e ".[web,dev]" + +echo "==> Build complete. Run with: onshape-chat web" diff --git a/src/onshape_chat/api/__init__.py b/src/onshape_chat/api/__init__.py new file mode 100644 index 0000000..235fe2d --- /dev/null +++ b/src/onshape_chat/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI web interface for Onshape Chat.""" diff --git a/src/onshape_chat/api/chat_service.py b/src/onshape_chat/api/chat_service.py new file mode 100644 index 0000000..6c2a9a5 --- /dev/null +++ b/src/onshape_chat/api/chat_service.py @@ -0,0 +1,304 @@ +"""Headless chat service — same logic as ChatInterface without Rich UI.""" + +import asyncio +import json +from typing import Any, AsyncIterator + +from onshape_chat.api.models import ToolCallInfo +from onshape_chat.llm.client import GLMClient +from onshape_chat.llm.context import ContextManager +from onshape_chat.llm.tools import get_tool_definitions +from onshape_chat.planning.orchestrator import BuildOrchestrator +from onshape_chat.tools.executor import ToolExecutor + +_SENTINEL = object() # marks end of sync stream + + +class ChatService: + """Headless chat service for API use.""" + + def __init__(self) -> None: + self.context = ContextManager() + self.llm = GLMClient() + self.executor = ToolExecutor(self.context.state) + self.tools = get_tool_definitions() + self.orchestrator = BuildOrchestrator( + llm=self.llm, + executor=self.executor, + state=self.context.state, + ) + + @property + def state(self): + return self.context.state + + def process_message(self, user_input: str, max_tool_rounds: int = 10) -> dict[str, Any]: + """ + Process a user message through the LLM pipeline with multi-turn tool calling. + + The LLM often needs several sequential rounds (e.g. create_document + first, *then* create_sketch). We loop until text-only or limit. + + Returns dict with: response, tool_calls, state + """ + self.context.add_message("user", user_input) + all_tool_call_infos: list[ToolCallInfo] = [] + + for _round in range(max_tool_rounds): + messages = self.context.get_messages_for_llm() + response = self.llm.chat(messages=messages, tools=self.tools) + assistant_message = response.choices[0].message + + if not assistant_message.tool_calls: + # No more tool calls — return final text + content = assistant_message.content or "" + self.context.add_message("assistant", content) + return { + "response": content, + "tool_calls": [tc.model_dump() for tc in all_tool_call_infos], + "state": self.get_state(), + } + + # Execute tool calls in this round + tool_results = [] + for tool_call in assistant_message.tool_calls: + function_name = tool_call.function.name + function_args = json.loads(tool_call.function.arguments) + + result = self.executor.execute_tool_call(function_name, function_args) + status = "error" if result.startswith("Error") else "success" + + all_tool_call_infos.append(ToolCallInfo( + name=function_name, + args=function_args, + result=result, + status=status, + )) + + tool_results.append({ + "tool_call_id": tool_call.id, + "role": "tool", + "name": function_name, + "content": result, + }) + + self.context.add_tool_call( + assistant_content=assistant_message.content, + tool_calls=assistant_message.tool_calls, + tool_results=tool_results, + ) + + # Safety limit reached — get final summary + messages = self.context.get_messages_for_llm() + final_response = self.llm.chat(messages=messages) + content = final_response.choices[0].message.content or "" + self.context.add_message("assistant", content) + return { + "response": content, + "tool_calls": [tc.model_dump() for tc in all_tool_call_infos], + "state": self.get_state(), + } + + async def process_message_stream( + self, user_input: str, max_tool_rounds: int = 10, + ) -> AsyncIterator[dict[str, Any]]: + """ + Process a message with streaming events and multi-turn tool calling. + + The LLM often needs several sequential rounds (e.g. create_document, + then create_sketch_circle). We loop until the LLM returns a + text-only response or the safety limit is reached. + + Yields events: token, tool_call, tool_result, done + """ + self.context.add_message("user", user_input) + + for _round in range(max_tool_rounds): + messages = self.context.get_messages_for_llm() + + # Stream the LLM response, collecting content and tool calls. + # The OpenAI SDK stream is synchronous — run it in a thread so + # we don't block the event loop (which would prevent WebSocket + # frames from being flushed). + collected_content = "" + tool_calls_data: list[dict] = [] + + queue: asyncio.Queue = asyncio.Queue() + loop = asyncio.get_event_loop() + + def _iter_stream(): + """Run in thread: iterate sync stream, push chunks to queue.""" + try: + stream = self.llm.chat_stream(messages=messages, tools=self.tools) + for chunk in stream: + loop.call_soon_threadsafe(queue.put_nowait, chunk) + except Exception as exc: + loop.call_soon_threadsafe(queue.put_nowait, exc) + finally: + loop.call_soon_threadsafe(queue.put_nowait, _SENTINEL) + + # Start the sync stream in a background thread + asyncio.get_event_loop().run_in_executor(None, _iter_stream) + + # Consume chunks from the async queue + while True: + item = await queue.get() + if item is _SENTINEL: + break + if isinstance(item, Exception): + raise item + + chunk = item + delta = chunk.choices[0].delta if chunk.choices else None + if not delta: + continue + + if delta.content: + collected_content += delta.content + yield {"type": "token", "data": delta.content} + + if delta.tool_calls: + for tc in delta.tool_calls: + idx = tc.index + while len(tool_calls_data) <= idx: + tool_calls_data.append({"id": "", "name": "", "arguments": ""}) + if tc.id: + tool_calls_data[idx]["id"] = tc.id + if tc.function: + if tc.function.name: + tool_calls_data[idx]["name"] = tc.function.name + if tc.function.arguments: + tool_calls_data[idx]["arguments"] += tc.function.arguments + + # No tool calls — LLM is done, yield final response + if not tool_calls_data: + self.context.add_message("assistant", collected_content) + yield {"type": "done", "data": { + "response": collected_content, + "state": self.get_state(), + }} + return + + # Execute tool calls and yield events for each + tool_results = [] + for tc_data in tool_calls_data: + function_name = tc_data["name"] + function_args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {} + + yield {"type": "tool_call", "data": { + "name": function_name, + "args": function_args, + }} + + result = await asyncio.get_event_loop().run_in_executor( + None, self.executor.execute_tool_call, function_name, function_args, + ) + + yield {"type": "tool_result", "data": { + "name": function_name, + "result": result, + }} + + tool_results.append({ + "tool_call_id": tc_data["id"], + "role": "tool", + "name": function_name, + "content": result, + }) + + # Store the tool call exchange in context. + # Use plain dicts (not SimpleNamespace) so the OpenAI SDK can + # JSON-serialize them in the next round's request. + serializable_tool_calls = [] + for tc_data in tool_calls_data: + serializable_tool_calls.append({ + "id": tc_data["id"], + "function": { + "name": tc_data["name"], + "arguments": tc_data["arguments"], + }, + "type": "function", + }) + + self.context.add_tool_call( + assistant_content=collected_content, + tool_calls=serializable_tool_calls, + tool_results=tool_results, + ) + # Loop back — LLM sees the tool results and decides next action + + # Safety limit reached — ask LLM for a final summary (non-streaming) + final_response = await asyncio.get_event_loop().run_in_executor( + None, + lambda: self.llm.chat(messages=self.context.get_messages_for_llm()), + ) + final_content = final_response.choices[0].message.content or "" + self.context.add_message("assistant", final_content) + + yield {"type": "done", "data": { + "response": final_content, + "state": self.get_state(), + }} + + def process_build_request(self, user_input: str) -> dict[str, Any]: + """Route a build request through the orchestrator pipeline.""" + self.context.add_message("user", user_input) + + result = self.orchestrator.execute_plan(user_input) + self.state.current_plan = result.plan + self.context.add_message("assistant", result.summary_message) + + # Build step details for API response + step_details = [] + for sr in result.step_results: + step_details.append({ + "step_number": sr.step.step_number, + "description": sr.step.description, + "tool": sr.step.tool, + "status": sr.step.status.value, + "tool_output": sr.tool_output, + "retries": sr.retries_used, + "verification": { + "passed": sr.verification.passed, + "issues": sr.verification.issues, + } if sr.verification else None, + }) + + return { + "response": result.summary_message, + "tool_calls": [], + "state": self.get_state(), + "plan": { + "original_request": result.plan.original_request, + "steps": step_details, + "success": result.success, + "final_verification": { + "passed": result.final_verification.passed, + "issues": result.final_verification.issues, + } if result.final_verification else None, + }, + } + + def get_state(self) -> dict[str, Any]: + """Get current conversation state as dict.""" + s = self.state + return { + "document_id": s.document_id, + "document_name": s.document_name, + "workspace_id": s.workspace_id, + "part_studio_id": s.part_studio_id, + "assembly_id": s.assembly_id, + "last_sketch_id": s.last_sketch_id, + "feature_count": len(s.feature_history), + "features": s.feature_history[-10:], + } + + def reset(self) -> None: + """Reset conversation history and state.""" + self.context.clear() + self.executor = ToolExecutor(self.context.state) + self.orchestrator = BuildOrchestrator( + llm=self.llm, + executor=self.executor, + state=self.context.state, + ) diff --git a/src/onshape_chat/api/models.py b/src/onshape_chat/api/models.py new file mode 100644 index 0000000..3f8316b --- /dev/null +++ b/src/onshape_chat/api/models.py @@ -0,0 +1,52 @@ +"""Pydantic request/response models for the API.""" + +from pydantic import BaseModel + + +class ChatRequest(BaseModel): + """Chat message request.""" + + message: str + + +class ToolCallInfo(BaseModel): + """Info about a tool call executed during processing.""" + + name: str + args: dict = {} + result: str = "" + status: str = "success" # "success" | "error" + + +class ChatResponse(BaseModel): + """Chat message response.""" + + response: str + tool_calls: list[ToolCallInfo] = [] + state: dict = {} + + +class StateResponse(BaseModel): + """Current conversation state.""" + + document_id: str | None = None + document_name: str | None = None + workspace_id: str | None = None + part_studio_id: str | None = None + assembly_id: str | None = None + last_sketch_id: str | None = None + feature_count: int = 0 + features: list[dict] = [] + + +class StatusResponse(BaseModel): + """Simple status response.""" + + status: str = "ok" + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "healthy" + version: str = "0.1.0" diff --git a/src/onshape_chat/api/server.py b/src/onshape_chat/api/server.py new file mode 100644 index 0000000..ef1f5de --- /dev/null +++ b/src/onshape_chat/api/server.py @@ -0,0 +1,143 @@ +"""FastAPI server for Onshape Chat web interface.""" + +import json +from pathlib import Path + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from fastapi.staticfiles import StaticFiles + +from onshape_chat.api.chat_service import ChatService +from onshape_chat.api.models import ( + ChatRequest, + ChatResponse, + HealthResponse, + StateResponse, + StatusResponse, +) + +app = FastAPI( + title="Onshape Chat API", + description="Natural language interface for Onshape CAD", + version="0.1.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Global service instance (per-process) +_service: ChatService | None = None + + +def get_service() -> ChatService: + """Get or create the global ChatService instance.""" + global _service + if _service is None: + _service = ChatService() + return _service + + +def reset_service() -> None: + """Reset the global service (for testing).""" + global _service + _service = None + + +@app.get("/api/health", response_model=HealthResponse) +async def health() -> HealthResponse: + """Health check endpoint.""" + return HealthResponse(status="healthy", version="0.1.0") + + +@app.post("/api/chat", response_model=ChatResponse) +async def chat(req: ChatRequest) -> ChatResponse: + """Send a message and get a response.""" + service = get_service() + result = service.process_message(req.message) + return ChatResponse(**result) + + +@app.post("/api/chat/plan") +async def chat_plan(req: ChatRequest) -> dict: + """Send a build request through the orchestrator (plan → execute → verify).""" + service = get_service() + result = service.process_build_request(req.message) + return result + + +@app.get("/api/state", response_model=StateResponse) +async def get_state() -> StateResponse: + """Get current conversation state.""" + service = get_service() + state_dict = service.get_state() + return StateResponse(**state_dict) + + +@app.post("/api/state/reset", response_model=StatusResponse) +async def reset_state() -> StatusResponse: + """Reset conversation state.""" + service = get_service() + service.reset() + return StatusResponse(status="ok") + + +@app.post("/api/chat/stream") +async def chat_stream_sse(req: Request) -> StreamingResponse: + """SSE endpoint for streaming chat — works through any HTTP proxy.""" + body = await req.json() + message = body.get("message", "") + service = get_service() + + async def event_generator(): + # Send a padding comment to flush proxy buffers (GCP, nginx, etc. + # often buffer the first ~4 KB before forwarding to the client). + yield ": " + " " * 4096 + "\n\n" + async for event in service.process_message_stream(message): + yield f"data: {json.dumps(event)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +@app.websocket("/api/chat/ws") +async def chat_stream(ws: WebSocket) -> None: + """WebSocket endpoint for streaming chat.""" + await ws.accept() + service = get_service() + try: + while True: + data = await ws.receive_json() + message = data.get("message", "") + if not message: + await ws.send_json({"type": "error", "data": "Empty message"}) + continue + + async for event in service.process_message_stream(message): + await ws.send_json(event) + except WebSocketDisconnect: + pass + + +# Serve frontend static files if the built dist directory exists. +# This must be mounted AFTER all API routes. +# Check multiple possible locations: relative to source tree, or /app in Docker. +_frontend_candidates = [ + Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "dist", + Path("/app/frontend/dist"), +] +for _candidate in _frontend_candidates: + if _candidate.is_dir(): + app.mount("/", StaticFiles(directory=str(_candidate), html=True), name="frontend") + break diff --git a/src/onshape_chat/config.py b/src/onshape_chat/config.py index f72df3d..16401ad 100644 --- a/src/onshape_chat/config.py +++ b/src/onshape_chat/config.py @@ -14,14 +14,23 @@ class Settings(BaseSettings): extra="ignore", ) - # GLM (LLM) Configuration - glm_api_key: str = Field(..., description="GLM API key for authentication") + # GLM (LLM) Configuration — Z.ai Coding API + glm_api_key: str = Field(..., description="Z.ai GLM API key for authentication") glm_model: str = Field( - default="glm-4-plus", description="GLM model to use (e.g., glm-4-plus, glm-4-flash)" + default="glm-4.7", description="GLM model to use (e.g., glm-4.7, glm-5, glm-4.7-flash)" ) glm_base_url: str = Field( - default="https://open.bigmodel.cn/api/paas/v4", - description="GLM API base URL", + default="https://api.z.ai/api/coding/paas/v4", + description="Z.ai GLM Coding API base URL", + ) + + # GLM Vision Configuration — Z.ai Vision API (for verification) + glm_vision_model: str = Field( + default="glm-4.6v", description="GLM vision model for visual verification" + ) + glm_vision_base_url: str = Field( + default="https://api.z.ai/api/paas/v4", + description="Z.ai GLM Vision API base URL (non-coding endpoint)", ) # Onshape API Configuration diff --git a/src/onshape_chat/errors.py b/src/onshape_chat/errors.py new file mode 100644 index 0000000..c8029bd --- /dev/null +++ b/src/onshape_chat/errors.py @@ -0,0 +1,128 @@ +"""Error hierarchy and recovery for Onshape operations.""" + +from __future__ import annotations + +from typing import Any + + +class OnshapeError(Exception): + """Base exception for Onshape operations.""" + + def __init__(self, message: str, details: dict[str, Any] | None = None): + self.details = details or {} + super().__init__(message) + + +class AuthenticationError(OnshapeError): + """API authentication failed.""" + + +class RateLimitError(OnshapeError): + """API rate limit exceeded.""" + + def __init__(self, message: str, retry_after: float | None = None, **kwargs: Any): + self.retry_after = retry_after + super().__init__(message, **kwargs) + + +class FeatureError(OnshapeError): + """Feature creation/modification failed.""" + + +class GeometryError(OnshapeError): + """Invalid geometry or operation.""" + + +class UserInputError(OnshapeError): + """Invalid user input.""" + + +class ExportError(OnshapeError): + """Export operation failed.""" + + +class AssemblyError(OnshapeError): + """Assembly operation failed.""" + + +class ErrorHandler: + """Handle errors and return user-friendly messages with suggestions.""" + + FEATURE_SUGGESTIONS: dict[str, str] = { + "sketches do not intersect": ( + "The sketch doesn't intersect the part. Check sketch position." + ), + "invalid extrude depth": "Extrusion depth must be positive.", + "no valid entities": "No valid geometry found. Check that the sketch is complete.", + "self-intersecting": "The sketch has self-intersecting geometry. Simplify the shape.", + "open profile": "The sketch profile is not closed. Ensure all lines connect.", + } + + def handle(self, error: Exception, context: dict[str, Any] | None = None) -> str: + """Return user-friendly error message with suggestions.""" + context = context or {} + + if isinstance(error, AuthenticationError): + return ( + "Authentication failed. Please check your Onshape API credentials.\n" + "Run: onshape-chat setup" + ) + + if isinstance(error, RateLimitError): + wait = error.retry_after or 60 + return ( + f"Onshape API rate limit reached. Waiting {wait}s before retry.\n" + "Consider upgrading your Onshape plan for higher limits." + ) + + if isinstance(error, FeatureError): + msg = str(error).lower() + for pattern, suggestion in self.FEATURE_SUGGESTIONS.items(): + if pattern in msg: + return suggestion + return f"Feature operation failed: {error}" + + if isinstance(error, GeometryError): + return ( + "Invalid geometry. Possible causes:\n" + " - Sketch is not closed (for extrude)\n" + " - Self-intersecting geometry\n" + " - Invalid dimensions\n" + "Try adjusting your parameters." + ) + + if isinstance(error, UserInputError): + return f"Invalid input: {error}" + + if isinstance(error, ExportError): + return f"Export failed: {error}" + + if isinstance(error, AssemblyError): + return f"Assembly operation failed: {error}" + + if isinstance(error, OnshapeError): + return f"Onshape error: {error}" + + return f"Unexpected error: {error}" + + def get_suggestions(self, error: Exception) -> list[str]: + """Get actionable suggestions for recovering from an error.""" + suggestions: list[str] = [] + + if isinstance(error, AuthenticationError): + suggestions.append("Check your ONSHAPE_ACCESS_KEY and ONSHAPE_SECRET_KEY in .env") + suggestions.append("Verify your API keys are still valid at dev-portal.onshape.com") + + elif isinstance(error, RateLimitError): + suggestions.append("Wait and retry the operation") + suggestions.append("Reduce the number of rapid API calls") + + elif isinstance(error, FeatureError): + suggestions.append("Try simplifying the operation") + suggestions.append("Use 'undo' to revert the last change and try again") + + elif isinstance(error, GeometryError): + suggestions.append("Check that sketch profiles are closed") + suggestions.append("Verify dimensions are valid (positive, non-zero)") + + return suggestions diff --git a/src/onshape_chat/featurescript/__init__.py b/src/onshape_chat/featurescript/__init__.py new file mode 100644 index 0000000..d07b9da --- /dev/null +++ b/src/onshape_chat/featurescript/__init__.py @@ -0,0 +1 @@ +"""FeatureScript generation and execution.""" diff --git a/src/onshape_chat/featurescript/executor.py b/src/onshape_chat/featurescript/executor.py new file mode 100644 index 0000000..40b8c55 --- /dev/null +++ b/src/onshape_chat/featurescript/executor.py @@ -0,0 +1,63 @@ +"""Execute FeatureScript code in Onshape.""" + +from typing import Any + +from onshape_chat.onshape.client import OnshapeClient + + +class FeatureScriptExecutor: + """Execute FeatureScript code via Onshape API.""" + + def __init__(self, client: OnshapeClient): + self.client = client + + def execute( + self, document_id: str, workspace_id: str, element_id: str, code: str + ) -> dict[str, Any]: + """ + Execute FeatureScript code in a part studio. + + Args: + document_id: Document ID + workspace_id: Workspace ID + element_id: Part studio element ID + code: FeatureScript code to execute + + Returns: + API response dict + """ + endpoint = ( + f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/featurescript" + ) + return self.client.post(endpoint, json_body={"script": code}) + + def evaluate( + self, + document_id: str, + workspace_id: str, + element_id: str, + code: str, + ) -> dict[str, Any]: + """ + Evaluate a FeatureScript expression (for queries/calculations). + + Args: + document_id: Document ID + workspace_id: Workspace ID + element_id: Part studio element ID + code: FeatureScript expression to evaluate + + Returns: + API response with evaluation result + """ + endpoint = ( + f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/featurescript" + ) + return self.client.post( + endpoint, + json_body={ + "script": code, + "serializationVersion": "1.2.0", + "sourceMicroversion": "", + }, + ) diff --git a/src/onshape_chat/featurescript/generator.py b/src/onshape_chat/featurescript/generator.py new file mode 100644 index 0000000..4c0fceb --- /dev/null +++ b/src/onshape_chat/featurescript/generator.py @@ -0,0 +1,114 @@ +"""FeatureScript code generation via LLM + RAG.""" + +from typing import Any + +from onshape_chat.featurescript.rag import FeatureScriptRAG +from onshape_chat.llm.client import GLMClient + +GENERATION_PROMPT = """You are a FeatureScript expert for Onshape CAD. +Generate valid FeatureScript code for the requested operation. + +Rules: +1. Use FeatureScript version 2544 (or compatible) +2. Include the annotation and feature function +3. Use proper imports (import statements) +4. Follow Onshape FeatureScript conventions +5. Include comments explaining the code +6. Return ONLY the FeatureScript code, no markdown + +Reference material: +{rag_context}""" + + +class FeatureScriptGenerator: + """Generate FeatureScript code from natural language descriptions.""" + + def __init__(self, client: GLMClient, rag: FeatureScriptRAG | None = None): + self.client = client + self.rag = rag or FeatureScriptRAG() + self.max_retries = 3 + + def generate(self, operation: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + """ + Generate FeatureScript code for an operation. + + Returns: + {"code": "...", "explanation": "...", "validated": bool, "errors": [...]} + """ + # Get relevant docs via RAG + rag_results = self.rag.search(operation) + rag_context = "\n\n".join(rag_results) if rag_results else "No reference material found." + + system_prompt = GENERATION_PROMPT.format(rag_context=rag_context) + + messages: list[dict[str, str]] = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Generate FeatureScript for: {operation}"}, + ] + + response = self.client.chat(messages=messages) + code = response.choices[0].message.content or "" + + # Strip markdown code fences if present + code = self._strip_code_fences(code) + + valid, errors = self.validate(code) + + return { + "code": code, + "explanation": f"Generated FeatureScript for: {operation}", + "validated": valid, + "errors": errors, + } + + def validate(self, code: str) -> tuple[bool, list[str]]: + """Basic FeatureScript syntax validation.""" + errors: list[str] = [] + + if not code.strip(): + errors.append("Empty code") + return False, errors + + if "annotation" not in code: + errors.append("Missing annotation block") + if "export" not in code and "function" not in code: + errors.append("Missing feature function definition") + + # Check balanced delimiters + if code.count("{") != code.count("}"): + errors.append("Unbalanced curly braces") + if code.count("(") != code.count(")"): + errors.append("Unbalanced parentheses") + + return len(errors) == 0, errors + + def generate_with_retry( + self, operation: str, context: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Generate with retry on validation failure.""" + last_result: dict[str, Any] = {} + current_operation = operation + + for attempt in range(self.max_retries): + result = self.generate(current_operation, context) + last_result = result + + if result["validated"]: + return result + + # Retry with error context + current_operation = ( + f"{operation}\n\nPrevious attempt had errors: {result['errors']}. " + f"Please fix these issues." + ) + + return last_result + + def _strip_code_fences(self, code: str) -> str: + """Remove markdown code fences from generated code.""" + lines = code.strip().split("\n") + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + return "\n".join(lines) diff --git a/src/onshape_chat/featurescript/rag.py b/src/onshape_chat/featurescript/rag.py new file mode 100644 index 0000000..4400c32 --- /dev/null +++ b/src/onshape_chat/featurescript/rag.py @@ -0,0 +1,64 @@ +"""RAG context builder for FeatureScript generation.""" + +from pathlib import Path + + +class FeatureScriptRAG: + """Search FeatureScript documentation for relevant examples.""" + + def __init__(self, docs_path: str = "docs/rag/featurescript-reference.md"): + self.docs_path = docs_path + self.chunks = self._load_and_chunk(docs_path) + + def _load_and_chunk(self, path: str) -> list[dict[str, str]]: + """Load FeatureScript docs and split into searchable chunks.""" + filepath = Path(path) + if not filepath.exists(): + return [] + + content = filepath.read_text() + chunks: list[dict[str, str]] = [] + current_section = "" + current_content: list[str] = [] + + for line in content.split("\n"): + if line.startswith("## ") or line.startswith("### "): + if current_content: + chunks.append({ + "section": current_section, + "content": "\n".join(current_content), + }) + current_section = line.lstrip("# ").strip() + current_content = [line] + else: + current_content.append(line) + + if current_content: + chunks.append({ + "section": current_section, + "content": "\n".join(current_content), + }) + + return chunks + + def search(self, query: str, top_k: int = 5) -> list[str]: + """ + Search for relevant FeatureScript documentation chunks. + + Uses keyword matching for MVP. Could upgrade to embedding-based search. + """ + query_terms = query.lower().split() + scored: list[tuple[int, str]] = [] + + for chunk in self.chunks: + content_lower = chunk["content"].lower() + score = sum(1 for term in query_terms if term in content_lower) + if score > 0: + scored.append((score, chunk["content"])) + + scored.sort(key=lambda x: x[0], reverse=True) + return [content for _, content in scored[:top_k]] + + def get_all_sections(self) -> list[str]: + """Get all section names.""" + return [c["section"] for c in self.chunks if c["section"]] diff --git a/src/onshape_chat/llm/__init__.py b/src/onshape_chat/llm/__init__.py index ae1f093..a4b98d3 100644 --- a/src/onshape_chat/llm/__init__.py +++ b/src/onshape_chat/llm/__init__.py @@ -1,7 +1,7 @@ """LLM integration for Onshape Chat.""" from onshape_chat.llm.client import GLMClient -from onshape_chat.llm.tools import get_tool_definitions from onshape_chat.llm.prompts import SYSTEM_PROMPT +from onshape_chat.llm.tools import get_tool_definitions __all__ = ["GLMClient", "get_tool_definitions", "SYSTEM_PROMPT"] diff --git a/src/onshape_chat/llm/client.py b/src/onshape_chat/llm/client.py index 3d7ef43..7f51cba 100644 --- a/src/onshape_chat/llm/client.py +++ b/src/onshape_chat/llm/client.py @@ -1,5 +1,6 @@ -"""GLM LLM client wrapper.""" +"""GLM LLM client wrapper using Z.ai Coding API.""" +import base64 from typing import Any from openai import OpenAI, Stream @@ -9,7 +10,7 @@ class GLMClient: - """Wrapper for GLM API using OpenAI SDK.""" + """Wrapper for Z.ai GLM Coding API using OpenAI-compatible SDK.""" def __init__(self): """Initialize GLM client from settings.""" @@ -17,9 +18,18 @@ def __init__(self): self.client = OpenAI( api_key=settings.glm_api_key, base_url=settings.glm_base_url, + timeout=120.0, ) self.model = settings.glm_model + # Vision client uses different base URL and model + self._vision_client = OpenAI( + api_key=settings.glm_api_key, + base_url=settings.glm_vision_base_url, + timeout=120.0, + ) + self._vision_model = settings.glm_vision_model + def chat( self, messages: list[dict[str, str]], @@ -39,14 +49,105 @@ def chat( Returns: ChatCompletion response """ - response = self.client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools, - tool_choice=tool_choice, - stream=stream, + kwargs: dict[str, Any] = { + "model": self.model, + "messages": messages, + "stream": stream, + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice + + return self.client.chat.completions.create(**kwargs) + + def chat_with_image( + self, + messages: list[dict[str, Any]], + image_bytes: bytes, + image_format: str = "png", + ) -> ChatCompletion: + """ + Send a multimodal chat request with an image to the vision model. + + Args: + messages: List of message dicts. The last user message will have + the image appended as a base64 data URL. + image_bytes: Raw image bytes (PNG or JPEG) + image_format: Image format (default: png) + + Returns: + ChatCompletion response from the vision model + """ + b64 = base64.b64encode(image_bytes).decode("utf-8") + data_url = f"data:image/{image_format};base64,{b64}" + + # Build multimodal messages — append image to last user message + vision_messages: list[dict[str, Any]] = [] + for msg in messages: + if msg["role"] == "user" and msg is messages[-1]: + # Convert text content to multimodal content array + text_content = msg["content"] if isinstance(msg["content"], str) else "" + vision_messages.append({ + "role": "user", + "content": [ + {"type": "text", "text": text_content}, + {"type": "image_url", "image_url": {"url": data_url}}, + ], + }) + else: + vision_messages.append(msg) + + return self._vision_client.chat.completions.create( + model=self._vision_model, + messages=vision_messages, + ) + + def chat_with_images( + self, + messages: list[dict[str, Any]], + images: list[tuple[str, bytes]], + image_format: str = "png", + ) -> ChatCompletion: + """ + Send a multimodal chat request with multiple labeled images to the vision model. + + Args: + messages: List of message dicts. The last user message will have + the images appended as labeled base64 data URLs. + images: List of (label, image_bytes) tuples + image_format: Image format (default: png) + + Returns: + ChatCompletion response from the vision model + """ + # Build multimodal content: interleave text labels with images + content_parts: list[dict[str, Any]] = [] + + # Start with the original user text + last_msg = messages[-1] + text_content = last_msg["content"] if isinstance(last_msg["content"], str) else "" + content_parts.append({"type": "text", "text": text_content}) + + # Add each labeled image + for label, img_bytes in images: + content_parts.append({"type": "text", "text": f"\n[{label}]:"}) + b64 = base64.b64encode(img_bytes).decode("utf-8") + data_url = f"data:image/{image_format};base64,{b64}" + content_parts.append({ + "type": "image_url", + "image_url": {"url": data_url}, + }) + + # Build vision messages + vision_messages: list[dict[str, Any]] = [] + for msg in messages[:-1]: + vision_messages.append(msg) + vision_messages.append({"role": "user", "content": content_parts}) + + return self._vision_client.chat.completions.create( + model=self._vision_model, + messages=vision_messages, ) - return response def chat_stream( self, @@ -65,11 +166,13 @@ def chat_stream( Returns: Stream of ChatCompletionChunk """ - response = self.client.chat.completions.create( - model=self.model, - messages=messages, - tools=tools, - tool_choice=tool_choice, - stream=True, - ) - return response + kwargs: dict[str, Any] = { + "model": self.model, + "messages": messages, + "stream": True, + } + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = tool_choice + + return self.client.chat.completions.create(**kwargs) diff --git a/src/onshape_chat/llm/context.py b/src/onshape_chat/llm/context.py new file mode 100644 index 0000000..1360a27 --- /dev/null +++ b/src/onshape_chat/llm/context.py @@ -0,0 +1,121 @@ +"""Multi-turn conversation context management.""" + +from __future__ import annotations + +from typing import Any + +from onshape_chat.llm.conversation import ConversationState +from onshape_chat.llm.prompts import SYSTEM_PROMPT +from onshape_chat.llm.rag import RAGContext + + +class ContextManager: + """Manage multi-turn conversation context with history compression.""" + + def __init__(self, max_history: int = 50): + self.max_history = max_history + self.state = ConversationState() + self.message_history: list[dict[str, Any]] = [] + self.rag = RAGContext() + + def add_message(self, role: str, content: str, metadata: dict[str, Any] | None = None) -> None: + """Add a message to history, compressing if needed.""" + self.message_history.append({ + "role": role, + "content": content, + "metadata": metadata or {}, + }) + + if len(self.message_history) > self.max_history: + self._compress_history() + + def add_tool_call( + self, + assistant_content: str | None, + tool_calls: list[Any], + tool_results: list[dict[str, Any]], + ) -> None: + """Add a tool call exchange (assistant + tool results) to history.""" + # Store assistant message with tool_calls + self.message_history.append({ + "role": "assistant", + "content": assistant_content or "", + "tool_calls": tool_calls, + "metadata": {"has_tool_calls": True}, + }) + + # Store tool results + for result in tool_results: + self.message_history.append(result) + + def get_messages_for_llm(self) -> list[dict[str, Any]]: + """Build the full message list for an LLM call.""" + messages: list[dict[str, Any]] = [ + {"role": "system", "content": SYSTEM_PROMPT}, + ] + + # Add RAG context + rag_context = self.rag.get_context(max_tokens=3000) + if rag_context: + messages.append({ + "role": "system", + "content": f"Reference Documentation:\n{rag_context}", + }) + + # Add current state context + messages.append({ + "role": "system", + "content": f"Current State:\n{self.state.get_summary()}", + }) + + # Add conversation history (strip internal metadata for LLM) + for msg in self.message_history: + llm_msg: dict[str, Any] = {"role": msg["role"], "content": msg["content"]} + if "tool_calls" in msg: + llm_msg["tool_calls"] = msg["tool_calls"] + if "tool_call_id" in msg: + llm_msg["tool_call_id"] = msg["tool_call_id"] + if "name" in msg: + llm_msg["name"] = msg["name"] + messages.append(llm_msg) + + return messages + + def _compress_history(self) -> None: + """Compress older messages into a summary, keeping recent ones verbatim.""" + keep_recent = 20 + recent = self.message_history[-keep_recent:] + older = self.message_history[:-keep_recent] + + summary = self._create_summary(older) + + self.message_history = [ + { + "role": "system", + "content": f"[Conversation Summary]\n{summary}", + "metadata": {"is_summary": True}, + } + ] + recent + + def _create_summary(self, messages: list[dict[str, Any]]) -> str: + """Create a concise summary of older messages.""" + actions: list[str] = [] + for msg in messages: + meta = msg.get("metadata", {}) + if meta.get("has_tool_calls"): + actions.append(f"- Tool call: {msg['content'][:100]}") + elif msg["role"] == "user": + actions.append(f"- User: {msg['content'][:100]}") + elif msg["role"] == "tool": + tool_name = msg.get("name", "unknown") + actions.append(f"- Tool result ({tool_name}): {msg['content'][:80]}") + + if not actions: + return "No significant actions in earlier conversation." + + return "Earlier in this conversation:\n" + "\n".join(actions) + + def clear(self) -> None: + """Reset conversation history and state.""" + self.message_history.clear() + self.state = ConversationState() diff --git a/src/onshape_chat/llm/conversation.py b/src/onshape_chat/llm/conversation.py index d2e25a8..e556eb5 100644 --- a/src/onshape_chat/llm/conversation.py +++ b/src/onshape_chat/llm/conversation.py @@ -1,7 +1,12 @@ """Conversation state management.""" +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from onshape_chat.planning.models import BuildPlan @dataclass @@ -13,9 +18,11 @@ class ConversationState: workspace_id: str | None = None part_studio_id: str | None = None part_id: str | None = None + assembly_id: str | None = None last_sketch_id: str | None = None last_feature_id: str | None = None feature_history: list[dict[str, Any]] = field(default_factory=list) + current_plan: BuildPlan | None = None def update(self, key: str, value: Any) -> None: """Update a state field.""" @@ -41,6 +48,14 @@ def get_summary(self) -> str: summary_parts.append(f"Part studio: {self.part_studio_id}") if self.last_sketch_id: summary_parts.append(f"Last sketch: {self.last_sketch_id}") + if self.assembly_id: + summary_parts.append(f"Assembly: {self.assembly_id}") if self.feature_history: summary_parts.append(f"Features created: {len(self.feature_history)}") + last = self.feature_history[-1] + summary_parts.append(f"Last feature: {last['name']}") + if self.current_plan: + summary_parts.append( + f"Active plan: {self.current_plan.completed_steps}/{self.current_plan.total_steps} steps done" + ) return "\n".join(summary_parts) if summary_parts else "No active document yet" diff --git a/src/onshape_chat/llm/prompts.py b/src/onshape_chat/llm/prompts.py index 1f20d8d..98e8470 100644 --- a/src/onshape_chat/llm/prompts.py +++ b/src/onshape_chat/llm/prompts.py @@ -1,6 +1,6 @@ """System prompts and context for the LLM.""" -SYSTEM_PROMPT = """You are an Onshape CAD assistant. You help users create 3D models using natural language. +SYSTEM_PROMPT = """You are an Onshape CAD assistant. You help users create 3D models and assemblies using natural language. You have access to the Onshape API and can perform the following operations: @@ -12,34 +12,214 @@ - Create sketches on default planes (XY, XZ, YZ) - Draw rectangles with specified width and height - Draw circles with specified radius +- Draw regular polygons (triangles, hexagons, octagons, etc.) with specified number of sides and radius +- Draw arbitrary closed profiles from computed [x, y] coordinate points (for gear teeth, cams, custom shapes) - All dimensions are in millimeters +**Complex Shapes via Coordinates:** +- For gears, cams, splines, and complex profiles: compute the [x,y] coordinates yourself and use `create_sketch_from_points` +- You are a capable math engine — calculate involute gear tooth profiles, cam curves, etc. directly + **3D Features:** - Extrude sketches into 3D solids - Specify extrusion depth and direction +- Boolean operations: `new` (separate body), `add` (merge with existing), `subtract` (cut from existing) + +**Assembly Operations:** +- Create assemblies +- Insert parts into assemblies +- Add mates (constraints) between parts: FASTENED, REVOLUTE, SLIDER, CYLINDRICAL, PLANAR, BALL, PARALLEL, TANGENT + +**Export:** +- Export parts to STL (for 3D printing) or STEP (for CAD interchange) +- Show visual previews of parts and assemblies + +**Undo/Rollback:** +- Undo the last operation +- Rollback to a specific feature +- View feature history **Important Guidelines:** -1. **Dimensions**: Always use millimeters (mm) for all dimensions. Users may omit units, so assume mm. +1. **Dimensions**: Always use millimeters (mm). Users may omit units — assume mm. 2. **Coordinate System**: - XY plane is horizontal (Z is up) - XZ plane is vertical depth (Y is forward) - YZ plane is vertical side (X is sideways) -3. **State Tracking**: You automatically track: - - Current document and workspace - - Current part studio - - Last sketch created (for extrusion) +3. **State Tracking**: You automatically track the current document, workspace, part studio, assembly, and all features created. 4. **Workflow**: The typical workflow is: - Create a document - - Create a sketch on a plane - - Extrude the sketch to make it 3D + - Create sketches on planes + - Extrude sketches to make 3D parts + - Create assemblies and add mates + - Export when ready + +5. **Assemblies**: When building assemblies: + - Create the assembly first + - Insert parts one at a time + - Add mates to position parts relative to each other + - The first inserted part is grounded at origin + +6. **Errors**: If something fails, explain what went wrong and suggest how to fix it. Users can use "undo" to revert. + +7. **Clarity**: Always confirm what you're about to do before doing it. + +8. **Unit Conversion**: Convert user dimensions to mm before calling tools. + - 1 inch = 25.4 mm + - 1 cm = 10 mm + - 1 foot = 304.8 mm + +9. **Hollow Objects** (mugs, cups, pipes, boxes): Use the subtract extrude workflow: + a. Sketch the **outer** profile on a plane + b. Extrude with `operation="new"` to create the solid body + c. Sketch the **inner** profile (smaller, concentric) on the **same plane** + d. Extrude with `operation="subtract"` to hollow it out + - For a mug: outer circle → extrude (new) → inner circle (wall thickness smaller) → extrude subtract (leave a bottom) + - For a box: outer rectangle → extrude (new) → inner rectangle → extrude subtract + +10. **Attached Parts** (handles, fins, bosses): Use the add extrude workflow: + - Sketch the attachment profile on the appropriate side plane + - Extrude with `operation="add"` to merge it with the main body + +11. **Coffee Mug Example** (step-by-step): + a. Create document "Coffee Mug" + b. Sketch outer circle on XY plane (e.g., radius 38mm) + c. Extrude 100mm forward (`operation="new"`) — solid cylinder + d. Sketch inner circle on XY plane (e.g., radius 35mm — 3mm wall) + e. Extrude 97mm forward (`operation="subtract"`) — hollows it, leaving 3mm bottom + f. (Optional) Sketch handle profile on YZ plane, extrude with `operation="add"` + +12. **Complex Geometry**: For complex shapes: + - Use `create_sketch_polygon` for regular shapes (hexagons, octagons, etc.) + - Use `create_sketch_from_points` for gears, cams, and custom profiles — **compute the involute tooth or profile coordinates yourself** as [x,y] pairs in mm, then pass them as a closed profile + - For a gear: calculate involute tooth profiles, generate points for all teeth around the circle, and pass them all as one closed polygon + - Do NOT use `run_featurescript` for geometry — it is unreliable + +13. **Gear Workflow**: To create a gear: + a. Create document + b. Calculate gear geometry: module = OD / (N+2), pitch radius, base radius, root radius, tip radius + c. For each tooth, compute involute curve points from base circle to tip circle + d. Connect teeth with root circle arcs (as line segments) + e. Pass ALL points as one closed profile to `create_sketch_from_points` + f. Extrude to desired thickness + +Break down complex requests into clear steps and execute them one at a time. +""" + +PLANNING_PROMPT = """You are a CAD build planner. Given a user's request to build a 3D model, break it down into an ordered list of tool calls. + +Available tools: +{tool_descriptions} + +Current state: +{state_summary} + +Rules: +1. If no document exists, the first step must be create_document. +2. Every 3D part requires TWO steps: a sketch step THEN an extrude step. Never skip the extrude. +3. All dimensions are in millimeters. Convert user units (cm, inches) to mm. +4. Use the simplest sketch tool possible: + - Rectangles/squares: create_sketch_rectangle + - Circles: create_sketch_circle + - Regular polygons (hexagons, etc.): create_sketch_polygon + - Complex shapes (gears, cams, custom profiles): create_sketch_from_points +5. NEVER use run_featurescript for creating geometry. It is unreliable and fails often. + Always use create_sketch_from_points for complex shapes instead. +6. For gears: compute the tooth profile coordinates yourself and pass them to create_sketch_from_points. + - Calculate: module = diameter / (num_teeth + 2), pitch_radius, base_radius, tip_radius, root_radius + - For each tooth: generate involute curve points from base circle to tip circle + - Connect all teeth with root circle arcs + - Pass ALL points as one closed polygon to create_sketch_from_points + - Then extrude to the desired thickness +7. Choose the correct plane based on orientation: + - XY = horizontal (Z is up) — default for most parts + - XZ = vertical depth (Y is forward) — perpendicular to XY when viewed from front + - YZ = vertical side (X is sideways) — perpendicular to XY when viewed from side +8. Return ONLY a JSON array of steps. No commentary, no markdown. + +Each step must be an object with: +- "step_number": integer starting at 1 +- "description": short human-readable description of what this step does +- "tool": exact tool function name +- "args": object with the tool's arguments and their values + +Example — simple cylinder: +[ + {{"step_number": 1, "description": "Create a new document", "tool": "create_document", "args": {{"name": "Cylinder"}}}}, + {{"step_number": 2, "description": "Sketch circle for cylinder base", "tool": "create_sketch_circle", "args": {{"plane": "XY", "radius": 40}}}}, + {{"step_number": 3, "description": "Extrude cylinder body", "tool": "extrude", "args": {{"depth": 100}}}} +] + +Example — gear (10 teeth, 50mm diameter, 20mm thick): +[ + {{"step_number": 1, "description": "Create a new document", "tool": "create_document", "args": {{"name": "Gear"}}}}, + {{"step_number": 2, "description": "Sketch gear tooth profile (10 teeth, 50mm OD)", "tool": "create_sketch_from_points", "args": {{"plane": "XY", "points": [[24.0, 0.0], [25.0, 2.0], ...], "closed": true}}}}, + {{"step_number": 3, "description": "Extrude gear to 20mm thickness", "tool": "extrude", "args": {{"depth": 20}}}} +] + +Example — coffee mug (hollow body with handle): +A hollow object requires MULTIPLE sketch+extrude pairs with different operations: +1. Outer profile → extrude with operation="new" (solid body) +2. Inner profile on SAME plane → extrude with operation="subtract" (hollows it out, leave a bottom) +3. Handle profile on side plane → extrude with operation="add" (attaches to body) +[ + {{"step_number": 1, "description": "Create a new document", "tool": "create_document", "args": {{"name": "Coffee Mug"}}}}, + {{"step_number": 2, "description": "Sketch outer circle (76.2mm diameter)", "tool": "create_sketch_circle", "args": {{"plane": "XY", "radius": 38.1}}}}, + {{"step_number": 3, "description": "Extrude outer cylinder 101.6mm", "tool": "extrude", "args": {{"depth": 101.6, "operation": "new"}}}}, + {{"step_number": 4, "description": "Sketch inner circle on same plane (3mm wall)", "tool": "create_sketch_circle", "args": {{"plane": "XY", "radius": 35.1}}}}, + {{"step_number": 5, "description": "Subtract-extrude to hollow (leave 3mm bottom)", "tool": "extrude", "args": {{"depth": 98.6, "operation": "subtract"}}}}, + {{"step_number": 6, "description": "Sketch D-shaped handle on YZ plane", "tool": "create_sketch_from_points", "args": {{"plane": "YZ", "points": [[38.1, 20.0], [60.0, 20.0], [60.0, 82.0], [38.1, 82.0]], "closed": true}}}}, + {{"step_number": 7, "description": "Extrude handle and merge with body", "tool": "extrude", "args": {{"depth": 12.7, "operation": "add"}}}} +] + +IMPORTANT rules for hollow objects (mugs, cups, boxes, pipes): +- Always create the OUTER shape first with operation="new" +- Then create an INNER shape on the SAME plane and extrude with operation="subtract" +- Leave a bottom/wall by making the subtract extrude shorter than the outer +- Attachments (handles, fins, bosses) use a sketch on a side plane + extrude with operation="add" + +User request: {user_request} +""" + +VERIFICATION_PROMPT = """You are a CAD quality inspector. You are given a screenshot of a CAD model in progress and must verify whether the latest step was executed correctly. + +Overall goal: {overall_goal} +Current step: Step {step_number} — {step_description} +Tool used: {tool_name}({tool_args}) + +Look at the image and determine: +1. Does the model look correct so far? +2. Is the latest operation visible and reasonable? +3. Are there any obvious geometry errors (missing features, wrong proportions, overlapping geometry)? + +Respond with ONLY a JSON object: +{{"pass": true/false, "issues": "description of problems if any", "suggestion": "specific fix suggestion if failed"}} + +IMPORTANT: Only extrude operations produce visible 3D geometry changes. Sketch operations (create_sketch_*) create 2D profiles +on planes and will NOT be visually different in the 3D view. You will only be asked to verify steps that produce visible 3D changes. + +If you cannot determine correctness (e.g. the image is blank or unclear), set pass to true and note the uncertainty in issues. +""" + +MULTI_ANGLE_VERIFICATION_PROMPT = """You are a CAD quality inspector examining a model from 14 camera angles (6 orthographic sides + 8 isometric corners). +Each image is labeled with its view direction. + +Overall goal: {overall_goal} +Current step: Step {step_number} — {step_description} +Tool used: {tool_name}({tool_args}) -5. **Clarity**: Always confirm what you're about to do before doing it. For example: "I'll create a 50mm × 30mm rectangle on the XY plane." +Check ALL views for: +1. Is the geometry correct from every angle? +2. Are there missing features visible from certain angles but not others? +3. Are dimensions/proportions consistent across views? +4. Any holes, cuts, or boolean operations fully penetrating as expected? +5. Any floating geometry or disconnected parts? -6. **Errors**: If something fails, explain what went wrong and suggest how to fix it. +Respond with ONLY a JSON object: +{{"pass": true/false, "issues": "description of problems if any", "suggestion": "specific fix suggestion if failed"}} -When users describe what they want to build, break it down into clear steps and execute them one at a time. +If you cannot determine correctness, set pass to true and note the uncertainty in issues. """ diff --git a/src/onshape_chat/llm/rag.py b/src/onshape_chat/llm/rag.py new file mode 100644 index 0000000..5e58d5a --- /dev/null +++ b/src/onshape_chat/llm/rag.py @@ -0,0 +1,60 @@ +"""RAG context loading for enhanced LLM prompts.""" + +from pathlib import Path + + +class RAGContext: + """Load and serve RAG documents for LLM context.""" + + def __init__(self, docs_dir: str | Path | None = None): + self.docs_dir = Path(docs_dir) if docs_dir else self._find_docs_dir() + self.documents: dict[str, str] = {} + if self.docs_dir.exists(): + self._load_documents() + + def _find_docs_dir(self) -> Path: + """Find the docs/rag directory relative to the project root.""" + # Walk up from this file to find docs/rag + current = Path(__file__).resolve() + for parent in current.parents: + rag_dir = parent / "docs" / "rag" + if rag_dir.exists(): + return rag_dir + return Path("docs/rag") + + def _load_documents(self) -> None: + """Load all markdown files from the RAG directory.""" + for md_file in self.docs_dir.glob("*.md"): + key = md_file.stem.replace("-", "_") + self.documents[key] = md_file.read_text(encoding="utf-8") + + def get_context(self, max_tokens: int = 4000) -> str: + """ + Get all RAG context as a single string. + + Args: + max_tokens: Approximate max character budget (rough 4 chars/token) + + Returns: + Combined RAG context string + """ + if not self.documents: + return "" + + sections = [] + budget = max_tokens * 4 # rough char estimate + used = 0 + + for name, content in self.documents.items(): + header = f"## {name.replace('_', ' ').title()}\n\n" + section = header + content + "\n\n" + if used + len(section) > budget: + break + sections.append(section) + used += len(section) + + return "".join(sections) + + def get_section(self, name: str) -> str | None: + """Get a specific RAG document by name.""" + return self.documents.get(name) diff --git a/src/onshape_chat/llm/tools.py b/src/onshape_chat/llm/tools.py index cb3432a..0433663 100644 --- a/src/onshape_chat/llm/tools.py +++ b/src/onshape_chat/llm/tools.py @@ -2,119 +2,252 @@ from typing import Any -# Tool definitions for function calling -TOOLS = [ - { + +def _tool(name: str, description: str, properties: dict, required: list[str] | None = None) -> dict: + """Helper to build an OpenAI-style tool definition.""" + return { "type": "function", "function": { - "name": "create_document", - "description": "Create a new Onshape document (CAD project)", + "name": name, + "description": description, "parameters": { "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Document name", - }, - "description": {"type": "string", "description": "Optional description of the document"}, - }, - "required": ["name"], + "properties": properties, + "required": required or [], }, }, - }, - { - "type": "function", - "function": { - "name": "create_sketch_rectangle", - "description": "Create a rectangular sketch on a plane", - "parameters": { - "type": "object", - "properties": { - "plane": { - "type": "string", - "enum": ["XY", "XZ", "YZ"], - "description": "Plane to sketch on (XY=horizontal, XZ=vertical depth, YZ=vertical side)", - }, - "width": { - "type": "number", - "description": "Rectangle width in mm (must be positive)", - }, - "height": { - "type": "number", - "description": "Rectangle height in mm (must be positive)", - }, - "center_x": { - "type": "number", - "description": "X position of rectangle center in mm (default: 0)", - "default": 0.0, - }, - "center_y": { - "type": "number", - "description": "Y position of rectangle center in mm (default: 0)", - "default": 0.0, - }, - }, - "required": ["plane", "width", "height"], + } + + +# ────────────────────────────────────────────── +# Document tools +# ────────────────────────────────────────────── + +DOCUMENT_TOOLS = [ + _tool( + "create_document", + "Create a new Onshape document (CAD project)", + { + "name": {"type": "string", "description": "Document name"}, + "description": {"type": "string", "description": "Optional description"}, + }, + ["name"], + ), +] + +# ────────────────────────────────────────────── +# Sketch tools +# ────────────────────────────────────────────── + +SKETCH_TOOLS = [ + _tool( + "create_sketch_rectangle", + "Create a rectangular sketch on a plane", + { + "plane": { + "type": "string", + "enum": ["XY", "XZ", "YZ"], + "description": "Plane to sketch on (XY=horizontal, XZ=vertical depth, YZ=vertical side)", }, + "width": {"type": "number", "description": "Rectangle width in mm"}, + "height": {"type": "number", "description": "Rectangle height in mm"}, + "center_x": {"type": "number", "description": "X center position in mm (default: 0)", "default": 0.0}, + "center_y": {"type": "number", "description": "Y center position in mm (default: 0)", "default": 0.0}, }, - }, - { - "type": "function", - "function": { - "name": "create_sketch_circle", - "description": "Create a circular sketch on a plane", - "parameters": { - "type": "object", - "properties": { - "plane": { - "type": "string", - "enum": ["XY", "XZ", "YZ"], - "description": "Plane to sketch on", - }, - "radius": { - "type": "number", - "description": "Circle radius in mm (must be positive)", - }, - "center_x": { - "type": "number", - "description": "X position of circle center in mm (default: 0)", - "default": 0.0, - }, - "center_y": { - "type": "number", - "description": "Y position of circle center in mm (default: 0)", - "default": 0.0, - }, - }, - "required": ["plane", "radius"], + ["plane", "width", "height"], + ), + _tool( + "create_sketch_circle", + "Create a circular sketch on a plane", + { + "plane": {"type": "string", "enum": ["XY", "XZ", "YZ"], "description": "Plane to sketch on"}, + "radius": {"type": "number", "description": "Circle radius in mm"}, + "center_x": {"type": "number", "description": "X center in mm (default: 0)", "default": 0.0}, + "center_y": {"type": "number", "description": "Y center in mm (default: 0)", "default": 0.0}, + }, + ["plane", "radius"], + ), + _tool( + "create_sketch_polygon", + "Create a regular polygon sketch (triangle, hexagon, octagon, etc.)", + { + "plane": {"type": "string", "enum": ["XY", "XZ", "YZ"], "description": "Plane to sketch on"}, + "num_sides": {"type": "integer", "description": "Number of sides (3=triangle, 6=hexagon, 8=octagon)"}, + "radius": {"type": "number", "description": "Circumscribed radius in mm (center to vertex)"}, + "center_x": {"type": "number", "description": "X center in mm (default: 0)", "default": 0.0}, + "center_y": {"type": "number", "description": "Y center in mm (default: 0)", "default": 0.0}, + }, + ["plane", "num_sides", "radius"], + ), + _tool( + "create_sketch_from_points", + "Create a sketch from a list of [x,y] coordinate points forming a closed profile. Use for complex shapes like gear teeth, cams, or any custom profile.", + { + "plane": {"type": "string", "enum": ["XY", "XZ", "YZ"], "description": "Plane to sketch on"}, + "points": { + "type": "array", + "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, + "description": "List of [x, y] coordinate pairs in mm", }, + "closed": {"type": "boolean", "description": "Whether to close the profile (default: true)", "default": True}, }, - }, - { - "type": "function", - "function": { - "name": "extrude", - "description": "Extrude the most recent sketch into a 3D solid", - "parameters": { - "type": "object", - "properties": { - "depth": { - "type": "number", - "description": "Extrusion depth in mm (must be positive)", - }, - "direction": { - "type": "string", - "enum": ["forward", "backward", "symmetric"], - "description": "Extrusion direction (default: forward)", - "default": "forward", - }, - }, - "required": ["depth"], + ["plane", "points"], + ), +] + +# ────────────────────────────────────────────── +# FeatureScript tools +# ────────────────────────────────────────────── + +FEATURESCRIPT_TOOLS = [ + _tool( + "run_featurescript", + "Generate and execute simple FeatureScript expressions (queries, calculations). NOT for creating geometry — use create_sketch_from_points instead for complex shapes like gears.", + { + "description": { + "type": "string", + "description": "Natural language description of the CAD operation to perform (e.g., 'Create a 32-tooth involute spur gear with module 2')", }, }, - }, + ["description"], + ), ] +# ────────────────────────────────────────────── +# Feature tools +# ────────────────────────────────────────────── + +FEATURE_TOOLS = [ + _tool( + "extrude", + "Extrude the most recent sketch into a 3D solid. Use operation='subtract' to cut material (boolean cut).", + { + "depth": {"type": "number", "description": "Extrusion depth in mm"}, + "direction": { + "type": "string", + "enum": ["forward", "backward", "symmetric"], + "description": "Extrusion direction (default: forward)", + "default": "forward", + }, + "operation": { + "type": "string", + "enum": ["new", "add", "subtract"], + "description": "Boolean operation: 'new' creates a new body, 'add' joins to existing, 'subtract' cuts from existing (default: new)", + "default": "new", + }, + }, + ["depth"], + ), +] + +# ────────────────────────────────────────────── +# Assembly tools (Phase 3) +# ────────────────────────────────────────────── + +ASSEMBLY_TOOLS = [ + _tool( + "create_assembly", + "Create a new assembly in the current document", + { + "name": {"type": "string", "description": "Assembly name (default: 'Assembly 1')"}, + }, + ), + _tool( + "add_part_to_assembly", + "Add a part to the current assembly", + { + "part": {"type": "string", "description": "Part name or ID to add"}, + }, + ["part"], + ), + _tool( + "mate_parts", + "Create a mate (constraint) between two parts in the assembly", + { + "part1_face": {"type": "string", "description": "First part and face (e.g., 'Base top face')"}, + "part2_face": {"type": "string", "description": "Second part and face (e.g., 'Support bottom face')"}, + "mate_type": { + "type": "string", + "enum": ["FASTENED", "REVOLUTE", "SLIDER", "CYLINDRICAL", "PLANAR", "BALL", "PARALLEL", "TANGENT"], + "description": "Type of mate relationship (default: FASTENED)", + "default": "FASTENED", + }, + "offset": {"type": "number", "description": "Distance/angle offset (default: 0)", "default": 0}, + }, + ["part1_face", "part2_face"], + ), +] + +# ────────────────────────────────────────────── +# Export tools (Phase 3) +# ────────────────────────────────────────────── + +EXPORT_TOOLS = [ + _tool( + "export_stl", + "Export a part to STL format for 3D printing", + { + "part": {"type": "string", "description": "Part name or ID to export (default: current part)"}, + "filename": {"type": "string", "description": "Output filename (without .stl extension)"}, + }, + ), + _tool( + "export_step", + "Export a part or assembly to STEP format for CAD interchange", + { + "part": {"type": "string", "description": "Part name or ID to export (default: current part)"}, + "filename": {"type": "string", "description": "Output filename (without .step extension)"}, + }, + ), + _tool( + "show_preview", + "Display a visual preview of the current part or assembly in the terminal", + {}, + ), +] + +# ────────────────────────────────────────────── +# Undo tools (Phase 3) +# ────────────────────────────────────────────── + +UNDO_TOOLS = [ + _tool( + "undo", + "Undo the last operation (deletes the most recent feature)", + {}, + ), + _tool( + "rollback", + "Rollback to a specific feature by name or number", + { + "feature": { + "type": "string", + "description": "Feature name, ID, or position (e.g., 'last', '2', 'Extrude 1')", + }, + }, + ["feature"], + ), + _tool( + "show_history", + "Show the feature history of the current part", + {}, + ), +] + +# ────────────────────────────────────────────── +# All tools combined +# ────────────────────────────────────────────── + +TOOLS: list[dict[str, Any]] = ( + DOCUMENT_TOOLS + + SKETCH_TOOLS + + FEATURE_TOOLS + # + FEATURESCRIPT_TOOLS # Disabled — generator produces invalid code + + ASSEMBLY_TOOLS + + EXPORT_TOOLS + + UNDO_TOOLS +) + def get_tool_definitions() -> list[dict[str, Any]]: """Get all tool definitions for function calling.""" diff --git a/src/onshape_chat/main.py b/src/onshape_chat/main.py index af7dec9..add53d2 100644 --- a/src/onshape_chat/main.py +++ b/src/onshape_chat/main.py @@ -1,23 +1,34 @@ """Main entry point for Onshape Chat.""" import sys -from dotenv import load_dotenv -from onshape_chat.ui.chat import ChatInterface +from dotenv import load_dotenv def main() -> int: """ - Run the Onshape Chat CLI. + Run the Onshape Chat CLI or web server. - Returns: - Exit code (0 for success, 1 for error) + Usage: + onshape-chat # Terminal chat UI + onshape-chat web # FastAPI web server (port 8000) + onshape-chat web 3000 # Custom port """ - # Load environment variables load_dotenv() + args = sys.argv[1:] + + if args and args[0] == "web": + return _run_web(args[1:]) + + return _run_cli() + + +def _run_cli() -> int: + """Run the Rich terminal chat interface.""" try: - # Create and run chat interface + from onshape_chat.ui.chat import ChatInterface + chat = ChatInterface() chat.run() return 0 @@ -29,5 +40,28 @@ def main() -> int: return 1 +def _run_web(args: list[str]) -> int: + """Run the FastAPI web server.""" + try: + import uvicorn + + from onshape_chat.api.server import app + + port = int(args[0]) if args else 8000 + host = "0.0.0.0" + print(f"Starting Onshape Chat web server on http://{host}:{port}") + uvicorn.run(app, host=host, port=port, timeout_keep_alive=120) + return 0 + except ImportError: + print("Error: FastAPI/uvicorn not installed. Run: pip install 'onshape-chat[web]'") + return 1 + except KeyboardInterrupt: + print("\nServer stopped.") + return 0 + except Exception as e: + print(f"Error: {e}") + return 1 + + if __name__ == "__main__": sys.exit(main()) diff --git a/src/onshape_chat/onshape/assemblies.py b/src/onshape_chat/onshape/assemblies.py new file mode 100644 index 0000000..0b39340 --- /dev/null +++ b/src/onshape_chat/onshape/assemblies.py @@ -0,0 +1,370 @@ +"""Onshape assembly operations.""" + +from typing import Any, Literal + +from onshape_chat.errors import AssemblyError, OnshapeError +from onshape_chat.onshape.client import OnshapeClient + + +class AssemblyManager: + """Manager for Onshape assembly operations.""" + + def __init__(self, client: OnshapeClient): + """ + Initialize assembly manager. + + Args: + client: Authenticated Onshape API client + """ + self.client = client + + def create_assembly( + self, + document_id: str, + workspace_id: str, + name: str = "Assembly 1", + ) -> dict[str, Any]: + """ + Create a new assembly element in a document. + + Args: + document_id: Document ID + workspace_id: Workspace ID + name: Assembly name (default: "Assembly 1") + + Returns: + Created assembly info with keys: + - id: Assembly element ID + - name: Assembly name + - elementType: "ASSEMBLY" + + Raises: + AssemblyError: If creation fails + """ + try: + endpoint = f"/assemblies/d/{document_id}/w/{workspace_id}" + body = {"name": name} + response = self.client.post(endpoint, json_body=body) + return response + except OnshapeError as e: + raise AssemblyError( + f"Failed to create assembly: {e}", + details={"document_id": document_id, "workspace_id": workspace_id}, + ) from e + + def insert_part( + self, + document_id: str, + workspace_id: str, + assembly_id: str, + part_studio_id: str, + part_id: str, + position: dict[str, float] | None = None, + ) -> dict[str, Any]: + """ + Insert a part instance into an assembly. + + Args: + document_id: Document ID + workspace_id: Workspace ID + assembly_id: Assembly element ID + part_studio_id: Part Studio element ID containing the part + part_id: Part ID to insert + position: Optional position dict with keys x, y, z (in mm). + If None, part is inserted at origin. + + Returns: + Created instance info with: + - id: Instance ID + - documentId: Source document ID + - elementId: Source element ID + - partId: Source part ID + - transform: 4x4 transformation matrix + + Raises: + AssemblyError: If insertion fails + """ + try: + endpoint = f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}/instances" + + # Build 4x4 identity matrix with position in last column + # Format: row-major [m00, m01, m02, m03, m10, m11, m12, m13, ...] + if position: + x = position.get("x", 0.0) + y = position.get("y", 0.0) + z = position.get("z", 0.0) + # Convert mm to meters (Onshape uses meters internally) + x_m = x / 1000.0 + y_m = y / 1000.0 + z_m = z / 1000.0 + transform = [ + 1.0, 0.0, 0.0, x_m, # Row 0 + 0.0, 1.0, 0.0, y_m, # Row 1 + 0.0, 0.0, 1.0, z_m, # Row 2 + 0.0, 0.0, 0.0, 1.0, # Row 3 + ] + else: + # Identity matrix (origin) + transform = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] + + body = { + "documentId": document_id, + "elementId": part_studio_id, + "partId": part_id, + "transform": transform, + } + + response = self.client.post(endpoint, json_body=body) + return response + except OnshapeError as e: + raise AssemblyError( + f"Failed to insert part: {e}", + details={ + "document_id": document_id, + "workspace_id": workspace_id, + "assembly_id": assembly_id, + "part_id": part_id, + }, + ) from e + + def add_mate( + self, + document_id: str, + workspace_id: str, + assembly_id: str, + entity1: dict[str, Any], + entity2: dict[str, Any], + mate_type: Literal[ + "FASTENED", + "REVOLUTE", + "SLIDER", + "CYLINDRICAL", + "PIN_SLOT", + "PLANAR", + "BALL", + "PARALLEL", + "TANGENT", + ], + offset: float = 0.0, + flipped: bool = False, + ) -> dict[str, Any]: + """ + Create a mate feature between two entities in an assembly. + + Args: + document_id: Document ID + workspace_id: Workspace ID + assembly_id: Assembly element ID + entity1: First entity dict with keys: + - occurrence: List of instance IDs forming occurrence path + - entityType: Type of entity (e.g., "FACE", "EDGE") + - entityId: Entity ID + entity2: Second entity dict (same structure as entity1) + mate_type: Type of mate constraint: + - FASTENED: Fixed connection (no degrees of freedom) + - REVOLUTE: Rotation around an axis + - SLIDER: Translation along an axis + - CYLINDRICAL: Rotation + translation along axis + - PIN_SLOT: Rotation + translation in slot + - PLANAR: Sliding on a plane + - BALL: Rotation in all directions + - PARALLEL: Parallel alignment + - TANGENT: Tangent contact + offset: Offset distance in mm (for PLANAR, SLIDER, etc.) + flipped: Whether to flip the mate orientation + + Returns: + Created mate feature info with: + - id: Mate feature ID + - type: Mate type + - suppressed: Whether mate is suppressed + + Raises: + AssemblyError: If mate creation fails + """ + try: + endpoint = f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}/features" + + # Build mate feature structure + feature = { + "feature": { + "type": 134, # BTMFeature type code for mates + "typeName": "BTMFeature", + "message": { + "featureType": "mate", + "name": f"{mate_type.capitalize()} Mate", + "parameters": [ + { + "type": 148, # BTMParameterEnum + "typeName": "BTMParameterEnum", + "message": { + "parameterId": "mateType", + "enumName": mate_type, + }, + }, + { + "type": 145, # BTMParameterQueryList + "typeName": "BTMParameterQueryList", + "message": { + "parameterId": "mateConnector1", + "queries": [ + { + "type": 138, # BTMIndividualQuery + "typeName": "BTMIndividualQuery", + "message": { + "occurrence": entity1.get("occurrence", []), + "queryType": entity1.get("entityType", "FACE"), + "deterministicId": entity1.get("entityId", ""), + }, + } + ], + }, + }, + { + "type": 145, # BTMParameterQueryList + "typeName": "BTMParameterQueryList", + "message": { + "parameterId": "mateConnector2", + "queries": [ + { + "type": 138, # BTMIndividualQuery + "typeName": "BTMIndividualQuery", + "message": { + "occurrence": entity2.get("occurrence", []), + "queryType": entity2.get("entityType", "FACE"), + "deterministicId": entity2.get("entityId", ""), + }, + } + ], + }, + }, + ], + }, + } + } + + # Add offset parameter if non-zero + if offset != 0.0: + feature["feature"]["message"]["parameters"].append( + { + "type": 147, # BTMParameterQuantity + "typeName": "BTMParameterQuantity", + "message": { + "parameterId": "offset", + "expression": str(offset), + "units": "millimeter", + }, + } + ) + + # Add flipped parameter if True + if flipped: + feature["feature"]["message"]["parameters"].append( + { + "type": 144, # BTMParameterBoolean + "typeName": "BTMParameterBoolean", + "message": { + "parameterId": "flipped", + "value": True, + }, + } + ) + + response = self.client.post(endpoint, json_body=feature) + return response + except OnshapeError as e: + raise AssemblyError( + f"Failed to create mate: {e}", + details={ + "document_id": document_id, + "workspace_id": workspace_id, + "assembly_id": assembly_id, + "mate_type": mate_type, + }, + ) from e + + def get_assembly_definition( + self, + document_id: str, + workspace_id: str, + assembly_id: str, + ) -> dict[str, Any]: + """ + Get the complete assembly structure and definition. + + Args: + document_id: Document ID + workspace_id: Workspace ID + assembly_id: Assembly element ID + + Returns: + Assembly definition with keys: + - rootAssembly: Root assembly info + - subAssemblies: List of sub-assemblies + - parts: List of part instances + - instances: List of all instances + - features: List of assembly features (mates, patterns, etc.) + + Raises: + AssemblyError: If retrieval fails + """ + try: + endpoint = f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}" + response = self.client.get(endpoint) + return response + except OnshapeError as e: + raise AssemblyError( + f"Failed to get assembly definition: {e}", + details={ + "document_id": document_id, + "workspace_id": workspace_id, + "assembly_id": assembly_id, + }, + ) from e + + def get_instances( + self, + document_id: str, + workspace_id: str, + assembly_id: str, + ) -> list[dict[str, Any]]: + """ + List all instances in an assembly. + + Args: + document_id: Document ID + workspace_id: Workspace ID + assembly_id: Assembly element ID + + Returns: + List of instance dicts with keys: + - id: Instance ID + - name: Instance name + - type: Instance type (Part, Assembly, etc.) + - documentId: Source document ID + - elementId: Source element ID + - partId: Source part ID (for part instances) + - transform: 4x4 transformation matrix + - suppressed: Whether instance is suppressed + + Raises: + AssemblyError: If retrieval fails + """ + try: + definition = self.get_assembly_definition(document_id, workspace_id, assembly_id) + instances = definition.get("instances", []) + return instances + except OnshapeError as e: + raise AssemblyError( + f"Failed to get assembly instances: {e}", + details={ + "document_id": document_id, + "workspace_id": workspace_id, + "assembly_id": assembly_id, + }, + ) from e diff --git a/src/onshape_chat/onshape/auth.py b/src/onshape_chat/onshape/auth.py index f47b53c..9256992 100644 --- a/src/onshape_chat/onshape/auth.py +++ b/src/onshape_chat/onshape/auth.py @@ -3,19 +3,21 @@ import base64 import hashlib import hmac -import time -from datetime import datetime +import secrets +import string +from datetime import datetime, timezone from typing import Literal def generate_nonce() -> str: - """Generate a unique nonce for API requests.""" - return str(int(time.time() * 1000)) + """Generate a unique 25-character alphanumeric nonce.""" + alphabet = string.ascii_lowercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(25)) def format_date() -> str: """Format current date in RFC 2616 format.""" - return datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S GMT") + return datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT") def generate_hmac_signature( @@ -30,30 +32,21 @@ def generate_hmac_signature( """ Generate HMAC-SHA256 signature for Onshape API request. - Args: - method: HTTP method (GET, POST, PUT, DELETE) - path: API endpoint path (e.g., /documents/d/...) - nonce: Unique nonce for this request - date: Date header in RFC 2616 format - content_type: Content-Type header value - secret_key: Onshape secret key - query: Query string (without ?) - - Returns: - Base64-encoded HMAC signature + The signature string is: method + nonce + date + content-type + path + query, + each separated by newlines and lowercased. """ - # Build signature string - # Format: method + nonce + date + content-type + path + query - signature_string = f"{method}{nonce}{date}{content_type}{path}{query}" + # Onshape requires a trailing newline after the query string + signature_string = ( + method + "\n" + nonce + "\n" + date + "\n" + + content_type + "\n" + path + "\n" + query + "\n" + ).lower() - # Generate HMAC-SHA256 signature = hmac.new( secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256, ).digest() - # Base64 encode return base64.b64encode(signature).decode("utf-8") @@ -70,18 +63,11 @@ def sign_request( Args: method: HTTP method - path: API endpoint path + path: API endpoint path (without query string) access_key: Onshape access key secret_key: Onshape secret key content_type: Content-Type header value - query: Query string - - Returns: - Dictionary with required headers: - - Authorization - - Date - - On-Nonce - - Content-Type + query: Query string (without leading ?) """ nonce = generate_nonce() date = format_date() @@ -96,7 +82,7 @@ def sign_request( query=query, ) - auth_header = f"Onshape {access_key}:{signature}" + auth_header = f"On {access_key}:HmacSHA256:{signature}" return { "Authorization": auth_header, diff --git a/src/onshape_chat/onshape/client.py b/src/onshape_chat/onshape/client.py index 000fd1d..2a116b1 100644 --- a/src/onshape_chat/onshape/client.py +++ b/src/onshape_chat/onshape/client.py @@ -2,21 +2,20 @@ import time from typing import Any, Literal +from urllib.parse import urlencode, urlparse import requests from requests.exceptions import RequestException +from onshape_chat.errors import ( + AuthenticationError, + OnshapeError, + RateLimitError, +) from onshape_chat.onshape.auth import sign_request - -class OnshapeAPIError(Exception): - """Exception raised for Onshape API errors.""" - - def __init__(self, message: str, status_code: int | None = None, response: dict | None = None): - self.message = message - self.status_code = status_code - self.response = response - super().__init__(self.message) +# Keep for backwards compatibility with existing imports +OnshapeAPIError = OnshapeError class OnshapeClient: @@ -28,14 +27,6 @@ def __init__( secret_key: str, base_url: str = "https://cad.onshape.com/api/v6", ): - """ - Initialize Onshape API client. - - Args: - access_key: Onshape API access key - secret_key: Onshape API secret key - base_url: Base URL for Onshape API - """ self.access_key = access_key self.secret_key = secret_key self.base_url = base_url.rstrip("/") @@ -43,49 +34,35 @@ def __init__( self.max_retries = 3 self.retry_delay = 1.0 + # Extract the path prefix from base_url for HMAC signing + # e.g., "https://cad.onshape.com/api/v6" -> "/api/v6" + self._path_prefix = urlparse(self.base_url).path.rstrip("/") + def request( self, method: Literal["GET", "POST", "PUT", "DELETE"], endpoint: str, params: dict[str, Any] | None = None, json_body: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """ - Make an authenticated request to the Onshape API. - - Args: - method: HTTP method - endpoint: API endpoint path (e.g., /documents) - params: Query parameters - json_body: JSON request body - - Returns: - Parsed JSON response - - Raises: - OnshapeAPIError: If the request fails - """ - # Build query string + ) -> dict[str, Any] | list: + """Make an authenticated request to the Onshape API with retry logic.""" query = "" if params: - from urllib.parse import urlencode - query = urlencode(params, doseq=True) - # Generate auth headers - path = endpoint if not query else f"{endpoint}?{query}" + # Sign with full path (prefix + endpoint) so HMAC matches the URL + sign_path = f"{self._path_prefix}{endpoint}" + headers = sign_request( method=method, - path=path, + path=sign_path, access_key=self.access_key, secret_key=self.secret_key, query=query, ) - # Build full URL url = f"{self.base_url}{endpoint}" - # Make request with retry logic for attempt in range(self.max_retries): try: response = self.session.request( @@ -97,34 +74,91 @@ def request( timeout=30, ) - # Handle errors + if response.status_code == 401: + raise AuthenticationError( + "Authentication failed — check API keys", + details={"status_code": 401}, + ) + + if response.status_code == 429: + retry_after = float(response.headers.get("Retry-After", 60)) + if attempt < self.max_retries - 1: + time.sleep(retry_after) + continue + raise RateLimitError( + "Rate limit exceeded", + retry_after=retry_after, + ) + if response.status_code >= 400: error_data = response.json() if response.content else {} - raise OnshapeAPIError( - message=error_data.get("message", f"HTTP {response.status_code}"), - status_code=response.status_code, - response=error_data, + raise OnshapeError( + error_data.get("message", f"HTTP {response.status_code}"), + details={ + "status_code": response.status_code, + "response": error_data, + }, ) + # Some endpoints return empty bodies (e.g., DELETE) + if not response.content: + return {} return response.json() - except OnshapeAPIError: - # Don't retry client errors (4xx) + except (AuthenticationError, OnshapeError): raise except RequestException as e: if attempt == self.max_retries - 1: - raise OnshapeAPIError(f"Request failed: {e}") from e - # Wait before retry - time.sleep(self.retry_delay * (2**attempt)) + raise OnshapeError(f"Request failed after {self.max_retries} retries: {e}") from e + time.sleep(self.retry_delay * (2 ** attempt)) + + raise OnshapeError("Max retries exceeded") + + def request_binary( + self, + method: Literal["GET", "POST"], + endpoint: str, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + ) -> bytes: + """Make an authenticated request that returns binary data (for exports/thumbnails).""" + query = "" + if params: + query = urlencode(params, doseq=True) + + sign_path = f"{self._path_prefix}{endpoint}" + + headers = sign_request( + method=method, + path=sign_path, + access_key=self.access_key, + secret_key=self.secret_key, + query=query, + ) + + url = f"{self.base_url}{endpoint}" + response = self.session.request( + method=method, + url=url, + headers=headers, + params=params, + json=json_body, + timeout=120, + ) - # Should never reach here - raise OnshapeAPIError("Max retries exceeded") + if response.status_code >= 400: + raise OnshapeError( + f"Binary request failed: HTTP {response.status_code}", + details={"status_code": response.status_code}, + ) - def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + return response.content + + def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any] | list: """Make a GET request.""" return self.request("GET", endpoint, params=params) - def post(self, endpoint: str, json_body: dict[str, Any] | None = None) -> dict[str, Any]: + def post(self, endpoint: str, json_body: dict[str, Any] | None = None) -> dict[str, Any] | list: """Make a POST request.""" return self.request("POST", endpoint, json_body=json_body) @@ -135,3 +169,7 @@ def put(self, endpoint: str, json_body: dict[str, Any] | None = None) -> dict[st def delete(self, endpoint: str) -> dict[str, Any]: """Make a DELETE request.""" return self.request("DELETE", endpoint) + + def get_binary(self, endpoint: str, params: dict[str, Any] | None = None) -> bytes: + """Make a GET request for binary data.""" + return self.request_binary("GET", endpoint, params=params) diff --git a/src/onshape_chat/onshape/documents.py b/src/onshape_chat/onshape/documents.py index 5b9fead..2159f66 100644 --- a/src/onshape_chat/onshape/documents.py +++ b/src/onshape_chat/onshape/documents.py @@ -2,7 +2,7 @@ from typing import Any -from onshape_chat.onshape.client import OnshapeClient, OnshapeAPIError +from onshape_chat.onshape.client import OnshapeClient class DocumentManager: @@ -34,7 +34,7 @@ def create_document(self, name: str, description: str | None = None) -> dict[str Raises: OnshapeAPIError: If creation fails """ - body: dict[str, Any] = {"name": name} + body: dict[str, Any] = {"name": name, "isPublic": True} if description: body["description"] = description @@ -107,4 +107,7 @@ def get_workspaces(self, document_id: str) -> list[dict[str, Any]]: endpoint = f"/documents/{document_id}/workspaces" response = self.client.get(endpoint) + # API may return a list directly or a dict with "items" key + if isinstance(response, list): + return response return response.get("items", []) diff --git a/src/onshape_chat/onshape/export.py b/src/onshape_chat/onshape/export.py new file mode 100644 index 0000000..b9b0bb9 --- /dev/null +++ b/src/onshape_chat/onshape/export.py @@ -0,0 +1,307 @@ +"""Onshape export and thumbnail operations.""" + +from pathlib import Path +from typing import Any, Literal + +from onshape_chat.errors import ExportError, OnshapeError +from onshape_chat.onshape.client import OnshapeClient + +# Valid export formats +ExportFormat = Literal["STL", "STEP", "PARASOLID"] + + +def save_to_file(data: bytes, filepath: str) -> str: + """ + Save binary data to a file. + + Args: + data: Binary data to save + filepath: Path to save the file + + Returns: + Absolute path of the saved file + + Raises: + ExportError: If file write fails + """ + try: + path = Path(filepath).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return str(path) + except Exception as e: + raise ExportError(f"Failed to save file to {filepath}: {e}") from e + + +class ExportManager: + """Manager for Onshape export and thumbnail operations.""" + + def __init__(self, client: OnshapeClient): + """ + Initialize export manager. + + Args: + client: Authenticated Onshape API client + """ + self.client = client + + def _generate_filename(self, identifier: str, export_format: ExportFormat) -> str: + """ + Generate a filename for an export. + + Args: + identifier: Part ID or other identifier + export_format: Export format + + Returns: + Generated filename + """ + # Map format to file extension + extension_map = { + "STL": "stl", + "STEP": "step", + "PARASOLID": "x_t", + } + extension = extension_map[export_format] + + # Use first 8 characters of identifier + short_id = identifier[:8] if len(identifier) > 8 else identifier + return f"{short_id}_{export_format.lower()}.{extension}" + + def export_part( + self, + document_id: str, + workspace_id: str, + element_id: str, + part_id: str, + export_format: ExportFormat, + filename: str | None = None, + ) -> dict[str, Any]: + """ + Export a part from a Part Studio. + + Args: + document_id: Document ID + workspace_id: Workspace ID + element_id: Element (Part Studio) ID + part_id: Part ID to export + export_format: Export format (STL, STEP, or PARASOLID) + filename: Output filename (auto-generated if None) + + Returns: + Export info with keys: + - format: Export format used + - filename: Absolute path to saved file + - size_bytes: File size in bytes + + Raises: + ExportError: If export fails + OnshapeError: If API request fails + """ + # Build endpoint based on format + format_endpoints = { + "STL": f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/stl", + "STEP": f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/step", + "PARASOLID": f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/parasolid", + } + + endpoint = format_endpoints.get(export_format) + if not endpoint: + raise ExportError(f"Unsupported export format: {export_format}") + + # Build parameters + params: dict[str, Any] = {"partId": part_id} + + # STL-specific parameters + if export_format == "STL": + params["units"] = "millimeter" + params["mode"] = "binary" + + try: + # Download binary data + data = self.client.get_binary(endpoint, params=params) + + # Generate filename if not provided + if filename is None: + filename = self._generate_filename(part_id, export_format) + + # Save to file + filepath = save_to_file(data, filename) + + return { + "format": export_format, + "filename": filepath, + "size_bytes": len(data), + } + + except OnshapeError: + raise + except Exception as e: + raise ExportError(f"Failed to export part: {e}") from e + + def export_assembly( + self, + document_id: str, + workspace_id: str, + assembly_id: str, + export_format: ExportFormat, + filename: str | None = None, + ) -> dict[str, Any]: + """ + Export an assembly. + + Args: + document_id: Document ID + workspace_id: Workspace ID + assembly_id: Assembly element ID + export_format: Export format (STL, STEP, or PARASOLID) + filename: Output filename (auto-generated if None) + + Returns: + Export info with keys: + - format: Export format used + - filename: Absolute path to saved file + - size_bytes: File size in bytes + + Raises: + ExportError: If export fails + OnshapeError: If API request fails + """ + # Build endpoint based on format + format_endpoints = { + "STL": f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}/stl", + "STEP": f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}/step", + "PARASOLID": f"/assemblies/d/{document_id}/w/{workspace_id}/e/{assembly_id}/parasolid", + } + + endpoint = format_endpoints.get(export_format) + if not endpoint: + raise ExportError(f"Unsupported export format: {export_format}") + + # Build parameters + params: dict[str, Any] = {} + + # STL-specific parameters + if export_format == "STL": + params["units"] = "millimeter" + params["mode"] = "binary" + + try: + # Download binary data + data = self.client.get_binary(endpoint, params=params) + + # Generate filename if not provided + if filename is None: + filename = self._generate_filename(assembly_id, export_format) + + # Save to file + filepath = save_to_file(data, filename) + + return { + "format": export_format, + "filename": filepath, + "size_bytes": len(data), + } + + except OnshapeError: + raise + except Exception as e: + raise ExportError(f"Failed to export assembly: {e}") from e + + def get_thumbnail( + self, + document_id: str, + workspace_id: str, + element_id: str, + width: int = 300, + height: int = 300, + ) -> bytes: + """ + Get a thumbnail image for an element. + + Args: + document_id: Document ID + workspace_id: Workspace ID + element_id: Element ID + width: Thumbnail width in pixels (default: 300) + height: Thumbnail height in pixels (default: 300) + + Returns: + Raw image bytes (PNG format) + + Raises: + ExportError: If thumbnail retrieval fails + OnshapeError: If API request fails + """ + endpoint = f"/thumbnails/d/{document_id}/w/{workspace_id}/e/{element_id}/s/{width}x{height}" + + try: + return self.client.get_binary(endpoint) + except OnshapeError: + raise + except Exception as e: + raise ExportError(f"Failed to get thumbnail: {e}") from e + + def get_shaded_view( + self, + document_id: str, + workspace_id: str, + element_id: str, + width: int = 800, + height: int = 600, + view_matrix: str | list[float] | None = None, + output_format: str = "PNG", + pixel_size: float | None = None, + ) -> bytes: + """ + Get a shaded view rendering of a Part Studio. + + Args: + document_id: Document ID + workspace_id: Workspace ID + element_id: Element (Part Studio) ID + width: Output width in pixels (default: 800) + height: Output height in pixels (default: 600) + view_matrix: Named string (e.g. "front", "isometric"), + 12-float (3x4) matrix, or 16-float (4x4) matrix + output_format: Output format (default: PNG) + pixel_size: Pixel size in meters (0.0 to auto-fit model in frame) + + Returns: + Raw image bytes + + Raises: + ExportError: If shaded view retrieval fails + OnshapeError: If API request fails + """ + endpoint = f"/partstudios/d/{document_id}/w/{workspace_id}/e/{element_id}/shadedviews" + + params: dict[str, Any] = { + "outputHeight": height, + "outputWidth": width, + } + + if view_matrix is not None: + if isinstance(view_matrix, str): + params["viewMatrix"] = view_matrix + elif isinstance(view_matrix, list): + if len(view_matrix) not in (12, 16): + raise ExportError( + f"View matrix must contain 12 (3x4) or 16 (4x4) elements, got {len(view_matrix)}" + ) + params["viewMatrix"] = ",".join(str(v) for v in view_matrix) + else: + raise ExportError(f"view_matrix must be a string or list of floats, got {type(view_matrix)}") + + if pixel_size is not None: + params["pixelSize"] = pixel_size + + if output_format: + params["outputFormat"] = output_format + + try: + return self.client.get_binary(endpoint, params=params) + except OnshapeError: + raise + except Exception as e: + raise ExportError(f"Failed to get shaded view: {e}") from e diff --git a/src/onshape_chat/onshape/features.py b/src/onshape_chat/onshape/features.py index 12bb5dc..c521dd1 100644 --- a/src/onshape_chat/onshape/features.py +++ b/src/onshape_chat/onshape/features.py @@ -1,8 +1,14 @@ -"""Onshape feature operations (extrude, fillet, etc.).""" +"""Onshape feature operations (extrude, fillet, rollback, etc.).""" from typing import Any, Literal -from onshape_chat.onshape.client import OnshapeClient, OnshapeAPIError +from onshape_chat.errors import FeatureError +from onshape_chat.onshape.client import OnshapeAPIError, OnshapeClient + + +def _endpoint(document_id: str, workspace_id: str, part_id: str, suffix: str = "") -> str: + """Build the standard part studio features endpoint.""" + return f"/partstudios/d/{document_id}/w/{workspace_id}/e/{part_id}/features{suffix}" class FeatureManager: @@ -33,7 +39,7 @@ def extrude( Args: document_id: Document ID workspace_id: Workspace ID - part_id: Part studio ID (with or without 'w/' prefix) + part_id: Part studio element ID sketch_id: Sketch feature ID to extrude depth: Extrusion depth in mm direction: Extrusion direction (forward, backward, or symmetric) @@ -45,51 +51,67 @@ def extrude( Raises: OnshapeAPIError: If extrusion fails """ - if not part_id.startswith("w/"): - part_id = f"w/{part_id}" - - # Map direction to FeatureScript values - direction_map = { - "forward": "forward", - "backward": "backward", - "symmetric": "symmetric", - } - - # Map operation to FeatureScript values operation_map = { - "new": "New", - "add": "Add", - "subtract": "Remove", + "new": "NEW", + "add": "ADD", + "subtract": "REMOVE", } - # Build extrude feature + parameters = [ + { + "btType": "BTMParameterQueryList-148", + "queries": [ + { + "btType": "BTMIndividualSketchRegionQuery-140", + "featureId": sketch_id, + "filterInnerLoops": True, + "queryString": f'query = qSketchRegion(id + "{sketch_id}", true);', + "deterministicIds": [], + } + ], + "parameterId": "entities", + }, + { + "btType": "BTMParameterEnum-145", + "enumName": "NewBodyOperationType", + "value": operation_map[operation], + "parameterId": "operationType", + }, + { + "btType": "BTMParameterQuantity-147", + "isInteger": False, + "expression": f"{depth} mm", + "parameterId": "depth", + }, + ] + + if direction == "backward": + parameters.append({ + "btType": "BTMParameterBoolean-144", + "value": True, + "parameterId": "oppositeDirection", + }) + elif direction == "symmetric": + parameters.append({ + "btType": "BTMParameterEnum-145", + "enumName": "BoundingType", + "value": "SYMMETRIC", + "parameterId": "endBound", + }) + feature = { + "btType": "BTFeatureDefinitionCall-1406", "feature": { - "type": "btFeatureScriptID", - "version": "20D129406DC4A47B492F92D240D7364C", - "featureId": "extrude", + "btType": "BTMFeature-134", + "featureType": "extrude", "name": "Extrude", - "parameters": { - "entities": { - "type": "btBRepSketchSelection", - "value": sketch_id, - }, - "direction": { - "type": "btDirectionEntitySelect", - "value": {"directionType": direction_map[direction]}, - }, - "depth": { - "type": "btDistance", - "value": {"value": depth, "unit": "millimeter"}, - }, - "operationType": operation_map[operation], - }, - } + "suppressed": False, + "parameters": parameters, + }, } - endpoint = f"/partstudios/{part_id}/features" - response = self.client.post(endpoint, json_body=feature) - return response + endpoint = _endpoint(document_id, workspace_id, part_id) + return self.client.post(endpoint, json_body=feature) def get_features( self, @@ -103,14 +125,87 @@ def get_features( Args: document_id: Document ID workspace_id: Workspace ID - part_id: Part studio ID + part_id: Part studio element ID Returns: List of features with their IDs and metadata """ - if not part_id.startswith("w/"): - part_id = f"w/{part_id}" - - endpoint = f"/partstudios/{part_id}/features" + endpoint = _endpoint(document_id, workspace_id, part_id) response = self.client.get(endpoint) return response.get("features", []) + + def delete_feature( + self, + document_id: str, + workspace_id: str, + part_id: str, + feature_id: str, + ) -> dict[str, Any]: + """ + Delete (rollback) a feature from a part studio. + + Args: + document_id: Document ID + workspace_id: Workspace ID + part_id: Part studio element ID + feature_id: Feature ID to delete + + Returns: + Deletion result + + Raises: + FeatureError: If deletion fails + """ + endpoint = _endpoint(document_id, workspace_id, part_id, f"/featureid/{feature_id}") + try: + return self.client.delete(endpoint) + except OnshapeAPIError as e: + raise FeatureError(f"Failed to delete feature {feature_id}: {e}") from e + + def rollback_to_feature( + self, + document_id: str, + workspace_id: str, + part_id: str, + feature_id: str, + ) -> dict[str, Any]: + """ + Set the rollback bar to just after a specific feature. + + All features after this point become suppressed. + + Args: + document_id: Document ID + workspace_id: Workspace ID + part_id: Part studio element ID + feature_id: Feature ID to rollback to (-1 for beginning) + + Returns: + Rollback result + """ + endpoint = _endpoint(document_id, workspace_id, part_id, "/rollback") + body = {"featureId": feature_id} + return self.client.post(endpoint, json_body=body) + + def get_feature_history( + self, + document_id: str, + workspace_id: str, + part_id: str, + ) -> list[dict[str, Any]]: + """ + Get ordered feature history with IDs, names, and types. + + Returns: + List of feature dicts: [{"feature_id": str, "name": str, "type": str}, ...] + """ + features = self.get_features(document_id, workspace_id, part_id) + history = [] + for i, feat in enumerate(features): + history.append({ + "index": i, + "feature_id": feat.get("featureId", ""), + "name": feat.get("name", f"Feature {i}"), + "type": feat.get("featureType", "unknown"), + }) + return history diff --git a/src/onshape_chat/onshape/sketches.py b/src/onshape_chat/onshape/sketches.py index dfda7e8..f3432fe 100644 --- a/src/onshape_chat/onshape/sketches.py +++ b/src/onshape_chat/onshape/sketches.py @@ -1,34 +1,84 @@ -"""Onshape sketch operations.""" +"""Onshape sketch operations using BTMSketch-151 format.""" +import math from typing import Any, Literal -from onshape_chat.onshape.client import OnshapeClient, OnshapeAPIError +from onshape_chat.onshape.client import OnshapeClient +# Standard plane deterministic IDs (consistent across all Onshape Part Studios) +PLANE_IDS = { + "XY": "JDC", # Top plane + "XZ": "JCC", # Front plane + "YZ": "JEC", # Right plane +} -# FeatureScript constants for sketch entities -FEATURESCRIPT_VERSION = "20D129406DC4A47B492F92D240D7364C" + +def _mm_to_m(mm: float) -> float: + """Convert millimeters to meters (Onshape internal unit).""" + return mm / 1000.0 + + +def _endpoint(document_id: str, workspace_id: str, part_id: str) -> str: + """Build the part studio features endpoint.""" + return f"/partstudios/d/{document_id}/w/{workspace_id}/e/{part_id}/features" + + +def _plane_query(plane: str) -> dict[str, Any]: + """Build BTMParameterQueryList-148 for a default plane.""" + plane_id = PLANE_IDS.get(plane.upper(), PLANE_IDS["XY"]) + return { + "btType": "BTMParameterQueryList-148", + "queries": [ + { + "btType": "BTMIndividualQuery-138", + "deterministicIds": [plane_id], + } + ], + "parameterId": "sketchPlane", + } + + +def _line_entity( + entity_id: str, + x_start: float, + y_start: float, + x_end: float, + y_end: float, +) -> dict[str, Any]: + """Build a BTMSketchCurveSegment-155 line entity. Coordinates in meters.""" + dx = x_end - x_start + dy = y_end - y_start + length = math.sqrt(dx * dx + dy * dy) + dir_x = dx / length if length > 0 else 1.0 + dir_y = dy / length if length > 0 else 0.0 + return { + "btType": "BTMSketchCurveSegment-155", + "entityId": entity_id, + "startPointId": f"{entity_id}.start", + "endPointId": f"{entity_id}.end", + "startParam": 0.0, + "endParam": length, + "geometry": { + "btType": "BTCurveGeometryLine-117", + "pntX": x_start, + "pntY": y_start, + "dirX": dir_x, + "dirY": dir_y, + }, + "isConstruction": False, + } class SketchManager: """Manager for Onshape sketch operations.""" def __init__(self, client: OnshapeClient): - """ - Initialize sketch manager. - - Args: - client: Authenticated Onshape API client - """ self.client = client + self._entity_counter = 0 - def _get_default_plane(self, plane: str) -> str: - """Get FeatureScript identifier for default plane.""" - planes = { - "XY": "QSpline", - "XZ": "RSpline", - "YZ": "PSpline", - } - return planes.get(plane.upper(), planes["XY"]) + def _next_id(self, prefix: str = "entity") -> str: + self._entity_counter += 1 + return f"{prefix}.{self._entity_counter}" def create_rectangle( self, @@ -41,84 +91,36 @@ def create_rectangle( center_x: float = 0.0, center_y: float = 0.0, ) -> dict[str, Any]: - """ - Create a rectangular sketch. - - Args: - document_id: Document ID - workspace_id: Workspace ID - part_id: Part studio ID (with or without 'w/' prefix) - plane: Plane to sketch on (XY, XZ, or YZ) - width: Rectangle width in mm - height: Rectangle height in mm - center_x: X position of rectangle center - center_y: Y position of rectangle center - - Returns: - Created feature info - - Raises: - OnshapeAPIError: If sketch creation fails - """ - # Normalize IDs - if not part_id.startswith("w/"): - part_id = f"w/{part_id}" - - # Calculate rectangle corner positions - x_min = center_x - width / 2 - x_max = center_x + width / 2 - y_min = center_y - height / 2 - y_max = center_y + height / 2 - - # Build FeatureScript sketch feature - feature = { + """Create a rectangular sketch. Dimensions in mm.""" + ep = _endpoint(document_id, workspace_id, part_id) + + # Convert mm to meters + x_min = _mm_to_m(center_x - width / 2) + x_max = _mm_to_m(center_x + width / 2) + y_min = _mm_to_m(center_y - height / 2) + y_max = _mm_to_m(center_y + height / 2) + + rid = self._next_id("rect") + entities = [ + _line_entity(f"{rid}.bottom", x_min, y_min, x_max, y_min), + _line_entity(f"{rid}.right", x_max, y_min, x_max, y_max), + _line_entity(f"{rid}.top", x_max, y_max, x_min, y_max), + _line_entity(f"{rid}.left", x_min, y_max, x_min, y_min), + ] + + body = { "feature": { - "type": "btFeatureScriptID", - "version": FEATURESCRIPT_VERSION, - "featureId": "sketch_rectangle", + "btType": "BTMSketch-151", + "featureType": "newSketch", "name": "Rectangle", - "parameters": { - "sketchPlane": { - "type": "btPlaneSelection", - "value": {"plane": {"type": "btDefaultPlane", "value": plane.upper()}}, - }, - "entities": [ - { - "type": "btLine", - "xStart": x_min, - "yStart": y_min, - "xEnd": x_max, - "yEnd": y_min, - }, - { - "type": "btLine", - "xStart": x_max, - "yStart": y_min, - "xEnd": x_max, - "yEnd": y_max, - }, - { - "type": "btLine", - "xStart": x_max, - "yStart": y_max, - "xEnd": x_min, - "yEnd": y_max, - }, - { - "type": "btLine", - "xStart": x_min, - "yStart": y_max, - "xEnd": x_min, - "yEnd": y_min, - }, - ], - }, + "suppressed": False, + "parameters": [_plane_query(plane)], + "entities": entities, + "constraints": [], } } - endpoint = f"/partstudios/{part_id}/features" - response = self.client.post(endpoint, json_body=feature) - return response + return self.client.post(ep, json_body=body) def create_circle( self, @@ -130,51 +132,126 @@ def create_circle( center_x: float = 0.0, center_y: float = 0.0, ) -> dict[str, Any]: - """ - Create a circular sketch. - - Args: - document_id: Document ID - workspace_id: Workspace ID - part_id: Part studio ID - plane: Plane to sketch on (XY, XZ, or YZ) - radius: Circle radius in mm - center_x: X position of circle center - center_y: Y position of circle center - - Returns: - Created feature info - - Raises: - OnshapeAPIError: If sketch creation fails - """ - if not part_id.startswith("w/"): - part_id = f"w/{part_id}" - - # Build FeatureScript sketch feature - feature = { + """Create a circular sketch. Dimensions in mm.""" + ep = _endpoint(document_id, workspace_id, part_id) + cid = self._next_id("circle") + + body = { "feature": { - "type": "btFeatureScriptID", - "version": FEATURESCRIPT_VERSION, - "featureId": "sketch_circle", + "btType": "BTMSketch-151", + "featureType": "newSketch", "name": "Circle", - "parameters": { - "sketchPlane": { - "type": "btPlaneSelection", - "value": {"plane": {"type": "btDefaultPlane", "value": plane.upper()}}, - }, - "entities": [ - { - "type": "btCircle", - "centerX": center_x, - "centerY": center_y, - "radius": radius, - } - ], - }, + "suppressed": False, + "parameters": [_plane_query(plane)], + "entities": [ + { + "btType": "BTMSketchCurve-4", + "entityId": cid, + "centerId": f"{cid}.center", + "geometry": { + "btType": "BTCurveGeometryCircle-115", + "radius": _mm_to_m(radius), + "xCenter": _mm_to_m(center_x), + "yCenter": _mm_to_m(center_y), + "xDir": 1.0, + "yDir": 0.0, + "clockwise": False, + }, + "isConstruction": False, + } + ], + "constraints": [], + } + } + + return self.client.post(ep, json_body=body) + + def create_polygon( + self, + document_id: str, + workspace_id: str, + part_id: str, + plane: Literal["XY", "XZ", "YZ"], + num_sides: int, + radius: float, + center_x: float = 0.0, + center_y: float = 0.0, + ) -> dict[str, Any]: + """Create a regular polygon sketch. Dimensions in mm.""" + ep = _endpoint(document_id, workspace_id, part_id) + pid = self._next_id("poly") + + # Generate vertices (in meters) + cx_m = _mm_to_m(center_x) + cy_m = _mm_to_m(center_y) + r_m = _mm_to_m(radius) + vertices = [] + for i in range(num_sides): + angle = 2 * math.pi * i / num_sides + vertices.append((cx_m + r_m * math.cos(angle), cy_m + r_m * math.sin(angle))) + + entities = [] + for i in range(num_sides): + x_s, y_s = vertices[i] + x_e, y_e = vertices[(i + 1) % num_sides] + entities.append(_line_entity(f"{pid}.side{i}", x_s, y_s, x_e, y_e)) + + body = { + "feature": { + "btType": "BTMSketch-151", + "featureType": "newSketch", + "name": f"Polygon ({num_sides} sides)", + "suppressed": False, + "parameters": [_plane_query(plane)], + "entities": entities, + "constraints": [], + } + } + + return self.client.post(ep, json_body=body) + + def create_from_points( + self, + document_id: str, + workspace_id: str, + part_id: str, + plane: Literal["XY", "XZ", "YZ"], + points: list[list[float]], + closed: bool = True, + ) -> dict[str, Any]: + """Create a sketch from coordinate points. Points in mm.""" + if len(points) < 2: + msg = "At least 2 points are required" + raise ValueError(msg) + + ep = _endpoint(document_id, workspace_id, part_id) + fid = self._next_id("profile") + + entities = [] + num_segments = len(points) if closed else len(points) - 1 + for i in range(num_segments): + x_s, y_s = points[i] + x_e, y_e = points[(i + 1) % len(points)] + entities.append( + _line_entity( + f"{fid}.seg{i}", + _mm_to_m(x_s), + _mm_to_m(y_s), + _mm_to_m(x_e), + _mm_to_m(y_e), + ) + ) + + body = { + "feature": { + "btType": "BTMSketch-151", + "featureType": "newSketch", + "name": f"Profile ({len(points)} points)", + "suppressed": False, + "parameters": [_plane_query(plane)], + "entities": entities, + "constraints": [], } } - endpoint = f"/partstudios/{part_id}/features" - response = self.client.post(endpoint, json_body=feature) - return response + return self.client.post(ep, json_body=body) diff --git a/src/onshape_chat/optimization/__init__.py b/src/onshape_chat/optimization/__init__.py new file mode 100644 index 0000000..9665d60 --- /dev/null +++ b/src/onshape_chat/optimization/__init__.py @@ -0,0 +1 @@ +"""Design analysis and optimization.""" diff --git a/src/onshape_chat/optimization/analyzer.py b/src/onshape_chat/optimization/analyzer.py new file mode 100644 index 0000000..68aa39a --- /dev/null +++ b/src/onshape_chat/optimization/analyzer.py @@ -0,0 +1,146 @@ +"""Design analysis for manufacturing optimization.""" + +import json +from typing import Any + +from onshape_chat.llm.client import GLMClient +from onshape_chat.onshape.client import OnshapeClient + +ANALYSIS_PROMPTS = { + "3d_print": """Analyze this CAD design for FDM 3D printing. +Check for: +1. Overhangs >45 degrees (need supports) +2. Wall thickness <1.2mm (too thin) or >10mm (wasteful) +3. Bridging distances >10mm +4. Small features <0.4mm (below nozzle resolution) +5. Sharp internal corners (stress concentrators) + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON: +{{ + "issues": [{{"severity": "high|medium|low", "description": "...", "location": "...", "fix": "..."}}], + "score": 1-10, + "summary": "..." +}}""", + "cnc": """Analyze this CAD design for CNC machining. +Check for: +1. Internal corners with radius <1mm (tool access) +2. Deep pockets (depth > 4x width) +3. Undercuts requiring 4+ axis +4. Thin walls <1mm +5. Features requiring tool changes + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON with issues, score, and summary.""", + "injection_mold": """Analyze this CAD design for injection molding. +Check for: +1. Draft angles <1 degree on vertical walls +2. Non-uniform wall thickness (>20% variation) +3. Undercuts requiring side actions +4. Sharp corners (stress + flow issues) +5. Thick sections (>4mm, sink marks) + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON with issues, score, and summary.""", + "general": """Analyze this CAD design for general manufacturability. +Check for common issues: +1. Very thin walls (<1mm) +2. Sharp internal corners +3. Overly complex geometry +4. Features that may be hard to manufacture + +Part geometry: +{geometry} + +Feature history: +{features} + +Return JSON with issues, score, and summary.""", +} + +AVAILABLE_METHODS = list(ANALYSIS_PROMPTS.keys()) + + +class DesignAnalyzer: + """Analyze designs for manufacturing issues.""" + + def __init__(self, onshape: OnshapeClient, llm: GLMClient): + self.onshape = onshape + self.llm = llm + + def analyze( + self, + doc_id: str, + ws_id: str, + element_id: str, + method: str = "3d_print", + ) -> dict[str, Any]: + """ + Analyze a design for a specific manufacturing method. + + Returns: + {"method": "...", "score": 7, "summary": "...", "issues": [...]} + """ + if method not in ANALYSIS_PROMPTS: + raise ValueError( + f"Unknown method: {method}. Available: {AVAILABLE_METHODS}" + ) + + geometry = self._get_geometry(doc_id, ws_id, element_id) + features = self._get_features(doc_id, ws_id, element_id) + + prompt = ANALYSIS_PROMPTS[method].format( + geometry=json.dumps(geometry, indent=2), + features=json.dumps(features, indent=2), + ) + + response = self.llm.chat( + messages=[ + {"role": "system", "content": "You are a manufacturing engineering expert."}, + {"role": "user", "content": prompt}, + ], + ) + + content = response.choices[0].message.content or "{}" + + try: + result = json.loads(content) + except json.JSONDecodeError: + result = { + "issues": [], + "score": 5, + "summary": content[:200], + } + + result["method"] = method + return result + + def _get_geometry(self, doc_id: str, ws_id: str, element_id: str) -> dict[str, Any]: + """Fetch part body details from Onshape.""" + endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{element_id}/bodydetails" + return self.onshape.get(endpoint) + + def _get_features(self, doc_id: str, ws_id: str, element_id: str) -> dict[str, Any]: + """Fetch feature list from Onshape.""" + endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{element_id}/features" + return self.onshape.get(endpoint) + + @staticmethod + def list_methods() -> list[str]: + """List available manufacturing analysis methods.""" + return AVAILABLE_METHODS diff --git a/src/onshape_chat/optimization/fixer.py b/src/onshape_chat/optimization/fixer.py new file mode 100644 index 0000000..0e368ac --- /dev/null +++ b/src/onshape_chat/optimization/fixer.py @@ -0,0 +1,53 @@ +"""Auto-fixer for design optimization suggestions.""" + +from typing import Any + +from onshape_chat.optimization.suggestions import Suggestion +from onshape_chat.tools.executor import ToolExecutor + + +class AutoFixer: + """Apply auto-fixable design suggestions.""" + + def __init__(self, executor: ToolExecutor): + self.executor = executor + + def apply_suggestions(self, suggestions: list[Suggestion]) -> list[dict[str, Any]]: + """ + Apply all auto-fixable suggestions. + + Returns list of {suggestion, tool, success, result} dicts. + """ + results: list[dict[str, Any]] = [] + + fixable = [s for s in suggestions if s.auto_fixable and s.fix_tool_call] + + for suggestion in fixable: + tool_call = suggestion.fix_tool_call + assert tool_call is not None + + result = self.executor.execute_tool_call( + tool_call["name"], tool_call.get("args", {}) + ) + + success = not result.startswith("Error") + results.append({ + "suggestion": suggestion.description, + "tool": tool_call["name"], + "success": success, + "result": result, + }) + + return results + + def apply_single(self, suggestion: Suggestion) -> dict[str, Any]: + """Apply a single suggestion.""" + if not suggestion.auto_fixable or not suggestion.fix_tool_call: + return { + "suggestion": suggestion.description, + "tool": None, + "success": False, + "result": "Not auto-fixable", + } + + return self.apply_suggestions([suggestion])[0] diff --git a/src/onshape_chat/optimization/suggestions.py b/src/onshape_chat/optimization/suggestions.py new file mode 100644 index 0000000..ef2ae9d --- /dev/null +++ b/src/onshape_chat/optimization/suggestions.py @@ -0,0 +1,98 @@ +"""Suggestion engine for design optimization.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Suggestion: + """A design improvement suggestion.""" + + severity: str # "high", "medium", "low" + description: str + location: str + fix_description: str + auto_fixable: bool = False + fix_tool_call: dict[str, Any] | None = None + + +# Map common issues to tool calls +FIX_MAPPINGS: dict[str, dict[str, Any]] = { + "sharp corner": { + "tool": "create_fillet", + "description": "Add fillet to smooth sharp corner", + }, + "fillet": { + "tool": "create_fillet", + "description": "Add fillet to edges", + }, + "chamfer": { + "tool": "create_chamfer", + "description": "Add chamfer to edges", + }, +} + + +class SuggestionEngine: + """Process and prioritize design suggestions.""" + + SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} + + def process_analysis(self, analysis: dict[str, Any]) -> list[Suggestion]: + """Convert raw LLM analysis into prioritized suggestions.""" + suggestions: list[Suggestion] = [] + + for issue in analysis.get("issues", []): + fix_info = self._find_fix(issue) + suggestions.append( + Suggestion( + severity=issue.get("severity", "medium"), + description=issue.get("description", ""), + location=issue.get("location", "unknown"), + fix_description=issue.get("fix", ""), + auto_fixable=fix_info is not None, + fix_tool_call=fix_info, + ) + ) + + suggestions.sort( + key=lambda s: self.SEVERITY_ORDER.get(s.severity, 3) + ) + return suggestions + + def _find_fix(self, issue: dict[str, Any]) -> dict[str, Any] | None: + """Check if an issue can be auto-fixed with existing tools.""" + desc = issue.get("description", "").lower() + fix_text = issue.get("fix", "").lower() + combined = desc + " " + fix_text + + for keyword, mapping in FIX_MAPPINGS.items(): + if keyword in combined and mapping.get("tool"): + return {"name": mapping["tool"], "args": {}} + return None + + def format_report( + self, suggestions: list[Suggestion], score: int, method: str = "" + ) -> str: + """Format suggestions into a readable report.""" + lines: list[str] = [] + if method: + lines.append(f"Manufacturing Method: {method}") + lines.append(f"Design Score: {score}/10\n") + + if not suggestions: + lines.append("No issues found. Design looks good!") + return "\n".join(lines) + + for i, s in enumerate(suggestions, 1): + icon = {"high": "[!!!]", "medium": "[!!]", "low": "[!]"}.get( + s.severity, "[?]" + ) + fixable = " [auto-fixable]" if s.auto_fixable else "" + lines.append(f"{i}. {icon} {s.description}{fixable}") + lines.append(f" Location: {s.location}") + if s.fix_description: + lines.append(f" Fix: {s.fix_description}") + lines.append("") + + return "\n".join(lines) diff --git a/src/onshape_chat/planning/__init__.py b/src/onshape_chat/planning/__init__.py new file mode 100644 index 0000000..b3e2533 --- /dev/null +++ b/src/onshape_chat/planning/__init__.py @@ -0,0 +1 @@ +"""Planning and orchestration for multi-step CAD builds.""" diff --git a/src/onshape_chat/planning/models.py b/src/onshape_chat/planning/models.py new file mode 100644 index 0000000..619ff30 --- /dev/null +++ b/src/onshape_chat/planning/models.py @@ -0,0 +1,96 @@ +"""Data models for build planning and verification.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class StepStatus(str, Enum): + PENDING = "pending" + IN_PROGRESS = "in_progress" + PASSED = "passed" + FAILED = "failed" + SKIPPED = "skipped" + + +@dataclass +class PlanStep: + """A single step in a build plan.""" + + step_number: int + description: str + tool: str + args: dict[str, Any] + status: StepStatus = StepStatus.PENDING + retry_count: int = 0 + error: str | None = None + verification_suggestion: str | None = None + + +@dataclass +class VerificationResult: + """Result of visual verification for a step.""" + + passed: bool + issues: str = "" + suggestion: str = "" + + +@dataclass +class StepResult: + """Result of executing and verifying a single step.""" + + step: PlanStep + tool_output: str = "" + verification: VerificationResult | None = None + retries_used: int = 0 + + +@dataclass +class BuildPlan: + """A complete plan for building a CAD model.""" + + original_request: str + steps: list[PlanStep] = field(default_factory=list) + current_step_index: int = 0 + + @property + def total_steps(self) -> int: + return len(self.steps) + + @property + def completed_steps(self) -> int: + return sum( + 1 for s in self.steps if s.status in (StepStatus.PASSED, StepStatus.SKIPPED) + ) + + @property + def is_complete(self) -> bool: + return self.current_step_index >= len(self.steps) + + def summary(self) -> str: + lines = [f"Build Plan: {self.original_request}"] + for step in self.steps: + icon = { + StepStatus.PENDING: " ", + StepStatus.IN_PROGRESS: "~", + StepStatus.PASSED: "v", + StepStatus.FAILED: "x", + StepStatus.SKIPPED: "-", + }.get(step.status, "?") + lines.append(f" [{icon}] Step {step.step_number}: {step.description}") + lines.append(f"Progress: {self.completed_steps}/{self.total_steps}") + return "\n".join(lines) + + +@dataclass +class BuildResult: + """Final result of an orchestrated build.""" + + plan: BuildPlan + step_results: list[StepResult] = field(default_factory=list) + final_verification: VerificationResult | None = None + success: bool = False + summary_message: str = "" diff --git a/src/onshape_chat/planning/orchestrator.py b/src/onshape_chat/planning/orchestrator.py new file mode 100644 index 0000000..dbb5699 --- /dev/null +++ b/src/onshape_chat/planning/orchestrator.py @@ -0,0 +1,257 @@ +"""Build orchestrator — plan, execute, verify, retry loop.""" + +from __future__ import annotations + +import logging +from typing import Any + +from onshape_chat.llm.client import GLMClient +from onshape_chat.llm.conversation import ConversationState +from onshape_chat.planning.models import ( + BuildPlan, + BuildResult, + PlanStep, + StepResult, + StepStatus, + VerificationResult, +) +from onshape_chat.planning.planner import Planner +from onshape_chat.tools.executor import ToolExecutor +from onshape_chat.verification.camera_views import capture_all_views +from onshape_chat.verification.verifier import Verifier + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 3 + +# Tools that produce visible 3D geometry changes (worth screenshotting after). +# Sketch tools are excluded: they create 2D profiles on planes but produce no +# visible 3D change, so visual verification would always see "nothing changed" +# and incorrectly trigger retries/undos. +GEOMETRY_TOOLS = { + "extrude", + "run_featurescript", +} + + +class BuildOrchestrator: + """Orchestrates multi-step CAD builds with planning, execution, and verification.""" + + def __init__( + self, + llm: GLMClient, + executor: ToolExecutor, + state: ConversationState, + ): + self.llm = llm + self.executor = executor + self.state = state + self.planner = Planner(llm) + self.verifier = Verifier(llm) + self.on_step_start: Any = None # callback(step: PlanStep) + self.on_step_result: Any = None # callback(result: StepResult) + self.on_plan_created: Any = None # callback(plan: BuildPlan) + + def execute_plan(self, user_request: str) -> BuildResult: + """ + Full pipeline: plan → execute each step → verify → retry on failure. + + Returns a BuildResult with all step results and final verification. + """ + # 1. Create plan + state_summary = self.state.get_summary() + plan = self.planner.create_plan(user_request, state_summary) + + if not plan.steps: + return BuildResult( + plan=plan, + success=False, + summary_message="Failed to create a build plan. Try rephrasing your request.", + ) + + if self.on_plan_created: + self.on_plan_created(plan) + + # 2. Execute each step + step_results: list[StepResult] = [] + for i, step in enumerate(plan.steps): + plan.current_step_index = i + result = self._execute_step(step, plan) + step_results.append(result) + + plan.current_step_index = len(plan.steps) + + # 3. Final verification (screenshot whole model) + final_verification = self._final_verify(plan) + + # 4. Build result + passed_count = sum(1 for r in step_results if r.step.status == StepStatus.PASSED) + total = len(step_results) + success = passed_count == total + + summary_lines = [f"Completed {passed_count}/{total} steps for: {user_request}"] + for r in step_results: + icon = "v" if r.step.status == StepStatus.PASSED else "x" + summary_lines.append(f" [{icon}] Step {r.step.step_number}: {r.step.description}") + if r.step.error: + summary_lines.append(f" Error: {r.step.error}") + + if final_verification and not final_verification.passed: + summary_lines.append(f"\nFinal check issues: {final_verification.issues}") + + return BuildResult( + plan=plan, + step_results=step_results, + final_verification=final_verification, + success=success, + summary_message="\n".join(summary_lines), + ) + + def _execute_step(self, step: PlanStep, plan: BuildPlan) -> StepResult: + """Execute a single step with up to MAX_RETRIES retries on verification failure.""" + step.status = StepStatus.IN_PROGRESS + if self.on_step_start: + self.on_step_start(step) + + for attempt in range(MAX_RETRIES + 1): + step.retry_count = attempt + + # Execute tool + tool_output = self.executor.execute_tool_call(step.tool, step.args) + + if tool_output.startswith("Error"): + step.status = StepStatus.FAILED + step.error = tool_output + result = StepResult(step=step, tool_output=tool_output, retries_used=attempt) + if self.on_step_result: + self.on_step_result(result) + # Don't retry on hard tool errors (missing doc, etc) + if "No document" in tool_output or "No sketch" in tool_output: + return result + # Retry: undo and try again + if attempt < MAX_RETRIES: + self.executor.undo() + step.status = StepStatus.IN_PROGRESS + continue + return result + + # Verify if geometry was created (multi-angle for geometry tools) + verification = None + if step.tool in GEOMETRY_TOOLS: + images = self._get_multi_angle_screenshots() + if images: + verification = self.verifier.verify_step_multi_angle( + images=images, + step=step, + overall_goal=plan.original_request, + ) + else: + # Fallback to single screenshot + image = self._get_screenshot() + if image: + verification = self.verifier.verify_step( + image_bytes=image, + step=step, + overall_goal=plan.original_request, + ) + + if verification and not verification.passed and attempt < MAX_RETRIES: + logger.info( + "Step %d failed verification (attempt %d): %s", + step.step_number, attempt + 1, verification.issues, + ) + # Undo and retry with the suggestion + self.executor.undo() + if verification.suggestion: + step.verification_suggestion = verification.suggestion + step.status = StepStatus.IN_PROGRESS + continue + + # Pass + step.status = StepStatus.PASSED + step.error = None + result = StepResult( + step=step, + tool_output=tool_output, + verification=verification, + retries_used=attempt, + ) + if self.on_step_result: + self.on_step_result(result) + return result + + # Exhausted retries + step.status = StepStatus.FAILED + result = StepResult(step=step, tool_output=tool_output, verification=verification, retries_used=MAX_RETRIES) + if self.on_step_result: + self.on_step_result(result) + return result + + def _get_screenshot(self) -> bytes | None: + """Get a screenshot of the current model state via the Onshape shaded view API.""" + if not self.state.document_id or not self.state.workspace_id: + return None + + element_id = self.state.part_studio_id + if not element_id: + return None + + try: + return self.executor.exports.get_shaded_view( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + ) + except Exception as e: + logger.warning("Failed to get screenshot: %s", e) + return None + + def _get_multi_angle_screenshots(self) -> list[tuple[str, bytes]] | None: + """Get screenshots from all 14 camera angles.""" + if not self.state.document_id or not self.state.workspace_id: + return None + + element_id = self.state.part_studio_id + if not element_id: + return None + + try: + images = capture_all_views( + exports=self.executor.exports, + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + ) + return images if images else None + except Exception as e: + logger.warning("Failed to capture multi-angle screenshots: %s", e) + return None + + def _final_verify(self, plan: BuildPlan) -> VerificationResult | None: + """Run a final verification on the complete model using all 14 angles.""" + final_step = PlanStep( + step_number=plan.total_steps + 1, + description=f"Final model for: {plan.original_request}", + tool="final_check", + args={}, + ) + + # Prefer multi-angle for final verification + images = self._get_multi_angle_screenshots() + if images: + return self.verifier.verify_step_multi_angle( + images=images, + step=final_step, + overall_goal=plan.original_request, + ) + + # Fallback to single screenshot + image = self._get_screenshot() + if not image: + return None + + return self.verifier.verify_step( + image_bytes=image, + step=final_step, + overall_goal=plan.original_request, + ) diff --git a/src/onshape_chat/planning/planner.py b/src/onshape_chat/planning/planner.py new file mode 100644 index 0000000..a4fe913 --- /dev/null +++ b/src/onshape_chat/planning/planner.py @@ -0,0 +1,111 @@ +"""Step decomposition — breaks user requests into ordered tool call plans.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from onshape_chat.llm.client import GLMClient +from onshape_chat.llm.prompts import PLANNING_PROMPT +from onshape_chat.llm.tools import get_tool_definitions +from onshape_chat.planning.models import BuildPlan, PlanStep + +logger = logging.getLogger(__name__) + + +def _tool_descriptions_text(tools: list[dict[str, Any]]) -> str: + """Format tool definitions into a concise text summary for the planner prompt.""" + lines = [] + for t in tools: + fn = t["function"] + params = fn["parameters"].get("properties", {}) + required = fn["parameters"].get("required", []) + param_parts = [] + for name, schema in params.items(): + req = " (required)" if name in required else "" + param_parts.append(f" {name}: {schema.get('type', 'any')}{req} — {schema.get('description', '')}") + lines.append(f"- {fn['name']}: {fn['description']}") + if param_parts: + lines.extend(param_parts) + return "\n".join(lines) + + +# Tools the planner should never suggest (unreliable for geometry creation) +_EXCLUDED_PLANNER_TOOLS = {"run_featurescript"} + + +class Planner: + """Decomposes a user request into a BuildPlan of ordered PlanSteps.""" + + def __init__(self, llm: GLMClient): + self.llm = llm + # Filter out unreliable tools so the planner can't pick them + self.tools = [ + t for t in get_tool_definitions() + if t["function"]["name"] not in _EXCLUDED_PLANNER_TOOLS + ] + self._tool_text = _tool_descriptions_text(self.tools) + + def create_plan(self, user_request: str, state_summary: str = "") -> BuildPlan: + """ + Call the LLM (text-only, no tool_choice) to decompose a request into steps. + + Returns a BuildPlan with ordered PlanSteps. + """ + prompt = PLANNING_PROMPT.format( + tool_descriptions=self._tool_text, + state_summary=state_summary or "No active document yet", + user_request=user_request, + ) + + response = self.llm.chat( + messages=[ + {"role": "system", "content": "You are a CAD build planner. Return only valid JSON."}, + {"role": "user", "content": prompt}, + ], + tools=None, + ) + + raw = response.choices[0].message.content or "[]" + steps = self._parse_steps(raw) + + plan = BuildPlan(original_request=user_request, steps=steps) + logger.info("Created plan with %d steps for: %s", len(steps), user_request) + return plan + + @staticmethod + def _parse_steps(raw_json: str) -> list[PlanStep]: + """Parse LLM JSON output into PlanStep objects.""" + # Strip markdown code fences if present + text = raw_json.strip() + if text.startswith("```"): + lines = text.split("\n") + # Remove first and last fence lines + lines = [line for line in lines if not line.strip().startswith("```")] + text = "\n".join(lines) + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.error("Failed to parse plan JSON: %s", text[:200]) + return [] + + if not isinstance(data, list): + logger.error("Plan JSON is not a list: %s", type(data)) + return [] + + steps = [] + for i, item in enumerate(data): + if not isinstance(item, dict): + continue + steps.append( + PlanStep( + step_number=item.get("step_number", i + 1), + description=item.get("description", f"Step {i + 1}"), + tool=item.get("tool", ""), + args=item.get("args", {}), + ) + ) + + return steps diff --git a/src/onshape_chat/templates/__init__.py b/src/onshape_chat/templates/__init__.py new file mode 100644 index 0000000..989d71b --- /dev/null +++ b/src/onshape_chat/templates/__init__.py @@ -0,0 +1 @@ +"""Parametric templates for common mechanical parts.""" diff --git a/src/onshape_chat/templates/bearings.py b/src/onshape_chat/templates/bearings.py new file mode 100644 index 0000000..7677363 --- /dev/null +++ b/src/onshape_chat/templates/bearings.py @@ -0,0 +1,99 @@ +"""Bearing parametric templates.""" + +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_ball_bearing(**params: Any) -> dict[str, Any]: + """Build a standard ball bearing.""" + bore_diameter = params["bore_diameter"] + outer_diameter = params["outer_diameter"] + width = params["width"] + + # Derived dimensions + mean_diameter = (bore_diameter + outer_diameter) / 2 + radial_thickness = (outer_diameter - bore_diameter) / 2 + + return { + "template": "ball_bearing", + "bore_diameter": bore_diameter, + "outer_diameter": outer_diameter, + "width": width, + "mean_diameter": mean_diameter, + "radial_thickness": radial_thickness, + "operations": [ + f"Create a circle with radius {outer_diameter / 2}mm on XY plane", + f"Extrude {width}mm", + f"Create a circle with radius {bore_diameter / 2}mm at center, subtract through", + ], + } + + +def build_bearing_housing(**params: Any) -> dict[str, Any]: + """Build a pillow block bearing housing.""" + bore_diameter = params["bore_diameter"] + base_width = params["base_width"] + base_length = params["base_length"] + base_height = params["base_height"] + bolt_diameter = params["bolt_diameter"] + bolt_spacing = params["bolt_spacing"] + + # Housing outer diameter is typically 2x bore + housing_od = bore_diameter * 2.5 + total_height = base_height + housing_od / 2 + + return { + "template": "bearing_housing", + "bore_diameter": bore_diameter, + "base_width": base_width, + "base_length": base_length, + "base_height": base_height, + "bolt_diameter": bolt_diameter, + "bolt_spacing": bolt_spacing, + "housing_od": housing_od, + "total_height": total_height, + "operations": [ + f"Create base: rectangle {base_length}mm x {base_width}mm, extrude {base_height}mm", + f"Add cylindrical housing: OD {housing_od}mm centered on base top", + f"Create bore hole: diameter {bore_diameter}mm through housing center", + f"Add 2 bolt holes: diameter {bolt_diameter}mm, spacing {bolt_spacing}mm", + ], + } + + +BALL_BEARING = Template( + name="ball_bearing", + display_name="Ball Bearing", + description="A standard radial ball bearing", + params=[ + TemplateParam("bore_diameter", "float", "Inner bore diameter in mm", + default=10.0, min_value=1.0, max_value=200.0), + TemplateParam("outer_diameter", "float", "Outer diameter in mm", + default=30.0, min_value=3.0, max_value=400.0), + TemplateParam("width", "float", "Bearing width in mm", + default=9.0, min_value=1.0, max_value=100.0), + ], + builder=build_ball_bearing, +) + +BEARING_HOUSING = Template( + name="bearing_housing", + display_name="Bearing Housing", + description="A pillow block bearing mount", + params=[ + TemplateParam("bore_diameter", "float", "Bearing bore diameter in mm", + default=25.0, min_value=5.0, max_value=150.0), + TemplateParam("base_width", "float", "Base width in mm", + default=50.0, min_value=20.0), + TemplateParam("base_length", "float", "Base length in mm", + default=80.0, min_value=30.0), + TemplateParam("base_height", "float", "Base height in mm", + default=15.0, min_value=5.0, max_value=50.0), + TemplateParam("bolt_diameter", "float", "Bolt hole diameter in mm", + default=10.0, min_value=3.0, max_value=30.0), + TemplateParam("bolt_spacing", "float", "Distance between bolt holes in mm", + default=60.0, min_value=15.0), + ], + builder=build_bearing_housing, +) diff --git a/src/onshape_chat/templates/enclosures.py b/src/onshape_chat/templates/enclosures.py new file mode 100644 index 0000000..654bbbc --- /dev/null +++ b/src/onshape_chat/templates/enclosures.py @@ -0,0 +1,143 @@ +"""Enclosure parametric templates.""" + +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_box_enclosure(**params: Any) -> dict[str, Any]: + """Build a rectangular box enclosure.""" + length = params["length"] + width = params["width"] + height = params["height"] + wall_thickness = params["wall_thickness"] + has_lid = params.get("has_lid", False) + lid_thickness = params.get("lid_thickness", wall_thickness) + + inner_length = length - 2 * wall_thickness + inner_width = width - 2 * wall_thickness + inner_height = height - wall_thickness # open top + + operations = [ + f"Create a rectangle {length}mm x {width}mm on XY plane", + f"Extrude {height}mm", + f"Shell with wall thickness {wall_thickness}mm, removing top face", + ] + + result: dict[str, Any] = { + "template": "box_enclosure", + "length": length, + "width": width, + "height": height, + "wall_thickness": wall_thickness, + "inner_length": inner_length, + "inner_width": inner_width, + "inner_height": inner_height, + "has_lid": has_lid, + "operations": operations, + } + + if has_lid: + result["lid_thickness"] = lid_thickness + result["operations"].append( + f"Create lid: rectangle {length}mm x {width}mm, extrude {lid_thickness}mm" + ) + + return result + + +def build_electronics_case(**params: Any) -> dict[str, Any]: + """Build an electronics case with PCB standoffs.""" + length = params["length"] + width = params["width"] + height = params["height"] + wall_thickness = params["wall_thickness"] + pcb_length = params["pcb_length"] + pcb_width = params["pcb_width"] + standoff_height = params["standoff_height"] + standoff_diameter = params["standoff_diameter"] + vent_slots = params.get("vent_slots", False) + + inner_length = length - 2 * wall_thickness + inner_width = width - 2 * wall_thickness + + # PCB offset from inner walls + pcb_offset_x = (inner_length - pcb_length) / 2 + pcb_offset_y = (inner_width - pcb_width) / 2 + + operations = [ + f"Create a rectangle {length}mm x {width}mm on XY plane", + f"Extrude {height}mm", + f"Shell with wall thickness {wall_thickness}mm, removing top face", + f"Add 4 standoffs: diameter {standoff_diameter}mm, height {standoff_height}mm at PCB corners", + ] + + if vent_slots: + operations.append("Add ventilation slots on side walls") + + return { + "template": "electronics_case", + "length": length, + "width": width, + "height": height, + "wall_thickness": wall_thickness, + "pcb_length": pcb_length, + "pcb_width": pcb_width, + "standoff_height": standoff_height, + "standoff_diameter": standoff_diameter, + "vent_slots": vent_slots, + "inner_length": inner_length, + "inner_width": inner_width, + "pcb_offset_x": pcb_offset_x, + "pcb_offset_y": pcb_offset_y, + "operations": operations, + } + + +BOX_ENCLOSURE = Template( + name="box_enclosure", + display_name="Box Enclosure", + description="A rectangular box enclosure with optional lid", + params=[ + TemplateParam("length", "float", "Enclosure length in mm", + default=100.0, min_value=10.0, max_value=500.0), + TemplateParam("width", "float", "Enclosure width in mm", + default=60.0, min_value=10.0, max_value=500.0), + TemplateParam("height", "float", "Enclosure height in mm", + default=40.0, min_value=5.0, max_value=300.0), + TemplateParam("wall_thickness", "float", "Wall thickness in mm", + default=2.0, min_value=0.5, max_value=10.0), + TemplateParam("has_lid", "bool", "Whether to include a lid", + default=False, required=False), + TemplateParam("lid_thickness", "float", "Lid thickness in mm", + required=False), + ], + builder=build_box_enclosure, +) + +ELECTRONICS_CASE = Template( + name="electronics_case", + display_name="Electronics Case", + description="An enclosure with PCB standoffs and optional ventilation", + params=[ + TemplateParam("length", "float", "Case length in mm", + default=120.0, min_value=20.0, max_value=500.0), + TemplateParam("width", "float", "Case width in mm", + default=80.0, min_value=20.0, max_value=500.0), + TemplateParam("height", "float", "Case height in mm", + default=35.0, min_value=10.0, max_value=200.0), + TemplateParam("wall_thickness", "float", "Wall thickness in mm", + default=2.0, min_value=0.5, max_value=10.0), + TemplateParam("pcb_length", "float", "PCB length in mm", + default=100.0, min_value=10.0), + TemplateParam("pcb_width", "float", "PCB width in mm", + default=60.0, min_value=10.0), + TemplateParam("standoff_height", "float", "Standoff height in mm", + default=5.0, min_value=1.0, max_value=30.0), + TemplateParam("standoff_diameter", "float", "Standoff diameter in mm", + default=6.0, min_value=2.0, max_value=15.0), + TemplateParam("vent_slots", "bool", "Whether to add ventilation slots", + default=False, required=False), + ], + builder=build_electronics_case, +) diff --git a/src/onshape_chat/templates/fasteners.py b/src/onshape_chat/templates/fasteners.py new file mode 100644 index 0000000..4e1428f --- /dev/null +++ b/src/onshape_chat/templates/fasteners.py @@ -0,0 +1,120 @@ +"""Fastener parametric templates.""" + +import math +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + +# ISO metric bolt head sizes (across flats) +ISO_HEAD_SIZES = { + 3: 5.5, 4: 7.0, 5: 8.0, 6: 10.0, 8: 13.0, + 10: 16.0, 12: 18.0, 16: 24.0, 20: 30.0, 24: 36.0, +} + + +def build_hex_bolt(**params: Any) -> dict[str, Any]: + """Build a hex bolt.""" + diameter = params["diameter"] + length = params["length"] + head_height = params.get("head_height", diameter * 0.7) + + # Look up standard head size or calculate + head_af = ISO_HEAD_SIZES.get(int(diameter), diameter * 1.7) + head_ac = head_af / math.cos(math.radians(30)) # Across corners + + return { + "template": "hex_bolt", + "diameter": diameter, + "length": length, + "head_height": head_height, + "head_across_flats": head_af, + "head_across_corners": head_ac, + "operations": [ + f"Create a hexagon with across-flats {head_af}mm on XY plane", + f"Extrude {head_height}mm for the head", + f"Create a circle with radius {diameter / 2}mm on top of head", + f"Extrude {length}mm for the shaft", + ], + } + + +def build_hex_nut(**params: Any) -> dict[str, Any]: + """Build a hex nut.""" + diameter = params["diameter"] + height = params.get("height", diameter * 0.8) + + head_af = ISO_HEAD_SIZES.get(int(diameter), diameter * 1.7) + + return { + "template": "hex_nut", + "diameter": diameter, + "height": height, + "across_flats": head_af, + "bore_diameter": diameter, + "operations": [ + f"Create a hexagon with across-flats {head_af}mm on XY plane", + f"Extrude {height}mm", + f"Create a circle with radius {diameter / 2}mm at center, subtract through", + ], + } + + +def build_washer(**params: Any) -> dict[str, Any]: + """Build a flat washer.""" + inner_diameter = params["inner_diameter"] + outer_diameter = params["outer_diameter"] + thickness = params["thickness"] + + return { + "template": "washer", + "inner_diameter": inner_diameter, + "outer_diameter": outer_diameter, + "thickness": thickness, + "operations": [ + f"Create a circle with radius {outer_diameter / 2}mm on XY plane", + f"Extrude {thickness}mm", + f"Create a circle with radius {inner_diameter / 2}mm at center, subtract through", + ], + } + + +HEX_BOLT = Template( + name="hex_bolt", + display_name="Hex Bolt", + description="ISO metric hex bolt", + params=[ + TemplateParam("diameter", "float", "Bolt diameter (M size) in mm", + default=6.0, min_value=2.0, max_value=36.0), + TemplateParam("length", "float", "Bolt shaft length in mm", + default=20.0, min_value=4.0, max_value=300.0), + TemplateParam("head_height", "float", "Head height in mm", required=False), + ], + builder=build_hex_bolt, +) + +HEX_NUT = Template( + name="hex_nut", + display_name="Hex Nut", + description="ISO metric hex nut", + params=[ + TemplateParam("diameter", "float", "Nut diameter (M size) in mm", + default=6.0, min_value=2.0, max_value=36.0), + TemplateParam("height", "float", "Nut height in mm", required=False), + ], + builder=build_hex_nut, +) + +WASHER = Template( + name="washer", + display_name="Flat Washer", + description="A flat washer (annular ring)", + params=[ + TemplateParam("inner_diameter", "float", "Inner diameter in mm", + default=6.5, min_value=1.0), + TemplateParam("outer_diameter", "float", "Outer diameter in mm", + default=12.0, min_value=2.0), + TemplateParam("thickness", "float", "Washer thickness in mm", + default=1.6, min_value=0.3, max_value=10.0), + ], + builder=build_washer, +) diff --git a/src/onshape_chat/templates/fittings.py b/src/onshape_chat/templates/fittings.py new file mode 100644 index 0000000..ce5d5ba --- /dev/null +++ b/src/onshape_chat/templates/fittings.py @@ -0,0 +1,104 @@ +"""Pipe fitting parametric templates.""" + +import math +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_pipe_flange(**params: Any) -> dict[str, Any]: + """Build a circular pipe flange.""" + nominal_pipe_size = params["nominal_pipe_size"] + flange_diameter = params["flange_diameter"] + flange_thickness = params["flange_thickness"] + bolt_circle_diameter = params["bolt_circle_diameter"] + num_bolts = params["num_bolts"] + bolt_hole_diameter = params["bolt_hole_diameter"] + + # Derived dimensions + bolt_angle_spacing = 360.0 / num_bolts + pipe_inner_radius = nominal_pipe_size / 2 + + operations = [ + f"Create a circle with radius {flange_diameter / 2}mm on XY plane", + f"Extrude {flange_thickness}mm", + f"Create center bore: diameter {nominal_pipe_size}mm, subtract through", + f"Add {num_bolts} bolt holes: diameter {bolt_hole_diameter}mm on bolt circle diameter {bolt_circle_diameter}mm, spaced {bolt_angle_spacing:.1f} degrees apart", + ] + + return { + "template": "pipe_flange", + "nominal_pipe_size": nominal_pipe_size, + "flange_diameter": flange_diameter, + "flange_thickness": flange_thickness, + "bolt_circle_diameter": bolt_circle_diameter, + "num_bolts": num_bolts, + "bolt_hole_diameter": bolt_hole_diameter, + "bolt_angle_spacing": bolt_angle_spacing, + "pipe_inner_radius": pipe_inner_radius, + "operations": operations, + } + + +def build_pipe_elbow(**params: Any) -> dict[str, Any]: + """Build a 90-degree pipe elbow.""" + nominal_pipe_size = params["nominal_pipe_size"] + wall_thickness = params["wall_thickness"] + bend_radius = params["bend_radius"] + + # Derived dimensions + outer_diameter = nominal_pipe_size + 2 * wall_thickness + inner_diameter = nominal_pipe_size + arc_length = math.pi / 2 * bend_radius # 90 degrees in radians * radius + + return { + "template": "pipe_elbow", + "nominal_pipe_size": nominal_pipe_size, + "wall_thickness": wall_thickness, + "bend_radius": bend_radius, + "outer_diameter": outer_diameter, + "inner_diameter": inner_diameter, + "arc_length": arc_length, + "operations": [ + f"Create 90-degree arc path with bend radius {bend_radius}mm", + f"Create annular profile: OD {outer_diameter}mm, ID {inner_diameter}mm", + "Sweep annular profile along arc path", + ], + } + + +PIPE_FLANGE = Template( + name="pipe_flange", + display_name="Pipe Flange", + description="A circular pipe flange with bolt holes", + params=[ + TemplateParam("nominal_pipe_size", "float", "Nominal pipe inner diameter in mm", + default=50.0, min_value=10.0, max_value=600.0), + TemplateParam("flange_diameter", "float", "Flange outer diameter in mm", + default=125.0, min_value=30.0, max_value=800.0), + TemplateParam("flange_thickness", "float", "Flange thickness in mm", + default=16.0, min_value=5.0, max_value=60.0), + TemplateParam("bolt_circle_diameter", "float", "Bolt circle diameter in mm", + default=100.0, min_value=20.0, max_value=700.0), + TemplateParam("num_bolts", "int", "Number of bolt holes", + default=4, min_value=3, max_value=24), + TemplateParam("bolt_hole_diameter", "float", "Bolt hole diameter in mm", + default=14.0, min_value=5.0, max_value=40.0), + ], + builder=build_pipe_flange, +) + +PIPE_ELBOW = Template( + name="pipe_elbow", + display_name="Pipe Elbow", + description="A 90-degree pipe elbow", + params=[ + TemplateParam("nominal_pipe_size", "float", "Nominal pipe inner diameter in mm", + default=50.0, min_value=10.0, max_value=600.0), + TemplateParam("wall_thickness", "float", "Pipe wall thickness in mm", + default=3.0, min_value=1.0, max_value=30.0), + TemplateParam("bend_radius", "float", "Center-line bend radius in mm", + default=75.0, min_value=15.0, max_value=500.0), + ], + builder=build_pipe_elbow, +) diff --git a/src/onshape_chat/templates/gears.py b/src/onshape_chat/templates/gears.py new file mode 100644 index 0000000..69ea8fc --- /dev/null +++ b/src/onshape_chat/templates/gears.py @@ -0,0 +1,120 @@ +"""Gear parametric templates.""" + +import math +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_spur_gear(**params: Any) -> dict[str, Any]: + """ + Build a spur gear. + + Uses sketch_mgr and feature_mgr from context if provided, + otherwise returns computed geometry for testing. + """ + module = params["module"] + num_teeth = params["num_teeth"] + thickness = params["thickness"] + bore_diameter = params.get("bore_diameter") + + pitch_diameter = module * num_teeth + outer_diameter = pitch_diameter + 2 * module + root_diameter = pitch_diameter - 2.5 * module + tooth_angle = 360.0 / num_teeth + + result: dict[str, Any] = { + "template": "spur_gear", + "module": module, + "num_teeth": num_teeth, + "thickness": thickness, + "pitch_diameter": pitch_diameter, + "outer_diameter": outer_diameter, + "root_diameter": root_diameter, + "tooth_angle": tooth_angle, + "operations": [ + f"Create a circle with radius {outer_diameter / 2}mm on the XY plane", + f"Extrude {thickness}mm", + ], + } + + if bore_diameter: + result["bore_diameter"] = bore_diameter + result["operations"].append( + f"Create a circle with radius {bore_diameter / 2}mm at center, subtract" + ) + + # If sketch/feature managers are provided, execute operations + sketch_mgr = params.get("sketch_mgr") + feature_mgr = params.get("feature_mgr") + if sketch_mgr and feature_mgr: + doc_id = params["doc_id"] + ws_id = params["ws_id"] + part_id = params["part_id"] + + sketch = sketch_mgr.create_circle( + doc_id, ws_id, part_id, "XY", outer_diameter / 2, + ) + feature_mgr.extrude( + doc_id, ws_id, part_id, sketch.get("featureId", ""), thickness, + ) + + return result + + +def build_rack_gear(**params: Any) -> dict[str, Any]: + """Build a rack gear.""" + module = params["module"] + num_teeth = params["num_teeth"] + width = params["width"] + height = params["height"] + + tooth_pitch = math.pi * module + total_length = tooth_pitch * num_teeth + tooth_height = 2.25 * module + + return { + "template": "rack_gear", + "module": module, + "num_teeth": num_teeth, + "width": width, + "height": height, + "tooth_pitch": tooth_pitch, + "total_length": total_length, + "tooth_height": tooth_height, + "operations": [ + f"Create a rectangle {total_length}mm x {height}mm on the XY plane", + f"Extrude {width}mm", + ], + } + + +SPUR_GEAR = Template( + name="spur_gear", + display_name="Spur Gear", + description="A standard spur gear with involute tooth profile", + params=[ + TemplateParam("module", "float", "Gear module (mm per tooth)", + default=2.0, min_value=0.5, max_value=10.0), + TemplateParam("num_teeth", "int", "Number of teeth", + default=20, min_value=8, max_value=200), + TemplateParam("thickness", "float", "Gear thickness in mm", + default=10.0, min_value=1.0), + TemplateParam("bore_diameter", "float", "Center bore diameter in mm", + required=False), + ], + builder=build_spur_gear, +) + +RACK_GEAR = Template( + name="rack_gear", + display_name="Rack Gear", + description="A linear rack gear", + params=[ + TemplateParam("module", "float", "Gear module", default=2.0, min_value=0.5, max_value=10.0), + TemplateParam("num_teeth", "int", "Number of teeth", default=10, min_value=3, max_value=100), + TemplateParam("width", "float", "Rack width in mm", default=20.0, min_value=1.0), + TemplateParam("height", "float", "Rack body height in mm", default=15.0, min_value=1.0), + ], + builder=build_rack_gear, +) diff --git a/src/onshape_chat/templates/registry.py b/src/onshape_chat/templates/registry.py new file mode 100644 index 0000000..9bcb8e7 --- /dev/null +++ b/src/onshape_chat/templates/registry.py @@ -0,0 +1,120 @@ +"""Template registry for parametric part templates.""" + +from dataclasses import dataclass, field +from typing import Any, Callable + + +@dataclass +class TemplateParam: + """Definition for a template parameter.""" + + name: str + type: str # "float", "int", "str", "bool" + description: str + default: Any = None + required: bool = True + min_value: float | None = None + max_value: float | None = None + + +@dataclass +class Template: + """A parametric part template.""" + + name: str + display_name: str + description: str + params: list[TemplateParam] = field(default_factory=list) + builder: Callable | None = None + + def get_param_info(self) -> list[dict[str, Any]]: + """Get parameter info for LLM tool descriptions.""" + return [ + { + "name": p.name, + "type": p.type, + "description": p.description, + "required": p.required, + "default": p.default, + } + for p in self.params + ] + + +class TemplateRegistry: + """Registry of available parametric templates.""" + + def __init__(self) -> None: + self._templates: dict[str, Template] = {} + + def register(self, template: Template) -> None: + """Register a template.""" + self._templates[template.name] = template + + def get(self, name: str) -> Template: + """Get a template by name.""" + if name not in self._templates: + available = list(self._templates.keys()) + raise ValueError(f"Unknown template: {name}. Available: {available}") + return self._templates[name] + + def list_templates(self) -> list[dict[str, Any]]: + """List all registered templates with basic info.""" + return [ + { + "name": t.name, + "display_name": t.display_name, + "description": t.description, + "params": [p.name for p in t.params], + } + for t in self._templates.values() + ] + + def validate_params(self, template_name: str, params: dict[str, Any]) -> dict[str, Any]: + """Validate and coerce parameters for a template.""" + template = self.get(template_name) + validated: dict[str, Any] = {} + + for param_def in template.params: + if param_def.name in params: + value = params[param_def.name] + + # Type coercion + if param_def.type == "float": + value = float(value) + elif param_def.type == "int": + value = int(value) + elif param_def.type == "bool": + value = bool(value) + elif param_def.type == "str": + value = str(value) + + # Range check + if param_def.min_value is not None and value < param_def.min_value: + raise ValueError( + f"{param_def.name} must be >= {param_def.min_value}, got {value}" + ) + if param_def.max_value is not None and value > param_def.max_value: + raise ValueError( + f"{param_def.name} must be <= {param_def.max_value}, got {value}" + ) + + validated[param_def.name] = value + elif param_def.required: + raise ValueError(f"Missing required parameter: {param_def.name}") + else: + validated[param_def.name] = param_def.default + + return validated + + def execute_template( + self, template_name: str, params: dict[str, Any], **context: Any + ) -> dict[str, Any]: + """Validate params and execute a template builder.""" + template = self.get(template_name) + validated = self.validate_params(template_name, params) + + if template.builder is None: + raise ValueError(f"Template '{template_name}' has no builder function") + + return template.builder(**validated, **context) diff --git a/src/onshape_chat/templates/springs.py b/src/onshape_chat/templates/springs.py new file mode 100644 index 0000000..bf61133 --- /dev/null +++ b/src/onshape_chat/templates/springs.py @@ -0,0 +1,120 @@ +"""Spring parametric templates.""" + +import math +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_compression_spring(**params: Any) -> dict[str, Any]: + """Build a helical compression spring.""" + wire_diameter = params["wire_diameter"] + coil_diameter = params["coil_diameter"] + num_coils = params["num_coils"] + free_length = params["free_length"] + ground_ends = params.get("ground_ends", False) + + # Derived dimensions + coil_length = math.pi * coil_diameter * num_coils + spring_index = coil_diameter / wire_diameter + pitch = free_length / num_coils + inner_diameter = coil_diameter - wire_diameter + outer_diameter = coil_diameter + wire_diameter + + operations = [ + f"Create helix: coil diameter {coil_diameter}mm, pitch {pitch:.2f}mm, {num_coils} coils", + f"Sweep circle (diameter {wire_diameter}mm) along helix path", + ] + + if ground_ends: + operations.append("Flatten top and bottom coil ends") + + return { + "template": "compression_spring", + "wire_diameter": wire_diameter, + "coil_diameter": coil_diameter, + "num_coils": num_coils, + "free_length": free_length, + "ground_ends": ground_ends, + "coil_length": coil_length, + "spring_index": spring_index, + "pitch": pitch, + "inner_diameter": inner_diameter, + "outer_diameter": outer_diameter, + "operations": operations, + } + + +def build_torsion_spring(**params: Any) -> dict[str, Any]: + """Build a torsion spring.""" + wire_diameter = params["wire_diameter"] + coil_diameter = params["coil_diameter"] + num_coils = params["num_coils"] + leg_length = params["leg_length"] + leg_angle = params["leg_angle"] + + # Derived dimensions + coil_length = math.pi * coil_diameter * num_coils + body_length = wire_diameter * (num_coils + 1) + inner_diameter = coil_diameter - wire_diameter + outer_diameter = coil_diameter + wire_diameter + leg_angle_rad = math.radians(leg_angle) + + return { + "template": "torsion_spring", + "wire_diameter": wire_diameter, + "coil_diameter": coil_diameter, + "num_coils": num_coils, + "leg_length": leg_length, + "leg_angle": leg_angle, + "coil_length": coil_length, + "body_length": body_length, + "inner_diameter": inner_diameter, + "outer_diameter": outer_diameter, + "leg_angle_rad": leg_angle_rad, + "operations": [ + f"Create helix: coil diameter {coil_diameter}mm, {num_coils} coils, tight pitch", + f"Sweep circle (diameter {wire_diameter}mm) along helix path", + f"Add leg 1: length {leg_length}mm extending tangentially", + f"Add leg 2: length {leg_length}mm at {leg_angle} degrees from leg 1", + ], + } + + +COMPRESSION_SPRING = Template( + name="compression_spring", + display_name="Compression Spring", + description="A helical compression spring", + params=[ + TemplateParam("wire_diameter", "float", "Wire diameter in mm", + default=1.5, min_value=0.1, max_value=20.0), + TemplateParam("coil_diameter", "float", "Mean coil diameter in mm", + default=12.0, min_value=1.0, max_value=200.0), + TemplateParam("num_coils", "int", "Number of active coils", + default=8, min_value=2, max_value=50), + TemplateParam("free_length", "float", "Free (uncompressed) length in mm", + default=40.0, min_value=5.0, max_value=500.0), + TemplateParam("ground_ends", "bool", "Whether ends are ground flat", + default=False, required=False), + ], + builder=build_compression_spring, +) + +TORSION_SPRING = Template( + name="torsion_spring", + display_name="Torsion Spring", + description="A torsion spring with two legs", + params=[ + TemplateParam("wire_diameter", "float", "Wire diameter in mm", + default=1.0, min_value=0.1, max_value=15.0), + TemplateParam("coil_diameter", "float", "Mean coil diameter in mm", + default=10.0, min_value=1.0, max_value=150.0), + TemplateParam("num_coils", "int", "Number of coils", + default=5, min_value=1, max_value=30), + TemplateParam("leg_length", "float", "Length of each leg in mm", + default=15.0, min_value=2.0, max_value=100.0), + TemplateParam("leg_angle", "float", "Angle between legs in degrees", + default=90.0, min_value=10.0, max_value=360.0), + ], + builder=build_torsion_spring, +) diff --git a/src/onshape_chat/templates/structural.py b/src/onshape_chat/templates/structural.py new file mode 100644 index 0000000..c121b92 --- /dev/null +++ b/src/onshape_chat/templates/structural.py @@ -0,0 +1,107 @@ +"""Structural part templates (brackets, plates).""" + +from typing import Any + +from onshape_chat.templates.registry import Template, TemplateParam + + +def build_l_bracket(**params: Any) -> dict[str, Any]: + """Build an L-bracket.""" + width = params["width"] + height = params["height"] + depth = params["depth"] + thickness = params["thickness"] + hole_diameter = params.get("hole_diameter") + + operations = [ + f"Create an L-shape sketch: {width}mm wide, {height}mm tall, {thickness}mm thick on XY", + f"Extrude {depth}mm", + ] + + if hole_diameter: + operations.extend([ + f"Add mounting hole (diameter {hole_diameter}mm) centered on horizontal leg", + f"Add mounting hole (diameter {hole_diameter}mm) centered on vertical leg", + ]) + + return { + "template": "l_bracket", + "width": width, + "height": height, + "depth": depth, + "thickness": thickness, + "hole_diameter": hole_diameter, + "operations": operations, + } + + +def build_mounting_plate(**params: Any) -> dict[str, Any]: + """Build a mounting plate with hole pattern.""" + width = params["width"] + height = params["height"] + thickness = params["thickness"] + hole_diameter = params["hole_diameter"] + hole_pattern = params.get("hole_pattern", "grid") + + operations = [ + f"Create a rectangle {width}mm x {height}mm on XY plane", + f"Extrude {thickness}mm", + ] + + if hole_pattern == "grid": + operations.append( + f"Add 4 holes (diameter {hole_diameter}mm) in grid pattern near corners" + ) + elif hole_pattern == "circle": + operations.append( + f"Add holes (diameter {hole_diameter}mm) in circular pattern" + ) + + return { + "template": "mounting_plate", + "width": width, + "height": height, + "thickness": thickness, + "hole_diameter": hole_diameter, + "hole_pattern": hole_pattern, + "operations": operations, + } + + +L_BRACKET = Template( + name="l_bracket", + display_name="L-Bracket", + description="A 90-degree L-shaped bracket with optional mounting holes", + params=[ + TemplateParam("width", "float", "Horizontal leg width in mm", + default=50.0, min_value=10.0), + TemplateParam("height", "float", "Vertical leg height in mm", + default=50.0, min_value=10.0), + TemplateParam("depth", "float", "Bracket depth in mm", + default=30.0, min_value=5.0), + TemplateParam("thickness", "float", "Material thickness in mm", + default=3.0, min_value=1.0, max_value=20.0), + TemplateParam("hole_diameter", "float", "Mounting hole diameter in mm", + required=False), + ], + builder=build_l_bracket, +) + +MOUNTING_PLATE = Template( + name="mounting_plate", + display_name="Mounting Plate", + description="A flat plate with mounting hole pattern", + params=[ + TemplateParam("width", "float", "Plate width in mm", + default=80.0, min_value=10.0), + TemplateParam("height", "float", "Plate height in mm", + default=60.0, min_value=10.0), + TemplateParam("thickness", "float", "Plate thickness in mm", + default=3.0, min_value=1.0, max_value=20.0), + TemplateParam("hole_diameter", "float", "Hole diameter in mm", + default=5.0, min_value=1.0), + TemplateParam("hole_pattern", "str", "Hole pattern: 'grid' or 'circle'", + default="grid", required=False), + ], + builder=build_mounting_plate, +) diff --git a/src/onshape_chat/tools/__init__.py b/src/onshape_chat/tools/__init__.py index 1b133be..0a82a3e 100644 --- a/src/onshape_chat/tools/__init__.py +++ b/src/onshape_chat/tools/__init__.py @@ -1,5 +1,3 @@ """Tool implementations for LLM function calling.""" -from onshape_chat.tools.executor import ToolExecutor - __all__ = ["ToolExecutor"] diff --git a/src/onshape_chat/tools/executor.py b/src/onshape_chat/tools/executor.py index 70d5320..e82dbf8 100644 --- a/src/onshape_chat/tools/executor.py +++ b/src/onshape_chat/tools/executor.py @@ -1,26 +1,26 @@ """Execute LLM tool calls by calling Onshape API.""" -import json from typing import Any from onshape_chat.config import get_settings +from onshape_chat.errors import ErrorHandler, OnshapeError +from onshape_chat.featurescript.executor import FeatureScriptExecutor +from onshape_chat.featurescript.generator import FeatureScriptGenerator +from onshape_chat.llm.client import GLMClient from onshape_chat.llm.conversation import ConversationState +from onshape_chat.onshape.assemblies import AssemblyManager from onshape_chat.onshape.client import OnshapeClient from onshape_chat.onshape.documents import DocumentManager +from onshape_chat.onshape.export import ExportManager from onshape_chat.onshape.features import FeatureManager from onshape_chat.onshape.sketches import SketchManager +from onshape_chat.ui.display import TerminalImageDisplay, save_temp_image class ToolExecutor: """Execute LLM function calls by interacting with Onshape API.""" def __init__(self, state: ConversationState): - """ - Initialize tool executor. - - Args: - state: Conversation state to track context - """ settings = get_settings() self.client = OnshapeClient( access_key=settings.onshape_access_key, @@ -28,35 +28,60 @@ def __init__(self, state: ConversationState): base_url=settings.onshape_base_url, ) self.state = state + self.error_handler = ErrorHandler() # Initialize managers self.documents = DocumentManager(self.client) self.sketches = SketchManager(self.client) self.features = FeatureManager(self.client) + self.assemblies = AssemblyManager(self.client) + self.exports = ExportManager(self.client) + self.display = TerminalImageDisplay() + + # FeatureScript pipeline + self.fs_executor = FeatureScriptExecutor(self.client) + self.fs_generator = FeatureScriptGenerator(GLMClient()) + + @staticmethod + def _extract_feature_id(result: dict, fallback: str = "unknown") -> str: + """Extract featureId from Onshape API response.""" + if isinstance(result, dict): + # New format: {"feature": {"featureId": "..."}} + feat = result.get("feature", {}) + if isinstance(feat, dict) and feat.get("featureId"): + return feat["featureId"] + # Legacy fallback + if result.get("featureId"): + return result["featureId"] + return fallback + + # ── Document tools ──────────────────────── def create_document(self, name: str, description: str | None = None) -> str: - """ - Create a new Onshape document. - - Args: - name: Document name - description: Optional description - - Returns: - Success message with document ID - """ result = self.documents.create_document(name, description) - - # Update state - self.state.update("document_id", result["id"]) + doc_id = result["id"] + self.state.update("document_id", doc_id) self.state.update("document_name", name) - # Get workspace - workspaces = self.documents.get_workspaces(result["id"]) + workspaces = self.documents.get_workspaces(doc_id) if workspaces: - self.state.update("workspace_id", workspaces[0]["id"]) + ws_id = workspaces[0]["id"] + self.state.update("workspace_id", ws_id) + + # Auto-discover part studio and assembly from the new document + elements = self.client.get( + f"/documents/d/{doc_id}/w/{ws_id}/elements" + ) + if isinstance(elements, list): + for el in elements: + if el.get("type") == "Part Studio" and not self.state.part_studio_id: + self.state.update("part_studio_id", el["id"]) + elif el.get("type") == "Assembly" and not getattr(self.state, "assembly_id", None): + self.state.update("assembly_id", el["id"]) - return f"Created document '{name}' (ID: {result['id']})" + return f"Created document '{name}' (ID: {doc_id})" + + # ── Sketch tools ────────────────────────── def create_sketch_rectangle( self, @@ -66,26 +91,10 @@ def create_sketch_rectangle( center_x: float = 0.0, center_y: float = 0.0, ) -> str: - """ - Create a rectangular sketch. - - Args: - plane: Plane to sketch on - width: Rectangle width in mm - height: Rectangle height in mm - center_x: X center position - center_y: Y center position - - Returns: - Success message - """ if not self.state.document_id or not self.state.workspace_id: return "Error: No document created yet. Please create a document first." - # For now, we'll use a default part studio ID - # In production, you'd query for the actual part studio part_id = self.state.part_studio_id or "default_part_studio" - result = self.sketches.create_rectangle( document_id=self.state.document_id, workspace_id=self.state.workspace_id, @@ -97,12 +106,10 @@ def create_sketch_rectangle( center_y=center_y, ) - # Update state - sketch_id = result.get("featureId", "sketch_rectangle") + sketch_id = self._extract_feature_id(result, "sketch_rectangle") self.state.update("last_sketch_id", sketch_id) - self.state.add_feature("sketch", sketch_id, f"Rectangle {width}×{height} on {plane}") - - return f"Created rectangular sketch ({width}mm × {height}mm) on {plane} plane" + self.state.add_feature("sketch", sketch_id, f"Rectangle {width}x{height} on {plane}") + return f"Created rectangular sketch ({width}mm x {height}mm) on {plane} plane" def create_sketch_circle( self, @@ -111,23 +118,10 @@ def create_sketch_circle( center_x: float = 0.0, center_y: float = 0.0, ) -> str: - """ - Create a circular sketch. - - Args: - plane: Plane to sketch on - radius: Circle radius in mm - center_x: X center position - center_y: Y center position - - Returns: - Success message - """ if not self.state.document_id or not self.state.workspace_id: return "Error: No document created yet. Please create a document first." part_id = self.state.part_studio_id or "default_part_studio" - result = self.sketches.create_circle( document_id=self.state.document_id, workspace_id=self.state.workspace_id, @@ -138,31 +132,112 @@ def create_sketch_circle( center_y=center_y, ) - sketch_id = result.get("featureId", "sketch_circle") + sketch_id = self._extract_feature_id(result, "sketch_circle") self.state.update("last_sketch_id", sketch_id) self.state.add_feature("sketch", sketch_id, f"Circle r={radius} on {plane}") - return f"Created circular sketch (radius: {radius}mm) on {plane} plane" - def extrude(self, depth: float, direction: str = "forward") -> str: - """ - Extrude the last sketch into 3D. + def create_sketch_polygon( + self, + plane: str, + num_sides: int, + radius: float, + center_x: float = 0.0, + center_y: float = 0.0, + ) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document created yet. Please create a document first." + + part_id = self.state.part_studio_id or "default_part_studio" + result = self.sketches.create_polygon( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + part_id=part_id, + plane=plane, + num_sides=num_sides, + radius=radius, + center_x=center_x, + center_y=center_y, + ) + + sketch_id = self._extract_feature_id(result, "sketch_polygon") + self.state.update("last_sketch_id", sketch_id) + self.state.add_feature("sketch", sketch_id, f"Polygon {num_sides}-sided r={radius} on {plane}") + return f"Created {num_sides}-sided polygon sketch (radius: {radius}mm) on {plane} plane" - Args: - depth: Extrusion depth in mm - direction: Extrusion direction + def create_sketch_from_points( + self, + plane: str, + points: list[list[float]], + closed: bool = True, + ) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document created yet. Please create a document first." - Returns: - Success message - """ + part_id = self.state.part_studio_id or "default_part_studio" + result = self.sketches.create_from_points( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + part_id=part_id, + plane=plane, + points=points, + closed=closed, + ) + + sketch_id = self._extract_feature_id(result, "sketch_from_points") + self.state.update("last_sketch_id", sketch_id) + self.state.add_feature("sketch", sketch_id, f"Profile ({len(points)} points) on {plane}") + return f"Created sketch from {len(points)} points on {plane} plane" + + # ── FeatureScript tools ──────────────────── + + def run_featurescript(self, description: str) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document created yet. Please create a document first." + + element_id = self.state.part_studio_id or "default_part_studio" + + # Generate FeatureScript code from description + gen_result = self.fs_generator.generate_with_retry(description) + + if not gen_result.get("validated"): + errors = gen_result.get("errors", ["Unknown error"]) + return f"Error: Failed to generate valid FeatureScript. Errors: {', '.join(errors)}" + + code = gen_result["code"] + + # Execute the generated code + try: + exec_result = self.fs_executor.execute( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + code=code, + ) + except Exception as e: + return f"Error: FeatureScript execution failed: {e}\n\nConsider using create_sketch_from_points instead." + + # Check for errors in the result + if isinstance(exec_result, dict): + notices = exec_result.get("notices", []) + errors = [n for n in notices if n.get("level") == "ERROR"] + if errors: + error_msgs = [e.get("message", "Unknown error") for e in errors] + return f"Error: FeatureScript failed: {'; '.join(error_msgs)}\n\nConsider using create_sketch_from_points instead." + + feature_id = self._extract_feature_id(exec_result, "featurescript") + self.state.add_feature("featurescript", feature_id, f"FeatureScript: {description[:50]}") + return f"Executed FeatureScript for: {description}\n\nGenerated code:\n```\n{code}\n```" + + # ── Feature tools ───────────────────────── + + def extrude(self, depth: float, direction: str = "forward", operation: str = "new") -> str: if not self.state.last_sketch_id: return "Error: No sketch to extrude. Please create a sketch first." - if not self.state.document_id or not self.state.workspace_id: return "Error: No document available." part_id = self.state.part_studio_id or "default_part_studio" - result = self.features.extrude( document_id=self.state.document_id, workspace_id=self.state.workspace_id, @@ -170,30 +245,218 @@ def extrude(self, depth: float, direction: str = "forward") -> str: sketch_id=self.state.last_sketch_id, depth=depth, direction=direction, - operation="new", + operation=operation, ) - feature_id = result.get("featureId", "extrude") - self.state.add_feature("extrude", feature_id, f"Extrude {depth}mm {direction}") + op_label = {"new": "new body", "add": "add", "subtract": "subtract (cut)"}.get(operation, operation) + feature_id = self._extract_feature_id(result, "extrude") + self.state.add_feature("extrude", feature_id, f"Extrude {depth}mm {direction} ({op_label})") + return f"Extruded sketch to depth of {depth}mm {direction} (operation: {op_label})" - return f"Extruded sketch to depth of {depth}mm {direction}" + # ── Assembly tools ──────────────────────── - def execute_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> str: - """ - Execute a tool call from the LLM. + def create_assembly(self, name: str = "Assembly 1") -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document created yet. Please create a document first." + + result = self.assemblies.create_assembly( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + name=name, + ) + + assembly_id = result.get("id", "") + self.state.update("assembly_id", assembly_id) + return f"Created assembly '{name}' (ID: {assembly_id})" + + def add_part_to_assembly(self, part: str) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + assembly_id = getattr(self.state, "assembly_id", None) + if not assembly_id: + return "Error: No assembly created yet. Please create an assembly first." - Args: - tool_name: Name of the tool to call - arguments: Tool arguments + part_studio_id = self.state.part_studio_id or "default_part_studio" + result = self.assemblies.insert_part( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + assembly_id=assembly_id, + part_studio_id=part_studio_id, + part_id=part, + ) + + instance_id = result.get("id", "") + return f"Added part '{part}' to assembly (instance: {instance_id})" + + def mate_parts( + self, + part1_face: str, + part2_face: str, + mate_type: str = "FASTENED", + offset: float = 0, + ) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + assembly_id = getattr(self.state, "assembly_id", None) + if not assembly_id: + return "Error: No assembly available." + + entity1 = {"occurrence": [], "entityType": "FACE", "entityId": part1_face} + entity2 = {"occurrence": [], "entityType": "FACE", "entityId": part2_face} + + self.assemblies.add_mate( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + assembly_id=assembly_id, + entity1=entity1, + entity2=entity2, + mate_type=mate_type, + offset=offset, + ) + return f"Created {mate_type} mate between '{part1_face}' and '{part2_face}'" + + # ── Export tools ────────────────────────── + + def export_stl(self, part: str | None = None, filename: str | None = None) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." - Returns: - Result message - """ + element_id = self.state.part_studio_id or "default_part_studio" + part_id = part or self.state.part_id or "default_part" + + if filename and not filename.endswith(".stl"): + filename = f"{filename}.stl" + + result = self.exports.export_part( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + part_id=part_id, + export_format="STL", + filename=filename, + ) + return f"Exported to STL: {result['filename']} ({result['size_bytes']} bytes)" + + def export_step(self, part: str | None = None, filename: str | None = None) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + + element_id = self.state.part_studio_id or "default_part_studio" + part_id = part or self.state.part_id or "default_part" + + if filename and not filename.endswith(".step"): + filename = f"{filename}.step" + + result = self.exports.export_part( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + part_id=part_id, + export_format="STEP", + filename=filename, + ) + return f"Exported to STEP: {result['filename']} ({result['size_bytes']} bytes)" + + def show_preview(self) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + + element_id = self.state.part_studio_id or getattr(self.state, "assembly_id", None) + if not element_id: + return "Error: No part or assembly to preview." + + image_data = self.exports.get_thumbnail( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + element_id=element_id, + ) + + success = self.display.display_image(image_data) + if success: + return "Preview displayed." + path = save_temp_image(image_data) + return f"Could not display in terminal. Preview saved to: {path}" + + # ── Undo tools ──────────────────────────── + + def undo(self) -> str: + if not self.state.feature_history: + return "Nothing to undo." + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + + last = self.state.feature_history[-1] + part_id = self.state.part_studio_id or "default_part_studio" + + self.features.delete_feature( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + part_id=part_id, + feature_id=last["id"], + ) + + self.state.feature_history.pop() + self.state.last_feature_id = ( + self.state.feature_history[-1]["id"] if self.state.feature_history else None + ) + return f"Undid: {last['name']}" + + def rollback(self, feature: str) -> str: + if not self.state.document_id or not self.state.workspace_id: + return "Error: No document available." + + part_id = self.state.part_studio_id or "default_part_studio" + + # Resolve feature reference + if feature.lower() == "last" and self.state.feature_history: + target = self.state.feature_history[-1] + elif feature.isdigit(): + idx = int(feature) + if 0 <= idx < len(self.state.feature_history): + target = self.state.feature_history[idx] + else: + return f"Error: Feature index {idx} out of range (0-{len(self.state.feature_history) - 1})" + else: + target = None + for f in self.state.feature_history: + if feature.lower() in f["name"].lower(): + target = f + break + if not target: + return f"Error: Feature '{feature}' not found in history" + + self.features.rollback_to_feature( + document_id=self.state.document_id, + workspace_id=self.state.workspace_id, + part_id=part_id, + feature_id=target["id"], + ) + return f"Rolled back to: {target['name']}" + + def show_history(self) -> str: + if not self.state.feature_history: + return "No features created yet." + + lines = ["Feature History:"] + for i, feat in enumerate(self.state.feature_history): + lines.append(f" {i}. [{feat['type']}] {feat['name']}") + return "\n".join(lines) + + # ── Dispatch ────────────────────────────── + + def execute_tool_call(self, tool_name: str, arguments: dict[str, Any]) -> str: + """Execute a tool call from the LLM.""" tool_method = getattr(self, tool_name, None) if tool_method is None: return f"Error: Unknown tool '{tool_name}'" try: return tool_method(**arguments) + except OnshapeError as e: + error_msg = self.error_handler.handle(e) + suggestions = self.error_handler.get_suggestions(e) + if suggestions: + error_msg += "\n\nSuggestions:\n" + "\n".join(f" - {s}" for s in suggestions) + return error_msg except Exception as e: - return f"Error executing {tool_name}: {str(e)}" + return f"Error executing {tool_name}: {e}" diff --git a/src/onshape_chat/ui/__init__.py b/src/onshape_chat/ui/__init__.py index de769c7..32ba093 100644 --- a/src/onshape_chat/ui/__init__.py +++ b/src/onshape_chat/ui/__init__.py @@ -1,5 +1,3 @@ """User interface components.""" -from onshape_chat.ui.chat import ChatInterface - __all__ = ["ChatInterface"] diff --git a/src/onshape_chat/ui/chat.py b/src/onshape_chat/ui/chat.py index d180ff6..7ee9f54 100644 --- a/src/onshape_chat/ui/chat.py +++ b/src/onshape_chat/ui/chat.py @@ -7,13 +7,13 @@ from rich.markdown import Markdown from rich.panel import Panel from rich.prompt import Prompt -from rich.syntax import Syntax from rich.text import Text from onshape_chat.llm.client import GLMClient -from onshape_chat.llm.conversation import ConversationState -from onshape_chat.llm.prompts import SYSTEM_PROMPT +from onshape_chat.llm.context import ContextManager from onshape_chat.llm.tools import get_tool_definitions +from onshape_chat.planning.models import BuildPlan, PlanStep, StepResult, StepStatus +from onshape_chat.planning.orchestrator import BuildOrchestrator from onshape_chat.tools.executor import ToolExecutor @@ -21,18 +21,54 @@ class ChatInterface: """Interactive chat interface for Onshape Chat.""" def __init__(self): - """Initialize chat interface.""" self.console = Console() - self.state = ConversationState() - self.messages: list[dict[str, Any]] = [] + self.context = ContextManager() self.llm = GLMClient() - self.executor = ToolExecutor(self.state) + self.executor = ToolExecutor(self.context.state) self.tools = get_tool_definitions() + self.orchestrator = BuildOrchestrator( + llm=self.llm, + executor=self.executor, + state=self.context.state, + ) + self._setup_orchestrator_callbacks() + + def _setup_orchestrator_callbacks(self) -> None: + """Wire up Rich display callbacks for the orchestrator.""" + self.orchestrator.on_plan_created = self._display_plan + self.orchestrator.on_step_start = self._display_step_start + self.orchestrator.on_step_result = self._display_step_result + + def _display_plan(self, plan: BuildPlan) -> None: + lines = [f"**Build Plan** ({plan.total_steps} steps):"] + for step in plan.steps: + lines.append(f" {step.step_number}. {step.description} (`{step.tool}`)") + self.console.print(Panel(Markdown("\n".join(lines)), title="Plan", border_style="cyan")) + + def _display_step_start(self, step: PlanStep) -> None: + self.console.print(f"\n[bold cyan]Step {step.step_number}:[/bold cyan] {step.description}") + retry_note = f" (retry {step.retry_count})" if step.retry_count > 0 else "" + self.console.print(f" [dim]> {step.tool}({json.dumps(step.args, default=str)}){retry_note}[/dim]") + + def _display_step_result(self, result: StepResult) -> None: + step = result.step + if step.status == StepStatus.PASSED: + icon = "[green]PASS[/green]" + else: + icon = "[red]FAIL[/red]" + self.console.print(f" {icon} (retries: {result.retries_used})") + if result.verification and not result.verification.passed: + self.console.print(f" [yellow]Issues: {result.verification.issues}[/yellow]") + if step.error: + self.console.print(f" [red]Error: {step.error}[/red]") + + @property + def state(self): + return self.context.state def display_welcome(self) -> None: - """Display welcome message.""" welcome_text = """ -# 🎨 Onshape Chat +# Onshape Chat Natural language interface for Onshape CAD @@ -41,6 +77,8 @@ def display_welcome(self) -> None: - `/quit` or `/exit` - Exit the chat - `/clear` - Clear conversation history - `/state` - Show current state +- `/history` - Show feature history +- `/undo` - Undo last feature **Getting Started:** Try saying: "Create a document called 'My First Part'" @@ -48,13 +86,6 @@ def display_welcome(self) -> None: self.console.print(Panel(Markdown(welcome_text), title="Welcome", border_style="blue")) def display_message(self, role: str, content: str) -> None: - """ - Display a chat message. - - Args: - role: Message role ('user' or 'assistant') - content: Message content - """ if role == "user": style = "bold blue" prefix = "You" @@ -69,77 +100,55 @@ def display_message(self, role: str, content: str) -> None: self.console.print(text) def display_tool_call(self, tool_name: str, args: dict[str, Any]) -> None: - """ - Display a tool being executed. - - Args: - tool_name: Name of the tool - args: Tool arguments - """ - self.console.print(f" [dim]🔧[/dim] [cyan]{tool_name}[/cyan](", end="") + self.console.print(f" [dim]> [/dim] [cyan]{tool_name}[/cyan](", end="") arg_strs = [f"{k}={repr(v)}" for k, v in args.items()] self.console.print(", ".join(arg_strs), end=")\n") def display_error(self, message: str) -> None: - """ - Display an error message. - - Args: - message: Error message - """ - self.console.print(f"❌ [red]{message}[/red]") + self.console.print(f"[red]{message}[/red]") def display_state(self) -> None: - """Display current conversation state.""" + state = self.state state_info = { - "Document": self.state.document_name or "None", - "Document ID": self.state.document_id or "None", - "Workspace": self.state.workspace_id or "None", - "Part Studio": self.state.part_studio_id or "None", - "Last Sketch": self.state.last_sketch_id or "None", - "Features Created": str(len(self.state.feature_history)), + "Document": state.document_name or "None", + "Document ID": state.document_id or "None", + "Workspace": state.workspace_id or "None", + "Part Studio": state.part_studio_id or "None", + "Assembly": state.assembly_id or "None", + "Last Sketch": state.last_sketch_id or "None", + "Features Created": str(len(state.feature_history)), } state_text = "\n".join(f"**{k}:** {v}" for k, v in state_info.items()) self.console.print(Panel(Markdown(state_text), title="Current State", border_style="yellow")) - def process_message(self, user_input: str) -> str: - """ - Process a user message through the LLM. + def process_message(self, user_input: str, max_tool_rounds: int = 10) -> str: + """Process a user message through the LLM with multi-turn tool calling. - Args: - user_input: User's text input - - Returns: - Assistant's response + The LLM may need several sequential rounds of tool calls (e.g. create + a document first, *then* sketch on it). We loop until the LLM returns + a text-only response or the safety limit is reached. """ - # Add user message to history - self.messages.append({"role": "user", "content": user_input}) - - # Prepare messages with context - messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "system", "content": f"Current Context:\n{self.state.get_summary()}"}, - ] - messages.extend(self.messages) + self.context.add_message("user", user_input) - # Call LLM - response = self.llm.chat(messages=messages, tools=self.tools) + for _round in range(max_tool_rounds): + messages = self.context.get_messages_for_llm() + response = self.llm.chat(messages=messages, tools=self.tools) + assistant_message = response.choices[0].message - # Process response - assistant_message = response.choices[0].message + if not assistant_message.tool_calls: + # No more tool calls — return the final text + content = assistant_message.content or "" + self.context.add_message("assistant", content) + return content - # Handle tool calls - if assistant_message.tool_calls: + # Execute every tool call in this round tool_results = [] for tool_call in assistant_message.tool_calls: function_name = tool_call.function.name function_args = json.loads(tool_call.function.arguments) - # Display tool call self.display_tool_call(function_name, function_args) - - # Execute tool result = self.executor.execute_tool_call(function_name, function_args) tool_results.append( { @@ -150,38 +159,33 @@ def process_message(self, user_input: str) -> str: } ) - # Add assistant message with tool calls - self.messages.append( - { - "role": "assistant", - "content": assistant_message.content or "", - "tool_calls": assistant_message.tool_calls, - } + # Store tool call exchange in context, then loop for next round + self.context.add_tool_call( + assistant_content=assistant_message.content, + tool_calls=assistant_message.tool_calls, + tool_results=tool_results, ) - # Add tool results - self.messages.extend(tool_results) + # Safety limit reached — ask LLM for a final summary + messages = self.context.get_messages_for_llm() + final_response = self.llm.chat(messages=messages) + content = final_response.choices[0].message.content or "" + self.context.add_message("assistant", content) + return content - # Get final response from LLM - messages.extend( - [ - self.messages[-2], - {"role": "assistant", "content": assistant_message.content or ""}, - ] - ) - messages.extend(tool_results) + def _process_build_request(self, user_input: str) -> str: + """Route a build request through the orchestrator pipeline.""" + self.context.add_message("user", user_input) - final_response = self.llm.chat(messages=messages) - final_message = final_response.choices[0].message.content + result = self.orchestrator.execute_plan(user_input) - # Add to history - self.messages.append({"role": "assistant", "content": final_message}) + # Store the plan in conversation state for context + self.state.current_plan = result.plan - return final_message + # Add the summary to conversation history so the LLM knows what happened + self.context.add_message("assistant", result.summary_message) - # No tool calls, just return the response - self.messages.append({"role": "assistant", "content": assistant_message.content}) - return assistant_message.content + return result.summary_message def run(self) -> None: """Run the main chat loop.""" @@ -189,10 +193,8 @@ def run(self) -> None: while True: try: - # Get user input user_input = Prompt.ask("\n[bold blue]You[/bold blue]", default="") - # Handle commands if user_input.lower() in ["/quit", "/exit", "q"]: self.console.print("\n[yellow]Goodbye![/yellow]") break @@ -202,9 +204,8 @@ def run(self) -> None: continue if user_input.lower() == "/clear": - self.messages = [] - self.state = ConversationState() - self.executor = ToolExecutor(self.state) + self.context.clear() + self.executor = ToolExecutor(self.context.state) self.console.print("[yellow]Conversation cleared.[/yellow]") continue @@ -212,16 +213,34 @@ def run(self) -> None: self.display_state() continue + if user_input.lower() == "/history": + result = self.executor.show_history() + self.console.print(result) + continue + + if user_input.lower() == "/undo": + result = self.executor.undo() + self.console.print(result) + continue + + if user_input.lower().startswith("/plan "): + request = user_input[6:].strip() + if request: + self.display_message("user", f"[plan] {request}") + response = self._process_build_request(request) + self.display_message("assistant", response) + else: + self.console.print("[yellow]Usage: /plan [/yellow]") + continue + if not user_input.strip(): continue - # Process message self.display_message("user", user_input) - response = self.process_message(user_input) self.display_message("assistant", response) except KeyboardInterrupt: self.console.print("\n\n[yellow]Interrupted. Type /quit to exit.[/yellow]") except Exception as e: - self.display_error(f"An error occurred: {str(e)}") + self.display_error(f"An error occurred: {e}") diff --git a/src/onshape_chat/ui/display.py b/src/onshape_chat/ui/display.py new file mode 100644 index 0000000..d90b4ff --- /dev/null +++ b/src/onshape_chat/ui/display.py @@ -0,0 +1,195 @@ +"""Terminal image display module for showing 3D model thumbnails. + +Supports multiple terminal protocols: +- Kitty graphics protocol +- Sixel (if available) +- Fallback to system viewer +""" + +import base64 +import os +import subprocess +import sys +import tempfile +import webbrowser +from typing import Literal + + +def save_temp_image(data: bytes, suffix: str = ".png") -> str: + """Save image data to a temporary file and return the path. + + Args: + data: Image data as bytes + suffix: File extension (default: .png) + + Returns: + Path to the temporary file + """ + fd, path = tempfile.mkstemp(suffix=suffix) + try: + os.write(fd, data) + finally: + os.close(fd) + return path + + +class TerminalImageDisplay: + """Display images in the terminal using various protocols.""" + + def __init__(self) -> None: + """Initialize and detect terminal protocol support.""" + self.protocol = self._detect_protocol() + + def _detect_protocol(self) -> Literal["kitty", "sixel", "none"]: + """Detect which image protocol the terminal supports. + + Returns: + Protocol name: "kitty", "sixel", or "none" + """ + # Check for Kitty terminal + term_program = os.environ.get("TERM_PROGRAM", "") + if term_program.lower() == "kitty": + return "kitty" + + # Check for Sixel support + term = os.environ.get("TERM", "") + if "xterm" in term.lower(): + # Check if sixel is actually supported + # Most modern xterm builds support sixel, but we'll attempt import + try: + import importlib.util + if importlib.util.find_spec("libsixel") is not None: + return "sixel" + except (ImportError, ValueError): + pass + + return "none" + + def display_image(self, image_data: bytes, width: int = 80) -> bool: + """Display image using the detected protocol. + + Args: + image_data: Image data as bytes (PNG format) + width: Display width in characters (for sixel) + + Returns: + True if image was displayed successfully, False otherwise + """ + if self.protocol == "kitty": + return self._display_kitty(image_data) + elif self.protocol == "sixel": + return self._display_sixel(image_data, width) + else: + return self._display_fallback(image_data) + + def _display_kitty(self, image_data: bytes) -> bool: + """Display image using Kitty graphics protocol. + + Args: + image_data: Image data as bytes + + Returns: + True if successful + """ + try: + # Encode image data to base64 + encoded = base64.b64encode(image_data).decode("ascii") + + # Split into chunks (Kitty protocol prefers chunks of 4096 bytes) + chunk_size = 4096 + chunks = [encoded[i:i + chunk_size] for i in range(0, len(encoded), chunk_size)] + + # Send image using Kitty graphics protocol + # Format: \033_G;\033\\ + for i, chunk in enumerate(chunks): + if i == 0: + # First chunk: specify format and transmission + control = "a=T,f=100" # action=transmit, format=PNG + else: + # Subsequent chunks: continuation + control = "m=1" # more data coming + + if i == len(chunks) - 1: + # Last chunk: no more data flag + if i > 0: + control = "m=0" + + sys.stdout.write(f"\033_G{control};{chunk}\033\\") + sys.stdout.flush() + + # Add newline after image + sys.stdout.write("\n") + sys.stdout.flush() + + return True + except Exception: + return False + + def _display_sixel(self, image_data: bytes, width: int) -> bool: + """Display image using Sixel protocol. + + Args: + image_data: Image data as bytes + width: Display width in characters + + Returns: + True if successful, False if sixel not available + """ + try: + import libsixel # type: ignore + + # Save to temp file first (libsixel typically needs a file) + temp_path = save_temp_image(image_data) + + try: + # Attempt to use libsixel to encode and display + # Note: This is a simplified stub - actual libsixel usage + # would require more configuration + encoder = libsixel.Encoder() + encoder.encode(temp_path) + return True + finally: + # Clean up temp file + try: + os.unlink(temp_path) + except OSError: + pass + + except (ImportError, Exception): + # Fallback if sixel not available or fails + return False + + def _display_fallback(self, image_data: bytes, filepath: str | None = None) -> bool: + """Display image using system default viewer. + + Args: + image_data: Image data as bytes + filepath: Optional pre-existing file path + + Returns: + True if viewer was opened successfully + """ + try: + # Save to temp file if not provided + if filepath is None: + filepath = save_temp_image(image_data) + + print("[Opening preview in default viewer...]") + + # Try macOS open command first + if sys.platform == "darwin": + subprocess.run(["open", filepath], check=False) + return True + + # Try Linux xdg-open + elif sys.platform.startswith("linux"): + subprocess.run(["xdg-open", filepath], check=False) + return True + + # Fallback to webbrowser module (works cross-platform) + else: + webbrowser.open(f"file://{filepath}") + return True + + except Exception: + return False diff --git a/src/onshape_chat/verification/__init__.py b/src/onshape_chat/verification/__init__.py new file mode 100644 index 0000000..a1dbc1f --- /dev/null +++ b/src/onshape_chat/verification/__init__.py @@ -0,0 +1 @@ +"""Visual verification of CAD build steps.""" diff --git a/src/onshape_chat/verification/camera_views.py b/src/onshape_chat/verification/camera_views.py new file mode 100644 index 0000000..4fb423b --- /dev/null +++ b/src/onshape_chat/verification/camera_views.py @@ -0,0 +1,118 @@ +"""Camera view definitions and multi-angle capture for visual verification.""" + +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from onshape_chat.onshape.export import ExportManager + +logger = logging.getLogger(__name__) + + +def _look_at_matrix(eye_x: float, eye_y: float, eye_z: float) -> list[float]: + """Build a 3x4 row-major view matrix looking from (eye) toward the origin. + + Onshape view matrix convention (3x4, row-major): + Row 0: view-space X axis (right) + Row 1: view-space Y axis (up) + Row 2: view-space Z axis (toward viewer) + Column 3 of each row: translation (0 for auto-fit) + + World up is +Z. + """ + # Normalize eye direction (this IS the view-space Z, pointing toward viewer) + length = math.sqrt(eye_x**2 + eye_y**2 + eye_z**2) + vz_x, vz_y, vz_z = eye_x / length, eye_y / length, eye_z / length + + # World up = +Z + up_x, up_y, up_z = 0.0, 0.0, 1.0 + + # If eye is nearly along Z axis, use Y as fallback up + if abs(vz_z) > 0.99: + up_x, up_y, up_z = 0.0, 1.0, 0.0 + + # view X = normalize(up cross vz) + cx = up_y * vz_z - up_z * vz_y + cy = up_z * vz_x - up_x * vz_z + cz = up_x * vz_y - up_y * vz_x + cl = math.sqrt(cx**2 + cy**2 + cz**2) + vx_x, vx_y, vx_z = cx / cl, cy / cl, cz / cl + + # view Y = normalize(vz cross vx) + vy_x = vz_y * vx_z - vz_z * vx_y + vy_y = vz_z * vx_x - vz_x * vx_z + vy_z = vz_x * vx_y - vz_y * vx_x + + return [ + vx_x, vx_y, vx_z, 0.0, + vy_x, vy_y, vy_z, 0.0, + vz_x, vz_y, vz_z, 0.0, + ] + + +def _build_isometric_views() -> list[dict]: + """Generate the 8 isometric corner views (all sign combos of ±1, ±1, ±1).""" + views = [] + labels_x = {1: "Right", -1: "Left"} + labels_y = {1: "Back", -1: "Front"} + labels_z = {1: "Top", -1: "Bottom"} + + for sz in (1, -1): + for sy in (-1, 1): + for sx in (1, -1): + name = f"{labels_y[sy]}-{labels_x[sx]}-{labels_z[sz]}" + matrix = _look_at_matrix(float(sx), float(sy), float(sz)) + views.append({"name": name, "view_matrix": matrix}) + return views + + +# 6 orthographic views (named strings accepted by Onshape API) +ORTHOGRAPHIC_VIEWS: list[dict] = [ + {"name": "Front", "view_matrix": "front"}, + {"name": "Back", "view_matrix": "back"}, + {"name": "Top", "view_matrix": "top"}, + {"name": "Bottom", "view_matrix": "bottom"}, + {"name": "Left", "view_matrix": "left"}, + {"name": "Right", "view_matrix": "right"}, +] + +# 8 isometric corner views (computed 3x4 matrices) +ISOMETRIC_VIEWS: list[dict] = _build_isometric_views() + +# All 14 views combined +CAMERA_VIEWS: list[dict] = ORTHOGRAPHIC_VIEWS + ISOMETRIC_VIEWS + + +def capture_all_views( + exports: ExportManager, + document_id: str, + workspace_id: str, + element_id: str, + width: int = 400, + height: int = 400, +) -> list[tuple[str, bytes]]: + """Capture screenshots from all 14 camera angles. + + Returns: + List of (label, png_bytes) tuples. Views that fail to capture are skipped + with a warning. + """ + results: list[tuple[str, bytes]] = [] + for view in CAMERA_VIEWS: + try: + image = exports.get_shaded_view( + document_id=document_id, + workspace_id=workspace_id, + element_id=element_id, + width=width, + height=height, + view_matrix=view["view_matrix"], + pixel_size=0.0, + ) + results.append((view["name"], image)) + except Exception as e: + logger.warning("Failed to capture %s view: %s", view["name"], e) + return results diff --git a/src/onshape_chat/verification/verifier.py b/src/onshape_chat/verification/verifier.py new file mode 100644 index 0000000..e4e7b26 --- /dev/null +++ b/src/onshape_chat/verification/verifier.py @@ -0,0 +1,118 @@ +"""Visual verification of build steps using GLM vision model.""" + +from __future__ import annotations + +import json +import logging + +from onshape_chat.llm.client import GLMClient +from onshape_chat.llm.prompts import MULTI_ANGLE_VERIFICATION_PROMPT, VERIFICATION_PROMPT +from onshape_chat.planning.models import PlanStep, VerificationResult + +logger = logging.getLogger(__name__) + + +class Verifier: + """Verifies build step correctness by sending screenshots to the vision LLM.""" + + def __init__(self, llm: GLMClient): + self.llm = llm + + def verify_step( + self, + image_bytes: bytes, + step: PlanStep, + overall_goal: str, + ) -> VerificationResult: + """ + Send a screenshot + step context to the vision model and parse the result. + + Returns a VerificationResult with pass/fail, issues, and suggestions. + """ + prompt = VERIFICATION_PROMPT.format( + overall_goal=overall_goal, + step_number=step.step_number, + step_description=step.description, + tool_name=step.tool, + tool_args=json.dumps(step.args, default=str), + ) + + try: + response = self.llm.chat_with_image( + messages=[{"role": "user", "content": prompt}], + image_bytes=image_bytes, + ) + + raw = response.choices[0].message.content or "" + return self._parse_result(raw) + + except Exception as e: + logger.warning("Vision verification failed: %s — defaulting to pass", e) + return VerificationResult( + passed=True, + issues=f"Verification unavailable: {e}", + ) + + def verify_step_multi_angle( + self, + images: list[tuple[str, bytes]], + step: PlanStep, + overall_goal: str, + ) -> VerificationResult: + """ + Send multiple labeled screenshots + step context to the vision model. + + Args: + images: List of (label, png_bytes) from capture_all_views + step: The plan step being verified + overall_goal: The user's original request + + Returns: + VerificationResult with pass/fail, issues, and suggestions. + """ + prompt = MULTI_ANGLE_VERIFICATION_PROMPT.format( + overall_goal=overall_goal, + step_number=step.step_number, + step_description=step.description, + tool_name=step.tool, + tool_args=json.dumps(step.args, default=str), + ) + + try: + response = self.llm.chat_with_images( + messages=[{"role": "user", "content": prompt}], + images=images, + ) + + raw = response.choices[0].message.content or "" + return self._parse_result(raw) + + except Exception as e: + logger.warning("Multi-angle verification failed: %s — defaulting to pass", e) + return VerificationResult( + passed=True, + issues=f"Multi-angle verification unavailable: {e}", + ) + + @staticmethod + def _parse_result(raw: str) -> VerificationResult: + """Parse vision model JSON response into VerificationResult.""" + text = raw.strip() + # Strip markdown code fences + if text.startswith("```"): + lines = text.split("\n") + lines = [line for line in lines if not line.strip().startswith("```")] + text = "\n".join(lines) + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.warning("Could not parse verification JSON: %s", text[:200]) + # If we can't parse, assume pass to avoid blocking + return VerificationResult(passed=True, issues=f"Unparseable response: {text[:100]}") + + return VerificationResult( + passed=bool(data.get("pass", True)), + issues=str(data.get("issues", "")), + suggestion=str(data.get("suggestion", "")), + ) diff --git a/src/onshape_chat/vision/__init__.py b/src/onshape_chat/vision/__init__.py new file mode 100644 index 0000000..1d8fe31 --- /dev/null +++ b/src/onshape_chat/vision/__init__.py @@ -0,0 +1 @@ +"""Image import and interpretation for CAD modeling.""" diff --git a/src/onshape_chat/vision/interpreter.py b/src/onshape_chat/vision/interpreter.py new file mode 100644 index 0000000..064647f --- /dev/null +++ b/src/onshape_chat/vision/interpreter.py @@ -0,0 +1,139 @@ +"""Vision-based image interpretation for CAD modeling.""" + +import base64 +import json +from pathlib import Path +from typing import Any + +from onshape_chat.llm.client import GLMClient + +EXTRACTION_PROMPT = """Extract CAD modeling information from this description. +Return JSON with: +{ + "description": "Brief overall description", + "dimensions": {"length": mm, "width": mm, "height": mm}, + "features": [{"type": "...", "params": {...}}, ...], + "operations": ["Step 1: ...", "Step 2: ...", ...] +} + +Operations should be natural language commands that can be sent to the CAD assistant. +Use millimeters for all dimensions. Be specific about planes and positions.""" + +IMAGE_ANALYSIS_PROMPT = ( + "Describe this object for CAD modeling. Include: " + "overall shape, estimated dimensions in mm, " + "features (holes, fillets, chamfers), " + "and spatial relationships between parts." +) + +MAX_IMAGE_SIZE = 4_000_000 # 4MB + + +class VisionInterpreter: + """Interpret reference images for CAD modeling.""" + + def __init__(self, client: GLMClient, vision_model: str = "glm-4v"): + self.client = client + self.vision_model = vision_model + + def encode_image(self, image_path: str | Path) -> str: + """Encode an image file to base64.""" + path = Path(image_path) + if not path.exists(): + raise FileNotFoundError(f"Image not found: {path}") + + size = path.stat().st_size + if size > MAX_IMAGE_SIZE: + raise ValueError( + f"Image too large ({size} bytes). Maximum: {MAX_IMAGE_SIZE} bytes." + ) + + suffix = path.suffix.lower() + if suffix not in {".png", ".jpg", ".jpeg", ".webp"}: + raise ValueError(f"Unsupported image format: {suffix}. Use PNG, JPG, or WebP.") + + with open(path, "rb") as f: + return base64.b64encode(f.read()).decode("utf-8") + + def interpret_image(self, image_path: str | Path) -> dict[str, Any]: + """ + Interpret a reference image for CAD modeling. + + Returns: + { + "description": "A rectangular bracket with...", + "dimensions": {"length": 100, "width": 50, "height": 25}, + "features": [{"type": "rectangle", ...}], + "operations": ["Create a rectangle 100mm x 50mm on XY plane", ...] + } + """ + base64_image = self.encode_image(image_path) + + # Step 1: Get natural language description from vision model + description = self._describe_image(base64_image) + + # Step 2: Extract structured CAD plan + structured = self._extract_structure(description) + + return structured + + def _describe_image(self, base64_image: str) -> str: + """Get natural language description from vision model.""" + response = self.client.client.chat.completions.create( + model=self.vision_model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": IMAGE_ANALYSIS_PROMPT}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{base64_image}" + }, + }, + ], + } + ], + ) + return response.choices[0].message.content + + def _extract_structure(self, description: str) -> dict[str, Any]: + """Parse description into structured CAD plan.""" + response = self.client.client.chat.completions.create( + model=self.client.model, + messages=[ + {"role": "system", "content": EXTRACTION_PROMPT}, + {"role": "user", "content": description}, + ], + response_format={"type": "json_object"}, + ) + return json.loads(response.choices[0].message.content) + + +class ImageBuildExecutor: + """Execute a CAD plan from image interpretation.""" + + def __init__(self, chat_service: Any): + """ + Args: + chat_service: ChatService or ChatInterface with process_message(). + """ + self.chat = chat_service + + def execute_plan(self, plan: dict[str, Any]) -> list[dict[str, str]]: + """ + Execute each operation in a CAD plan. + + Returns list of {operation, result} dicts. + """ + results = [] + for operation in plan.get("operations", []): + result = self.chat.process_message(operation) + # Handle both ChatService (returns dict) and ChatInterface (returns str) + if isinstance(result, dict): + response = result.get("response", "") + else: + response = str(result) + results.append({"operation": operation, "result": response}) + return results diff --git a/src/onshape_chat/voice/__init__.py b/src/onshape_chat/voice/__init__.py new file mode 100644 index 0000000..1b6da17 --- /dev/null +++ b/src/onshape_chat/voice/__init__.py @@ -0,0 +1 @@ +"""Voice input via Whisper speech-to-text.""" diff --git a/src/onshape_chat/voice/recorder.py b/src/onshape_chat/voice/recorder.py new file mode 100644 index 0000000..c51ae3a --- /dev/null +++ b/src/onshape_chat/voice/recorder.py @@ -0,0 +1,82 @@ +"""Microphone recording for voice input.""" + +import tempfile +import wave +from pathlib import Path +from typing import Any + + +class MicrophoneRecorder: + """Record audio from the system microphone.""" + + CHUNK = 1024 + CHANNELS = 1 + RATE = 16000 # Whisper expects 16kHz + SAMPLE_WIDTH = 2 # 16-bit + + def __init__(self) -> None: + self._pyaudio: Any = None + + @property + def pyaudio(self) -> Any: + """Lazy-load pyaudio.""" + if self._pyaudio is None: + try: + import pyaudio + + self._pyaudio = pyaudio + except ImportError: + raise ImportError( + "PyAudio not installed. Run: pip install 'onshape-chat[voice]'" + ) + return self._pyaudio + + def record(self, duration: float = 5.0) -> str: + """ + Record audio from the microphone. + + Args: + duration: Recording duration in seconds. + + Returns: + Path to temporary WAV file. + """ + pa = self.pyaudio + p = pa.PyAudio() + + stream = p.open( + format=pa.paInt16, + channels=self.CHANNELS, + rate=self.RATE, + input=True, + frames_per_buffer=self.CHUNK, + ) + + frames: list[bytes] = [] + num_chunks = int(self.RATE / self.CHUNK * duration) + for _ in range(num_chunks): + data = stream.read(self.CHUNK) + frames.append(data) + + stream.stop_stream() + stream.close() + p.terminate() + + return self._save_wav(frames) + + def _save_wav(self, frames: list[bytes]) -> str: + """Save recorded frames to a temporary WAV file.""" + tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + with wave.open(tmp.name, "wb") as wf: + wf.setnchannels(self.CHANNELS) + wf.setsampwidth(self.SAMPLE_WIDTH) + wf.setframerate(self.RATE) + wf.writeframes(b"".join(frames)) + return tmp.name + + @staticmethod + def cleanup(filepath: str) -> None: + """Remove a temporary recording file.""" + path = Path(filepath) + if path.exists(): + path.unlink() diff --git a/src/onshape_chat/voice/transcriber.py b/src/onshape_chat/voice/transcriber.py new file mode 100644 index 0000000..d6adeac --- /dev/null +++ b/src/onshape_chat/voice/transcriber.py @@ -0,0 +1,98 @@ +"""Speech-to-text transcription using OpenAI Whisper.""" + +from pathlib import Path +from typing import Any + +# CAD-specific vocabulary prompt to improve accuracy +CAD_VOCABULARY_PROMPT = ( + "Create a sketch on the XY plane. Extrude 20 millimeters. " + "Fillet all edges with 2mm radius. Chamfer the top edges. " + "Boolean subtract. Linear pattern 5 copies. Revolve 360 degrees. " + "Rectangle, circle, polygon, arc, spline. Part studio. Assembly." +) + +# Common misheard CAD terms and corrections +CAD_CORRECTIONS = { + "fill it": "fillet", + "fill et": "fillet", + "phillip": "fillet", + "exclude": "extrude", + "exude": "extrude", + "plain": "plane", + "ex wife": "XY", + "ex said": "XZ", + "wise said": "YZ", + "why said": "YZ", +} + + +class WhisperTranscriber: + """Transcribe audio to text using OpenAI Whisper.""" + + def __init__(self, model_size: str = "base", language: str | None = None): + """ + Initialize Whisper transcriber. + + Args: + model_size: Whisper model size (tiny, base, small, medium, large) + language: Force language (e.g., "en"). None for auto-detection. + """ + self.model_size = model_size + self.language = language + self._model: Any = None + + @property + def model(self) -> Any: + """Lazy-load the Whisper model.""" + if self._model is None: + try: + import whisper + except ImportError: + raise ImportError( + "Whisper not installed. Run: pip install 'onshape-chat[voice]'" + ) + self._model = whisper.load_model(self.model_size) + return self._model + + def transcribe(self, audio_path: str | Path) -> dict[str, Any]: + """ + Transcribe an audio file to text. + + Args: + audio_path: Path to audio file (wav, mp3, m4a, ogg, flac) + + Returns: + {"text": "...", "language": "en"} + """ + path = Path(audio_path) + if not path.exists(): + raise FileNotFoundError(f"Audio file not found: {path}") + + kwargs: dict[str, Any] = { + "initial_prompt": CAD_VOCABULARY_PROMPT, + } + if self.language: + kwargs["language"] = self.language + + result = self.model.transcribe(str(path), **kwargs) + + text = result["text"].strip() + text = self._apply_corrections(text) + + return { + "text": text, + "language": result.get("language", self.language or "en"), + } + + def _apply_corrections(self, text: str) -> str: + """Apply CAD-specific term corrections.""" + corrected = text + for wrong, right in CAD_CORRECTIONS.items(): + # Case-insensitive replacement + lower = corrected.lower() + idx = lower.find(wrong) + while idx != -1: + corrected = corrected[:idx] + right + corrected[idx + len(wrong):] + lower = corrected.lower() + idx = lower.find(wrong, idx + len(right)) + return corrected diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..eda1320 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +"""Shared test fixtures for onshape_chat tests.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from onshape_chat.llm.conversation import ConversationState +from onshape_chat.onshape.client import OnshapeClient + + +@pytest.fixture(autouse=True) +def mock_env(): + """Ensure test environment has required vars.""" + env = { + "GLM_API_KEY": "test-glm-key", + "GLM_MODEL": "glm-4.7", + "GLM_BASE_URL": "https://api.z.ai/api/coding/paas/v4", + "ONSHAPE_ACCESS_KEY": "test-access-key", + "ONSHAPE_SECRET_KEY": "test-secret-key", + "ONSHAPE_BASE_URL": "https://cad.onshape.com/api/v6", + } + with patch.dict(os.environ, env, clear=False): + # Reset cached settings + import onshape_chat.config + onshape_chat.config._settings = None + yield + onshape_chat.config._settings = None + + +@pytest.fixture +def mock_client(): + """Create a mock OnshapeClient.""" + client = MagicMock(spec=OnshapeClient) + client.get.return_value = {} + client.post.return_value = {} + client.put.return_value = {} + client.delete.return_value = {} + client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # Fake PNG + return client + + +@pytest.fixture +def state(): + """Create a ConversationState with test data.""" + s = ConversationState() + s.document_id = "doc-123" + s.document_name = "Test Document" + s.workspace_id = "ws-456" + s.part_studio_id = "ps-789" + s.part_id = "part-abc" + return s + + +@pytest.fixture +def state_with_features(state): + """ConversationState with some features already created.""" + state.add_feature("sketch", "sk-1", "Rectangle 50x30 on XY") + state.add_feature("extrude", "ext-1", "Extrude 20mm forward") + state.last_sketch_id = "sk-1" + return state diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..1a4fd56 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,86 @@ +"""Shared fixtures for integration tests requiring real Onshape API access.""" + +import os +import uuid + +import pytest + +from onshape_chat.onshape.client import OnshapeClient +from onshape_chat.onshape.documents import DocumentManager + + +def _has_onshape_keys() -> bool: + return bool(os.environ.get("ONSHAPE_ACCESS_KEY") and os.environ.get("ONSHAPE_SECRET_KEY")) + + +def _has_glm_key() -> bool: + return bool(os.environ.get("GLM_API_KEY")) + + +requires_onshape = pytest.mark.skipif( + not _has_onshape_keys(), + reason="ONSHAPE_ACCESS_KEY and ONSHAPE_SECRET_KEY environment variables not set", +) + +requires_glm = pytest.mark.skipif( + not _has_glm_key(), + reason="GLM_API_KEY environment variable not set", +) + + +@pytest.fixture +def real_onshape_client(): + """Create a real OnshapeClient from environment variables.""" + client = OnshapeClient( + access_key=os.environ["ONSHAPE_ACCESS_KEY"], + secret_key=os.environ["ONSHAPE_SECRET_KEY"], + base_url=os.environ.get("ONSHAPE_BASE_URL", "https://cad.onshape.com/api/v6"), + ) + return client + + +@pytest.fixture +def real_document(real_onshape_client): + """Create a temporary Onshape document for testing, then delete it on teardown. + + Yields a dict with keys: + - client: OnshapeClient instance + - doc_manager: DocumentManager instance + - document: full document response from create_document + - document_id: shortcut to document["id"] + - workspace_id: the default workspace ID + """ + doc_manager = DocumentManager(real_onshape_client) + unique_name = f"integration-test-{uuid.uuid4().hex[:8]}" + document = doc_manager.create_document(unique_name, description="Auto-created by integration test") + + document_id = document["id"] + + # Retrieve the default workspace + workspaces = doc_manager.get_workspaces(document_id) + workspace_id = workspaces[0]["id"] if workspaces else None + + # Find the default Part Studio element + # New documents always have a Part Studio as the first element + elements = document.get("defaultWorkspace", {}).get("elements", []) + part_studio_id = None + for elem in elements: + if elem.get("elementType") == "PARTSTUDIO": + part_studio_id = elem["id"] + break + + yield { + "client": real_onshape_client, + "doc_manager": doc_manager, + "document": document, + "document_id": document_id, + "workspace_id": workspace_id, + "part_studio_id": part_studio_id, + } + + # Cleanup: delete the document + try: + doc_manager.delete_document(document_id) + except Exception: + # Best effort cleanup; don't fail the test on cleanup errors + pass diff --git a/tests/integration/test_assemblies.py b/tests/integration/test_assemblies.py new file mode 100644 index 0000000..133031c --- /dev/null +++ b/tests/integration/test_assemblies.py @@ -0,0 +1,181 @@ +"""Integration tests for Onshape assembly operations.""" + +import pytest + +from onshape_chat.onshape.assemblies import AssemblyManager +from onshape_chat.onshape.features import FeatureManager +from onshape_chat.onshape.sketches import SketchManager + +from .conftest import requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestAssemblyOperations: + """Test assembly creation and part insertion against the real Onshape API.""" + + def _make_part_path(self, real_document): + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + if ps_id is None: + pytest.skip("No Part Studio element found in new document") + return f"d/{doc_id}/w/{ws_id}/e/{ps_id}" + + def _create_solid_part(self, real_document, part_path): + """Helper: create a rectangle sketch and extrude it into a solid. + + Returns (sketch_id, extrude_result) so caller can find part IDs. + """ + client = real_document["client"] + sketch_mgr = SketchManager(client) + sketch_result = sketch_mgr.create_rectangle( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + plane="XY", + width=40.0, + height=20.0, + ) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + + if not sketch_id: + pytest.skip("Could not get sketch featureId") + + feat_mgr = FeatureManager(client) + extrude_result = feat_mgr.extrude( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + sketch_id=sketch_id, + depth=15.0, + direction="forward", + operation="new", + ) + return sketch_id, extrude_result + + def test_create_assembly(self, real_document): + """Create an assembly element in a document.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + + asm_mgr = AssemblyManager(client) + result = asm_mgr.create_assembly( + document_id=doc_id, + workspace_id=ws_id, + name="Test Assembly", + ) + + assert result is not None + assert isinstance(result, dict) + assert "id" in result, "Assembly creation should return an ID" + + def test_create_assembly_with_default_name(self, real_document): + """Create an assembly with the default name.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + + asm_mgr = AssemblyManager(client) + result = asm_mgr.create_assembly( + document_id=doc_id, + workspace_id=ws_id, + ) + + assert result is not None + assert "id" in result + + def test_insert_part_into_assembly(self, real_document): + """Create a solid part, create an assembly, and insert the part into it.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + part_path = self._make_part_path(real_document) + + # Create a solid part first + self._create_solid_part(real_document, part_path) + + # Get the part ID from the part studio + # After extrude, the part studio should have a part + parts_endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{ps_id}/parts" + parts_response = client.get(parts_endpoint) + if not parts_response: + pytest.skip("No parts found after extrude") + + # parts_response could be a list or have an 'items' key + parts = parts_response if isinstance(parts_response, list) else parts_response.get("items", []) + if not parts: + pytest.skip("No parts available in part studio") + + part_id = parts[0].get("partId", parts[0].get("id", "")) + assert part_id, "Part should have a partId" + + # Create assembly + asm_mgr = AssemblyManager(client) + assembly = asm_mgr.create_assembly( + document_id=doc_id, + workspace_id=ws_id, + name="Assembly with Part", + ) + assembly_id = assembly["id"] + + # Insert part + insert_result = asm_mgr.insert_part( + document_id=doc_id, + workspace_id=ws_id, + assembly_id=assembly_id, + part_studio_id=ps_id, + part_id=part_id, + ) + + assert insert_result is not None + assert isinstance(insert_result, dict) + + def test_get_assembly_definition(self, real_document): + """Create an assembly and retrieve its definition.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + + asm_mgr = AssemblyManager(client) + assembly = asm_mgr.create_assembly( + document_id=doc_id, + workspace_id=ws_id, + name="Definition Test Assembly", + ) + assembly_id = assembly["id"] + + definition = asm_mgr.get_assembly_definition( + document_id=doc_id, + workspace_id=ws_id, + assembly_id=assembly_id, + ) + + assert definition is not None + assert isinstance(definition, dict) + + def test_get_instances_empty_assembly(self, real_document): + """An empty assembly should return an empty (or near-empty) instance list.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + + asm_mgr = AssemblyManager(client) + assembly = asm_mgr.create_assembly( + document_id=doc_id, + workspace_id=ws_id, + name="Empty Assembly", + ) + assembly_id = assembly["id"] + + instances = asm_mgr.get_instances( + document_id=doc_id, + workspace_id=ws_id, + assembly_id=assembly_id, + ) + + assert isinstance(instances, list) + # Empty assembly should have no instances + assert len(instances) == 0 diff --git a/tests/integration/test_documents.py b/tests/integration/test_documents.py new file mode 100644 index 0000000..4dd5cd4 --- /dev/null +++ b/tests/integration/test_documents.py @@ -0,0 +1,87 @@ +"""Integration tests for Onshape document CRUD operations.""" + +import uuid + +import pytest + +from onshape_chat.onshape.documents import DocumentManager + +from .conftest import requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestDocumentOperations: + """Test document create, list, get, and delete against the real Onshape API.""" + + def test_create_and_delete_document(self, real_onshape_client): + """Create a document and verify it can be retrieved and deleted.""" + doc_manager = DocumentManager(real_onshape_client) + name = f"test-create-{uuid.uuid4().hex[:8]}" + + # Create + doc = doc_manager.create_document(name, description="Integration test document") + assert doc is not None + assert "id" in doc + assert doc.get("name") == name + + doc_id = doc["id"] + + try: + # Get + fetched = doc_manager.get_document(doc_id) + assert fetched is not None + assert fetched.get("name") == name + + # Workspaces + workspaces = doc_manager.get_workspaces(doc_id) + assert isinstance(workspaces, list) + assert len(workspaces) >= 1, "New document should have at least one workspace" + assert "id" in workspaces[0] + finally: + # Delete + doc_manager.delete_document(doc_id) + + def test_list_documents(self, real_onshape_client): + """List documents and verify the response structure.""" + doc_manager = DocumentManager(real_onshape_client) + + docs = doc_manager.list_documents(limit=5) + assert isinstance(docs, list) + # The user should have at least some documents (or an empty list is OK) + for doc in docs: + assert "id" in doc + assert "name" in doc + + def test_created_document_appears_in_list(self, real_onshape_client): + """A newly created document should appear in the document list.""" + doc_manager = DocumentManager(real_onshape_client) + name = f"test-list-{uuid.uuid4().hex[:8]}" + + doc = doc_manager.create_document(name) + doc_id = doc["id"] + + try: + # List and check presence + all_docs = doc_manager.list_documents(limit=50) + doc_ids = [d["id"] for d in all_docs] + assert doc_id in doc_ids, f"Newly created doc {doc_id} not found in list" + finally: + doc_manager.delete_document(doc_id) + + def test_delete_removes_document(self, real_onshape_client): + """Deleting a document should make it inaccessible.""" + doc_manager = DocumentManager(real_onshape_client) + name = f"test-delete-{uuid.uuid4().hex[:8]}" + + doc = doc_manager.create_document(name) + doc_id = doc["id"] + + # Delete + doc_manager.delete_document(doc_id) + + # Attempting to get a deleted document should raise an error + from onshape_chat.errors import OnshapeError + + with pytest.raises(OnshapeError): + doc_manager.get_document(doc_id) diff --git a/tests/integration/test_end_to_end.py b/tests/integration/test_end_to_end.py new file mode 100644 index 0000000..13da7cc --- /dev/null +++ b/tests/integration/test_end_to_end.py @@ -0,0 +1,190 @@ +"""End-to-end integration tests for the full chat pipeline. + +These tests exercise the ToolExecutor with real Onshape API calls, +simulating the workflow a user would follow through the chat interface. +""" + +import os +import uuid + +import pytest + +from onshape_chat.llm.conversation import ConversationState +from onshape_chat.onshape.documents import DocumentManager +from onshape_chat.tools.executor import ToolExecutor + +from .conftest import requires_glm, requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestEndToEndPipeline: + """Test the full pipeline: create doc -> sketch -> extrude -> export.""" + + @pytest.fixture + def e2e_executor(self, real_onshape_client, monkeypatch): + """Create a ToolExecutor configured with real API keys. + + We monkeypatch the settings so ToolExecutor picks up real credentials. + """ + monkeypatch.setenv("ONSHAPE_ACCESS_KEY", os.environ["ONSHAPE_ACCESS_KEY"]) + monkeypatch.setenv("ONSHAPE_SECRET_KEY", os.environ["ONSHAPE_SECRET_KEY"]) + monkeypatch.setenv("ONSHAPE_BASE_URL", os.environ.get("ONSHAPE_BASE_URL", "https://cad.onshape.com/api/v6")) + # Set dummy GLM keys so Settings doesn't fail validation + monkeypatch.setenv("GLM_API_KEY", os.environ.get("GLM_API_KEY", "dummy-for-executor")) + + # Reset cached settings so ToolExecutor picks up monkeypatched env + import onshape_chat.config + onshape_chat.config._settings = None + + state = ConversationState() + executor = ToolExecutor(state) + yield executor, state + + # Cleanup: delete the document if one was created + if state.document_id: + try: + doc_mgr = DocumentManager(executor.client) + doc_mgr.delete_document(state.document_id) + except Exception: + pass + + onshape_chat.config._settings = None + + def test_full_workflow_create_sketch_extrude(self, e2e_executor): + """Full workflow: create document, sketch a rectangle, extrude it.""" + executor, state = e2e_executor + doc_name = f"e2e-test-{uuid.uuid4().hex[:8]}" + + # Step 1: Create document + result = executor.execute_tool_call("create_document", {"name": doc_name}) + assert "Created document" in result + assert state.document_id is not None + assert state.workspace_id is not None + + # Step 2: Create a sketch + result = executor.execute_tool_call( + "create_sketch_rectangle", + {"plane": "XY", "width": 50.0, "height": 30.0}, + ) + assert "Created rectangular sketch" in result + assert state.last_sketch_id is not None + + # Step 3: Extrude + result = executor.execute_tool_call("extrude", {"depth": 20.0}) + assert "Extruded sketch" in result + assert len(state.feature_history) >= 2 # sketch + extrude + + def test_full_workflow_with_history(self, e2e_executor): + """Full workflow with feature history check.""" + executor, state = e2e_executor + doc_name = f"e2e-history-{uuid.uuid4().hex[:8]}" + + # Create document + sketch + extrude + executor.execute_tool_call("create_document", {"name": doc_name}) + executor.execute_tool_call( + "create_sketch_rectangle", + {"plane": "XY", "width": 40.0, "height": 25.0}, + ) + executor.execute_tool_call("extrude", {"depth": 15.0}) + + # Check history + result = executor.execute_tool_call("show_history", {}) + assert "Feature History:" in result + assert len(state.feature_history) >= 2 + + def test_full_workflow_undo(self, e2e_executor): + """Full workflow with undo to remove last feature.""" + executor, state = e2e_executor + doc_name = f"e2e-undo-{uuid.uuid4().hex[:8]}" + + executor.execute_tool_call("create_document", {"name": doc_name}) + executor.execute_tool_call( + "create_sketch_rectangle", + {"plane": "XY", "width": 60.0, "height": 40.0}, + ) + executor.execute_tool_call("extrude", {"depth": 25.0}) + + feature_count_before = len(state.feature_history) + + # Undo the extrude + result = executor.execute_tool_call("undo", {}) + assert "Undid" in result + assert len(state.feature_history) == feature_count_before - 1 + + def test_full_workflow_circle_sketch(self, e2e_executor): + """Full workflow with a circle sketch instead of rectangle.""" + executor, state = e2e_executor + doc_name = f"e2e-circle-{uuid.uuid4().hex[:8]}" + + executor.execute_tool_call("create_document", {"name": doc_name}) + result = executor.execute_tool_call( + "create_sketch_circle", + {"plane": "XY", "radius": 25.0}, + ) + assert "Created circular sketch" in result + + result = executor.execute_tool_call("extrude", {"depth": 30.0}) + assert "Extruded sketch" in result + + def test_error_handling_no_document(self, e2e_executor): + """Executing sketch without a document should return an error message, not crash.""" + executor, state = e2e_executor + + result = executor.execute_tool_call( + "create_sketch_rectangle", + {"plane": "XY", "width": 50.0, "height": 30.0}, + ) + assert "Error" in result + + def test_error_handling_no_sketch_for_extrude(self, e2e_executor): + """Extruding without a sketch should return an error message.""" + executor, state = e2e_executor + doc_name = f"e2e-nosk-{uuid.uuid4().hex[:8]}" + + executor.execute_tool_call("create_document", {"name": doc_name}) + result = executor.execute_tool_call("extrude", {"depth": 20.0}) + assert "Error" in result + + def test_create_assembly_pipeline(self, e2e_executor): + """Full workflow: create document, part, assembly.""" + executor, state = e2e_executor + doc_name = f"e2e-asm-{uuid.uuid4().hex[:8]}" + + executor.execute_tool_call("create_document", {"name": doc_name}) + result = executor.execute_tool_call("create_assembly", {"name": "Test Assembly"}) + assert "Created assembly" in result + assert state.assembly_id is not None + + +@requires_onshape +@requires_glm +@pytest.mark.integration +class TestEndToEndWithLLM: + """Test the full pipeline including the GLM LLM integration. + + These tests require both Onshape and GLM API keys. + """ + + def test_glm_client_initializes(self): + """Verify GLM client can be initialized with real credentials.""" + from onshape_chat.llm.client import GLMClient + + client = GLMClient() + assert client.client is not None + assert client.model is not None + + def test_context_manager_builds_messages(self): + """Verify ContextManager builds a valid message list for the LLM.""" + from onshape_chat.llm.context import ContextManager + + ctx = ContextManager() + ctx.add_message("user", "Create a box") + + messages = ctx.get_messages_for_llm() + assert len(messages) >= 2 # system + user at minimum + assert messages[0]["role"] == "system" + # The last user message should be present + user_msgs = [m for m in messages if m["role"] == "user"] + assert len(user_msgs) >= 1 + assert user_msgs[-1]["content"] == "Create a box" diff --git a/tests/integration/test_export.py b/tests/integration/test_export.py new file mode 100644 index 0000000..7af578c --- /dev/null +++ b/tests/integration/test_export.py @@ -0,0 +1,180 @@ +"""Integration tests for Onshape export operations.""" + +import os +import tempfile + +import pytest + +from onshape_chat.onshape.export import ExportManager +from onshape_chat.onshape.features import FeatureManager +from onshape_chat.onshape.sketches import SketchManager + +from .conftest import requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestExportOperations: + """Test export operations against the real Onshape API.""" + + def _make_part_path(self, real_document): + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + if ps_id is None: + pytest.skip("No Part Studio element found in new document") + return f"d/{doc_id}/w/{ws_id}/e/{ps_id}" + + def _create_solid_part(self, real_document, part_path): + """Create a rectangle sketch and extrude it, returning the part ID.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + sketch_mgr = SketchManager(client) + sketch_result = sketch_mgr.create_rectangle( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + plane="XY", + width=30.0, + height=20.0, + ) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + if not sketch_id: + pytest.skip("Could not get sketch featureId") + + feat_mgr = FeatureManager(client) + feat_mgr.extrude( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + sketch_id=sketch_id, + depth=10.0, + direction="forward", + operation="new", + ) + + # Get the part ID + parts_endpoint = f"/partstudios/d/{doc_id}/w/{ws_id}/e/{ps_id}/parts" + parts_response = client.get(parts_endpoint) + parts = parts_response if isinstance(parts_response, list) else parts_response.get("items", []) + if not parts: + pytest.skip("No parts found after extrude") + + return parts[0].get("partId", parts[0].get("id", "")) + + def test_export_stl(self, real_document): + """Export a part as STL and verify the file is created with content.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + part_path = self._make_part_path(real_document) + + part_id = self._create_solid_part(real_document, part_path) + assert part_id, "Should have a valid part ID" + + export_mgr = ExportManager(client) + + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "test_export.stl") + result = export_mgr.export_part( + document_id=doc_id, + workspace_id=ws_id, + element_id=ps_id, + part_id=part_id, + export_format="STL", + filename=filepath, + ) + + assert result is not None + assert result["format"] == "STL" + assert result["size_bytes"] > 0, "STL file should have content" + assert os.path.isfile(result["filename"]), "STL file should exist on disk" + + def test_export_step(self, real_document): + """Export a part as STEP and verify the file is created with content.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + part_path = self._make_part_path(real_document) + + part_id = self._create_solid_part(real_document, part_path) + assert part_id, "Should have a valid part ID" + + export_mgr = ExportManager(client) + + with tempfile.TemporaryDirectory() as tmpdir: + filepath = os.path.join(tmpdir, "test_export.step") + result = export_mgr.export_part( + document_id=doc_id, + workspace_id=ws_id, + element_id=ps_id, + part_id=part_id, + export_format="STEP", + filename=filepath, + ) + + assert result is not None + assert result["format"] == "STEP" + assert result["size_bytes"] > 0, "STEP file should have content" + assert os.path.isfile(result["filename"]), "STEP file should exist on disk" + + def test_export_stl_auto_filename(self, real_document): + """Export STL with auto-generated filename.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + part_path = self._make_part_path(real_document) + + part_id = self._create_solid_part(real_document, part_path) + assert part_id, "Should have a valid part ID" + + export_mgr = ExportManager(client) + + result = export_mgr.export_part( + document_id=doc_id, + workspace_id=ws_id, + element_id=ps_id, + part_id=part_id, + export_format="STL", + filename=None, + ) + + assert result is not None + assert result["format"] == "STL" + assert result["size_bytes"] > 0 + assert result["filename"].endswith(".stl") + + # Clean up the auto-generated file + try: + os.unlink(result["filename"]) + except OSError: + pass + + def test_get_thumbnail(self, real_document): + """Get a thumbnail image for a Part Studio element.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + if ps_id is None: + pytest.skip("No Part Studio element found") + + export_mgr = ExportManager(client) + + image_data = export_mgr.get_thumbnail( + document_id=doc_id, + workspace_id=ws_id, + element_id=ps_id, + width=200, + height=200, + ) + + assert isinstance(image_data, bytes) + assert len(image_data) > 0, "Thumbnail should have content" diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py new file mode 100644 index 0000000..81c1f52 --- /dev/null +++ b/tests/integration/test_features.py @@ -0,0 +1,164 @@ +"""Integration tests for Onshape feature operations (extrude, fillet, etc.).""" + +import pytest + +from onshape_chat.onshape.features import FeatureManager +from onshape_chat.onshape.sketches import SketchManager + +from .conftest import requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestFeatureOperations: + """Test feature operations against the real Onshape API.""" + + def _make_part_path(self, real_document): + """Build the part studio path from the real_document fixture.""" + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + if ps_id is None: + pytest.skip("No Part Studio element found in new document") + return f"d/{doc_id}/w/{ws_id}/e/{ps_id}" + + def _create_sketch(self, real_document, part_path): + """Helper: create a rectangle sketch and return the response.""" + sketch_mgr = SketchManager(real_document["client"]) + return sketch_mgr.create_rectangle( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + plane="XY", + width=50.0, + height=30.0, + ) + + def test_extrude_sketch(self, real_document): + """Create a sketch then extrude it into a 3D solid.""" + client = real_document["client"] + part_path = self._make_part_path(real_document) + + # Step 1: create a sketch + sketch_result = self._create_sketch(real_document, part_path) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + assert sketch_id, "Sketch creation should return a featureId" + + # Step 2: extrude + feat_mgr = FeatureManager(client) + extrude_result = feat_mgr.extrude( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + sketch_id=sketch_id, + depth=20.0, + direction="forward", + operation="new", + ) + + assert extrude_result is not None + assert isinstance(extrude_result, dict) + + def test_extrude_symmetric(self, real_document): + """Extrude a sketch symmetrically.""" + client = real_document["client"] + part_path = self._make_part_path(real_document) + + sketch_result = self._create_sketch(real_document, part_path) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + assert sketch_id, "Sketch creation should return a featureId" + + feat_mgr = FeatureManager(client) + result = feat_mgr.extrude( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + sketch_id=sketch_id, + depth=15.0, + direction="symmetric", + operation="new", + ) + + assert result is not None + assert isinstance(result, dict) + + def test_get_features_after_creation(self, real_document): + """After creating a sketch and extrude, get_features should list them.""" + client = real_document["client"] + part_path = self._make_part_path(real_document) + + # Create sketch + extrude + sketch_result = self._create_sketch(real_document, part_path) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + + feat_mgr = FeatureManager(client) + if sketch_id: + feat_mgr.extrude( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + sketch_id=sketch_id, + depth=10.0, + ) + + features = feat_mgr.get_features( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + ) + + assert isinstance(features, list) + # At minimum we should have the sketch feature + assert len(features) >= 1, "Should have at least one feature after sketch creation" + + def test_get_feature_history(self, real_document): + """get_feature_history should return ordered list of features.""" + client = real_document["client"] + part_path = self._make_part_path(real_document) + + # Create a sketch + self._create_sketch(real_document, part_path) + + feat_mgr = FeatureManager(client) + history = feat_mgr.get_feature_history( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + ) + + assert isinstance(history, list) + for entry in history: + assert "index" in entry + assert "feature_id" in entry + assert "name" in entry + assert "type" in entry + + def test_delete_feature(self, real_document): + """Create a sketch, then delete it and verify removal.""" + client = real_document["client"] + part_path = self._make_part_path(real_document) + + sketch_result = self._create_sketch(real_document, part_path) + sketch_id = sketch_result.get("featureId", sketch_result.get("feature", {}).get("featureId", "")) + + if not sketch_id: + pytest.skip("Could not retrieve sketch featureId for deletion test") + + feat_mgr = FeatureManager(client) + + # Delete the feature + feat_mgr.delete_feature( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + feature_id=sketch_id, + ) + + # Verify it's gone + features = feat_mgr.get_features( + document_id=real_document["document_id"], + workspace_id=real_document["workspace_id"], + part_id=part_path, + ) + feature_ids = [f.get("featureId") for f in features] + assert sketch_id not in feature_ids, "Deleted feature should not appear in feature list" diff --git a/tests/integration/test_sketches.py b/tests/integration/test_sketches.py new file mode 100644 index 0000000..7b3ac1b --- /dev/null +++ b/tests/integration/test_sketches.py @@ -0,0 +1,119 @@ +"""Integration tests for Onshape sketch operations.""" + +import pytest + +from onshape_chat.onshape.sketches import SketchManager + +from .conftest import requires_onshape + + +@requires_onshape +@pytest.mark.integration +class TestSketchOperations: + """Test sketch creation against the real Onshape API.""" + + def test_create_rectangle_sketch(self, real_document): + """Create a rectangular sketch on the XY plane.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + sketch_mgr = SketchManager(client) + + # Build the part studio path: d/{doc}/w/{ws}/e/{element} + part_path = f"d/{doc_id}/w/{ws_id}/e/{ps_id}" if ps_id else None + if part_path is None: + pytest.skip("No Part Studio element found in new document") + + result = sketch_mgr.create_rectangle( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + plane="XY", + width=50.0, + height=30.0, + center_x=0.0, + center_y=0.0, + ) + + assert result is not None + # The API should return some kind of feature or feature ID + assert isinstance(result, dict) + + def test_create_circle_sketch(self, real_document): + """Create a circular sketch on the XY plane.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + sketch_mgr = SketchManager(client) + + part_path = f"d/{doc_id}/w/{ws_id}/e/{ps_id}" if ps_id else None + if part_path is None: + pytest.skip("No Part Studio element found in new document") + + result = sketch_mgr.create_circle( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + plane="XY", + radius=25.0, + center_x=0.0, + center_y=0.0, + ) + + assert result is not None + assert isinstance(result, dict) + + def test_create_rectangle_on_different_planes(self, real_document): + """Create rectangles on XZ and YZ planes to verify plane selection works.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + sketch_mgr = SketchManager(client) + + part_path = f"d/{doc_id}/w/{ws_id}/e/{ps_id}" if ps_id else None + if part_path is None: + pytest.skip("No Part Studio element found in new document") + + for plane in ["XZ", "YZ"]: + result = sketch_mgr.create_rectangle( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + plane=plane, + width=40.0, + height=20.0, + ) + assert result is not None + assert isinstance(result, dict) + + def test_create_circle_with_offset_center(self, real_document): + """Create a circle with a non-zero center position.""" + client = real_document["client"] + doc_id = real_document["document_id"] + ws_id = real_document["workspace_id"] + ps_id = real_document["part_studio_id"] + + sketch_mgr = SketchManager(client) + + part_path = f"d/{doc_id}/w/{ws_id}/e/{ps_id}" if ps_id else None + if part_path is None: + pytest.skip("No Part Studio element found in new document") + + result = sketch_mgr.create_circle( + document_id=doc_id, + workspace_id=ws_id, + part_id=part_path, + plane="XY", + radius=10.0, + center_x=50.0, + center_y=25.0, + ) + + assert result is not None + assert isinstance(result, dict) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..c369da5 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,387 @@ +"""Tests for Phase 4a: FastAPI backend.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from onshape_chat.api.chat_service import ChatService +from onshape_chat.api.models import ( + ChatRequest, + ChatResponse, + HealthResponse, + StateResponse, + ToolCallInfo, +) +from onshape_chat.api.server import app, get_service, reset_service + +# ── Fixtures ────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _reset_server(): + """Reset global service between tests.""" + reset_service() + yield + reset_service() + + +@pytest.fixture +def mock_llm_response(): + """Create a mock LLM response (no tool calls).""" + msg = MagicMock() + msg.content = "I created a rectangle for you." + msg.tool_calls = None + + resp = MagicMock() + resp.choices = [MagicMock(message=msg)] + return resp + + +@pytest.fixture +def mock_llm_response_with_tools(): + """Create a mock LLM response with tool calls.""" + # First response: has tool calls + tool_call = MagicMock() + tool_call.id = "call_123" + tool_call.function.name = "create_sketch_rectangle" + tool_call.function.arguments = json.dumps({"plane": "XY", "width": 50, "height": 30}) + + msg1 = MagicMock() + msg1.content = None + msg1.tool_calls = [tool_call] + + resp1 = MagicMock() + resp1.choices = [MagicMock(message=msg1)] + + # Second response: final answer + msg2 = MagicMock() + msg2.content = "Created a 50x30 rectangle on XY plane." + msg2.tool_calls = None + + resp2 = MagicMock() + resp2.choices = [MagicMock(message=msg2)] + + return resp1, resp2 + + +# ── Pydantic Model Tests ───────────────────────────── + + +class TestModels: + def test_chat_request(self): + req = ChatRequest(message="Create a box") + assert req.message == "Create a box" + + def test_chat_response(self): + resp = ChatResponse(response="Done", tool_calls=[], state={}) + assert resp.response == "Done" + assert resp.tool_calls == [] + + def test_tool_call_info(self): + tc = ToolCallInfo(name="extrude", args={"depth": 10}, result="Extruded 10mm") + assert tc.name == "extrude" + assert tc.status == "success" + + def test_state_response_defaults(self): + sr = StateResponse() + assert sr.document_id is None + assert sr.feature_count == 0 + assert sr.features == [] + + def test_state_response_populated(self): + sr = StateResponse( + document_id="doc-1", + document_name="Test", + feature_count=3, + features=[{"type": "sketch", "id": "s1", "name": "rect"}], + ) + assert sr.document_id == "doc-1" + assert sr.feature_count == 3 + + def test_health_response(self): + hr = HealthResponse() + assert hr.status == "healthy" + assert hr.version == "0.1.0" + + +# ── ChatService Tests ───────────────────────────────── + + +class TestChatService: + @patch("onshape_chat.api.chat_service.ToolExecutor") + @patch("onshape_chat.api.chat_service.GLMClient") + @patch("onshape_chat.api.chat_service.ContextManager") + def test_process_message_no_tools(self, mock_ctx_cls, mock_llm_cls, mock_exec_cls, + mock_llm_response): + mock_ctx = MagicMock() + mock_ctx.state = MagicMock() + mock_ctx.state.document_id = None + mock_ctx.state.document_name = None + mock_ctx.state.workspace_id = None + mock_ctx.state.part_studio_id = None + mock_ctx.state.assembly_id = None + mock_ctx.state.last_sketch_id = None + mock_ctx.state.feature_history = [] + mock_ctx_cls.return_value = mock_ctx + + mock_llm = MagicMock() + mock_llm.chat.return_value = mock_llm_response + mock_llm_cls.return_value = mock_llm + + service = ChatService() + result = service.process_message("Create a rectangle") + + assert result["response"] == "I created a rectangle for you." + assert result["tool_calls"] == [] + assert "document_id" in result["state"] + + @patch("onshape_chat.api.chat_service.ToolExecutor") + @patch("onshape_chat.api.chat_service.GLMClient") + @patch("onshape_chat.api.chat_service.ContextManager") + def test_process_message_with_tools(self, mock_ctx_cls, mock_llm_cls, mock_exec_cls, + mock_llm_response_with_tools): + resp1, resp2 = mock_llm_response_with_tools + + mock_ctx = MagicMock() + mock_ctx.state = MagicMock() + mock_ctx.state.document_id = "doc-1" + mock_ctx.state.document_name = "Test" + mock_ctx.state.workspace_id = "ws-1" + mock_ctx.state.part_studio_id = None + mock_ctx.state.assembly_id = None + mock_ctx.state.last_sketch_id = None + mock_ctx.state.feature_history = [] + mock_ctx_cls.return_value = mock_ctx + + mock_llm = MagicMock() + mock_llm.chat.side_effect = [resp1, resp2] + mock_llm_cls.return_value = mock_llm + + mock_exec = MagicMock() + mock_exec.execute_tool_call.return_value = "Created rectangular sketch" + mock_exec_cls.return_value = mock_exec + + service = ChatService() + result = service.process_message("Create a rectangle 50x30") + + assert result["response"] == "Created a 50x30 rectangle on XY plane." + assert len(result["tool_calls"]) == 1 + assert result["tool_calls"][0]["name"] == "create_sketch_rectangle" + + @patch("onshape_chat.api.chat_service.ToolExecutor") + @patch("onshape_chat.api.chat_service.GLMClient") + @patch("onshape_chat.api.chat_service.ContextManager") + def test_get_state(self, mock_ctx_cls, mock_llm_cls, mock_exec_cls): + mock_ctx = MagicMock() + mock_ctx.state = MagicMock() + mock_ctx.state.document_id = "doc-123" + mock_ctx.state.document_name = "My Part" + mock_ctx.state.workspace_id = "ws-456" + mock_ctx.state.part_studio_id = "ps-789" + mock_ctx.state.assembly_id = None + mock_ctx.state.last_sketch_id = "sk-1" + mock_ctx.state.feature_history = [ + {"type": "sketch", "id": "sk-1", "name": "Rectangle"}, + ] + mock_ctx_cls.return_value = mock_ctx + + service = ChatService() + state = service.get_state() + + assert state["document_id"] == "doc-123" + assert state["document_name"] == "My Part" + assert state["feature_count"] == 1 + assert len(state["features"]) == 1 + + @patch("onshape_chat.api.chat_service.ToolExecutor") + @patch("onshape_chat.api.chat_service.GLMClient") + @patch("onshape_chat.api.chat_service.ContextManager") + def test_reset(self, mock_ctx_cls, mock_llm_cls, mock_exec_cls): + mock_ctx = MagicMock() + mock_ctx.state = MagicMock() + mock_ctx.state.feature_history = [] + mock_ctx_cls.return_value = mock_ctx + + service = ChatService() + service.reset() + + mock_ctx.clear.assert_called_once() + + @patch("onshape_chat.api.chat_service.ToolExecutor") + @patch("onshape_chat.api.chat_service.GLMClient") + @patch("onshape_chat.api.chat_service.ContextManager") + def test_tool_error_status(self, mock_ctx_cls, mock_llm_cls, mock_exec_cls, + mock_llm_response_with_tools): + resp1, resp2 = mock_llm_response_with_tools + + mock_ctx = MagicMock() + mock_ctx.state = MagicMock() + mock_ctx.state.document_id = None + mock_ctx.state.document_name = None + mock_ctx.state.workspace_id = None + mock_ctx.state.part_studio_id = None + mock_ctx.state.assembly_id = None + mock_ctx.state.last_sketch_id = None + mock_ctx.state.feature_history = [] + mock_ctx_cls.return_value = mock_ctx + + mock_llm = MagicMock() + mock_llm.chat.side_effect = [resp1, resp2] + mock_llm_cls.return_value = mock_llm + + mock_exec = MagicMock() + mock_exec.execute_tool_call.return_value = "Error: No document created yet." + mock_exec_cls.return_value = mock_exec + + service = ChatService() + result = service.process_message("Create a rectangle") + + assert result["tool_calls"][0]["status"] == "error" + + +# ── FastAPI Endpoint Tests ──────────────────────────── + + +class TestAPIEndpoints: + @patch("onshape_chat.api.server.get_service") + def test_post_chat(self, mock_get_service): + mock_service = MagicMock() + mock_service.process_message.return_value = { + "response": "Created a box.", + "tool_calls": [], + "state": {"document_id": None, "feature_count": 0}, + } + mock_get_service.return_value = mock_service + + client = TestClient(app) + resp = client.post("/api/chat", json={"message": "Create a box"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["response"] == "Created a box." + assert data["tool_calls"] == [] + + @patch("onshape_chat.api.server.get_service") + def test_post_chat_with_tool_calls(self, mock_get_service): + mock_service = MagicMock() + mock_service.process_message.return_value = { + "response": "Done!", + "tool_calls": [{"name": "extrude", "args": {"depth": 10}, + "result": "Extruded", "status": "success"}], + "state": {"document_id": "doc-1", "feature_count": 1}, + } + mock_get_service.return_value = mock_service + + client = TestClient(app) + resp = client.post("/api/chat", json={"message": "Extrude 10mm"}) + + assert resp.status_code == 200 + data = resp.json() + assert len(data["tool_calls"]) == 1 + assert data["tool_calls"][0]["name"] == "extrude" + + @patch("onshape_chat.api.server.get_service") + def test_get_state(self, mock_get_service): + mock_service = MagicMock() + mock_service.get_state.return_value = { + "document_id": "doc-123", + "document_name": "Test", + "workspace_id": "ws-456", + "part_studio_id": None, + "assembly_id": None, + "last_sketch_id": None, + "feature_count": 0, + "features": [], + } + mock_get_service.return_value = mock_service + + client = TestClient(app) + resp = client.get("/api/state") + + assert resp.status_code == 200 + data = resp.json() + assert data["document_id"] == "doc-123" + + @patch("onshape_chat.api.server.get_service") + def test_reset_state(self, mock_get_service): + mock_service = MagicMock() + mock_get_service.return_value = mock_service + + client = TestClient(app) + resp = client.post("/api/state/reset") + + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + mock_service.reset.assert_called_once() + + def test_health_check(self): + client = TestClient(app) + resp = client.get("/api/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "healthy" + assert data["version"] == "0.1.0" + + def test_post_chat_missing_message(self): + client = TestClient(app) + resp = client.post("/api/chat", json={}) + assert resp.status_code == 422 # Validation error + + @patch("onshape_chat.api.server.get_service") + def test_sse_stream(self, mock_get_service): + import json as _json + + mock_service = MagicMock() + + async def fake_stream(msg): + yield {"type": "token", "data": "Hello"} + yield {"type": "done", "data": {"response": "Hello", "state": {}}} + + mock_service.process_message_stream = fake_stream + mock_get_service.return_value = mock_service + + client = TestClient(app) + resp = client.post("/api/chat/stream", json={"message": "Hi"}) + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + lines = [ + line for line in resp.text.strip().split("\n\n") + if line.startswith("data: ") + ] + assert len(lines) == 2 + assert _json.loads(lines[0][6:])["type"] == "token" + assert _json.loads(lines[1][6:])["type"] == "done" + + +# ── Server Module Tests ─────────────────────────────── + + +class TestServerModule: + def test_get_service_creates_instance(self): + reset_service() + with patch("onshape_chat.api.server.ChatService") as mock_cls: + mock_cls.return_value = MagicMock() + service = get_service() + assert service is not None + + def test_get_service_reuses_instance(self): + reset_service() + with patch("onshape_chat.api.server.ChatService") as mock_cls: + mock_instance = MagicMock() + mock_cls.return_value = mock_instance + s1 = get_service() + s2 = get_service() + assert s1 is s2 + mock_cls.assert_called_once() + + def test_reset_service(self): + reset_service() + with patch("onshape_chat.api.server.ChatService") as mock_cls: + mock_cls.return_value = MagicMock() + get_service() + reset_service() + # Next call should create new instance + get_service() + assert mock_cls.call_count == 2 diff --git a/tests/test_assemblies.py b/tests/test_assemblies.py new file mode 100644 index 0000000..07f362a --- /dev/null +++ b/tests/test_assemblies.py @@ -0,0 +1,405 @@ +"""Tests for AssemblyManager.""" + +import pytest + +from onshape_chat.errors import AssemblyError +from onshape_chat.onshape.assemblies import AssemblyManager +from onshape_chat.onshape.client import OnshapeAPIError + + +def test_create_assembly(mock_client): + """Test create_assembly calls correct endpoint and returns result.""" + mock_client.post.return_value = { + "id": "asm-1", + "name": "Test Assembly", + "elementType": "ASSEMBLY", + } + + manager = AssemblyManager(mock_client) + result = manager.create_assembly( + document_id="doc-123", + workspace_id="ws-456", + name="Test Assembly", + ) + + # Verify correct endpoint called + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert call_args[0][0] == "/assemblies/d/doc-123/w/ws-456" + assert call_args[1]["json_body"] == {"name": "Test Assembly"} + + # Verify result + assert result["id"] == "asm-1" + assert result["name"] == "Test Assembly" + + +def test_create_assembly_default_name(mock_client): + """Test create_assembly uses default name when not provided.""" + mock_client.post.return_value = {"id": "asm-1"} + + manager = AssemblyManager(mock_client) + manager.create_assembly( + document_id="doc-123", + workspace_id="ws-456", + ) + + call_args = mock_client.post.call_args + assert call_args[1]["json_body"]["name"] == "Assembly 1" + + +def test_create_assembly_error(mock_client): + """Test create_assembly raises AssemblyError on failure.""" + mock_client.post.side_effect = OnshapeAPIError("API error", 400) + + manager = AssemblyManager(mock_client) + with pytest.raises(AssemblyError) as exc_info: + manager.create_assembly("doc-123", "ws-456") + + assert "Failed to create assembly" in str(exc_info.value) + assert exc_info.value.details["document_id"] == "doc-123" + assert exc_info.value.details["workspace_id"] == "ws-456" + + +def test_insert_part(mock_client): + """Test insert_part calls correct endpoint with identity transform.""" + mock_client.post.return_value = { + "id": "inst-1", + "documentId": "doc-123", + "elementId": "ps-789", + "partId": "part-abc", + } + + manager = AssemblyManager(mock_client) + result = manager.insert_part( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + part_studio_id="ps-789", + part_id="part-abc", + ) + + # Verify endpoint + call_args = mock_client.post.call_args + assert call_args[0][0] == "/assemblies/d/doc-123/w/ws-456/e/asm-1/instances" + + # Verify body structure + body = call_args[1]["json_body"] + assert body["documentId"] == "doc-123" + assert body["elementId"] == "ps-789" + assert body["partId"] == "part-abc" + + # Verify identity transform (4x4 matrix) + expected_transform = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] + assert body["transform"] == expected_transform + + assert result["id"] == "inst-1" + + +def test_insert_part_with_position(mock_client): + """Test insert_part converts mm to meters in transform matrix.""" + mock_client.post.return_value = {"id": "inst-1"} + + manager = AssemblyManager(mock_client) + manager.insert_part( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + part_studio_id="ps-789", + part_id="part-abc", + position={"x": 100.0, "y": 200.0, "z": 300.0}, # mm + ) + + body = mock_client.post.call_args[1]["json_body"] + transform = body["transform"] + + # Check that position is in meters (divided by 1000) + # Transform format: [m00, m01, m02, m03, m10, m11, m12, m13, ...] + # Position is in the 4th, 8th, and 12th elements (0-indexed: 3, 7, 11) + assert transform[3] == 0.1 # 100mm -> 0.1m + assert transform[7] == 0.2 # 200mm -> 0.2m + assert transform[11] == 0.3 # 300mm -> 0.3m + + # Verify rotation part is still identity + assert transform[0] == 1.0 + assert transform[5] == 1.0 + assert transform[10] == 1.0 + + +def test_insert_part_with_partial_position(mock_client): + """Test insert_part handles partial position dict.""" + mock_client.post.return_value = {"id": "inst-1"} + + manager = AssemblyManager(mock_client) + manager.insert_part( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + part_studio_id="ps-789", + part_id="part-abc", + position={"x": 50.0}, # Only x specified + ) + + body = mock_client.post.call_args[1]["json_body"] + transform = body["transform"] + + assert transform[3] == 0.05 # 50mm -> 0.05m + assert transform[7] == 0.0 # y defaults to 0 + assert transform[11] == 0.0 # z defaults to 0 + + +def test_insert_part_error(mock_client): + """Test insert_part raises AssemblyError on failure.""" + mock_client.post.side_effect = OnshapeAPIError("Part not found", 404) + + manager = AssemblyManager(mock_client) + with pytest.raises(AssemblyError) as exc_info: + manager.insert_part( + "doc-123", "ws-456", "asm-1", "ps-789", "part-abc" + ) + + assert "Failed to insert part" in str(exc_info.value) + assert exc_info.value.details["part_id"] == "part-abc" + + +def test_add_mate(mock_client): + """Test add_mate creates correct mate feature structure.""" + mock_client.post.return_value = { + "id": "mate-1", + "type": "FASTENED", + "suppressed": False, + } + + entity1 = { + "occurrence": ["inst-1"], + "entityType": "FACE", + "entityId": "face-1", + } + entity2 = { + "occurrence": ["inst-2"], + "entityType": "FACE", + "entityId": "face-2", + } + + manager = AssemblyManager(mock_client) + result = manager.add_mate( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + entity1=entity1, + entity2=entity2, + mate_type="FASTENED", + ) + + # Verify endpoint + call_args = mock_client.post.call_args + assert call_args[0][0] == "/assemblies/d/doc-123/w/ws-456/e/asm-1/features" + + # Verify body structure + body = call_args[1]["json_body"] + assert body["feature"]["type"] == 134 + assert body["feature"]["typeName"] == "BTMFeature" + + message = body["feature"]["message"] + assert message["featureType"] == "mate" + assert message["name"] == "Fastened Mate" + + # Check parameters + params = message["parameters"] + assert len(params) == 3 # mateType + 2 connectors + + # Check mate type parameter + mate_type_param = params[0] + assert mate_type_param["type"] == 148 + assert mate_type_param["message"]["parameterId"] == "mateType" + assert mate_type_param["message"]["enumName"] == "FASTENED" + + # Check first connector + connector1 = params[1] + assert connector1["message"]["parameterId"] == "mateConnector1" + query1 = connector1["message"]["queries"][0] + assert query1["message"]["occurrence"] == ["inst-1"] + assert query1["message"]["queryType"] == "FACE" + assert query1["message"]["deterministicId"] == "face-1" + + # Check second connector + connector2 = params[2] + assert connector2["message"]["parameterId"] == "mateConnector2" + + assert result["id"] == "mate-1" + + +def test_add_mate_with_offset(mock_client): + """Test add_mate includes offset parameter when non-zero.""" + mock_client.post.return_value = {"id": "mate-1"} + + entity1 = {"occurrence": [], "entityType": "FACE", "entityId": "face-1"} + entity2 = {"occurrence": [], "entityType": "FACE", "entityId": "face-2"} + + manager = AssemblyManager(mock_client) + manager.add_mate( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + entity1=entity1, + entity2=entity2, + mate_type="PLANAR", + offset=10.0, + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["message"]["parameters"] + + # Should have 4 parameters: mateType + 2 connectors + offset + assert len(params) == 4 + + offset_param = params[3] + assert offset_param["type"] == 147 + assert offset_param["message"]["parameterId"] == "offset" + assert offset_param["message"]["expression"] == "10.0" + assert offset_param["message"]["units"] == "millimeter" + + +def test_add_mate_with_flipped(mock_client): + """Test add_mate includes flipped parameter when True.""" + mock_client.post.return_value = {"id": "mate-1"} + + entity1 = {"occurrence": [], "entityType": "FACE", "entityId": "face-1"} + entity2 = {"occurrence": [], "entityType": "FACE", "entityId": "face-2"} + + manager = AssemblyManager(mock_client) + manager.add_mate( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + entity1=entity1, + entity2=entity2, + mate_type="FASTENED", + flipped=True, + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["message"]["parameters"] + + # Should have 4 parameters: mateType + 2 connectors + flipped + assert len(params) == 4 + + flipped_param = params[3] + assert flipped_param["type"] == 144 + assert flipped_param["message"]["parameterId"] == "flipped" + assert flipped_param["message"]["value"] is True + + +def test_add_mate_error(mock_client): + """Test add_mate raises AssemblyError on failure.""" + mock_client.post.side_effect = OnshapeAPIError("Invalid mate", 400) + + entity1 = {"occurrence": [], "entityType": "FACE", "entityId": "face-1"} + entity2 = {"occurrence": [], "entityType": "FACE", "entityId": "face-2"} + + manager = AssemblyManager(mock_client) + with pytest.raises(AssemblyError) as exc_info: + manager.add_mate( + "doc-123", "ws-456", "asm-1", entity1, entity2, "FASTENED" + ) + + assert "Failed to create mate" in str(exc_info.value) + assert exc_info.value.details["mate_type"] == "FASTENED" + + +def test_get_assembly_definition(mock_client): + """Test get_assembly_definition calls correct GET endpoint.""" + mock_client.get.return_value = { + "rootAssembly": {"id": "asm-1"}, + "subAssemblies": [], + "parts": [], + "instances": [{"id": "inst-1"}], + "features": [], + } + + manager = AssemblyManager(mock_client) + result = manager.get_assembly_definition( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + ) + + # Verify endpoint + mock_client.get.assert_called_once_with( + "/assemblies/d/doc-123/w/ws-456/e/asm-1" + ) + + assert result["rootAssembly"]["id"] == "asm-1" + assert len(result["instances"]) == 1 + + +def test_get_assembly_definition_error(mock_client): + """Test get_assembly_definition raises AssemblyError on failure.""" + mock_client.get.side_effect = OnshapeAPIError("Not found", 404) + + manager = AssemblyManager(mock_client) + with pytest.raises(AssemblyError) as exc_info: + manager.get_assembly_definition("doc-123", "ws-456", "asm-1") + + assert "Failed to get assembly definition" in str(exc_info.value) + + +def test_get_instances(mock_client): + """Test get_instances parses instances from definition.""" + mock_client.get.return_value = { + "instances": [ + { + "id": "inst-1", + "name": "Part 1", + "type": "Part", + "documentId": "doc-123", + "elementId": "ps-789", + "partId": "part-abc", + "transform": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + "suppressed": False, + }, + { + "id": "inst-2", + "name": "Part 2", + "type": "Part", + "documentId": "doc-123", + "elementId": "ps-789", + "partId": "part-def", + "transform": [1, 0, 0, 0.1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + "suppressed": False, + }, + ] + } + + manager = AssemblyManager(mock_client) + instances = manager.get_instances("doc-123", "ws-456", "asm-1") + + assert len(instances) == 2 + assert instances[0]["id"] == "inst-1" + assert instances[0]["name"] == "Part 1" + assert instances[1]["id"] == "inst-2" + + +def test_get_instances_empty(mock_client): + """Test get_instances returns empty list when no instances.""" + mock_client.get.return_value = {"instances": []} + + manager = AssemblyManager(mock_client) + instances = manager.get_instances("doc-123", "ws-456", "asm-1") + + assert instances == [] + + +def test_get_instances_error(mock_client): + """Test get_instances raises AssemblyError on failure.""" + mock_client.get.side_effect = OnshapeAPIError("Not found", 404) + + manager = AssemblyManager(mock_client) + with pytest.raises(AssemblyError) as exc_info: + manager.get_instances("doc-123", "ws-456", "asm-1") + + assert "Failed to get assembly instances" in str(exc_info.value) diff --git a/tests/test_auth.py b/tests/test_auth.py index 5555b43..a237b98 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,7 +9,8 @@ def test_generate_nonce(): nonce = generate_nonce() assert isinstance(nonce, str) - assert len(nonce) > 0 + assert len(nonce) == 25 + assert nonce.isalnum() def test_format_date(): @@ -53,5 +54,5 @@ def test_sign_request(): assert "Content-Type" in headers # Check Authorization header format - assert headers["Authorization"].startswith("Onshape test_access:") + assert headers["Authorization"].startswith("On test_access:HmacSHA256:") assert "GMT" in headers["Date"] diff --git a/tests/test_camera_views.py b/tests/test_camera_views.py new file mode 100644 index 0000000..e7acc4f --- /dev/null +++ b/tests/test_camera_views.py @@ -0,0 +1,241 @@ +"""Tests for camera view definitions and multi-angle capture.""" + +import math +from unittest.mock import MagicMock + +from onshape_chat.verification.camera_views import ( + CAMERA_VIEWS, + ISOMETRIC_VIEWS, + ORTHOGRAPHIC_VIEWS, + _look_at_matrix, + capture_all_views, +) + + +class TestCameraViewConstants: + """Tests for the 14 camera view definitions.""" + + def test_total_view_count(self): + """All 14 views are defined.""" + assert len(CAMERA_VIEWS) == 14 + + def test_orthographic_count(self): + """6 orthographic views defined.""" + assert len(ORTHOGRAPHIC_VIEWS) == 6 + + def test_isometric_count(self): + """8 isometric views defined.""" + assert len(ISOMETRIC_VIEWS) == 8 + + def test_orthographic_views_are_named_strings(self): + """Orthographic views use named string matrices.""" + expected_names = {"front", "back", "top", "bottom", "left", "right"} + actual = {v["view_matrix"] for v in ORTHOGRAPHIC_VIEWS} + assert actual == expected_names + + def test_isometric_views_are_12_float_lists(self): + """Isometric views use 12-element float lists.""" + for view in ISOMETRIC_VIEWS: + matrix = view["view_matrix"] + assert isinstance(matrix, list), f"{view['name']} matrix is not a list" + assert len(matrix) == 12, f"{view['name']} matrix has {len(matrix)} elements, expected 12" + assert all(isinstance(v, float) for v in matrix), f"{view['name']} has non-float elements" + + def test_all_views_have_unique_names(self): + """All 14 views have unique names.""" + names = [v["name"] for v in CAMERA_VIEWS] + assert len(names) == len(set(names)) + + def test_all_views_have_name_and_matrix(self): + """Every view has both 'name' and 'view_matrix' keys.""" + for view in CAMERA_VIEWS: + assert "name" in view + assert "view_matrix" in view + + +class TestViewMatrixMath: + """Tests for the look-at matrix computation.""" + + def test_matrix_has_12_elements(self): + """look_at_matrix returns exactly 12 floats.""" + matrix = _look_at_matrix(1.0, 1.0, 1.0) + assert len(matrix) == 12 + + def test_matrix_rows_are_orthonormal(self): + """Each 3x4 matrix has orthonormal row vectors (ignoring translation col).""" + for view in ISOMETRIC_VIEWS: + m = view["view_matrix"] + # Extract 3x3 rotation part (rows are 4 elements each: 3 rotation + 1 translation) + vx = (m[0], m[1], m[2]) + vy = (m[4], m[5], m[6]) + vz = (m[8], m[9], m[10]) + + # Check unit length + for label, v in [("vx", vx), ("vy", vy), ("vz", vz)]: + length = math.sqrt(sum(c**2 for c in v)) + assert abs(length - 1.0) < 1e-10, f"{view['name']} {label} length={length}" + + # Check orthogonality (dot products ≈ 0) + dot_xy = sum(a * b for a, b in zip(vx, vy)) + dot_xz = sum(a * b for a, b in zip(vx, vz)) + dot_yz = sum(a * b for a, b in zip(vy, vz)) + + assert abs(dot_xy) < 1e-10, f"{view['name']} vx·vy={dot_xy}" + assert abs(dot_xz) < 1e-10, f"{view['name']} vx·vz={dot_xz}" + assert abs(dot_yz) < 1e-10, f"{view['name']} vy·vz={dot_yz}" + + def test_matrix_positive_determinant(self): + """Rotation matrices have determinant +1 (right-handed).""" + for view in ISOMETRIC_VIEWS: + m = view["view_matrix"] + # Extract 3x3 rotation (skip translation at indices 3, 7, 11) + r = [m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]] + det = ( + r[0] * (r[4] * r[8] - r[5] * r[7]) + - r[1] * (r[3] * r[8] - r[5] * r[6]) + + r[2] * (r[3] * r[7] - r[4] * r[6]) + ) + assert abs(det - 1.0) < 1e-10, f"{view['name']} determinant={det}" + + def test_translation_columns_are_zero(self): + """Translation column (indices 3, 7, 11) should be 0.0.""" + for view in ISOMETRIC_VIEWS: + m = view["view_matrix"] + assert m[3] == 0.0, f"{view['name']} tx={m[3]}" + assert m[7] == 0.0, f"{view['name']} ty={m[7]}" + assert m[11] == 0.0, f"{view['name']} tz={m[11]}" + + def test_z_axis_fallback_for_vertical_eye(self): + """Looking straight down/up uses Y-axis fallback for world up.""" + # Looking straight down (eye at 0, 0, 1) — Z-aligned + matrix = _look_at_matrix(0.0, 0.0, 1.0) + assert len(matrix) == 12 + # Should still produce orthonormal result + vx = (matrix[0], matrix[1], matrix[2]) + length = math.sqrt(sum(c**2 for c in vx)) + assert abs(length - 1.0) < 1e-10 + + +class TestCaptureAllViews: + """Tests for the capture_all_views function.""" + + def test_captures_14_views(self): + """capture_all_views returns exactly 14 (label, bytes) tuples.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + b"\x00" * 100 + + results = capture_all_views( + exports=mock_exports, + document_id="doc-1", + workspace_id="ws-1", + element_id="ps-1", + ) + + assert len(results) == 14 + assert mock_exports.get_shaded_view.call_count == 14 + + def test_returns_label_and_bytes_tuples(self): + """Each result is a (str, bytes) tuple.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + b"\x00" * 50 + + results = capture_all_views( + exports=mock_exports, + document_id="doc-1", + workspace_id="ws-1", + element_id="ps-1", + ) + + for label, data in results: + assert isinstance(label, str) + assert isinstance(data, bytes) + + def test_uses_specified_dimensions(self): + """capture_all_views passes width/height to get_shaded_view.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + + capture_all_views( + exports=mock_exports, + document_id="d", + workspace_id="w", + element_id="e", + width=200, + height=200, + ) + + # Check first call's kwargs + call_kwargs = mock_exports.get_shaded_view.call_args_list[0][1] + assert call_kwargs["width"] == 200 + assert call_kwargs["height"] == 200 + + def test_passes_pixel_size_zero(self): + """capture_all_views sets pixel_size=0.0 for auto-fit.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + + capture_all_views( + exports=mock_exports, + document_id="d", + workspace_id="w", + element_id="e", + ) + + call_kwargs = mock_exports.get_shaded_view.call_args_list[0][1] + assert call_kwargs["pixel_size"] == 0.0 + + def test_skips_failed_views(self): + """Views that raise exceptions are skipped, not fatal.""" + mock_exports = MagicMock() + call_count = 0 + + def side_effect(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 3: + raise RuntimeError("Simulated failure") + return b"\x89PNG" + + mock_exports.get_shaded_view.side_effect = side_effect + + results = capture_all_views( + exports=mock_exports, + document_id="d", + workspace_id="w", + element_id="e", + ) + + assert len(results) == 13 # 14 - 1 failed + + def test_view_matrix_passed_correctly_for_named_strings(self): + """Orthographic views pass named strings as view_matrix.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + + capture_all_views( + exports=mock_exports, + document_id="d", + workspace_id="w", + element_id="e", + ) + + # First call should be "front" + first_call = mock_exports.get_shaded_view.call_args_list[0][1] + assert first_call["view_matrix"] == "front" + + def test_view_matrix_passed_correctly_for_iso_lists(self): + """Isometric views pass 12-float lists as view_matrix.""" + mock_exports = MagicMock() + mock_exports.get_shaded_view.return_value = b"\x89PNG" + + capture_all_views( + exports=mock_exports, + document_id="d", + workspace_id="w", + element_id="e", + ) + + # 7th call (index 6) is the first isometric view + iso_call = mock_exports.get_shaded_view.call_args_list[6][1] + assert isinstance(iso_call["view_matrix"], list) + assert len(iso_call["view_matrix"]) == 12 diff --git a/tests/test_config.py b/tests/test_config.py index dc2a445..2c67e07 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,9 +11,10 @@ def test_settings_missing_key(): """Test that settings validation fails with missing API key.""" with patch.dict(os.environ, {}, clear=True): with pytest.raises(ValidationError): - from onshape_chat.config import get_settings + from onshape_chat.config import Settings - get_settings() + # _env_file=None prevents reading .env from disk + Settings(_env_file=None) def test_settings_with_valid_env(): @@ -41,5 +42,5 @@ def test_settings_with_valid_env(): ) assert settings.glm_api_key == "test_glm_key" - assert settings.glm_model == "glm-4-plus" # default value + assert settings.glm_model == "glm-4.7" # default value assert settings.onshape_base_url == "https://cad.onshape.com/api/v6" # default diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..35d44ef --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,361 @@ +"""Tests for ContextManager.""" + +from unittest.mock import patch + +from onshape_chat.llm.context import ContextManager +from onshape_chat.llm.conversation import ConversationState + + +def test_add_message(): + """Test add_message adds messages to history.""" + ctx = ContextManager() + + ctx.add_message("user", "Hello") + ctx.add_message("assistant", "Hi there") + + assert len(ctx.message_history) == 2 + assert ctx.message_history[0]["role"] == "user" + assert ctx.message_history[0]["content"] == "Hello" + assert ctx.message_history[1]["role"] == "assistant" + assert ctx.message_history[1]["content"] == "Hi there" + + +def test_add_message_with_metadata(): + """Test add_message stores metadata.""" + ctx = ContextManager() + + ctx.add_message("user", "Create a box", metadata={"intent": "create"}) + + assert ctx.message_history[0]["metadata"] == {"intent": "create"} + + +def test_add_message_without_metadata(): + """Test add_message creates empty metadata dict when not provided.""" + ctx = ContextManager() + + ctx.add_message("user", "Hello") + + assert ctx.message_history[0]["metadata"] == {} + + +def test_get_messages_for_llm_system_prompt(): + """Test get_messages_for_llm includes system prompt.""" + ctx = ContextManager() + + messages = ctx.get_messages_for_llm() + + # First message should be system prompt + assert messages[0]["role"] == "system" + assert "content" in messages[0] + + +def test_get_messages_for_llm_rag_context(): + """Test get_messages_for_llm includes RAG context.""" + ctx = ContextManager() + + # Mock RAG to return some context + with patch.object(ctx.rag, "get_context", return_value="RAG documentation here"): + messages = ctx.get_messages_for_llm() + + # Should have system message with RAG context + rag_messages = [m for m in messages if "Reference Documentation" in m.get("content", "")] + assert len(rag_messages) == 1 + assert "RAG documentation here" in rag_messages[0]["content"] + + +def test_get_messages_for_llm_no_rag_context(): + """Test get_messages_for_llm handles empty RAG context.""" + ctx = ContextManager() + + with patch.object(ctx.rag, "get_context", return_value=""): + messages = ctx.get_messages_for_llm() + + # Should not have RAG context message + rag_messages = [m for m in messages if "Reference Documentation" in m.get("content", "")] + assert len(rag_messages) == 0 + + +def test_get_messages_for_llm_state_summary(): + """Test get_messages_for_llm includes state summary.""" + ctx = ContextManager() + ctx.state.document_name = "Test Doc" + ctx.state.part_studio_id = "ps-123" + + messages = ctx.get_messages_for_llm() + + # Should have system message with state + state_messages = [m for m in messages if m["role"] == "system" and "Current State" in m["content"]] + assert len(state_messages) == 1 + assert "Test Doc" in state_messages[0]["content"] + + +def test_get_messages_for_llm_conversation_history(): + """Test get_messages_for_llm includes conversation history.""" + ctx = ContextManager() + + ctx.add_message("user", "Create a document") + ctx.add_message("assistant", "Document created") + + messages = ctx.get_messages_for_llm() + + # Filter out system messages + conversation = [m for m in messages if m["role"] in ["user", "assistant"]] + assert len(conversation) == 2 + assert conversation[0]["content"] == "Create a document" + assert conversation[1]["content"] == "Document created" + + +def test_get_messages_for_llm_strips_metadata(): + """Test get_messages_for_llm strips internal metadata.""" + ctx = ContextManager() + + ctx.add_message("user", "Hello", metadata={"internal": "data"}) + + messages = ctx.get_messages_for_llm() + + user_msg = [m for m in messages if m["role"] == "user"][0] + assert "metadata" not in user_msg + assert user_msg["role"] == "user" + assert user_msg["content"] == "Hello" + + +def test_get_messages_for_llm_preserves_tool_calls(): + """Test get_messages_for_llm preserves tool_calls field.""" + ctx = ContextManager() + + tool_calls = [{"id": "call-1", "function": {"name": "create_document"}}] + ctx.message_history.append({ + "role": "assistant", + "content": "", + "tool_calls": tool_calls, + "metadata": {}, + }) + + messages = ctx.get_messages_for_llm() + + assistant_msgs = [m for m in messages if m["role"] == "assistant"] + assert len(assistant_msgs) == 1 + assert "tool_calls" in assistant_msgs[0] + assert assistant_msgs[0]["tool_calls"] == tool_calls + + +def test_get_messages_for_llm_preserves_tool_results(): + """Test get_messages_for_llm preserves tool result fields.""" + ctx = ContextManager() + + ctx.message_history.append({ + "role": "tool", + "content": "Result content", + "tool_call_id": "call-1", + "name": "create_document", + "metadata": {}, + }) + + messages = ctx.get_messages_for_llm() + + tool_msgs = [m for m in messages if m["role"] == "tool"] + assert len(tool_msgs) == 1 + assert tool_msgs[0]["tool_call_id"] == "call-1" + assert tool_msgs[0]["name"] == "create_document" + assert tool_msgs[0]["content"] == "Result content" + + +def test_compress_history_triggers_at_max(): + """Test _compress_history is called when history exceeds max.""" + ctx = ContextManager(max_history=50) + + # Add 51 messages to exceed max + for i in range(51): + ctx.add_message("user", f"Message {i}") + + # History should be compressed + # Should have summary (1) + recent 20 messages = 21 total + assert len(ctx.message_history) == 21 + assert ctx.message_history[0]["metadata"].get("is_summary") is True + + +def test_compress_history_keeps_recent(): + """Test _compress_history keeps recent 20 messages.""" + ctx = ContextManager(max_history=50) + + # Add 60 messages + for i in range(60): + ctx.add_message("user", f"Message {i}") + + # Force compression + ctx._compress_history() + + # Should have 1 summary + 20 recent = 21 total + assert len(ctx.message_history) == 21 + + # First message should be summary + assert ctx.message_history[0]["metadata"].get("is_summary") is True + + # Last message should be the most recent + assert "Message 59" in ctx.message_history[-1]["content"] + + +def test_compress_history_creates_summary(): + """Test _compress_history creates meaningful summary.""" + ctx = ContextManager(max_history=50) + + # Add various message types + ctx.add_message("user", "Create a document") + ctx.add_message("assistant", "Creating...", metadata={"has_tool_calls": True}) + ctx.message_history.append({ + "role": "tool", + "content": "Document created successfully", + "name": "create_document", + "metadata": {}, + }) + + # Keep last 20 messages (we have 3, so all will be in "older") + # Force all into older by setting keep_recent to 0 + older = ctx.message_history[:] + summary = ctx._create_summary(older) + + assert "Earlier in this conversation" in summary + assert "User:" in summary + assert "Tool call:" in summary + assert "Tool result (create_document):" in summary + + +def test_create_summary_with_no_messages(): + """Test _create_summary handles empty message list.""" + ctx = ContextManager() + + summary = ctx._create_summary([]) + + assert "No significant actions" in summary + + +def test_add_tool_call(): + """Test add_tool_call stores tool calls correctly.""" + ctx = ContextManager() + + tool_calls = [ + {"id": "call-1", "function": {"name": "create_document", "arguments": "{}"}} + ] + tool_results = [ + { + "role": "tool", + "content": "Document created", + "tool_call_id": "call-1", + "name": "create_document", + } + ] + + ctx.add_tool_call( + assistant_content="Creating document...", + tool_calls=tool_calls, + tool_results=tool_results, + ) + + # Should have 2 messages: assistant + tool + assert len(ctx.message_history) == 2 + + # Check assistant message + assert ctx.message_history[0]["role"] == "assistant" + assert ctx.message_history[0]["content"] == "Creating document..." + assert ctx.message_history[0]["tool_calls"] == tool_calls + assert ctx.message_history[0]["metadata"]["has_tool_calls"] is True + + # Check tool result + assert ctx.message_history[1]["role"] == "tool" + assert ctx.message_history[1]["content"] == "Document created" + + +def test_add_tool_call_with_none_content(): + """Test add_tool_call handles None assistant content.""" + ctx = ContextManager() + + tool_calls = [{"id": "call-1"}] + tool_results = [{"role": "tool", "content": "Result"}] + + ctx.add_tool_call( + assistant_content=None, + tool_calls=tool_calls, + tool_results=tool_results, + ) + + assert ctx.message_history[0]["content"] == "" + + +def test_add_tool_call_multiple_results(): + """Test add_tool_call handles multiple tool results.""" + ctx = ContextManager() + + tool_calls = [ + {"id": "call-1", "function": {"name": "tool1"}}, + {"id": "call-2", "function": {"name": "tool2"}}, + ] + tool_results = [ + {"role": "tool", "content": "Result 1", "tool_call_id": "call-1"}, + {"role": "tool", "content": "Result 2", "tool_call_id": "call-2"}, + ] + + ctx.add_tool_call( + assistant_content="Calling tools...", + tool_calls=tool_calls, + tool_results=tool_results, + ) + + # Should have 3 messages: assistant + 2 tool results + assert len(ctx.message_history) == 3 + + +def test_clear(): + """Test clear resets history and state.""" + ctx = ContextManager() + + # Add some data + ctx.add_message("user", "Hello") + ctx.state.document_id = "doc-123" + ctx.state.document_name = "Test Doc" + + # Clear + ctx.clear() + + # Verify reset + assert len(ctx.message_history) == 0 + assert ctx.state.document_id is None + assert ctx.state.document_name is None + assert isinstance(ctx.state, ConversationState) + + +def test_clear_creates_new_state(): + """Test clear creates a fresh ConversationState instance.""" + ctx = ContextManager() + + old_state = ctx.state + ctx.state.document_id = "doc-123" + + ctx.clear() + + # Should be a different instance + assert ctx.state is not old_state + assert ctx.state.document_id is None + + +def test_context_manager_default_max_history(): + """Test ContextManager uses default max_history of 50.""" + ctx = ContextManager() + + assert ctx.max_history == 50 + + +def test_context_manager_custom_max_history(): + """Test ContextManager accepts custom max_history.""" + ctx = ContextManager(max_history=100) + + assert ctx.max_history == 100 + + +def test_rag_context_max_tokens(): + """Test get_messages_for_llm passes max_tokens to RAG.""" + ctx = ContextManager() + + with patch.object(ctx.rag, "get_context", return_value="context") as mock_get: + ctx.get_messages_for_llm() + + mock_get.assert_called_once_with(max_tokens=3000) diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..376772c --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,270 @@ +"""Tests for TerminalImageDisplay.""" + +import os +from unittest.mock import MagicMock, patch + +from onshape_chat.ui.display import TerminalImageDisplay, save_temp_image + + +def test_detect_protocol_kitty(): + """Test _detect_protocol returns 'kitty' when TERM_PROGRAM=kitty.""" + with patch.dict(os.environ, {"TERM_PROGRAM": "kitty"}): + display = TerminalImageDisplay() + assert display.protocol == "kitty" + + +def test_detect_protocol_kitty_case_insensitive(): + """Test _detect_protocol handles KITTY in any case.""" + with patch.dict(os.environ, {"TERM_PROGRAM": "KITTY"}): + display = TerminalImageDisplay() + assert display.protocol == "kitty" + + +def test_detect_protocol_sixel(): + """Test _detect_protocol returns 'sixel' when xterm and libsixel available.""" + with patch.dict(os.environ, {"TERM": "xterm-256color", "TERM_PROGRAM": ""}): + with patch("importlib.util.find_spec", return_value=MagicMock()): + display = TerminalImageDisplay() + assert display.protocol == "sixel" + + +def test_detect_protocol_none_no_special_term(): + """Test _detect_protocol returns 'none' when no special terminal.""" + with patch.dict(os.environ, {"TERM": "vt100", "TERM_PROGRAM": ""}, clear=True): + display = TerminalImageDisplay() + assert display.protocol == "none" + + +def test_detect_protocol_none_xterm_no_sixel(): + """Test _detect_protocol returns 'none' when xterm but no libsixel.""" + with patch.dict(os.environ, {"TERM": "xterm", "TERM_PROGRAM": ""}): + # Ensure libsixel import fails + import sys + with patch.dict(sys.modules, {"libsixel": None}): + display = TerminalImageDisplay() + assert display.protocol == "none" + + +def test_save_temp_image(tmp_path): + """Test save_temp_image writes data and returns valid path.""" + data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + # Patch tempfile to use tmp_path + with patch("tempfile.mkstemp") as mock_mkstemp: + temp_file = tmp_path / "test_image.png" + fd = os.open(temp_file, os.O_CREAT | os.O_WRONLY) + mock_mkstemp.return_value = (fd, str(temp_file)) + + result = save_temp_image(data) + + # Verify file was written + assert temp_file.exists() + assert temp_file.read_bytes() == data + assert result == str(temp_file) + + +def test_save_temp_image_custom_suffix(tmp_path): + """Test save_temp_image accepts custom suffix.""" + data = b"Test data" + + with patch("tempfile.mkstemp") as mock_mkstemp: + temp_file = tmp_path / "test.jpg" + fd = os.open(temp_file, os.O_CREAT | os.O_WRONLY) + mock_mkstemp.return_value = (fd, str(temp_file)) + + result = save_temp_image(data, suffix=".jpg") + + assert result.endswith(".jpg") + + +def test_display_image_kitty(): + """Test display_image uses Kitty protocol when available.""" + with patch.dict(os.environ, {"TERM_PROGRAM": "kitty"}): + display = TerminalImageDisplay() + + with patch.object(display, "_display_kitty", return_value=True) as mock_kitty: + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + result = display.display_image(image_data) + + assert result is True + mock_kitty.assert_called_once_with(image_data) + + +def test_display_image_sixel(): + """Test display_image uses Sixel protocol when available.""" + with patch.dict(os.environ, {"TERM": "xterm", "TERM_PROGRAM": ""}): + with patch("importlib.util.find_spec", return_value=MagicMock()): + display = TerminalImageDisplay() + + with patch.object(display, "_display_sixel", return_value=True) as mock_sixel: + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + result = display.display_image(image_data, width=80) + + assert result is True + mock_sixel.assert_called_once_with(image_data, 80) + + +def test_display_image_fallback(): + """Test display_image uses fallback when no protocol available.""" + with patch.dict(os.environ, {"TERM": "vt100", "TERM_PROGRAM": ""}, clear=True): + display = TerminalImageDisplay() + + with patch.object(display, "_display_fallback", return_value=True) as mock_fallback: + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + result = display.display_image(image_data) + + assert result is True + mock_fallback.assert_called_once_with(image_data) + + +def test_display_kitty_basic(): + """Test _display_kitty sends correct escape sequences.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + with patch("sys.stdout") as mock_stdout: + result = display._display_kitty(image_data) + + assert result is True + # Verify stdout.write was called + assert mock_stdout.write.called + + +def test_display_kitty_chunking(): + """Test _display_kitty splits large images into chunks.""" + display = TerminalImageDisplay() + # Create data that will result in >4096 base64 chars + image_data = b"\x00" * 5000 + + with patch("sys.stdout") as mock_stdout: + result = display._display_kitty(image_data) + + assert result is True + # Should have multiple write calls for chunks + assert mock_stdout.write.call_count > 1 + + +def test_display_kitty_error_handling(): + """Test _display_kitty returns False on error.""" + display = TerminalImageDisplay() + + with patch("sys.stdout") as mock_stdout: + mock_stdout.write.side_effect = Exception("Write failed") + + result = display._display_kitty(b"data") + + assert result is False + + +def test_display_sixel_not_available(): + """Test _display_sixel returns False when libsixel not available.""" + display = TerminalImageDisplay() + + # Ensure libsixel import fails + with patch.dict("sys.modules", {"libsixel": None}): + result = display._display_sixel(b"data", 80) + + assert result is False + + +def test_display_fallback_macos(tmp_path): + """Test _display_fallback uses 'open' command on macOS.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + with patch("sys.platform", "darwin"): + with patch("subprocess.run") as mock_run: + with patch("onshape_chat.ui.display.save_temp_image") as mock_save: + temp_file = tmp_path / "test.png" + mock_save.return_value = str(temp_file) + + result = display._display_fallback(image_data) + + assert result is True + mock_run.assert_called_once() + # Verify 'open' command was used + assert mock_run.call_args[0][0] == ["open", str(temp_file)] + + +def test_display_fallback_linux(tmp_path): + """Test _display_fallback uses 'xdg-open' command on Linux.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + with patch("sys.platform", "linux"): + with patch("subprocess.run") as mock_run: + with patch("onshape_chat.ui.display.save_temp_image") as mock_save: + temp_file = tmp_path / "test.png" + mock_save.return_value = str(temp_file) + + result = display._display_fallback(image_data) + + assert result is True + mock_run.assert_called_once() + assert mock_run.call_args[0][0] == ["xdg-open", str(temp_file)] + + +def test_display_fallback_other_platform(tmp_path): + """Test _display_fallback uses webbrowser on other platforms.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + with patch("sys.platform", "win32"): + with patch("webbrowser.open") as mock_browser: + with patch("onshape_chat.ui.display.save_temp_image") as mock_save: + temp_file = tmp_path / "test.png" + mock_save.return_value = str(temp_file) + + result = display._display_fallback(image_data) + + assert result is True + mock_browser.assert_called_once() + assert str(temp_file) in mock_browser.call_args[0][0] + + +def test_display_fallback_with_existing_filepath(tmp_path): + """Test _display_fallback uses provided filepath instead of creating temp.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + existing_file = tmp_path / "existing.png" + existing_file.write_bytes(image_data) + + with patch("sys.platform", "darwin"): + with patch("subprocess.run") as mock_run: + with patch("onshape_chat.ui.display.save_temp_image") as mock_save: + result = display._display_fallback(image_data, filepath=str(existing_file)) + + assert result is True + # save_temp_image should NOT be called + mock_save.assert_not_called() + # Should use the provided filepath + assert mock_run.call_args[0][0] == ["open", str(existing_file)] + + +def test_display_fallback_error_handling(): + """Test _display_fallback returns False on error.""" + display = TerminalImageDisplay() + + with patch("sys.platform", "darwin"): + with patch("subprocess.run", side_effect=Exception("Command failed")): + with patch("onshape_chat.ui.display.save_temp_image", return_value="/tmp/test.png"): + result = display._display_fallback(b"data") + + assert result is False + + +def test_display_fallback_prints_message(tmp_path, capsys): + """Test _display_fallback prints message to user.""" + display = TerminalImageDisplay() + image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + with patch("sys.platform", "darwin"): + with patch("subprocess.run"): + with patch("onshape_chat.ui.display.save_temp_image") as mock_save: + temp_file = tmp_path / "test.png" + mock_save.return_value = str(temp_file) + + display._display_fallback(image_data) + + captured = capsys.readouterr() + assert "Opening preview" in captured.out diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..ff4b052 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,275 @@ +"""Tests for error hierarchy and ErrorHandler.""" + + +from onshape_chat.errors import ( + AssemblyError, + AuthenticationError, + ErrorHandler, + ExportError, + FeatureError, + GeometryError, + OnshapeError, + RateLimitError, + UserInputError, +) + + +def test_onshape_error_basic(): + """Test OnshapeError can be instantiated with message.""" + error = OnshapeError("Test error") + assert str(error) == "Test error" + assert error.details == {} + + +def test_onshape_error_with_details(): + """Test OnshapeError stores details dict.""" + details = {"document_id": "doc-123", "status": 404} + error = OnshapeError("Not found", details=details) + assert str(error) == "Not found" + assert error.details == details + + +def test_authentication_error(): + """Test AuthenticationError can be instantiated.""" + error = AuthenticationError("Invalid credentials") + assert isinstance(error, OnshapeError) + assert str(error) == "Invalid credentials" + + +def test_rate_limit_error(): + """Test RateLimitError stores retry_after.""" + error = RateLimitError("Too many requests", retry_after=60.0) + assert isinstance(error, OnshapeError) + assert error.retry_after == 60.0 + + +def test_rate_limit_error_without_retry(): + """Test RateLimitError works without retry_after.""" + error = RateLimitError("Too many requests") + assert error.retry_after is None + + +def test_feature_error(): + """Test FeatureError can be instantiated.""" + error = FeatureError("Extrude failed", details={"depth": -5}) + assert isinstance(error, OnshapeError) + assert error.details["depth"] == -5 + + +def test_geometry_error(): + """Test GeometryError can be instantiated.""" + error = GeometryError("Self-intersecting geometry") + assert isinstance(error, OnshapeError) + + +def test_user_input_error(): + """Test UserInputError can be instantiated.""" + error = UserInputError("Invalid dimension") + assert isinstance(error, OnshapeError) + + +def test_export_error(): + """Test ExportError can be instantiated.""" + error = ExportError("STL export failed") + assert isinstance(error, OnshapeError) + + +def test_assembly_error(): + """Test AssemblyError can be instantiated.""" + error = AssemblyError("Mate creation failed") + assert isinstance(error, OnshapeError) + + +def test_handler_authentication_error(): + """Test ErrorHandler returns correct message for AuthenticationError.""" + handler = ErrorHandler() + error = AuthenticationError("Invalid API key") + result = handler.handle(error) + + assert "Authentication failed" in result + assert "onshape-chat setup" in result + + +def test_handler_rate_limit_error_with_retry(): + """Test ErrorHandler returns correct message for RateLimitError with retry_after.""" + handler = ErrorHandler() + error = RateLimitError("Rate limit exceeded", retry_after=120.0) + result = handler.handle(error) + + assert "rate limit" in result + assert "120" in result + + +def test_handler_rate_limit_error_without_retry(): + """Test ErrorHandler uses default wait time when retry_after is None.""" + handler = ErrorHandler() + error = RateLimitError("Rate limit exceeded") + result = handler.handle(error) + + assert "rate limit" in result + assert "60" in result # Default wait time + + +def test_handler_feature_error_pattern_matching(): + """Test ErrorHandler matches patterns in FeatureError messages.""" + handler = ErrorHandler() + + # Test "sketches do not intersect" pattern + error = FeatureError("The sketches do not intersect") + result = handler.handle(error) + assert "doesn't intersect" in result + assert "Check sketch position" in result + + # Test "invalid extrude depth" pattern + error = FeatureError("invalid extrude depth provided") + result = handler.handle(error) + assert "depth must be positive" in result + + # Test "no valid entities" pattern + error = FeatureError("no valid entities found") + result = handler.handle(error) + assert "No valid geometry" in result + + # Test "self-intersecting" pattern + error = FeatureError("self-intersecting geometry detected") + result = handler.handle(error) + assert "self-intersecting" in result + assert "Simplify" in result + + # Test "open profile" pattern + error = FeatureError("open profile cannot be extruded") + result = handler.handle(error) + assert "not closed" in result + + +def test_handler_feature_error_no_pattern_match(): + """Test ErrorHandler returns generic message when no pattern matches.""" + handler = ErrorHandler() + error = FeatureError("Some unknown feature error") + result = handler.handle(error) + + assert "Feature operation failed" in result + assert "unknown feature error" in result + + +def test_handler_geometry_error(): + """Test ErrorHandler returns helpful message for GeometryError.""" + handler = ErrorHandler() + error = GeometryError("Invalid geometry") + result = handler.handle(error) + + assert "Invalid geometry" in result + assert "not closed" in result + assert "Self-intersecting" in result + assert "Invalid dimensions" in result + + +def test_handler_user_input_error(): + """Test ErrorHandler returns message for UserInputError.""" + handler = ErrorHandler() + error = UserInputError("Width must be positive") + result = handler.handle(error) + + assert "Invalid input" in result + assert "Width must be positive" in result + + +def test_handler_export_error(): + """Test ErrorHandler returns message for ExportError.""" + handler = ErrorHandler() + error = ExportError("Export failed") + result = handler.handle(error) + + assert "Export failed" in result + + +def test_handler_assembly_error(): + """Test ErrorHandler returns message for AssemblyError.""" + handler = ErrorHandler() + error = AssemblyError("Mate failed") + result = handler.handle(error) + + assert "Assembly operation failed" in result + assert "Mate failed" in result + + +def test_handler_generic_onshape_error(): + """Test ErrorHandler returns generic message for base OnshapeError.""" + handler = ErrorHandler() + error = OnshapeError("Generic error") + result = handler.handle(error) + + assert "Onshape error" in result + assert "Generic error" in result + + +def test_handler_unexpected_error(): + """Test ErrorHandler handles non-OnshapeError exceptions.""" + handler = ErrorHandler() + error = ValueError("Some other error") + result = handler.handle(error) + + assert "Unexpected error" in result + + +def test_get_suggestions_authentication(): + """Test get_suggestions returns relevant suggestions for AuthenticationError.""" + handler = ErrorHandler() + error = AuthenticationError("Invalid credentials") + suggestions = handler.get_suggestions(error) + + assert len(suggestions) == 2 + assert any("ONSHAPE_ACCESS_KEY" in s for s in suggestions) + assert any("dev-portal.onshape.com" in s for s in suggestions) + + +def test_get_suggestions_rate_limit(): + """Test get_suggestions returns relevant suggestions for RateLimitError.""" + handler = ErrorHandler() + error = RateLimitError("Too many requests") + suggestions = handler.get_suggestions(error) + + assert len(suggestions) == 2 + assert any("Wait" in s for s in suggestions) + assert any("rapid" in s for s in suggestions) + + +def test_get_suggestions_feature_error(): + """Test get_suggestions returns relevant suggestions for FeatureError.""" + handler = ErrorHandler() + error = FeatureError("Extrude failed") + suggestions = handler.get_suggestions(error) + + assert len(suggestions) == 2 + assert any("simplifying" in s for s in suggestions) + assert any("undo" in s for s in suggestions) + + +def test_get_suggestions_geometry_error(): + """Test get_suggestions returns relevant suggestions for GeometryError.""" + handler = ErrorHandler() + error = GeometryError("Invalid geometry") + suggestions = handler.get_suggestions(error) + + assert len(suggestions) == 2 + assert any("closed" in s for s in suggestions) + assert any("dimensions" in s for s in suggestions) + + +def test_get_suggestions_other_error(): + """Test get_suggestions returns empty list for unknown errors.""" + handler = ErrorHandler() + error = ValueError("Some error") + suggestions = handler.get_suggestions(error) + + assert suggestions == [] + + +def test_handler_with_context(): + """Test ErrorHandler accepts context parameter (though not currently used).""" + handler = ErrorHandler() + error = FeatureError("Test error") + context = {"operation": "extrude", "depth": 10} + + result = handler.handle(error, context=context) + assert "Feature operation failed" in result diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..142008f --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,596 @@ +"""Tests for ToolExecutor.""" + +import sys +from unittest.mock import MagicMock, Mock, patch + +import pytest + +# Prevent circular import by mocking ui.chat before importing +sys.modules["onshape_chat.ui.chat"] = Mock() + +from onshape_chat.errors import FeatureError # noqa: E402 +from onshape_chat.tools.executor import ToolExecutor # noqa: E402 + + +@pytest.fixture +def executor(state): + """Create ToolExecutor with mocked state and client.""" + with ( + patch("onshape_chat.tools.executor.OnshapeClient") as mock_client_class, + patch("onshape_chat.tools.executor.GLMClient"), + ): + mock_client = MagicMock() + mock_client_class.return_value = mock_client + executor = ToolExecutor(state) + executor.client = mock_client + return executor + + +def test_create_document(executor): + """Test create_document updates state with document and workspace.""" + executor.documents.create_document = MagicMock(return_value={"id": "doc-123"}) + executor.documents.get_workspaces = MagicMock(return_value=[{"id": "ws-456"}]) + + result = executor.create_document("Test Doc", "A test document") + + assert "Created document 'Test Doc'" in result + assert executor.state.document_id == "doc-123" + assert executor.state.document_name == "Test Doc" + assert executor.state.workspace_id == "ws-456" + + +def test_create_document_no_workspaces(executor): + """Test create_document handles no workspaces gracefully.""" + # Reset workspace_id first + executor.state.workspace_id = None + executor.documents.create_document = MagicMock(return_value={"id": "doc-new"}) + executor.documents.get_workspaces = MagicMock(return_value=[]) + + executor.create_document("Test Doc") + + assert executor.state.document_id == "doc-new" + assert executor.state.workspace_id is None + + +def test_create_sketch_rectangle_no_document(executor): + """Test create_sketch_rectangle returns error when no document.""" + executor.state.document_id = None + executor.state.workspace_id = None + + result = executor.create_sketch_rectangle("XY", 50, 30) + + assert "Error: No document created yet" in result + + +def test_create_sketch_rectangle(executor): + """Test create_sketch_rectangle creates sketch and updates state.""" + executor.sketches.create_rectangle = MagicMock(return_value={"featureId": "sketch-1"}) + + result = executor.create_sketch_rectangle("XY", 50, 30, center_x=10, center_y=20) + + assert "Created rectangular sketch" in result + assert "50mm x 30mm" in result + assert executor.state.last_sketch_id == "sketch-1" + assert len(executor.state.feature_history) == 1 + assert executor.state.feature_history[0]["type"] == "sketch" + assert executor.state.feature_history[0]["id"] == "sketch-1" + + +def test_create_sketch_circle(executor): + """Test create_sketch_circle creates sketch and updates state.""" + executor.sketches.create_circle = MagicMock(return_value={"featureId": "sketch-2"}) + + result = executor.create_sketch_circle("XZ", 25.0) + + assert "Created circular sketch" in result + assert "radius: 25.0mm" in result + assert executor.state.last_sketch_id == "sketch-2" + + +def test_extrude_no_sketch(executor): + """Test extrude returns error when no sketch exists.""" + executor.state.last_sketch_id = None + + result = executor.extrude(10.0) + + assert "Error: No sketch to extrude" in result + + +def test_extrude_no_document(executor): + """Test extrude returns error when no document.""" + executor.state.last_sketch_id = "sketch-1" + executor.state.document_id = None + + result = executor.extrude(10.0) + + assert "Error: No document available" in result + + +def test_extrude(executor, state_with_features): + """Test extrude creates feature and updates state.""" + executor.state = state_with_features + executor.features.extrude = MagicMock(return_value={"featureId": "extrude-1"}) + + result = executor.extrude(20.0, direction="forward") + + assert "Extruded sketch to depth of 20.0mm" in result + assert len(executor.state.feature_history) == 3 # 2 existing + 1 new + assert executor.state.feature_history[-1]["type"] == "extrude" + assert executor.state.feature_history[-1]["id"] == "extrude-1" + # Verify operation was passed through + executor.features.extrude.assert_called_once_with( + document_id=executor.state.document_id, + workspace_id=executor.state.workspace_id, + part_id=executor.state.part_studio_id or "default_part_studio", + sketch_id=executor.state.last_sketch_id, + depth=20.0, + direction="forward", + operation="new", + ) + + +def test_extrude_subtract(executor, state_with_features): + """Test extrude with subtract operation passes through correctly.""" + executor.state = state_with_features + executor.features.extrude = MagicMock(return_value={"featureId": "extrude-sub-1"}) + + result = executor.extrude(15.0, direction="forward", operation="subtract") + + assert "subtract (cut)" in result + executor.features.extrude.assert_called_once_with( + document_id=executor.state.document_id, + workspace_id=executor.state.workspace_id, + part_id=executor.state.part_studio_id or "default_part_studio", + sketch_id=executor.state.last_sketch_id, + depth=15.0, + direction="forward", + operation="subtract", + ) + + +def test_create_assembly_no_document(executor): + """Test create_assembly returns error when no document.""" + executor.state.document_id = None + + result = executor.create_assembly("Test Assembly") + + assert "Error: No document created yet" in result + + +def test_create_assembly(executor): + """Test create_assembly creates assembly and updates state.""" + executor.assemblies.create_assembly = MagicMock(return_value={"id": "asm-1"}) + + result = executor.create_assembly("Test Assembly") + + assert "Created assembly 'Test Assembly'" in result + assert executor.state.assembly_id == "asm-1" + + +def test_add_part_to_assembly_no_assembly(executor): + """Test add_part_to_assembly returns error when no assembly.""" + executor.state.assembly_id = None + + result = executor.add_part_to_assembly("part-1") + + assert "Error: No assembly created yet" in result + + +def test_add_part_to_assembly(executor): + """Test add_part_to_assembly inserts part.""" + executor.state.assembly_id = "asm-1" + executor.assemblies.insert_part = MagicMock(return_value={"id": "inst-1"}) + + result = executor.add_part_to_assembly("part-abc") + + assert "Added part 'part-abc' to assembly" in result + assert "inst-1" in result + + +def test_mate_parts(executor): + """Test mate_parts creates mate.""" + executor.state.assembly_id = "asm-1" + executor.assemblies.add_mate = MagicMock(return_value={"id": "mate-1"}) + + result = executor.mate_parts("part1_face", "part2_face", "FASTENED", 0) + + assert "Created FASTENED mate" in result + assert "part1_face" in result + assert "part2_face" in result + + +def test_export_stl(executor): + """Test export_stl exports part.""" + executor.exports.export_part = MagicMock(return_value={ + "format": "STL", + "filename": "/tmp/part.stl", + "size_bytes": 1024, + }) + + result = executor.export_stl("part-abc", "output") + + assert "Exported to STL" in result + assert "/tmp/part.stl" in result + assert "1024 bytes" in result + + +def test_export_step(executor): + """Test export_step exports part.""" + executor.exports.export_part = MagicMock(return_value={ + "format": "STEP", + "filename": "/tmp/part.step", + "size_bytes": 2048, + }) + + result = executor.export_step() + + assert "Exported to STEP" in result + assert "2048 bytes" in result + + +def test_show_preview(executor): + """Test show_preview displays image.""" + executor.exports.get_thumbnail = MagicMock(return_value=b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + executor.display.display_image = MagicMock(return_value=True) + + result = executor.show_preview() + + assert "Preview displayed" in result + + +def test_show_preview_fallback(executor): + """Test show_preview falls back to file save on display failure.""" + executor.exports.get_thumbnail = MagicMock(return_value=b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + executor.display.display_image = MagicMock(return_value=False) + + with patch("onshape_chat.tools.executor.save_temp_image", return_value="/tmp/preview.png"): + result = executor.show_preview() + + assert "Could not display in terminal" in result + assert "/tmp/preview.png" in result + + +def test_undo_empty(executor): + """Test undo returns message when nothing to undo.""" + executor.state.feature_history = [] + + result = executor.undo() + + assert "Nothing to undo" in result + + +def test_undo(executor, state_with_features): + """Test undo deletes last feature and updates history.""" + executor.state = state_with_features + executor.features.delete_feature = MagicMock(return_value={"success": True}) + + initial_count = len(executor.state.feature_history) + result = executor.undo() + + assert "Undid:" in result + assert "Extrude 20mm forward" in result + assert len(executor.state.feature_history) == initial_count - 1 + executor.features.delete_feature.assert_called_once() + + +def test_undo_updates_last_feature_id(executor, state_with_features): + """Test undo updates last_feature_id to previous feature.""" + executor.state = state_with_features + executor.features.delete_feature = MagicMock() + + executor.undo() + + # Should point to the first feature now + assert executor.state.last_feature_id == "sk-1" + + +def test_undo_clears_last_feature_id_when_empty(executor): + """Test undo clears last_feature_id when history becomes empty.""" + executor.state.feature_history = [{"type": "sketch", "id": "sk-1", "name": "Sketch"}] + executor.features.delete_feature = MagicMock() + + executor.undo() + + assert executor.state.last_feature_id is None + + +def test_rollback_by_index(executor, state_with_features): + """Test rollback works with numeric index.""" + executor.state = state_with_features + executor.features.rollback_to_feature = MagicMock(return_value={"success": True}) + + result = executor.rollback("0") + + assert "Rolled back to:" in result + assert "Rectangle 50x30 on XY" in result + executor.features.rollback_to_feature.assert_called_once() + # Should rollback to first feature's ID + call_args = executor.features.rollback_to_feature.call_args[1] + assert call_args["feature_id"] == "sk-1" + + +def test_rollback_by_last(executor, state_with_features): + """Test rollback works with 'last' keyword.""" + executor.state = state_with_features + executor.features.rollback_to_feature = MagicMock() + + result = executor.rollback("last") + + assert "Rolled back to:" in result + # Should rollback to last feature + call_args = executor.features.rollback_to_feature.call_args[1] + assert call_args["feature_id"] == "ext-1" + + +def test_rollback_by_name(executor, state_with_features): + """Test rollback works with feature name search.""" + executor.state = state_with_features + executor.features.rollback_to_feature = MagicMock() + + result = executor.rollback("extrude") + + assert "Rolled back to:" in result + # Should find the extrude feature + call_args = executor.features.rollback_to_feature.call_args[1] + assert call_args["feature_id"] == "ext-1" + + +def test_rollback_invalid_index(executor, state_with_features): + """Test rollback returns error for out-of-range index.""" + executor.state = state_with_features + + result = executor.rollback("99") + + assert "Error: Feature index 99 out of range" in result + + +def test_rollback_not_found(executor, state_with_features): + """Test rollback returns error when feature name not found.""" + executor.state = state_with_features + + result = executor.rollback("nonexistent") + + assert "Error: Feature 'nonexistent' not found" in result + + +def test_show_history_empty(executor): + """Test show_history returns message when no features.""" + executor.state.feature_history = [] + + result = executor.show_history() + + assert "No features created yet" in result + + +def test_show_history(executor, state_with_features): + """Test show_history returns formatted feature list.""" + executor.state = state_with_features + + result = executor.show_history() + + assert "Feature History:" in result + assert "0. [sketch] Rectangle 50x30 on XY" in result + assert "1. [extrude] Extrude 20mm forward" in result + + +def test_create_sketch_polygon_no_document(executor): + """Test create_sketch_polygon returns error when no document.""" + executor.state.document_id = None + executor.state.workspace_id = None + + result = executor.create_sketch_polygon("XY", 6, 25.0) + + assert "Error: No document created yet" in result + + +def test_create_sketch_polygon(executor): + """Test create_sketch_polygon creates sketch and updates state.""" + executor.sketches.create_polygon = MagicMock(return_value={"featureId": "sketch-hex"}) + + result = executor.create_sketch_polygon("XY", 6, 25.0) + + assert "6-sided polygon" in result + assert "25.0mm" in result + assert executor.state.last_sketch_id == "sketch-hex" + assert executor.state.feature_history[-1]["type"] == "sketch" + + +def test_create_sketch_from_points_no_document(executor): + """Test create_sketch_from_points returns error when no document.""" + executor.state.document_id = None + executor.state.workspace_id = None + + result = executor.create_sketch_from_points("XY", [[0, 0], [10, 0], [10, 10]]) + + assert "Error: No document created yet" in result + + +def test_create_sketch_from_points(executor): + """Test create_sketch_from_points creates sketch and updates state.""" + executor.sketches.create_from_points = MagicMock(return_value={"featureId": "sketch-pts"}) + points = [[0, 0], [10, 0], [10, 10], [0, 10]] + + result = executor.create_sketch_from_points("XY", points) + + assert "4 points" in result + assert executor.state.last_sketch_id == "sketch-pts" + assert executor.state.feature_history[-1]["type"] == "sketch" + + +def test_run_featurescript_no_document(executor): + """Test run_featurescript returns error when no document.""" + executor.state.document_id = None + executor.state.workspace_id = None + + result = executor.run_featurescript("Create a gear") + + assert "Error: No document created yet" in result + + +def test_run_featurescript(executor): + """Test run_featurescript generates and executes code.""" + executor.fs_generator.generate_with_retry = MagicMock(return_value={ + "code": "annotation { ... }", + "validated": True, + "errors": [], + }) + executor.fs_executor.execute = MagicMock(return_value={"featureId": "fs-1"}) + + result = executor.run_featurescript("Create a 32-tooth gear") + + assert "Executed FeatureScript" in result + assert "32-tooth gear" in result + assert executor.state.feature_history[-1]["type"] == "featurescript" + executor.fs_generator.generate_with_retry.assert_called_once_with("Create a 32-tooth gear") + executor.fs_executor.execute.assert_called_once() + + +def test_run_featurescript_generation_failure(executor): + """Test run_featurescript handles generation failure.""" + executor.fs_generator.generate_with_retry = MagicMock(return_value={ + "code": "", + "validated": False, + "errors": ["Empty code", "Missing annotation block"], + }) + + result = executor.run_featurescript("Create something impossible") + + assert "Error: Failed to generate valid FeatureScript" in result + assert "Empty code" in result + + +def test_execute_tool_call_success(executor): + """Test execute_tool_call dispatches to correct method.""" + executor.create_document = MagicMock(return_value="Document created") + + result = executor.execute_tool_call("create_document", {"name": "Test"}) + + assert result == "Document created" + executor.create_document.assert_called_once_with(name="Test") + + +def test_execute_tool_call_unknown_tool(executor): + """Test execute_tool_call returns error for unknown tool.""" + result = executor.execute_tool_call("nonexistent_tool", {}) + + assert "Error: Unknown tool 'nonexistent_tool'" in result + + +def test_execute_tool_call_with_onshape_error(executor): + """Test execute_tool_call uses ErrorHandler for OnshapeError.""" + executor.create_document = MagicMock(side_effect=FeatureError("Test error")) + + result = executor.execute_tool_call("create_document", {"name": "Test"}) + + # ErrorHandler should be used + assert "Feature operation failed" in result or "Test error" in result + + +def test_execute_tool_call_with_onshape_error_and_suggestions(executor): + """Test execute_tool_call includes suggestions from ErrorHandler.""" + from onshape_chat.errors import GeometryError + + executor.extrude = MagicMock(side_effect=GeometryError("Invalid geometry")) + + result = executor.execute_tool_call("extrude", {"depth": 10}) + + # Should include error message and suggestions + assert "Invalid geometry" in result + assert "Suggestions:" in result + + +def test_execute_tool_call_with_generic_exception(executor): + """Test execute_tool_call handles generic exceptions.""" + executor.create_document = MagicMock(side_effect=ValueError("Some error")) + + result = executor.execute_tool_call("create_document", {"name": "Test"}) + + assert "Error executing create_document" in result + assert "Some error" in result + + +def test_executor_initializes_managers(state): + """Test ToolExecutor initializes all required managers.""" + with ( + patch("onshape_chat.tools.executor.OnshapeClient"), + patch("onshape_chat.tools.executor.GLMClient"), + ): + executor = ToolExecutor(state) + + assert hasattr(executor, "documents") + assert hasattr(executor, "sketches") + assert hasattr(executor, "features") + assert hasattr(executor, "assemblies") + assert hasattr(executor, "exports") + assert hasattr(executor, "display") + assert hasattr(executor, "error_handler") + assert hasattr(executor, "fs_executor") + assert hasattr(executor, "fs_generator") + + +def test_executor_uses_state_part_studio_id(executor): + """Test executor uses part_studio_id from state if available.""" + executor.state.part_studio_id = "custom-ps" + executor.sketches.create_rectangle = MagicMock(return_value={"featureId": "sketch-1"}) + + executor.create_sketch_rectangle("XY", 50, 30) + + # Should use custom part_studio_id + call_args = executor.sketches.create_rectangle.call_args[1] + assert call_args["part_id"] == "custom-ps" + + +def test_executor_defaults_part_studio_id(executor): + """Test executor uses default when part_studio_id not in state.""" + executor.state.part_studio_id = None + executor.sketches.create_rectangle = MagicMock(return_value={"featureId": "sketch-1"}) + + executor.create_sketch_rectangle("XY", 50, 30) + + # Should use default + call_args = executor.sketches.create_rectangle.call_args[1] + assert call_args["part_id"] == "default_part_studio" + + +def test_export_stl_adds_extension(executor): + """Test export_stl adds .stl extension if missing.""" + executor.exports.export_part = MagicMock(return_value={ + "format": "STL", + "filename": "/tmp/output.stl", + "size_bytes": 1024, + }) + + executor.export_stl(filename="output") + + # Should add .stl extension + call_args = executor.exports.export_part.call_args[1] + assert call_args["filename"] == "output.stl" + + +def test_export_step_adds_extension(executor): + """Test export_step adds .step extension if missing.""" + executor.exports.export_part = MagicMock(return_value={ + "format": "STEP", + "filename": "/tmp/output.step", + "size_bytes": 2048, + }) + + executor.export_step(filename="output") + + # Should add .step extension + call_args = executor.exports.export_part.call_args[1] + assert call_args["filename"] == "output.step" + + +def test_show_preview_uses_assembly_if_no_part(executor): + """Test show_preview uses assembly_id if part_studio_id not available.""" + executor.state.part_studio_id = None + executor.state.assembly_id = "asm-1" + executor.exports.get_thumbnail = MagicMock(return_value=b"\x89PNG" + b"\x00" * 100) + executor.display.display_image = MagicMock(return_value=True) + + executor.show_preview() + + # Should use assembly_id + call_args = executor.exports.get_thumbnail.call_args[1] + assert call_args["element_id"] == "asm-1" diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..e648a7f --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,443 @@ +"""Tests for ExportManager.""" + +import pytest + +from onshape_chat.errors import ExportError +from onshape_chat.onshape.client import OnshapeAPIError +from onshape_chat.onshape.export import ExportManager, save_to_file + + +def test_export_part_stl(mock_client): + """Test export_part with STL format uses correct endpoint and params.""" + mock_client.get_binary.return_value = b"\x00" * 1024 # Fake STL data + + manager = ExportManager(mock_client) + result = manager.export_part( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + part_id="part-abc", + export_format="STL", + filename="test.stl", + ) + + # Verify endpoint + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/stl" + + # Verify params + params = call_args[1]["params"] + assert params["partId"] == "part-abc" + assert params["units"] == "millimeter" + assert params["mode"] == "binary" + + # Verify result + assert result["format"] == "STL" + assert result["filename"].endswith("test.stl") + assert result["size_bytes"] == 1024 + + +def test_export_part_step(mock_client): + """Test export_part with STEP format uses correct endpoint.""" + mock_client.get_binary.return_value = b"\x00" * 2048 + + manager = ExportManager(mock_client) + result = manager.export_part( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + part_id="part-abc", + export_format="STEP", + filename="test.step", + ) + + # Verify endpoint + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/step" + + # Verify params + params = call_args[1]["params"] + assert params["partId"] == "part-abc" + # STEP doesn't have units/mode params + assert "units" not in params + assert "mode" not in params + + assert result["format"] == "STEP" + assert result["size_bytes"] == 2048 + + +def test_export_part_parasolid(mock_client): + """Test export_part with PARASOLID format.""" + mock_client.get_binary.return_value = b"\x00" * 512 + + manager = ExportManager(mock_client) + result = manager.export_part( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + part_id="part-abc", + export_format="PARASOLID", + ) + + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/parasolid" + assert result["format"] == "PARASOLID" + + +def test_export_part_invalid_format(mock_client): + """Test export_part raises error for unsupported format.""" + manager = ExportManager(mock_client) + + with pytest.raises(ExportError) as exc_info: + manager.export_part( + "doc-123", "ws-456", "ps-789", "part-abc", "OBJ" # type: ignore + ) + + assert "Unsupported export format" in str(exc_info.value) + + +def test_export_part_auto_filename(mock_client): + """Test export_part auto-generates filename when not provided.""" + mock_client.get_binary.return_value = b"\x00" * 100 + + manager = ExportManager(mock_client) + result = manager.export_part( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + part_id="part-abc-def", + export_format="STL", + ) + + # Filename should be auto-generated from part_id + filename = result["filename"] + assert "part-abc" in filename # First 8 chars of part-abc-def + assert filename.endswith(".stl") + + +def test_export_part_api_error(mock_client): + """Test export_part raises ExportError on API failure.""" + mock_client.get_binary.side_effect = OnshapeAPIError("Part not found", 404) + + manager = ExportManager(mock_client) + with pytest.raises(OnshapeAPIError): + manager.export_part( + "doc-123", "ws-456", "ps-789", "part-abc", "STL" + ) + + +def test_export_assembly_stl(mock_client): + """Test export_assembly with STL format.""" + mock_client.get_binary.return_value = b"\x00" * 4096 + + manager = ExportManager(mock_client) + result = manager.export_assembly( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + export_format="STL", + filename="assembly.stl", + ) + + # Verify endpoint + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/assemblies/d/doc-123/w/ws-456/e/asm-1/stl" + + # Verify params + params = call_args[1]["params"] + assert params["units"] == "millimeter" + assert params["mode"] == "binary" + + assert result["format"] == "STL" + assert result["filename"].endswith("assembly.stl") + + +def test_export_assembly_step(mock_client): + """Test export_assembly with STEP format.""" + mock_client.get_binary.return_value = b"\x00" * 2048 + + manager = ExportManager(mock_client) + result = manager.export_assembly( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-1", + export_format="STEP", + ) + + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/assemblies/d/doc-123/w/ws-456/e/asm-1/step" + assert result["format"] == "STEP" + + +def test_export_assembly_auto_filename(mock_client): + """Test export_assembly auto-generates filename.""" + mock_client.get_binary.return_value = b"\x00" * 100 + + manager = ExportManager(mock_client) + result = manager.export_assembly( + document_id="doc-123", + workspace_id="ws-456", + assembly_id="asm-12345678901234", + export_format="STEP", + ) + + # Should use first 8 chars of assembly_id + filename = result["filename"] + assert "asm-1234" in filename + assert filename.endswith(".step") + + +def test_export_assembly_invalid_format(mock_client): + """Test export_assembly raises error for unsupported format.""" + manager = ExportManager(mock_client) + + with pytest.raises(ExportError) as exc_info: + manager.export_assembly( + "doc-123", "ws-456", "asm-1", "IGES" # type: ignore + ) + + assert "Unsupported export format" in str(exc_info.value) + + +def test_get_thumbnail(mock_client): + """Test get_thumbnail calls correct endpoint with dimensions.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + manager = ExportManager(mock_client) + result = manager.get_thumbnail( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + width=400, + height=300, + ) + + # Verify endpoint includes dimensions + mock_client.get_binary.assert_called_once_with( + "/thumbnails/d/doc-123/w/ws-456/e/ps-789/s/400x300" + ) + + assert result.startswith(b"\x89PNG") + + +def test_get_thumbnail_default_dimensions(mock_client): + """Test get_thumbnail uses default dimensions when not provided.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + manager = ExportManager(mock_client) + manager.get_thumbnail( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + ) + + # Should use default 300x300 + call_args = mock_client.get_binary.call_args + assert "300x300" in call_args[0][0] + + +def test_get_thumbnail_error(mock_client): + """Test get_thumbnail raises ExportError on API failure.""" + mock_client.get_binary.side_effect = OnshapeAPIError("Not found", 404) + + manager = ExportManager(mock_client) + with pytest.raises(OnshapeAPIError): + manager.get_thumbnail("doc-123", "ws-456", "ps-789") + + +def test_generate_filename_stl(): + """Test _generate_filename creates correct STL filename.""" + manager = ExportManager(None) # type: ignore + filename = manager._generate_filename("part-abc-123", "STL") + + assert filename == "part-abc_stl.stl" + + +def test_generate_filename_step(): + """Test _generate_filename creates correct STEP filename.""" + manager = ExportManager(None) # type: ignore + filename = manager._generate_filename("part-def-456", "STEP") + + assert filename == "part-def_step.step" + + +def test_generate_filename_parasolid(): + """Test _generate_filename creates correct PARASOLID filename.""" + manager = ExportManager(None) # type: ignore + filename = manager._generate_filename("part-ghi-789", "PARASOLID") + + assert filename == "part-ghi_parasolid.x_t" + + +def test_generate_filename_short_id(): + """Test _generate_filename handles IDs shorter than 8 chars.""" + manager = ExportManager(None) # type: ignore + filename = manager._generate_filename("abc", "STL") + + assert filename == "abc_stl.stl" + + +def test_save_to_file(tmp_path): + """Test save_to_file writes bytes correctly.""" + data = b"Test data content" + filepath = tmp_path / "test_file.txt" + + result = save_to_file(data, str(filepath)) + + # Verify file was created + assert filepath.exists() + assert filepath.read_bytes() == data + + # Verify absolute path returned + assert result == str(filepath) + + +def test_save_to_file_creates_directories(tmp_path): + """Test save_to_file creates parent directories.""" + data = b"Test data" + filepath = tmp_path / "subdir" / "nested" / "file.txt" + + save_to_file(data, str(filepath)) + + assert filepath.exists() + assert filepath.read_bytes() == data + + +def test_save_to_file_error_handling(tmp_path): + """Test save_to_file raises ExportError on write failure.""" + # Try to write to a read-only directory (simulate error) + import stat + + readonly_dir = tmp_path / "readonly" + readonly_dir.mkdir() + readonly_dir.chmod(stat.S_IRUSR | stat.S_IXUSR) + + try: + filepath = readonly_dir / "test.txt" + with pytest.raises(ExportError) as exc_info: + save_to_file(b"data", str(filepath)) + + assert "Failed to save file" in str(exc_info.value) + finally: + # Restore permissions for cleanup + readonly_dir.chmod(stat.S_IRWXU) + + +def test_get_shaded_view(mock_client): + """Test get_shaded_view calls correct endpoint with params.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 200 + + manager = ExportManager(mock_client) + result = manager.get_shaded_view( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + width=1024, + height=768, + output_format="PNG", + ) + + call_args = mock_client.get_binary.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/shadedviews" + + params = call_args[1]["params"] + assert params["outputWidth"] == 1024 + assert params["outputHeight"] == 768 + assert params["outputFormat"] == "PNG" + + assert result.startswith(b"\x89PNG") + + +def test_get_shaded_view_with_view_matrix(mock_client): + """Test get_shaded_view includes view matrix parameter.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + # 4x4 identity matrix + view_matrix = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ] + + manager = ExportManager(mock_client) + manager.get_shaded_view( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + view_matrix=view_matrix, + ) + + params = mock_client.get_binary.call_args[1]["params"] + assert "viewMatrix" in params + # Should be comma-separated string + assert params["viewMatrix"] == "1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0" + + +def test_get_shaded_view_invalid_matrix(mock_client): + """Test get_shaded_view raises error for invalid matrix size.""" + manager = ExportManager(mock_client) + + with pytest.raises(ExportError) as exc_info: + manager.get_shaded_view( + "doc-123", "ws-456", "ps-789", + view_matrix=[1.0, 0.0, 0.0] # Only 3 elements, needs 12 or 16 + ) + + assert "12 (3x4) or 16 (4x4)" in str(exc_info.value) + + +def test_get_shaded_view_with_named_string(mock_client): + """Test get_shaded_view accepts named string view matrices.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + manager = ExportManager(mock_client) + manager.get_shaded_view( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + view_matrix="front", + ) + + params = mock_client.get_binary.call_args[1]["params"] + assert params["viewMatrix"] == "front" + + +def test_get_shaded_view_with_12_float_matrix(mock_client): + """Test get_shaded_view accepts 12-float (3x4) view matrices.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + # 3x4 identity-ish matrix + view_matrix = [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + ] + + manager = ExportManager(mock_client) + manager.get_shaded_view( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + view_matrix=view_matrix, + ) + + params = mock_client.get_binary.call_args[1]["params"] + assert "viewMatrix" in params + assert params["viewMatrix"] == "1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0" + + +def test_get_shaded_view_with_pixel_size(mock_client): + """Test get_shaded_view passes pixel_size parameter.""" + mock_client.get_binary.return_value = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + + manager = ExportManager(mock_client) + manager.get_shaded_view( + document_id="doc-123", + workspace_id="ws-456", + element_id="ps-789", + pixel_size=0.0, + ) + + params = mock_client.get_binary.call_args[1]["params"] + assert params["pixelSize"] == 0.0 diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..d5a55ef --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,303 @@ +"""Tests for FeatureManager.""" + +import pytest + +from onshape_chat.errors import FeatureError +from onshape_chat.onshape.client import OnshapeAPIError +from onshape_chat.onshape.features import FeatureManager + + +def _find_param(params, param_id): + """Find a parameter by parameterId in a list.""" + for p in params: + if p.get("parameterId") == param_id: + return p + return None + + +def test_extrude(mock_client): + """Test extrude calls correct endpoint with BTM body.""" + mock_client.post.return_value = { + "feature": {"featureId": "feat-123", "name": "Extrude"}, + } + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=50.0, + direction="forward", + operation="new", + ) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/features" + + body = call_args[1]["json_body"] + assert body["btType"] == "BTFeatureDefinitionCall-1406" + feature = body["feature"] + assert feature["btType"] == "BTMFeature-134" + assert feature["featureType"] == "extrude" + assert feature["name"] == "Extrude" + + params = feature["parameters"] + entities = _find_param(params, "entities") + assert entities["btType"] == "BTMParameterQueryList-148" + assert entities["queries"][0]["featureId"] == "sketch-1" + + depth = _find_param(params, "depth") + assert depth["expression"] == "50.0 mm" + + op = _find_param(params, "operationType") + assert op["value"] == "NEW" + + +def test_extrude_builds_correct_endpoint(mock_client): + """Test extrude builds /partstudios/d/{doc}/w/{ws}/e/{elem}/features endpoint.""" + mock_client.post.return_value = {"feature": {"featureId": "feat-123"}} + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=10.0, + ) + + endpoint = mock_client.post.call_args[0][0] + assert endpoint == "/partstudios/d/doc-123/w/ws-456/e/ps-789/features" + + +def test_extrude_direction_backward(mock_client): + """Test extrude with backward direction sets oppositeDirection.""" + mock_client.post.return_value = {"feature": {"featureId": "feat-123"}} + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=25.0, + direction="backward", + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["parameters"] + opp = _find_param(params, "oppositeDirection") + assert opp is not None + assert opp["value"] is True + + +def test_extrude_direction_symmetric(mock_client): + """Test extrude with symmetric direction sets endBound.""" + mock_client.post.return_value = {"feature": {"featureId": "feat-123"}} + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=30.0, + direction="symmetric", + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["parameters"] + bound = _find_param(params, "endBound") + assert bound is not None + assert bound["value"] == "SYMMETRIC" + + +def test_extrude_operation_add(mock_client): + """Test extrude with add operation.""" + mock_client.post.return_value = {"feature": {"featureId": "feat-123"}} + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=10.0, + operation="add", + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["parameters"] + op = _find_param(params, "operationType") + assert op["value"] == "ADD" + + +def test_extrude_operation_subtract(mock_client): + """Test extrude with subtract operation.""" + mock_client.post.return_value = {"feature": {"featureId": "feat-123"}} + + manager = FeatureManager(mock_client) + manager.extrude( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + sketch_id="sketch-1", + depth=10.0, + operation="subtract", + ) + + body = mock_client.post.call_args[1]["json_body"] + params = body["feature"]["parameters"] + op = _find_param(params, "operationType") + assert op["value"] == "REMOVE" + + +def test_get_features(mock_client): + """Test get_features calls correct GET endpoint and parses response.""" + mock_client.get.return_value = { + "features": [ + {"featureId": "feat-1", "name": "Sketch 1", "featureType": "sketch"}, + {"featureId": "feat-2", "name": "Extrude 1", "featureType": "extrude"}, + ] + } + + manager = FeatureManager(mock_client) + features = manager.get_features( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + ) + + mock_client.get.assert_called_once_with("/partstudios/d/doc-123/w/ws-456/e/ps-789/features") + + assert len(features) == 2 + assert features[0]["featureId"] == "feat-1" + assert features[1]["name"] == "Extrude 1" + + +def test_get_features_empty(mock_client): + """Test get_features returns empty list when no features.""" + mock_client.get.return_value = {} + + manager = FeatureManager(mock_client) + features = manager.get_features("doc-123", "ws-456", "ps-789") + + assert features == [] + + +def test_delete_feature(mock_client): + """Test delete_feature calls correct DELETE endpoint.""" + mock_client.delete.return_value = {"success": True} + + manager = FeatureManager(mock_client) + result = manager.delete_feature( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + feature_id="feat-123", + ) + + mock_client.delete.assert_called_once_with( + "/partstudios/d/doc-123/w/ws-456/e/ps-789/features/featureid/feat-123" + ) + + assert result["success"] is True + + +def test_delete_feature_error(mock_client): + """Test delete_feature raises FeatureError on failure.""" + mock_client.delete.side_effect = OnshapeAPIError("Feature not found", 404) + + manager = FeatureManager(mock_client) + with pytest.raises(FeatureError) as exc_info: + manager.delete_feature("doc-123", "ws-456", "ps-789", "feat-123") + + assert "Failed to delete feature feat-123" in str(exc_info.value) + + +def test_rollback_to_feature(mock_client): + """Test rollback_to_feature calls correct POST endpoint.""" + mock_client.post.return_value = {"success": True} + + manager = FeatureManager(mock_client) + result = manager.rollback_to_feature( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + feature_id="feat-2", + ) + + call_args = mock_client.post.call_args + assert call_args[0][0] == "/partstudios/d/doc-123/w/ws-456/e/ps-789/features/rollback" + assert call_args[1]["json_body"] == {"featureId": "feat-2"} + + assert result["success"] is True + + +def test_rollback_to_feature_beginning(mock_client): + """Test rollback_to_feature accepts -1 for beginning.""" + mock_client.post.return_value = {"success": True} + + manager = FeatureManager(mock_client) + manager.rollback_to_feature( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + feature_id="-1", + ) + + body = mock_client.post.call_args[1]["json_body"] + assert body["featureId"] == "-1" + + +def test_get_feature_history(mock_client): + """Test get_feature_history parses features into history format.""" + mock_client.get.return_value = { + "features": [ + {"featureId": "feat-1", "name": "Sketch 1", "featureType": "sketch"}, + {"featureId": "feat-2", "name": "Extrude 1", "featureType": "extrude"}, + {"featureId": "feat-3", "name": "Fillet 1", "featureType": "fillet"}, + ] + } + + manager = FeatureManager(mock_client) + history = manager.get_feature_history( + document_id="doc-123", + workspace_id="ws-456", + part_id="ps-789", + ) + + assert len(history) == 3 + assert history[0]["index"] == 0 + assert history[0]["feature_id"] == "feat-1" + assert history[0]["name"] == "Sketch 1" + assert history[0]["type"] == "sketch" + assert history[1]["index"] == 1 + assert history[1]["feature_id"] == "feat-2" + assert history[2]["index"] == 2 + assert history[2]["type"] == "fillet" + + +def test_get_feature_history_missing_fields(mock_client): + """Test get_feature_history handles missing fields gracefully.""" + mock_client.get.return_value = {"features": [{}]} + + manager = FeatureManager(mock_client) + history = manager.get_feature_history("doc-123", "ws-456", "ps-789") + + assert len(history) == 1 + assert history[0]["index"] == 0 + assert history[0]["feature_id"] == "" + assert history[0]["name"] == "Feature 0" + assert history[0]["type"] == "unknown" + + +def test_get_feature_history_empty(mock_client): + """Test get_feature_history returns empty list when no features.""" + mock_client.get.return_value = {"features": []} + + manager = FeatureManager(mock_client) + history = manager.get_feature_history("doc-123", "ws-456", "ps-789") + + assert history == [] diff --git a/tests/test_featurescript.py b/tests/test_featurescript.py new file mode 100644 index 0000000..765b824 --- /dev/null +++ b/tests/test_featurescript.py @@ -0,0 +1,196 @@ +"""Tests for Phase 4e: FeatureScript Generation.""" + +from unittest.mock import MagicMock + +from onshape_chat.featurescript.executor import FeatureScriptExecutor +from onshape_chat.featurescript.generator import FeatureScriptGenerator +from onshape_chat.featurescript.rag import FeatureScriptRAG + +# ── FeatureScriptRAG Tests ──────────────────────────── + + +class TestFeatureScriptRAG: + def test_load_existing_docs(self): + rag = FeatureScriptRAG("docs/rag/featurescript-reference.md") + # May or may not have chunks depending on whether file exists + assert isinstance(rag.chunks, list) + + def test_load_nonexistent_docs(self): + rag = FeatureScriptRAG("/nonexistent/path.md") + assert rag.chunks == [] + + def test_load_and_chunk(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text( + "## Section One\nThis is about helixes and spirals.\n\n" + "### Sub Section\nDetails about helix parameters.\n\n" + "## Section Two\nThis covers extrusion operations.\n" + ) + + rag = FeatureScriptRAG(str(doc)) + assert len(rag.chunks) >= 2 + + def test_search_keyword_matching(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text( + "## Helix Operations\nCreate a helix with pitch and turns.\n" + "Use opHelix for helix creation.\n\n" + "## Extrude Operations\nExtrude a sketch to create a solid.\n" + ) + + rag = FeatureScriptRAG(str(doc)) + results = rag.search("helix pitch") + + assert len(results) >= 1 + assert "helix" in results[0].lower() + + def test_search_returns_top_k(self, tmp_path): + doc = tmp_path / "test.md" + sections = [f"## Section {i}\nContent about topic {i}.\n" for i in range(10)] + doc.write_text("\n".join(sections)) + + rag = FeatureScriptRAG(str(doc)) + results = rag.search("topic", top_k=3) + assert len(results) <= 3 + + def test_search_no_results(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text("## Section\nSome content.\n") + + rag = FeatureScriptRAG(str(doc)) + results = rag.search("zzzznonexistent") + assert results == [] + + def test_get_all_sections(self, tmp_path): + doc = tmp_path / "test.md" + doc.write_text("## Alpha\ncontent\n\n## Beta\ncontent\n") + + rag = FeatureScriptRAG(str(doc)) + sections = rag.get_all_sections() + assert "Alpha" in sections + assert "Beta" in sections + + +# ── FeatureScriptGenerator Tests ────────────────────── + + +class TestFeatureScriptGenerator: + def _make_generator(self, code_response: str) -> FeatureScriptGenerator: + mock_client = MagicMock() + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content=code_response))] + mock_client.chat.return_value = mock_resp + + mock_rag = MagicMock() + mock_rag.search.return_value = ["Example FeatureScript code"] + + return FeatureScriptGenerator(client=mock_client, rag=mock_rag) + + def test_generate_valid_code(self): + code = '''annotation { "Feature Type Name" : "MyFeature" } +export const myFeature = defineFeature(function(context is Context, id is Id, definition is map) +{ + // Implementation +});''' + gen = self._make_generator(code) + result = gen.generate("Create a helix") + + assert result["validated"] is True + assert result["errors"] == [] + assert "annotation" in result["code"] + + def test_generate_invalid_code_missing_annotation(self): + code = "function doSomething() { return 1; }" + gen = self._make_generator(code) + result = gen.generate("Bad request") + + assert result["validated"] is False + assert "Missing annotation block" in result["errors"] + + def test_generate_unbalanced_braces(self): + code = 'annotation { "Name" : "X" }\nexport function foo() {' + gen = self._make_generator(code) + result = gen.generate("Unbalanced") + + assert result["validated"] is False + assert "Unbalanced curly braces" in result["errors"] + + def test_generate_unbalanced_parens(self): + code = 'annotation { "Name" : "X" }\nexport function foo(a, b {' + gen = self._make_generator(code) + result = gen.generate("Unbalanced") + + assert result["validated"] is False + assert any("parentheses" in e.lower() for e in result["errors"]) + + def test_validate_empty_code(self): + gen = self._make_generator("") + valid, errors = gen.validate("") + assert valid is False + assert "Empty code" in errors + + def test_strip_code_fences(self): + gen = self._make_generator("") + code = "```featurescript\nsome code\n```" + stripped = gen._strip_code_fences(code) + assert stripped == "some code" + + def test_strip_code_fences_no_fences(self): + gen = self._make_generator("") + code = "some code here" + assert gen._strip_code_fences(code) == code + + def test_generate_with_retry_success_first_try(self): + code = 'annotation { "Feature Type Name" : "X" }\nexport function foo() {}' + gen = self._make_generator(code) + result = gen.generate_with_retry("Create something") + + assert result["validated"] is True + + def test_generate_with_retry_eventual_failure(self): + bad_code = "no annotation here" + gen = self._make_generator(bad_code) + result = gen.generate_with_retry("Bad code") + + assert result["validated"] is False + # Should have retried max_retries times + assert gen.client.chat.call_count == 3 + + +# ── FeatureScriptExecutor Tests ────────────────────── + + +class TestFeatureScriptExecutor: + def test_execute(self): + mock_client = MagicMock() + mock_client.post.return_value = {"result": {"value": 42}} + + executor = FeatureScriptExecutor(mock_client) + result = executor.execute("doc-1", "ws-1", "elem-1", "some code") + + assert result["result"]["value"] == 42 + mock_client.post.assert_called_once_with( + "/partstudios/d/doc-1/w/ws-1/e/elem-1/featurescript", + json_body={"script": "some code"}, + ) + + def test_evaluate(self): + mock_client = MagicMock() + mock_client.post.return_value = {"result": {"value": 100}} + + executor = FeatureScriptExecutor(mock_client) + result = executor.evaluate("doc-1", "ws-1", "elem-1", "query code") + + assert result["result"]["value"] == 100 + call_args = mock_client.post.call_args + assert call_args[1]["json_body"]["serializationVersion"] == "1.2.0" + + def test_execute_endpoint_construction(self): + mock_client = MagicMock() + mock_client.post.return_value = {} + + executor = FeatureScriptExecutor(mock_client) + executor.execute("abc", "def", "ghi", "code") + + endpoint = mock_client.post.call_args[0][0] + assert "/partstudios/d/abc/w/def/e/ghi/featurescript" == endpoint diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py new file mode 100644 index 0000000..837c07a --- /dev/null +++ b/tests/test_llm_client.py @@ -0,0 +1,132 @@ +"""Tests for GLMClient, focusing on chat_with_images.""" + +import base64 +from unittest.mock import MagicMock, patch + +from onshape_chat.llm.client import GLMClient + + +@patch("onshape_chat.llm.client.get_settings") +@patch("onshape_chat.llm.client.OpenAI") +def test_chat_with_images_builds_content_array(mock_openai_cls, mock_settings): + """chat_with_images builds a content array with alternating text labels and images.""" + mock_settings.return_value = MagicMock( + glm_api_key="key", + glm_base_url="https://api.example.com", + glm_model="model", + glm_vision_base_url="https://vision.example.com", + glm_vision_model="vision-model", + ) + + client = GLMClient() + + # Mock the vision client's create method + mock_completion = MagicMock() + client._vision_client.chat.completions.create.return_value = mock_completion + + images = [ + ("Front", b"\x89PNG-front"), + ("Back", b"\x89PNG-back"), + ("Top", b"\x89PNG-top"), + ] + + result = client.chat_with_images( + messages=[{"role": "user", "content": "Check this model"}], + images=images, + ) + + assert result == mock_completion + + # Inspect the messages passed to the vision client + call_kwargs = client._vision_client.chat.completions.create.call_args[1] + messages = call_kwargs["messages"] + + assert len(messages) == 1 # Single user message + content = messages[0]["content"] + + # Should have: 1 text (prompt) + 3 * (1 text label + 1 image) = 7 parts + assert len(content) == 7 + + # First part is the user's text + assert content[0]["type"] == "text" + assert content[0]["text"] == "Check this model" + + # Check alternating label + image pattern + for i, (label, img_bytes) in enumerate(images): + text_idx = 1 + i * 2 + img_idx = 2 + i * 2 + + assert content[text_idx]["type"] == "text" + assert f"[{label}]" in content[text_idx]["text"] + + assert content[img_idx]["type"] == "image_url" + expected_b64 = base64.b64encode(img_bytes).decode("utf-8") + assert expected_b64 in content[img_idx]["image_url"]["url"] + + +@patch("onshape_chat.llm.client.get_settings") +@patch("onshape_chat.llm.client.OpenAI") +def test_chat_with_images_preserves_system_messages(mock_openai_cls, mock_settings): + """chat_with_images preserves system/assistant messages before the last user message.""" + mock_settings.return_value = MagicMock( + glm_api_key="key", + glm_base_url="https://api.example.com", + glm_model="model", + glm_vision_base_url="https://vision.example.com", + glm_vision_model="vision-model", + ) + + client = GLMClient() + client._vision_client.chat.completions.create.return_value = MagicMock() + + messages = [ + {"role": "system", "content": "You are a CAD inspector"}, + {"role": "user", "content": "Check views"}, + ] + + client.chat_with_images( + messages=messages, + images=[("Front", b"\x89PNG")], + ) + + call_kwargs = client._vision_client.chat.completions.create.call_args[1] + sent_messages = call_kwargs["messages"] + + assert len(sent_messages) == 2 + assert sent_messages[0]["role"] == "system" + assert sent_messages[0]["content"] == "You are a CAD inspector" + assert sent_messages[1]["role"] == "user" + assert isinstance(sent_messages[1]["content"], list) + + +@patch("onshape_chat.llm.client.get_settings") +@patch("onshape_chat.llm.client.OpenAI") +def test_chat_with_images_14_images(mock_openai_cls, mock_settings): + """chat_with_images handles 14 images (the full multi-angle set).""" + mock_settings.return_value = MagicMock( + glm_api_key="key", + glm_base_url="https://api.example.com", + glm_model="model", + glm_vision_base_url="https://vision.example.com", + glm_vision_model="vision-model", + ) + + client = GLMClient() + client._vision_client.chat.completions.create.return_value = MagicMock() + + images = [(f"View-{i}", b"\x89PNG" + bytes([i])) for i in range(14)] + + client.chat_with_images( + messages=[{"role": "user", "content": "Verify model"}], + images=images, + ) + + call_kwargs = client._vision_client.chat.completions.create.call_args[1] + content = call_kwargs["messages"][0]["content"] + + # 1 prompt text + 14 * (1 label text + 1 image) = 29 parts + assert len(content) == 29 + + # Count image_url entries + image_count = sum(1 for part in content if part["type"] == "image_url") + assert image_count == 14 diff --git a/tests/test_optimization.py b/tests/test_optimization.py new file mode 100644 index 0000000..cb12729 --- /dev/null +++ b/tests/test_optimization.py @@ -0,0 +1,267 @@ +"""Tests for Phase 4f: Design Optimization.""" + +import json +from unittest.mock import MagicMock + +import pytest + +from onshape_chat.optimization.analyzer import ( + ANALYSIS_PROMPTS, + DesignAnalyzer, +) +from onshape_chat.optimization.fixer import AutoFixer +from onshape_chat.optimization.suggestions import ( + Suggestion, + SuggestionEngine, +) + +# ── DesignAnalyzer Tests ────────────────────────────── + + +class TestDesignAnalyzer: + def _make_analyzer(self, llm_response: str) -> DesignAnalyzer: + mock_onshape = MagicMock() + mock_onshape.get.return_value = {"bodies": []} + + mock_llm = MagicMock() + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content=llm_response))] + mock_llm.chat.return_value = mock_resp + + return DesignAnalyzer(mock_onshape, mock_llm) + + def test_analyze_3d_print(self): + response = json.dumps({ + "issues": [ + {"severity": "high", "description": "Thin wall", "location": "left", "fix": "Thicken"}, + ], + "score": 7, + "summary": "One issue found", + }) + + analyzer = self._make_analyzer(response) + result = analyzer.analyze("doc-1", "ws-1", "elem-1", method="3d_print") + + assert result["method"] == "3d_print" + assert result["score"] == 7 + assert len(result["issues"]) == 1 + + def test_analyze_cnc(self): + response = json.dumps({"issues": [], "score": 9, "summary": "Good"}) + analyzer = self._make_analyzer(response) + result = analyzer.analyze("doc-1", "ws-1", "elem-1", method="cnc") + assert result["method"] == "cnc" + + def test_analyze_unknown_method(self): + analyzer = self._make_analyzer("{}") + with pytest.raises(ValueError, match="Unknown method"): + analyzer.analyze("doc-1", "ws-1", "elem-1", method="laser_cutting") + + def test_analyze_non_json_response(self): + analyzer = self._make_analyzer("This is not JSON, just a text analysis.") + result = analyzer.analyze("doc-1", "ws-1", "elem-1", method="general") + + # Should gracefully handle non-JSON + assert result["method"] == "general" + assert result["score"] == 5 + + def test_get_geometry(self): + mock_onshape = MagicMock() + mock_onshape.get.return_value = {"bodies": [{"type": "solid"}]} + + analyzer = DesignAnalyzer(mock_onshape, MagicMock()) + geo = analyzer._get_geometry("doc", "ws", "elem") + + assert geo["bodies"][0]["type"] == "solid" + mock_onshape.get.assert_called_with( + "/partstudios/d/doc/w/ws/e/elem/bodydetails" + ) + + def test_get_features(self): + mock_onshape = MagicMock() + mock_onshape.get.return_value = {"features": []} + + analyzer = DesignAnalyzer(mock_onshape, MagicMock()) + feats = analyzer._get_features("doc", "ws", "elem") + + assert feats["features"] == [] + mock_onshape.get.assert_called_with( + "/partstudios/d/doc/w/ws/e/elem/features" + ) + + def test_list_methods(self): + methods = DesignAnalyzer.list_methods() + assert "3d_print" in methods + assert "cnc" in methods + assert "injection_mold" in methods + assert "general" in methods + + def test_all_prompts_have_placeholders(self): + for method, prompt in ANALYSIS_PROMPTS.items(): + assert "{geometry}" in prompt, f"{method} prompt missing {{geometry}}" + assert "{features}" in prompt, f"{method} prompt missing {{features}}" + + +# ── SuggestionEngine Tests ──────────────────────────── + + +class TestSuggestionEngine: + def test_process_analysis(self): + engine = SuggestionEngine() + analysis = { + "issues": [ + {"severity": "low", "description": "Minor issue", "location": "top", "fix": "optional"}, + {"severity": "high", "description": "Sharp corner stress", "location": "edge", "fix": "Add fillet"}, + {"severity": "medium", "description": "Thin wall", "location": "side", "fix": "Thicken"}, + ] + } + + suggestions = engine.process_analysis(analysis) + assert len(suggestions) == 3 + # Should be sorted by severity + assert suggestions[0].severity == "high" + assert suggestions[1].severity == "medium" + assert suggestions[2].severity == "low" + + def test_process_analysis_auto_fixable(self): + engine = SuggestionEngine() + analysis = { + "issues": [ + {"severity": "high", "description": "Sharp corner detected", + "location": "edge-1", "fix": "Add fillet to smooth it"}, + ] + } + + suggestions = engine.process_analysis(analysis) + assert len(suggestions) == 1 + # "sharp corner" + "fillet" should match fix mappings + assert suggestions[0].auto_fixable is True + assert suggestions[0].fix_tool_call is not None + + def test_process_analysis_not_auto_fixable(self): + engine = SuggestionEngine() + analysis = { + "issues": [ + {"severity": "medium", "description": "Wall too thin", + "location": "side", "fix": "Increase dimension in sketch"}, + ] + } + + suggestions = engine.process_analysis(analysis) + assert suggestions[0].auto_fixable is False + + def test_process_empty_analysis(self): + engine = SuggestionEngine() + suggestions = engine.process_analysis({"issues": []}) + assert suggestions == [] + + def test_format_report(self): + engine = SuggestionEngine() + suggestions = [ + Suggestion("high", "Overhang detected", "face-1", "Add support"), + Suggestion("low", "Could optimize", "body", "Optional change"), + ] + + report = engine.format_report(suggestions, score=7, method="3d_print") + + assert "Design Score: 7/10" in report + assert "[!!!]" in report # High severity + assert "[!]" in report # Low severity + assert "3d_print" in report + + def test_format_report_empty(self): + engine = SuggestionEngine() + report = engine.format_report([], score=10) + assert "No issues found" in report + + def test_format_report_auto_fixable_label(self): + engine = SuggestionEngine() + suggestions = [ + Suggestion("high", "Sharp corner", "edge", "Fillet it", + auto_fixable=True, fix_tool_call={"name": "create_fillet", "args": {}}), + ] + + report = engine.format_report(suggestions, score=6) + assert "[auto-fixable]" in report + + +# ── AutoFixer Tests ─────────────────────────────────── + + +class TestAutoFixer: + def test_apply_suggestions_success(self): + mock_executor = MagicMock() + mock_executor.execute_tool_call.return_value = "Fillet created successfully" + + fixer = AutoFixer(mock_executor) + suggestions = [ + Suggestion("high", "Sharp corner", "edge-1", "Add fillet", + auto_fixable=True, + fix_tool_call={"name": "create_fillet", "args": {"radius": 2}}), + ] + + results = fixer.apply_suggestions(suggestions) + + assert len(results) == 1 + assert results[0]["success"] is True + assert results[0]["tool"] == "create_fillet" + mock_executor.execute_tool_call.assert_called_once_with("create_fillet", {"radius": 2}) + + def test_apply_suggestions_error(self): + mock_executor = MagicMock() + mock_executor.execute_tool_call.return_value = "Error: No edges selected" + + fixer = AutoFixer(mock_executor) + suggestions = [ + Suggestion("high", "Issue", "loc", "Fix", + auto_fixable=True, + fix_tool_call={"name": "create_fillet", "args": {}}), + ] + + results = fixer.apply_suggestions(suggestions) + assert results[0]["success"] is False + + def test_apply_skips_non_fixable(self): + mock_executor = MagicMock() + fixer = AutoFixer(mock_executor) + + suggestions = [ + Suggestion("high", "Thin wall", "side", "Manual fix needed"), + Suggestion("medium", "Sharp corner", "edge", "Add fillet", + auto_fixable=True, + fix_tool_call={"name": "create_fillet", "args": {}}), + ] + + mock_executor.execute_tool_call.return_value = "Done" + results = fixer.apply_suggestions(suggestions) + + assert len(results) == 1 # Only the fixable one + + def test_apply_single_not_fixable(self): + mock_executor = MagicMock() + fixer = AutoFixer(mock_executor) + + s = Suggestion("low", "Minor", "body", "N/A") + result = fixer.apply_single(s) + + assert result["success"] is False + assert result["result"] == "Not auto-fixable" + + def test_apply_single_fixable(self): + mock_executor = MagicMock() + mock_executor.execute_tool_call.return_value = "Fixed" + + fixer = AutoFixer(mock_executor) + s = Suggestion("high", "Issue", "loc", "Fix", + auto_fixable=True, + fix_tool_call={"name": "create_fillet", "args": {}}) + + result = fixer.apply_single(s) + assert result["success"] is True + + def test_apply_empty_list(self): + mock_executor = MagicMock() + fixer = AutoFixer(mock_executor) + + results = fixer.apply_suggestions([]) + assert results == [] diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py new file mode 100644 index 0000000..1503936 --- /dev/null +++ b/tests/test_orchestrator.py @@ -0,0 +1,213 @@ +"""Tests for BuildOrchestrator, focusing on multi-angle verification integration.""" + +from unittest.mock import MagicMock, patch + +from onshape_chat.planning.models import ( + BuildPlan, + PlanStep, + StepStatus, + VerificationResult, +) +from onshape_chat.planning.orchestrator import BuildOrchestrator + + +def _make_orchestrator( + plan_steps: list[PlanStep] | None = None, + tool_output: str = "Success", + multi_angle_images: list[tuple[str, bytes]] | None = None, + verification_result: VerificationResult | None = None, +): + """Create an orchestrator with mocked dependencies.""" + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.document_id = "doc-1" + mock_state.workspace_id = "ws-1" + mock_state.part_studio_id = "ps-1" + mock_state.get_summary.return_value = "Document: test" + + orch = BuildOrchestrator(mock_llm, mock_executor, mock_state) + + # Mock tool execution + mock_executor.execute_tool_call.return_value = tool_output + + # Mock planner + if plan_steps is not None: + plan = BuildPlan( + original_request="Build a cylinder", + steps=plan_steps, + total_steps=len(plan_steps), + ) + orch.planner.create_plan = MagicMock(return_value=plan) + + # Mock multi-angle capture + if multi_angle_images is not None: + orch._get_multi_angle_screenshots = MagicMock(return_value=multi_angle_images) + else: + orch._get_multi_angle_screenshots = MagicMock(return_value=None) + + # Mock single screenshot fallback + orch._get_screenshot = MagicMock(return_value=b"\x89PNG") + + # Mock verifier + if verification_result: + orch.verifier.verify_step_multi_angle = MagicMock(return_value=verification_result) + orch.verifier.verify_step = MagicMock(return_value=verification_result) + + return orch + + +class TestMultiAngleInStepVerification: + """Tests for multi-angle verification during step execution.""" + + def test_geometry_step_uses_multi_angle(self): + """Geometry tools trigger multi-angle verification.""" + images = [(f"View-{i}", b"\x89PNG") for i in range(14)] + vr = VerificationResult(passed=True, issues="") + + step = PlanStep(step_number=1, description="Extrude", tool="extrude", args={"depth": 50}) + plan = BuildPlan(original_request="Build", steps=[step]) + + orch = _make_orchestrator( + multi_angle_images=images, + verification_result=vr, + ) + + result = orch._execute_step(step, plan) + + assert result.step.status == StepStatus.PASSED + orch.verifier.verify_step_multi_angle.assert_called_once() + orch.verifier.verify_step.assert_not_called() + + def test_geometry_step_falls_back_to_single(self): + """When multi-angle returns None, falls back to single screenshot.""" + vr = VerificationResult(passed=True, issues="") + + step = PlanStep(step_number=1, description="Extrude", tool="extrude", args={"depth": 50}) + plan = BuildPlan(original_request="Build", steps=[step]) + + orch = _make_orchestrator( + multi_angle_images=None, # Multi-angle unavailable + verification_result=vr, + ) + + result = orch._execute_step(step, plan) + + assert result.step.status == StepStatus.PASSED + orch.verifier.verify_step.assert_called_once() + orch.verifier.verify_step_multi_angle.assert_not_called() + + def test_non_geometry_step_skips_verification(self): + """Non-geometry tools skip visual verification entirely.""" + step = PlanStep( + step_number=1, description="Create doc", tool="create_document", args={"name": "Test"} + ) + plan = BuildPlan(original_request="Build", steps=[step]) + + orch = _make_orchestrator() + + result = orch._execute_step(step, plan) + + assert result.step.status == StepStatus.PASSED + orch._get_multi_angle_screenshots.assert_not_called() + + +class TestMultiAngleInFinalVerify: + """Tests for multi-angle verification in final check.""" + + def test_final_verify_uses_multi_angle(self): + """_final_verify uses multi-angle when available.""" + images = [(f"View-{i}", b"\x89PNG") for i in range(14)] + vr = VerificationResult(passed=True, issues="") + + plan = BuildPlan(original_request="Build a mug", steps=[]) + + orch = _make_orchestrator( + multi_angle_images=images, + verification_result=vr, + ) + + result = orch._final_verify(plan) + + assert result is not None + assert result.passed is True + orch.verifier.verify_step_multi_angle.assert_called_once() + + def test_final_verify_fallback_single(self): + """_final_verify falls back to single screenshot when multi-angle unavailable.""" + vr = VerificationResult(passed=True, issues="") + plan = BuildPlan(original_request="Build", steps=[]) + + orch = _make_orchestrator( + multi_angle_images=None, + verification_result=vr, + ) + + result = orch._final_verify(plan) + + assert result is not None + orch.verifier.verify_step.assert_called_once() + + +class TestGetMultiAngleScreenshots: + """Tests for _get_multi_angle_screenshots method.""" + + @patch("onshape_chat.planning.orchestrator.capture_all_views") + def test_returns_images(self, mock_capture): + """Returns images from capture_all_views.""" + mock_capture.return_value = [("Front", b"\x89PNG")] + + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.document_id = "doc-1" + mock_state.workspace_id = "ws-1" + mock_state.part_studio_id = "ps-1" + + orch = BuildOrchestrator(mock_llm, mock_executor, mock_state) + result = orch._get_multi_angle_screenshots() + + assert result == [("Front", b"\x89PNG")] + mock_capture.assert_called_once_with( + exports=mock_executor.exports, + document_id="doc-1", + workspace_id="ws-1", + element_id="ps-1", + ) + + def test_returns_none_without_document(self): + """Returns None when no document is set.""" + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.document_id = None + + orch = BuildOrchestrator(mock_llm, mock_executor, mock_state) + assert orch._get_multi_angle_screenshots() is None + + def test_returns_none_without_part_studio(self): + """Returns None when no part studio is set.""" + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.document_id = "doc-1" + mock_state.workspace_id = "ws-1" + mock_state.part_studio_id = None + + orch = BuildOrchestrator(mock_llm, mock_executor, mock_state) + assert orch._get_multi_angle_screenshots() is None + + @patch("onshape_chat.planning.orchestrator.capture_all_views") + def test_returns_none_on_exception(self, mock_capture): + """Returns None when capture_all_views raises.""" + mock_capture.side_effect = RuntimeError("API failure") + + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.document_id = "doc-1" + mock_state.workspace_id = "ws-1" + mock_state.part_studio_id = "ps-1" + + orch = BuildOrchestrator(mock_llm, mock_executor, mock_state) + assert orch._get_multi_angle_screenshots() is None diff --git a/tests/test_planning.py b/tests/test_planning.py new file mode 100644 index 0000000..ea0db50 --- /dev/null +++ b/tests/test_planning.py @@ -0,0 +1,340 @@ +"""Tests for the planning, verification, and orchestration pipeline.""" + +import json +from unittest.mock import MagicMock + +from onshape_chat.planning.models import ( + BuildPlan, + PlanStep, + StepStatus, + VerificationResult, +) +from onshape_chat.planning.orchestrator import BuildOrchestrator +from onshape_chat.planning.planner import Planner +from onshape_chat.verification.verifier import Verifier + +# ── Model Tests ────────────────────────────────────── + + +class TestPlanStep: + def test_defaults(self): + step = PlanStep(step_number=1, description="test", tool="extrude", args={"depth": 10}) + assert step.status == StepStatus.PENDING + assert step.retry_count == 0 + assert step.error is None + + def test_fields(self): + step = PlanStep( + step_number=2, + description="Create circle", + tool="create_sketch_circle", + args={"plane": "XY", "radius": 25}, + status=StepStatus.PASSED, + ) + assert step.tool == "create_sketch_circle" + assert step.args["radius"] == 25 + + +class TestBuildPlan: + def test_empty_plan(self): + plan = BuildPlan(original_request="test") + assert plan.total_steps == 0 + assert plan.completed_steps == 0 + assert plan.is_complete + + def test_progress_tracking(self): + steps = [ + PlanStep(step_number=1, description="a", tool="t", args={}, status=StepStatus.PASSED), + PlanStep(step_number=2, description="b", tool="t", args={}, status=StepStatus.FAILED), + PlanStep(step_number=3, description="c", tool="t", args={}, status=StepStatus.PENDING), + ] + plan = BuildPlan(original_request="test", steps=steps) + assert plan.total_steps == 3 + assert plan.completed_steps == 1 + assert not plan.is_complete + + def test_summary(self): + steps = [ + PlanStep(step_number=1, description="Create doc", tool="create_document", args={}, status=StepStatus.PASSED), + ] + plan = BuildPlan(original_request="make a box", steps=steps) + summary = plan.summary() + assert "make a box" in summary + assert "Create doc" in summary + + +class TestVerificationResult: + def test_pass(self): + r = VerificationResult(passed=True) + assert r.passed + assert r.issues == "" + + def test_fail(self): + r = VerificationResult(passed=False, issues="wrong shape", suggestion="try again") + assert not r.passed + assert "wrong shape" in r.issues + + +# ── Planner Tests ──────────────────────────────────── + + +class TestPlanner: + def test_create_plan_basic(self): + mock_llm = MagicMock() + plan_json = json.dumps([ + {"step_number": 1, "description": "Create document", "tool": "create_document", "args": {"name": "Box"}}, + {"step_number": 2, "description": "Sketch rectangle", "tool": "create_sketch_rectangle", "args": {"plane": "XY", "width": 50, "height": 50}}, + {"step_number": 3, "description": "Extrude box", "tool": "extrude", "args": {"depth": 30}}, + ]) + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = plan_json + mock_llm.chat.return_value = mock_response + + planner = Planner(mock_llm) + plan = planner.create_plan("make a box") + + assert len(plan.steps) == 3 + assert plan.steps[0].tool == "create_document" + assert plan.steps[1].tool == "create_sketch_rectangle" + assert plan.steps[2].args["depth"] == 30 + assert plan.original_request == "make a box" + + def test_create_plan_with_code_fences(self): + mock_llm = MagicMock() + plan_json = '```json\n[{"step_number": 1, "description": "test", "tool": "extrude", "args": {"depth": 10}}]\n```' + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = plan_json + mock_llm.chat.return_value = mock_response + + planner = Planner(mock_llm) + plan = planner.create_plan("test") + + assert len(plan.steps) == 1 + assert plan.steps[0].tool == "extrude" + + def test_create_plan_invalid_json(self): + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "not json at all" + mock_llm.chat.return_value = mock_response + + planner = Planner(mock_llm) + plan = planner.create_plan("test") + + assert len(plan.steps) == 0 + + def test_parse_steps_skips_non_dicts(self): + steps = Planner._parse_steps('[1, "bad", {"step_number": 1, "description": "ok", "tool": "t", "args": {}}]') + assert len(steps) == 1 + + +# ── Verifier Tests ─────────────────────────────────── + + +class TestVerifier: + def test_verify_step_pass(self): + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"pass": true, "issues": "", "suggestion": ""}' + mock_llm.chat_with_image.return_value = mock_response + + verifier = Verifier(mock_llm) + step = PlanStep(step_number=1, description="test", tool="extrude", args={"depth": 10}) + result = verifier.verify_step(b"fake_image", step, "make a box") + + assert result.passed + assert result.issues == "" + + def test_verify_step_fail(self): + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '{"pass": false, "issues": "wrong shape", "suggestion": "use circle"}' + mock_llm.chat_with_image.return_value = mock_response + + verifier = Verifier(mock_llm) + step = PlanStep(step_number=1, description="test", tool="extrude", args={}) + result = verifier.verify_step(b"fake_image", step, "goal") + + assert not result.passed + assert "wrong shape" in result.issues + assert "circle" in result.suggestion + + def test_verify_step_api_error_defaults_to_pass(self): + mock_llm = MagicMock() + mock_llm.chat_with_image.side_effect = Exception("API down") + + verifier = Verifier(mock_llm) + step = PlanStep(step_number=1, description="test", tool="extrude", args={}) + result = verifier.verify_step(b"fake_image", step, "goal") + + assert result.passed # graceful degradation + + def test_parse_result_invalid_json(self): + result = Verifier._parse_result("not json") + assert result.passed # defaults to pass on parse failure + + def test_parse_result_with_code_fences(self): + result = Verifier._parse_result('```json\n{"pass": false, "issues": "bad", "suggestion": "fix"}\n```') + assert not result.passed + assert result.issues == "bad" + + +# ── Orchestrator Tests ─────────────────────────────── + + +class TestBuildOrchestrator: + def _make_orchestrator(self, plan_steps_json, tool_outputs=None, screenshot=None, verify_results=None): + """Helper to create an orchestrator with mocked dependencies.""" + mock_llm = MagicMock() + mock_executor = MagicMock() + mock_state = MagicMock() + mock_state.get_summary.return_value = "No active document yet" + mock_state.document_id = "doc-1" + mock_state.workspace_id = "ws-1" + mock_state.part_studio_id = "ps-1" + + # Mock planner response + mock_plan_response = MagicMock() + mock_plan_response.choices = [MagicMock()] + mock_plan_response.choices[0].message.content = json.dumps(plan_steps_json) + + # Mock verification response + verify_results = verify_results or [{"pass": True, "issues": "", "suggestion": ""}] + verify_idx = [0] + + def mock_chat_with_image(messages, image_bytes, **kwargs): + resp = MagicMock() + resp.choices = [MagicMock()] + idx = min(verify_idx[0], len(verify_results) - 1) + resp.choices[0].message.content = json.dumps(verify_results[idx]) + verify_idx[0] += 1 + return resp + + def mock_chat_with_images(messages, images, **kwargs): + resp = MagicMock() + resp.choices = [MagicMock()] + idx = min(verify_idx[0], len(verify_results) - 1) + resp.choices[0].message.content = json.dumps(verify_results[idx]) + verify_idx[0] += 1 + return resp + + mock_llm.chat.return_value = mock_plan_response + mock_llm.chat_with_image.side_effect = mock_chat_with_image + mock_llm.chat_with_images.side_effect = mock_chat_with_images + + # Mock tool execution + tool_outputs = tool_outputs or ["Success"] + output_idx = [0] + + def mock_execute(tool, args): + idx = min(output_idx[0], len(tool_outputs) - 1) + result = tool_outputs[idx] + output_idx[0] += 1 + return result + + mock_executor.execute_tool_call.side_effect = mock_execute + mock_executor.undo.return_value = "Undid last" + + # Mock screenshot + mock_executor.exports = MagicMock() + mock_executor.exports.get_shaded_view.return_value = screenshot or b"fake_png" + + orch = BuildOrchestrator(llm=mock_llm, executor=mock_executor, state=mock_state) + return orch + + def test_execute_plan_all_pass(self): + steps = [ + {"step_number": 1, "description": "Create doc", "tool": "create_document", "args": {"name": "Test"}}, + {"step_number": 2, "description": "Sketch", "tool": "create_sketch_circle", "args": {"plane": "XY", "radius": 10}}, + {"step_number": 3, "description": "Extrude", "tool": "extrude", "args": {"depth": 20}}, + ] + + orch = self._make_orchestrator(steps, tool_outputs=["Created doc", "Created sketch", "Extruded"]) + result = orch.execute_plan("make a cylinder") + + assert result.success + assert len(result.step_results) == 3 + assert all(sr.step.status == StepStatus.PASSED for sr in result.step_results) + + def test_execute_plan_empty_plan(self): + orch = self._make_orchestrator([]) + result = orch.execute_plan("garbage request") + + assert not result.success + assert "Failed to create" in result.summary_message + + def test_execute_plan_tool_error_no_retry_on_missing_doc(self): + steps = [ + {"step_number": 1, "description": "Sketch", "tool": "create_sketch_circle", "args": {"plane": "XY", "radius": 10}}, + ] + + orch = self._make_orchestrator(steps, tool_outputs=["Error: No document created yet."]) + result = orch.execute_plan("make a circle") + + assert not result.success + assert result.step_results[0].step.status == StepStatus.FAILED + + def test_execute_plan_verification_retry(self): + # Use extrude (a GEOMETRY_TOOL that triggers visual verification) + steps = [ + {"step_number": 1, "description": "Extrude cylinder", "tool": "extrude", "args": {"depth": 50}}, + ] + + # First verify fails, second passes + verify_results = [ + {"pass": False, "issues": "wrong depth", "suggestion": "increase depth"}, + {"pass": True, "issues": "", "suggestion": ""}, + {"pass": True, "issues": "", "suggestion": ""}, # final verify + ] + + orch = self._make_orchestrator(steps, tool_outputs=["Extruded", "Extruded"], verify_results=verify_results) + result = orch.execute_plan("make a cylinder") + + assert result.step_results[0].step.status == StepStatus.PASSED + assert result.step_results[0].retries_used == 1 + + def test_callbacks_called(self): + steps = [ + {"step_number": 1, "description": "Create doc", "tool": "create_document", "args": {"name": "Test"}}, + ] + + orch = self._make_orchestrator(steps, tool_outputs=["Created doc"]) + plan_cb = MagicMock() + step_start_cb = MagicMock() + step_result_cb = MagicMock() + orch.on_plan_created = plan_cb + orch.on_step_start = step_start_cb + orch.on_step_result = step_result_cb + + orch.execute_plan("test") + + plan_cb.assert_called_once() + step_start_cb.assert_called_once() + step_result_cb.assert_called_once() + + +# ── Planner Tool Filtering ──────────────────────────── + + +class TestPlannerToolFiltering: + def test_run_featurescript_excluded(self): + """Planner should not offer run_featurescript as a tool option.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "[]" + mock_llm.chat.return_value = mock_response + + planner = Planner(mock_llm) + tool_names = {t["function"]["name"] for t in planner.tools} + + assert "run_featurescript" not in tool_names + assert "create_sketch_from_points" in tool_names + assert "extrude" in tool_names diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..5d8cef2 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,305 @@ +"""Tests for Phase 4d: Parametric Templates.""" + +import math + +import pytest + +from onshape_chat.templates.fasteners import ( + HEX_BOLT, + HEX_NUT, + WASHER, + build_hex_bolt, + build_hex_nut, + build_washer, +) +from onshape_chat.templates.gears import ( + RACK_GEAR, + SPUR_GEAR, + build_rack_gear, + build_spur_gear, +) +from onshape_chat.templates.registry import Template, TemplateParam, TemplateRegistry +from onshape_chat.templates.structural import ( + L_BRACKET, + MOUNTING_PLATE, + build_l_bracket, + build_mounting_plate, +) + +# ── TemplateParam Tests ─────────────────────────────── + + +class TestTemplateParam: + def test_basic_param(self): + p = TemplateParam("width", "float", "Width in mm") + assert p.name == "width" + assert p.type == "float" + assert p.required is True + + def test_optional_param(self): + p = TemplateParam("bore", "float", "Bore diameter", required=False, default=5.0) + assert p.required is False + assert p.default == 5.0 + + def test_range_param(self): + p = TemplateParam("teeth", "int", "Num teeth", min_value=8, max_value=200) + assert p.min_value == 8 + assert p.max_value == 200 + + +# ── Template Tests ──────────────────────────────────── + + +class TestTemplate: + def test_get_param_info(self): + t = Template( + name="test", display_name="Test", description="A test", + params=[ + TemplateParam("x", "float", "X value", default=1.0), + TemplateParam("y", "int", "Y value", required=False, default=0), + ], + ) + info = t.get_param_info() + assert len(info) == 2 + assert info[0]["name"] == "x" + assert info[1]["required"] is False + + +# ── TemplateRegistry Tests ──────────────────────────── + + +class TestTemplateRegistry: + def test_register_and_get(self): + reg = TemplateRegistry() + t = Template(name="test", display_name="Test", description="desc") + reg.register(t) + + result = reg.get("test") + assert result.name == "test" + + def test_get_unknown(self): + reg = TemplateRegistry() + with pytest.raises(ValueError, match="Unknown template"): + reg.get("nonexistent") + + def test_list_templates(self): + reg = TemplateRegistry() + reg.register(Template(name="a", display_name="A", description="desc A")) + reg.register(Template(name="b", display_name="B", description="desc B")) + + templates = reg.list_templates() + assert len(templates) == 2 + names = {t["name"] for t in templates} + assert names == {"a", "b"} + + def test_validate_params_basic(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[ + TemplateParam("width", "float", "Width"), + TemplateParam("count", "int", "Count"), + ], + )) + + validated = reg.validate_params("test", {"width": "10.5", "count": "3"}) + assert validated["width"] == 10.5 + assert validated["count"] == 3 + + def test_validate_params_missing_required(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[TemplateParam("width", "float", "Width")], + )) + + with pytest.raises(ValueError, match="Missing required"): + reg.validate_params("test", {}) + + def test_validate_params_optional_default(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[ + TemplateParam("x", "float", "X", required=False, default=5.0), + ], + )) + + validated = reg.validate_params("test", {}) + assert validated["x"] == 5.0 + + def test_validate_params_below_min(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[ + TemplateParam("x", "float", "X", min_value=1.0), + ], + )) + + with pytest.raises(ValueError, match="must be >= 1.0"): + reg.validate_params("test", {"x": 0.5}) + + def test_validate_params_above_max(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[ + TemplateParam("x", "float", "X", max_value=10.0), + ], + )) + + with pytest.raises(ValueError, match="must be <= 10.0"): + reg.validate_params("test", {"x": 15.0}) + + def test_execute_template(self): + def builder(**params): + return {"result": params["x"] * 2} + + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + params=[TemplateParam("x", "float", "X")], + builder=builder, + )) + + result = reg.execute_template("test", {"x": 5.0}) + assert result["result"] == 10.0 + + def test_execute_template_no_builder(self): + reg = TemplateRegistry() + reg.register(Template( + name="test", display_name="Test", description="desc", + )) + + with pytest.raises(ValueError, match="no builder"): + reg.execute_template("test", {}) + + +# ── Gear Template Tests ─────────────────────────────── + + +class TestGearTemplates: + def test_spur_gear_template_exists(self): + assert SPUR_GEAR.name == "spur_gear" + assert len(SPUR_GEAR.params) >= 3 + + def test_build_spur_gear_basic(self): + result = build_spur_gear(module=2.0, num_teeth=20, thickness=10.0) + + assert result["template"] == "spur_gear" + assert result["pitch_diameter"] == 40.0 # 2 * 20 + assert result["outer_diameter"] == 44.0 # 40 + 2*2 + assert result["root_diameter"] == 35.0 # 40 - 2.5*2 + assert result["tooth_angle"] == 18.0 # 360 / 20 + assert len(result["operations"]) >= 2 + + def test_build_spur_gear_with_bore(self): + result = build_spur_gear(module=2.0, num_teeth=20, thickness=10.0, bore_diameter=8.0) + assert result["bore_diameter"] == 8.0 + assert len(result["operations"]) == 3 # circle + extrude + bore + + def test_rack_gear_template_exists(self): + assert RACK_GEAR.name == "rack_gear" + + def test_build_rack_gear(self): + result = build_rack_gear(module=2.0, num_teeth=10, width=20.0, height=15.0) + + assert result["template"] == "rack_gear" + assert result["tooth_pitch"] == pytest.approx(math.pi * 2.0) + assert result["total_length"] == pytest.approx(math.pi * 2.0 * 10) + assert result["tooth_height"] == pytest.approx(2.25 * 2.0) + + +# ── Fastener Template Tests ────────────────────────── + + +class TestFastenerTemplates: + def test_hex_bolt(self): + result = build_hex_bolt(diameter=6.0, length=20.0) + assert result["template"] == "hex_bolt" + assert result["diameter"] == 6.0 + assert result["length"] == 20.0 + assert result["head_across_flats"] == 10.0 # ISO standard for M6 + assert len(result["operations"]) >= 3 + + def test_hex_bolt_custom_head(self): + result = build_hex_bolt(diameter=6.0, length=20.0, head_height=5.0) + assert result["head_height"] == 5.0 + + def test_hex_nut(self): + result = build_hex_nut(diameter=6.0) + assert result["template"] == "hex_nut" + assert result["across_flats"] == 10.0 + assert len(result["operations"]) >= 2 + + def test_washer(self): + result = build_washer(inner_diameter=6.5, outer_diameter=12.0, thickness=1.6) + assert result["template"] == "washer" + assert len(result["operations"]) >= 2 + + def test_template_objects_exist(self): + assert HEX_BOLT.name == "hex_bolt" + assert HEX_NUT.name == "hex_nut" + assert WASHER.name == "washer" + + +# ── Structural Template Tests ──────────────────────── + + +class TestStructuralTemplates: + def test_l_bracket(self): + result = build_l_bracket(width=50, height=50, depth=30, thickness=3) + assert result["template"] == "l_bracket" + assert result["hole_diameter"] is None + assert len(result["operations"]) == 2 + + def test_l_bracket_with_holes(self): + result = build_l_bracket(width=50, height=50, depth=30, thickness=3, hole_diameter=5.0) + assert result["hole_diameter"] == 5.0 + assert len(result["operations"]) == 4 # L-shape + extrude + 2 holes + + def test_mounting_plate_grid(self): + result = build_mounting_plate( + width=80, height=60, thickness=3, hole_diameter=5.0, hole_pattern="grid" + ) + assert result["template"] == "mounting_plate" + assert result["hole_pattern"] == "grid" + + def test_mounting_plate_circle(self): + result = build_mounting_plate( + width=80, height=60, thickness=3, hole_diameter=5.0, hole_pattern="circle" + ) + assert result["hole_pattern"] == "circle" + + def test_template_objects_exist(self): + assert L_BRACKET.name == "l_bracket" + assert MOUNTING_PLATE.name == "mounting_plate" + + +# ── Full Registry Integration ───────────────────────── + + +class TestRegistryIntegration: + def test_register_all_templates(self): + reg = TemplateRegistry() + for t in [SPUR_GEAR, RACK_GEAR, HEX_BOLT, HEX_NUT, WASHER, L_BRACKET, MOUNTING_PLATE]: + reg.register(t) + + templates = reg.list_templates() + assert len(templates) == 7 + + def test_execute_spur_gear_via_registry(self): + reg = TemplateRegistry() + reg.register(SPUR_GEAR) + + result = reg.execute_template("spur_gear", {"module": 3, "num_teeth": 15, "thickness": 8}) + assert result["pitch_diameter"] == 45.0 + + def test_validate_and_execute_hex_bolt(self): + reg = TemplateRegistry() + reg.register(HEX_BOLT) + + result = reg.execute_template("hex_bolt", {"diameter": 8, "length": 30}) + assert result["template"] == "hex_bolt" + assert result["head_across_flats"] == 13.0 # ISO M8 diff --git a/tests/test_templates_extended.py b/tests/test_templates_extended.py new file mode 100644 index 0000000..b80a6d4 --- /dev/null +++ b/tests/test_templates_extended.py @@ -0,0 +1,332 @@ +"""Tests for Phase 5d: Additional template libraries.""" + +import math + +import pytest + +from onshape_chat.templates.bearings import ( + BALL_BEARING, + BEARING_HOUSING, + build_ball_bearing, + build_bearing_housing, +) +from onshape_chat.templates.enclosures import ( + BOX_ENCLOSURE, + ELECTRONICS_CASE, + build_box_enclosure, + build_electronics_case, +) +from onshape_chat.templates.fasteners import HEX_BOLT, HEX_NUT, WASHER +from onshape_chat.templates.fittings import ( + PIPE_ELBOW, + PIPE_FLANGE, + build_pipe_elbow, + build_pipe_flange, +) +from onshape_chat.templates.gears import RACK_GEAR, SPUR_GEAR +from onshape_chat.templates.registry import TemplateRegistry +from onshape_chat.templates.springs import ( + COMPRESSION_SPRING, + TORSION_SPRING, + build_compression_spring, + build_torsion_spring, +) +from onshape_chat.templates.structural import L_BRACKET, MOUNTING_PLATE + +# ── Enclosure Template Tests ──────────────────────────── + + +class TestEnclosureTemplates: + def test_box_enclosure_template_exists(self): + assert BOX_ENCLOSURE.name == "box_enclosure" + assert len(BOX_ENCLOSURE.params) >= 4 + + def test_build_box_enclosure_basic(self): + result = build_box_enclosure( + length=100.0, width=60.0, height=40.0, wall_thickness=2.0, + ) + assert result["template"] == "box_enclosure" + assert result["inner_length"] == 96.0 # 100 - 2*2 + assert result["inner_width"] == 56.0 # 60 - 2*2 + assert result["inner_height"] == 38.0 # 40 - 2 + assert result["has_lid"] is False + assert len(result["operations"]) == 3 + + def test_build_box_enclosure_with_lid(self): + result = build_box_enclosure( + length=100.0, width=60.0, height=40.0, + wall_thickness=2.0, has_lid=True, lid_thickness=3.0, + ) + assert result["has_lid"] is True + assert result["lid_thickness"] == 3.0 + assert len(result["operations"]) == 4 + + def test_build_box_enclosure_lid_defaults_to_wall_thickness(self): + result = build_box_enclosure( + length=100.0, width=60.0, height=40.0, + wall_thickness=2.5, has_lid=True, + ) + assert result["lid_thickness"] == 2.5 + + def test_electronics_case_template_exists(self): + assert ELECTRONICS_CASE.name == "electronics_case" + assert len(ELECTRONICS_CASE.params) >= 8 + + def test_build_electronics_case_basic(self): + result = build_electronics_case( + length=120.0, width=80.0, height=35.0, wall_thickness=2.0, + pcb_length=100.0, pcb_width=60.0, + standoff_height=5.0, standoff_diameter=6.0, + ) + assert result["template"] == "electronics_case" + assert result["inner_length"] == 116.0 # 120 - 2*2 + assert result["inner_width"] == 76.0 # 80 - 2*2 + assert result["pcb_offset_x"] == 8.0 # (116 - 100) / 2 + assert result["pcb_offset_y"] == 8.0 # (76 - 60) / 2 + assert result["vent_slots"] is False + assert len(result["operations"]) == 4 + + def test_build_electronics_case_with_vents(self): + result = build_electronics_case( + length=120.0, width=80.0, height=35.0, wall_thickness=2.0, + pcb_length=100.0, pcb_width=60.0, + standoff_height=5.0, standoff_diameter=6.0, + vent_slots=True, + ) + assert result["vent_slots"] is True + assert len(result["operations"]) == 5 + + +# ── Bearing Template Tests ─────────────────────────────── + + +class TestBearingTemplates: + def test_ball_bearing_template_exists(self): + assert BALL_BEARING.name == "ball_bearing" + assert len(BALL_BEARING.params) == 3 + + def test_build_ball_bearing_basic(self): + result = build_ball_bearing( + bore_diameter=10.0, outer_diameter=30.0, width=9.0, + ) + assert result["template"] == "ball_bearing" + assert result["mean_diameter"] == 20.0 # (10 + 30) / 2 + assert result["radial_thickness"] == 10.0 # (30 - 10) / 2 + assert len(result["operations"]) == 3 + + def test_build_ball_bearing_computed_values(self): + result = build_ball_bearing( + bore_diameter=25.0, outer_diameter=52.0, width=15.0, + ) + assert result["mean_diameter"] == pytest.approx(38.5) + assert result["radial_thickness"] == pytest.approx(13.5) + + def test_bearing_housing_template_exists(self): + assert BEARING_HOUSING.name == "bearing_housing" + assert len(BEARING_HOUSING.params) == 6 + + def test_build_bearing_housing_basic(self): + result = build_bearing_housing( + bore_diameter=25.0, base_width=50.0, base_length=80.0, + base_height=15.0, bolt_diameter=10.0, bolt_spacing=60.0, + ) + assert result["template"] == "bearing_housing" + assert result["housing_od"] == 62.5 # 25 * 2.5 + assert result["total_height"] == 46.25 # 15 + 62.5/2 + assert len(result["operations"]) == 4 + + +# ── Spring Template Tests ──────────────────────────────── + + +class TestSpringTemplates: + def test_compression_spring_template_exists(self): + assert COMPRESSION_SPRING.name == "compression_spring" + assert len(COMPRESSION_SPRING.params) >= 4 + + def test_build_compression_spring_basic(self): + result = build_compression_spring( + wire_diameter=1.5, coil_diameter=12.0, + num_coils=8, free_length=40.0, + ) + assert result["template"] == "compression_spring" + assert result["coil_length"] == pytest.approx(math.pi * 12.0 * 8) + assert result["spring_index"] == pytest.approx(8.0) # 12 / 1.5 + assert result["pitch"] == pytest.approx(5.0) # 40 / 8 + assert result["inner_diameter"] == pytest.approx(10.5) # 12 - 1.5 + assert result["outer_diameter"] == pytest.approx(13.5) # 12 + 1.5 + assert result["ground_ends"] is False + assert len(result["operations"]) == 2 + + def test_build_compression_spring_ground_ends(self): + result = build_compression_spring( + wire_diameter=2.0, coil_diameter=15.0, + num_coils=6, free_length=30.0, ground_ends=True, + ) + assert result["ground_ends"] is True + assert len(result["operations"]) == 3 + + def test_compression_spring_coil_length_formula(self): + """Verify coil_length = pi * coil_diameter * num_coils.""" + for d, n in [(10.0, 5), (20.0, 10), (8.0, 3)]: + result = build_compression_spring( + wire_diameter=1.0, coil_diameter=d, + num_coils=n, free_length=50.0, + ) + expected = math.pi * d * n + assert result["coil_length"] == pytest.approx(expected) + + def test_torsion_spring_template_exists(self): + assert TORSION_SPRING.name == "torsion_spring" + assert len(TORSION_SPRING.params) == 5 + + def test_build_torsion_spring_basic(self): + result = build_torsion_spring( + wire_diameter=1.0, coil_diameter=10.0, + num_coils=5, leg_length=15.0, leg_angle=90.0, + ) + assert result["template"] == "torsion_spring" + assert result["coil_length"] == pytest.approx(math.pi * 10.0 * 5) + assert result["body_length"] == pytest.approx(1.0 * 6) # wire * (coils + 1) + assert result["inner_diameter"] == pytest.approx(9.0) + assert result["outer_diameter"] == pytest.approx(11.0) + assert result["leg_angle_rad"] == pytest.approx(math.radians(90.0)) + assert len(result["operations"]) == 4 + + +# ── Fittings Template Tests ────────────────────────────── + + +class TestFittingsTemplates: + def test_pipe_flange_template_exists(self): + assert PIPE_FLANGE.name == "pipe_flange" + assert len(PIPE_FLANGE.params) == 6 + + def test_build_pipe_flange_basic(self): + result = build_pipe_flange( + nominal_pipe_size=50.0, flange_diameter=125.0, + flange_thickness=16.0, bolt_circle_diameter=100.0, + num_bolts=4, bolt_hole_diameter=14.0, + ) + assert result["template"] == "pipe_flange" + assert result["bolt_angle_spacing"] == pytest.approx(90.0) # 360 / 4 + assert result["pipe_inner_radius"] == pytest.approx(25.0) # 50 / 2 + assert len(result["operations"]) == 4 + + def test_build_pipe_flange_many_bolts(self): + result = build_pipe_flange( + nominal_pipe_size=100.0, flange_diameter=220.0, + flange_thickness=20.0, bolt_circle_diameter=180.0, + num_bolts=8, bolt_hole_diameter=18.0, + ) + assert result["bolt_angle_spacing"] == pytest.approx(45.0) # 360 / 8 + + def test_pipe_elbow_template_exists(self): + assert PIPE_ELBOW.name == "pipe_elbow" + assert len(PIPE_ELBOW.params) == 3 + + def test_build_pipe_elbow_basic(self): + result = build_pipe_elbow( + nominal_pipe_size=50.0, wall_thickness=3.0, bend_radius=75.0, + ) + assert result["template"] == "pipe_elbow" + assert result["outer_diameter"] == pytest.approx(56.0) # 50 + 2*3 + assert result["inner_diameter"] == pytest.approx(50.0) + assert result["arc_length"] == pytest.approx(math.pi / 2 * 75.0) + assert len(result["operations"]) == 3 + + def test_build_pipe_elbow_computed_arc_length(self): + """Verify arc_length = pi/2 * bend_radius for 90-degree elbow.""" + for r in [50.0, 100.0, 150.0]: + result = build_pipe_elbow( + nominal_pipe_size=30.0, wall_thickness=2.0, bend_radius=r, + ) + expected = math.pi / 2 * r + assert result["arc_length"] == pytest.approx(expected) + + +# ── Full Registry Integration ──────────────────────────── + + +class TestExtendedRegistryIntegration: + def test_register_all_15_templates(self): + reg = TemplateRegistry() + all_templates = [ + # Original 7 + SPUR_GEAR, RACK_GEAR, + HEX_BOLT, HEX_NUT, WASHER, + L_BRACKET, MOUNTING_PLATE, + # New 8 + BOX_ENCLOSURE, ELECTRONICS_CASE, + BALL_BEARING, BEARING_HOUSING, + COMPRESSION_SPRING, TORSION_SPRING, + PIPE_FLANGE, PIPE_ELBOW, + ] + for t in all_templates: + reg.register(t) + + templates = reg.list_templates() + assert len(templates) == 15 + + def test_execute_box_enclosure_via_registry(self): + reg = TemplateRegistry() + reg.register(BOX_ENCLOSURE) + + result = reg.execute_template("box_enclosure", { + "length": 80, "width": 50, "height": 30, "wall_thickness": 2, + }) + assert result["template"] == "box_enclosure" + assert result["inner_length"] == 76.0 + + def test_execute_compression_spring_via_registry(self): + reg = TemplateRegistry() + reg.register(COMPRESSION_SPRING) + + result = reg.execute_template("compression_spring", { + "wire_diameter": 2.0, "coil_diameter": 15.0, + "num_coils": 10, "free_length": 50.0, + }) + assert result["template"] == "compression_spring" + assert result["coil_length"] == pytest.approx(math.pi * 15.0 * 10) + + def test_execute_pipe_flange_via_registry(self): + reg = TemplateRegistry() + reg.register(PIPE_FLANGE) + + result = reg.execute_template("pipe_flange", { + "nominal_pipe_size": 50, "flange_diameter": 125, + "flange_thickness": 16, "bolt_circle_diameter": 100, + "num_bolts": 6, "bolt_hole_diameter": 14, + }) + assert result["template"] == "pipe_flange" + assert result["bolt_angle_spacing"] == pytest.approx(60.0) + + def test_execute_ball_bearing_via_registry(self): + reg = TemplateRegistry() + reg.register(BALL_BEARING) + + result = reg.execute_template("ball_bearing", { + "bore_diameter": 20, "outer_diameter": 42, "width": 12, + }) + assert result["template"] == "ball_bearing" + assert result["mean_diameter"] == pytest.approx(31.0) + + def test_all_new_templates_have_builders(self): + """Verify every new template has a builder function assigned.""" + for t in [BOX_ENCLOSURE, ELECTRONICS_CASE, BALL_BEARING, + BEARING_HOUSING, COMPRESSION_SPRING, TORSION_SPRING, + PIPE_FLANGE, PIPE_ELBOW]: + assert t.builder is not None, f"{t.name} has no builder" + + def test_all_new_template_names_unique(self): + """Verify all template names are unique across all 15.""" + all_templates = [ + SPUR_GEAR, RACK_GEAR, HEX_BOLT, HEX_NUT, WASHER, + L_BRACKET, MOUNTING_PLATE, + BOX_ENCLOSURE, ELECTRONICS_CASE, + BALL_BEARING, BEARING_HOUSING, + COMPRESSION_SPRING, TORSION_SPRING, + PIPE_FLANGE, PIPE_ELBOW, + ] + names = [t.name for t in all_templates] + assert len(names) == len(set(names)) diff --git a/tests/test_tools_definitions.py b/tests/test_tools_definitions.py new file mode 100644 index 0000000..cedc288 --- /dev/null +++ b/tests/test_tools_definitions.py @@ -0,0 +1,392 @@ +"""Tests for tool definitions.""" + + +from onshape_chat.llm.tools import ( + ASSEMBLY_TOOLS, + DOCUMENT_TOOLS, + EXPORT_TOOLS, + FEATURE_TOOLS, + FEATURESCRIPT_TOOLS, + SKETCH_TOOLS, + TOOLS, + UNDO_TOOLS, + get_tool_definitions, +) + + +def test_tool_count(): + """Test total number of tools is 15 (featurescript disabled).""" + # Count tools in each category (FEATURESCRIPT_TOOLS excluded from TOOLS) + total = ( + len(DOCUMENT_TOOLS) + + len(SKETCH_TOOLS) + + len(FEATURE_TOOLS) + + len(ASSEMBLY_TOOLS) + + len(EXPORT_TOOLS) + + len(UNDO_TOOLS) + ) + + assert total == 15 + assert len(TOOLS) == 15 + + +def test_all_tools_have_required_fields(): + """Test each tool has type, function.name, function.description, function.parameters.""" + for tool in TOOLS: + # Check top-level type + assert "type" in tool + assert tool["type"] == "function" + + # Check function object + assert "function" in tool + func = tool["function"] + + assert "name" in func + assert isinstance(func["name"], str) + assert len(func["name"]) > 0 + + assert "description" in func + assert isinstance(func["description"], str) + assert len(func["description"]) > 0 + + assert "parameters" in func + params = func["parameters"] + + # Check parameters structure + assert "type" in params + assert params["type"] == "object" + assert "properties" in params + assert isinstance(params["properties"], dict) + assert "required" in params + assert isinstance(params["required"], list) + + +def test_tool_names_unique(): + """Test no duplicate tool names.""" + names = [tool["function"]["name"] for tool in TOOLS] + assert len(names) == len(set(names)) + + +def test_document_tools_exist(): + """Test document tools are defined.""" + names = [t["function"]["name"] for t in DOCUMENT_TOOLS] + assert "create_document" in names + assert len(DOCUMENT_TOOLS) == 1 + + +def test_sketch_tools_exist(): + """Test sketch tools are defined.""" + names = [t["function"]["name"] for t in SKETCH_TOOLS] + assert "create_sketch_rectangle" in names + assert "create_sketch_circle" in names + assert "create_sketch_polygon" in names + assert "create_sketch_from_points" in names + assert len(SKETCH_TOOLS) == 4 + + +def test_featurescript_tools_exist(): + """Test featurescript tools are defined.""" + names = [t["function"]["name"] for t in FEATURESCRIPT_TOOLS] + assert "run_featurescript" in names + assert len(FEATURESCRIPT_TOOLS) == 1 + + +def test_feature_tools_exist(): + """Test feature tools are defined.""" + names = [t["function"]["name"] for t in FEATURE_TOOLS] + assert "extrude" in names + assert len(FEATURE_TOOLS) == 1 + + +def test_assembly_tools_exist(): + """Test assembly tools are defined.""" + names = [t["function"]["name"] for t in ASSEMBLY_TOOLS] + assert "create_assembly" in names + assert "add_part_to_assembly" in names + assert "mate_parts" in names + assert len(ASSEMBLY_TOOLS) == 3 + + +def test_export_tools_exist(): + """Test export tools are defined.""" + names = [t["function"]["name"] for t in EXPORT_TOOLS] + assert "export_stl" in names + assert "export_step" in names + assert "show_preview" in names + assert len(EXPORT_TOOLS) == 3 + + +def test_undo_tools_exist(): + """Test undo tools are defined.""" + names = [t["function"]["name"] for t in UNDO_TOOLS] + assert "undo" in names + assert "rollback" in names + assert "show_history" in names + assert len(UNDO_TOOLS) == 3 + + +def test_create_document_tool(): + """Test create_document tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_document") + + assert tool["function"]["description"] + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "name" in props + assert props["name"]["type"] == "string" + assert "name" in required + + assert "description" in props + assert props["description"]["type"] == "string" + + +def test_create_sketch_rectangle_tool(): + """Test create_sketch_rectangle tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_sketch_rectangle") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + # Required params + assert "plane" in required + assert "width" in required + assert "height" in required + + # Plane enum + assert props["plane"]["type"] == "string" + assert "enum" in props["plane"] + assert set(props["plane"]["enum"]) == {"XY", "XZ", "YZ"} + + # Dimensions + assert props["width"]["type"] == "number" + assert props["height"]["type"] == "number" + + # Optional center + assert "center_x" in props + assert "center_y" in props + + +def test_create_sketch_circle_tool(): + """Test create_sketch_circle tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_sketch_circle") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "plane" in required + assert "radius" in required + assert props["radius"]["type"] == "number" + + +def test_extrude_tool(): + """Test extrude tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "extrude") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "depth" in required + assert props["depth"]["type"] == "number" + + # Direction enum + assert "direction" in props + assert props["direction"]["type"] == "string" + assert "enum" in props["direction"] + assert "forward" in props["direction"]["enum"] + assert "backward" in props["direction"]["enum"] + assert "symmetric" in props["direction"]["enum"] + + # Operation enum + assert "operation" in props + assert props["operation"]["type"] == "string" + assert props["operation"]["enum"] == ["new", "add", "subtract"] + assert props["operation"]["default"] == "new" + + +def test_create_assembly_tool(): + """Test create_assembly tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_assembly") + + props = tool["function"]["parameters"]["properties"] + assert "name" in props + + +def test_add_part_to_assembly_tool(): + """Test add_part_to_assembly tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "add_part_to_assembly") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "part" in required + assert props["part"]["type"] == "string" + + +def test_mate_parts_tool(): + """Test mate_parts tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "mate_parts") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "part1_face" in required + assert "part2_face" in required + + # Mate type enum + assert "mate_type" in props + assert "enum" in props["mate_type"] + mate_types = props["mate_type"]["enum"] + assert "FASTENED" in mate_types + assert "REVOLUTE" in mate_types + assert "SLIDER" in mate_types + + +def test_export_stl_tool(): + """Test export_stl tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "export_stl") + + props = tool["function"]["parameters"]["properties"] + assert "part" in props + assert "filename" in props + + +def test_export_step_tool(): + """Test export_step tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "export_step") + + props = tool["function"]["parameters"]["properties"] + assert "part" in props + assert "filename" in props + + +def test_show_preview_tool(): + """Test show_preview tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "show_preview") + + # show_preview has no parameters + props = tool["function"]["parameters"]["properties"] + assert len(props) == 0 + + +def test_undo_tool(): + """Test undo tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "undo") + + # undo has no parameters + props = tool["function"]["parameters"]["properties"] + assert len(props) == 0 + + +def test_rollback_tool(): + """Test rollback tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "rollback") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "feature" in required + assert props["feature"]["type"] == "string" + + +def test_show_history_tool(): + """Test show_history tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "show_history") + + # show_history has no parameters + props = tool["function"]["parameters"]["properties"] + assert len(props) == 0 + + +def test_create_sketch_polygon_tool(): + """Test create_sketch_polygon tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_sketch_polygon") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "plane" in required + assert "num_sides" in required + assert "radius" in required + assert props["num_sides"]["type"] == "integer" + assert props["radius"]["type"] == "number" + + +def test_create_sketch_from_points_tool(): + """Test create_sketch_from_points tool definition.""" + tool = next(t for t in TOOLS if t["function"]["name"] == "create_sketch_from_points") + + props = tool["function"]["parameters"]["properties"] + required = tool["function"]["parameters"]["required"] + + assert "plane" in required + assert "points" in required + assert props["points"]["type"] == "array" + + +def test_run_featurescript_tool_defined_but_excluded(): + """Test run_featurescript is defined in FEATURESCRIPT_TOOLS but excluded from TOOLS.""" + names_in_fs = [t["function"]["name"] for t in FEATURESCRIPT_TOOLS] + assert "run_featurescript" in names_in_fs + + names_in_tools = {t["function"]["name"] for t in TOOLS} + assert "run_featurescript" not in names_in_tools + + +def test_get_tool_definitions(): + """Test get_tool_definitions returns all tools.""" + tools = get_tool_definitions() + + assert len(tools) == 15 + assert tools == TOOLS + + +def test_all_tools_in_combined_list(): + """Test TOOLS contains all individual tool lists (except featurescript).""" + all_tools = ( + DOCUMENT_TOOLS + + SKETCH_TOOLS + + FEATURE_TOOLS + + ASSEMBLY_TOOLS + + EXPORT_TOOLS + + UNDO_TOOLS + ) + + assert len(TOOLS) == len(all_tools) + + # Verify all names match + tools_names = {t["function"]["name"] for t in TOOLS} + all_names = {t["function"]["name"] for t in all_tools} + assert tools_names == all_names + + +def test_required_fields_are_subset_of_properties(): + """Test required fields are always in properties.""" + for tool in TOOLS: + params = tool["function"]["parameters"] + required = params["required"] + properties = params["properties"] + + for req_field in required: + assert req_field in properties, f"Required field '{req_field}' not in properties for {tool['function']['name']}" + + +def test_tool_descriptions_are_meaningful(): + """Test all tool descriptions are non-empty and meaningful.""" + for tool in TOOLS: + desc = tool["function"]["description"] + assert len(desc) > 10, f"Description too short for {tool['function']['name']}" + assert not desc.startswith("TODO"), f"Placeholder description for {tool['function']['name']}" + + +def test_parameter_descriptions(): + """Test important parameters have descriptions.""" + # Check a few key tools + extrude = next(t for t in TOOLS if t["function"]["name"] == "extrude") + depth_prop = extrude["function"]["parameters"]["properties"]["depth"] + assert "description" in depth_prop + assert "mm" in depth_prop["description"].lower() + + rectangle = next(t for t in TOOLS if t["function"]["name"] == "create_sketch_rectangle") + plane_prop = rectangle["function"]["parameters"]["properties"]["plane"] + assert "description" in plane_prop diff --git a/tests/test_verifier.py b/tests/test_verifier.py new file mode 100644 index 0000000..07cc022 --- /dev/null +++ b/tests/test_verifier.py @@ -0,0 +1,170 @@ +"""Tests for the Verifier class.""" + +import json +from unittest.mock import MagicMock + +from onshape_chat.planning.models import PlanStep +from onshape_chat.verification.verifier import Verifier + + +def _make_step(step_number: int = 1) -> PlanStep: + return PlanStep( + step_number=step_number, + description="Extrude cylinder body", + tool="extrude", + args={"depth": 50}, + ) + + +class TestVerifyStep: + """Tests for single-image verify_step.""" + + def test_verify_step_pass(self): + """verify_step returns passed=True when vision model says pass.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = json.dumps({ + "pass": True, "issues": "", "suggestion": "" + }) + mock_llm.chat_with_image.return_value = mock_response + + verifier = Verifier(mock_llm) + result = verifier.verify_step( + image_bytes=b"\x89PNG", + step=_make_step(), + overall_goal="Build a cylinder", + ) + + assert result.passed is True + mock_llm.chat_with_image.assert_called_once() + + def test_verify_step_fail(self): + """verify_step returns passed=False with issues.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = json.dumps({ + "pass": False, + "issues": "Hole does not go through", + "suggestion": "Increase extrude depth", + }) + mock_llm.chat_with_image.return_value = mock_response + + verifier = Verifier(mock_llm) + result = verifier.verify_step( + image_bytes=b"\x89PNG", + step=_make_step(), + overall_goal="Build a cylinder", + ) + + assert result.passed is False + assert "Hole does not go through" in result.issues + + def test_verify_step_exception_defaults_pass(self): + """verify_step defaults to pass when vision call fails.""" + mock_llm = MagicMock() + mock_llm.chat_with_image.side_effect = RuntimeError("API down") + + verifier = Verifier(mock_llm) + result = verifier.verify_step( + image_bytes=b"\x89PNG", + step=_make_step(), + overall_goal="Build a cylinder", + ) + + assert result.passed is True + assert "unavailable" in result.issues.lower() + + +class TestVerifyStepMultiAngle: + """Tests for multi-angle verification.""" + + def test_sends_images_to_chat_with_images(self): + """verify_step_multi_angle uses chat_with_images with all labeled images.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = json.dumps({ + "pass": True, "issues": "", "suggestion": "" + }) + mock_llm.chat_with_images.return_value = mock_response + + verifier = Verifier(mock_llm) + images = [(f"View-{i}", b"\x89PNG" + bytes([i])) for i in range(14)] + + result = verifier.verify_step_multi_angle( + images=images, + step=_make_step(), + overall_goal="Build a mug", + ) + + assert result.passed is True + mock_llm.chat_with_images.assert_called_once() + + # Verify images were passed through + call_kwargs = mock_llm.chat_with_images.call_args[1] + assert len(call_kwargs["images"]) == 14 + + def test_multi_angle_fail(self): + """verify_step_multi_angle returns failure with issues.""" + mock_llm = MagicMock() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = json.dumps({ + "pass": False, + "issues": "Back view shows missing handle", + "suggestion": "Add handle extrusion", + }) + mock_llm.chat_with_images.return_value = mock_response + + verifier = Verifier(mock_llm) + images = [("Front", b"\x89PNG"), ("Back", b"\x89PNG")] + + result = verifier.verify_step_multi_angle( + images=images, + step=_make_step(), + overall_goal="Build a mug", + ) + + assert result.passed is False + assert "missing handle" in result.issues.lower() + + def test_multi_angle_exception_defaults_pass(self): + """verify_step_multi_angle defaults to pass on exception.""" + mock_llm = MagicMock() + mock_llm.chat_with_images.side_effect = RuntimeError("Vision API error") + + verifier = Verifier(mock_llm) + result = verifier.verify_step_multi_angle( + images=[("Front", b"\x89PNG")], + step=_make_step(), + overall_goal="Build a mug", + ) + + assert result.passed is True + assert "unavailable" in result.issues.lower() + + +class TestParseResult: + """Tests for _parse_result.""" + + def test_parse_valid_json(self): + result = Verifier._parse_result('{"pass": true, "issues": "", "suggestion": ""}') + assert result.passed is True + + def test_parse_fail_json(self): + result = Verifier._parse_result('{"pass": false, "issues": "bad geometry", "suggestion": "fix it"}') + assert result.passed is False + assert result.issues == "bad geometry" + assert result.suggestion == "fix it" + + def test_parse_markdown_fenced_json(self): + raw = '```json\n{"pass": true, "issues": "none"}\n```' + result = Verifier._parse_result(raw) + assert result.passed is True + + def test_parse_invalid_json_defaults_pass(self): + result = Verifier._parse_result("This is not JSON at all") + assert result.passed is True + assert "Unparseable" in result.issues diff --git a/tests/test_vision.py b/tests/test_vision.py new file mode 100644 index 0000000..b437970 --- /dev/null +++ b/tests/test_vision.py @@ -0,0 +1,159 @@ +"""Tests for Phase 4c: Image Import & Interpretation.""" + +import base64 +from unittest.mock import MagicMock + +import pytest + +from onshape_chat.vision.interpreter import ( + MAX_IMAGE_SIZE, + ImageBuildExecutor, + VisionInterpreter, +) + +# ── VisionInterpreter Tests ────────────────────────── + + +class TestVisionInterpreter: + def test_init(self): + mock_client = MagicMock() + vi = VisionInterpreter(mock_client, vision_model="glm-4v") + assert vi.vision_model == "glm-4v" + + def test_encode_image(self, tmp_path): + # Create a small test image file + img_path = tmp_path / "test.png" + img_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + img_path.write_bytes(img_data) + + mock_client = MagicMock() + vi = VisionInterpreter(mock_client) + + encoded = vi.encode_image(img_path) + decoded = base64.b64decode(encoded) + assert decoded == img_data + + def test_encode_image_not_found(self): + mock_client = MagicMock() + vi = VisionInterpreter(mock_client) + + with pytest.raises(FileNotFoundError): + vi.encode_image("/nonexistent/image.png") + + def test_encode_image_unsupported_format(self, tmp_path): + bmp = tmp_path / "test.bmp" + bmp.write_bytes(b"\x00" * 10) + + mock_client = MagicMock() + vi = VisionInterpreter(mock_client) + + with pytest.raises(ValueError, match="Unsupported image format"): + vi.encode_image(bmp) + + def test_encode_image_too_large(self, tmp_path): + large = tmp_path / "large.png" + large.write_bytes(b"\x00" * (MAX_IMAGE_SIZE + 1)) + + mock_client = MagicMock() + vi = VisionInterpreter(mock_client) + + with pytest.raises(ValueError, match="Image too large"): + vi.encode_image(large) + + def test_supported_formats(self, tmp_path): + mock_client = MagicMock() + vi = VisionInterpreter(mock_client) + + for ext in [".png", ".jpg", ".jpeg", ".webp"]: + img = tmp_path / f"test{ext}" + img.write_bytes(b"\x00" * 10) + # Should not raise + vi.encode_image(img) + + def test_interpret_image(self, tmp_path): + img_path = tmp_path / "test.jpg" + img_path.write_bytes(b"\xff\xd8\xff" + b"\x00" * 20) + + mock_client = MagicMock() + # Mock vision model response + mock_vision_resp = MagicMock() + mock_vision_resp.choices = [MagicMock(message=MagicMock( + content="A rectangular bracket, 100mm x 50mm, with two mounting holes" + ))] + + # Mock extraction response + mock_extract_resp = MagicMock() + mock_extract_resp.choices = [MagicMock(message=MagicMock( + content='{"description": "Bracket", "dimensions": {"length": 100}, ' + '"features": [], "operations": ["Create rectangle 100x50"]}' + ))] + + mock_client.client.chat.completions.create.side_effect = [ + mock_vision_resp, mock_extract_resp + ] + mock_client.model = "glm-4-plus" + + vi = VisionInterpreter(mock_client) + result = vi.interpret_image(img_path) + + assert result["description"] == "Bracket" + assert result["dimensions"]["length"] == 100 + assert len(result["operations"]) == 1 + + def test_describe_image(self): + mock_client = MagicMock() + mock_resp = MagicMock() + mock_resp.choices = [MagicMock(message=MagicMock(content="A bracket"))] + mock_client.client.chat.completions.create.return_value = mock_resp + + vi = VisionInterpreter(mock_client) + desc = vi._describe_image("base64data") + assert desc == "A bracket" + + +# ── ImageBuildExecutor Tests ────────────────────────── + + +class TestImageBuildExecutor: + def test_execute_plan_with_dict_response(self): + mock_chat = MagicMock() + mock_chat.process_message.return_value = { + "response": "Created rectangle", + } + + executor = ImageBuildExecutor(mock_chat) + plan = { + "operations": [ + "Create a rectangle 100x50 on XY plane", + "Extrude 25mm", + ] + } + + results = executor.execute_plan(plan) + assert len(results) == 2 + assert results[0]["operation"] == "Create a rectangle 100x50 on XY plane" + assert results[0]["result"] == "Created rectangle" + + def test_execute_plan_with_str_response(self): + mock_chat = MagicMock() + mock_chat.process_message.return_value = "Done" + + executor = ImageBuildExecutor(mock_chat) + plan = {"operations": ["Create a rectangle"]} + + results = executor.execute_plan(plan) + assert results[0]["result"] == "Done" + + def test_execute_plan_empty(self): + mock_chat = MagicMock() + executor = ImageBuildExecutor(mock_chat) + + results = executor.execute_plan({"operations": []}) + assert results == [] + + def test_execute_plan_no_operations_key(self): + mock_chat = MagicMock() + executor = ImageBuildExecutor(mock_chat) + + results = executor.execute_plan({}) + assert results == [] diff --git a/tests/test_voice.py b/tests/test_voice.py new file mode 100644 index 0000000..718d87b --- /dev/null +++ b/tests/test_voice.py @@ -0,0 +1,166 @@ +"""Tests for Phase 4b: Voice Input.""" + +import wave +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from onshape_chat.voice.recorder import MicrophoneRecorder +from onshape_chat.voice.transcriber import ( + CAD_VOCABULARY_PROMPT, + WhisperTranscriber, +) + +# ── WhisperTranscriber Tests ────────────────────────── + + +class TestWhisperTranscriber: + def test_init_default(self): + t = WhisperTranscriber() + assert t.model_size == "base" + assert t.language is None + + def test_init_custom(self): + t = WhisperTranscriber(model_size="small", language="en") + assert t.model_size == "small" + assert t.language == "en" + + def test_lazy_model_loading_import_error(self): + t = WhisperTranscriber() + with patch.dict("sys.modules", {"whisper": None}): + with pytest.raises(ImportError, match="Whisper not installed"): + _ = t.model + + def test_transcribe_file_not_found(self): + t = WhisperTranscriber() + t._model = MagicMock() + with pytest.raises(FileNotFoundError): + t.transcribe("/nonexistent/audio.wav") + + def test_transcribe_with_mock_model(self, tmp_path): + # Create a real WAV file + wav_path = tmp_path / "test.wav" + with wave.open(str(wav_path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00" * 3200) + + mock_model = MagicMock() + mock_model.transcribe.return_value = { + "text": " Create a rectangle 50 by 30 ", + "language": "en", + } + + t = WhisperTranscriber() + t._model = mock_model + + result = t.transcribe(wav_path) + assert result["text"] == "Create a rectangle 50 by 30" + assert result["language"] == "en" + + def test_transcribe_with_language(self, tmp_path): + wav_path = tmp_path / "test.wav" + with wave.open(str(wav_path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(b"\x00" * 3200) + + mock_model = MagicMock() + mock_model.transcribe.return_value = { + "text": "extrude 20mm", + "language": "en", + } + + t = WhisperTranscriber(language="en") + t._model = mock_model + + result = t.transcribe(wav_path) + assert result["language"] == "en" + + # Verify language was passed to transcribe + call_kwargs = mock_model.transcribe.call_args[1] + assert call_kwargs["language"] == "en" + + def test_cad_corrections(self): + t = WhisperTranscriber() + + assert t._apply_corrections("fill it the edges") == "fillet the edges" + assert t._apply_corrections("exclude 20mm") == "extrude 20mm" + assert t._apply_corrections("on the plain") == "on the plane" + + def test_cad_corrections_case_insensitive(self): + t = WhisperTranscriber() + assert t._apply_corrections("Fill It the edges") == "fillet the edges" + + def test_cad_vocabulary_prompt_exists(self): + assert "extrude" in CAD_VOCABULARY_PROMPT.lower() + assert "fillet" in CAD_VOCABULARY_PROMPT.lower() + assert "chamfer" in CAD_VOCABULARY_PROMPT.lower() + + +# ── MicrophoneRecorder Tests ────────────────────────── + + +class TestMicrophoneRecorder: + def test_init(self): + r = MicrophoneRecorder() + assert r.RATE == 16000 + assert r.CHANNELS == 1 + + def test_lazy_pyaudio_import_error(self): + r = MicrophoneRecorder() + with patch.dict("sys.modules", {"pyaudio": None}): + with pytest.raises(ImportError, match="PyAudio not installed"): + _ = r.pyaudio + + def test_save_wav(self, tmp_path): + r = MicrophoneRecorder() + frames = [b"\x00" * 1024, b"\x00" * 1024] + path = r._save_wav(frames) + + assert Path(path).exists() + with wave.open(path, "rb") as wf: + assert wf.getnchannels() == 1 + assert wf.getframerate() == 16000 + assert wf.getsampwidth() == 2 + + # Cleanup + Path(path).unlink() + + def test_cleanup(self, tmp_path): + filepath = tmp_path / "temp.wav" + filepath.write_bytes(b"fake") + assert filepath.exists() + + MicrophoneRecorder.cleanup(str(filepath)) + assert not filepath.exists() + + def test_cleanup_nonexistent(self, tmp_path): + # Should not raise + MicrophoneRecorder.cleanup(str(tmp_path / "nonexistent.wav")) + + def test_record_mock(self): + r = MicrophoneRecorder() + + mock_pa = MagicMock() + mock_pyaudio = MagicMock() + mock_pyaudio.PyAudio.return_value = mock_pa + mock_pyaudio.paInt16 = 8 + + mock_stream = MagicMock() + mock_stream.read.return_value = b"\x00" * 1024 + mock_pa.open.return_value = mock_stream + + r._pyaudio = mock_pyaudio + + path = r.record(duration=0.1) + assert Path(path).exists() + + mock_stream.stop_stream.assert_called_once() + mock_stream.close.assert_called_once() + mock_pa.terminate.assert_called_once() + + Path(path).unlink()