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>
397 lines
13 KiB
Python
397 lines
13 KiB
Python
"""
|
|
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
|