""" 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