""" Interface to LucyDOM database and AI Connectors. Uses the JSON connector for data access with added language support. """ import os import logging import uuid from datetime import datetime from typing import Dict, Any, List, Optional, Union import importlib import hashlib import json from modules.shared.mimeUtils import isTextMimeType, determineContentEncoding from modules.interfaces.lucydomAccess import LucyDOMAccess # DYNAMIC PART: Connectors to the Interface from modules.connectors.connectorDbJson import DatabaseConnector from modules.connectors.connectorAiOpenai import ChatService # Basic Configurations from modules.shared.configuration import APP_CONFIG logger = logging.getLogger(__name__) # Initialize AI service at module level _aiService = None def initializeAIService(): """Initialize the AI service for the LucyDOM interface.""" global _aiService if _aiService is None: try: _aiService = ChatService() logger.info("AI service initialized successfully") except Exception as e: logger.error(f"Failed to initialize AI service: {str(e)}") _aiService = None return _aiService # Initialize AI service when module is imported initializeAIService() # Custom exceptions for file handling class FileError(Exception): """Base class for file handling exceptions.""" pass class FileNotFoundError(FileError): """Exception raised when a file is not found.""" pass class FileStorageError(FileError): """Exception raised when there's an error storing a file.""" pass class FilePermissionError(FileError): """Exception raised when there's a permission issue with a file.""" pass class FileDeletionError(FileError): """Exception raised when there's an error deleting a file.""" pass from modules.security.auth import getInitialContext class LucyDOMInterface: """ Interface to the LucyDOM database. Uses the JSON connector for data access. """ def __init__(self, _mandateId: str, _userId: str): """Initializes the LucyDOM Interface with mandate and user context.""" logger.debug(f"Initializing LucyDOMInterface with mandateId={_mandateId}, userId={_userId}") self._mandateId = _mandateId self._userId = _userId # Add language settings self.userLanguage = "en" # Default user language # Set AI service from module-level instance self.aiService = _aiService if not self.aiService: logger.warning("AI service not available during LucyDOMInterface initialization") # Initialize database connector self._initializeDatabase() # Load user information self.currentUser = self._getCurrentUserInfo() # Initialize access control self.access = LucyDOMAccess(self.currentUser, self._mandateId, self._userId) self.access.db = self.db # Share database connection # Get initial IDs if not provided if not self._mandateId or not self._userId: logger.debug("No context provided, getting initial context from auth") self._mandateId, self._userId = getInitialContext() logger.debug(f"Retrieved initial context: mandate={self._mandateId}, user={self._userId}") if self._mandateId and self._userId: self.db.updateContext(self._mandateId, self._userId) logger.debug(f"Updated database context with initial IDs") else: logger.warning("No initial context available from auth") # Initialize standard records if needed self._initRecords() def _getCurrentUserInfo(self) -> Dict[str, Any]: """Gets information about the current user including privileges.""" # For production, you would get this from authentication # For now return basic user info with default privilege return { "id": self._userId, "_mandateId": self._mandateId, "privilege": "user", # Default privilege level "language": self.userLanguage } def _initializeDatabase(self): """Initializes the database connection.""" self.db = DatabaseConnector( dbHost=APP_CONFIG.get("DB_LUCYDOM_HOST"), dbDatabase=APP_CONFIG.get("DB_LUCYDOM_DATABASE"), dbUser=APP_CONFIG.get("DB_LUCYDOM_USER"), dbPassword=APP_CONFIG.get("DB_LUCYDOM_PASSWORD_SECRET"), _mandateId=self._mandateId, _userId=self._userId, skipInitialIdLookup=True ) def _initRecords(self): """Initializes standard records in the database if they don't exist.""" # Only initialize prompts if we have valid context if self._mandateId and self._userId: logger.debug(f"Initializing prompts with context: mandate={self._mandateId}, user={self._userId}") self._initializeStandardPrompts() else: logger.warning("Skipping prompt initialization - no valid context available") def _initializeStandardPrompts(self): """Creates standard prompts if they don't exist.""" prompts = self.db.getRecordset("prompts") logger.debug(f"Found {len(prompts)} existing prompts") if not prompts: logger.debug("Creating standard prompts") # Define standard prompts standardPrompts = [ { "content": "Research the current market trends and developments in [TOPIC]. Collect information about leading companies, innovative products or services, and current challenges. Present the results in a structured overview with relevant data and sources.", "name": "Web Research: Market Research" }, { "content": "Analyze the attached dataset on [TOPIC] and identify the most important trends, patterns, and anomalies. Perform statistical calculations to support your findings. Present the results in a clearly structured analysis and draw relevant conclusions.", "name": "Analysis: Data Analysis" }, { "content": "Create a detailed protocol of our meeting on [TOPIC]. Capture all discussed points, decisions made, and agreed measures. Structure the protocol clearly with agenda items, participant list, and clear responsibilities for follow-up actions.", "name": "Protocol: Meeting Minutes" }, { "content": "Develop a UI/UX design concept for [APPLICATION/WEBSITE]. Consider the target audience, main functions, and brand identity. Describe the visual design, navigation, interaction patterns, and information architecture. Explain how the design optimizes user-friendliness and user experience.", "name": "Design: UI/UX Design" }, { "content": "Gib mir die ersten 1000 Primzahlen", "name": "Code: Primzahlen" }, { "content": "Bereite mir eine formelle E-Mail an peter.muster@domain.com vor, um meinen Termin von 10 Uhr auf Freitag zu scheiben.", "name": "Mail: Vorbereitung" }, ] # Create prompts for promptData in standardPrompts: createdPrompt = self.db.recordCreate("prompts", promptData) logger.debug(f"Prompt '{promptData.get('name', 'Standard')}' was created with ID {createdPrompt['id']} and context mandate={createdPrompt.get('_mandateId')}, user={createdPrompt.get('_userId')}") else: logger.debug("Prompts already exist, skipping creation") def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Delegate to access control module.""" return self.access._uam(table, recordset) def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """Delegate to access control module.""" return self.access._canModify(table, recordId) # Language support method def setUserLanguage(self, languageCode: str): """Set the user's preferred language""" self.userLanguage = languageCode logger.debug(f"User language set to: {languageCode}") # AI Call Root Function async def callAi(self, messages: List[Dict[str, str]], produceUserAnswer: bool = False, temperature: float = None) -> str: """Enhanced AI service call with language support.""" if not self.aiService: logger.error("AI service not set in LucyDOMInterface") return "Error: AI service not available" # Add language instruction for user-facing responses if produceUserAnswer and self.userLanguage: ltext= f"Please respond in '{self.userLanguage}' language." if messages and messages[0]["role"] == "system": if "language" not in messages[0]["content"].lower(): messages[0]["content"] = f"{ltext} {messages[0]['content']}" else: # Insert a system message with language instruction messages.insert(0, { "role": "system", "content": ltext }) # Call the AI service if temperature is not None: return await self.aiService.callApi(messages, temperature=temperature) else: return await self.aiService.callApi(messages) async def callAi4Image(self, imageData: Union[str, bytes], mimeType: str = None, prompt: str = "Describe this image") -> str: """Enhanced AI service call with language support.""" if not self.aiService: logger.error("AI service not set in LucyDOMInterface") return "Error: AI service not available" return await self.aiService.analyzeImage(imageData, mimeType, prompt) # Utilities def getInitialId(self, table: str) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(table) def _getCurrentTimestamp(self) -> str: """Returns the current timestamp in ISO format""" return datetime.now().isoformat() # Prompt methods def getAllPrompts(self) -> List[Dict[str, Any]]: """Returns prompts based on user access level.""" allPrompts = self.db.getRecordset("prompts") return self._uam("prompts", allPrompts) def getPrompt(self, promptId: str) -> Optional[Dict[str, Any]]: """Returns a prompt by ID if user has access.""" prompts = self.db.getRecordset("prompts", recordFilter={"id": promptId}) if not prompts: return None filteredPrompts = self._uam("prompts", prompts) return filteredPrompts[0] if filteredPrompts else None def createPrompt(self, content: str, name: str) -> Dict[str, Any]: """Creates a new prompt if user has permission.""" if not self._canModify("prompts"): raise PermissionError("No permission to create prompts") promptData = { "content": content, "name": name, "createdAt": self._getCurrentTimestamp() } return self.db.recordCreate("prompts", promptData) def updatePrompt(self, promptId: str, content: str = None, name: str = None) -> Dict[str, Any]: """Updates a prompt if user has access.""" # Check if the prompt exists and user has access prompt = self.getPrompt(promptId) if not prompt: return None if not self._canModify("prompts", promptId): raise PermissionError(f"No permission to update prompt {promptId}") # Prepare data for update promptData = {} if content is not None: promptData["content"] = content if name is not None: promptData["name"] = name # Update prompt return self.db.recordModify("prompts", promptId, promptData) def deletePrompt(self, promptId: str) -> bool: """Deletes a prompt if user has access.""" # Check if the prompt exists and user has access prompt = self.getPrompt(promptId) if not prompt: return False if not self._canModify("prompts", promptId): raise PermissionError(f"No permission to delete prompt {promptId}") return self.db.recordDelete("prompts", promptId) # File Utilities def calculateFileHash(self, fileContent: bytes) -> str: """Calculates a SHA-256 hash for the file content""" return hashlib.sha256(fileContent).hexdigest() def checkForDuplicateFile(self, fileHash: str) -> Optional[Dict[str, Any]]: """Checks if a file with the same hash already exists for the current user and mandate.""" files = self.db.getRecordset("files", recordFilter={ "fileHash": fileHash, "_mandateId": self._mandateId, "_userId": self._userId }) if files: return files[0] return None def getMimeType(self, filename: str) -> str: """Determines the MIME type based on the file extension.""" import os ext = os.path.splitext(filename)[1].lower()[1:] extensionToMime = { "pdf": "application/pdf", "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "doc": "application/msword", "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xls": "application/vnd.ms-excel", "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", "ppt": "application/vnd.ms-powerpoint", "csv": "text/csv", "txt": "text/plain", "json": "application/json", "xml": "application/xml", "html": "text/html", "htm": "text/html", "jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp", "svg": "image/svg+xml", "py": "text/x-python", "js": "application/javascript", "css": "text/css" } return extensionToMime.get(ext.lower(), "application/octet-stream") # File methods - metadata-based operations def getAllFiles(self) -> List[Dict[str, Any]]: """Returns files based on user access level.""" allFiles = self.db.getRecordset("files") return self._uam("files", allFiles) def getFile(self, fileId: str) -> Optional[Dict[str, Any]]: """Returns a file by ID if user has access.""" files = self.db.getRecordset("files", recordFilter={"id": fileId}) if not files: return None filteredFiles = self._uam("files", files) return filteredFiles[0] if filteredFiles else None def createFile(self, name: str, mimeType: str, size: int = None, fileHash: str = None) -> Dict[str, Any]: """Creates a new file entry if user has permission.""" if not self._canModify("files"): raise PermissionError("No permission to create files") fileData = { "_mandateId": self._mandateId, "_userId": self._userId, "name": name, "mimeType": mimeType, "size": size, "fileHash": fileHash, "creationDate": self._getCurrentTimestamp() } return self.db.recordCreate("files", fileData) def updateFile(self, fileId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: """Updates file metadata if user has access.""" # Check if the file exists and user has access file = self.getFile(fileId) if not file: raise FileNotFoundError(f"File with ID {fileId} not found") if not self._canModify("files", fileId): raise PermissionError(f"No permission to update file {fileId}") # Update file return self.db.recordModify("files", fileId, updateData) def deleteFile(self, fileId: str) -> bool: """Deletes a file if user has access.""" try: # Check if the file exists and user has access file = self.getFile(fileId) if not file: raise FileNotFoundError(f"File with ID {fileId} not found") if not self._canModify("files", fileId): raise PermissionError(f"No permission to delete file {fileId}") # Check for other references to this file (by hash) fileHash = file.get("fileHash") if fileHash: otherReferences = [f for f in self.db.getRecordset("files", recordFilter={"fileHash": fileHash}) if f.get("id") != fileId] # Only delete associated fileData if no other references exist if not otherReferences: try: fileDataEntries = self.db.getRecordset("fileData", recordFilter={"id": fileId}) if fileDataEntries: self.db.recordDelete("fileData", fileId) logger.debug(f"FileData for file {fileId} deleted") except Exception as e: logger.warning(f"Error deleting FileData for file {fileId}: {str(e)}") # Delete the FileItem entry return self.db.recordDelete("files", fileId) except FileNotFoundError as e: raise except FilePermissionError as e: raise except Exception as e: logger.error(f"Error deleting file {fileId}: {str(e)}") raise FileDeletionError(f"Error deleting file: {str(e)}") # FileData methods - data operations def createFileData(self, fileId: str, data: bytes) -> bool: """Stores the binary data of a file in the database.""" try: import base64 # Check file access file = self.getFile(fileId) if not file: logger.error(f"File with ID {fileId} not found when storing data") return False # Determine if this is a text-based format mimeType = file.get("mimeType", "application/octet-stream") isTextFormat = isTextMimeType(mimeType) base64Encoded = False fileData = None if isTextFormat: # Try to decode as text try: textContent = data.decode('utf-8') fileData = textContent base64Encoded = False logger.debug(f"Stored file {fileId} as text") except UnicodeDecodeError: # Fallback to base64 if text decoding fails encodedData = base64.b64encode(data).decode('utf-8') fileData = encodedData base64Encoded = True logger.warning(f"Failed to decode text file {fileId}, falling back to base64") else: # Binary format - always use base64 encodedData = base64.b64encode(data).decode('utf-8') fileData = encodedData base64Encoded = True logger.debug(f"Stored file {fileId} as base64") # Create the fileData record with data and encoding flag fileDataObj = { "id": fileId, "data": fileData, "base64Encoded": base64Encoded } self.db.recordCreate("fileData", fileDataObj) logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})") return True except Exception as e: logger.error(f"Error storing data for file {fileId}: {str(e)}") return False def getFileData(self, fileId: str) -> Optional[bytes]: """Returns the binary data of a file if user has access.""" # Check file access file = self.getFile(fileId) if not file: logger.warning(f"No access to file ID {fileId}") return None import base64 fileDataEntries = self.db.getRecordset("fileData", recordFilter={"id": fileId}) if not fileDataEntries: logger.warning(f"No data found for file ID {fileId}") return None fileDataEntry = fileDataEntries[0] if "data" not in fileDataEntry: logger.warning(f"No data field in file data for ID {fileId}") return None data = fileDataEntry["data"] base64Encoded = fileDataEntry.get("base64Encoded", False) try: if base64Encoded: # Decode base64 to bytes return base64.b64decode(data) else: # Convert text to bytes return data.encode('utf-8') except Exception as e: logger.error(f"Error processing file data for {fileId}: {str(e)}") return None def updateFileData(self, fileId: str, data: Union[bytes, str]) -> bool: """Updates file data if user has access.""" # Check file access file = self.getFile(fileId) if not file: logger.error(f"File with ID {fileId} not found when updating data") return False if not self._canModify("files", fileId): logger.error(f"No permission to update file data for {fileId}") return False try: import base64 # Determine if this is a text-based format mimeType = file.get("mimeType", "application/octet-stream") isTextFormat = isTextMimeType(mimeType) base64Encoded = False fileData = None # Convert input data to the right format if isinstance(data, bytes): if isTextFormat: try: # Try to convert bytes to text fileData = data.decode('utf-8') base64Encoded = False except UnicodeDecodeError: # Fallback to base64 if text decoding fails fileData = base64.b64encode(data).decode('utf-8') base64Encoded = True else: # Binary format - use base64 fileData = base64.b64encode(data).decode('utf-8') base64Encoded = True elif isinstance(data, str): if isTextFormat: # Text format - store as text fileData = data base64Encoded = False else: # Check if it's already base64 encoded try: # Try to decode as base64 to validate base64.b64decode(data) fileData = data base64Encoded = True except: # Not valid base64, encode the string fileData = base64.b64encode(data.encode('utf-8')).decode('utf-8') base64Encoded = True else: # Convert to string first stringData = str(data) if isTextFormat: fileData = stringData base64Encoded = False else: fileData = base64.b64encode(stringData.encode('utf-8')).decode('utf-8') base64Encoded = True # Check if a record already exists fileDataEntries = self.db.getRecordset("fileData", recordFilter={"id": fileId}) dataUpdate = { "data": fileData, "base64Encoded": base64Encoded } if fileDataEntries: # Update the existing record self.db.recordModify("fileData", fileId, dataUpdate) logger.debug(f"Updated file data for file ID {fileId} (base64Encoded: {base64Encoded})") else: # Create a new record dataUpdate["id"] = fileId self.db.recordCreate("fileData", dataUpdate) logger.debug(f"Created new file data for file ID {fileId} (base64Encoded: {base64Encoded})") return True except Exception as e: logger.error(f"Error updating data for file {fileId}: {str(e)}") return False def saveUploadedFile(self, fileContent: bytes, fileName: str) -> Dict[str, Any]: """Saves an uploaded file if user has permission.""" try: # Check file creation permission if not self._canModify("files"): raise PermissionError("No permission to upload files") logger.debug(f"Starting upload process for file: {fileName}") if not isinstance(fileContent, bytes): logger.error(f"Invalid fileContent type: {type(fileContent)}") raise ValueError(f"fileContent must be bytes, got {type(fileContent)}") # Calculate file hash for deduplication fileHash = self.calculateFileHash(fileContent) logger.debug(f"Calculated file hash: {fileHash}") # Check for duplicate within same user/mandate existingFile = self.checkForDuplicateFile(fileHash) if existingFile: logger.debug(f"Duplicate found for {fileName}: {existingFile['id']}") return existingFile # Determine MIME type and size mimeType = self.getMimeType(fileName) fileSize = len(fileContent) # Save metadata logger.debug(f"Saving file metadata to database for file: {fileName}") dbFile = self.createFile( name=fileName, mimeType=mimeType, size=fileSize, fileHash=fileHash ) # Save binary data logger.debug(f"Saving file content to database for file: {fileName}") self.createFileData(dbFile["id"], fileContent) logger.debug(f"File upload process completed for: {fileName}") return dbFile except Exception as e: logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True) raise FileStorageError(f"Error saving file: {str(e)}") def downloadFile(self, fileId: str) -> Optional[Dict[str, Any]]: """Returns a file for download if user has access.""" try: # Check file access file = self.getFile(fileId) if not file: raise FileNotFoundError(f"File with ID {fileId} not found") # Get binary data fileContent = self.getFileData(fileId) if fileContent is None: raise FileNotFoundError(f"Binary data for file with ID {fileId} not found") return { "id": fileId, "name": file.get("name", f"file_{fileId}"), "contentType": file.get("mimeType", "application/octet-stream"), "size": file.get("size", len(fileContent)), "content": fileContent } except FileNotFoundError as e: raise except Exception as e: logger.error(f"Error downloading file {fileId}: {str(e)}") raise FileError(f"Error downloading file: {str(e)}") # Workflow methods def getAllWorkflows(self) -> List[Dict[str, Any]]: """Returns workflows based on user access level.""" allWorkflows = self.db.getRecordset("workflows") return self._uam("workflows", allWorkflows) def getWorkflowsByUser(self, _userId: str) -> List[Dict[str, Any]]: """Returns workflows for a specific user if current user has access.""" # Get workflows by _userId workflows = self.db.getRecordset("workflows", recordFilter={"_userId": _userId}) # Apply access control return self._uam("workflows", workflows) def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: """Returns a workflow by ID if user has access.""" workflows = self.db.getRecordset("workflows", recordFilter={"id": workflowId}) if not workflows: return None filteredWorkflows = self._uam("workflows", workflows) return filteredWorkflows[0] if filteredWorkflows else None def createWorkflow(self, workflowData: Dict[str, Any]) -> Dict[str, Any]: """Creates a new workflow if user has permission.""" if not self._canModify("workflows"): raise PermissionError("No permission to create workflows") # Make sure mandateId and userId are set if "_mandateId" not in workflowData: workflowData["_mandateId"] = self._mandateId if "_userId" not in workflowData: workflowData["_userId"] = self._userId # Set timestamp if not present currentTime = self._getCurrentTimestamp() if "startedAt" not in workflowData: workflowData["startedAt"] = currentTime if "lastActivity" not in workflowData: workflowData["lastActivity"] = currentTime return self.db.recordCreate("workflows", workflowData) def updateWorkflow(self, workflowId: str, workflowData: Dict[str, Any]) -> Dict[str, Any]: """Updates a workflow if user has access.""" # Check if the workflow exists and user has access workflow = self.getWorkflow(workflowId) if not workflow: return None if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to update workflow {workflowId}") # Set update time workflowData["lastActivity"] = self._getCurrentTimestamp() # Update workflow return self.db.recordModify("workflows", workflowId, workflowData) def deleteWorkflow(self, workflowId: str) -> bool: """Deletes a workflow if user has access.""" # Check if the workflow exists and user has access workflow = self.getWorkflow(workflowId) if not workflow: return False if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to delete workflow {workflowId}") # Delete workflow return self.db.recordDelete("workflows", workflowId) # Workflow Messages def getWorkflowMessages(self, workflowId: str) -> List[Dict[str, Any]]: """Returns messages for a workflow if user has access to the workflow.""" # Check workflow access first workflow = self.getWorkflow(workflowId) if not workflow: return [] # Get messages for this workflow messages = self.db.getRecordset("workflowMessages", recordFilter={"workflowId": workflowId}) return messages # No further filtering needed since workflow access is already checked def createWorkflowMessage(self, messageData: Dict[str, Any]) -> Dict[str, Any]: """Creates a message for a workflow if user has access.""" try: # Check required fields requiredFields = ["id", "workflowId"] for field in requiredFields: if field not in messageData: logger.error(f"Required field '{field}' missing in messageData") raise ValueError(f"Required field '{field}' missing in message data") # Check workflow access workflowId = messageData["workflowId"] workflow = self.getWorkflow(workflowId) if not workflow: raise PermissionError(f"No access to workflow {workflowId}") if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") # Validate that ID is not None if messageData["id"] is None: messageData["id"] = f"msg_{uuid.uuid4()}" logger.warning(f"Automatically generated ID for workflow message: {messageData['id']}") # Ensure required fields are present if "startedAt" not in messageData and "createdAt" not in messageData: messageData["startedAt"] = self._getCurrentTimestamp() if "createdAt" in messageData and "startedAt" not in messageData: messageData["startedAt"] = messageData["createdAt"] del messageData["createdAt"] # Set status if not present if "status" not in messageData: messageData["status"] = "completed" # Set sequence number if not present if "sequenceNo" not in messageData: # Get current messages to determine next sequence number existingMessages = self.getWorkflowMessages(workflowId) messageData["sequenceNo"] = len(existingMessages) + 1 # Ensure role and agentName are present if "role" not in messageData: messageData["role"] = "assistant" if messageData.get("agentName") else "user" if "agentName" not in messageData: messageData["agentName"] = "" # Create message in database createdMessage = self.db.recordCreate("workflowMessages", messageData) # Update workflow's messageIds if this is a new message if createdMessage: # Get current messageIds or initialize empty list messageIds = workflow.get("messageIds", []) # Add the new message ID if not already in the list if createdMessage["id"] not in messageIds: messageIds.append(createdMessage["id"]) self.updateWorkflow(workflowId, {"messageIds": messageIds}) return createdMessage except Exception as e: logger.error(f"Error creating workflow message: {str(e)}") return None def updateWorkflowMessage(self, messageId: str, messageData: Dict[str, Any]) -> Dict[str, Any]: """Updates a workflow message if user has access to the workflow.""" try: logger.debug(f"Updating message {messageId} in database") # Ensure messageId is provided if not messageId: logger.error("No messageId provided for updateWorkflowMessage") raise ValueError("messageId cannot be empty") # Check if message exists in database messages = self.db.getRecordset("workflowMessages", recordFilter={"id": messageId}) if not messages: logger.warning(f"Message with ID {messageId} does not exist in database") # If message doesn't exist but we have workflowId, create it if "workflowId" in messageData: workflowId = messageData.get("workflowId") # Check workflow access workflow = self.getWorkflow(workflowId) if not workflow: raise PermissionError(f"No access to workflow {workflowId}") if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") logger.info(f"Creating new message with ID {messageId} for workflow {workflowId}") return self.db.recordCreate("workflowMessages", messageData) else: logger.error(f"Workflow ID missing for new message {messageId}") return None # Update existing message existingMessage = messages[0] # Check workflow access workflowId = existingMessage.get("workflowId") workflow = self.getWorkflow(workflowId) if not workflow: raise PermissionError(f"No access to workflow {workflowId}") if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") # Ensure required fields present for key in ["role", "agentName"]: if key not in messageData and key not in existingMessage: messageData[key] = "assistant" if key == "role" else "" # Ensure ID is in the dataset if 'id' not in messageData: messageData['id'] = messageId # Convert createdAt to startedAt if needed if "createdAt" in messageData and "startedAt" not in messageData: messageData["startedAt"] = messageData["createdAt"] del messageData["createdAt"] # Update the message updatedMessage = self.db.recordModify("workflowMessages", messageId, messageData) if updatedMessage: logger.debug(f"Message {messageId} updated successfully") else: logger.warning(f"Failed to update message {messageId}") return updatedMessage except Exception as e: logger.error(f"Error updating message {messageId}: {str(e)}", exc_info=True) raise ValueError(f"Error updating message {messageId}: {str(e)}") def deleteWorkflowMessage(self, workflowId: str, messageId: str) -> bool: """Deletes a workflow message if user has access to the workflow.""" try: # Check workflow access workflow = self.getWorkflow(workflowId) if not workflow: logger.warning(f"No access to workflow {workflowId}") return False if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") # Check if the message exists messages = self.getWorkflowMessages(workflowId) message = next((m for m in messages if m.get("id") == messageId), None) if not message: logger.warning(f"Message {messageId} for workflow {workflowId} not found") return False # Delete the message from the database return self.db.recordDelete("workflowMessages", messageId) except Exception as e: logger.error(f"Error deleting message {messageId}: {str(e)}") return False def deleteFileFromMessage(self, workflowId: str, messageId: str, fileId: str) -> bool: """Removes a file reference from a message if user has access.""" try: # Check workflow access workflow = self.getWorkflow(workflowId) if not workflow: logger.warning(f"No access to workflow {workflowId}") return False if not self._canModify("workflows", workflowId): raise PermissionError(f"No permission to modify workflow {workflowId}") logger.debug(f"Removing file {fileId} from message {messageId} in workflow {workflowId}") # Get all workflow messages allMessages = self.getWorkflowMessages(workflowId) logger.debug(f"Workflow {workflowId} has {len(allMessages)} messages") # Try different approaches to find the message message = None # Exact match message = next((m for m in allMessages if m.get("id") == messageId), None) # Case-insensitive match if not message and isinstance(messageId, str): message = next((m for m in allMessages if isinstance(m.get("id"), str) and m.get("id").lower() == messageId.lower()), None) # Partial match (starts with) if not message and isinstance(messageId, str): message = next((m for m in allMessages if isinstance(m.get("id"), str) and m.get("id").startswith(messageId)), None) if not message: logger.warning(f"Message {messageId} not found in workflow {workflowId}") return False # Log the found message logger.debug(f"Found message: {message.get('id')}") # Check if message has documents if "documents" not in message or not message["documents"]: logger.warning(f"No documents in message {messageId}") return False # Log existing documents documents = message.get("documents", []) logger.debug(f"Message has {len(documents)} documents") # Create a new list of documents without the one to delete updatedDocuments = [] removed = False for doc in documents: docId = doc.get("id") fileIdValue = doc.get("fileId") # Flexible matching approach shouldRemove = ( (docId == fileId) or (fileIdValue == fileId) or (isinstance(docId, str) and str(fileId) in docId) or (isinstance(fileIdValue, str) and str(fileId) in fileIdValue) ) if shouldRemove: removed = True logger.debug(f"Found file to remove: docId={docId}, fileId={fileIdValue}") else: updatedDocuments.append(doc) if not removed: logger.warning(f"No matching file {fileId} found in message {messageId}") return False # Update message with modified documents array messageUpdate = { "documents": updatedDocuments } # Apply the update directly to the database updated = self.db.recordModify("workflowMessages", message["id"], messageUpdate) if updated: logger.debug(f"Successfully removed file {fileId} from message {messageId}") return True else: logger.warning(f"Failed to update message {messageId} in database") return False except Exception as e: logger.error(f"Error removing file {fileId} from message {messageId}: {str(e)}") return False # Workflow Logs def getWorkflowLogs(self, workflowId: str) -> List[Dict[str, Any]]: """Returns logs for a workflow if user has access to the workflow.""" # Check workflow access first workflow = self.getWorkflow(workflowId) if not workflow: return [] # Get logs for this workflow return self.db.getRecordset("workflowLogs", recordFilter={"workflowId": workflowId}) def createWorkflowLog(self, logData: Dict[str, Any]) -> Dict[str, Any]: """Creates a log entry for a workflow if user has access.""" # Check workflow access workflowId = logData.get("workflowId") if not workflowId: logger.error("No workflowId provided for createWorkflowLog") return None workflow = self.getWorkflow(workflowId) if not workflow: logger.warning(f"No access to workflow {workflowId}") return None if not self._canModify("workflows", workflowId): logger.warning(f"No permission to modify workflow {workflowId}") return None # Make sure required fields are present if "timestamp" not in logData: logData["timestamp"] = self._getCurrentTimestamp() # Add status information if not present if "status" not in logData and "type" in logData: if logData["type"] == "error": logData["status"] = "error" else: logData["status"] = "running" # Add progress information if not present if "progress" not in logData: # Default progress values based on log type if logData.get("type") == "info": logData["progress"] = 50 # Default middle progress elif logData.get("type") == "error": logData["progress"] = -1 # Error state elif logData.get("type") == "warning": logData["progress"] = 50 # Default middle progress return self.db.recordCreate("workflowLogs", logData) # Workflow Management def saveWorkflowState(self, workflow: Dict[str, Any], saveMessages: bool = True, saveLogs: bool = True) -> bool: """Saves workflow state if user has access.""" try: workflowId = workflow.get("id") if not workflowId: return False # Check workflow access existingWorkflow = self.getWorkflow(workflowId) if not existingWorkflow and not self._canModify("workflows"): logger.warning(f"No permission to create workflow {workflowId}") return False if existingWorkflow and not self._canModify("workflows", workflowId): logger.warning(f"No permission to update workflow {workflowId}") return False # Extract only the database-relevant workflow fields workflowDbData = { "id": workflowId, "_mandateId": workflow.get("_mandateId", self._mandateId), "_userId": workflow.get("_userId", self._userId), "name": workflow.get("name", f"Workflow {workflowId}"), "status": workflow.get("status", "completed"), "startedAt": workflow.get("startedAt", self._getCurrentTimestamp()), "lastActivity": workflow.get("lastActivity", self._getCurrentTimestamp()), "dataStats": workflow.get("dataStats", {}) } # Check if workflow already exists if existingWorkflow: self.updateWorkflow(workflowId, workflowDbData) else: self.createWorkflow(workflowDbData) # Save messages if saveMessages and "messages" in workflow: for message in workflow["messages"]: messageId = message.get("id") if not messageId: continue # Get existing message from database existingMessages = self.getWorkflowMessages(workflowId) existingMessage = next((m for m in existingMessages if m.get("id") == messageId), None) if existingMessage: # Check if updates are needed hasChanges = False for key in ["role", "agentName", "content", "status", "documents"]: if key in message and message.get(key) != existingMessage.get(key): hasChanges = True break if hasChanges: # Extract only relevant data for the database messageData = { "role": message.get("role", existingMessage.get("role", "unknown")), "content": message.get("content", existingMessage.get("content", "")), "agentName": message.get("agentName", existingMessage.get("agentName", "")), "status": message.get("status", existingMessage.get("status", "completed")), "documents": message.get("documents", existingMessage.get("documents", [])) } self.updateWorkflowMessage(messageId, messageData) else: # Message doesn't exist in database yet logger.warning(f"Message {messageId} in workflow {workflowId} not found in database") # Save logs if saveLogs and "logs" in workflow: # Get existing logs existingLogs = {log["id"]: log for log in self.getWorkflowLogs(workflowId)} for log in workflow["logs"]: logId = log.get("id") if not logId: continue # Extract only relevant data for the database logData = { "id": logId, "workflowId": workflowId, "message": log.get("message", ""), "type": log.get("type", "info"), "timestamp": log.get("timestamp", self._getCurrentTimestamp()), "agentName": log.get("agentName", "(undefined)"), "status": log.get("status", "running"), "progress": log.get("progress", 50) } # Create or update log if logId in existingLogs: self.db.recordModify("workflowLogs", logId, logData) else: self.db.recordCreate("workflowLogs", logData) return True except Exception as e: logger.error(f"Error saving workflow state: {str(e)}") return False def loadWorkflowState(self, workflowId: str) -> Optional[Dict[str, Any]]: """Loads workflow state if user has access.""" try: # Check workflow access workflow = self.getWorkflow(workflowId) if not workflow: return None logger.debug(f"Loaded base workflow {workflowId} from database") # Load messages messages = self.getWorkflowMessages(workflowId) # Sort by sequence number messages.sort(key=lambda x: x.get("sequenceNo", 0)) messageCount = len(messages) logger.debug(f"Loaded {messageCount} messages for workflow {workflowId}") # Check if messageIds exists and is valid messageIds = workflow.get("messageIds", []) if not messageIds or len(messageIds) != len(messages): # Rebuild messageIds from messages messageIds = [msg.get("id") for msg in messages] # Update in database self.updateWorkflow(workflowId, {"messageIds": messageIds}) logger.debug(f"Rebuilt messageIds for workflow {workflowId}") # Log document counts for each message for msg in messages: docCount = len(msg.get("documents", [])) if docCount > 0: logger.debug(f"Message {msg.get('id')} has {docCount} documents loaded from database") # Load logs logs = self.getWorkflowLogs(workflowId) # Sort by timestamp logs.sort(key=lambda x: x.get("timestamp", "")) # Assemble complete workflow object completeWorkflow = workflow.copy() completeWorkflow["messages"] = messages completeWorkflow["messageIds"] = messageIds completeWorkflow["logs"] = logs return completeWorkflow except Exception as e: logger.error(f"Error loading workflow state: {str(e)}") return None # Microsoft Login def getMsftToken(self) -> Optional[Dict[str, Any]]: """Get Microsoft token data for the current user from database""" try: # Get token from database using current user's mandateId and userId tokens = self.db.getRecordset("msftTokens", recordFilter={ "_mandateId": self._mandateId, "_userId": self._userId }) if tokens and len(tokens) > 0: token_data = json.loads(tokens[0]["token_data"]) logger.debug(f"Retrieved Microsoft token for user {self._userId}") return token_data else: logger.debug(f"No Microsoft token found for user {self._userId}") return None except Exception as e: logger.error(f"Error retrieving Microsoft token: {str(e)}") return None def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: """Save Microsoft token data for the current user to database""" try: # Check if token already exists tokens = self.db.getRecordset("msftTokens", recordFilter={ "_mandateId": self._mandateId, "_userId": self._userId }) if tokens and len(tokens) > 0: # Update existing token token_id = tokens[0]["id"] updated_data = { "token_data": json.dumps(token_data), "updated_at": datetime.now().isoformat() } self.db.recordModify("msftTokens", token_id, updated_data) logger.debug(f"Updated Microsoft token for user {self._userId}") else: # Create new token with UUID new_token = { "_mandateId": self._mandateId, "_userId": self._userId, "token_data": json.dumps(token_data), "created_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat() } self.db.recordCreate("msftTokens", new_token) logger.debug(f"Saved new Microsoft token for user {self._userId}") return True except Exception as e: logger.error(f"Error saving Microsoft token: {str(e)}") return False # Singleton factory for LucyDOMInterface instances per context _lucydomInterfaces = {} def getLucydomInterface(_mandateId: str = None, _userId: str = None) -> LucyDOMInterface: """ Returns a LucyDOMInterface instance for the specified context. Ensures AI service is initialized and preserves it across instances. """ # For initialization, use empty strings instead of None contextKey = f"{_mandateId or ''}_{_userId or ''}" # Ensure AI service is initialized if _aiService is None: initializeAIService() # Create new instance if needed if contextKey not in _lucydomInterfaces: _lucydomInterfaces[contextKey] = LucyDOMInterface(_mandateId or '', _userId or '') return _lucydomInterfaces[contextKey] # Initialize default instance with empty strings getLucydomInterface('', '')