""" Gemini Planner - Strategic planning and test design using Gemini Pro. Handles: - Implementation planning from objectives - Test scenario generation - Architecture decisions - Risk assessment """ import asyncio import json import logging import os from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @dataclass class PlanTask: """A single task in the implementation plan.""" id: str description: str file: Optional[str] = None code_hint: Optional[str] = None priority: str = "medium" dependencies: List[str] = None def __post_init__(self): if self.dependencies is None: self.dependencies = [] @dataclass class TestScenario: """A test scenario for dashboard verification.""" id: str name: str type: str # "api", "browser", "cli", "filesystem" steps: List[Dict] = None expected_outcome: Dict = None def __post_init__(self): if self.steps is None: self.steps = [] if self.expected_outcome is None: self.expected_outcome = {"status": "pass"} class GeminiPlanner: """ Strategic planner using Gemini Pro. Generates: - Implementation tasks for Claude Code - Test scenarios for dashboard verification - Architecture decisions - Risk assessments """ def __init__(self, config: Optional[Dict] = None): """ Initialize the planner. Args: config: Configuration with API key and model settings """ self.config = config or {} self._client = None self._model = None @property def client(self): """Lazy-load Gemini client.""" if self._client is None: try: import google.generativeai as genai api_key = self.config.get("api_key") or os.environ.get("GEMINI_API_KEY") if not api_key: raise ValueError("GEMINI_API_KEY not set") genai.configure(api_key=api_key) self._client = genai model_name = self.config.get("model", "gemini-2.0-flash-thinking-exp-01-21") self._model = genai.GenerativeModel(model_name) logger.info(f"Gemini client initialized with model: {model_name}") except ImportError: logger.warning("google-generativeai not installed, using mock planner") self._client = "mock" return self._client async def create_plan(self, request: Dict) -> Dict: """ Create an implementation plan from an objective. Args: request: Dict with: - objective: What to achieve - context: Additional context (study spec, etc.) - previous_results: Results from last iteration - historical_learnings: Relevant LAC insights Returns: Plan dict with tasks, test_scenarios, risks """ objective = request.get("objective", "") context = request.get("context", {}) previous_results = request.get("previous_results") learnings = request.get("historical_learnings", []) # Build planning prompt prompt = self._build_planning_prompt(objective, context, previous_results, learnings) # Get response from Gemini if self.client == "mock": plan = self._mock_plan(objective, context) else: plan = await self._query_gemini(prompt) return plan def _build_planning_prompt( self, objective: str, context: Dict, previous_results: Optional[Dict], learnings: List[Dict], ) -> str: """Build the planning prompt for Gemini.""" prompt = f"""## Atomizer Development Planning Session ### Objective {objective} ### Context {json.dumps(context, indent=2) if context else "No additional context provided."} ### Previous Iteration Results {json.dumps(previous_results, indent=2) if previous_results else "First iteration - no previous results."} ### Historical Learnings (from LAC) {self._format_learnings(learnings)} ### Required Outputs Generate a detailed implementation plan in JSON format with the following structure: ```json {{ "objective": "{objective}", "approach": "Brief description of the approach", "tasks": [ {{ "id": "task_001", "description": "What to do", "file": "path/to/file.py", "code_hint": "Pseudo-code or pattern to use", "priority": "high|medium|low", "dependencies": ["task_000"] }} ], "test_scenarios": [ {{ "id": "test_001", "name": "Test name", "type": "api|browser|cli|filesystem", "steps": [ {{"action": "navigate", "target": "/canvas"}} ], "expected_outcome": {{"status": "pass", "assertions": []}} }} ], "risks": [ {{ "description": "What could go wrong", "mitigation": "How to handle it", "severity": "high|medium|low" }} ], "acceptance_criteria": [ "Criteria 1", "Criteria 2" ] }} ``` ### Guidelines 1. **Tasks should be specific and actionable** - Each task should be completable by Claude Code 2. **Test scenarios must be verifiable** - Use dashboard endpoints and browser actions 3. **Consider Atomizer architecture** - Use existing extractors (SYS_12), follow AtomizerSpec v2.0 4. **Apply historical learnings** - Avoid known failure patterns ### Important Atomizer Patterns - Studies use `atomizer_spec.json` (AtomizerSpec v2.0) - Design variables have bounds: {{"min": X, "max": Y}} - Objectives use extractors: E1 (displacement), E3 (stress), E4 (mass) - Constraints define limits with operators: <, >, <=, >= Output ONLY the JSON plan, no additional text. """ return prompt def _format_learnings(self, learnings: List[Dict]) -> str: """Format LAC learnings for the prompt.""" if not learnings: return "No relevant historical learnings." formatted = [] for learning in learnings[:5]: # Limit to 5 most relevant formatted.append( f"- [{learning.get('category', 'insight')}] {learning.get('insight', '')}" ) return "\n".join(formatted) async def _query_gemini(self, prompt: str) -> Dict: """Query Gemini and parse response.""" try: # Run in executor to not block loop = asyncio.get_event_loop() response = await loop.run_in_executor( None, lambda: self._model.generate_content(prompt) ) # Extract JSON from response text = response.text # Try to parse JSON try: # Find JSON block if "```json" in text: start = text.find("```json") + 7 end = text.find("```", start) json_str = text[start:end].strip() elif "```" in text: start = text.find("```") + 3 end = text.find("```", start) json_str = text[start:end].strip() else: json_str = text.strip() plan = json.loads(json_str) logger.info(f"Gemini plan parsed: {len(plan.get('tasks', []))} tasks") return plan except json.JSONDecodeError as e: logger.error(f"Failed to parse Gemini response: {e}") return { "objective": "Parse error", "error": str(e), "raw_response": text[:500], "tasks": [], "test_scenarios": [], } except Exception as e: logger.error(f"Gemini query failed: {e}") return { "objective": "Query error", "error": str(e), "tasks": [], "test_scenarios": [], } def _mock_plan(self, objective: str, context: Dict) -> Dict: """Generate a mock plan for testing without Gemini API.""" logger.info("Using mock planner (Gemini not available)") # Detect objective type is_study_creation = any( kw in objective.lower() for kw in ["create", "study", "new", "setup"] ) tasks = [] test_scenarios = [] if is_study_creation: study_name = context.get("study_name", "support_arm") tasks = [ { "id": "task_001", "description": f"Create study directory structure for {study_name}", "file": f"studies/_Other/{study_name}/", "priority": "high", "dependencies": [], }, { "id": "task_002", "description": "Copy NX model files to study directory", "file": f"studies/_Other/{study_name}/1_setup/model/", "priority": "high", "dependencies": ["task_001"], }, { "id": "task_003", "description": "Create AtomizerSpec v2.0 configuration", "file": f"studies/_Other/{study_name}/atomizer_spec.json", "priority": "high", "dependencies": ["task_002"], }, { "id": "task_004", "description": "Create run_optimization.py script", "file": f"studies/_Other/{study_name}/run_optimization.py", "priority": "high", "dependencies": ["task_003"], }, { "id": "task_005", "description": "Create README.md documentation", "file": f"studies/_Other/{study_name}/README.md", "priority": "medium", "dependencies": ["task_003"], }, ] test_scenarios = [ { "id": "test_001", "name": "Study directory exists", "type": "filesystem", "steps": [{"action": "check_exists", "path": f"studies/_Other/{study_name}"}], "expected_outcome": {"exists": True}, }, { "id": "test_002", "name": "AtomizerSpec is valid", "type": "api", "steps": [ {"action": "get", "endpoint": f"/api/studies/{study_name}/spec/validate"} ], "expected_outcome": {"valid": True}, }, { "id": "test_003", "name": "Dashboard loads study", "type": "browser", "steps": [ {"action": "navigate", "url": f"/canvas/{study_name}"}, {"action": "wait_for", "selector": "[data-testid='canvas-container']"}, ], "expected_outcome": {"loaded": True}, }, ] return { "objective": objective, "approach": "Mock plan for development testing", "tasks": tasks, "test_scenarios": test_scenarios, "risks": [ { "description": "NX model files may have dependencies", "mitigation": "Copy all related files (_i.prt, .fem, .sim)", "severity": "high", } ], "acceptance_criteria": [ "Study directory structure created", "AtomizerSpec validates without errors", "Dashboard loads study canvas", ], } async def analyze_codebase(self, query: str) -> Dict: """ Use Gemini to analyze codebase state. Args: query: What to analyze (e.g., "current dashboard components") Returns: Analysis results """ # This would integrate with codebase scanning # For now, return a stub return { "query": query, "analysis": "Codebase analysis not yet implemented", "recommendations": [], } async def generate_test_scenarios( self, feature: str, context: Optional[Dict] = None, ) -> List[Dict]: """ Generate test scenarios for a specific feature. Args: feature: Feature to test (e.g., "study creation", "spec validation") context: Additional context Returns: List of test scenarios """ prompt = f"""Generate test scenarios for the Atomizer feature: {feature} Context: {json.dumps(context, indent=2) if context else "None"} Output as JSON array of test scenarios: ```json [ {{ "id": "test_001", "name": "Test name", "type": "api|browser|cli|filesystem", "steps": [...] "expected_outcome": {{...}} }} ] ``` """ if self.client == "mock": return self._mock_plan(feature, context or {}).get("test_scenarios", []) # Query Gemini try: loop = asyncio.get_event_loop() response = await loop.run_in_executor( None, lambda: self._model.generate_content(prompt) ) text = response.text if "```json" in text: start = text.find("```json") + 7 end = text.find("```", start) json_str = text[start:end].strip() return json.loads(json_str) except Exception as e: logger.error(f"Failed to generate test scenarios: {e}") return []