200 lines
No EOL
9.5 KiB
Python
200 lines
No EOL
9.5 KiB
Python
# 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.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, WorkflowModeEnum
|
|
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
|
|
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
|
|
|
|
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)}")
|
|
raise ValueError(f"Failed to parse AI task plan response: {str(e)}") from e
|
|
|
|
from modules.workflows.processing.core.validator import WorkflowValidator
|
|
validator = WorkflowValidator(self.services)
|
|
if not validator.validateTask(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
|
|
|
|
|