Archive Management: - Moved RALPH_LOOP, CANVAS, and dashboard implementation plans to archive/review/ for CEO review - Moved completed restructuring plan and protocol v1 to archive/historical/ - Moved old session summaries to archive/review/ New HQ Documentation (docs/hq/): - README.md: Overview of Atomizer-HQ multi-agent optimization team - PROJECT_STRUCTURE.md: Standard KB-integrated project layout with Hydrotech reference - KB_CONVENTIONS.md: Knowledge Base accumulation principles with generation tracking - AGENT_WORKFLOWS.md: Project lifecycle phases and agent handoffs (OP_09 integration) - STUDY_CONVENTIONS.md: Technical study execution standards and atomizer_spec.json format Index Update: - Reorganized docs/00_INDEX.md with HQ docs prominent - Updated structure to reflect new agent-focused organization - Maintained core documentation access for engineers No files deleted, only moved to appropriate archive locations.
41 KiB
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:
// 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:
// 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):
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.
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:
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):
await websocket.send_json({
"type": "spec_modified",
"tool": tool_call["tool"],
"changes": tool_call["result_preview"],
})
Change: Send full spec instead of just changes.
# 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:
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):
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:
// 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:
return {
// ... existing
notifyCanvasEdit, // NEW
};
Task 1.5: Frontend - Use Full Spec from spec_updated
File: frontend/src/hooks/useChat.ts
Current (line 247-262):
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.
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:
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):
onCanvasModification: chatPowerMode ? (modification) => {
console.log('Canvas modification from Claude:', modification);
showNotification(`Claude: ${modification.action}...`);
reloadSpec();
} : undefined,
Change: Use onSpecUpdated callback.
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:
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):
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:
const [streamingText, setStreamingText] = useState<string>('');
Handle text_delta:
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():
{
"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:
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:
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)
"""
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:
{
"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:
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)
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:
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
- Start with Phase 1 - It's the foundation for everything else
- Test each task individually before moving to next
- The key insight:
atomizer_spec.jsonis the single source of truth - Don't break existing functionality - Power mode should still work
Quick Wins (Do First)
- Task 1.1 - Add canvas state to Claude context (easy, high value)
- Task 1.2 - Send full spec in response (easy, removes HTTP reload)
- 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:
-
Bi-directional sync works: Claude modifies → Canvas updates instantly. User edits → Claude sees in next message.
-
Streaming works: Text appears as Claude types, tool calls show in real-time.
-
Study creation works: User can say "create bracket optimization with mass objective" and see it built on canvas.
-
Interview works: User can say "help me set up an optimization" and be guided through the process.
-
No HTTP reloads: Canvas updates purely through WebSocket.