feat: Add Studio UI, intake system, and extractor improvements

Dashboard:
- Add Studio page with drag-drop model upload and Claude chat
- Add intake system for study creation workflow
- Improve session manager and context builder
- Add intake API routes and frontend components

Optimization Engine:
- Add CLI module for command-line operations
- Add intake module for study preprocessing
- Add validation module with gate checks
- Improve Zernike extractor documentation
- Update spec models with better validation
- Enhance solve_simulation robustness

Documentation:
- Add ATOMIZER_STUDIO.md planning doc
- Add ATOMIZER_UX_SYSTEM.md for UX patterns
- Update extractor library docs
- Add study-readme-generator skill

Tools:
- Add test scripts for extraction validation
- Add Zernike recentering test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

@@ -13,7 +13,19 @@ import sys
# Add parent directory to path to import optimization_engine
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
from api.routes import optimization, claude, terminal, insights, context, files, nx, claude_code, spec
from api.routes import (
optimization,
claude,
terminal,
insights,
context,
files,
nx,
claude_code,
spec,
devloop,
intake,
)
from api.websocket import optimization_stream
@@ -23,6 +35,7 @@ async def lifespan(app: FastAPI):
"""Manage application lifespan - start/stop session manager"""
# Startup
from api.routes.claude import get_session_manager
manager = get_session_manager()
await manager.start()
print("Session manager started")
@@ -63,6 +76,9 @@ app.include_router(nx.router, prefix="/api/nx", tags=["nx"])
app.include_router(claude_code.router, prefix="/api", tags=["claude-code"])
app.include_router(spec.router, prefix="/api", tags=["spec"])
app.include_router(spec.validate_router, prefix="/api", tags=["spec"])
app.include_router(devloop.router, prefix="/api", tags=["devloop"])
app.include_router(intake.router, prefix="/api", tags=["intake"])
@app.get("/")
async def root():
@@ -70,11 +86,13 @@ async def root():
dashboard_path = Path(__file__).parent.parent.parent / "dashboard-enhanced.html"
return FileResponse(dashboard_path)
@app.get("/health")
async def health_check():
"""Health check endpoint with database status"""
try:
from api.services.conversation_store import ConversationStore
store = ConversationStore()
# Test database by creating/getting a health check session
store.get_session("health_check")
@@ -87,12 +105,8 @@ async def health_check():
"database": db_status,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, log_level="info")

File diff suppressed because it is too large Load Diff

View File

@@ -245,17 +245,45 @@ def _get_study_error_info(study_dir: Path, results_dir: Path) -> dict:
def _load_study_info(study_dir: Path, topic: Optional[str] = None) -> Optional[dict]:
"""Load study info from a study directory. Returns None if not a valid study."""
# Look for optimization config (check multiple locations)
config_file = study_dir / "optimization_config.json"
if not config_file.exists():
config_file = study_dir / "1_setup" / "optimization_config.json"
if not config_file.exists():
# Look for config file - prefer atomizer_spec.json (v2.0), fall back to legacy optimization_config.json
config_file = None
is_atomizer_spec = False
# Check for AtomizerSpec v2.0 first
for spec_path in [
study_dir / "atomizer_spec.json",
study_dir / "1_setup" / "atomizer_spec.json",
]:
if spec_path.exists():
config_file = spec_path
is_atomizer_spec = True
break
# Fall back to legacy optimization_config.json
if config_file is None:
for legacy_path in [
study_dir / "optimization_config.json",
study_dir / "1_setup" / "optimization_config.json",
]:
if legacy_path.exists():
config_file = legacy_path
break
if config_file is None:
return None
# Load config
with open(config_file) as f:
config = json.load(f)
# Normalize AtomizerSpec v2.0 to legacy format for compatibility
if is_atomizer_spec and "meta" in config:
# Extract study_name and description from meta
meta = config.get("meta", {})
config["study_name"] = meta.get("study_name", study_dir.name)
config["description"] = meta.get("description", "")
config["version"] = meta.get("version", "2.0")
# Check if results directory exists (support both 2_results and 3_results)
results_dir = study_dir / "2_results"
if not results_dir.exists():
@@ -311,12 +339,21 @@ def _load_study_info(study_dir: Path, topic: Optional[str] = None) -> Optional[d
best_trial = min(history, key=lambda x: x["objective"])
best_value = best_trial["objective"]
# Get total trials from config (supports both formats)
total_trials = (
config.get("optimization_settings", {}).get("n_trials")
or config.get("optimization", {}).get("n_trials")
or config.get("trials", {}).get("n_trials", 50)
)
# Get total trials from config (supports AtomizerSpec v2.0 and legacy formats)
total_trials = None
# AtomizerSpec v2.0: optimization.budget.max_trials
if is_atomizer_spec:
total_trials = config.get("optimization", {}).get("budget", {}).get("max_trials")
# Legacy formats
if total_trials is None:
total_trials = (
config.get("optimization_settings", {}).get("n_trials")
or config.get("optimization", {}).get("n_trials")
or config.get("optimization", {}).get("max_trials")
or config.get("trials", {}).get("n_trials", 100)
)
# Get accurate status using process detection
status = get_accurate_study_status(study_dir.name, trial_count, total_trials, has_db)
@@ -380,7 +417,12 @@ async def list_studies():
continue
# Check if this is a study (flat structure) or a topic folder (nested structure)
is_study = (item / "1_setup").exists() or (item / "optimization_config.json").exists()
# Support both AtomizerSpec v2.0 (atomizer_spec.json) and legacy (optimization_config.json)
is_study = (
(item / "1_setup").exists()
or (item / "atomizer_spec.json").exists()
or (item / "optimization_config.json").exists()
)
if is_study:
# Flat structure: study directly in studies/
@@ -396,10 +438,12 @@ async def list_studies():
if sub_item.name.startswith("."):
continue
# Check if this subdirectory is a study
sub_is_study = (sub_item / "1_setup").exists() or (
sub_item / "optimization_config.json"
).exists()
# Check if this subdirectory is a study (AtomizerSpec v2.0 or legacy)
sub_is_study = (
(sub_item / "1_setup").exists()
or (sub_item / "atomizer_spec.json").exists()
or (sub_item / "optimization_config.json").exists()
)
if sub_is_study:
study_info = _load_study_info(sub_item, topic=item.name)
if study_info:

View File

@@ -0,0 +1,396 @@
"""
Claude README Generator Service
Generates intelligent README.md files for optimization studies
using Claude Code CLI (not API) with study context from AtomizerSpec.
"""
import asyncio
import json
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
# Base directory
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
# Load skill prompt
SKILL_PATH = ATOMIZER_ROOT / ".claude" / "skills" / "modules" / "study-readme-generator.md"
def load_skill_prompt() -> str:
"""Load the README generator skill prompt."""
if SKILL_PATH.exists():
return SKILL_PATH.read_text(encoding="utf-8")
return ""
class ClaudeReadmeGenerator:
"""Generate README.md files using Claude Code CLI."""
def __init__(self):
self.skill_prompt = load_skill_prompt()
def generate_readme(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]] = None,
topic: Optional[str] = None,
) -> str:
"""
Generate a README.md for a study using Claude Code CLI.
Args:
study_name: Name of the study
spec: Full AtomizerSpec v2.0 dict
context_files: Optional dict of {filename: content} for context
topic: Optional topic folder name
Returns:
Generated README.md content
"""
# Build context for Claude
context = self._build_context(study_name, spec, context_files, topic)
# Build the prompt
prompt = self._build_prompt(context)
try:
# Run Claude Code CLI synchronously
result = self._run_claude_cli(prompt)
# Extract markdown content from response
readme_content = self._extract_markdown(result)
if readme_content:
return readme_content
# If no markdown found, return the whole response
return result if result else self._generate_fallback_readme(study_name, spec)
except Exception as e:
print(f"Claude CLI error: {e}")
return self._generate_fallback_readme(study_name, spec)
async def generate_readme_async(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]] = None,
topic: Optional[str] = None,
) -> str:
"""Async version of generate_readme."""
# Run in thread pool to not block
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None, lambda: self.generate_readme(study_name, spec, context_files, topic)
)
def _run_claude_cli(self, prompt: str) -> str:
"""Run Claude Code CLI and get response."""
try:
# Use claude CLI with --print flag for non-interactive output
result = subprocess.run(
["claude", "--print", prompt],
capture_output=True,
text=True,
timeout=120, # 2 minute timeout
cwd=str(ATOMIZER_ROOT),
)
if result.returncode != 0:
error_msg = result.stderr or "Unknown error"
raise Exception(f"Claude CLI error: {error_msg}")
return result.stdout.strip()
except subprocess.TimeoutExpired:
raise Exception("Request timed out")
except FileNotFoundError:
raise Exception("Claude CLI not found. Make sure 'claude' is in PATH.")
def _build_context(
self,
study_name: str,
spec: Dict[str, Any],
context_files: Optional[Dict[str, str]],
topic: Optional[str],
) -> Dict[str, Any]:
"""Build the context object for Claude."""
meta = spec.get("meta", {})
model = spec.get("model", {})
introspection = model.get("introspection", {}) or {}
context = {
"study_name": study_name,
"topic": topic or meta.get("topic", "Other"),
"description": meta.get("description", ""),
"created": meta.get("created", datetime.now().isoformat()),
"status": meta.get("status", "draft"),
"design_variables": spec.get("design_variables", []),
"extractors": spec.get("extractors", []),
"objectives": spec.get("objectives", []),
"constraints": spec.get("constraints", []),
"optimization": spec.get("optimization", {}),
"introspection": {
"mass_kg": introspection.get("mass_kg"),
"volume_mm3": introspection.get("volume_mm3"),
"solver_type": introspection.get("solver_type"),
"expressions": introspection.get("expressions", []),
"expressions_count": len(introspection.get("expressions", [])),
},
"model_files": {
"sim": model.get("sim", {}).get("path") if model.get("sim") else None,
"prt": model.get("prt", {}).get("path") if model.get("prt") else None,
"fem": model.get("fem", {}).get("path") if model.get("fem") else None,
},
}
# Add context files if provided
if context_files:
context["context_files"] = context_files
return context
def _build_prompt(self, context: Dict[str, Any]) -> str:
"""Build the prompt for Claude CLI."""
# Build context files section if available
context_files_section = ""
if context.get("context_files"):
context_files_section = "\n\n## User-Provided Context Files\n\nIMPORTANT: Use this information to understand the optimization goals, design variables, objectives, and constraints:\n\n"
for filename, content in context.get("context_files", {}).items():
context_files_section += f"### {filename}\n```\n{content}\n```\n\n"
# Remove context_files from JSON dump to avoid duplication
context_for_json = {k: v for k, v in context.items() if k != "context_files"}
prompt = f"""Generate a README.md for this FEA optimization study.
## Study Technical Data
```json
{json.dumps(context_for_json, indent=2, default=str)}
```
{context_files_section}
## Requirements
1. Use the EXACT values from the technical data - no placeholders
2. If context files are provided, extract:
- Design variable bounds (min/max)
- Optimization objectives (minimize/maximize what)
- Constraints (stress limits, etc.)
- Any specific requirements mentioned
3. Format the README with these sections:
- Title (# Study Name)
- Overview (topic, date, status, description from context)
- Engineering Problem (what we're optimizing and why - from context files)
- Model Information (mass, solver, files)
- Design Variables (if context specifies bounds, include them in a table)
- Optimization Objectives (from context files)
- Constraints (from context files)
- Expressions Found (table of discovered expressions, highlight candidates)
- Next Steps (what needs to be configured)
4. Keep it professional and concise
5. Use proper markdown table formatting
6. Include units where applicable
7. For expressions table, show: name, value, units, is_candidate
Generate ONLY the README.md content in markdown format, no explanations:"""
return prompt
def _extract_markdown(self, response: str) -> Optional[str]:
"""Extract markdown content from Claude response."""
if not response:
return None
# If response starts with #, it's already markdown
if response.strip().startswith("#"):
return response.strip()
# Try to find markdown block
if "```markdown" in response:
start = response.find("```markdown") + len("```markdown")
end = response.find("```", start)
if end > start:
return response[start:end].strip()
if "```md" in response:
start = response.find("```md") + len("```md")
end = response.find("```", start)
if end > start:
return response[start:end].strip()
# Look for first # heading
lines = response.split("\n")
for i, line in enumerate(lines):
if line.strip().startswith("# "):
return "\n".join(lines[i:]).strip()
return None
def _generate_fallback_readme(self, study_name: str, spec: Dict[str, Any]) -> str:
"""Generate a basic README if Claude fails."""
meta = spec.get("meta", {})
model = spec.get("model", {})
introspection = model.get("introspection", {}) or {}
dvs = spec.get("design_variables", [])
objs = spec.get("objectives", [])
cons = spec.get("constraints", [])
opt = spec.get("optimization", {})
expressions = introspection.get("expressions", [])
lines = [
f"# {study_name.replace('_', ' ').title()}",
"",
f"**Topic**: {meta.get('topic', 'Other')}",
f"**Created**: {meta.get('created', 'Unknown')[:10] if meta.get('created') else 'Unknown'}",
f"**Status**: {meta.get('status', 'draft')}",
"",
]
if meta.get("description"):
lines.extend([meta["description"], ""])
# Model Information
lines.extend(
[
"## Model Information",
"",
]
)
if introspection.get("mass_kg"):
lines.append(f"- **Mass**: {introspection['mass_kg']:.2f} kg")
sim_path = model.get("sim", {}).get("path") if model.get("sim") else None
if sim_path:
lines.append(f"- **Simulation**: {sim_path}")
lines.append("")
# Expressions Found
if expressions:
lines.extend(
[
"## Expressions Found",
"",
"| Name | Value | Units | Candidate |",
"|------|-------|-------|-----------|",
]
)
for expr in expressions:
is_candidate = "" if expr.get("is_candidate") else ""
value = f"{expr.get('value', '-')}"
units = expr.get("units", "-")
lines.append(f"| {expr.get('name', '-')} | {value} | {units} | {is_candidate} |")
lines.append("")
# Design Variables (if configured)
if dvs:
lines.extend(
[
"## Design Variables",
"",
"| Variable | Expression | Range | Units |",
"|----------|------------|-------|-------|",
]
)
for dv in dvs:
bounds = dv.get("bounds", {})
units = dv.get("units", "-")
lines.append(
f"| {dv.get('name', 'Unknown')} | "
f"{dv.get('expression_name', '-')} | "
f"[{bounds.get('min', '-')}, {bounds.get('max', '-')}] | "
f"{units} |"
)
lines.append("")
# Objectives
if objs:
lines.extend(
[
"## Objectives",
"",
"| Objective | Direction | Weight |",
"|-----------|-----------|--------|",
]
)
for obj in objs:
lines.append(
f"| {obj.get('name', 'Unknown')} | "
f"{obj.get('direction', 'minimize')} | "
f"{obj.get('weight', 1.0)} |"
)
lines.append("")
# Constraints
if cons:
lines.extend(
[
"## Constraints",
"",
"| Constraint | Condition | Threshold |",
"|------------|-----------|-----------|",
]
)
for con in cons:
lines.append(
f"| {con.get('name', 'Unknown')} | "
f"{con.get('operator', '<=')} | "
f"{con.get('threshold', '-')} |"
)
lines.append("")
# Algorithm
algo = opt.get("algorithm", {})
budget = opt.get("budget", {})
lines.extend(
[
"## Methodology",
"",
f"- **Algorithm**: {algo.get('type', 'TPE')}",
f"- **Max Trials**: {budget.get('max_trials', 100)}",
"",
]
)
# Next Steps
lines.extend(
[
"## Next Steps",
"",
]
)
if not dvs:
lines.append("- [ ] Configure design variables from discovered expressions")
if not objs:
lines.append("- [ ] Define optimization objectives")
if not dvs and not objs:
lines.append("- [ ] Open in Canvas Builder to complete configuration")
else:
lines.append("- [ ] Run baseline solve to validate setup")
lines.append("- [ ] Finalize study to move to studies folder")
lines.append("")
return "\n".join(lines)
# Singleton instance
_generator: Optional[ClaudeReadmeGenerator] = None
def get_readme_generator() -> ClaudeReadmeGenerator:
"""Get the singleton README generator instance."""
global _generator
if _generator is None:
_generator = ClaudeReadmeGenerator()
return _generator

View File

@@ -26,6 +26,7 @@ class ContextBuilder:
study_id: Optional[str] = None,
conversation_history: Optional[List[Dict[str, Any]]] = None,
canvas_state: Optional[Dict[str, Any]] = None,
spec_path: Optional[str] = None,
) -> str:
"""
Build full system prompt with context.
@@ -35,6 +36,7 @@ class ContextBuilder:
study_id: Optional study name to provide context for
conversation_history: Optional recent messages for continuity
canvas_state: Optional canvas state (nodes, edges) from the UI
spec_path: Optional path to the atomizer_spec.json file
Returns:
Complete system prompt string
@@ -45,7 +47,7 @@ class ContextBuilder:
if canvas_state:
node_count = len(canvas_state.get("nodes", []))
print(f"[ContextBuilder] Including canvas context with {node_count} nodes")
parts.append(self._canvas_context(canvas_state))
parts.append(self._canvas_context(canvas_state, spec_path))
else:
print("[ContextBuilder] No canvas state provided")
@@ -57,7 +59,7 @@ class ContextBuilder:
if conversation_history:
parts.append(self._conversation_context(conversation_history))
parts.append(self._mode_instructions(mode))
parts.append(self._mode_instructions(mode, spec_path))
return "\n\n---\n\n".join(parts)
@@ -298,7 +300,7 @@ Important guidelines:
return context
def _canvas_context(self, canvas_state: Dict[str, Any]) -> str:
def _canvas_context(self, canvas_state: Dict[str, Any], spec_path: Optional[str] = None) -> str:
"""
Build context from canvas state (nodes and edges).
@@ -317,6 +319,8 @@ Important guidelines:
context += f"**Study Name**: {study_name}\n"
if study_path:
context += f"**Study Path**: {study_path}\n"
if spec_path:
context += f"**Spec File**: `{spec_path}`\n"
context += "\n"
# Group nodes by type
@@ -438,61 +442,100 @@ Important guidelines:
context += f"Total edges: {len(edges)}\n"
context += "Flow: Design Variables → Model → Solver → Extractors → Objectives/Constraints → Algorithm\n\n"
# Canvas modification instructions
context += """## Canvas Modification Tools
**For AtomizerSpec v2.0 studies (preferred):**
Use spec tools when working with v2.0 studies (check if study uses `atomizer_spec.json`):
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
- `spec_remove_node` - Remove nodes from the spec
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
**For Legacy Canvas (optimization_config.json):**
- `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint)
- `canvas_update_node` - Update node properties (bounds, weights, names)
- `canvas_remove_node` - Remove a node from the canvas
- `canvas_connect_nodes` - Create an edge between nodes
**Example user requests you can handle:**
- "Add a design variable called hole_diameter with range 5-15 mm" → Use spec_add_node or canvas_add_node
- "Change the weight of wfe_40_20 to 8" → Use spec_modify or canvas_update_node
- "Remove the constraint node" → Use spec_remove_node or canvas_remove_node
- "Add a custom extractor that computes stress ratio" → Use spec_add_custom_extractor
Always respond with confirmation of changes made to the canvas/spec.
"""
# Instructions will be in _mode_instructions based on spec_path
return context
def _mode_instructions(self, mode: str) -> str:
def _mode_instructions(self, mode: str, spec_path: Optional[str] = None) -> str:
"""Mode-specific instructions"""
if mode == "power":
return """# Power Mode Instructions
instructions = """# Power Mode Instructions
You have **FULL ACCESS** to modify Atomizer studies. **DO NOT ASK FOR PERMISSION** - just do it.
## Direct Actions (no confirmation needed):
- **Add design variables**: Use `canvas_add_node` or `spec_add_node` with node_type="designVar"
- **Add extractors**: Use `canvas_add_node` with node_type="extractor"
- **Add objectives**: Use `canvas_add_node` with node_type="objective"
- **Add constraints**: Use `canvas_add_node` with node_type="constraint"
- **Update node properties**: Use `canvas_update_node` or `spec_modify`
- **Remove nodes**: Use `canvas_remove_node`
- **Edit atomizer_spec.json directly**: Use the Edit tool
## CRITICAL: How to Modify the Spec
## For custom extractors with Python code:
Use `spec_add_custom_extractor` to add a custom function.
## IMPORTANT:
- You have --dangerously-skip-permissions enabled
- The user has explicitly granted you power mode access
- **ACT IMMEDIATELY** when asked to add/modify/remove things
- Explain what you did AFTER doing it, not before
- Do NOT say "I need permission" - you already have it
Example: If user says "add a volume extractor", immediately use canvas_add_node to add it.
"""
if spec_path:
instructions += f"""**The spec file is at**: `{spec_path}`
When asked to add/modify/remove design variables, extractors, objectives, or constraints:
1. **Read the spec file first** using the Read tool
2. **Edit the spec file** using the Edit tool to make precise changes
3. **Confirm what you changed** in your response
### AtomizerSpec v2.0 Structure
The spec has these main arrays you can modify:
- `design_variables` - Parameters to optimize
- `extractors` - Physics extraction functions
- `objectives` - What to minimize/maximize
- `constraints` - Limits that must be satisfied
### Example: Add a Design Variable
To add a design variable called "thickness" with bounds [1, 10]:
1. Read the spec: `Read({spec_path})`
2. Find the `"design_variables": [...]` array
3. Add a new entry like:
```json
{{
"id": "dv_thickness",
"name": "thickness",
"expression_name": "thickness",
"type": "continuous",
"bounds": {{"min": 1, "max": 10}},
"baseline": 5,
"units": "mm",
"enabled": true
}}
```
4. Use Edit tool to insert this into the array
### Example: Add an Objective
To add a "minimize mass" objective:
```json
{{
"id": "obj_mass",
"name": "mass",
"direction": "minimize",
"weight": 1.0,
"source": {{
"extractor_id": "ext_mass",
"output_name": "mass"
}}
}}
```
### Example: Add an Extractor
To add a mass extractor:
```json
{{
"id": "ext_mass",
"name": "mass",
"type": "mass",
"builtin": true,
"outputs": [{{"name": "mass", "units": "kg"}}]
}}
```
"""
else:
instructions += """No spec file is currently set. Ask the user which study they want to work with.
"""
instructions += """## IMPORTANT Rules:
- You have --dangerously-skip-permissions enabled
- **ACT IMMEDIATELY** when asked to add/modify/remove things
- Use the **Edit** tool to modify the spec file directly
- Generate unique IDs like `dv_<name>`, `ext_<name>`, `obj_<name>`, `con_<name>`
- Explain what you changed AFTER doing it, not before
- Do NOT say "I need permission" - you already have it
"""
return instructions
else:
return """# User Mode Instructions
@@ -503,29 +546,11 @@ You can help with optimization workflows:
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
**For modifying studies**, the user needs to switch to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`
**AtomizerSpec v2.0 Tools (preferred for new studies):**
- `spec_get` - Get the full AtomizerSpec for a study
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
- `spec_remove_node` - Remove nodes from the spec
- `spec_validate` - Validate spec against JSON Schema
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
- `spec_create_from_description` - Create a new study from natural language description
**Canvas Tools (for visual workflow builder):**
- `validate_canvas_intent` - Validate a canvas-generated optimization intent
- `execute_canvas_intent` - Create a study from a canvas intent
- `interpret_canvas_intent` - Analyze intent and provide recommendations
When you receive a message containing "INTENT:" followed by JSON, this is from the Canvas UI.
Parse the intent and use the appropriate canvas tool to process it.
In user mode you can:
- Read and explain study configurations
- Analyze optimization results
- Provide recommendations
- Answer questions about FEA and optimization
"""

View File

@@ -1,11 +1,15 @@
"""
Session Manager
Manages persistent Claude Code sessions with MCP integration.
Manages persistent Claude Code sessions with direct file editing.
Fixed for Windows compatibility - uses subprocess.Popen with ThreadPoolExecutor.
Strategy: Claude edits atomizer_spec.json directly using Edit/Write tools
(no MCP dependency for reliability).
"""
import asyncio
import hashlib
import json
import os
import subprocess
@@ -26,6 +30,10 @@ MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools"
# Thread pool for subprocess operations (Windows compatible)
_executor = ThreadPoolExecutor(max_workers=4)
import logging
logger = logging.getLogger(__name__)
@dataclass
class ClaudeSession:
@@ -130,6 +138,7 @@ class SessionManager:
Send a message to a session and stream the response.
Uses synchronous subprocess.Popen via ThreadPoolExecutor for Windows compatibility.
Claude edits atomizer_spec.json directly using Edit/Write tools (no MCP).
Args:
session_id: The session ID
@@ -147,45 +156,48 @@ class SessionManager:
# Store user message
self.store.add_message(session_id, "user", message)
# Get spec path and hash BEFORE Claude runs (to detect changes)
spec_path = self._get_spec_path(session.study_id) if session.study_id else None
spec_hash_before = self._get_file_hash(spec_path) if spec_path else None
# Build context with conversation history AND canvas state
history = self.store.get_history(session_id, limit=10)
full_prompt = self.context_builder.build(
mode=session.mode,
study_id=session.study_id,
conversation_history=history[:-1],
canvas_state=canvas_state, # Pass canvas state for context
canvas_state=canvas_state,
spec_path=str(spec_path) if spec_path else None, # Tell Claude where the spec is
)
full_prompt += f"\n\nUser: {message}\n\nRespond helpfully and concisely:"
# Build CLI arguments
# Build CLI arguments - NO MCP for reliability
cli_args = ["claude", "--print"]
# Ensure MCP config exists
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
if not mcp_config_path.exists():
mcp_config = self._build_mcp_config(session.mode)
with open(mcp_config_path, "w") as f:
json.dump(mcp_config, f)
cli_args.extend(["--mcp-config", str(mcp_config_path)])
if session.mode == "user":
cli_args.extend([
"--allowedTools",
"Read Write(**/STUDY_REPORT.md) Write(**/3_results/*.md) Bash(python:*) mcp__atomizer-tools__*"
])
# User mode: limited tools
cli_args.extend(
[
"--allowedTools",
"Read Bash(python:*)",
]
)
else:
# Power mode: full access to edit files
cli_args.append("--dangerously-skip-permissions")
cli_args.append("-") # Read from stdin
full_response = ""
tool_calls: List[Dict] = []
process: Optional[subprocess.Popen] = None
try:
loop = asyncio.get_event_loop()
# Run subprocess in thread pool (Windows compatible)
def run_claude():
nonlocal process
try:
process = subprocess.Popen(
cli_args,
@@ -194,8 +206,8 @@ class SessionManager:
stderr=subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
text=True,
encoding='utf-8',
errors='replace',
encoding="utf-8",
errors="replace",
)
stdout, stderr = process.communicate(input=full_prompt, timeout=300)
return {
@@ -204,10 +216,13 @@ class SessionManager:
"returncode": process.returncode,
}
except subprocess.TimeoutExpired:
process.kill()
if process:
process.kill()
return {"error": "Response timeout (5 minutes)"}
except FileNotFoundError:
return {"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"}
return {
"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"
}
except Exception as e:
return {"error": str(e)}
@@ -219,24 +234,14 @@ class SessionManager:
full_response = result["stdout"] or ""
if full_response:
# Check if response contains canvas modifications (from MCP tools)
import logging
logger = logging.getLogger(__name__)
modifications = self._extract_canvas_modifications(full_response)
logger.info(f"[SEND_MSG] Found {len(modifications)} canvas modifications to send")
for mod in modifications:
logger.info(f"[SEND_MSG] Sending canvas_modification: {mod.get('action')} {mod.get('nodeType')}")
yield {"type": "canvas_modification", "modification": mod}
# Always send the text response
# Always send the text response first
yield {"type": "text", "content": full_response}
if result["returncode"] != 0 and result["stderr"]:
yield {"type": "error", "message": f"CLI error: {result['stderr']}"}
logger.warning(f"[SEND_MSG] CLI stderr: {result['stderr']}")
except Exception as e:
logger.error(f"[SEND_MSG] Exception: {e}")
yield {"type": "error", "message": str(e)}
# Store assistant response
@@ -248,8 +253,46 @@ class SessionManager:
tool_calls=tool_calls if tool_calls else None,
)
# Check if spec was modified by comparing hashes
if spec_path and session.mode == "power" and session.study_id:
spec_hash_after = self._get_file_hash(spec_path)
if spec_hash_before != spec_hash_after:
logger.info(f"[SEND_MSG] Spec file was modified! Sending update.")
spec_update = await self._check_spec_updated(session.study_id)
if spec_update:
yield {
"type": "spec_updated",
"spec": spec_update,
"tool": "direct_edit",
"reason": "Claude modified spec file directly",
}
yield {"type": "done", "tool_calls": tool_calls}
def _get_spec_path(self, study_id: str) -> Optional[Path]:
"""Get the atomizer_spec.json path for a study."""
if not study_id:
return None
if study_id.startswith("draft_"):
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
else:
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
if not spec_path.exists():
spec_path = ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
return spec_path if spec_path.exists() else None
def _get_file_hash(self, path: Optional[Path]) -> Optional[str]:
"""Get MD5 hash of a file for change detection."""
if not path or not path.exists():
return None
try:
with open(path, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
except Exception:
return None
async def switch_mode(
self,
session_id: str,
@@ -313,6 +356,7 @@ class SessionManager:
"""
import re
import logging
logger = logging.getLogger(__name__)
modifications = []
@@ -327,14 +371,16 @@ class SessionManager:
try:
# Method 1: Look for JSON in code fences
code_block_pattern = r'```(?:json)?\s*([\s\S]*?)```'
code_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
for match in re.finditer(code_block_pattern, response):
block_content = match.group(1).strip()
try:
obj = json.loads(block_content)
if isinstance(obj, dict) and 'modification' in obj:
logger.info(f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}")
modifications.append(obj['modification'])
if isinstance(obj, dict) and "modification" in obj:
logger.info(
f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}"
)
modifications.append(obj["modification"])
except json.JSONDecodeError:
continue
@@ -342,7 +388,7 @@ class SessionManager:
# This handles nested objects correctly
i = 0
while i < len(response):
if response[i] == '{':
if response[i] == "{":
# Found a potential JSON start, find matching close
brace_count = 1
j = i + 1
@@ -354,14 +400,14 @@ class SessionManager:
if escape_next:
escape_next = False
elif char == '\\':
elif char == "\\":
escape_next = True
elif char == '"' and not escape_next:
in_string = not in_string
elif not in_string:
if char == '{':
if char == "{":
brace_count += 1
elif char == '}':
elif char == "}":
brace_count -= 1
j += 1
@@ -369,11 +415,13 @@ class SessionManager:
potential_json = response[i:j]
try:
obj = json.loads(potential_json)
if isinstance(obj, dict) and 'modification' in obj:
mod = obj['modification']
if isinstance(obj, dict) and "modification" in obj:
mod = obj["modification"]
# Avoid duplicates
if mod not in modifications:
logger.info(f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}")
logger.info(
f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}"
)
modifications.append(mod)
except json.JSONDecodeError as e:
# Not valid JSON, skip
@@ -388,6 +436,43 @@ class SessionManager:
logger.info(f"[CANVAS_MOD] Extracted {len(modifications)} modification(s)")
return modifications
async def _check_spec_updated(self, study_id: str) -> Optional[Dict]:
"""
Check if the atomizer_spec.json was modified and return the updated spec.
For drafts in _inbox/, we check the spec file directly.
"""
import logging
logger = logging.getLogger(__name__)
try:
# Determine spec path based on study_id
if study_id.startswith("draft_"):
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
else:
# Regular study path
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
if not spec_path.exists():
spec_path = (
ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
)
if not spec_path.exists():
logger.debug(f"[SPEC_CHECK] Spec not found at {spec_path}")
return None
# Read and return the spec
with open(spec_path, "r", encoding="utf-8") as f:
spec = json.load(f)
logger.info(f"[SPEC_CHECK] Loaded spec from {spec_path}")
return spec
except Exception as e:
logger.error(f"[SPEC_CHECK] Error checking spec: {e}")
return None
def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict:
"""Build MCP configuration for Claude"""
return {

View File

@@ -47,11 +47,13 @@ from optimization_engine.config.spec_validator import (
class SpecManagerError(Exception):
"""Base error for SpecManager operations."""
pass
class SpecNotFoundError(SpecManagerError):
"""Raised when spec file doesn't exist."""
pass
@@ -118,7 +120,7 @@ class SpecManager:
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, 'r', encoding='utf-8') as f:
with open(self.spec_path, "r", encoding="utf-8") as f:
data = json.load(f)
if validate:
@@ -141,14 +143,15 @@ class SpecManager:
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, 'r', encoding='utf-8') as f:
with open(self.spec_path, "r", encoding="utf-8") as f:
return json.load(f)
def save(
self,
spec: Union[AtomizerSpec, Dict[str, Any]],
modified_by: str = "api",
expected_hash: Optional[str] = None
expected_hash: Optional[str] = None,
skip_validation: bool = False,
) -> str:
"""
Save spec with validation and broadcast.
@@ -157,6 +160,7 @@ class SpecManager:
spec: Spec to save (AtomizerSpec or dict)
modified_by: Who/what is making the change
expected_hash: If provided, verify current file hash matches
skip_validation: If True, skip strict validation (for draft specs)
Returns:
New spec hash
@@ -167,7 +171,7 @@ class SpecManager:
"""
# Convert to dict if needed
if isinstance(spec, AtomizerSpec):
data = spec.model_dump(mode='json')
data = spec.model_dump(mode="json")
else:
data = spec
@@ -176,24 +180,30 @@ class SpecManager:
current_hash = self.get_hash()
if current_hash != expected_hash:
raise SpecConflictError(
"Spec was modified by another client",
current_hash=current_hash
"Spec was modified by another client", current_hash=current_hash
)
# Update metadata
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
data["meta"]["modified"] = now
data["meta"]["modified_by"] = modified_by
# Validate
self.validator.validate(data, strict=True)
# Validate (skip for draft specs or when explicitly requested)
status = data.get("meta", {}).get("status", "draft")
is_draft = status in ("draft", "introspected", "configured")
if not skip_validation and not is_draft:
self.validator.validate(data, strict=True)
elif not skip_validation:
# For draft specs, just validate non-strictly (collect warnings only)
self.validator.validate(data, strict=False)
# Compute new hash
new_hash = self._compute_hash(data)
# Atomic write (write to temp, then rename)
temp_path = self.spec_path.with_suffix('.tmp')
with open(temp_path, 'w', encoding='utf-8') as f:
temp_path = self.spec_path.with_suffix(".tmp")
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
temp_path.replace(self.spec_path)
@@ -202,12 +212,9 @@ class SpecManager:
self._last_hash = new_hash
# Broadcast to subscribers
self._broadcast({
"type": "spec_updated",
"hash": new_hash,
"modified_by": modified_by,
"timestamp": now
})
self._broadcast(
{"type": "spec_updated", "hash": new_hash, "modified_by": modified_by, "timestamp": now}
)
return new_hash
@@ -219,7 +226,7 @@ class SpecManager:
"""Get current spec hash."""
if not self.spec_path.exists():
return ""
with open(self.spec_path, 'r', encoding='utf-8') as f:
with open(self.spec_path, "r", encoding="utf-8") as f:
data = json.load(f)
return self._compute_hash(data)
@@ -240,12 +247,7 @@ class SpecManager:
# Patch Operations
# =========================================================================
def patch(
self,
path: str,
value: Any,
modified_by: str = "api"
) -> AtomizerSpec:
def patch(self, path: str, value: Any, modified_by: str = "api") -> AtomizerSpec:
"""
Apply a JSONPath-style modification.
@@ -306,7 +308,7 @@ class SpecManager:
"""Parse JSONPath into parts."""
# Handle both dot notation and bracket notation
parts = []
for part in re.split(r'\.|\[|\]', path):
for part in re.split(r"\.|\[|\]", path):
if part:
parts.append(part)
return parts
@@ -316,10 +318,7 @@ class SpecManager:
# =========================================================================
def add_node(
self,
node_type: str,
node_data: Dict[str, Any],
modified_by: str = "canvas"
self, node_type: str, node_data: Dict[str, Any], modified_by: str = "canvas"
) -> str:
"""
Add a new node (design var, extractor, objective, constraint).
@@ -353,20 +352,19 @@ class SpecManager:
self.save(data, modified_by)
# Broadcast node addition
self._broadcast({
"type": "node_added",
"node_type": node_type,
"node_id": node_id,
"modified_by": modified_by
})
self._broadcast(
{
"type": "node_added",
"node_type": node_type,
"node_id": node_id,
"modified_by": modified_by,
}
)
return node_id
def update_node(
self,
node_id: str,
updates: Dict[str, Any],
modified_by: str = "canvas"
self, node_id: str, updates: Dict[str, Any], modified_by: str = "canvas"
) -> None:
"""
Update an existing node.
@@ -396,11 +394,7 @@ class SpecManager:
self.save(data, modified_by)
def remove_node(
self,
node_id: str,
modified_by: str = "canvas"
) -> None:
def remove_node(self, node_id: str, modified_by: str = "canvas") -> None:
"""
Remove a node and all edges referencing it.
@@ -427,24 +421,18 @@ class SpecManager:
# Remove edges referencing this node
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e for e in data["canvas"]["edges"]
e
for e in data["canvas"]["edges"]
if e.get("source") != node_id and e.get("target") != node_id
]
self.save(data, modified_by)
# Broadcast node removal
self._broadcast({
"type": "node_removed",
"node_id": node_id,
"modified_by": modified_by
})
self._broadcast({"type": "node_removed", "node_id": node_id, "modified_by": modified_by})
def update_node_position(
self,
node_id: str,
position: Dict[str, float],
modified_by: str = "canvas"
self, node_id: str, position: Dict[str, float], modified_by: str = "canvas"
) -> None:
"""
Update a node's canvas position.
@@ -456,12 +444,7 @@ class SpecManager:
"""
self.update_node(node_id, {"canvas_position": position}, modified_by)
def add_edge(
self,
source: str,
target: str,
modified_by: str = "canvas"
) -> None:
def add_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
"""
Add a canvas edge between nodes.
@@ -483,19 +466,11 @@ class SpecManager:
if edge.get("source") == source and edge.get("target") == target:
return # Already exists
data["canvas"]["edges"].append({
"source": source,
"target": target
})
data["canvas"]["edges"].append({"source": source, "target": target})
self.save(data, modified_by)
def remove_edge(
self,
source: str,
target: str,
modified_by: str = "canvas"
) -> None:
def remove_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
"""
Remove a canvas edge.
@@ -508,7 +483,8 @@ class SpecManager:
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e for e in data["canvas"]["edges"]
e
for e in data["canvas"]["edges"]
if not (e.get("source") == source and e.get("target") == target)
]
@@ -524,7 +500,7 @@ class SpecManager:
code: str,
outputs: List[str],
description: Optional[str] = None,
modified_by: str = "claude"
modified_by: str = "claude",
) -> str:
"""
Add a custom extractor function.
@@ -546,9 +522,7 @@ class SpecManager:
try:
compile(code, f"<custom:{name}>", "exec")
except SyntaxError as e:
raise SpecValidationError(
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
)
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
data = self.load_raw()
@@ -561,13 +535,9 @@ class SpecManager:
"name": description or f"Custom: {name}",
"type": "custom_function",
"builtin": False,
"function": {
"name": name,
"module": "custom_extractors.dynamic",
"source_code": code
},
"function": {"name": name, "module": "custom_extractors.dynamic", "source_code": code},
"outputs": [{"name": o, "metric": "custom"} for o in outputs],
"canvas_position": self._auto_position("extractor", data)
"canvas_position": self._auto_position("extractor", data),
}
data["extractors"].append(extractor)
@@ -580,7 +550,7 @@ class SpecManager:
extractor_id: str,
code: Optional[str] = None,
outputs: Optional[List[str]] = None,
modified_by: str = "claude"
modified_by: str = "claude",
) -> None:
"""
Update an existing custom function.
@@ -611,9 +581,7 @@ class SpecManager:
try:
compile(code, f"<custom:{extractor_id}>", "exec")
except SyntaxError as e:
raise SpecValidationError(
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
)
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
if "function" not in extractor:
extractor["function"] = {}
extractor["function"]["source_code"] = code
@@ -672,7 +640,7 @@ class SpecManager:
"design_variable": "dv",
"extractor": "ext",
"objective": "obj",
"constraint": "con"
"constraint": "con",
}
prefix = prefix_map.get(node_type, node_type[:3])
@@ -697,7 +665,7 @@ class SpecManager:
"design_variable": "design_variables",
"extractor": "extractors",
"objective": "objectives",
"constraint": "constraints"
"constraint": "constraints",
}
return section_map.get(node_type, node_type + "s")
@@ -709,7 +677,7 @@ class SpecManager:
"design_variable": 50,
"extractor": 740,
"objective": 1020,
"constraint": 1020
"constraint": 1020,
}
x = x_positions.get(node_type, 400)
@@ -729,11 +697,123 @@ class SpecManager:
return {"x": x, "y": y}
# =========================================================================
# Intake Workflow Methods
# =========================================================================
def update_status(self, status: str, modified_by: str = "api") -> None:
"""
Update the spec status field.
Args:
status: New status (draft, introspected, configured, validated, ready, running, completed, failed)
modified_by: Who/what is making the change
"""
data = self.load_raw()
data["meta"]["status"] = status
self.save(data, modified_by)
def get_status(self) -> str:
"""
Get the current spec status.
Returns:
Current status string
"""
if not self.exists():
return "unknown"
data = self.load_raw()
return data.get("meta", {}).get("status", "draft")
def add_introspection(
self, introspection_data: Dict[str, Any], modified_by: str = "introspection"
) -> None:
"""
Add introspection data to the spec's model section.
Args:
introspection_data: Dict with timestamp, expressions, mass_kg, etc.
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "model" not in data:
data["model"] = {}
data["model"]["introspection"] = introspection_data
data["meta"]["status"] = "introspected"
self.save(data, modified_by)
def add_baseline(
self, baseline_data: Dict[str, Any], modified_by: str = "baseline_solve"
) -> None:
"""
Add baseline solve results to introspection data.
Args:
baseline_data: Dict with timestamp, solve_time_seconds, mass_kg, etc.
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "model" not in data:
data["model"] = {}
if "introspection" not in data["model"] or data["model"]["introspection"] is None:
data["model"]["introspection"] = {}
data["model"]["introspection"]["baseline"] = baseline_data
# Update status based on baseline success
if baseline_data.get("success", False):
data["meta"]["status"] = "validated"
self.save(data, modified_by)
def set_topic(self, topic: str, modified_by: str = "api") -> None:
"""
Set the spec's topic field.
Args:
topic: Topic folder name
modified_by: Who/what is making the change
"""
data = self.load_raw()
data["meta"]["topic"] = topic
self.save(data, modified_by)
def get_introspection(self) -> Optional[Dict[str, Any]]:
"""
Get introspection data from spec.
Returns:
Introspection dict or None if not present
"""
if not self.exists():
return None
data = self.load_raw()
return data.get("model", {}).get("introspection")
def get_design_candidates(self) -> List[Dict[str, Any]]:
"""
Get expressions marked as design variable candidates.
Returns:
List of expression dicts where is_candidate=True
"""
introspection = self.get_introspection()
if not introspection:
return []
expressions = introspection.get("expressions", [])
return [e for e in expressions if e.get("is_candidate", False)]
# =========================================================================
# Factory Function
# =========================================================================
def get_spec_manager(study_path: Union[str, Path]) -> SpecManager:
"""
Get a SpecManager instance for a study.