""" 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()