import os import logging import uuid import shutil from datetime import datetime, timedelta import mimetypes from typing import Dict, Any, List, Optional, Union, BinaryIO, Tuple import importlib import asyncio import hashlib from pathlib import Path from connectors.connector_db_json import DatabaseConnector logger = logging.getLogger(__name__) # 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 class LucyDOMInterface: """ Interface zur LucyDOM-Datenbank. Verwendet den JSON-Konnektor für den Datenzugriff. """ def __init__(self, mandate_id: int, user_id: int): """ Initialisiert das LucyDOM-Interface mit Mandanten- und Benutzerkontext. Args: mandate_id: ID des aktuellen Mandanten user_id: ID des aktuellen Benutzers """ self.mandate_id = mandate_id self.user_id = user_id # Load configuration from config.ini import configload config = configload.load_config() # Datenverzeichnis self.data_folder = "_database_lucydom" os.makedirs(self.data_folder, exist_ok=True) # Upload und temp Verzeichnisse aus config.ini lesen self.upload_dir = config.get('Module_AgentserviceInterface', 'UPLOAD_DIR', fallback='./_uploads') self.temp_dir = os.path.join(self.upload_dir, "temp") os.makedirs(self.upload_dir, exist_ok=True) os.makedirs(self.temp_dir, exist_ok=True) # Datenmodell-Modul importieren try: self.model_module = importlib.import_module("modules.lucydom_model") logger.info("lucydom_model erfolgreich importiert") except ImportError as e: logger.error(f"Fehler beim Importieren von lucydom_model: {e}") raise # Konnektor erstellen self.db = DatabaseConnector( db_folder=self.data_folder, mandate_id=mandate_id, user_id=user_id ) # Datenbank initialisieren, falls nötig self._initialize_database() # Schedule periodic cleanup of temporary files self._schedule_temp_file_cleanup() def _schedule_temp_file_cleanup(self): """Schedule periodic cleanup of temporary files""" try: loop = asyncio.get_event_loop() loop.create_task(self._periodic_temp_file_cleanup()) except RuntimeError: # If no event loop is available, log a warning logger.warning("Kein Event-Loop verfügbar für temporäre Datei-Bereinigung") async def _periodic_temp_file_cleanup(self): """Periodically clean up temporary files""" while True: try: self.cleanup_temp_files() # Run cleanup every 24 hours await asyncio.sleep(24 * 60 * 60) except Exception as e: logger.error(f"Fehler bei der periodischen Bereinigung temporärer Dateien: {str(e)}") # If there's an error, wait 1 hour before trying again await asyncio.sleep(60 * 60) def cleanup_temp_files(self, max_age_hours: int = 24): """ Clean up temporary files older than the specified age Args: max_age_hours: Maximum age of temporary files in hours """ try: now = datetime.now() count = 0 for item in os.listdir(self.temp_dir): item_path = os.path.join(self.temp_dir, item) if os.path.isfile(item_path): # Check file age file_time = datetime.fromtimestamp(os.path.getmtime(item_path)) if now - file_time > timedelta(hours=max_age_hours): try: os.remove(item_path) count += 1 except Exception as e: logger.warning(f"Konnte temporäre Datei nicht löschen: {item_path} - {str(e)}") logger.info(f"{count} temporäre Dateien bereinigt") except Exception as e: logger.error(f"Fehler bei der Bereinigung temporärer Dateien: {str(e)}") def cleanup_orphaned_files(self): """ Clean up orphaned files that exist on disk but don't have records in the database """ try: # Get all file records from the database all_files = self.get_all_files() db_file_paths = {file.get("path") for file in all_files if file.get("path")} # Scan the upload directory count = 0 for root, _, files in os.walk(self.upload_dir): # Skip the temp directory if os.path.normpath(root) == os.path.normpath(self.temp_dir): continue for file in files: file_path = os.path.join(root, file) # If the file isn't in the database, delete it if file_path not in db_file_paths: try: os.remove(file_path) count += 1 except Exception as e: logger.warning(f"Konnte verwaiste Datei nicht löschen: {file_path} - {str(e)}") logger.info(f"{count} verwaiste Dateien bereinigt") except Exception as e: logger.error(f"Fehler bei der Bereinigung verwaister Dateien: {str(e)}") def _initialize_database(self): """ Initialisiert die Datenbank mit minimalen Objekten für den angemeldeten Benutzer im Mandanten, falls sie noch nicht existiert. Ohne gültigen Benutzer keine Initialisierung. Erstellt für jede im Datenmodell definierte Tabelle einen initialen Datensatz. """ effective_mandate_id = self.mandate_id effective_user_id = self.user_id if effective_mandate_id is None or effective_user_id is None: #data available return # Initialisierung von Standard-Prompts für verschiedene Bereiche prompts = self.db.get_recordset("prompts") if not prompts: logger.info("Erstelle Standard-Prompts") # Standard-Prompts definieren standard_prompts = [ { "mandate_id": effective_mandate_id, "user_id": effective_user_id, "content": "Recherchiere die aktuellen Markttrends und Entwicklungen im Bereich [THEMA]. Sammle Informationen zu führenden Unternehmen, innovativen Produkten oder Dienstleistungen und aktuellen Herausforderungen. Präsentiere die Ergebnisse in einer strukturierten Übersicht mit relevanten Daten und Quellen.", "name": "Web Research: Marktforschung" }, { "mandate_id": effective_mandate_id, "user_id": effective_user_id, "content": "Analysiere den beigefügten Datensatz zu [THEMA] und identifiziere die wichtigsten Trends, Muster und Auffälligkeiten. Führe statistische Berechnungen durch, um deine Erkenntnisse zu untermauern. Stelle die Ergebnisse in einer klar strukturierten Analyse dar und ziehe relevante Schlussfolgerungen.", "name": "Analyse: Datenanalyse" }, { "mandate_id": effective_mandate_id, "user_id": effective_user_id, "content": "Erstelle ein detailliertes Protokoll unserer Besprechung zum Thema [THEMA]. Erfasse alle besprochenen Punkte, getroffenen Entscheidungen und vereinbarten Maßnahmen. Strukturiere das Protokoll übersichtlich mit Tagesordnungspunkten, Teilnehmerliste und klaren Verantwortlichkeiten für die Follow-up-Aktionen.", "name": "Protokoll: Besprechungsprotokoll" }, { "mandate_id": effective_mandate_id, "user_id": effective_user_id, "content": "Entwickle ein UI/UX-Designkonzept für [ANWENDUNG/WEBSITE]. Berücksichtige die Zielgruppe, Hauptfunktionen und die Markenidentität. Beschreibe die visuelle Gestaltung, Navigation, Interaktionsmuster und Informationsarchitektur. Erläutere, wie das Design die Benutzerfreundlichkeit und das Nutzererlebnis optimiert.", "name": "Design: UI/UX Design" } ] # Prompts erstellen for prompt_data in standard_prompts: created_prompt = self.db.record_create("prompts", prompt_data) logger.info(f"Prompt '{prompt_data.get('name', 'Standard')}' wurde erstellt mit ID {created_prompt['id']}") # File utilities - Moved from agentservice_filehandling def get_mime_type(self, file_path: str) -> str: """ Bestimmt den MIME-Typ einer Datei. Args: file_path: Pfad zur Datei Returns: Der erkannte MIME-Typ """ # Versuche, den MIME-Typ über den Dateipfad zu erkennen mime_type, _ = mimetypes.guess_type(file_path) # Wenn kein MIME-Typ erkannt wurde, versuche es über die Dateiendung if not mime_type: ext = os.path.splitext(file_path)[1].lower()[1:] mime_type = self.get_mime_type_from_extension(ext) return mime_type def get_mime_type_from_extension(self, extension: str) -> str: """ Bestimmt den MIME-Typ basierend auf der Dateiendung. Args: extension: Die Dateiendung ohne Punkt Returns: Der entsprechende MIME-Typ """ extension_to_mime = { "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 extension_to_mime.get(extension.lower(), "application/octet-stream") def determine_file_type(self, file_path: str) -> str: """ Bestimmt den Typ einer Datei basierend auf dem MIME-Typ. Args: file_path: Pfad zur Datei Returns: Art der Datei: "image", "document" oder "file" """ mime_type = self.get_mime_type(file_path) # Bildtypen if mime_type.startswith("image/"): return "image" # Dokumenttypen document_types = [ "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", # docx "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", # xlsx "application/vnd.openxmlformats-officedocument.presentationml.presentation", # pptx "application/vnd.ms-excel", "application/vnd.ms-powerpoint", "application/msword", "text/csv", "text/plain", "application/json", "application/xml", "text/html", "text/x-python", "application/javascript", "text/css" ] if any(mime_type.startswith(dt) for dt in document_types) or mime_type in document_types: return "document" # Fallback für unbekannte Typen return "file" def calculate_file_hash(self, file_content: bytes) -> str: """ Calculate SHA-256 hash of file content for deduplication Args: file_content: Binary content of the file Returns: SHA-256 hash as a hexadecimal string """ return hashlib.sha256(file_content).hexdigest() def _get_current_timestamp(self) -> str: """Gibt den aktuellen Zeitstempel im ISO-Format zurück""" return datetime.now().isoformat() def get_initial_id(self, table: str) -> Optional[int]: """ Gibt die initiale ID für eine Tabelle zurück. Args: table: Name der Tabelle Returns: Die initiale ID oder None, wenn nicht vorhanden """ return self.db.get_initial_id(table) # Datei-Methoden def get_all_files(self) -> List[Dict[str, Any]]: """Gibt alle Dateien des aktuellen Mandanten zurück""" return self.db.get_recordset("files") def get_file(self, file_id: int) -> Optional[Dict[str, Any]]: """Gibt eine Datei anhand ihrer ID zurück""" files = self.db.get_recordset("files", record_filter={"id": file_id}) if files: return files[0] return None def create_file(self, name: str, file_type: str, content_type: str = None, size: int = None, path: str = None, file_hash: str = None) -> Dict[str, Any]: """Erstellt einen neuen Dateieintrag""" file_data = { "mandate_id": self.mandate_id, "user_id": self.user_id, "name": name, "type": file_type, "content_type": content_type, "size": size, "path": path, "hash": file_hash, "upload_date": self._get_current_timestamp() } return self.db.record_create("files", file_data) def update_file(self, file_id: int, update_data: Dict[str, Any]) -> Dict[str, Any]: """ Aktualisiert eine vorhandene Datei Args: file_id: ID der zu aktualisierenden Datei update_data: Dictionary mit zu aktualisierenden Feldern Returns: Das aktualisierte Datei-Objekt """ # Prüfen, ob die Datei existiert file = self.get_file(file_id) if not file: raise FileNotFoundError(f"Datei mit ID {file_id} nicht gefunden") # Datei aktualisieren return self.db.record_modify("files", file_id, update_data) def check_for_duplicate_file(self, file_hash: str) -> Optional[Dict[str, Any]]: """ Check if a file with the same hash already exists Args: file_hash: SHA-256 hash of the file content Returns: File record if a duplicate exists, None otherwise """ files = self.db.get_recordset("files", record_filter={"hash": file_hash}) if files: return files[0] return None def save_uploaded_file(self, file_content: bytes, file_name: str) -> Dict[str, Any]: """ Speichert eine hochgeladene Datei und erstellt einen Datenbankeintrag. Args: file_content: Binärdaten der Datei file_name: Name der Datei Returns: Dictionary mit Metadaten der gespeicherten Datei """ try: # Debug: Log the start of the file upload process logger.info(f"Starting upload process for file: {file_name}") logger.info(f"Upload directory: {self.upload_dir}, Mandate ID: {self.mandate_id}") # Debug: Check if file_content is valid bytes if not isinstance(file_content, bytes): logger.error(f"Invalid file_content type: {type(file_content)}") raise ValueError(f"file_content must be bytes, got {type(file_content)}") # Calculate file hash for deduplication file_hash = self.calculate_file_hash(file_content) logger.debug(f"Calculated file hash: {file_hash}") # Check for duplicate existing_file = self.check_for_duplicate_file(file_hash) if existing_file: # Simply return the existing file metadata logger.info(f"Duplikat gefunden für {file_name}: {existing_file['id']}") return existing_file # Generiere eindeutige ID file_id = f"file_{uuid.uuid4()}" logger.debug(f"Generated file ID: {file_id}") # Sanitize filename safe_filename = Path(file_name).name # Get only the filename part logger.debug(f"Sanitized filename: {safe_filename}") # Create parent directories if needed mandate_upload_dir = os.path.join(self.upload_dir, str(self.mandate_id)) logger.debug(f"Mandate upload directory: {mandate_upload_dir}") # Debug: Check if mandate upload directory exists if not os.path.exists(mandate_upload_dir): logger.info(f"Creating mandate upload directory: {mandate_upload_dir}") os.makedirs(mandate_upload_dir, exist_ok=True) # Dateipfad erstellen mit Mandant als Unterverzeichnis file_path = os.path.join(mandate_upload_dir, f"{file_id}_{safe_filename}") logger.debug(f"Full file path: {file_path}") # Datei speichern logger.info(f"Writing file content to: {file_path}") with open(file_path, "wb") as f: f.write(file_content) # Verify file was created if not os.path.exists(file_path): logger.error(f"File was not created at path: {file_path}") raise FileStorageError(f"File could not be created at {file_path}") else: logger.info(f"File successfully saved to: {file_path}") # Dateigröße bestimmen file_size = len(file_content) # MIME-Typ und Dateityp bestimmen mime_type = self.get_mime_type(file_path) file_type = self.determine_file_type(file_path) # Erstelle Metadaten file_meta = { "id": file_id, "name": file_name, "path": file_path, "size": file_size, "type": file_type, "content_type": mime_type, "hash": file_hash, "upload_date": datetime.now().isoformat(), "mandate_id": self.mandate_id, "user_id": self.user_id } logger.debug(f"File metadata: {file_meta}") # Speichere in der Datenbank logger.info(f"Saving file metadata to database for file: {file_id}") db_file = self.create_file( name=file_name, file_type=file_type, content_type=mime_type, size=file_size, path=file_path, file_hash=file_hash ) # Debug: Verify database record was created if not db_file: logger.warning(f"Database record for file {file_id} was not created properly") else: logger.info(f"Database record created for file {file_id}") # Wenn Datenbank-ID vorhanden ist, übernehme sie if db_file and "id" in db_file: file_meta["id"] = db_file["id"] logger.info(f"File upload process completed for: {file_name}") return file_meta except Exception as e: # If an error occurs, clean up any partial file if 'file_path' in locals() and os.path.exists(file_path): try: logger.warning(f"Cleaning up partial file: {file_path}") os.remove(file_path) except Exception as cleanup_error: logger.error(f"Error cleaning up partial file: {cleanup_error}") logger.error(f"Error in save_uploaded_file for {file_name}: {str(e)}", exc_info=True) raise FileStorageError(f"Fehler beim Speichern der Datei: {str(e)}") async def read_file_content(self, file_id: str) -> Optional[bytes]: """ Reads the content of a file by ID Args: file_id: ID of the file Returns: File content as bytes or None if not found """ try: # Get file metadata file = self.get_file(file_id) if not file or "path" not in file: raise FileNotFoundError(f"Datei mit ID {file_id} nicht gefunden") file_path = file["path"] # Check if file exists if not os.path.exists(file_path): raise FileNotFoundError(f"Datei nicht gefunden: {file_path}") # Read file content with open(file_path, "rb") as f: content = f.read() return content except FileNotFoundError as e: # Re-raise FileNotFoundError as is raise except Exception as e: logger.error(f"Fehler beim Lesen der Datei {file_id}: {str(e)}") raise FileError(f"Fehler beim Lesen der Datei: {str(e)}") def download_file(self, file_id: str) -> Optional[Dict[str, Any]]: """ Gibt eine Datei zum Download zurück. Args: file_id: ID der Datei Returns: Dictionary mit Dateidaten und -metadaten oder None, wenn nicht gefunden """ try: # Suche die Datei in der Datenbank file = self.get_file(file_id) if not file or "path" not in file: raise FileNotFoundError(f"Datei mit ID {file_id} nicht gefunden") file_path = file["path"] # Prüfe, ob die Datei existiert if not os.path.exists(file_path): raise FileNotFoundError(f"Datei nicht gefunden: {file_path}") # Lese die Datei with open(file_path, "rb") as f: file_content = f.read() return { "id": file_id, "name": file.get("name", os.path.basename(file_path)), "content_type": file.get("content_type", self.get_mime_type(file_path)), "size": file.get("size", len(file_content)), "path": file_path, "content": file_content } except FileNotFoundError as e: # Re-raise FileNotFoundError as is raise except Exception as e: logger.error(f"Fehler beim Herunterladen der Datei {file_id}: {str(e)}") raise FileError(f"Fehler beim Herunterladen der Datei: {str(e)}") def delete_file(self, file_id: str) -> bool: """ Löscht eine Datei aus der Datenbank und dem Dateisystem. Args: file_id: ID der Datei Returns: True bei Erfolg, False bei Fehler """ try: # Suche die Datei in der Datenbank file = self.get_file(file_id) if not file: raise FileNotFoundError(f"Datei mit ID {file_id} nicht gefunden") # Prüfe, ob die Datei zum aktuellen Mandanten gehört if file.get("mandate_id") != self.mandate_id: raise FilePermissionError(f"Keine Berechtigung zum Löschen der Datei {file_id}") # Speichere den Dateipfad file_path = file.get("path") # Check for other references to this file (by hash) file_hash = file.get("hash") if file_hash: other_references = [f for f in self.db.get_recordset("files", record_filter={"hash": file_hash}) if f.get("id") != file_id] # If other files reference this content, only delete the database entry if other_references: logger.info(f"Andere Referenzen auf den Dateiinhalt gefunden, nur DB-Eintrag wird gelöscht: {file_id}") return self.db.record_delete("files", file_id) # Lösche den Datenbankeintrag db_success = self.db.record_delete("files", file_id) # Wenn der Datenbankeintrag erfolgreich gelöscht wurde und ein Dateipfad vorhanden ist, # lösche auch die Datei if db_success and file_path and os.path.exists(file_path): try: os.remove(file_path) return True except Exception as e: logger.error(f"Fehler beim physischen Löschen der Datei {file_path}: {str(e)}") # Datenbankdatei wurde gelöscht, physische Datei nicht - trotzdem Erfolg melden return True return db_success except FileNotFoundError as e: # Pass through FileNotFoundError raise except FilePermissionError as e: # Pass through FilePermissionError raise except Exception as e: logger.error(f"Fehler beim Löschen der Datei {file_id}: {str(e)}") raise FileDeletionError(f"Fehler beim Löschen der Datei: {str(e)}") # Prompt-Methoden def get_all_prompts(self) -> List[Dict[str, Any]]: """Gibt alle Prompts des aktuellen Mandanten zurück""" return self.db.get_recordset("prompts") def get_prompt(self, prompt_id: int) -> Optional[Dict[str, Any]]: """Gibt einen Prompt anhand seiner ID zurück""" prompts = self.db.get_recordset("prompts", record_filter={"id": prompt_id}) if prompts: return prompts[0] return None def create_prompt(self, content: str, name: str) -> Dict[str, Any]: """Erstellt einen neuen Prompt""" prompt_data = { "mandate_id": self.mandate_id, "user_id": self.user_id, "content": content, "name": name, "created_at": self._get_current_timestamp() } return self.db.record_create("prompts", prompt_data) def update_prompt(self, prompt_id: int, content: str = None, name: str = None) -> Dict[str, Any]: """ Aktualisiert einen vorhandenen Prompt Args: prompt_id: ID des zu aktualisierenden Prompts content: Neuer Inhalt des Prompts Returns: Das aktualisierte Prompt-Objekt """ # Prüfen, ob der Prompt existiert prompt = self.get_prompt(prompt_id) if not prompt: return None # Daten für die Aktualisierung vorbereiten prompt_data = {} if content is not None: prompt_data["content"] = content if name is not None: prompt_data["name"] = name # Prompt aktualisieren return self.db.record_modify("prompts", prompt_id, prompt_data) def delete_prompt(self, prompt_id: int) -> bool: """ Löscht einen Prompt aus der Datenbank Args: prompt_id: ID des zu löschenden Prompts Returns: True, wenn der Prompt erfolgreich gelöscht wurde, sonst False """ return self.db.record_delete("prompts", prompt_id) # Workflow Methoden def get_all_workflows(self) -> List[Dict[str, Any]]: """Gibt alle Workflows des aktuellen Mandanten zurück""" return self.db.get_recordset("workflows") def get_workflows_by_user(self, user_id: int) -> List[Dict[str, Any]]: """Gibt alle Workflows eines Benutzers zurück""" return self.db.get_recordset("workflows", record_filter={"user_id": user_id}) def get_workflow(self, workflow_id: str) -> Optional[Dict[str, Any]]: """Gibt einen Workflow anhand seiner ID zurück""" workflows = self.db.get_recordset("workflows", record_filter={"id": workflow_id}) if workflows: return workflows[0] return None def create_workflow(self, workflow_data: Dict[str, Any]) -> Dict[str, Any]: """Erstellt einen neuen Workflow in der Datenbank""" # Stellen Sie sicher, dass mandate_id und user_id gesetzt sind if "mandate_id" not in workflow_data: workflow_data["mandate_id"] = self.mandate_id if "user_id" not in workflow_data: workflow_data["user_id"] = self.user_id # Zeitstempel setzen, falls nicht vorhanden current_time = self._get_current_timestamp() if "started_at" not in workflow_data: workflow_data["started_at"] = current_time if "last_activity" not in workflow_data: workflow_data["last_activity"] = current_time return self.db.record_create("workflows", workflow_data) def update_workflow(self, workflow_id: str, workflow_data: Dict[str, Any]) -> Dict[str, Any]: """ Aktualisiert einen vorhandenen Workflow. Args: workflow_id: ID des zu aktualisierenden Workflows workflow_data: Neue Daten für den Workflow Returns: Das aktualisierte Workflow-Objekt """ # Prüfen, ob der Workflow existiert workflow = self.get_workflow(workflow_id) if not workflow: return None # Aktualisierungszeit setzen workflow_data["last_activity"] = self._get_current_timestamp() # Workflow aktualisieren return self.db.record_modify("workflows", workflow_id, workflow_data) def delete_workflow(self, workflow_id: str) -> bool: """ Löscht einen Workflow aus der Datenbank. Args: workflow_id: ID des zu löschenden Workflows Returns: True bei Erfolg, False wenn der Workflow nicht existiert """ # Prüfen, ob der Workflow existiert workflow = self.get_workflow(workflow_id) if not workflow: return False # Prüfen, ob der Benutzer der Eigentümer ist oder Admin-Rechte hat if workflow.get("user_id") != self.user_id: # Hier könnte eine Prüfung auf Admin-Rechte erfolgen return False # Workflow löschen return self.db.record_delete("workflows", workflow_id) def get_workflow_messages(self, workflow_id: str) -> List[Dict[str, Any]]: """Gibt alle Nachrichten eines Workflows zurück""" return self.db.get_recordset("workflow_messages", record_filter={"workflow_id": workflow_id}) def create_workflow_message(self, message_data: Dict[str, Any]) -> Dict[str, Any]: """Erstellt eine neue Nachricht für einen Workflow Args: message_data: Die Nachrichtendaten Returns: Die erstellte Nachricht oder None bei Fehler """ try: # Check if required fields are present required_fields = ["id", "workflow_id"] for field in required_fields: if field not in message_data: logger.error(f"Pflichtfeld '{field}' fehlt in message_data") raise ValueError(f"Pflichtfeld '{field}' fehlt in den Nachrichtendaten") # Validate that ID is not None if message_data["id"] is None: message_data["id"] = f"msg_{uuid.uuid4()}" logger.warning(f"Automatisch generierte ID für Workflow-Nachricht: {message_data['id']}") # Stellen Sie sicher, dass die benötigten Felder vorhanden sind if "created_at" not in message_data: message_data["created_at"] = self._get_current_timestamp() # Debug-Log für die zu erstellenden Daten logger.debug(f"Erstelle Workflow-Nachricht mit Daten: {message_data}") return self.db.record_create("workflow_messages", message_data) except Exception as e: logger.error(f"Fehler beim Erstellen der Workflow-Nachricht: {str(e)}") # Return None instead of raising to avoid cascading failures return None def update_workflow_message(self, message_id: str, message_data: Dict[str, Any]) -> Dict[str, Any]: """ Aktualisiert eine bestehende Workflow-Nachricht in der Datenbank with improved document handling. Args: message_id: ID der Nachricht message_data: Zu aktualisierende Daten Returns: Das aktualisierte Nachrichtenobjekt oder None bei Fehler """ try: # Print debug info print(f"Updating message {message_id} in database") # Ensure message_id is provided if not message_id: logger.error("No message_id provided for update_workflow_message") raise ValueError("message_id cannot be empty") # Check if message exists in database messages = self.db.get_recordset("workflow_messages", record_filter={"id": message_id}) if not messages: logger.warning(f"Message with ID {message_id} does not exist in database") # If message doesn't exist but we have workflow_id, create it if "workflow_id" in message_data: logger.info(f"Creating new message with ID {message_id} for workflow {message_data.get('workflow_id')}") return self.db.record_create("workflow_messages", message_data) else: logger.error(f"Workflow ID missing for new message {message_id}") return None # Ensure documents array is handled properly if "documents" in message_data: logger.info(f"Message {message_id} has {len(message_data['documents'])} documents") # Make sure we're not storing huge content in the database # For each document, ensure content size is reasonable documents_to_store = [] for doc in message_data["documents"]: doc_copy = doc.copy() # Process contents array if it exists if "contents" in doc_copy: # Ensure contents is not too large - limit text size for content in doc_copy["contents"]: if content.get("type") == "text" and "text" in content: text = content["text"] if len(text) > 1000: # Limit text preview to 1000 chars content["text"] = text[:1000] + "... [truncated]" documents_to_store.append(doc_copy) # Replace with the processed documents message_data["documents"] = documents_to_store # Log the update data size for debugging update_data_size = len(str(message_data)) logger.debug(f"Update data size: {update_data_size} bytes") # Ensure ID is in the dataset if 'id' not in message_data: message_data['id'] = message_id # Update the message updated_message = self.db.record_modify("workflow_messages", message_id, message_data) if updated_message: logger.info(f"Message {message_id} updated successfully") else: logger.warning(f"Failed to update message {message_id}") return updated_message except Exception as e: logger.error(f"Error updating message {message_id}: {str(e)}", exc_info=True) # Re-raise with full information raise ValueError(f"Error updating message {message_id}: {str(e)}") def get_workflow_logs(self, workflow_id: str) -> List[Dict[str, Any]]: """Gibt alle Log-Einträge eines Workflows zurück""" return self.db.get_recordset("workflow_logs", record_filter={"workflow_id": workflow_id}) def create_workflow_log(self, log_data: Dict[str, Any]) -> Dict[str, Any]: """Erstellt einen neuen Log-Eintrag für einen Workflow""" # Stellen Sie sicher, dass die benötigten Felder vorhanden sind if "timestamp" not in log_data: log_data["timestamp"] = self._get_current_timestamp() return self.db.record_create("workflow_logs", log_data) def save_workflow_state(self, workflow: Dict[str, Any], save_messages: bool = True, save_logs: bool = True) -> bool: """ Speichert den kompletten Zustand eines Workflows in der Datenbank. Dies umfasst den Workflow selbst, Nachrichten und Logs. Args: workflow: Das vollständige Workflow-Objekt save_messages: Flag, ob Nachrichten gespeichert werden sollen save_logs: Flag, ob Logs gespeichert werden sollen Returns: True bei Erfolg, False bei Fehler """ try: workflow_id = workflow.get("id") if not workflow_id: return False # Extrahiere nur die für die Datenbank relevanten Workflow-Felder workflow_db_data = { "id": workflow_id, "mandate_id": workflow.get("mandate_id", self.mandate_id), "user_id": workflow.get("user_id", self.user_id), "name": workflow.get("name", f"Workflow {workflow_id}"), "status": workflow.get("status", "unknown"), "started_at": workflow.get("started_at", self._get_current_timestamp()), "last_activity": workflow.get("last_activity", self._get_current_timestamp()), "completed_at": workflow.get("completed_at"), "data_stats": workflow.get("data_stats", {}) } # Prüfen, ob der Workflow bereits existiert existing_workflow = self.get_workflow(workflow_id) if existing_workflow: self.update_workflow(workflow_id, workflow_db_data) else: self.create_workflow(workflow_db_data) # Nachrichten speichern if save_messages and "messages" in workflow: # Bestehende Nachrichten abrufen existing_messages = {msg["id"]: msg for msg in self.get_workflow_messages(workflow_id)} for message in workflow["messages"]: message_id = message.get("id") if not message_id: continue # Nur relevante Daten für die Datenbank extrahieren message_data = { "id": message_id, "workflow_id": workflow_id, "sequence_no": message.get("sequence_no", 0), "role": message.get("role", "unknown"), "content": message.get("content"), "agent_type": message.get("agent_type"), "created_at": message.get("started_at", self._get_current_timestamp()), # IMPORTANT: Include documents field to persist file attachments "documents": message.get("documents", []) } # Debug logging for documents doc_count = len(message.get("documents", [])) if doc_count > 0: logger.info(f"Message {message_id} has {doc_count} documents to save") # Nachricht erstellen oder aktualisieren if message_id in existing_messages: self.db.record_modify("workflow_messages", message_id, message_data) else: self.db.record_create("workflow_messages", message_data) # Logs speichern if save_logs and "logs" in workflow: # Bestehende Logs abrufen existing_logs = {log["id"]: log for log in self.get_workflow_logs(workflow_id)} for log in workflow["logs"]: log_id = log.get("id") if not log_id: continue # Nur relevante Daten für die Datenbank extrahieren log_data = { "id": log_id, "workflow_id": workflow_id, "message": log.get("message", ""), "type": log.get("type", "info"), "timestamp": log.get("timestamp", self._get_current_timestamp()), "agent_id": log.get("agent_id"), "agent_name": log.get("agent_name") } # Log erstellen oder aktualisieren if log_id in existing_logs: self.db.record_modify("workflow_logs", log_id, log_data) else: self.db.record_create("workflow_logs", log_data) return True except Exception as e: logger.error(f"Fehler beim Speichern des Workflow-Zustands: {str(e)}") return False def load_workflow_state(self, workflow_id: str) -> Optional[Dict[str, Any]]: """ Lädt den kompletten Zustand eines Workflows aus der Datenbank. Dies umfasst den Workflow selbst, Nachrichten und Logs. Args: workflow_id: ID des zu ladenden Workflows Returns: Das vollständige Workflow-Objekt oder None bei Fehler """ try: # Basis-Workflow laden workflow = self.get_workflow(workflow_id) if not workflow: return None # Log the workflow base retrieval logger.debug(f"Loaded base workflow {workflow_id} from database") # Nachrichten laden messages = self.get_workflow_messages(workflow_id) # Nach Sequenznummer sortieren messages.sort(key=lambda x: x.get("sequence_no", 0)) # Debug log for messages and document counts message_count = len(messages) logger.debug(f"Loaded {message_count} messages for workflow {workflow_id}") # Log document counts for each message for msg in messages: doc_count = len(msg.get("documents", [])) if doc_count > 0: logger.info(f"Message {msg.get('id')} has {doc_count} documents loaded from database") # Log document details for debugging for i, doc in enumerate(msg.get("documents", [])): source = doc.get("source", {}) logger.debug(f"Document {i+1}: {source.get('name', 'unnamed')} (ID: {source.get('id', 'unknown')})") # Logs laden logs = self.get_workflow_logs(workflow_id) # Nach Zeitstempel sortieren logs.sort(key=lambda x: x.get("timestamp", "")) # Vollständiges Workflow-Objekt zusammenbauen complete_workflow = workflow.copy() complete_workflow["messages"] = messages complete_workflow["logs"] = logs return complete_workflow except Exception as e: logger.error(f"Fehler beim Laden des Workflow-Zustands: {str(e)}") return None # DELETE Workflow message elements def delete_workflow_message(self, workflow_id: str, message_id: str) -> bool: """ Löscht eine Nachricht aus einem Workflow in der Datenbank. Args: workflow_id: ID des zugehörigen Workflows message_id: ID der zu löschenden Nachricht Returns: True bei Erfolg, False bei Fehler """ try: # Prüfen, ob die Nachricht existiert messages = self.get_workflow_messages(workflow_id) message = next((m for m in messages if m.get("id") == message_id), None) if not message: logger.warning(f"Nachricht {message_id} für Workflow {workflow_id} nicht gefunden") return False # Nachricht aus der Datenbank löschen return self.db.record_delete("workflow_messages", message_id) except Exception as e: logger.error(f"Fehler beim Löschen der Nachricht {message_id}: {str(e)}") return False def delete_file_from_message(self, workflow_id: str, message_id: str, file_id: str) -> bool: """ Entfernt eine Dateireferenz aus einer Nachricht. Die Datei selbst wird nicht gelöscht, nur die Referenz in der Nachricht. Enhanced version with improved file matching. Args: workflow_id: ID des zugehörigen Workflows message_id: ID der Nachricht file_id: ID der zu entfernenden Datei Returns: True bei Erfolg, False bei Fehler """ try: # Log operation logger.info(f"Removing file {file_id} from message {message_id} in workflow {workflow_id}") # Get all workflow messages all_messages = self.get_workflow_messages(workflow_id) logger.debug(f"Workflow {workflow_id} has {len(all_messages)} messages") # Try different approaches to find the message message = None # Exact match message = next((m for m in all_messages if m.get("id") == message_id), None) # Case-insensitive match if not message and isinstance(message_id, str): message = next((m for m in all_messages if isinstance(m.get("id"), str) and m.get("id").lower() == message_id.lower()), None) # Partial match (starts with) if not message and isinstance(message_id, str): message = next((m for m in all_messages if isinstance(m.get("id"), str) and m.get("id").startswith(message_id)), None) if not message: logger.warning(f"Message {message_id} not found in workflow {workflow_id}") return False # Log the found message logger.info(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 {message_id}") return False # Log existing documents documents = message.get("documents", []) logger.debug(f"Message has {len(documents)} documents") for i, doc in enumerate(documents): doc_id = doc.get("id", "unknown") source = doc.get("source", {}) source_id = source.get("id", "unknown") logger.debug(f"Document {i}: doc_id={doc_id}, source_id={source_id}") # Create a new list of documents without the one to delete updated_documents = [] removed = False for doc in documents: doc_id = doc.get("id") source = doc.get("source", {}) source_id = source.get("id") # Flexible matching approach should_remove = ( (doc_id == file_id) or (source_id == file_id) or (isinstance(doc_id, str) and file_id in doc_id) or (isinstance(source_id, str) and file_id in source_id) ) if should_remove: removed = True logger.info(f"Found file to remove: doc_id={doc_id}, source_id={source_id}") else: updated_documents.append(doc) if not removed: logger.warning(f"No matching file {file_id} found in message {message_id}") return False # Update message with modified documents array message_update = { "documents": updated_documents } # Apply the update directly to the database updated = self.db.record_modify("workflow_messages", message["id"], message_update) if updated: logger.info(f"Successfully removed file {file_id} from message {message_id}") return True else: logger.warning(f"Failed to update message {message_id} in database") return False except Exception as e: logger.error(f"Error removing file {file_id} from message {message_id}: {str(e)}") return False # Singleton-Factory für LucyDOMInterface-Instanzen pro Kontext _lucydom_interfaces = {} def get_lucydom_interface(mandate_id: int = 0, user_id: int = 0) -> LucyDOMInterface: """ Gibt eine LucyDOMInterface-Instanz für den angegebenen Kontext zurück. Wiederverwendet bestehende Instanzen. """ context_key = f"{mandate_id}_{user_id}" if context_key not in _lucydom_interfaces: _lucydom_interfaces[context_key] = LucyDOMInterface(mandate_id, user_id) return _lucydom_interfaces[context_key] # Init get_lucydom_interface()