- Restructure docs/ folder (remove numeric prefixes): - 04_USER_GUIDES -> guides/ - 05_API_REFERENCE -> api/ - 06_PHYSICS -> physics/ - 07_DEVELOPMENT -> development/ - 08_ARCHIVE -> archive/ - 09_DIAGRAMS -> diagrams/ - Replace tagline 'Talk, don't click' with 'LLM-driven optimization framework' in 9 files - Create comprehensive docs/GETTING_STARTED.md: - Prerequisites and quick setup - Project structure overview - First study tutorial (Claude or manual) - Dashboard usage guide - Neural acceleration introduction - Rewrite docs/00_INDEX.md with correct paths and modern structure - Archive obsolete files: - 01_PROTOCOLS.md -> archive/historical/01_PROTOCOLS_legacy.md - 03_GETTING_STARTED.md -> archive/historical/ - ATOMIZER_PODCAST_BRIEFING.md -> archive/marketing/ - Update timestamps to 2026-01-20 across all key files - Update .gitignore to exclude docs/generated/ - Version bump: ATOMIZER_CONTEXT v1.8 -> v2.0
1240 lines
41 KiB
Markdown
1240 lines
41 KiB
Markdown
# Claude + Canvas Integration Project
|
||
|
||
## Project Overview
|
||
|
||
**Project Name**: Unified Claude + Canvas Integration
|
||
**Goal**: Transform Atomizer's dashboard into a bi-directional Claude + Canvas experience where Claude and the user co-edit the same `atomizer_spec.json` in real-time.
|
||
|
||
**Core Principle**: `atomizer_spec.json` is the single source of truth. Both Claude (via tools) and the user (via canvas UI) read and write to it, with changes instantly reflected on both sides.
|
||
|
||
---
|
||
|
||
## Current State
|
||
|
||
### What Exists (✅ Working)
|
||
|
||
| Component | File | Status |
|
||
|-----------|------|--------|
|
||
| Power Mode WebSocket | `backend/api/routes/claude.py` | ✅ `/ws/power` endpoint |
|
||
| Write Tools | `backend/api/services/claude_agent.py` | ✅ 6 tools implemented |
|
||
| `spec_modified` Events | `backend/api/routes/claude.py:480-487` | ✅ Sent on tool use |
|
||
| Canvas Reload | `frontend/src/hooks/useChat.ts:247-262` | ✅ Triggers `reloadSpec()` |
|
||
| Spec Store | `frontend/src/hooks/useSpecStore.ts` | ✅ Manages spec state |
|
||
| Spec Renderer | `frontend/src/components/canvas/SpecRenderer.tsx` | ✅ Renders spec as nodes |
|
||
|
||
### What's Missing (❌ To Build)
|
||
|
||
| Feature | Priority | Effort |
|
||
|---------|----------|--------|
|
||
| Canvas state in Claude's context | P0 | Easy |
|
||
| Full spec in `spec_updated` payload | P0 | Easy |
|
||
| User edits notify Claude | P0 | Easy |
|
||
| Streaming responses | P1 | Medium |
|
||
| `create_study` tool | P1 | Medium |
|
||
| Interview Engine | P2 | Medium |
|
||
| Tool call UI indicators | P2 | Easy |
|
||
| Node animations | P3 | Easy |
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Target Data Flow
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ atomizer_spec.json │
|
||
│ (Single Source of Truth) │
|
||
└───────────────────────────────┬─────────────────────────────────────┘
|
||
│
|
||
┌───────────┴───────────┐
|
||
│ │
|
||
▼ ▼
|
||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||
│ Claude Agent │ │ Canvas UI │
|
||
│ (AtomizerClaudeAgent) │ │ (SpecRenderer) │
|
||
├───────────────────────────┤ ├───────────────────────────┤
|
||
│ • Reads spec for context │ │ • Renders spec as nodes │
|
||
│ • Writes via tools │ │ • User edits nodes │
|
||
│ • Sees user's edits │ │ • Receives Claude's edits │
|
||
└───────────────────────────┘ └───────────────────────────┘
|
||
│ │
|
||
│ WebSocket │
|
||
│ (bi-directional) │
|
||
│ │
|
||
└───────────┬───────────┘
|
||
│
|
||
┌───────────┴───────────┐
|
||
│ /api/claude/ws/power │
|
||
│ (Enhanced Endpoint) │
|
||
└───────────────────────┘
|
||
```
|
||
|
||
### Message Protocol (Enhanced)
|
||
|
||
**Client → Server:**
|
||
```typescript
|
||
// Chat message
|
||
{ type: "message", content: "Add a thickness variable 2-10mm" }
|
||
|
||
// User edited canvas (NEW)
|
||
{ type: "canvas_edit", spec: { /* full spec */ } }
|
||
|
||
// Switch study
|
||
{ type: "set_study", study_id: "bracket_v1" }
|
||
|
||
// Heartbeat
|
||
{ type: "ping" }
|
||
```
|
||
|
||
**Server → Client:**
|
||
```typescript
|
||
// Streaming text (NEW - replaces single "text" message)
|
||
{ type: "text_delta", content: "Adding" }
|
||
{ type: "text_delta", content: " thickness" }
|
||
{ type: "text_delta", content: " variable..." }
|
||
|
||
// Tool started (NEW)
|
||
{ type: "tool_start", tool: "add_design_variable", input: {...} }
|
||
|
||
// Tool completed
|
||
{ type: "tool_result", tool: "add_design_variable", result: "✓ Added..." }
|
||
|
||
// Spec updated - NOW INCLUDES FULL SPEC (CHANGED)
|
||
{ type: "spec_updated", spec: { /* full atomizer_spec.json */ } }
|
||
|
||
// Response complete
|
||
{ type: "done" }
|
||
|
||
// Error
|
||
{ type: "error", message: "..." }
|
||
|
||
// Heartbeat response
|
||
{ type: "pong" }
|
||
```
|
||
|
||
---
|
||
|
||
## Implementation Tasks
|
||
|
||
### Phase 1: Core Bi-directional Sync (P0)
|
||
|
||
#### Task 1.1: Add Canvas State to Claude's Context
|
||
**File**: `backend/api/services/claude_agent.py`
|
||
|
||
**Current** (`_build_system_prompt`):
|
||
```python
|
||
if self.study_id and self.study_dir and self.study_dir.exists():
|
||
context = self._get_study_context()
|
||
base_prompt += f"\n## Current Study: {self.study_id}\n{context}\n"
|
||
```
|
||
|
||
**Change**: Add method to format current spec as context, call it from WebSocket handler.
|
||
|
||
```python
|
||
def set_canvas_state(self, spec: Dict[str, Any]) -> None:
|
||
"""Update the current canvas state for context"""
|
||
self.canvas_state = spec
|
||
|
||
def _format_canvas_context(self) -> str:
|
||
"""Format current canvas state for Claude's system prompt"""
|
||
if not self.canvas_state:
|
||
return ""
|
||
|
||
spec = self.canvas_state
|
||
lines = ["\n## Current Canvas State\n"]
|
||
lines.append("The user can see this canvas. When you modify it, they see changes in real-time.\n")
|
||
|
||
# Design Variables
|
||
dvs = spec.get('design_variables', [])
|
||
if dvs:
|
||
lines.append(f"**Design Variables ({len(dvs)}):**")
|
||
for dv in dvs:
|
||
bounds = dv.get('bounds', {})
|
||
lines.append(f" - `{dv.get('id')}`: {dv.get('name')} [{bounds.get('min')}, {bounds.get('max')}]")
|
||
|
||
# Extractors
|
||
exts = spec.get('extractors', [])
|
||
if exts:
|
||
lines.append(f"\n**Extractors ({len(exts)}):**")
|
||
for ext in exts:
|
||
lines.append(f" - `{ext.get('id')}`: {ext.get('name')} ({ext.get('type')})")
|
||
|
||
# Objectives
|
||
objs = spec.get('objectives', [])
|
||
if objs:
|
||
lines.append(f"\n**Objectives ({len(objs)}):**")
|
||
for obj in objs:
|
||
lines.append(f" - `{obj.get('id')}`: {obj.get('name')} ({obj.get('direction')})")
|
||
|
||
# Constraints
|
||
cons = spec.get('constraints', [])
|
||
if cons:
|
||
lines.append(f"\n**Constraints ({len(cons)}):**")
|
||
for con in cons:
|
||
lines.append(f" - `{con.get('id')}`: {con.get('name')} {con.get('operator')} {con.get('threshold')}")
|
||
|
||
# Model
|
||
model = spec.get('model', {})
|
||
if model.get('sim', {}).get('path'):
|
||
lines.append(f"\n**Model**: {model['sim']['path']}")
|
||
|
||
return "\n".join(lines)
|
||
```
|
||
|
||
**Update `_build_system_prompt`**:
|
||
```python
|
||
def _build_system_prompt(self) -> str:
|
||
base_prompt = """...""" # existing prompt
|
||
|
||
# Add study context
|
||
if self.study_id and self.study_dir and self.study_dir.exists():
|
||
context = self._get_study_context()
|
||
base_prompt += f"\n## Current Study: {self.study_id}\n{context}\n"
|
||
|
||
# Add canvas state (NEW)
|
||
canvas_context = self._format_canvas_context()
|
||
if canvas_context:
|
||
base_prompt += canvas_context
|
||
|
||
return base_prompt
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 1.2: Send Full Spec in `spec_updated`
|
||
**File**: `backend/api/routes/claude.py`
|
||
|
||
**Current** (line 483-487):
|
||
```python
|
||
await websocket.send_json({
|
||
"type": "spec_modified",
|
||
"tool": tool_call["tool"],
|
||
"changes": tool_call["result_preview"],
|
||
})
|
||
```
|
||
|
||
**Change**: Send full spec instead of just changes.
|
||
|
||
```python
|
||
# After any write tool completes, send full spec
|
||
if tool_call["tool"] in ["add_design_variable", "add_extractor",
|
||
"add_objective", "add_constraint",
|
||
"update_spec_field", "remove_node"]:
|
||
# Load the updated spec
|
||
spec = agent.load_current_spec()
|
||
await websocket.send_json({
|
||
"type": "spec_updated",
|
||
"tool": tool_call["tool"],
|
||
"spec": spec, # Full spec!
|
||
})
|
||
```
|
||
|
||
**Add to `AtomizerClaudeAgent`**:
|
||
```python
|
||
def load_current_spec(self) -> Optional[Dict[str, Any]]:
|
||
"""Load the current atomizer_spec.json"""
|
||
if not self.study_dir:
|
||
return None
|
||
spec_path = self.study_dir / "atomizer_spec.json"
|
||
if not spec_path.exists():
|
||
return None
|
||
with open(spec_path, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 1.3: Handle User Canvas Edits
|
||
**File**: `backend/api/routes/claude.py`
|
||
|
||
**Add to `power_mode_websocket`** (after line 524):
|
||
```python
|
||
elif data.get("type") == "canvas_edit":
|
||
# User made a manual edit to the canvas
|
||
spec = data.get("spec")
|
||
if spec:
|
||
# Update agent's canvas state so Claude sees the change
|
||
agent.set_canvas_state(spec)
|
||
# Optionally save to file if the frontend already saved
|
||
# (or let frontend handle saving)
|
||
await websocket.send_json({
|
||
"type": "canvas_edit_received",
|
||
"acknowledged": True
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 1.4: Frontend - Send Canvas Edits to Claude
|
||
**File**: `frontend/src/hooks/useChat.ts`
|
||
|
||
**Add to state/hook**:
|
||
```typescript
|
||
// Add to sendMessage or create new function
|
||
const notifyCanvasEdit = useCallback((spec: AtomizerSpec) => {
|
||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||
wsRef.current.send(JSON.stringify({
|
||
type: 'canvas_edit',
|
||
spec: spec
|
||
}));
|
||
}
|
||
}, []);
|
||
```
|
||
|
||
**Return from hook**:
|
||
```typescript
|
||
return {
|
||
// ... existing
|
||
notifyCanvasEdit, // NEW
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 1.5: Frontend - Use Full Spec from `spec_updated`
|
||
**File**: `frontend/src/hooks/useChat.ts`
|
||
|
||
**Current** (line 247-262):
|
||
```typescript
|
||
case 'spec_modified':
|
||
console.log('[useChat] Spec was modified by assistant:', data.tool, data.changes);
|
||
if (onCanvasModification) {
|
||
onCanvasModification({
|
||
action: 'add_node',
|
||
data: { _refresh: true, tool: data.tool, changes: data.changes },
|
||
});
|
||
}
|
||
break;
|
||
```
|
||
|
||
**Change**: Use the spec directly instead of triggering reload.
|
||
|
||
```typescript
|
||
case 'spec_updated':
|
||
console.log('[useChat] Spec updated by assistant:', data.tool);
|
||
// Directly update spec store instead of triggering HTTP reload
|
||
if (data.spec && onSpecUpdated) {
|
||
onSpecUpdated(data.spec);
|
||
}
|
||
break;
|
||
```
|
||
|
||
**Add callback to hook options**:
|
||
```typescript
|
||
interface UseChatOptions {
|
||
// ... existing
|
||
onSpecUpdated?: (spec: AtomizerSpec) => void; // NEW
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 1.6: Wire Canvas to Use Direct Spec Updates
|
||
**File**: `frontend/src/pages/CanvasView.tsx`
|
||
|
||
**Current** (line 57-64):
|
||
```typescript
|
||
onCanvasModification: chatPowerMode ? (modification) => {
|
||
console.log('Canvas modification from Claude:', modification);
|
||
showNotification(`Claude: ${modification.action}...`);
|
||
reloadSpec();
|
||
} : undefined,
|
||
```
|
||
|
||
**Change**: Use `onSpecUpdated` callback.
|
||
|
||
```typescript
|
||
const { setSpec } = useSpecStore();
|
||
|
||
// In useChat options:
|
||
onSpecUpdated: chatPowerMode ? (spec) => {
|
||
console.log('Spec updated by Claude');
|
||
setSpec(spec); // Direct update, no HTTP reload
|
||
showNotification('Canvas updated by Claude');
|
||
} : undefined,
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 2: Streaming Responses (P1)
|
||
|
||
#### Task 2.1: Implement Streaming in Claude Agent
|
||
**File**: `backend/api/services/claude_agent.py`
|
||
|
||
**Add new method**:
|
||
```python
|
||
async def chat_stream(
|
||
self,
|
||
message: str,
|
||
conversation_history: List[Dict[str, Any]]
|
||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||
"""
|
||
Stream chat response with tool calls.
|
||
Yields events: text_delta, tool_start, tool_result, done
|
||
"""
|
||
# Rebuild system prompt with current canvas state
|
||
self.system_prompt = self._build_system_prompt()
|
||
|
||
messages = conversation_history + [{"role": "user", "content": message}]
|
||
|
||
# Use streaming API
|
||
with self.client.messages.stream(
|
||
model="claude-sonnet-4-20250514",
|
||
max_tokens=4096,
|
||
system=self.system_prompt,
|
||
messages=messages,
|
||
tools=self.tools
|
||
) as stream:
|
||
for event in stream:
|
||
if event.type == "content_block_delta":
|
||
if hasattr(event.delta, "text"):
|
||
yield {"type": "text_delta", "content": event.delta.text}
|
||
|
||
# Get final response for tool handling
|
||
response = stream.get_final_message()
|
||
|
||
# Process tool calls
|
||
for block in response.content:
|
||
if block.type == "tool_use":
|
||
yield {"type": "tool_start", "tool": block.name, "input": block.input}
|
||
|
||
# Execute tool
|
||
result = self._execute_tool_sync(block.name, block.input)
|
||
|
||
yield {"type": "tool_result", "tool": block.name, "result": result}
|
||
|
||
# If spec changed, yield updated spec
|
||
if block.name in ["add_design_variable", "add_extractor",
|
||
"add_objective", "add_constraint",
|
||
"update_spec_field", "remove_node"]:
|
||
spec = self.load_current_spec()
|
||
if spec:
|
||
yield {"type": "spec_updated", "spec": spec}
|
||
|
||
yield {"type": "done"}
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 2.2: Use Streaming in WebSocket Handler
|
||
**File**: `backend/api/routes/claude.py`
|
||
|
||
**Change `power_mode_websocket`** (line 464-501):
|
||
```python
|
||
if data.get("type") == "message":
|
||
content = data.get("content", "")
|
||
if not content:
|
||
continue
|
||
|
||
try:
|
||
# Stream the response
|
||
async for event in agent.chat_stream(content, conversation_history):
|
||
await websocket.send_json(event)
|
||
|
||
# Update conversation history
|
||
# (need to track this differently with streaming)
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
await websocket.send_json({
|
||
"type": "error",
|
||
"message": str(e),
|
||
})
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 2.3: Frontend - Handle Streaming Text
|
||
**File**: `frontend/src/hooks/useChat.ts`
|
||
|
||
**Add state for streaming**:
|
||
```typescript
|
||
const [streamingText, setStreamingText] = useState<string>('');
|
||
```
|
||
|
||
**Handle `text_delta`**:
|
||
```typescript
|
||
case 'text_delta':
|
||
setStreamingText(prev => prev + data.content);
|
||
break;
|
||
|
||
case 'done':
|
||
// Finalize the streaming message
|
||
if (streamingText) {
|
||
setState(prev => ({
|
||
...prev,
|
||
messages: [...prev.messages, {
|
||
id: Date.now().toString(),
|
||
role: 'assistant',
|
||
content: streamingText
|
||
}]
|
||
}));
|
||
setStreamingText('');
|
||
}
|
||
setState(prev => ({ ...prev, isThinking: false }));
|
||
break;
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 3: Study Creation (P1)
|
||
|
||
#### Task 3.1: Add `create_study` Tool
|
||
**File**: `backend/api/services/claude_agent.py`
|
||
|
||
**Add to `_define_tools()`**:
|
||
```python
|
||
{
|
||
"name": "create_study",
|
||
"description": "Create a new optimization study with directory structure and atomizer_spec.json. Use this when the user wants to start a new optimization from scratch.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"description": "Study name in snake_case (e.g., 'bracket_mass_v1')"
|
||
},
|
||
"category": {
|
||
"type": "string",
|
||
"description": "Category folder (e.g., 'Simple_Bracket', 'M1_Mirror'). Optional."
|
||
},
|
||
"model_path": {
|
||
"type": "string",
|
||
"description": "Path to NX simulation file (.sim). Optional, can be set later."
|
||
},
|
||
"description": {
|
||
"type": "string",
|
||
"description": "Brief description of the optimization goal"
|
||
}
|
||
},
|
||
"required": ["name"]
|
||
}
|
||
},
|
||
```
|
||
|
||
**Add implementation**:
|
||
```python
|
||
def _tool_create_study(self, params: Dict[str, Any]) -> str:
|
||
"""Create a new study with directory structure and atomizer_spec.json"""
|
||
study_name = params['name']
|
||
category = params.get('category', '')
|
||
model_path = params.get('model_path', '')
|
||
description = params.get('description', '')
|
||
|
||
# Build study path
|
||
if category:
|
||
study_dir = STUDIES_DIR / category / study_name
|
||
else:
|
||
study_dir = STUDIES_DIR / study_name
|
||
|
||
# Check if exists
|
||
if study_dir.exists():
|
||
return f"✗ Study '{study_name}' already exists at {study_dir}"
|
||
|
||
# Create directory structure
|
||
study_dir.mkdir(parents=True, exist_ok=True)
|
||
(study_dir / "1_setup").mkdir(exist_ok=True)
|
||
(study_dir / "2_iterations").mkdir(exist_ok=True)
|
||
(study_dir / "3_results").mkdir(exist_ok=True)
|
||
|
||
# Create initial spec
|
||
spec = {
|
||
"meta": {
|
||
"version": "2.0",
|
||
"study_name": study_name,
|
||
"description": description,
|
||
"created_at": datetime.now().isoformat(),
|
||
"created_by": "claude_agent"
|
||
},
|
||
"model": {
|
||
"sim": {
|
||
"path": model_path,
|
||
"solver": "nastran"
|
||
}
|
||
},
|
||
"design_variables": [],
|
||
"extractors": [],
|
||
"objectives": [],
|
||
"constraints": [],
|
||
"optimization": {
|
||
"algorithm": {"type": "TPE"},
|
||
"budget": {"max_trials": 100}
|
||
},
|
||
"canvas": {
|
||
"edges": [],
|
||
"layout_version": "2.0"
|
||
}
|
||
}
|
||
|
||
# Save spec
|
||
spec_path = study_dir / "atomizer_spec.json"
|
||
with open(spec_path, 'w', encoding='utf-8') as f:
|
||
json.dump(spec, f, indent=2)
|
||
|
||
# Update agent context
|
||
self.study_id = f"{category}/{study_name}" if category else study_name
|
||
self.study_dir = study_dir
|
||
self.canvas_state = spec
|
||
|
||
return f"✓ Created study '{study_name}' at {study_dir}\n\nThe canvas is now showing this empty study. You can start adding design variables, extractors, and objectives."
|
||
```
|
||
|
||
**Add to tool dispatcher**:
|
||
```python
|
||
elif tool_name == "create_study":
|
||
return self._tool_create_study(tool_input)
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 4: Interview Engine (P2)
|
||
|
||
#### Task 4.1: Create Interview Engine Class
|
||
**File**: `backend/api/services/interview_engine.py` (NEW)
|
||
|
||
```python
|
||
"""
|
||
Interview Engine for guided study creation.
|
||
|
||
Walks the user through creating an optimization study step-by-step,
|
||
building the atomizer_spec.json incrementally.
|
||
"""
|
||
|
||
from typing import Dict, List, Optional, Any
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum
|
||
import re
|
||
|
||
|
||
class InterviewPhase(Enum):
|
||
WELCOME = "welcome"
|
||
MODEL = "model"
|
||
OBJECTIVES = "objectives"
|
||
DESIGN_VARS = "design_vars"
|
||
CONSTRAINTS = "constraints"
|
||
METHOD = "method"
|
||
REVIEW = "review"
|
||
COMPLETE = "complete"
|
||
|
||
|
||
@dataclass
|
||
class InterviewState:
|
||
phase: InterviewPhase = InterviewPhase.WELCOME
|
||
collected: Dict[str, Any] = field(default_factory=dict)
|
||
spec: Dict[str, Any] = field(default_factory=dict)
|
||
model_expressions: List[Dict] = field(default_factory=list)
|
||
|
||
|
||
class InterviewEngine:
|
||
"""Guided study creation through conversation"""
|
||
|
||
PHASE_QUESTIONS = {
|
||
InterviewPhase.WELCOME: "What kind of optimization do you want to set up? (e.g., minimize mass of a bracket, reduce wavefront error of a mirror)",
|
||
InterviewPhase.MODEL: "What's the path to your NX simulation file (.sim)?\n(You can type the path or I can help you find it)",
|
||
InterviewPhase.OBJECTIVES: "What do you want to optimize?\n\nCommon objectives:\n- Minimize mass/weight\n- Minimize displacement (maximize stiffness)\n- Minimize stress\n- Minimize wavefront error (WFE)\n\nYou can have multiple objectives (multi-objective optimization).",
|
||
InterviewPhase.DESIGN_VARS: "Which parameters should vary during optimization?\n\n{suggestions}",
|
||
InterviewPhase.CONSTRAINTS: "Any constraints to respect?\n\nExamples:\n- Maximum stress ≤ 200 MPa\n- Minimum frequency ≥ 50 Hz\n- Maximum mass ≤ 5 kg\n\n(Say 'none' if no constraints)",
|
||
InterviewPhase.METHOD: "Based on your setup, I recommend **{method}**.\n\nReason: {reason}\n\nShould I use this method?",
|
||
InterviewPhase.REVIEW: "Here's your configuration:\n\n{summary}\n\nReady to create the study? (yes/no)",
|
||
}
|
||
|
||
def __init__(self):
|
||
self.state = InterviewState()
|
||
self._init_spec()
|
||
|
||
def _init_spec(self):
|
||
"""Initialize empty spec structure"""
|
||
self.state.spec = {
|
||
"meta": {"version": "2.0"},
|
||
"model": {"sim": {"path": "", "solver": "nastran"}},
|
||
"design_variables": [],
|
||
"extractors": [],
|
||
"objectives": [],
|
||
"constraints": [],
|
||
"optimization": {
|
||
"algorithm": {"type": "TPE"},
|
||
"budget": {"max_trials": 100}
|
||
},
|
||
"canvas": {"edges": [], "layout_version": "2.0"}
|
||
}
|
||
|
||
def get_current_question(self) -> str:
|
||
"""Get the question for the current phase"""
|
||
question = self.PHASE_QUESTIONS.get(self.state.phase, "")
|
||
|
||
# Dynamic substitutions
|
||
if self.state.phase == InterviewPhase.DESIGN_VARS:
|
||
suggestions = self._format_dv_suggestions()
|
||
question = question.format(suggestions=suggestions)
|
||
elif self.state.phase == InterviewPhase.METHOD:
|
||
method, reason = self._recommend_method()
|
||
question = question.format(method=method, reason=reason)
|
||
elif self.state.phase == InterviewPhase.REVIEW:
|
||
summary = self._format_summary()
|
||
question = question.format(summary=summary)
|
||
|
||
return question
|
||
|
||
def process_answer(self, answer: str) -> Dict[str, Any]:
|
||
"""Process user's answer and advance interview"""
|
||
phase = self.state.phase
|
||
result = {
|
||
"phase": phase.value,
|
||
"spec_changes": [],
|
||
"next_phase": None,
|
||
"question": None,
|
||
"complete": False,
|
||
"error": None
|
||
}
|
||
|
||
try:
|
||
if phase == InterviewPhase.WELCOME:
|
||
self._process_welcome(answer)
|
||
elif phase == InterviewPhase.MODEL:
|
||
self._process_model(answer, result)
|
||
elif phase == InterviewPhase.OBJECTIVES:
|
||
self._process_objectives(answer, result)
|
||
elif phase == InterviewPhase.DESIGN_VARS:
|
||
self._process_design_vars(answer, result)
|
||
elif phase == InterviewPhase.CONSTRAINTS:
|
||
self._process_constraints(answer, result)
|
||
elif phase == InterviewPhase.METHOD:
|
||
self._process_method(answer, result)
|
||
elif phase == InterviewPhase.REVIEW:
|
||
self._process_review(answer, result)
|
||
|
||
# Advance to next phase
|
||
self._advance_phase()
|
||
|
||
if self.state.phase == InterviewPhase.COMPLETE:
|
||
result["complete"] = True
|
||
else:
|
||
result["next_phase"] = self.state.phase.value
|
||
result["question"] = self.get_current_question()
|
||
|
||
except Exception as e:
|
||
result["error"] = str(e)
|
||
|
||
return result
|
||
|
||
def _advance_phase(self):
|
||
"""Move to next phase"""
|
||
phases = list(InterviewPhase)
|
||
current_idx = phases.index(self.state.phase)
|
||
if current_idx < len(phases) - 1:
|
||
self.state.phase = phases[current_idx + 1]
|
||
|
||
def _process_welcome(self, answer: str):
|
||
"""Extract optimization type from welcome"""
|
||
self.state.collected["goal"] = answer
|
||
|
||
# Try to infer study name
|
||
words = answer.lower().split()
|
||
if "bracket" in words:
|
||
self.state.collected["geometry_type"] = "bracket"
|
||
elif "mirror" in words:
|
||
self.state.collected["geometry_type"] = "mirror"
|
||
elif "beam" in words:
|
||
self.state.collected["geometry_type"] = "beam"
|
||
|
||
def _process_model(self, answer: str, result: Dict):
|
||
"""Extract model path"""
|
||
# Extract path from answer
|
||
path = answer.strip().strip('"').strip("'")
|
||
self.state.spec["model"]["sim"]["path"] = path
|
||
self.state.collected["model_path"] = path
|
||
result["spec_changes"].append(f"Set model path: {path}")
|
||
|
||
def _process_objectives(self, answer: str, result: Dict):
|
||
"""Extract objectives from natural language"""
|
||
answer_lower = answer.lower()
|
||
objectives = []
|
||
extractors = []
|
||
|
||
# Mass/weight
|
||
if any(w in answer_lower for w in ["mass", "weight", "light"]):
|
||
extractors.append({
|
||
"id": "ext_mass",
|
||
"name": "Mass",
|
||
"type": "bdf_mass",
|
||
"enabled": True
|
||
})
|
||
objectives.append({
|
||
"id": "obj_mass",
|
||
"name": "Mass",
|
||
"direction": "minimize",
|
||
"source": {"extractor_id": "ext_mass", "output_key": "mass"},
|
||
"enabled": True
|
||
})
|
||
result["spec_changes"].append("Added objective: minimize mass")
|
||
|
||
# Displacement/stiffness
|
||
if any(w in answer_lower for w in ["displacement", "stiff", "deflection"]):
|
||
extractors.append({
|
||
"id": "ext_disp",
|
||
"name": "Max Displacement",
|
||
"type": "displacement",
|
||
"config": {"node_set": "all", "direction": "magnitude"},
|
||
"enabled": True
|
||
})
|
||
objectives.append({
|
||
"id": "obj_disp",
|
||
"name": "Max Displacement",
|
||
"direction": "minimize",
|
||
"source": {"extractor_id": "ext_disp", "output_key": "max_displacement"},
|
||
"enabled": True
|
||
})
|
||
result["spec_changes"].append("Added objective: minimize displacement")
|
||
|
||
# Stress
|
||
if "stress" in answer_lower and "constraint" not in answer_lower:
|
||
extractors.append({
|
||
"id": "ext_stress",
|
||
"name": "Max Stress",
|
||
"type": "stress",
|
||
"config": {"stress_type": "von_mises"},
|
||
"enabled": True
|
||
})
|
||
objectives.append({
|
||
"id": "obj_stress",
|
||
"name": "Max Stress",
|
||
"direction": "minimize",
|
||
"source": {"extractor_id": "ext_stress", "output_key": "max_stress"},
|
||
"enabled": True
|
||
})
|
||
result["spec_changes"].append("Added objective: minimize stress")
|
||
|
||
# WFE/wavefront
|
||
if any(w in answer_lower for w in ["wfe", "wavefront", "optical", "zernike"]):
|
||
extractors.append({
|
||
"id": "ext_wfe",
|
||
"name": "Wavefront Error",
|
||
"type": "zernike",
|
||
"config": {"terms": [4, 5, 6, 7, 8, 9, 10, 11]},
|
||
"enabled": True
|
||
})
|
||
objectives.append({
|
||
"id": "obj_wfe",
|
||
"name": "WFE RMS",
|
||
"direction": "minimize",
|
||
"source": {"extractor_id": "ext_wfe", "output_key": "wfe_rms"},
|
||
"enabled": True
|
||
})
|
||
result["spec_changes"].append("Added objective: minimize wavefront error")
|
||
|
||
self.state.spec["extractors"].extend(extractors)
|
||
self.state.spec["objectives"].extend(objectives)
|
||
self.state.collected["objectives"] = [o["name"] for o in objectives]
|
||
|
||
def _process_design_vars(self, answer: str, result: Dict):
|
||
"""Extract design variables"""
|
||
answer_lower = answer.lower()
|
||
dvs = []
|
||
|
||
# Parse patterns like "thickness 2-10mm" or "thickness from 2 to 10"
|
||
# Pattern: name [range]
|
||
patterns = [
|
||
r'(\w+)\s+(\d+(?:\.\d+)?)\s*[-–to]+\s*(\d+(?:\.\d+)?)\s*(mm|deg|°)?',
|
||
r'(\w+)\s+\[(\d+(?:\.\d+)?),?\s*(\d+(?:\.\d+)?)\]',
|
||
]
|
||
|
||
for pattern in patterns:
|
||
matches = re.findall(pattern, answer_lower)
|
||
for match in matches:
|
||
name = match[0]
|
||
min_val = float(match[1])
|
||
max_val = float(match[2])
|
||
unit = match[3] if len(match) > 3 else ""
|
||
|
||
dv = {
|
||
"id": f"dv_{name}",
|
||
"name": name.replace("_", " ").title(),
|
||
"expression_name": name,
|
||
"type": "continuous",
|
||
"bounds": {"min": min_val, "max": max_val},
|
||
"baseline": (min_val + max_val) / 2,
|
||
"enabled": True
|
||
}
|
||
if unit:
|
||
dv["units"] = unit
|
||
dvs.append(dv)
|
||
result["spec_changes"].append(f"Added design variable: {name} [{min_val}, {max_val}]")
|
||
|
||
# If no pattern matched, try to use suggestions
|
||
if not dvs and self.state.model_expressions:
|
||
# User might have said "yes" or named expressions without ranges
|
||
for expr in self.state.model_expressions[:3]: # Use top 3
|
||
if expr["name"].lower() in answer_lower or "yes" in answer_lower or "all" in answer_lower:
|
||
val = expr["value"]
|
||
dv = {
|
||
"id": f"dv_{expr['name']}",
|
||
"name": expr["name"],
|
||
"expression_name": expr["name"],
|
||
"type": "continuous",
|
||
"bounds": {"min": val * 0.5, "max": val * 1.5},
|
||
"baseline": val,
|
||
"enabled": True
|
||
}
|
||
dvs.append(dv)
|
||
result["spec_changes"].append(f"Added design variable: {expr['name']} [{val*0.5:.2f}, {val*1.5:.2f}]")
|
||
|
||
self.state.spec["design_variables"].extend(dvs)
|
||
self.state.collected["design_vars"] = [dv["name"] for dv in dvs]
|
||
|
||
def _process_constraints(self, answer: str, result: Dict):
|
||
"""Extract constraints"""
|
||
answer_lower = answer.lower()
|
||
|
||
if answer_lower in ["none", "no", "skip", "n/a"]:
|
||
return
|
||
|
||
constraints = []
|
||
|
||
# Pattern: "stress < 200" or "stress <= 200 MPa"
|
||
stress_match = re.search(r'stress\s*([<>=≤≥]+)\s*(\d+(?:\.\d+)?)', answer_lower)
|
||
if stress_match:
|
||
op = stress_match.group(1).replace("≤", "<=").replace("≥", ">=")
|
||
val = float(stress_match.group(2))
|
||
|
||
# Add extractor if not exists
|
||
if not any(e["type"] == "stress" for e in self.state.spec["extractors"]):
|
||
self.state.spec["extractors"].append({
|
||
"id": "ext_stress_con",
|
||
"name": "Stress for Constraint",
|
||
"type": "stress",
|
||
"config": {"stress_type": "von_mises"},
|
||
"enabled": True
|
||
})
|
||
|
||
constraints.append({
|
||
"id": "con_stress",
|
||
"name": "Max Stress",
|
||
"operator": op if op in ["<=", ">=", "<", ">", "=="] else "<=",
|
||
"threshold": val,
|
||
"source": {"extractor_id": "ext_stress_con", "output_key": "max_stress"},
|
||
"enabled": True
|
||
})
|
||
result["spec_changes"].append(f"Added constraint: stress {op} {val}")
|
||
|
||
# Similar patterns for frequency, mass, displacement...
|
||
|
||
self.state.spec["constraints"].extend(constraints)
|
||
|
||
def _process_method(self, answer: str, result: Dict):
|
||
"""Confirm or change optimization method"""
|
||
answer_lower = answer.lower()
|
||
|
||
if any(w in answer_lower for w in ["yes", "ok", "sure", "good", "proceed"]):
|
||
# Keep recommended method
|
||
pass
|
||
elif "nsga" in answer_lower:
|
||
self.state.spec["optimization"]["algorithm"]["type"] = "NSGA-II"
|
||
elif "tpe" in answer_lower:
|
||
self.state.spec["optimization"]["algorithm"]["type"] = "TPE"
|
||
elif "cma" in answer_lower:
|
||
self.state.spec["optimization"]["algorithm"]["type"] = "CMA-ES"
|
||
|
||
result["spec_changes"].append(f"Set method: {self.state.spec['optimization']['algorithm']['type']}")
|
||
|
||
def _process_review(self, answer: str, result: Dict):
|
||
"""Confirm or revise"""
|
||
answer_lower = answer.lower()
|
||
|
||
if any(w in answer_lower for w in ["yes", "ok", "create", "proceed", "looks good"]):
|
||
result["complete"] = True
|
||
else:
|
||
# User wants changes - stay in review or go back
|
||
result["error"] = "What would you like to change?"
|
||
|
||
def _recommend_method(self) -> tuple:
|
||
"""Recommend optimization method based on problem"""
|
||
n_obj = len(self.state.spec["objectives"])
|
||
n_dv = len(self.state.spec["design_variables"])
|
||
|
||
if n_obj > 1:
|
||
return "NSGA-II", f"You have {n_obj} objectives, which requires multi-objective optimization"
|
||
elif n_dv > 10:
|
||
return "CMA-ES", f"With {n_dv} design variables, CMA-ES handles high dimensions well"
|
||
else:
|
||
return "TPE", "TPE (Bayesian optimization) is efficient for single-objective problems"
|
||
|
||
def _format_dv_suggestions(self) -> str:
|
||
"""Format design variable suggestions"""
|
||
if self.state.model_expressions:
|
||
lines = ["I found these expressions in your model:"]
|
||
for expr in self.state.model_expressions[:5]:
|
||
lines.append(f" - {expr['name']} = {expr['value']}")
|
||
lines.append("\nWhich ones should vary? (or describe your own)")
|
||
return "\n".join(lines)
|
||
return "Describe the parameters and their ranges (e.g., 'thickness 2-10mm, width 5-20mm')"
|
||
|
||
def _format_summary(self) -> str:
|
||
"""Format configuration summary"""
|
||
spec = self.state.spec
|
||
lines = []
|
||
|
||
lines.append(f"**Model**: {spec['model']['sim']['path'] or 'Not set'}")
|
||
|
||
lines.append(f"\n**Design Variables ({len(spec['design_variables'])}):**")
|
||
for dv in spec["design_variables"]:
|
||
b = dv["bounds"]
|
||
lines.append(f" - {dv['name']}: [{b['min']}, {b['max']}]")
|
||
|
||
lines.append(f"\n**Objectives ({len(spec['objectives'])}):**")
|
||
for obj in spec["objectives"]:
|
||
lines.append(f" - {obj['direction']} {obj['name']}")
|
||
|
||
lines.append(f"\n**Constraints ({len(spec['constraints'])}):**")
|
||
if spec["constraints"]:
|
||
for con in spec["constraints"]:
|
||
lines.append(f" - {con['name']} {con['operator']} {con['threshold']}")
|
||
else:
|
||
lines.append(" - None")
|
||
|
||
lines.append(f"\n**Method**: {spec['optimization']['algorithm']['type']}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
def get_spec(self) -> Dict[str, Any]:
|
||
"""Get the built spec"""
|
||
return self.state.spec
|
||
|
||
def set_model_expressions(self, expressions: List[Dict]):
|
||
"""Set model expressions for DV suggestions"""
|
||
self.state.model_expressions = expressions
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 4.2: Add Interview Tools to Claude Agent
|
||
**File**: `backend/api/services/claude_agent.py`
|
||
|
||
**Add tools**:
|
||
```python
|
||
{
|
||
"name": "start_interview",
|
||
"description": "Start a guided interview to create a new optimization study. Use this when the user wants help setting up an optimization but hasn't provided full details.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": []
|
||
}
|
||
},
|
||
{
|
||
"name": "interview_answer",
|
||
"description": "Process the user's answer during an interview. Extract relevant information and advance the interview.",
|
||
"input_schema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"answer": {
|
||
"type": "string",
|
||
"description": "The user's answer to the current interview question"
|
||
}
|
||
},
|
||
"required": ["answer"]
|
||
}
|
||
},
|
||
```
|
||
|
||
**Add state and implementations**:
|
||
```python
|
||
def __init__(self, study_id: Optional[str] = None):
|
||
# ... existing
|
||
self.interview: Optional[InterviewEngine] = None
|
||
|
||
def _tool_start_interview(self, params: Dict[str, Any]) -> str:
|
||
"""Start guided study creation"""
|
||
from api.services.interview_engine import InterviewEngine
|
||
self.interview = InterviewEngine()
|
||
question = self.interview.get_current_question()
|
||
return f"Let's set up your optimization step by step.\n\n{question}"
|
||
|
||
def _tool_interview_answer(self, params: Dict[str, Any]) -> str:
|
||
"""Process interview answer"""
|
||
if not self.interview:
|
||
return "No interview in progress. Use start_interview first."
|
||
|
||
result = self.interview.process_answer(params["answer"])
|
||
|
||
response_parts = []
|
||
|
||
# Show what was extracted
|
||
if result["spec_changes"]:
|
||
response_parts.append("**Updated:**")
|
||
for change in result["spec_changes"]:
|
||
response_parts.append(f" ✓ {change}")
|
||
|
||
if result["error"]:
|
||
response_parts.append(f"\n{result['error']}")
|
||
elif result["complete"]:
|
||
# Create the study
|
||
spec = self.interview.get_spec()
|
||
# ... create study directory and save spec
|
||
response_parts.append("\n✓ **Interview complete!** Creating your study...")
|
||
self.canvas_state = spec
|
||
elif result["question"]:
|
||
response_parts.append(f"\n{result['question']}")
|
||
|
||
return "\n".join(response_parts)
|
||
```
|
||
|
||
---
|
||
|
||
### Phase 5: UI Polish (P2/P3)
|
||
|
||
#### Task 5.1: Tool Call Indicators
|
||
**File**: `frontend/src/components/chat/ToolIndicator.tsx` (NEW)
|
||
|
||
```typescript
|
||
import { Loader2, Check, Variable, Cpu, Target, Lock, FolderPlus, Search, Wrench } from 'lucide-react';
|
||
|
||
interface ToolIndicatorProps {
|
||
tool: string;
|
||
status: 'running' | 'complete';
|
||
result?: string;
|
||
}
|
||
|
||
const TOOL_ICONS: Record<string, React.ComponentType<any>> = {
|
||
add_design_variable: Variable,
|
||
add_extractor: Cpu,
|
||
add_objective: Target,
|
||
add_constraint: Lock,
|
||
create_study: FolderPlus,
|
||
introspect_model: Search,
|
||
};
|
||
|
||
const TOOL_LABELS: Record<string, string> = {
|
||
add_design_variable: 'Adding design variable',
|
||
add_extractor: 'Adding extractor',
|
||
add_objective: 'Adding objective',
|
||
add_constraint: 'Adding constraint',
|
||
create_study: 'Creating study',
|
||
update_spec_field: 'Updating configuration',
|
||
remove_node: 'Removing node',
|
||
};
|
||
|
||
export function ToolIndicator({ tool, status, result }: ToolIndicatorProps) {
|
||
const Icon = TOOL_ICONS[tool] || Wrench;
|
||
const label = TOOL_LABELS[tool] || tool;
|
||
|
||
return (
|
||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
|
||
status === 'running'
|
||
? 'bg-amber-500/10 text-amber-400 border border-amber-500/20'
|
||
: 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||
}`}>
|
||
{status === 'running' ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Check className="w-4 h-4" />
|
||
)}
|
||
<Icon className="w-4 h-4" />
|
||
<span className="font-medium">{label}</span>
|
||
{status === 'complete' && result && (
|
||
<span className="text-xs opacity-75 ml-2">{result}</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### Task 5.2: Display Tool Calls in Chat
|
||
**File**: `frontend/src/components/chat/ChatMessage.tsx`
|
||
|
||
**Add tool call rendering**:
|
||
```typescript
|
||
import { ToolIndicator } from './ToolIndicator';
|
||
|
||
// In message rendering:
|
||
{message.toolCalls?.map((tc, idx) => (
|
||
<ToolIndicator
|
||
key={idx}
|
||
tool={tc.tool}
|
||
status={tc.status}
|
||
result={tc.result}
|
||
/>
|
||
))}
|
||
```
|
||
|
||
---
|
||
|
||
## File Summary
|
||
|
||
### Files to Modify
|
||
|
||
| File | Changes |
|
||
|------|---------|
|
||
| `backend/api/services/claude_agent.py` | Add canvas context, streaming, create_study, interview tools |
|
||
| `backend/api/routes/claude.py` | Send full spec, handle canvas_edit, use streaming |
|
||
| `frontend/src/hooks/useChat.ts` | Add notifyCanvasEdit, handle streaming, onSpecUpdated |
|
||
| `frontend/src/pages/CanvasView.tsx` | Wire onSpecUpdated, pass notifyCanvasEdit |
|
||
| `frontend/src/components/canvas/SpecRenderer.tsx` | Call notifyCanvasEdit on user edits |
|
||
|
||
### Files to Create
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `backend/api/services/interview_engine.py` | Guided study creation |
|
||
| `frontend/src/components/chat/ToolIndicator.tsx` | Tool call UI |
|
||
|
||
---
|
||
|
||
## Testing Checklist
|
||
|
||
### Phase 1 Tests
|
||
- [ ] Claude mentions current canvas state in response
|
||
- [ ] Canvas updates without HTTP reload when Claude modifies spec
|
||
- [ ] User edits canvas → ask Claude about it → Claude knows the change
|
||
|
||
### Phase 2 Tests
|
||
- [ ] Text streams in as Claude types
|
||
- [ ] Tool calls show "running" then "complete" status
|
||
|
||
### Phase 3 Tests
|
||
- [ ] "Create a new study called bracket_v2" creates directory + spec
|
||
- [ ] Canvas shows new empty study
|
||
|
||
### Phase 4 Tests
|
||
- [ ] "Help me set up an optimization" starts interview
|
||
- [ ] Each answer updates canvas incrementally
|
||
- [ ] Interview completes with valid spec
|
||
|
||
---
|
||
|
||
## Ralph Loop Execution Notes
|
||
|
||
1. **Start with Phase 1** - It's the foundation for everything else
|
||
2. **Test each task individually** before moving to next
|
||
3. **The key insight**: `atomizer_spec.json` is the single source of truth
|
||
4. **Don't break existing functionality** - Power mode should still work
|
||
|
||
### Quick Wins (Do First)
|
||
1. Task 1.1 - Add canvas state to Claude context (easy, high value)
|
||
2. Task 1.2 - Send full spec in response (easy, removes HTTP reload)
|
||
3. Task 1.5 - Frontend use spec directly (easy, faster updates)
|
||
|
||
### Recommended Order
|
||
```
|
||
1.1 → 1.2 → 1.5 → 1.6 → 1.3 → 1.4 → 2.1 → 2.2 → 2.3 → 3.1 → 4.1 → 4.2 → 5.1 → 5.2
|
||
```
|
||
|
||
---
|
||
|
||
## Success Criteria
|
||
|
||
The project is complete when:
|
||
|
||
1. **Bi-directional sync works**: Claude modifies → Canvas updates instantly. User edits → Claude sees in next message.
|
||
|
||
2. **Streaming works**: Text appears as Claude types, tool calls show in real-time.
|
||
|
||
3. **Study creation works**: User can say "create bracket optimization with mass objective" and see it built on canvas.
|
||
|
||
4. **Interview works**: User can say "help me set up an optimization" and be guided through the process.
|
||
|
||
5. **No HTTP reloads**: Canvas updates purely through WebSocket.
|