# 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(''); ``` **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> = { add_design_variable: Variable, add_extractor: Cpu, add_objective: Target, add_constraint: Lock, create_study: FolderPlus, introspect_model: Search, }; const TOOL_LABELS: Record = { 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 (
{status === 'running' ? ( ) : ( )} {label} {status === 'complete' && result && ( {result} )}
); } ``` --- #### 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) => ( ))} ``` --- ## 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.