# Copyright (c) 2025 Patrick Motsch # All rights reserved. # taskPlanner.py # Task planning functionality for workflows import json import logging from typing import Dict, Any from modules.features.aichat.datamodelFeatureAiChat import TaskStep, TaskContext, TaskPlan from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum from modules.workflows.processing.shared.promptGenerationTaskplan import ( generateTaskPlanningPrompt ) from modules.workflows.processing.shared.stateTools import checkWorkflowStopped logger = logging.getLogger(__name__) class TaskPlanner: """Handles task planning for workflows""" def __init__(self, services): self.services = services async def generateTaskPlan(self, userInput: str, workflow) -> TaskPlan: """Generate a high-level task plan for the workflow""" try: # Check workflow status before generating task plan checkWorkflowStopped(self.services) logger.info(f"=== STARTING TASK PLAN GENERATION ===") logger.info(f"Workflow ID: {workflow.id}") # Log normalized request instead of raw user input for security normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) if self.services else None if normalizedPrompt: logger.info(f"Normalized Request: {normalizedPrompt}") else: logger.info(f"Normalized Request: {userInput}") # Use normalized request if available, otherwise fallback to currentUserPrompt, then userInput actualUserPrompt = None if self.services and hasattr(self.services, 'currentUserPromptNormalized') and self.services.currentUserPromptNormalized: actualUserPrompt = self.services.currentUserPromptNormalized elif self.services and hasattr(self.services, 'currentUserPrompt') and self.services.currentUserPrompt: actualUserPrompt = self.services.currentUserPrompt else: actualUserPrompt = userInput # Check workflow status before calling AI service checkWorkflowStopped(self.services) # Analyze user intent to obtain cleaned user objective for planning # SKIP intent analysis for AUTOMATION mode - it uses predefined JSON plans from modules.features.aichat.datamodelFeatureAiChat import WorkflowModeEnum workflowMode = getattr(workflow, 'workflowMode', None) skipIntentionAnalysis = (workflowMode == WorkflowModeEnum.WORKFLOW_AUTOMATION) if skipIntentionAnalysis: logger.info("Skipping intent analysis for AUTOMATION mode - using direct user input") # For automation mode, use user input directly without intent analysis cleanedObjective = actualUserPrompt workflowIntent = None else: # Use workflowIntent from workflow object (set in workflowManager from userintention analysis) workflowIntent = getattr(workflow, '_workflowIntent', None) if workflowIntent and isinstance(workflowIntent, dict): cleanedObjective = workflowIntent.get('intent', actualUserPrompt) else: # Fallback: use user prompt directly if workflowIntent not available cleanedObjective = actualUserPrompt logger.warning("WorkflowIntent not found in workflow object, using user prompt directly") # Create proper context object for task planning using cleaned intent # For task planning, we need to create a minimal TaskStep since TaskContext requires it planningTaskStep = TaskStep( id="plan", objective=cleanedObjective, dependencies=[], successCriteria=[], estimatedComplexity="medium" ) taskPlanningContext = TaskContext( taskStep=planningTaskStep, workflow=workflow, workflowId=workflow.id, availableDocuments=None, availableConnections=None, previousResults=[], previousHandover=None, improvements=[], retryCount=0, previousActionResults=[], previousReviewResult=None, isRegeneration=False, failurePatterns=[], failedActions=[], successfulActions=[], criteriaProgress={ 'met_criteria': set(), 'unmet_criteria': set(), 'attempt_history': [] } ) # Build prompt bundle (template + placeholders) using new API bundle = generateTaskPlanningPrompt(self.services, taskPlanningContext) taskPlanningPromptTemplate = bundle.prompt placeholders = bundle.placeholders # Centralized AI call: Task planning (quality, detailed) with placeholders options = AiCallOptions( operationType=OperationTypeEnum.PLAN, priority=PriorityEnum.QUALITY, compressPrompt=False, compressContext=False, processingMode=ProcessingModeEnum.DETAILED, maxCost=0.10, maxProcessingTime=30 ) prompt = await self.services.ai.callAiPlanning( prompt=taskPlanningPromptTemplate, placeholders=placeholders, debugType="taskplan" ) # Check if AI response is valid if not prompt: raise ValueError("AI service returned no response for task planning") # Parse task plan response try: jsonStart = prompt.find('{') jsonEnd = prompt.rfind('}') + 1 if jsonStart == -1 or jsonEnd == 0: raise ValueError("No JSON found in response") jsonStr = prompt[jsonStart:jsonEnd] taskPlanDict = json.loads(jsonStr) if 'tasks' not in taskPlanDict: raise ValueError("Task plan missing 'tasks' field") except Exception as e: logger.error(f"Error parsing task plan response: {str(e)}") taskPlanDict = {'tasks': []} if not self._validateTaskPlan(taskPlanDict): logger.error("Generated task plan failed validation") logger.error(f"AI Response: {prompt}") logger.error(f"Parsed Task Plan: {json.dumps(taskPlanDict, indent=2)}") raise Exception("AI-generated task plan failed validation - AI is required for task planning") if not taskPlanDict.get('tasks'): raise ValueError("Task plan contains no tasks") # Use already detected language from services; do not detect here userLanguage = self.services.currentUserLanguage or 'en' logger.info(f"Task planning using user language: {userLanguage}") tasks = [] for i, taskDict in enumerate(taskPlanDict.get('tasks', [])): if not isinstance(taskDict, dict): logger.warning(f"Skipping invalid task {i+1}: not a dictionary") continue # Map old 'description' field to new 'objective' field if 'description' in taskDict and 'objective' not in taskDict: taskDict['objective'] = taskDict.pop('description') # Ensure objective is always set (required field) if 'objective' not in taskDict or not taskDict.get('objective'): logger.warning(f"Task {i+1} missing objective, using fallback") taskDict['objective'] = actualUserPrompt or 'Task objective not specified' # Extract format details from workflow intent and populate TaskStep # Use workflow-level intent for format requirements (tasks inherit from workflow) if isinstance(workflowIntent, dict): if 'dataType' in workflowIntent and 'dataType' not in taskDict: taskDict['dataType'] = workflowIntent.get('dataType') if 'expectedFormats' in workflowIntent and 'expectedFormats' not in taskDict: taskDict['expectedFormats'] = workflowIntent.get('expectedFormats') if 'qualityRequirements' in workflowIntent and 'qualityRequirements' not in taskDict: taskDict['qualityRequirements'] = workflowIntent.get('qualityRequirements') try: task = TaskStep(**taskDict) # User message is already generated by the AI in the task planning prompt # No separate call needed - userMessage comes directly from the AI response tasks.append(task) except Exception as e: logger.warning(f"Skipping invalid task {i+1}: {str(e)}") continue if not tasks: raise ValueError("No valid tasks could be created from AI response") taskPlan = TaskPlan( overview=taskPlanDict.get('overview', ''), tasks=tasks, userMessage=taskPlanDict.get('userMessage', '') ) logger.info(f"Task plan generated successfully with {len(tasks)} tasks") return taskPlan except Exception as e: logger.error(f"Error in generateTaskPlan: {str(e)}") raise def _validateTaskPlan(self, taskPlan: Dict[str, Any]) -> bool: """Validate task plan structure""" try: if not isinstance(taskPlan, dict): logger.error("Task plan is not a dictionary") return False if 'tasks' not in taskPlan or not isinstance(taskPlan['tasks'], list): logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}") return False # First pass: collect all task IDs to validate dependencies taskIds = set() for task in taskPlan['tasks']: if not isinstance(task, dict): logger.error(f"Task is not a dictionary: {type(task)}") return False if 'id' not in task: logger.error(f"Task missing 'id' field: {task}") return False taskIds.add(task['id']) # Second pass: validate each task for i, task in enumerate(taskPlan['tasks']): if not isinstance(task, dict): logger.error(f"Task {i} is not a dictionary: {type(task)}") return False requiredFields = ['id', 'objective', 'successCriteria'] missingFields = [field for field in requiredFields if field not in task] if missingFields: logger.error(f"Task {i} missing required fields: {missingFields}") return False # Check for duplicate IDs (shouldn't happen after first pass, but safety check) if task['id'] in taskIds and list(taskPlan['tasks']).count(task['id']) > 1: logger.error(f"Task {i} has duplicate ID: {task['id']}") return False dependencies = task.get('dependencies', []) if not isinstance(dependencies, list): logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}") return False for dep in dependencies: if dep not in taskIds and dep != 'task_0': logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})") return False logger.info(f"Task plan validation successful with {len(taskIds)} tasks") return True except Exception as e: logger.error(f"Error validating task plan: {str(e)}") return False