""" Workflow Decomposer Breaks complex user requests into atomic workflow steps that can be matched against existing codebase capabilities. IMPROVED VERSION: Handles multi-objective optimization, constraints, and complex requests. Author: Atomizer Development Team Version: 0.2.0 (Phase 2.5 - Improved) Last Updated: 2025-01-16 """ import re from typing import List, Dict, Any, Set from dataclasses import dataclass @dataclass class WorkflowStep: """Represents a single atomic step in a workflow.""" action: str domain: str params: Dict[str, Any] priority: int = 0 class WorkflowDecomposer: """Breaks complex requests into atomic workflow steps.""" def __init__(self): # Extended result type mapping self.result_types = { 'displacement': 'displacement', 'deformation': 'displacement', 'stress': 'stress', 'von mises': 'stress', 'strain': 'strain', 'modal': 'modal', 'mode': 'modal', 'eigenvalue': 'modal', 'frequency': 'modal', 'temperature': 'temperature', 'thermal': 'temperature', 'reaction': 'reaction_force', 'reaction force': 'reaction_force', 'nodal reaction': 'reaction_force', 'force': 'reaction_force', 'mass': 'mass', 'weight': 'mass', 'volume': 'volume' } # Solver type mapping self.solver_types = { 'sol101': 'SOL101', 'sol 101': 'SOL101', 'static': 'SOL101', 'sol103': 'SOL103', 'sol 103': 'SOL103', 'modal': 'SOL103', 'sol106': 'SOL106', 'sol 106': 'SOL106', 'nonlinear': 'SOL106', 'sol105': 'SOL105', 'buckling': 'SOL105' } def decompose(self, user_request: str) -> List[WorkflowStep]: """ Break user request into atomic workflow steps. Handles: - Multi-objective optimization - Constraints - Multiple result extractions - Custom expressions - Parameter filtering """ steps = [] request_lower = user_request.lower() # Check if this is an optimization request is_optimization = self._is_optimization_request(request_lower) if is_optimization: steps = self._decompose_optimization_workflow(user_request, request_lower) else: steps = self._decompose_simple_workflow(user_request, request_lower) # Sort by priority steps.sort(key=lambda s: s.priority) return steps def _is_optimization_request(self, text: str) -> bool: """Check if request involves optimization.""" optimization_keywords = [ 'optimize', 'optimiz', 'minimize', 'minimiz', 'maximize', 'maximiz', 'optuna', 'genetic', 'iteration', 'vary', 'varying' ] return any(kw in text for kw in optimization_keywords) def _decompose_optimization_workflow(self, request: str, request_lower: str) -> List[WorkflowStep]: """Decompose an optimization request into workflow steps.""" steps = [] priority = 1 # 1. Identify and filter parameters param_filter = self._extract_parameter_filter(request, request_lower) if param_filter: steps.append(WorkflowStep( action='identify_parameters', domain='geometry', params={'filter': param_filter}, priority=priority )) priority += 1 # 2. Update parameters (this happens in the optimization loop) steps.append(WorkflowStep( action='update_parameters', domain='geometry', params={'source': 'optimization_algorithm'}, priority=priority )) priority += 1 # 3. Run simulation solver = self._extract_solver_type(request_lower) if solver: steps.append(WorkflowStep( action='run_analysis', domain='simulation', params={'solver': solver}, priority=priority )) priority += 1 # 4. Extract ALL result types mentioned (multi-objective!) result_extractions = self._extract_all_results(request, request_lower) for result_info in result_extractions: # If result has custom_expression (e.g., mass from .prt expression), # it's a geometry operation, not result_extraction (OP2 file) if 'custom_expression' in result_info: steps.append(WorkflowStep( action='read_expression', domain='geometry', params=result_info, priority=priority )) else: steps.append(WorkflowStep( action='extract_result', domain='result_extraction', params=result_info, priority=priority )) priority += 1 # 5. Handle constraints constraints = self._extract_constraints(request, request_lower) if constraints: steps.append(WorkflowStep( action='apply_constraints', domain='optimization', params={'constraints': constraints}, priority=priority )) priority += 1 # 6. Optimize (multi-objective if multiple objectives detected) objectives = self._extract_objectives(request, request_lower) algorithm = self._extract_algorithm(request_lower) steps.append(WorkflowStep( action='optimize', domain='optimization', params={ 'objectives': objectives, 'algorithm': algorithm, 'multi_objective': len(objectives) > 1 }, priority=priority )) return steps def _decompose_simple_workflow(self, request: str, request_lower: str) -> List[WorkflowStep]: """Decompose a non-optimization request.""" steps = [] # Check for material creation if 'material' in request_lower and ('create' in request_lower or 'generate' in request_lower): steps.append(WorkflowStep( action='create_material', domain='materials', params={} )) # Check for simulation run solver = self._extract_solver_type(request_lower) if solver: steps.append(WorkflowStep( action='run_analysis', domain='simulation', params={'solver': solver} )) # Check for result extraction result_extractions = self._extract_all_results(request, request_lower) for result_info in result_extractions: # If result has custom_expression (e.g., mass from .prt expression), # it's a geometry operation, not result_extraction (OP2 file) if 'custom_expression' in result_info: steps.append(WorkflowStep( action='read_expression', domain='geometry', params=result_info )) else: steps.append(WorkflowStep( action='extract_result', domain='result_extraction', params=result_info )) return steps def _extract_parameter_filter(self, request: str, request_lower: str) -> str: """Extract parameter filter from text.""" # Look for specific suffixes/prefixes if '_opt' in request_lower or ' opt ' in request_lower: return '_opt' if 'v_' in request_lower: return 'v_' if '_var' in request_lower: return '_var' if 'design variable' in request_lower or 'design parameter' in request_lower: return 'design_variables' if 'all parameter' in request_lower or 'all expression' in request_lower: return 'all' # Default to none if not specified return '' def _extract_solver_type(self, text: str) -> str: """Extract solver type from text.""" for keyword, solver in self.solver_types.items(): if keyword in text: return solver return '' def _extract_all_results(self, request: str, request_lower: str) -> List[Dict[str, Any]]: """ Extract ALL result types mentioned in the request. Handles multiple objectives and constraints. """ result_extractions = [] # Find all result types mentioned found_types = set() for keyword, result_type in self.result_types.items(): if keyword in request_lower: found_types.add(result_type) # For each result type, extract details for result_type in found_types: result_info = { 'result_type': result_type } # Extract subcase information subcase = self._extract_subcase(request, request_lower) if subcase: result_info['subcase'] = subcase # Extract direction (for reaction forces, displacements) if result_type in ['reaction_force', 'displacement']: direction = self._extract_direction(request, request_lower) if direction: result_info['direction'] = direction # Extract metric (min, max, specific location) metric = self._extract_metric_for_type(request, request_lower, result_type) if metric: result_info['metric'] = metric # Extract custom expression (for mass, etc.) if result_type == 'mass': custom_expr = self._extract_custom_expression(request, request_lower, 'mass') if custom_expr: result_info['custom_expression'] = custom_expr result_extractions.append(result_info) return result_extractions def _extract_subcase(self, request: str, request_lower: str) -> str: """Extract subcase information (solution X subcase Y).""" # Look for patterns like "solution 1 subcase 3" match = re.search(r'solution\s+(\d+)\s+subcase\s+(\d+)', request_lower) if match: return f"solution_{match.group(1)}_subcase_{match.group(2)}" # Look for just "subcase X" match = re.search(r'subcase\s+(\d+)', request_lower) if match: return f"subcase_{match.group(1)}" return '' def _extract_direction(self, request: str, request_lower: str) -> str: """Extract direction (X, Y, Z) for vectorial results.""" # Look for explicit direction mentions if re.search(r'\bin\s+[xyz]\b', request_lower): match = re.search(r'in\s+([xyz])\b', request_lower) if match: return match.group(1).upper() # Look for "Y direction" pattern if re.search(r'[xyz]\s+direction', request_lower): match = re.search(r'([xyz])\s+direction', request_lower) if match: return match.group(1).upper() return '' def _extract_metric_for_type(self, request: str, request_lower: str, result_type: str) -> str: """Extract metric (min, max, average) for specific result type.""" # Check for explicit min/max keywords near the result type if 'max' in request_lower or 'maximum' in request_lower: return f'max_{result_type}' if 'min' in request_lower or 'minimum' in request_lower: return f'min_{result_type}' if 'average' in request_lower or 'mean' in request_lower: return f'avg_{result_type}' # Default to max for most result types return f'max_{result_type}' def _extract_custom_expression(self, request: str, request_lower: str, expr_type: str) -> str: """Extract custom expression names (e.g., mass_of_only_this_part).""" if expr_type == 'mass': # Look for custom mass expressions match = re.search(r'mass[_\w]*(?:of|for)[_\w]*', request_lower) if match: return match.group(0).replace(' ', '_') # Look for explicit expression names if 'expression' in request_lower: match = re.search(r'expression\s+(\w+)', request_lower) if match: return match.group(1) return '' def _extract_constraints(self, request: str, request_lower: str) -> List[Dict[str, Any]]: """ Extract constraints from the request. Examples: "maintain stress under 100 MPa", "keep displacement < 5mm" """ constraints = [] # Pattern 1: "maintain X under/below Y" maintain_pattern = r'maintain\s+(\w+)\s+(?:under|below|less than|<)\s+([\d.]+)\s*(\w+)?' for match in re.finditer(maintain_pattern, request_lower): result_type = self.result_types.get(match.group(1), match.group(1)) value = float(match.group(2)) unit = match.group(3) if match.group(3) else '' constraints.append({ 'type': 'upper_bound', 'result_type': result_type, 'value': value, 'unit': unit }) # Pattern 2: "stress < 100 MPa" or "stress < 100MPa" comparison_pattern = r'(\w+)\s*(<|>|<=|>=)\s*([\d.]+)\s*(\w+)?' for match in re.finditer(comparison_pattern, request_lower): result_type = self.result_types.get(match.group(1), match.group(1)) operator = match.group(2) value = float(match.group(3)) unit = match.group(4) if match.group(4) else '' constraint_type = 'upper_bound' if operator in ['<', '<='] else 'lower_bound' constraints.append({ 'type': constraint_type, 'result_type': result_type, 'operator': operator, 'value': value, 'unit': unit }) return constraints def _extract_objectives(self, request: str, request_lower: str) -> List[Dict[str, str]]: """ Extract optimization objectives. Can be multiple for multi-objective optimization. """ objectives = [] # Find all "minimize X" or "maximize X" patterns minimize_pattern = r'minimi[zs]e\s+(\w+(?:\s+\w+)*?)(?:\s+(?:and|but|with|using|varying|to)|\.|\,|$)' for match in re.finditer(minimize_pattern, request_lower): objective_text = match.group(1).strip() result_type = self._map_to_result_type(objective_text) objectives.append({ 'type': 'minimize', 'target': result_type if result_type else objective_text }) maximize_pattern = r'maximi[zs]e\s+(\w+(?:\s+\w+)*?)(?:\s+(?:and|but|with|using|varying|to)|\.|\,|$)' for match in re.finditer(maximize_pattern, request_lower): objective_text = match.group(1).strip() result_type = self._map_to_result_type(objective_text) objectives.append({ 'type': 'maximize', 'target': result_type if result_type else objective_text }) # If no explicit minimize/maximize but mentions optimization if not objectives and ('optimize' in request_lower or 'optim' in request_lower): # Try to infer from context for keyword, result_type in self.result_types.items(): if keyword in request_lower: # Assume minimize for stress, strain, displacement # Assume maximize for modal frequencies obj_type = 'maximize' if result_type == 'modal' else 'minimize' objectives.append({ 'type': obj_type, 'target': result_type }) return objectives if objectives else [{'type': 'minimize', 'target': 'unknown'}] def _map_to_result_type(self, text: str) -> str: """Map objective text to result type.""" text_lower = text.lower().strip() for keyword, result_type in self.result_types.items(): if keyword in text_lower: return result_type return text # Return as-is if no mapping found def _extract_algorithm(self, text: str) -> str: """Extract optimization algorithm.""" if 'optuna' in text: return 'optuna' if 'genetic' in text or 'ga' in text: return 'genetic_algorithm' if 'gradient' in text: return 'gradient_based' if 'pso' in text or 'particle swarm' in text: return 'pso' return 'optuna' # Default def get_workflow_summary(self, steps: List[WorkflowStep]) -> str: """Get human-readable summary of workflow.""" if not steps: return "No workflow steps identified" lines = ["Workflow Steps Identified:", "=" * 60, ""] for i, step in enumerate(steps, 1): lines.append(f"{i}. {step.action.replace('_', ' ').title()}") lines.append(f" Domain: {step.domain}") if step.params: lines.append(f" Parameters:") for key, value in step.params.items(): if isinstance(value, list) and value: lines.append(f" {key}:") for item in value[:3]: # Show first 3 items lines.append(f" - {item}") if len(value) > 3: lines.append(f" ... and {len(value) - 3} more") else: lines.append(f" {key}: {value}") lines.append("") return "\n".join(lines) def main(): """Test the improved workflow decomposer.""" decomposer = WorkflowDecomposer() # Test case 1: Complex multi-objective with constraints test_request_1 = """update a geometry (.prt) with all expressions that have a _opt suffix to make the mass minimized. But the mass is not directly the total mass used, its the value under the part expression mass_of_only_this_part which is the calculation of 1of the body mass of my part, the one that I want to minimize. the objective is to minimize mass but maintain stress of the solution 1 subcase 3 under 100Mpa. And also, as a second objective in my objective function, I want to minimize nodal reaction force in y of the same subcase.""" print("Test 1: Complex Multi-Objective Optimization with Constraints") print("=" * 80) print(f"Request: {test_request_1[:100]}...") print() steps_1 = decomposer.decompose(test_request_1) print(decomposer.get_workflow_summary(steps_1)) print("\nDetailed Analysis:") print("-" * 80) for i, step in enumerate(steps_1, 1): print(f"{i}. Action: {step.action}") print(f" Domain: {step.domain}") print(f" Params: {step.params}") print() # Test case 2: Simple strain optimization test_request_2 = "minimize strain using SOL101 and optuna varying v_ parameters" print("\n" + "=" * 80) print("Test 2: Simple Strain Optimization") print("=" * 80) print(f"Request: {test_request_2}") print() steps_2 = decomposer.decompose(test_request_2) print(decomposer.get_workflow_summary(steps_2)) if __name__ == '__main__': main()