gateway/modules/workflow/chatManager.py
ValueOn AG f3860723af wip
2025-06-08 03:12:43 +02:00

617 lines
No EOL
23 KiB
Python

"""
Chat Manager Module for managing chat workflows and agent handovers.
"""
import logging
from typing import Dict, Any, List, Optional, Union
from datetime import datetime, UTC
import uuid
import json
from dataclasses import dataclass
from modules.interfaces.serviceChatModel import (
ChatLog, ChatMessage, ChatDocument, UserInputRequest, ChatWorkflow,
AgentHandover
)
from modules.workflow.agentManager import getAgentManager
from modules.workflow.documentManager import getDocumentManager
logger = logging.getLogger(__name__)
class ChatManager:
"""Manager for chat workflows and agent handovers."""
_instance = None
@classmethod
def getInstance(cls):
"""Return a singleton instance of the chat manager."""
if cls._instance is None:
cls._instance = cls()
return cls._instance
# Core functions
def __init__(self):
"""Initialize the chat manager."""
if ChatManager._instance is not None:
raise RuntimeError("Singleton instance already exists - use getInstance()")
self.service = None
self.agentManager = getAgentManager()
self.documentManager = getDocumentManager()
def initialize(self, workflow: ChatWorkflow):
"""
Initialize the manager with an optional workflow object.
Args:
workflow: Optional ChatWorkflow object to initialize with
"""
# Initialize managers
self.agentManager.initialize(self.service)
self.documentManager.initialize(self.service)
# Add basic references to service
self.service.workflow = workflow
self.service.logAdd = self.logAdd
self.service.user = {
'id': None,
'name': None,
'language': 'en'
}
self.service.functions = {
'forEach': lambda items, action: [action(item) for item in items],
'while': lambda condition, action: [action() for _ in iter(lambda: condition(), False)]
}
self.service.model = {
'callAiBasic': self._callAiBasic,
'callAiComplex': self._callAiComplex,
'callAiImage': self._callAiImage
}
# Initialize document operations
self.service.document = {
'extract': self.documentManager.extractContent,
'convertFileRefToFileId': self.documentManager.convertFileRefToId,
'convertFileIdToFileRef': self.documentManager.convertFileIdToRef,
'convertDataFormat': self.documentManager.convertDataFormat,
'agentInputFilesCreate': self.documentManager.createAgentInputFileList,
'agentOutputFilesSave': self.documentManager.saveAgentOutputFiles
}
# Initialize data access
from modules.workflow.dataAccessFunctions import get_data_access
self.service.data = get_data_access().to_service_object()
return True
def createInitialHandover(self, userInput: UserInputRequest) -> AgentHandover:
"""
Create the initial handover object from user input.
Args:
userInput: User input request
Returns:
Initial handover object
"""
try:
# Create initial handover
handover = AgentHandover(
promptUserInitial=userInput.message,
documentsUserInitial=userInput.listFileId or [],
startedAt=datetime.now(UTC).isoformat()
)
# Process user input documents
if handover.documentsUserInitial:
handover.documentsInput = handover.documentsUserInitial
# Set initial prompt for next agent
handover.promptForNextAgent = handover.promptUserInitial
return handover
except Exception as e:
logger.error(f"Error creating initial handover: {str(e)}")
return AgentHandover(status="failed", error=str(e))
async def defineNextHandover(self, currentHandover: AgentHandover) -> Optional[AgentHandover]:
"""
Define the next handover object for agent transition.
Args:
currentHandover: Current handover object
Returns:
Next handover object or None if no next agent
"""
try:
# Get available agents
availableAgents = self.agentManager.getAgentInfos()
if not availableAgents:
logger.warning("No available agents found")
return None
# Create next handover object
nextHandover = AgentHandover(
promptUserInitial=currentHandover.promptUserInitial,
documentsUserInitial=currentHandover.documentsUserInitial,
startedAt=datetime.now(UTC).isoformat()
)
# If this is the first handover, use initial documents
if not currentHandover.promptFromFinishedAgent:
nextHandover.documentsInput = currentHandover.documentsUserInitial
nextHandover.promptForNextAgent = currentHandover.promptUserInitial
else:
# Use output documents from previous agent
nextHandover.documentsInput = currentHandover.documentsOutput
nextHandover.promptForNextAgent = currentHandover.promptFromFinishedAgent
# Select next agent based on available agents and current state
nextAgent = await self._selectNextAgent(availableAgents, nextHandover)
if not nextAgent:
logger.info("No suitable next agent found")
return None
nextHandover.nextAgent = nextAgent['name']
return nextHandover
except Exception as e:
logger.error(f"Error defining next handover: {str(e)}")
return None
async def _selectNextAgent(self, availableAgents: List[Dict[str, Any]], handover: AgentHandover) -> Optional[Dict[str, Any]]:
"""
Select the next agent using AI analysis of the current state and requirements.
Args:
availableAgents: List of available agents
handover: Current handover object
Returns:
Selected agent or None if no suitable agent
"""
try:
if not availableAgents:
logger.warning("No available agents found")
return None
# Get current workflow state
workflow = self.service.workflow
if not workflow:
logger.error("No workflow context available")
return None
# Detect user language if not already set
if not workflow.userLanguage:
workflow.userLanguage = await self._detectUserLanguage(handover.promptUserInitial)
# Get workflow summary for context
workflow_summary = await self.workflowSummarize(ChatMessage(
id=str(uuid.uuid4()),
workflowId=workflow.id,
role="user",
message=handover.promptUserInitial
))
# Prepare context for AI analysis
context = {
"current_state": {
"previous_agent": handover.currentAgent,
"status": handover.status,
"error": handover.error,
"user_language": workflow.userLanguage,
"input_documents": handover.documentsInput or [],
"output_documents": handover.documentsOutput or [],
"required_capabilities": handover.requiredCapabilities or []
},
"conversation_history": workflow_summary,
"available_agents": [
{
"name": agent.get("name", ""),
"capabilities": agent.get("capabilities", {}),
"description": agent.get("description", "")
}
for agent in availableAgents
]
}
# Create prompt for AI to analyze and select next agent
prompt = f"""
Analyze the current workflow state, conversation history, and available agents to determine the most suitable next agent.
Consider the following factors:
1. Previous agent's status and any errors
2. Required capabilities for the task
3. Document type compatibility
4. Language requirements
5. Agent's capabilities and specializations
6. Conversation history and context
Current State:
{json.dumps(context['current_state'], indent=2)}
Conversation History:
{context['conversation_history']}
Available Agents:
{json.dumps(context['available_agents'], indent=2)}
Return a JSON object with the following structure:
{{
"selected_agent": "name of the most suitable agent",
"reasoning": "brief explanation of why this agent was selected",
"required_capabilities": ["list", "of", "required", "capabilities"],
"potential_risks": ["list", "of", "potential", "issues"],
"task": {{
"description": "clear description of what the agent needs to do",
"input_format": {{
"documents": ["list", "of", "required", "input", "documents"],
"data": ["list", "of", "required", "data", "fields"]
}},
"output_format": {{
"documents": ["list", "of", "expected", "output", "documents"],
"data": ["list", "of", "expected", "output", "fields"]
}},
"requirements": [
"list of specific requirements",
"format requirements",
"quality requirements"
],
"constraints": [
"list of constraints",
"time limits",
"resource limits"
]
}},
"prompt_template": "template for the agent's prompt with placeholders for dynamic content"
}}
Format your response as a valid JSON object.
"""
# Get AI's analysis and selection
response = await self._callAiComplex(prompt)
try:
analysis = json.loads(response)
selected_agent_name = analysis.get('selected_agent')
# Find the selected agent in available agents
selected_agent = next(
(agent for agent in availableAgents if agent.get('name') == selected_agent_name),
None
)
if selected_agent:
logger.info(f"AI selected agent {selected_agent_name}: {analysis.get('reasoning')}")
# Update handover with AI's analysis
handover.requiredCapabilities = analysis.get('required_capabilities', [])
handover.analysis = {
'reasoning': analysis.get('reasoning'),
'potential_risks': analysis.get('potential_risks', []),
'task': analysis.get('task', {}),
'prompt_template': analysis.get('prompt_template', '')
}
return selected_agent
else:
logger.warning(f"AI selected agent {selected_agent_name} not found in available agents")
return None
except json.JSONDecodeError as e:
logger.error(f"Error parsing AI response: {str(e)}")
return None
except Exception as e:
logger.error(f"Error selecting next agent: {str(e)}")
return None
async def processNextAgent(self, handover: AgentHandover) -> AgentHandover:
"""
Process the next agent in the workflow.
Args:
handover: Current handover object
Returns:
Updated handover object
"""
try:
# Get agent instance
agent = self.agentManager.getAgent(handover.nextAgent)
if not agent:
handover.update_status("failed", f"Agent {handover.nextAgent} not found")
return handover
# Set current agent
handover.currentAgent = handover.nextAgent
handover.nextAgent = None
# Execute agent
response = await agent.execute(handover)
# Update handover with results
if response.success:
handover.update_status("success")
handover.documentsOutput = response.message.documents if response.message else []
handover.promptFromFinishedAgent = response.message.message if response.message else ""
else:
handover.update_status("failed", response.error)
return handover
except Exception as e:
logger.error(f"Error processing next agent: {str(e)}")
handover.update_status("failed", str(e))
return handover
# Agent functions
async def _callAiBasic(self, prompt: str, context: Dict[str, Any] = None) -> str:
"""Call basic AI model."""
try:
response = await self.service.base.callAi(prompt, context or {}, model="aiBase")
return response
except Exception as e:
logger.error(f"Error calling basic AI: {str(e)}")
return ""
async def _callAiComplex(self, prompt: str, context: Dict[str, Any] = None) -> str:
"""Call complex AI model."""
try:
response = await self.service.base.callAi(prompt, context or {}, model="aiComplex")
return response
except Exception as e:
logger.error(f"Error calling complex AI: {str(e)}")
return ""
async def _callAiImage(self, prompt: str, context: Dict[str, Any] = None) -> str:
"""Call image AI model."""
try:
response = await self.service.base.callAi(prompt, context or {}, model="aiImage")
return response
except Exception as e:
logger.error(f"Error calling image AI: {str(e)}")
return ""
def logAdd(self, message: str, level: str = "info",
progress: Optional[int] = None) -> str:
"""
Add a log entry to the workflow.
Args:
message: Log message
level: Log level (info, warning, error)
progress: Optional progress percentage
Returns:
str: ID of the created log entry
"""
workflow = self.service.workflow
try:
# Generate log ID
logId = str(uuid.uuid4())
# Create log entry
logEntry = ChatLog(
id=logId,
workflowId=workflow.id,
message=message,
level=level,
progress=progress,
timestamp=datetime.now().isoformat()
)
# Add to workflow logs
workflow.logs.append(logEntry)
# Also log to Python logger
logLevel = getattr(logging, level.upper())
logger.log(logLevel, f"[Workflow {workflow.id}] {message}")
# Save to database
self.chatManager.saveWorkflowLog(workflow.id, logEntry.to_dict())
return logId
except Exception as e:
logger.error(f"Error adding log entry: {str(e)}")
return ""
async def chatMessageToWorkflow(self, role: str, agent: Union[str, Dict[str, Any]], chatMessage: UserInputRequest) -> ChatMessage:
"""
Integrates chat message input into a Message object including files with complete contents.
Args:
role: Role of the message sender (e.g., 'user', 'assistant')
agent: Agent name or configuration
chatMessage: UserInputRequest object containing message data and file references
Returns:
ChatMessage object with complete file contents
"""
try:
# Process additional files with complete contents
additionalFileIds = chatMessage.listFileId or []
additionalFiles = await self.processFileIds(additionalFileIds)
# Create message object
message = ChatMessage(
id=str(uuid.uuid4()),
workflowId=self.service.workflow.id,
role=role,
agentName=agent if isinstance(agent, str) else agent.get("name", ""),
message=chatMessage.message,
documents=additionalFiles,
status="completed",
startedAt=datetime.now().isoformat()
)
return message
except Exception as e:
logger.error(f"Error creating workflow message: {str(e)}")
raise
async def sendFinalMessage(self, handover: AgentHandover) -> ChatMessage:
"""
Send final message to user with workflow results.
Args:
handover: Final handover object
Returns:
Final message to user
"""
try:
# Create final message content from handover
messageContent = handover.promptFromFinishedAgent
if handover.status == "failed":
messageContent = f"Workflow failed: {handover.error}"
# Add summary of generated documents
if handover.documentsOutput:
messageContent += "\n\nGenerated documents:"
for doc in handover.documentsOutput:
messageContent += f"\n- {doc.get('name', 'Unknown')}"
# Create message object
finalMessage = ChatMessage(
id=str(uuid.uuid4()),
workflowId=self.service.workflow.id,
agentName="Workflow Manager",
message=messageContent,
role="assistant",
status="completed",
sequenceNr=0,
startedAt=datetime.now(UTC).isoformat(),
finishedAt=datetime.now(UTC).isoformat(),
success=handover.status == "success",
documents=handover.documentsOutput
)
return finalMessage
except Exception as e:
logger.error(f"Error sending final message: {str(e)}")
return ChatMessage(
id=str(uuid.uuid4()),
workflowId=self.service.workflow.id,
agentName="Workflow Manager",
message=f"Error in workflow: {str(e)}",
role="system",
status="error",
sequenceNr=0,
startedAt=datetime.now(UTC).isoformat(),
finishedAt=datetime.now(UTC).isoformat(),
success=False
)
async def workflowSummarize(self, messageUser: ChatMessage) -> str:
"""
Creates a summary of the workflow without the current user message.
Args:
messageUser: Current user message
Returns:
Summary of the workflow
"""
if not self.service.workflow or "messages" not in self.service.workflow or not self.service.workflow["messages"]:
return "" # First message
# Go through messages in chronological order
messages = sorted(self.service.workflow["messages"], key=lambda m: m.get("sequenceNo", 0), reverse=False)
summaryParts = []
for message in messages:
if message["id"] != messageUser["id"]:
messageSummary = await self.messageSummarize(message)
summaryParts.append(messageSummary)
return "\n\n".join(summaryParts)
async def messageSummarize(self, message: ChatMessage) -> str:
"""
Creates a summary of a message including its documents.
Args:
message: Message to summarize
Returns:
Summary of the message
"""
role = message.role
agentName = message.agentName
content = message.content
try:
# Use the serviceBase for language-aware AI calls
prompt = f"Create a very concise summary (2-3 sentences, maximum 300 characters) of the following message:\n\n{content}"
contentSummary = await self._callAiBasic(prompt)
except Exception as e:
logger.error(f"Error creating summary: {str(e)}")
contentSummary = content[:200] + "..."
# Summarize documents
docsSummary = ""
if "documents" in message and message["documents"]:
docsList = []
for i, doc in enumerate(message["documents"]):
docName = self.getFilename(doc)
docsList.append(docName)
if docsList:
docsSummary = "\nDocuments:" + "\n- ".join(docsList)
return f"[{role} {agentName}]: {contentSummary}{docsSummary}"
def getFilename(self, document: ChatDocument) -> str:
"""
Gets the filename from a document by combining name and extension.
Args:
document: Document object
Returns:
Filename with extension
"""
name = document.name
ext = document.ext
if ext:
return f"{name}.{ext}"
return name
async def _detectUserLanguage(self, text: str) -> str:
"""
Detects the language of user input using AI.
Args:
text: User input text to analyze
Returns:
Language code (e.g., 'en', 'de', 'fr')
"""
try:
# Use basic AI model for language detection
prompt = f"""
Analyze the following text and identify its language.
Return only the ISO 639-1 language code (e.g., 'en' for English, 'de' for German).
Text: {text}
"""
response = await self._callAiBasic(prompt)
# Clean and validate response
lang_code = response.strip().lower()
# Basic validation of common language codes
valid_codes = {'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'ru', 'zh', 'ja', 'ko'}
return lang_code if lang_code in valid_codes else 'en'
except Exception as e:
logger.error(f"Error detecting language: {str(e)}")
return 'en' # Default to English on error
# Singleton factory for the chat manager
def getChatManager():
return ChatManager.getInstance()