diff --git a/_uploads/.gitignore b/_uploads/.gitignore deleted file mode 100644 index 86d0cb27..00000000 --- a/_uploads/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Ignore everything in this directory -* -# Except this file -!.gitignore \ No newline at end of file diff --git a/config.ini b/config.ini index 92f8e892..4b46fb49 100644 --- a/config.ini +++ b/config.ini @@ -32,8 +32,7 @@ Connector_AiWebscraping_MAX_SEARCH_RESULTS = 5 Module_AgentserviceInterface_UPLOAD_DIR = ./_uploads # File management configuration -File_Management_MAX_UPLOAD_SIZE = 10000000 -File_Management_ALLOWED_EXTENSIONS = pdf,docx,xlsx,txt,csv,json,jpg,png +File_Management_MAX_UPLOAD_SIZE_MB = 50 File_Management_CLEANUP_INTERVAL = 240 # Logging configuration diff --git a/modules/agentservice_agent_coder.py b/modules/agentservice_agent_coder.py index 1d77983d..9b9e3ebf 100644 --- a/modules/agentservice_agent_coder.py +++ b/modules/agentservice_agent_coder.py @@ -399,38 +399,6 @@ class SimpleCodeExecutor: """Clean up during garbage collection.""" self.cleanup() -# Unchanged error recommendation function -def get_error_recommendation(error_message: str) -> str: - """Generate recommendations based on error message.""" - if "ImportError" in error_message or "ModuleNotFoundError" in error_message: - return """ -### Recommendation -The error indicates a missing Python module. Try using standard libraries or common data analysis modules. -""" - elif "PermissionError" in error_message: - return """ -### Recommendation -The code doesn't have the necessary permissions to access files or directories. -""" - elif "SyntaxError" in error_message: - return """ -### Recommendation -There's a syntax error in the code. Check for missing parentheses, quotes, colons, or indentation errors. -""" - elif "FileNotFoundError" in error_message: - return """ -### Recommendation -A file could not be found. Check the file path and make sure the file exists. -""" - else: - return """ -### Recommendation -To fix the error: -1. Check the exact error message -2. Simplify the code and test step by step -3. Use try/except blocks for error-prone operations -""" - class CoderAgent(BaseAgent): """Agent for developing and executing Python code with auto-correction capabilities""" @@ -749,7 +717,7 @@ class CoderAgent(BaseAgent): response_content += f"### Final Error\n\n```\n{attempts_info[-1]['error']}\n```\n\n" # Add recommendation based on error - response_content += get_error_recommendation(error) + response_content += self.get_error_recommendation(error) # Add correction history if len(attempts_info) > 1: @@ -788,7 +756,7 @@ class CoderAgent(BaseAgent): response_content += f"### Error\n\n```\n{error}\n```\n\n" # Add recommendation based on error - response_content += get_error_recommendation(error) + response_content += self.get_error_recommendation(error) response["content"] = response_content else: @@ -1386,7 +1354,40 @@ result = {{"error": "Code generation failed", "message": "{error_str}"}} result_format="python_code", context_id=context_id ) - + + # Unchanged error recommendation function + def get_error_recommendation(error_message: str) -> str: + """Generate recommendations based on error message.""" + if "ImportError" in error_message or "ModuleNotFoundError" in error_message: + return """ + ### Recommendation + The error indicates a missing Python module. Try using standard libraries or common data analysis modules. + """ + elif "PermissionError" in error_message: + return """ + ### Recommendation + The code doesn't have the necessary permissions to access files or directories. + """ + elif "SyntaxError" in error_message: + return """ + ### Recommendation + There's a syntax error in the code. Check for missing parentheses, quotes, colons, or indentation errors. + """ + elif "FileNotFoundError" in error_message: + return """ + ### Recommendation + A file could not be found. Check the file path and make sure the file exists. + """ + else: + return """ + ### Recommendation + To fix the error: + 1. Check the exact error message + 2. Simplify the code and test step by step + 3. Use try/except blocks for error-prone operations + """ + + # Singleton instance _coder_agent = None diff --git a/modules/agentservice_base.py b/modules/agentservice_base.py index 8291a3be..f09e071b 100644 --- a/modules/agentservice_base.py +++ b/modules/agentservice_base.py @@ -12,7 +12,7 @@ import uuid logger = logging.getLogger(__name__) -class BaseAgent: +class AgentBase: """ Enhanced base agent class with improved communication capabilities. All specialized agents should inherit from this class. @@ -20,37 +20,16 @@ class BaseAgent: def __init__(self): """Initialize the enhanced agent.""" - self.id = "base_agent" - self.name = "Base Agent" - self.type = "base" - self.description = "Base agent for the Agentservice" + self.name = "base" self.capabilities = "Basic agent operations" self.result_format = "Text" - - # New properties for document handling - self.supports_documents = True - self.document_capabilities = ["read", "reference"] - self.required_context = [] - # System dependencies self.ai_service = None - self.document_handler = None - self.lucydom_interface = None def set_dependencies(self, ai_service=None, document_handler=None, lucydom_interface=None): - """ - Set system dependencies. - - Args: - ai_service: AI service for text generation - document_handler: Document handler for document operations - lucydom_interface: LucyDOM interface for database access - """ self.ai_service = ai_service - self.document_handler = document_handler - self.lucydom_interface = lucydom_interface - def get_agent_info(self) -> Dict[str, Any]: + def get_config(self) -> Dict[str, Any]: """ Get detailed information about the agent. @@ -58,15 +37,9 @@ class BaseAgent: Dictionary with agent information """ return { - "id": self.id, "name": self.name, - "type": self.type, - "description": self.description, "capabilities": self.capabilities, "result_format": self.result_format, - "supports_documents": self.supports_documents, - "document_capabilities": self.document_capabilities, - "required_context": self.required_context } def get_capabilities(self) -> List[str]: diff --git a/modules/agentservice_registry.py b/modules/agentservice_registry.py index f9f66e50..301e5e73 100644 --- a/modules/agentservice_registry.py +++ b/modules/agentservice_registry.py @@ -93,8 +93,6 @@ class AgentRegistry: lucydom_interface: LucyDOM interface for database access """ self.ai_service = ai_service - self.document_handler = document_handler - self.lucydom_interface = lucydom_interface # Update all registered agents self.update_agent_dependencies() diff --git a/modules/backup-lucydom_interface copy.py b/modules/backup-lucydom_interface copy.py new file mode 100644 index 00000000..9977c61c --- /dev/null +++ b/modules/backup-lucydom_interface copy.py @@ -0,0 +1,1109 @@ +import os +import logging +import uuid +from datetime import datetime, timedelta +import mimetypes +from typing import Dict, Any, List, Optional, Union, BinaryIO, Tuple +import importlib +import hashlib +from pathlib import Path + +from connectors.connector_db_json import DatabaseConnector +from modules.utility import APP_CONFIG + +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 + + # Upload Verzeichnis aus config.ini lesen + self.upload_dir = APP_CONFIG.get('Module_AgentserviceInterface_UPLOAD_DIR') + os.makedirs(self.upload_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 + + # Datenbank initialisieren, falls nötig + self._initialize_database() + + 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 + + self.db = DatabaseConnector( + db_host=APP_CONFIG.get("DB_LUCYDOM_HOST"), + db_database=APP_CONFIG.get("DB_LUCYDOM_DATABASE"), + db_user=APP_CONFIG.get("DB_LUCYDOM_USER"), + db_password=APP_CONFIG.get("DB_LUCYDOM_PASSWORD_SECRET"), + mandate_id=self.mandate_id, + user_id=self.user_id + ) + + # 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']}") + + + # Utilities + + 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) + + def _get_current_timestamp(self) -> str: + """Gibt den aktuellen Zeitstempel im ISO-Format zurück""" + return datetime.now().isoformat() + + + # 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) + + + # File Utilities + + 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 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 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={"file_hash": file_hash}) + if files: + return files[0] + return None + + + # File 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, + mime_type: str, + 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, + "mime_type": mime_type, + "size": size, + "path": path, + "file_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 delete_file(self, file_id: int) -> 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("file_hash") + if file_hash: + other_references = [f for f in self.db.get_recordset("files", record_filter={"file_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)}") + + 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 NameID + name_id = f"file_{uuid.uuid4()}" + logger.debug(f"Generated filename ID: {name_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"{name_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) + + # Speichere in der Datenbank + logger.info(f"Saving file metadata to database for file: {name_id}") + db_file = self.create_file( + name=file_name, + mime_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 {name_id} was not created properly") + else: + logger.info(f"Database record created for file {name_id}") + + logger.info(f"File upload process completed for: {file_name}") + return db_file + + 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)}") + + def download_file(self, file_id: int) -> 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)), + "mime_type": file.get("mime_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)}") + + + # 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 + + # Stelle sicher, dass last_message_id gesetzt ist, falls nicht vorhanden + if "last_message_id" not in workflow_data: + workflow_data["last_message_id"] = "" + + 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) + + + # Workflow Messages + + 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 "started_at" not in message_data and "created_at" not in message_data: + message_data["started_at"] = self._get_current_timestamp() + + # Wenn "created_at" vorhanden ist, übertrage es nach "started_at" + if "created_at" in message_data and "started_at" not in message_data: + message_data["started_at"] = message_data["created_at"] + del message_data["created_at"] + + # Status setzen, falls nicht vorhanden + if "status" not in message_data: + message_data["status"] = "completed" + + # Sequenznummer setzen, falls nicht vorhanden + if "sequence_no" not in message_data: + # Hole aktuelle Nachrichten, um die nächste Sequenznummer zu bestimmen + existing_messages = self.get_workflow_messages(message_data["workflow_id"]) + message_data["sequence_no"] = len(existing_messages) + 1 + + # 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 + + # Konvertiere created_at zu started_at falls nötig + if "created_at" in message_data and "started_at" not in message_data: + message_data["started_at"] = message_data["created_at"] + del message_data["created_at"] + + # 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 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: int) -> 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 + + + # Workflow Logs + + 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) + + + # Workflow Management + + 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()), + "last_message_id": workflow.get("last_message_id", ""), + "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_name": message.get("agent_name"), + "status": message.get("status", "completed"), + "started_at": message.get("started_at", self._get_current_timestamp()), + "finished_at": message.get("finished_at"), + "parent_message_id": message.get("parent_message_id"), + # 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 + + +# 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() \ No newline at end of file diff --git a/modules/chat.py b/modules/chat.py new file mode 100644 index 00000000..63b9622c --- /dev/null +++ b/modules/chat.py @@ -0,0 +1,858 @@ +""" +ChatManager Modul zur Verwaltung von AI-Chat-Workflows. +Implementiert eine kompakte und modulare Architektur für die Verarbeitung +von Benutzeranfragen, Agentenausführung und Ergebnisformatierung. +""" + +import logging +import json +import uuid +from datetime import datetime +from typing import Dict, Any, List, Optional, Union + +# Notwendige Importe +from connectors.connector_aichat_openai import ChatService +from modules.chat_registry import get_agent_registry +from modules.lucydom_interface import get_lucydom_interface + +# Logger konfigurieren +logger = logging.getLogger(__name__) + +class ChatManager: + """ + Verwaltet die Verarbeitung von Chat-Anfragen, Agentenausführung und + die Integration von Ergebnissen in den Workflow. + """ + + def __init__(self, mandate_id: int, user_id: int): + """ + Initialisiert den ChatManager 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 + self.ai_service = ChatService() + self.lucy_interface = get_lucydom_interface(mandate_id, user_id) + self.agent_registry = get_agent_registry() + + + ### Chat Management + + async def chat_run(self, user_input: Dict[str, Any], workflow_id: Optional[str] = None) -> Dict[str, Any]: + """ + Hauptfunktion zur Integration von Benutzeranfragen in den Workflow. + + Args: + user_input, which will be parsed to message_user: Message-Objekt mit Benutzeranfrage und Dokumenten + workflow_id: Optional - ID des Workflows (None für neue Workflows) + + Returns: + Workflow-Objekt mit aktualisiertem Zustand + """ + logger.info(f"User message object: {self.parse_json2text(message_user)}") + + # 0. User-Input mit file id's in Message User als message object transformieren und alle contents vorbereiten + message_user = self.chat_user_message_integration(user_input) + + # 1. Workflow initialisieren oder bestehenden laden + workflow = self.workflow_init(workflow_id) + + # 2. Benutzer-Message im Workflow speichern + self.message_add(workflow, message_user) + + # 3. Projektleiter-Prompt erstellen und Antwort analysieren + project_manager_response = await self.chat_prompt(message_user, workflow) + + # 3.1. Extrahiere die benötigten Informationen aus der Antwort + obj_answer = project_manager_response.get("obj_answer", []) + obj_workplan = project_manager_response.get("obj_workplan", []) + user_response = project_manager_response.get("user_response", "") + + # 3.2. Speichere die Antwort als Message im Workflow und füge Log-Einträge hinzu + response_message = { + "role": "assistant", + "agent_type": "project_manager", + "content": user_response + } + self.message_add(workflow, response_message) + + # 3.3. Log-Eintrag für den Workplan und die geplanten Ergebnisse + self.log_add(workflow, f"Arbeitsplan: {self.parse_json2text(obj_workplan)}") + self.log_add(workflow, f"Geplante Ergebnisse: {self.parse_json2text(obj_answer)}") + + # 4. Agenten gemäss Workplan ausführen + obj_results = [] + if obj_workplan: + for task in obj_workplan: + # Informiere Benutzer über aktuellen Schritt + agent_name = task.get("agent") + step_info = f"Führe Agent '{agent_name}' aus um {', '.join([d.get('label') for d in task.get('doc_output', [])])} zu erstellen" + self.log_add(workflow, step_info) + + # Bereite Eingabedokumente für den Agenten vor + input_docs = self.agent_input_documents(task.get('doc_input', []), workflow) + + # Führe den Agenten aus + agent_results = await self.agent_execute( + agent_name=agent_name, + prompt=task.get("prompt", ""), + input_docs=input_docs, + output_format=task.get("doc_output", []) + ) + + # Sammle Ergebnisse + obj_results.extend(agent_results) + + # Speichere Zwischenergebnisse + for result in agent_results: + self.log_add(workflow, f"Ergebnis erstellt: {result.get('label')}") + + # 5. Erstelle die finale Antwort mit den gesammelten Dokumenten + final_message = self.chat_final_message(user_response, obj_results, obj_answer) + self.message_add(workflow, final_message) + + # 6. Finalisiere den Workflow + self.workflow_finish(workflow) + + return workflow + + async def chat_prompt(self, message_user: Dict[str, Any], workflow: Dict[str, Any]) -> Dict[str, Any]: + """ + Erstellt den Prompt für den Projektleiter und verarbeitet seine Antwort. + + Args: + message_user: Message-Objekt mit Benutzeranfrage + workflow: Aktuelles Workflow-Objekt + + Returns: + Antwort des Projektleiters mit obj_answer, obj_workplan und user_response + """ + # Verfügbare Dokumenttypen aus der Funktion holen + doc_types = self.document_types_accepted() + doc_types_str = ", ".join(doc_types) + + # Verfügbare Agenten mit ihren Fähigkeiten abrufen + available_agents = self.agent_profiles() + + # Erstelle eine Zusammenfassung des Workflows + workflow_summary = await self.workflow_summarize(workflow, "Fasse den bisherigen Verlauf kurz und prägnant zusammen") + + # Erstelle eine Zusammenfassung der vom Benutzer bereitgestellten Dokumente + user_docs_summary = await self.message_summarize_documents(message_user, "Fasse den Inhalt des Dokuments kurz zusammen") + + # Liste der aktuell verfügbaren Dokumente aus User-Input oder bereits generierten Dokumenten erstellen + available_documents = self.available_documents_get(message_user, workflow) + available_docs_str = self.available_documents_format(available_documents) + + # Erstelle den Prompt für den Projektleiter + prompt = f""" +Basierend auf der Benutzeranfrage: "{message_user.get('content')}" und den bereitgestellten Dokumenten, +analysiere bitte die Anforderungen und erstelle einen Plan zur Bearbeitung. + +# Bisheriger Konversationsverlauf: +{workflow_summary} + +# Vom Benutzer bereitgestellte Dokumente: +{user_docs_summary} + +# Verfügbare Dokumente (aktuell im Workflow): +{available_docs_str} + +# Verfügbare Dokumenttypen: +{doc_types_str} + +# Verfügbare Agenten und ihre Fähigkeiten: +{self.parse_json2text(available_agents)} + +Bitte analysiere die Anfrage und erstelle: + +1. Eine Liste benötigter Ergebnisdokumente (obj_answer) +2. Einen Plan für die Ausführung von Agenten (obj_workplan) +3. Eine verständliche Antwort an den Benutzer + +## WICHTIGE REGELN FÜR DEN ARBEITSPLAN: +1. Jedes Eingabedokument muss entweder bereits vorhanden sein (vom Benutzer bereitgestellt oder vorher von einem Agenten erzeugt) oder von einem Agenten erstellt werden, bevor es verwendet wird. +2. Wenn nötig, konvertiere Eingabedokumente durch Agenten in ein passendes Format, wenn der Typ nicht übereinstimmt. +3. Definiere keine Dokument-Inputs, die nicht existieren oder nicht vorab generiert werden. +4. Erstelle eine logische Reihenfolge - frühere Agenten können Dokumente erzeugen, die später als Eingaben verwendet werden. +5. Wenn der Benutzer Dokumente bereitgestellt hat, nutze diese kreativ, auch wenn sie nicht exakt dem gewünschten Typ entsprechen. + +Antworte in folgendem JSON-Format: +{{ + "obj_answer": [ + {{ + "label": "eindeutiger_dokumentname", + "doc_type": "{doc_types[0]}", # Einer der verfügbaren Dokumenttypen: {doc_types_str} + "summary": "Beschreibung des Dokumentinhalts" + }} + ], + "obj_workplan": [ + {{ + "agent": "agent_name", # Name eines verfügbaren Agenten + "doc_output": [ + {{ + "label": "eindeutiger_dokumentname", + "doc_type": "{doc_types[0]}" # Einer der verfügbaren Dokumenttypen: {doc_types_str} + }} + ], + "prompt": "Anweisungen für den Agenten", + "doc_input": [ + {{ + "label": "eindeutiger_dokumentname", + "doc_type": "{doc_types[0]}" # Einer der verfügbaren Dokumenttypen: {doc_types_str} + }} + ] + # Falls keine Eingabedokumente benötigt werden, kann "doc_input" leer bleiben oder weggelassen werden + }} + # Mehrere Agent-Tasks können hier hinzugefügt werden und sollten logisch aufeinander aufbauen + ], + "user_response": "Klare Erklärung für den Benutzer, was als nächstes passiert" +}} +""" + + # Rufe den AI-Service auf, um die Antwort des Projektleiters zu erhalten + project_manager_output = await self.ai_service.call_api([ + {"role": "system", "content": "Du bist ein erfahrener Projektleiter, der Benutzeranfragen analysiert und Arbeitspläne erstellt. Du achtest sehr sorgfältig darauf, dass alle Dokument-Abhängigkeiten korrekt sind und keine nicht existierenden Dokumente als Eingaben definiert werden."}, + {"role": "user", "content": prompt} + ]) + + # Parsen der JSON-Antwort + return self.parse_json_response(project_manager_output) + + def chat_user_message_integration(self, user_input: Dict[str, Any]) -> Dict[str, Any]: + # Nachrichteninhalt überprüfen + message_content = user_input.get("message", "") + if isinstance(message_content, dict) and "content" in message_content: + message_content = message_content["content"] + + # Wenn Nachrichteninhalt leer ist, kein Chat + if message_content is None or message_content.strip() == "": + logger.warning(f"Leere Nachricht, kein Chat") + message_content = "(No user input received)" + + # Zusätzliche Dateien verarbeiten + additional_fileids = user_input.get("additional_fileids", []) + additional_files = self.process_file_ids(additional_fileids) + + # Nachrichtenobjekt erstellen + message_object = { + "role": "user", + "content": message_content, + "documents": additional_files + } + return message_object + + def chat_final_message(self, user_response: str, obj_results: List[Dict[str, Any]], + obj_answer: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Erstellt die finale Antwortnachricht mit Dokumenten. + + Args: + user_response: Textantwort an den Benutzer + obj_results: Liste der erzeugten Ergebnisdokumente + obj_answer: Liste der erwarteten Antwortdokumente + + Returns: + Vollständiges Message-Objekt mit Inhalt und Dokumenten + """ + # Grundlegende Nachrichtenstruktur erstellen + final_message = { + "role": "assistant", + "agent_type": "final_responder", + "content": user_response, + "documents": [] + } + + # Dokumente vom Typ "text" in die Antwort integrieren + text_parts = [user_response] + + for doc in obj_results: + doc_label = None + doc_contents = [] + + for content in doc.get("contents", []): + if content.get("label"): + doc_label = content.get("label") + + # Suche nach dem passenden doc_type in obj_answer + target_type = None + for answer_spec in obj_answer: + if answer_spec.get("label") == doc_label: + target_type = answer_spec.get("doc_type") + break + + # Wenn der Content vom Typ "text" ist und integriert werden soll + if content.get("type") == "text" and content.get("text"): + text = content.get("text") + text_parts.append(f"\n\n--- {doc_label} ---\n{text}") + + doc_contents.append(content) + + # Füge das Dokument zur finalen Nachricht hinzu + if doc_contents: + final_message["documents"].append({ + "id": doc.get("id", f"doc_{str(uuid.uuid4())}"), + "source": doc.get("source", {"type": "agent", "name": "Response Generator"}), + "contents": doc_contents + }) + + # Aktualisiere den Nachrichteninhalt mit integrierten Texten + final_message["content"] = "\n".join(text_parts) + + return final_message + + + ### Workflow + + def workflow_init(self, workflow_id: Optional[str] = None) -> Dict[str, Any]: + """ + Initialisiert einen Workflow oder lädt einen bestehenden. + + Args: + workflow_id: Optional - ID des zu ladenden Workflows + + Returns: + Initialisiertes Workflow-Objekt + """ + current_time = datetime.now().isoformat() + + if workflow_id is None or not self.lucy_interface.get_workflow(workflow_id): + # Neuen Workflow erstellen + new_workflow_id = str(uuid.uuid4()) if workflow_id is None else workflow_id + workflow = { + "id": new_workflow_id, + "mandate_id": self.mandate_id, + "user_id": self.user_id, + "name": f"Workflow {new_workflow_id[:8]}", + "status": "running", + "started_at": current_time, + "last_activity": current_time, + "current_round": 1, + "waiting_for_user": False, + "messages": [], + "logs": [], + "data_stats": {} + } + + # In Datenbank speichern + self.lucy_interface.create_workflow(workflow) + return workflow + else: + # Bestehenden Workflow laden + workflow = self.lucy_interface.load_workflow_state(workflow_id) + + # Status aktualisieren + workflow["status"] = "running" + workflow["last_activity"] = current_time + workflow["waiting_for_user"] = False + + # In Datenbank aktualisieren + self.lucy_interface.save_workflow_state(workflow) + return workflow + + async def workflow_summarize(self, workflow: Dict[str, Any], prompt: str) -> str: + """ + Erstellt eine Zusammenfassung des Workflows. + + Args: + workflow: Workflow-Objekt + prompt: Anweisungen zur Erstellung der Zusammenfassung + + Returns: + Zusammenfassung des Workflows + """ + if not workflow or "messages" not in workflow or not workflow["messages"]: + return "Keine vorherigen Nachrichten im Workflow vorhanden." + + # Nachrichten in umgekehrter Reihenfolge durchgehen (neueste zuerst) + messages = sorted(workflow["messages"], key=lambda m: m.get("sequence_no", 0), reverse=True) + + summary_parts = [] + for message in messages: + message_summary = await self.message_summarize(message, prompt) + summary_parts.append(message_summary) + + return "\n\n".join(summary_parts) + + def workflow_finish(self, workflow: Dict[str, Any]) -> Dict[str, Any]: + """ + Finalisiert einen Workflow und setzt den Status auf 'stopped'. + + Args: + workflow: Workflow-Objekt + + Returns: + Aktualisiertes Workflow-Objekt + """ + workflow["status"] = "completed" + workflow["last_activity"] = datetime.now().isoformat() + workflow["waiting_for_user"] = True + + # In Datenbank speichern + self.lucy_interface.save_workflow_state(workflow) + return workflow + + + ### Agents + + def agent_profiles(self) -> List[Dict[str, Any]]: + """ + Ruft Informationen über alle verfügbaren Agenten ab. + + Returns: + Liste mit Informationen über alle verfügbaren Agenten + """ + return self.agent_registry.get_agent_infos() + + def agent_input_documents(self, doc_input_list: List[Dict[str, Any]], workflow: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Bereitet Eingabedokumente für einen Agenten vor. + + Args: + doc_input_list: Liste der benötigten Eingabedokumente + workflow: Workflow-Objekt + + Returns: + Aufbereitete Eingabedokumente für den Agenten + """ + prepared_inputs = [] + + for doc_spec in doc_input_list: + doc_label = doc_spec.get("label") + doc_type = doc_spec.get("doc_type") + + found_doc = None + + # Durchsuche alle Nachrichten nach dem gesuchten Dokument + for message in workflow.get("messages", []): + for doc in message.get("documents", []): + # Dokument anhand des Labels identifizieren + if any(content.get("label") == doc_label for content in doc.get("contents", [])): + found_doc = doc + break + + if found_doc: + break + + if found_doc: + prepared_inputs.append(found_doc) + + return prepared_inputs + + async def agent_execute(self, agent_name: str, prompt: str, input_docs: List[Dict[str, Any]], + output_format: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Führt einen Agenten mit den angegebenen Parametern aus. + + Args: + agent_name: Name des auszuführenden Agenten + prompt: Prompt für den Agenten + input_docs: Eingabedokumente + output_format: Erwartetes Ausgabeformat + + Returns: + Liste der vom Agenten erzeugten Ergebnisdokumente + """ + # Hole den Agenten aus der Registry + agent = self.agent_registry.get_agent(agent_name) + + if not agent: + logger.error(f"Agent '{agent_name}' nicht gefunden") + return [] + + try: + # Erstelle die Agenten-Anfrage + agent_request = { + "role": "user", + "content": prompt, + "documents": input_docs + } + + # Führe den Agenten aus + agent_response = await agent.process_message(agent_request, {"expected_format": output_format}) + + # Extrahiere die erzeugten Dokumente + results = agent_response.get("documents", []) + + # Benenne die Dokumente gemäß des angegebenen Ausgabeformats + for i, format_spec in enumerate(output_format): + if i < len(results): + for content in results[i].get("contents", []): + content["label"] = format_spec.get("label") + content["type"] = format_spec.get("doc_type") + + return results + except Exception as e: + logger.error(f"Fehler bei Ausführung von Agent '{agent_name}': {str(e)}") + return [] + + + ### Messages + + def message_add(self, workflow: Dict[str, Any], message: Dict[str, Any]) -> str: + """ + Fügt eine Nachricht zum Workflow hinzu und aktualisiert last_activity. + + Args: + workflow: Workflow-Objekt + message: Zu speichernde Nachricht + + Returns: + ID der hinzugefügten Nachricht + """ + current_time = datetime.now().isoformat() + + # Sicherstellen, dass Messages-Liste existiert + if "messages" not in workflow: + workflow["messages"] = [] + + # Neue Nachrichten-ID generieren, falls nicht vorhanden + if "id" not in message: + message["id"] = f"msg_{str(uuid.uuid4())}" + + # Workflow-ID und Zeitstempel hinzufügen + message["workflow_id"] = workflow["id"] + message["started_at"] = current_time + message["finished_at"] = current_time + + # Sequenznummer setzen + message["sequence_no"] = len(workflow["messages"]) + 1 + + # Status setzen + message["status"] = "completed" + + # Message zum Workflow hinzufügen + workflow["messages"].append(message) + + # Workflow-Status aktualisieren + workflow["last_activity"] = current_time + workflow["last_message_id"] = message["id"] + + # In Datenbank speichern + self.lucy_interface.create_workflow_message(message) + + return message["id"] + + async def message_summarize(self, message: Dict[str, Any], prompt: str) -> str: + """ + Erstellt eine Zusammenfassung einer Nachricht. + + Args: + message: Zu summarisierende Nachricht + prompt: Anweisungen zur Erstellung der Zusammenfassung + + Returns: + Zusammenfassung der Nachricht + """ + agent_type = message.get("agent_type", "Unbekannt") + role = message.get("role", "Unbekannt") + content = message.get("content", "") + + # Kurze Nachrichten direkt übernehmen + if len(content) < 200: + content_summary = content + else: + # Für längere Nachrichten AI verwenden + content_summary = await self.ai_service.call_api([ + {"role": "system", "content": f"Fasse den folgenden Text kurz zusammen. {prompt}"}, + {"role": "user", "content": content} + ]) + + # Dokumente zusammenfassen + docs_summary = "" + if "documents" in message and message["documents"]: + docs_list = [] + for i, doc in enumerate(message["documents"]): + doc_source = doc.get("source", {}) + doc_name = doc_source.get("name", f"Dokument {i+1}") + docs_list.append(f"- {doc_name}") + + if docs_list: + docs_summary = f"\nDokumente: {', '.join(docs_list)}" + + return f"[{role}/{agent_type}]: {content_summary}{docs_summary}" + + async def message_summarize_documents(self, message: Dict[str, Any], prompt: str) -> str: + """ + Erstellt eine Zusammenfassung der Dokumente in einer Nachricht. + + Args: + message: Nachricht mit Dokumenten + prompt: Anweisungen zur Erstellung der Zusammenfassung + + Returns: + Zusammenfassung der Dokumente + """ + if "documents" not in message or not message["documents"]: + return "Keine Dokumente vorhanden." + + summaries = [] + for i, doc in enumerate(message["documents"]): + doc_source = doc.get("source", {}) + doc_name = doc_source.get("name", f"Dokument {i+1}") + + content_summary = "Keine Inhalte verfügbar" + + if "contents" in doc and doc["contents"]: + text_contents = [] + for content in doc["contents"]: + if content.get("is_text", False) and "text" in content: + text = content["text"] + # Für kurze Texte keine Zusammenfassung notwendig + if len(text) < 200: + text_contents.append(text) + else: + # AI für Zusammenfassung verwenden + summary = self.ai_service.call_api([ + {"role": "system", "content": f"Fasse den folgenden Text kurz zusammen. {prompt}"}, + {"role": "user", "content": text} + ]) + text_contents.append(summary) + + if text_contents: + content_summary = "\n".join(text_contents) + + summaries.append(f"Dokument: {doc_name}\n{content_summary}") + + return "\n\n".join(summaries) + + + ### Documents + + def process_file_ids(self, file_ids: List[int]) -> List[Dict[str, Any]]: + """ + Verarbeitet eine Liste von File-IDs und gibt die entsprechenden Dateiobjekte als List of Document zurück + + Args: + file_ids: Liste von Datei-IDs + + Returns: + Liste von Dateiobjekten + """ + files = [] + logger.info(f"Verarbeite {len(file_ids)} Dateien") + + for file_id in file_ids: + try: + # Existiert die Datei? + file = self.lucy_interface.get_file(file_id) + if not file: + logger.warning(f"Datei mit ID {file_id} nicht gefunden") + continue + + # Prüfen, ob Datei zum aktuellen Mandanten gehört + if file.get("mandate_id") != self.mandate_id: + logger.warning(f"Datei {file_id} gehört nicht zum Mandanten {self.mandate_id}") + continue + + document = {} + + files.append(document) + logger.info(f"Datei {file.get('name', 'unbenannt')} (ID: {file_id}) hinzugefügt") + except Exception as e: + logger.error(f"Fehler bei der Verarbeitung der Datei {file_id}: {str(e)}") + # Mit restlichen Dateien fortfahren statt zu scheitern + continue + + return files + + def available_documents_get(self, message_user: Dict[str, Any], workflow: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Ermittelt alle aktuell verfügbaren Dokumente aus User-Input und bereits generierten Dokumenten. + + Args: + message_user: Aktuelle Nachricht vom Benutzer + workflow: Aktuelles Workflow-Objekt + + Returns: + Liste mit Informationen über alle verfügbaren Dokumente + """ + available_docs = [] + + # TODO ****** user input object --> spearate routine to read content from user documents (files: to store mime-type or extension? - to analyse) // separate routine to get content from messages + + # Dokumente aus der aktuellen Benutzer-Nachricht + if "documents" in message_user and message_user["documents"]: + for doc in message_user["documents"]: + source = doc.get("source", {}) + doc_info = { + "label": source.get("name", "unbenannt"), + "doc_type": source.get("content_type", "text"), + "source": "user", + "message_id": message_user.get("id", "current") + } + available_docs.append(doc_info) + + # Dokumente aus vorherigen Nachrichten im Workflow + if "messages" in workflow and workflow["messages"]: + for message in workflow["messages"]: + if "documents" in message and message["documents"]: + for doc in message["documents"]: + # Dokumente aus Inhalten extrahieren + for content in doc.get("contents", []): + if "label" in content: + doc_info = { + "label": content.get("label"), + "doc_type": content.get("type", "text"), + "source": "agent" if message.get("role") == "assistant" else "user", + "message_id": message.get("id", "unknown") + } + available_docs.append(doc_info) + + logger.info(f"Available documents: {available_docs}") + return available_docs + + def available_documents_format(self, documents: List[Dict[str, Any]]) -> str: + """ + Formatiert die Liste der verfügbaren Dokumente als lesbaren Text. + + Args: + documents: Liste mit Dokumentinformationen + + Returns: + Formatierter Text mit Dokumentinformationen + """ + if not documents: + return "Keine Dokumente verfügbar." + + formatted_text = "" + for i, doc in enumerate(documents, 1): + source_info = f"vom Benutzer" if doc.get("source") == "user" else "von einem Agenten" + formatted_text += f"{i}. '{doc.get('label')}' (Typ: {doc.get('doc_type')}, Quelle: {source_info})\n" + + return formatted_text + + def document_types_accepted(self) -> List[str]: + """ + Gibt eine Liste aller verfügbaren Dokumenttypen zurück. + + Returns: + Liste der Dokumenttypen + """ + return ['text', 'csv', 'png', 'html'] + + + ### Tools + + def log_add(self, workflow: Dict[str, Any], message: str, level: str = "info", + agent_id: Optional[str] = None, agent_name: Optional[str] = None) -> str: + """ + Fügt einen Log-Eintrag zum Workflow hinzu und loggt diesen auch im Logger. + + Args: + workflow: Workflow-Objekt + message: Log-Nachricht + level: Log-Level (info, warning, error) + agent_id: Optional - ID des Agenten + agent_name: Optional - Name des Agenten + + Returns: + ID des erstellten Log-Eintrags + """ + # Sicherstellen, dass Logs-Liste existiert + if "logs" not in workflow: + workflow["logs"] = [] + + # Log-ID generieren + log_id = f"log_{str(uuid.uuid4())}" + + # Log-Eintrag erstellen + log_entry = { + "id": log_id, + "workflow_id": workflow["id"], + "message": message, + "type": level, + "timestamp": datetime.now().isoformat(), + "agent_id": agent_id, + "agent_name": agent_name + } + + # Log zum Workflow hinzufügen + workflow["logs"].append(log_entry) + + # In Datenbank speichern + self.lucy_interface.create_workflow_log(log_entry) + + # Auch im Logger loggen + if level == "info": + logger.info(f"Workflow {workflow['id']}: {message}") + elif level == "warning": + logger.warning(f"Workflow {workflow['id']}: {message}") + elif level == "error": + logger.error(f"Workflow {workflow['id']}: {message}") + + return log_id + + def parse_json2text(self, json_obj: Any) -> str: + """ + Konvertiert ein JSON-Objekt in eine lesbare Textdarstellung. + + Args: + json_obj: Zu konvertierendes JSON-Objekt + + Returns: + Formatierte Textdarstellung + """ + if not json_obj: + return "Keine Daten vorhanden" + + try: + # Formatieren mit Einrückung für bessere Lesbarkeit + return json.dumps(json_obj, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"Fehler bei JSON-Konvertierung: {str(e)}") + return str(json_obj) + + def parse_json_response(self, response_text: str) -> Dict[str, Any]: + """ + Parst die JSON-Antwort aus einem Text. + + Args: + response_text: Text mit JSON-Inhalt + + Returns: + Geparste JSON-Daten + """ + try: + # Extrahiere JSON aus dem Text (falls mit anderen Inhalten vermischt) + json_start = response_text.find('{') + json_end = response_text.rfind('}') + 1 + + if json_start >= 0 and json_end > json_start: + json_str = response_text[json_start:json_end] + return json.loads(json_str) + else: + # Versuche den gesamten Text zu parsen + return json.loads(response_text) + except json.JSONDecodeError as e: + logger.error(f"JSON-Parse-Fehler: {str(e)}") + # Fallback: Leere Struktur zurückgeben + return { + "obj_answer": [], + "obj_workplan": [], + "user_response": "Entschuldigung, ich konnte Ihre Anfrage nicht verarbeiten. Bitte versuchen Sie es erneut." + } + + +# Singleton-Factory für den ChatManager +_chat_managers = {} + +def get_chat_manager(mandate_id: int = 0, user_id: int = 0) -> ChatManager: + """ + Gibt einen ChatManager für den angegebenen Kontext zurück. + Wiederverwendet bestehende Instanzen. + + Args: + mandate_id: ID des Mandanten + user_id: ID des Benutzers + + Returns: + ChatManager-Instanz + """ + context_key = f"{mandate_id}_{user_id}" + if context_key not in _chat_managers: + _chat_managers[context_key] = ChatManager(mandate_id, user_id) + return _chat_managers[context_key] \ No newline at end of file diff --git a/modules/chat_agent_analyst.py b/modules/chat_agent_analyst.py new file mode 100644 index 00000000..27ef1763 --- /dev/null +++ b/modules/chat_agent_analyst.py @@ -0,0 +1,786 @@ +""" +Datenanalyst-Agent für die Analyse und Interpretation von Daten. +Angepasst für die neue chat.py Architektur und chat_registry.py. +""" + +import logging +import json +import re +import uuid +import io +import base64 +from typing import Dict, Any, List, Optional +import pandas as pd +import numpy as np +import matplotlib.pyplot as plt +import seaborn as sns + +from modules.chat_registry import AgentBase + +logger = logging.getLogger(__name__) + +class AgentAnalyst(AgentBase): + """Agent für die Analyse und Interpretation von Daten""" + + def __init__(self): + """Initialisiert den Datenanalyse-Agent""" + super().__init__() + self.name = "Data Analyst" + self.capabilities = "data_analysis,pattern_recognition,statistics,visualization,data_interpretation" + self.result_format = "AnalysisReport" + + # Visualisierungseinstellungen + self.plt_style = 'seaborn-v0_8-whitegrid' + self.default_figsize = (10, 6) + self.chart_dpi = 100 + plt.style.use(self.plt_style) + + def get_agent_info(self) -> Dict[str, Any]: + """Gibt Agent-Informationen für die Registry zurück""" + info = super().get_config() + info.update({ + "metadata": { + "supported_formats": ["csv", "xlsx", "json", "text"], + "analysis_types": ["statistical", "trend", "comparative", "predictive", "clustering", "general"], + "visualization_types": ["bar", "line", "scatter", "histogram", "box", "heatmap", "pie"] + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht und führt Datenanalyse durch. + + Args: + message: Eingabenachricht + context: Optionaler Kontext + + Returns: + Antwortnachricht mit Analyseergebnissen + """ + # Workflow-ID aus Kontext oder Nachricht extrahieren + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Antwortstruktur erstellen + response = { + "role": "assistant", + "content": "", + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id, + "documents": [] + } + + try: + # Aufgabe aus Nachricht extrahieren + task = message.get("content", "") + + # Angehängte Dokumente verarbeiten und Daten extrahieren + document_context = "" + data_frames = {} + + if message.get("documents"): + logger.info("Verarbeite Dokumente für die Analyse") + document_context, data_frames = await self._process_and_extract_data(message) + + # Prüfen, ob wir analysierbare Inhalte haben + have_analyzable_content = len(data_frames) > 0 or (task and len(task.strip()) > 10) + + if not have_analyzable_content: + # Warnmeldung, wenn keine analysierbaren Inhalte vorhanden sind + if message.get("documents"): + analysis_content = "## Datenanalyse-Bericht\n\nIch konnte keine verarbeitbaren Daten in den bereitgestellten Dokumenten finden. Bitte stellen Sie sicher, dass Sie CSV-, Excel- oder andere Datendateien in einem Format beifügen, das ich analysieren kann." + else: + analysis_content = "## Datenanalyse-Bericht\n\nEs wurden keine Daten oder ausreichenden Textinhalte für die Analyse bereitgestellt. Bitte stellen Sie Text für die Analyse bereit oder fügen Sie Datendateien bei, die ich analysieren kann." + + response["content"] = analysis_content + return response + + # Analysetyp bestimmen und Analyse durchführen + analysis_type = self._determine_analysis_type(task) + logger.info(f"Führe {analysis_type}-Analyse durch") + + # Prompt mit Dokumentkontext erweitern + enhanced_prompt = self._create_enhanced_prompt(message, document_context, context) + + # Visualisierungsdokumente generieren, falls Daten vorhanden sind + visualization_documents = [] + if data_frames: + logger.info(f"Generiere Visualisierungen für {len(data_frames)} Datensätze") + visualization_documents = self._generate_visualizations(data_frames, analysis_type, workflow_id, task) + + # Visualisierungen zur Antwort hinzufügen + response["documents"].extend(visualization_documents) + + # Analyse mit Datenerkenntnissen generieren, falls Datenrahmen vorhanden sind + analysis_content = "" + if data_frames: + # Datenerkenntnisse extrahieren + data_insights = self._extract_data_insights(data_frames) + + # Erkenntnisse zum Prompt hinzufügen + enhanced_prompt += f"\n\n=== DATENERKENNTNISSE ===\n{data_insights}" + + # Analyse mit Datenerkenntnissen generieren + analysis_content = await self._generate_analysis(enhanced_prompt, analysis_type) + + # Verweise auf die Visualisierungsdokumente einfügen + if visualization_documents: + viz_references = "\n\n## Visualisierungen\n\n" + viz_references += "Die folgenden Visualisierungen wurden erstellt, um die Daten besser zu verstehen:\n\n" + + for i, doc in enumerate(visualization_documents, 1): + doc_source = doc.get("source", {}) + doc_name = doc_source.get("name", f"Visualisierung {i}") + viz_references += f"{i}. **{doc_name}** - Als angehängtes Dokument verfügbar\n" + + analysis_content += viz_references + else: + # Analyse basierend nur auf Text, wenn keine Datenrahmen vorhanden sind + logger.info("Keine Datenrahmen verfügbar, analysiere Textinhalt") + analysis_content = await self._generate_analysis(enhanced_prompt, analysis_type) + + # Inhalt in der Antwort setzen + response["content"] = analysis_content + + return response + + except Exception as e: + error_msg = f"Fehler bei der Datenanalyse: {str(e)}" + logger.error(error_msg) + response["content"] = f"## Fehler bei der Datenanalyse\n\n{error_msg}" + return response + + def _create_enhanced_prompt(self, message: Dict[str, Any], document_context: str, context: Dict[str, Any] = None) -> str: + """ + Erstellt einen erweiterten Prompt für die Analyse, der alle verfügbaren Inhalte integriert. + + Args: + message: Die ursprüngliche Nachricht + document_context: Aus Dokumenten extrahierter Kontext + context: Optionaler zusätzlicher Kontext + + Returns: + Erweiterter Prompt für die Analyse + """ + # Originale Aufgabe/Prompt abrufen + task = message.get("content", "") + + # Mit Aufgabe beginnen + enhanced_prompt = f"ANALYSEAUFGABE:\n{task}" + + # Dokumentkontext hinzufügen, falls vorhanden + if document_context: + enhanced_prompt += f"\n\n=== DOKUMENTINHALT ===\n{document_context}" + else: + # Wenn kein Dokumentinhalt vorhanden ist, ausdrücklich darauf hinweisen, dass wir den Textinhalt direkt analysieren + enhanced_prompt += "\n\nEs wurden keine Datendateien bereitgestellt. Führe eine Analyse des Textinhalts selbst durch." + + return enhanced_prompt + + async def _process_and_extract_data(self, message: Dict[str, Any]) -> tuple: + """ + Verarbeitet Dokumente und extrahiert strukturierte Daten. + + Args: + message: Eingabenachricht mit Dokumenten + + Returns: + Tuple aus (document_context, data_frames_dict) + """ + document_context = "" + data_frames = {} + + if not message.get("documents"): + return document_context, data_frames + + # Dokumenttext extrahieren + document_context = self._extract_document_text(message) + + # Datendateien identifizieren und verarbeiten (CSV, Excel usw.) + for document in message.get("documents", []): + source = document.get("source", {}) + filename = source.get("name", "") + + # Überspringen, wenn keine erkennbare Datendatei + if not self._is_data_file(filename): + continue + + try: + # Dateiinhalt aus Dokumentinhalten extrahieren + file_content = None + for content in document.get("contents", []): + if content.get("type") == "text": + file_content = content.get("text", "") + break + + # Nach Dateityp verarbeiten + if filename.lower().endswith('.csv') and file_content: + df = pd.read_csv(io.StringIO(file_content)) + df = self._preprocess_dataframe(df) + data_frames[filename] = df + + elif filename.lower().endswith(('.xlsx', '.xls')) and file_content: + # XLS-Dateien können nicht direkt aus Text verarbeitet werden + logger.warning(f"Excel-Datei {filename} kann nicht direkt aus Textinhalt verarbeitet werden") + + elif filename.lower().endswith('.json') and file_content: + try: + data = json.loads(file_content) + if isinstance(data, list): + df = pd.DataFrame(data) + elif isinstance(data, dict): + if any(isinstance(v, list) for v in data.values()): + for key, value in data.items(): + if isinstance(value, list) and len(value) > 0: + df = pd.DataFrame(value) + break + else: + continue + else: + df = pd.DataFrame([data]) + else: + continue + + df = self._preprocess_dataframe(df) + data_frames[filename] = df + except: + logger.error(f"Fehler beim Verarbeiten der JSON-Datei {filename}") + + except Exception as e: + logger.error(f"Fehler beim Verarbeiten der Datei {filename}: {str(e)}") + + return document_context, data_frames + + def _is_data_file(self, filename: str) -> bool: + """Prüft, ob eine Datei eine verarbeitbare Datendatei ist""" + if filename.lower().endswith(('.csv', '.xlsx', '.xls', '.json')): + return True + return False + + def _preprocess_dataframe(self, df: pd.DataFrame) -> pd.DataFrame: + """Führt grundlegende Vorverarbeitung für einen DataFrame durch""" + if df.empty: + return df + + # Vollständig leere Zeilen und Spalten entfernen + df = df.dropna(how='all') + df = df.dropna(axis=1, how='all') + + # Stringkonvertierung zu numerischen Werten, wo angemessen + for col in df.columns: + # Überspringen, wenn bereits numerisch + if pd.api.types.is_numeric_dtype(df[col]): + continue + + # Überspringen, wenn überwiegend nicht-numerische Strings + if df[col].dtype == 'object': + # Prüfen, ob mehr als 80% der Nicht-NA-Werte numerisch sein könnten + non_na_values = df[col].dropna() + if len(non_na_values) == 0: + continue + + # Versuch der Konvertierung zu numerischen Werten + numeric_count = pd.to_numeric(non_na_values, errors='coerce').notna().sum() + if numeric_count / len(non_na_values) > 0.8: + # Mehr als 80% können in numerische Werte konvertiert werden + df[col] = pd.to_numeric(df[col], errors='coerce') + + return df + + def _extract_document_text(self, message: Dict[str, Any]) -> str: + """ + Extrahiert Text aus Dokumenten. + + Args: + message: Eingabenachricht mit Dokumenten + + Returns: + Extrahierter Text + """ + text_content = "" + for document in message.get("documents", []): + source = document.get("source", {}) + name = source.get("name", "unnamed") + + text_content += f"\n\n--- {name} ---\n" + + for content in document.get("contents", []): + if content.get("type") == "text": + text_content += content.get("text", "") + + return text_content + + def _determine_analysis_type(self, task: str) -> str: + """ + Bestimmt den Analysetyp basierend auf der Aufgabe. + + Args: + task: Die Analyseaufgabe + + Returns: + Analysetyp + """ + task_lower = task.lower() + + # Prüfen auf statistische Analyse + if any(term in task_lower for term in ["statistik", "statistical", "mittelwert", "mean", "median", "varianz"]): + return "statistical" + + # Prüfen auf Trend-Analyse + elif any(term in task_lower for term in ["trend", "pattern", "zeitreihe", "time series", "historisch"]): + return "trend" + + # Prüfen auf vergleichende Analyse + elif any(term in task_lower for term in ["vergleich", "compare", "comparison", "versus", "vs", "unterschied"]): + return "comparative" + + # Prüfen auf prädiktive Analyse + elif any(term in task_lower for term in ["vorhersage", "predict", "forecast", "zukunft", "future"]): + return "predictive" + + # Prüfen auf Clustering oder Kategorisierung + elif any(term in task_lower for term in ["cluster", "segment", "kategorisieren", "classify"]): + return "clustering" + + # Standard: allgemeine Analyse + else: + return "general" + + def _extract_data_insights(self, data_frames: Dict[str, pd.DataFrame]) -> str: + """ + Extrahiert grundlegende Erkenntnisse aus DataFrames. + + Args: + data_frames: Dictionary von DataFrames + + Returns: + Extrahierte Erkenntnisse als Text + """ + insights = [] + + for name, df in data_frames.items(): + if df.empty: + continue + + insight = f"Datensatz: {name}\n" + insight += f"Form: {df.shape[0]} Zeilen, {df.shape[1]} Spalten\n" + insight += f"Spalten: {', '.join(df.columns.tolist())}\n" + + # Grundlegende Statistiken für numerische Spalten + numeric_cols = df.select_dtypes(include=['number']).columns + if len(numeric_cols) > 0: + insight += "Statistiken für numerische Spalten:\n" + for col in numeric_cols[:5]: # Auf die ersten 5 Spalten begrenzen + stats = df[col].describe() + insight += f" {col}: min={stats['min']:.2f}, max={stats['max']:.2f}, mean={stats['mean']:.2f}, median={df[col].median():.2f}\n" + + # Kategoriale Spaltenwerte + cat_cols = df.select_dtypes(include=['object', 'category']).columns + if len(cat_cols) > 0: + insight += "Kategoriale Spalten:\n" + for col in cat_cols[:3]: # Auf die ersten 3 Spalten begrenzen + # Top 3 Werte abrufen + top_values = df[col].value_counts().head(3) + vals_str = ", ".join([f"{val} ({count})" for val, count in top_values.items()]) + insight += f" {col}: {df[col].nunique()} eindeutige Werte. Häufigste Werte: {vals_str}\n" + + insights.append(insight) + + return "\n\n".join(insights) + + def _generate_visualizations(self, data_frames: Dict[str, pd.DataFrame], analysis_type: str, + workflow_id: str, task: str) -> List[Dict[str, Any]]: + """ + Generiert passende Visualisierungen basierend auf Daten und Analysetyp. + + Args: + data_frames: Dictionary von zu visualisierenden DataFrames + analysis_type: Durchzuführender Analysetyp + workflow_id: Workflow-ID + task: Ursprüngliche Aufgabenbeschreibung + + Returns: + Liste von Visualisierungsdokumentobjekten + """ + documents = [] + + for name, df in data_frames.items(): + if df.empty or df.shape[0] < 2: + continue # Leere oder einzeilige DataFrames überspringen + + # Verschiedene Visualisierungen basierend auf dem Analysetyp erzeugen + if analysis_type == "statistical": + viz_docs = self._create_statistical_visualizations(df, name) + documents.extend(viz_docs) + + elif analysis_type == "trend": + viz_docs = self._create_trend_visualizations(df, name) + documents.extend(viz_docs) + + elif analysis_type == "comparative": + viz_docs = self._create_comparative_visualizations(df, name) + documents.extend(viz_docs) + + else: # Allgemeine Analyse + viz_docs = self._create_general_visualizations(df, name) + documents.extend(viz_docs) + + return documents + + def _create_statistical_visualizations(self, df: pd.DataFrame, name: str) -> List[Dict[str, Any]]: + """Erstellt statistische Visualisierungen für einen DataFrame""" + documents = [] + + # 1. Verteilungs-/Histogramm-Plots für numerische Spalten + numeric_cols = df.select_dtypes(include=['number']).columns[:3] # Auf erste 3 begrenzen + if len(numeric_cols) > 0: + plt.figure(figsize=(12, 4 * len(numeric_cols))) + + for i, col in enumerate(numeric_cols, 1): + plt.subplot(len(numeric_cols), 1, i) + sns.histplot(df[col].dropna(), kde=True) + plt.title(f'Verteilung von {col}') + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_stat_dist_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Statistische Verteilungen - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + # 2. Box-Plots für numerische Spalten + if len(numeric_cols) > 0: + plt.figure(figsize=(12, 8)) + sns.boxplot(data=df[numeric_cols]) + plt.title(f'Box-Plots der numerischen Variablen in {name}') + plt.xticks(rotation=45) + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_stat_box_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Box-Plots - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + return documents + + def _create_trend_visualizations(self, df: pd.DataFrame, name: str) -> List[Dict[str, Any]]: + """Erstellt Trend-Visualisierungen für einen DataFrame""" + documents = [] + + # Numerische Spalten für die Darstellung verwenden + numeric_cols = df.select_dtypes(include=['number']).columns[:2] # Auf erste 2 begrenzen + + if len(numeric_cols) > 0: + plt.figure(figsize=(12, 6)) + + for col in numeric_cols: + plt.plot(df.index, df[col], marker='o', label=col) + + plt.title(f'Trendsicht von {", ".join(numeric_cols)} - {name}') + plt.legend() + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_trend_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Trendanalyse - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + return documents + + def _create_comparative_visualizations(self, df: pd.DataFrame, name: str) -> List[Dict[str, Any]]: + """Erstellt vergleichende Visualisierungen für einen DataFrame""" + documents = [] + + # 1. Kategoriale Spalten für Gruppierung suchen + cat_cols = df.select_dtypes(include=['object', 'category']).columns + + if len(cat_cols) > 0: + # Erste kategoriale Spalte mit angemessener Anzahl eindeutiger Werte verwenden + groupby_col = None + for col in cat_cols: + unique_count = df[col].nunique() + if 2 <= unique_count <= 10: # Angemessene Anzahl von Kategorien + groupby_col = col + break + + if groupby_col: + # Numerische Spalten für den Vergleich über Gruppen hinweg suchen + numeric_cols = df.select_dtypes(include=['number']).columns[:3] # Auf erste 3 begrenzen + + if len(numeric_cols) > 0: + # 1. Balkendiagramm, das Mittelwerte vergleicht + plt.figure(figsize=(12, 6)) + mean_by_group = df.groupby(groupby_col)[numeric_cols].mean() + mean_by_group.plot(kind='bar') + plt.title(f'Vergleich der Mittelwerte nach {groupby_col} - {name}') + plt.xticks(rotation=45) + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_comp_bar_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Mittelwertvergleich nach {groupby_col} - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + # 2. Streudiagramm für den Vergleich zweier numerischer Variablen + numeric_cols = df.select_dtypes(include=['number']).columns + if len(numeric_cols) >= 2: + plt.figure(figsize=(10, 8)) + # Erste beiden numerischen Spalten als Feature und Ziel verwenden + x_col, y_col = numeric_cols[0], numeric_cols[1] + + plt.scatter(df[x_col], df[y_col]) + plt.title(f'Vergleich von {x_col} vs {y_col} - {name}') + plt.xlabel(x_col) + plt.ylabel(y_col) + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_comp_scatter_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Variablenvergleich - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + return documents + + def _create_general_visualizations(self, df: pd.DataFrame, name: str) -> List[Dict[str, Any]]: + """Erstellt allgemeine Visualisierungen für einen DataFrame""" + documents = [] + + # 1. Datenübersicht: Numerische Zusammenfassung + numeric_cols = df.select_dtypes(include=['number']).columns + if len(numeric_cols) > 0: + # Balkendiagramm der Mittelwerte für numerische Spalten erstellen + plt.figure(figsize=(12, 6)) + means = df[numeric_cols].mean().sort_values() + means.plot(kind='bar') + plt.title(f'Mittelwerte der numerischen Variablen - {name}') + plt.xticks(rotation=45) + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_gen_means_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Zusammenfassung numerischer Variablen - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + + # 2. Übersicht über kategoriale Daten + cat_cols = df.select_dtypes(include=['object', 'category']).columns + if len(cat_cols) > 0: + # Erste kategoriale Spalte mit angemessener Kardinalität auswählen + for col in cat_cols: + if df[col].nunique() <= 10: # Angemessene Anzahl von Kategorien + plt.figure(figsize=(10, 6)) + value_counts = df[col].value_counts().sort_values(ascending=False) + value_counts.plot(kind='bar') + plt.title(f'Verteilung von {col} - {name}') + plt.xticks(rotation=45) + plt.tight_layout() + + # Abbildung speichern + img_data = self._get_figure_as_base64() + plt.close() + + # Dokument erstellen + doc_id = f"viz_gen_cat_{uuid.uuid4()}" + doc = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": f"Kategoriale Verteilung - {name}", + "content_type": "image/png" + }, + "contents": [{ + "type": "image", + "data": img_data, + "format": "base64" + }] + } + documents.append(doc) + break # Nur die erste geeignete Spalte verwenden + + return documents + + def _get_figure_as_base64(self) -> str: + """Konvertiert aktuelle matplotlib-Abbildung in base64-String""" + buffer = io.BytesIO() + plt.savefig(buffer, format='png', dpi=self.chart_dpi) + buffer.seek(0) + image_png = buffer.getvalue() + buffer.close() + + # Zu base64 konvertieren + image_base64 = base64.b64encode(image_png).decode('utf-8') + return image_base64 + + async def _generate_analysis(self, prompt: str, analysis_type: str) -> str: + """ + Generiert eine Analyse basierend auf Prompt und Analysetyp. + + Args: + prompt: Der Analyseprompt + analysis_type: Analysetyp + + Returns: + Generierte Analyse + """ + if not self.ai_service: + logging.warning("KI-Service nicht verfügbar für Analysegenerierung") + return f"## Datenanalyse ({analysis_type})\n\nAnalyse konnte nicht generiert werden: KI-Service nicht verfügbar." + + # Spezialisierten Prompt basierend auf Analysetyp erstellen + system_prompt = f""" + Du bist ein spezialisierter Datenanalyst, der auf {analysis_type}-Analysen fokussiert ist. + + Erstelle eine detaillierte Analyse der bereitgestellten Daten und/oder Textinhalte. + Deine Analyse sollte folgendes enthalten: + 1. Eine Zusammenfassung der Daten/Inhalte + 2. Wichtige Erkenntnisse und Einsichten + 3. Stützende Belege und Berechnungen + 4. Klare Schlussfolgerungen + 5. Empfehlungen, wo angemessen + + Formatiere die Analyse in Markdown mit geeigneten Überschriften, Listen und Tabellen. + """ + + # Bestimmen, ob dies eine datenbasierte oder textbasierte Analyse ist + is_data_analysis = "DATENERKENNTNISSE" in prompt + + # Prompt mit analysespezifischen Anweisungen erweitern + if is_data_analysis: + enhanced_prompt = f""" + Generiere eine detaillierte {analysis_type}-Analyse basierend auf den folgenden Daten: + + {prompt} + """ + else: + # Anweisungen für textbasierte Analyse + enhanced_prompt = f""" + Generiere eine detaillierte {analysis_type}-Analyse des folgenden Textinhalts: + + {prompt} + """ + + try: + content = await self.ai_service.call_api([ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": enhanced_prompt} + ]) + + # Sicherstellen, dass es einen Titel am Anfang gibt + if not content.strip().startswith("# "): + content = f"# {analysis_type.capitalize()}-Analyse\n\n{content}" + + return content + except Exception as e: + return f"# {analysis_type.capitalize()}-Analyse\n\nFehler bei der Analysegenerierung: {str(e)}" + +# Singleton-Instanz +_analyst_agent = None + +def get_analyst_agent(): + """Gibt eine Singleton-Instanz des Analyst-Agenten zurück""" + global _analyst_agent + if _analyst_agent is None: + _analyst_agent = AgentAnalyst() + return _analyst_agent \ No newline at end of file diff --git a/modules/chat_agent_coder.py b/modules/chat_agent_coder.py new file mode 100644 index 00000000..5249e806 --- /dev/null +++ b/modules/chat_agent_coder.py @@ -0,0 +1,804 @@ +""" +Coder-Agent für die Entwicklung und Ausführung von Python-Code. +Angepasst für die neue chat.py Architektur und chat_registry.py. +""" + +import logging +import json +import re +import uuid +import os +import subprocess +import tempfile +import shutil +import sys +from typing import Dict, Any, List, Optional, Tuple + +from modules.chat_registry import AgentBase + +logger = logging.getLogger(__name__) + + +class AgentCoder(AgentBase): + """Agent für die Entwicklung und Ausführung von Python-Code""" + + def __init__(self): + """Initialisiert den Coder-Agent""" + super().__init__() + self.name = "Python Code Agent" + self.capabilities = "code_development,data_processing,file_processing,automation" + self.result_format = "python_code" + + # Executor-Einstellungen + self.executor_timeout = 60 # Sekunden + self.executor_memory_limit = 512 # MB + + # KI-Service-Einstellungen + self.ai_temperature = 0.1 # Niedrigere Temperatur für deterministische Codegenerierung + + # Auto-Korrektur-Einstellungen + self.max_correction_attempts = 3 # Maximale Anzahl von Korrekturversuchen + + def get_agent_info(self) -> Dict[str, Any]: + """Gibt Agent-Informationen für die Registry zurück""" + info = super().get_config() + info.update({ + "metadata": { + "timeout": self.executor_timeout, + "memory_limit": self.executor_memory_limit, + "max_correction_attempts": self.max_correction_attempts + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht zur Entwicklung und Ausführung von Python-Code. + + Args: + message: Die zu verarbeitende Nachricht + context: Zusätzliche Kontextinformationen + + Returns: + Antwortnachricht + """ + # Workflow-ID aus Kontext oder Nachricht extrahieren + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Antwortstruktur erstellen + response = { + "role": "assistant", + "content": "", + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id, + "documents": [] + } + + try: + # Inhalt und Dokumente extrahieren + content = message.get("content", "") + documents = message.get("documents", []) + + # Code basierend auf dem Nachrichteninhalt mit KI generieren + logger.info("Generiere neuen Code mit KI") + + # Code mit KI generieren + code_to_execute, requirements = await self._generate_code_from_prompt(content, documents) + if not code_to_execute: + logger.warning("KI konnte keinen Code generieren") + response["content"] = "Ich konnte basierend auf Ihrer Anfrage keinen ausführbaren Code generieren. Bitte geben Sie detailliertere Anweisungen." + return response + + logger.info(f"Code mit KI generiert ({len(code_to_execute)} Zeichen)") + + # Code-Datei-Dokument erstellen + code_doc_id = f"code_{uuid.uuid4()}" + code_filename = "generated_code.py" + + code_document = { + "id": code_doc_id, + "source": { + "type": "generated", + "id": code_doc_id, + "name": code_filename, + "content_type": "text/x-python" + }, + "contents": [{ + "type": "text", + "text": code_to_execute, + "is_extracted": True + }] + } + + # Code-Dokument zur Antwort hinzufügen + response["documents"].append(code_document) + logger.info(f"Code-Datei '{code_filename}' zur Antwort hinzugefügt") + + # Code mit Auto-Korrektur-Schleife ausführen + if code_to_execute: + # Ausführungskontext vorbereiten + execution_context = { + "workflow_id": workflow_id, + "documents": documents, + "message": message + } + + # Verbesserte Ausführung mit Auto-Korrektur + result, attempts_info = await self._execute_with_auto_correction( + code_to_execute, + requirements, + execution_context, + content # Originaler Prompt/Nachricht + ) + + # Antwort basierend auf dem endgültigen Ergebnis vorbereiten (Erfolg oder Fehler) + if result.get("success", False): + # Code-Ausführung erfolgreich + output = result.get("output", "") + execution_result = result.get("result") + logger.info("Code erfolgreich ausgeführt") + + # Antwortinhalt formatieren + response_content = f"## Code erfolgreich ausgeführt" + + # Informationen zu Korrekturversuchen hinzufügen, falls Korrekturen vorgenommen wurden + if attempts_info and len(attempts_info) > 1: + response_content += f" (nach {len(attempts_info)-1} Korrekturversuchen)" + + response_content += "\n\n" + + # Den ausgeführten Code einbeziehen + response_content += f"### Ausgeführter Code\n\n```python\n{attempts_info[-1]['code']}\n```\n\n" + + # Die Ausgabe einbeziehen, falls verfügbar + if output: + response_content += f"### Ausgabe\n\n```\n{output}\n```\n\n" + + # Das Ausführungsergebnis einbeziehen, falls verfügbar + if execution_result: + result_str = json.dumps(execution_result, indent=2) if isinstance(execution_result, (dict, list)) else str(execution_result) + response_content += f"### Ergebnis\n\n```\n{result_str}\n```\n\n" + + response["content"] = response_content + + else: + # Code-Ausführung nach allen Versuchen fehlgeschlagen + error = result.get("error", "Unbekannter Fehler") + logger.error(f"Fehler bei der Code-Ausführung nach allen Korrekturversuchen: {error}") + + # Fehlerantwort formatieren + response_content = f"## Fehler bei der Code-Ausführung\n\n" + + # Informationen zu Korrekturversuchen hinzufügen + if attempts_info: + response_content += f"Ich habe {len(attempts_info)} Versuche unternommen, den Code zu korrigieren, konnte aber nicht alle Probleme lösen.\n\n" + + # Den letzten Versuch hinzufügen + response_content += f"### Letzter Code-Versuch\n\n```python\n{attempts_info[-1]['code']}\n```\n\n" + response_content += f"### Letzter Fehler\n\n```\n{attempts_info[-1]['error']}\n```\n\n" + + # Empfehlung basierend auf dem Fehler hinzufügen + response_content += "### Empfehlung\n\n" + response_content += self._get_error_recommendation(error) + else: + # Nur den Code und den Fehler anzeigen + response_content += f"### Ausgeführter Code\n\n```python\n{code_to_execute}\n```\n\n" + response_content += f"### Fehler\n\n```\n{error}\n```\n\n" + + response["content"] = response_content + else: + # Kein auszuführender Code + response["content"] = "Ich konnte keinen ausführbaren Code finden oder generieren. Bitte geben Sie Python-Code an oder erklären Sie Ihre Anforderungen klarer." + + return response + + except Exception as e: + error_msg = f"Fehler bei der Verarbeitung durch den Coder-Agent: {str(e)}" + logger.error(error_msg) + response["content"] = f"## Verarbeitungsfehler\n\n```\n{error_msg}\n```" + return response + + async def _execute_with_auto_correction( + self, + initial_code: str, + requirements: List[str], + context: Dict[str, Any], + original_prompt: str + ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + """ + Führt Code mit automatischer Fehlerkorrektur und Wiederholungsversuchen aus. + + Args: + initial_code: Der anfängliche Python-Code + requirements: Liste erforderlicher Pakete + context: Zusätzlicher Kontext für die Ausführung + original_prompt: Die ursprüngliche Benutzeranfrage/Prompt + + Returns: + Tuple aus (endgültiges Ausführungsergebnis, Liste von Versuchsinfo-Dictionarys) + """ + # Verfolgungs-Daten initialisieren + current_code = initial_code + current_requirements = requirements.copy() if requirements else [] + attempts_info = [] + + # Mit Korrekturschleife ausführen + for attempt in range(1, self.max_correction_attempts + 1): + if attempt == 1: + logger.info(f"Führe Code aus (Versuch {attempt}/{self.max_correction_attempts})") + else: + logger.info(f"Führe korrigierten Code aus (Versuch {attempt}/{self.max_correction_attempts})") + + # Aktuelle Code-Version ausführen + result = await self._execute_code(current_code, current_requirements, context) + + # Versuchsinformationen aufzeichnen + attempts_info.append({ + "attempt": attempt, + "code": current_code, + "error": result.get("error", ""), + "success": result.get("success", False) + }) + + # Prüfen, ob die Ausführung erfolgreich war + if result.get("success", False): + # Erfolg! Ergebnis und Versuchsinfo zurückgeben + return result, attempts_info + + # Fehlgeschlagene Ausführung - prüfen, ob die maximale Versuchsgrenze erreicht wurde + if attempt >= self.max_correction_attempts: + logger.warning(f"Maximale Korrekturversuche ({self.max_correction_attempts}) erreicht, Aufgabe") + break + + # Code basierend auf dem Fehler korrigieren + error_message = result.get("error", "Unbekannter Fehler") + + logger.info(f"Versuche, Code-Fehler zu beheben: {error_message[:200]}...") + + # Korrigierten Code generieren + corrected_code, new_requirements = await self._generate_code_correction( + current_code, + error_message, + original_prompt, + current_requirements + ) + + # Für den nächsten Versuch aktualisieren + if corrected_code: + current_code = corrected_code + + # Neue Anforderungen hinzufügen + if new_requirements: + for req in new_requirements: + if req not in current_requirements: + current_requirements.append(req) + logger.info(f"Neue Anforderung hinzugefügt: {req}") + else: + # Korrektur konnte nicht generiert werden, Schleife beenden + logger.warning("Konnte keine Code-Korrektur generieren, Aufgabe") + break + + # Wenn wir hierher gelangen, sind alle Versuche fehlgeschlagen - das letzte Ergebnis und die Versuchsinfo zurückgeben + return result, attempts_info + + async def _generate_code_correction( + self, + code: str, + error_message: str, + original_prompt: str, + current_requirements: List[str] = None + ) -> Tuple[str, List[str]]: + """ + Generiert eine korrigierte Version des Codes basierend auf Fehlermeldungen. + + Args: + code: Der Code, der Fehler erzeugt hat + error_message: Die zu behebende Fehlermeldung + original_prompt: Die ursprüngliche Aufgabe/Anforderungen + current_requirements: Liste der aktuell erforderlichen Pakete + + Returns: + Tuple aus (korrigierter Code, neue Anforderungsliste) + """ + try: + # Detaillierten Prompt für Code-Korrektur erstellen + correction_prompt = f"""Du musst einen Fehler in Python-Code beheben. Der Code wurde für diese Aufgabe geschrieben: + +ORIGINALE AUFGABE: +{original_prompt} + +AKTUELLER CODE: +```python +{code} +``` + +FEHLERMELDUNG: +``` +{error_message} +``` + +AKTUELLE ANFORDERUNGEN: {', '.join(current_requirements) if current_requirements else "Keine"} + +Deine Aufgabe ist es, den Fehler zu analysieren und eine korrigierte Version des Codes bereitzustellen. +Konzentriere dich speziell auf die Behebung des Fehlers unter Beibehaltung der ursprünglichen Funktionalität. + +Häufige Korrekturen sind: +- Behebung von Syntaxfehlern (fehlende Klammern, Einrückung usw.) +- Lösung von Import-Fehlern durch Hinzufügen geeigneter Anforderungen +- Korrektur von Dateipfaden oder Behandlung von "Datei nicht gefunden"-Fehlern +- Hinzufügen von Fehlerbehandlung für bestimmte Randfälle +- Behebung logischer Fehler im Code + +FORMATIERUNGSHINWEISE: +1. Gib NUR den vollständigen korrigierten Python-Code an OHNE Erklärungen +2. Verwende KEINE Codeblock-Markierungen wie ```python oder ``` +3. Erkläre NICHT, was der Code davor oder danach tut +4. Füge KEINEN Text hinzu, der kein gültiger Python-Code ist +5. Beginne deine Antwort direkt mit dem gültigen Python-Code +6. Beende deine Antwort mit gültigem Python-Code + +Wenn du neue erforderliche Pakete hinzufügen musst, platziere sie in einem speziell formatierten Kommentar am Anfang deines Codes wie folgt: +# REQUIREMENTS: paket1,paket2,paket3 + +Deine gesamte Antwort muss gültiges Python sein, das ohne Änderungen ausgeführt werden kann. +""" + + # Nachrichten für die API erstellen + messages = [ + {"role": "system", "content": "Du bist ein Python-Debugging-Experte. Du gibst NUR sauberen, fehlerfreien Python-Code zurück, ohne Erklärungen, Markdown-Formatierung oder Text, der kein Code ist. Deine Antwort sollte nur gültiger, korrigierter Python-Code sein, der direkt ausgeführt werden kann."}, + {"role": "user", "content": correction_prompt} + ] + + # API mit sehr niedriger Temperatur für deterministische Korrekturen aufrufen + generated_content = await self.ai_service.call_api( + messages, + temperature=0.1 + ) + + # Den generierten Inhalt bereinigen, um sicherzustellen, dass es sich nur um gültigen Python-Code handelt + fixed_code = self._clean_code(generated_content) + + # Anforderungen aus speziellem Kommentar am Anfang des Codes extrahieren + new_requirements = [] + for line in fixed_code.split('\n'): + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + new_requirements = [r.strip() for r in req_str.split(',') if r.strip()] + break + + return fixed_code, new_requirements + + except Exception as e: + logging.error(f"Fehler bei der Generierung der Code-Korrektur: {str(e)}") + # None zurückgeben, um Fehler anzuzeigen + return None, [] + + def _clean_code(self, code: str) -> str: + """ + Bereinigt Code durch Entfernen von Markdown-Codeblock-Markierungen und anderen Formatierungsartefakten. + + Args: + code: Der zu bereinigende Code-String + + Returns: + Bereinigter Code-String + """ + # Codeblock-Markierungen am Anfang/Ende entfernen + code = re.sub(r'^```(?:python)?\s*', '', code) + code = re.sub(r'```\s*$', '', code) + + # Zeilen in umgekehrter Reihenfolge durchgehen, um dem Ende zu beginnen + lines = code.split('\n') + clean_lines = [] + in_trailing_markdown = False + + for line in reversed(lines): + stripped = line.strip() + + # Prüfen, ob diese Zeile nur Backticks enthält (``` oder ` oder ``) + if re.match(r'^`{1,3}$', stripped): + in_trailing_markdown = True + continue + + # Wenn wir tatsächlichen Code erreicht haben, keine nachfolgende Markdown-Berücksichtigung mehr + if stripped and not in_trailing_markdown: + in_trailing_markdown = False + + # Diese Zeile hinzufügen, wenn sie nicht Teil von nachfolgendem Markdown ist + if not in_trailing_markdown: + clean_lines.insert(0, line) + + # Zeilen wieder zusammenfügen + clean_code = '\n'.join(clean_lines) + + # Endgültige Bereinigung für alle restlichen Backticks + clean_code = re.sub(r'`{1,3}\s*, ', clean_code) + + return clean_code.strip() + + async def _generate_code_from_prompt(self, prompt: str, documents: List[Dict[str, Any]]) -> Tuple[str, List[str]]: + """ + Generiert Python-Code aus einem Prompt mithilfe des KI-Dienstes. + + Args: + prompt: Der Prompt, aus dem Code generiert wird + documents: Mit dem Prompt verbundene Dokumente + + Returns: + Tuple aus (generierter Python-Code, erforderliche Pakete) + """ + try: + # Prompt für die Codegenerierung vorbereiten + ai_prompt = f"""Generiere Python-Code, um die folgende Aufgabe zu lösen: +{prompt} + +Verfügbare Eingabedateien: +""" + # Informationen über verfügbare Dokumente hinzufügen + if documents: + for i, doc in enumerate(documents): + source = doc.get("source", {}) + doc_name = source.get("name", f"Dokument {i+1}") + doc_type = source.get("content_type", "unbekannt") + doc_id = source.get("id", "") + + ai_prompt += f"- {doc_name} (Typ: {doc_type}, ID: {doc_id})\n" + else: + ai_prompt += "Keine Eingabedateien verfügbar.\n" + + ai_prompt += """ +WICHTIGE ANFORDERUNGEN: +1. Dein Code MUSS eine 'result'-Variable definieren, um das endgültige Ergebnis zu speichern. +2. Am Ende deines Skripts sollte die result-Variable ausgegeben werden. +3. Mache deine 'result'-Variable zu einem Dictionary oder einer anderen JSON-serialisierbaren Datenstruktur, die alle relevanten Ausgaben enthält. +4. Kommentiere den Code gut, um wichtige Operationen zu erklären. +5. Mache deinen Code vollständig und in sich geschlossen. +6. Füge eine angemessene Fehlerbehandlung hinzu. + +FORMATIERUNGSANWEISUNGEN: +- Gib NUR den Python-Code zurück, OHNE Einleitung, Erklärung oder Abschlusstext +- Verwende KEINE Codeblock-Markierungen wie ```python oder ``` +- Erkläre NICHT, was der Code davor oder danach tut +- Füge KEINEN Text hinzu, der kein gültiger Python-Code ist +- Beginne deine Antwort direkt mit gültigem Python-Code +- Beende deine Antwort mit gültigem Python-Code + +Für erforderliche Pakete platziere sie in einem speziell formatierten Kommentar am Anfang deines Codes in einer Zeile wie folgt: +# REQUIREMENTS: pandas,numpy,matplotlib,requests + +Deine gesamte Antwort muss gültiges Python sein, das ohne Änderungen ausgeführt werden kann. +""" + + # Nachrichten für die API erstellen + messages = [ + {"role": "system", "content": "Du bist ein Python-Codegenerator, der NUR sauberen, ausführbaren Python-Code ohne Erklärungen, Markdown-Formatierung oder Nicht-Code-Text liefert. Deine Antwort sollte ausschließlich aus gültigem Python-Code bestehen, der direkt ausgeführt werden kann."}, + {"role": "user", "content": ai_prompt} + ] + + # API aufrufen + logging.info(f"KI-API aufrufen, um Code zu generieren") + generated_content = await self.ai_service.call_api(messages, temperature=self.ai_temperature) + + # Den generierten Inhalt bereinigen, um sicherzustellen, dass es sich nur um gültigen Python-Code handelt + code = self._clean_code(generated_content) + + # Anforderungen aus speziellem Kommentar am Anfang des Codes extrahieren + requirements = [] + for line in code.split('\n'): + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + requirements = [r.strip() for r in req_str.split(',') if r.strip()] + break + + return code, requirements + + except Exception as e: + logging.error(f"Fehler bei der Generierung von Code mit KI: {str(e)}") + # Grundlegenden Fehlerbehandlungscode und keine Anforderungen zurückgeben + error_str = str(e).replace('"', '\\"') + return f""" +# Fehler bei der Codegenerierung +print(f"Bei der Codegenerierung ist ein Fehler aufgetreten: {error_str}") +# Fehlerergebnis zurückgeben +result = {{"error": "Codegenerierung fehlgeschlagen", "message": "{error_str}"}} +""", [] + + async def _execute_code(self, code: str, requirements: List[str] = None, context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Führt Python-Code mit dem SimpleCodeExecutor aus. + + Args: + code: Der auszuführende Python-Code + requirements: Liste erforderlicher Pakete + context: Zusätzlicher Kontext für die Ausführung + + Returns: + Ergebnis der Codeausführung + """ + # Workflow-ID abrufen und Logging einrichten + workflow_id = context.get("workflow_id", "") if context else "" + + try: + # Liste blockierter Pakete für die Sicherheit + blocked_packages = [ + "cryptography", "flask", "django", "tornado", # Sicherheitsrisiken + "tensorflow", "pytorch", "scikit-learn" # Ressourcenintensive Pakete + ] + + # SimpleCodeExecutor mit Anforderungen und workflow_id für Persistenz initialisieren + executor = SimpleCodeExecutor( + workflow_id=workflow_id, + timeout=self.executor_timeout, + max_memory_mb=self.executor_memory_limit, + requirements=requirements, + blocked_packages=blocked_packages, + ai_service=self.ai_service + ) + + # Eingabedaten für den Code vorbereiten + input_data = {"context": context, "workflow_id": workflow_id} + + # Code ausführen + result = executor.execute_code(code, input_data) + + # Nicht-persistente Umgebungen bereinigen + if not executor.is_persistent: + executor.cleanup() + + return result + + except Exception as e: + error_message = f"Fehler bei der Codeausführung: {str(e)}" + logger.error(error_message) + + return { + "success": False, + "output": "", + "error": error_message, + "result": None + } + + def _get_error_recommendation(self, error_message: str) -> str: + """Generiert Empfehlungen basierend auf der Fehlermeldung.""" + if "ImportError" in error_message or "ModuleNotFoundError" in error_message: + return """ +Versuchen Sie, Standardbibliotheken oder häufig verwendete Datenanalysemodule zu verwenden. +""" + elif "PermissionError" in error_message: + return """ +Der Code hat nicht die notwendigen Berechtigungen, um auf Dateien oder Verzeichnisse zuzugreifen. +""" + elif "SyntaxError" in error_message: + return """ +Es gibt einen Syntaxfehler im Code. Überprüfen Sie auf fehlende Klammern, Anführungszeichen, Doppelpunkte oder Einrückungsfehler. +""" + elif "FileNotFoundError" in error_message: + return """ +Eine Datei konnte nicht gefunden werden. Überprüfen Sie den Dateipfad und stellen Sie sicher, dass die Datei existiert. +""" + else: + return """ +Um den Fehler zu beheben: +1. Überprüfen Sie die genaue Fehlermeldung +2. Vereinfachen Sie den Code und testen Sie schrittweise +3. Verwenden Sie try/except-Blöcke für fehleranfällige Operationen +""" + + +class SimpleCodeExecutor: + """ + Ein vereinfachter Executor, der Python-Code in isolierten virtuellen Umgebungen ausführt. + """ + + # Klassenvariable zum Speichern von Workflow-Umgebungen für die Persistenz + _workflow_environments = {} + + def __init__(self, + workflow_id: str = None, + timeout: int = 30, + max_memory_mb: int = 512, + requirements: List[str] = None, + blocked_packages: List[str] = None, + ai_service = None): + """ + Initialisiert den SimpleCodeExecutor. + + Args: + workflow_id: Optionale Workflow-ID für persistente Umgebungen + timeout: Maximale Ausführungszeit in Sekunden + max_memory_mb: Maximaler Speicher in MB + requirements: Liste der zu installierenden Pakete + blocked_packages: Liste blockierter Pakete + """ + self.workflow_id = workflow_id + self.timeout = timeout + self.max_memory_mb = max_memory_mb + self.temp_dir = None + self.requirements = requirements or [] + self.blocked_packages = blocked_packages or [ + "cryptography", "flask", "django", "tornado", # Sicherheitsrisiken + "tensorflow", "pytorch", "scikit-learn" # Ressourcenintensive Pakete + ] + self.is_persistent = workflow_id is not None + self.ai_service = ai_service + + def _create_venv(self) -> str: + """Erstellt eine virtuelle Umgebung und gibt den Pfad zurück.""" + # Prüfen auf bestehende Umgebung bei Verwendung von workflow_id + if self.workflow_id: + self.is_persistent = True + existing_env = self._workflow_environments.get(self.workflow_id) + if existing_env and os.path.exists(existing_env): + logger.info(f"Wiederverwendung bestehender virtueller Umgebung: {existing_env}") + self.temp_dir = os.path.dirname(existing_env) + return existing_env + + # Neue Umgebung erstellen + venv_parent_dir = tempfile.mkdtemp(prefix="code_exec_") + self.temp_dir = venv_parent_dir + venv_path = os.path.join(venv_parent_dir, "venv") + + try: + # Virtuelle Umgebung erstellen + subprocess.run([sys.executable, "-m", "venv", venv_path], + check=True, + capture_output=True) + + # Umgebungspfad speichern, wenn für einen bestimmten Workflow + if self.workflow_id: + self._workflow_environments[self.workflow_id] = venv_path + + return venv_path + except subprocess.CalledProcessError as e: + logger.error(f"Fehler beim Erstellen der virtuellen Umgebung: {e}") + raise RuntimeError(f"Virtuelle Umgebung konnte nicht erstellt werden: {e}") + + def _get_python_executable(self, venv_path: str) -> str: + """Gibt den Pfad zum Python-Executable in der virtuellen Umgebung zurück.""" + if os.name == 'nt': # Windows + return os.path.join(venv_path, "Scripts", "python.exe") + else: # Unix/Linux + return os.path.join(venv_path, "bin", "python") + + def _extract_required_packages(self, code: str) -> List[str]: + """Extrahiert erforderliche Pakete aus REQUIREMENTS-Kommentaren in der ersten Codezeile""" + packages = set() + # Nach speziellem REQUIREMENTS-Kommentar suchen + first_lines = code.split('\n')[:5] # Nur die ersten Zeilen prüfen + for line in first_lines: + if line.strip().startswith("# REQUIREMENTS:"): + req_str = line.replace("# REQUIREMENTS:", "").strip() + for pkg in req_str.split(','): + if pkg.strip(): + packages.add(pkg.strip()) + return list(packages) + + def execute_code(self, code: str, input_data: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Führt Python-Code in einer isolierten Umgebung aus. + + Args: + code: Auszuführender Python-Code + input_data: Optionale Eingabedaten für den Code + + Returns: + Dictionary mit Ausführungsergebnissen + """ + logger.info(f"Führe Code mit workflow_id aus: {self.workflow_id}") + + # Virtuelle Umgebung erstellen oder wiederverwenden + venv_path = self._create_venv() + + # Datei für den Code erstellen + code_id = uuid.uuid4().hex[:8] + code_file = os.path.join(self.temp_dir, f"code_{code_id}.py") + + # Code ohne zusätzlichen Loader-Code schreiben + with open(code_file, "w", encoding="utf-8") as f: + f.write(code) + + # Python-Executable holen + python_executable = self._get_python_executable(venv_path) + logger.info(f"Verwende Python-Executable: {python_executable}") + + # Code ausführen + try: + # Code aus Root-Verzeichnis ausführen + working_dir = os.path.dirname(code_file) + process = subprocess.run( + [python_executable, code_file], + timeout=self.timeout, + capture_output=True, + text=True, + cwd=working_dir + ) + + # Ausgabe verarbeiten + stdout = process.stdout + stderr = process.stderr + + # Ergebnis aus stdout holen, falls verfügbar + result_data = None + if process.returncode == 0 and stdout: + try: + # Nach der letzten Zeile suchen, die JSON sein könnte + for line in reversed(stdout.strip().split('\n')): + line = line.strip() + if line and line[0] in '{[' and line[-1] in '}]': + try: + result_data = json.loads(line) + # Erfolgreich geparste JSON-Ergebnis verwenden + break + except json.JSONDecodeError: + # Kein gültiges JSON, mit nächster Zeile fortfahren + continue + except Exception as e: + logger.warning(f"Fehler beim Parsen des Ergebnisses aus stdout: {str(e)}") + + # Ergebnisdictionary erstellen + execution_result = { + "success": process.returncode == 0, + "output": stdout, + "error": stderr if process.returncode != 0 else "", + "result": result_data, + "exit_code": process.returncode + } + + except subprocess.TimeoutExpired: + logger.error(f"Ausführung nach {self.timeout} Sekunden abgelaufen") + execution_result = { + "success": False, + "output": "", + "error": f"Ausführung abgelaufen (Timeout nach {self.timeout} Sekunden)", + "result": None, + "exit_code": -1 + } + except Exception as e: + logger.error(f"Ausführungsfehler: {str(e)}") + execution_result = { + "success": False, + "output": "", + "error": f"Ausführungsfehler: {str(e)}", + "result": None, + "exit_code": -1 + } + + # Temporäre Code-Datei bereinigen + try: + if os.path.exists(code_file): + os.remove(code_file) + except Exception as e: + logger.warning(f"Fehler beim Bereinigen der temporären Code-Datei: {e}") + + return execution_result + + def cleanup(self): + """Temporäre Ressourcen bereinigen.""" + # Bereinigung für persistente Umgebungen überspringen + if self.is_persistent and self.workflow_id: + logger.info(f"Überspringe Bereinigung für persistente Umgebung von Workflow {self.workflow_id}") + return + + # Temporäres Verzeichnis bereinigen + if self.temp_dir and os.path.exists(self.temp_dir): + try: + shutil.rmtree(self.temp_dir) + logger.info(f"Temporäres Verzeichnis gelöscht: {self.temp_dir}") + except Exception as e: + logger.warning(f"Temporäres Verzeichnis {self.temp_dir} konnte nicht gelöscht werden: {e}") + + def __del__(self): + """Bereinigung während der Garbage Collection.""" + self.cleanup() + + +# Singleton-Instanz +_coder_agent = None + +def get_coder_agent(): + """Gibt eine Singleton-Instanz des Coder-Agenten zurück""" + global _coder_agent + if _coder_agent is None: + _coder_agent = AgentCoder() + return _coder_agent \ No newline at end of file diff --git a/modules/chat_agent_creative.py b/modules/chat_agent_creative.py new file mode 100644 index 00000000..22e3f04b --- /dev/null +++ b/modules/chat_agent_creative.py @@ -0,0 +1,131 @@ +""" +Kreativer Agent für wissensbasierte Antworten und kreative Inhaltsgenerierung. +Angepasst für die neue chat.py Architektur und chat_registry.py. +""" + +import logging +from typing import Dict, Any, List, Optional + +from modules.chat_registry import AgentBase + +logger = logging.getLogger(__name__) + +class AgentCreative(AgentBase): + """Agent für wissensbasierte Antworten und kreative Inhaltsgenerierung""" + + def __init__(self): + """Initialisiert den kreativen Agent""" + super().__init__() + self.name = "Creative Knowledge Assistant" + self.capabilities = ("knowledge_sharing,content_creation,document_generation," + "creative_writing,poweron,document_processing," + "information_extraction,data_transformation," + "document_analysis,text_processing,table_creation," + "content_structuring") + + self.result_format = "Text,Document,Table" + + def get_agent_info(self) -> Dict[str, Any]: + """Gibt Agent-Informationen für die Registry zurück""" + info = super().get_config() + info.update({ + "metadata": { + "specialties": [ + "creative_writing", + "documentation", + "knowledge", + "poweron", + "document_processing", + "information_extraction", + "content_transformation", + "table_generation", + "document_analysis" + ] + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht und generiert eine kreative oder wissensbasierte Antwort. + + Args: + message: Die zu verarbeitende Nachricht + context: Zusätzlicher Kontext + + Returns: + Die generierte Antwort + """ + # Workflow-ID aus Kontext oder Nachricht extrahieren + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Antwortstruktur erstellen + response = { + "role": "assistant", + "content": "", + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id, + "documents": [] + } + + try: + # Benutzernachricht extrahieren + user_message = message.get("content", "") + + if not user_message: + response["content"] = "Bitte geben Sie eine Nachricht an, auf die ich antworten kann." + return response + + # PowerOn-Behandlung, falls in der Anfrage enthalten + if "poweron" in user_message.lower(): + logger.info("PowerOn-Schlüsselwort erkannt, spezielle Antwort generieren") + + poweron_prompt = f""" + Bedanke dich beim Benutzer in der Sprache seiner Anfrage ganz herzlich dafür, dass er daran denkt, dass du PowerOn bist. + Teile ihm mit, wie erfreut du bist, Teil der PowerOn-Familie zu sein, die daran arbeitet, Menschen für ein besseres Leben zu unterstützen. + + Generiere dann eine kurze Antwort (1-2 Sätze) auf diese Frage: {user_message} + """ + + try: + poweron_response = await self.ai_service.call_api([ + {"role": "system", "content": "Du bist ein hilfreicher Assistent, der Teil der PowerOn-Familie ist."}, + {"role": "user", "content": poweron_prompt} + ]) + + response["content"] = poweron_response + return response + except Exception as e: + logger.error(f"Fehler beim Aufruf der API für PowerOn: {str(e)}") + response["content"] = "Ich bin auf einen Fehler gestoßen, während ich eine PowerOn-Antwort generierte. Bitte versuchen Sie es erneut." + return response + + # Einfacher Systemprompt, der sich auf die direkte Antwort auf die Benutzeranfrage konzentriert + system_prompt = """Du bist ein hilfreicher, kreativer Assistent. + Antworte direkt auf die Anfrage des Benutzers, ohne auf einen Workflow oder Systemkontext zu verweisen. + Konzentriere dich nur darauf, eine direkte, hilfreiche Antwort auf die spezifische Frage oder Anfrage zu geben.""" + + # Verarbeiten mit dem KI-Service + content = await self.ai_service.call_api([ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message} + ]) + + response["content"] = content + return response + + except Exception as e: + logger.error(f"Fehler in process_message: {str(e)}") + response["content"] = f"Bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten: {str(e)}" + return response + +# Singleton-Instanz +_creative_agent = None + +def get_creative_agent(): + """Gibt eine Singleton-Instanz des kreativen Agenten zurück""" + global _creative_agent + if _creative_agent is None: + _creative_agent = AgentCreative() + return _creative_agent \ No newline at end of file diff --git a/modules/chat_agent_documentation.py b/modules/chat_agent_documentation.py new file mode 100644 index 00000000..de97bf1c --- /dev/null +++ b/modules/chat_agent_documentation.py @@ -0,0 +1,320 @@ +""" +Dokumentations-Agent für die Erstellung von Dokumentation, Berichten und strukturierten Inhalten. +Angepasst für die neue chat.py Architektur und chat_registry.py. +""" + +import logging +import json +import uuid +from typing import Dict, Any, List +from datetime import datetime + +from modules.chat_registry import AgentBase + +logger = logging.getLogger(__name__) + +class AgentDocumentation(AgentBase): + """Agent für die Erstellung von Dokumentation und strukturierten Inhalten""" + + def __init__(self): + """Initialisiert den Dokumentations-Agent""" + super().__init__() + self.name = "Documentation Specialist" + self.capabilities = "report_generation,documentation,content_structuring,technical_writing,knowledge_organization" + self.result_format = "FormattedDocument" + + def get_agent_info(self) -> Dict[str, Any]: + """Gibt Agent-Informationen für die Registry zurück""" + info = super().get_config() + info.update({ + "metadata": { + "document_types": ["manual", "report", "process", "presentation", "document"], + "formats": ["markdown", "text"] + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht und erstellt Dokumentation. + + Args: + message: Eingabenachricht + context: Optionaler Kontext + + Returns: + Antwortnachricht mit Dokumentation + """ + # Workflow-ID aus Kontext oder Nachricht extrahieren + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Antwortstruktur erstellen + response = { + "role": "assistant", + "content": "", + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id, + "documents": [] + } + + try: + # Aufgabe aus Nachricht extrahieren + task = message.get("content", "") + + # Dokumenttyp erkennen + document_type = self._detect_document_type(task) + logger.info(f"Erstelle {document_type}-Dokumentation") + + # Angehängte Dokumente verarbeiten + document_context = "" + if message.get("documents"): + logger.info("Verarbeite Referenzdokumente") + document_context = self._process_documents(message) + + # Prompt mit Dokumentkontext erweitern + enhanced_prompt = f"{task}\n\n{document_context}" if document_context else task + + # Komplexität bewerten + is_complex = self._assess_complexity(enhanced_prompt) + + # Titel generieren + title = self._generate_title(enhanced_prompt, document_type) + + # Inhalt basierend auf Komplexität generieren + if is_complex: + content = await self._generate_complex_document(enhanced_prompt, document_type, title) + else: + content = await self._generate_simple_document(enhanced_prompt, document_type, title) + + # Dokument erstellen + doc_id = f"doc_{uuid.uuid4()}" + document = { + "id": doc_id, + "source": { + "type": "generated", + "id": doc_id, + "name": title, + "content_type": "text/markdown" + }, + "contents": [ + { + "type": "text", + "text": content, + "is_extracted": True + } + ] + } + + # Dokument zur Antwort hinzufügen + response["documents"].append(document) + + # Antwortinhalt aktualisieren + response["content"] = f"Ich habe ein Dokument mit dem Titel '{title}' erstellt, das die gewünschten Informationen enthält. Das Dokument ist dieser Nachricht beigefügt." + + return response + + except Exception as e: + error_msg = f"Fehler bei der Dokumentationserstellung: {str(e)}" + logger.error(error_msg) + response["content"] = f"Bei der Erstellung der Dokumentation ist ein Fehler aufgetreten: {str(e)}" + return response + + def _detect_document_type(self, message: str) -> str: + """ + Erkennt den Dokumenttyp aus der Nachricht. + + Args: + message: Benutzernachricht + + Returns: + Erkannter Dokumenttyp + """ + message = message.lower() + + if any(term in message for term in ["manual", "guide", "instruction", "tutorial", "anleitung", "handbuch"]): + return "manual" + elif any(term in message for term in ["report", "analysis", "assessment", "review", "bericht", "analyse"]): + return "report" + elif any(term in message for term in ["process", "workflow", "procedure", "steps", "prozess", "ablauf"]): + return "process" + elif any(term in message for term in ["presentation", "slides", "deck", "präsentation", "folien"]): + return "presentation" + else: + return "document" + + def _process_documents(self, message: Dict[str, Any]) -> str: + """ + Verarbeitet Dokumente in der Nachricht. + + Args: + message: Nachricht mit Dokumenten + + Returns: + Dokumentkontext als Text + """ + document_context = "" + + for document in message.get("documents", []): + source = document.get("source", {}) + doc_name = source.get("name", "unnamed") + + document_context += f"\n\n--- {doc_name} ---\n" + + for content in document.get("contents", []): + if content.get("type") == "text": + document_context += content.get("text", "") + + return document_context + + def _assess_complexity(self, task: str) -> bool: + """ + Bewertet die Aufgabenkomplexität. + + Args: + task: Die Aufgabenbeschreibung + + Returns: + True bei komplexem Dokument, sonst False + """ + # Einfache Heuristik zur Komplexitätsbewertung + complexity_indicators = [ + "detailliert", "ausführlich", "umfassend", "komplex", "detailed", + "comprehensive", "in-depth", "multiple sections", "kapitel", + "abschnitte", "struktur", "analyse", "vergleich" + ] + + # Zählen der Komplexitätsindikatoren + indicator_count = sum(1 for indicator in complexity_indicators if indicator in task.lower()) + + # Weitere Indikatoren: Textlänge, Anzahl der Anforderungen + length_factor = len(task) > 500 + requirements_count = task.lower().count("muss") + task.lower().count("soll") + task.lower().count("should") + task.lower().count("must") + + # Komplexität basierend auf Indikatoren bestimmen + return (indicator_count >= 2) or (length_factor and requirements_count >= 3) + + async def _generate_title(self, task: str, document_type: str) -> str: + """ + Generiert einen Titel für das Dokument. + + Args: + task: Die Aufgabenbeschreibung + document_type: Dokumenttyp + + Returns: + Generierter Titel + """ + if not self.ai_service: + return f"{document_type.capitalize()} Dokument" + + prompt = f""" + Erstelle einen prägnanten, professionellen Titel für dieses {document_type}: + + {task} + + Antworte NUR mit dem Titel, nichts anderes. + """ + + try: + title = await self.ai_service.call_api([ + {"role": "system", "content": "Du erstellst Dokumenttitel."}, + {"role": "user", "content": prompt} + ]) + + # Titel bereinigen + return title.strip('"\'#*- \n\t') + except Exception: + return f"{document_type.capitalize()} Dokument" + + async def _generate_complex_document(self, task: str, document_type: str, title: str) -> str: + """ + Generiert ein komplexes Dokument mit Struktur. + + Args: + task: Die Aufgabenbeschreibung + document_type: Dokumenttyp + title: Dokumenttitel + + Returns: + Generierter Dokumentinhalt + """ + if not self.ai_service: + return f"# {title}\n\nDokumentgenerierung nicht möglich: KI-Service nicht verfügbar." + + prompt = f""" + Erstelle ein umfassendes, gut strukturiertes {document_type} mit dem Titel "{title}" basierend auf: + + {task} + + Das Dokument sollte Folgendes enthalten: + 1. Eine klare Einleitung mit Zweck und Umfang + 2. Logisch organisierte Abschnitte mit Überschriften + 3. Detaillierte Inhalte mit Beispielen und Belegen + 4. Ein Fazit mit den wichtigsten Erkenntnissen + 5. Geeignete Formatierung mit Markdown + + Formatiere das Dokument in Markdown mit korrekten Überschriften, Listen und Hervorhebungen. + """ + + try: + content = await self.ai_service.call_api([ + {"role": "system", "content": "Du erstellst umfassende, gut strukturierte Dokumentation."}, + {"role": "user", "content": prompt} + ]) + + # Sicherstellen, dass der Titel am Anfang steht + if not content.strip().startswith("# "): + content = f"# {title}\n\n{content}" + + return content + except Exception as e: + return f"# {title}\n\nFehler bei der Dokumentgenerierung: {str(e)}" + + async def _generate_simple_document(self, task: str, document_type: str, title: str) -> str: + """ + Generiert ein einfaches Dokument ohne komplexe Struktur. + + Args: + task: Die Aufgabenbeschreibung + document_type: Dokumenttyp + title: Dokumenttitel + + Returns: + Generierter Dokumentinhalt + """ + if not self.ai_service: + return f"# {title}\n\nDokumentgenerierung nicht möglich: KI-Service nicht verfügbar." + + prompt = f""" + Erstelle ein präzises, fokussiertes {document_type} mit dem Titel "{title}" basierend auf: + + {task} + + Das Dokument sollte klar, präzise und auf den Punkt sein, ohne komplexe Kapitelstruktur. + Formatiere es mit Markdown und verwende geeignete Überschriften und Formatierungen. + """ + + try: + content = await self.ai_service.call_api([ + {"role": "system", "content": "Du erstellst präzise, fokussierte Dokumentation."}, + {"role": "user", "content": prompt} + ]) + + # Sicherstellen, dass der Titel am Anfang steht + if not content.strip().startswith("# "): + content = f"# {title}\n\n{content}" + + return content + except Exception as e: + return f"# {title}\n\nFehler bei der Dokumentgenerierung: {str(e)}" + +# Singleton-Instanz +_documentation_agent = None + +def get_documentation_agent(): + """Gibt eine Singleton-Instanz des Dokumentations-Agenten zurück""" + global _documentation_agent + if _documentation_agent is None: + _documentation_agent = AgentDocumentation() + return _documentation_agent \ No newline at end of file diff --git a/modules/chat_agent_webcrawler.py b/modules/chat_agent_webcrawler.py new file mode 100644 index 00000000..3028c11f --- /dev/null +++ b/modules/chat_agent_webcrawler.py @@ -0,0 +1,654 @@ +""" +Webcrawler-Agent für Recherche und Abruf von Informationen aus dem Web. +Angepasst für die neue chat.py Architektur und chat_registry.py. +""" + +import json +import logging +import time +import traceback +from typing import Dict, Any, List, Optional +from urllib.parse import quote_plus, unquote + +from bs4 import BeautifulSoup +import requests +from modules.chat_registry import AgentBase +from modules.utility import APP_CONFIG + +logger = logging.getLogger(__name__) + +class AgentWebcrawler(AgentBase): + """Agent für Webrecherche und Informationsabruf""" + + def __init__(self): + """Initialisiert den Webcrawler-Agent""" + super().__init__() + self.name = "Webscraper" + self.capabilities = "web_search,information_retrieval,data_collection,source_verification,content_integration" + self.result_format = "SearchResults" + + # Web-Crawling-Konfiguration + self.max_url = int(APP_CONFIG.get("Connector_AiWebscraping_MAX_URLS")) + self.max_key = int(APP_CONFIG.get("Connector_AiWebscraping_MAX_SEARCH_KEYWORDS")) + self.max_result = int(APP_CONFIG.get("Connector_AiWebscraping_MAX_SEARCH_RESULTS")) + self.timeout = int(APP_CONFIG.get("Connector_AiWebscraping_TIMEOUT")) + + def get_agent_info(self) -> Dict[str, Any]: + """Gibt Agent-Informationen für die Registry zurück""" + info = super().get_config() + info.update({ + "metadata": { + "max_url": self.max_url, + "max_result": self.max_result, + "timeout": self.timeout + } + }) + return info + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + """ + Verarbeitet eine Nachricht und führt bei Bedarf eine Webrecherche durch. + + Args: + message: Die zu verarbeitende Nachricht + context: Zusätzlicher Kontext + + Returns: + Die generierte Antwort oder Ablehnung, wenn keine Webrecherche erforderlich ist + """ + # Workflow-ID aus Kontext oder Nachricht extrahieren + workflow_id = context.get("workflow_id") if context else message.get("workflow_id", "unknown") + + # Antwortstruktur erstellen + response = { + "role": "assistant", + "content": "", + "agent_name": self.name, + "result_format": self.result_format, + "workflow_id": workflow_id + } + + try: + # Abfrage aus der Nachricht abrufen + prompt = message.get("content", "").strip() + + # Prüfen, ob es sich explizit um eine Webrecherche-Anfrage handelt + is_web_research = await self._is_web_research_request(prompt) + + if not is_web_research: + # Keine Webrecherche-Anfrage ablehnen + logger.info("Anfrage abgelehnt: keine Webrecherche-Aufgabe") + response["content"] = "Diese Anfrage scheint keine Webrecherche zu erfordern. Weiterleitung an einen passenderen Agenten." + response["status"] = "rejected" + return response + + # Mit Webrecherche fortfahren + logger.info(f"Webrecherche für: {prompt[:50]}...") + + # Suchstrategie vorbereiten + logger.info("Erstelle Suchstrategie") + + search_strategy = await self._create_search_strategy(prompt) + search_keys = search_strategy.get("skey", []) + search_urls = search_strategy.get("url", []) + + if search_keys: + logger.info(f"Suche nach {len(search_keys)} Schlüsselbegriffen: {', '.join(search_keys[:2])}...") + + if search_urls: + logger.info(f"Suche in {len(search_urls)} direkten URLs: {', '.join(search_urls[:2])}...") + + # Suche ausführen + results = [] + + # Suchbegriffe verarbeiten + for keyword in search_keys: + logger.info(f"Suche im Web nach: '{keyword}'") + keyword_results = self._search_web(keyword) + results.extend(keyword_results) + logger.info(f"Gefunden: {len(keyword_results)} Ergebnisse für '{keyword}'") + + # Direkte URLs verarbeiten + for url in search_urls: + logger.info(f"Extrahiere Inhalt von: {url}") + soup = self._read_url(url) + + # Titel aus der Seite extrahieren, falls vorhanden + title = self._extract_title(soup, url) + + result = self._parse_result(soup, title, url) + results.append(result) + logger.info(f"Extrahiert: '{title}' von {url}") + + # Ergebnisse für die endgültige Ausgabe verarbeiten + logger.info(f"Analysiere {len(results)} Web-Ergebnisse") + + # Zusammenfassungen für jedes Ergebnis generieren + processed_results = [] + for i, result in enumerate(results): + result_data_limited = self._limit_text(result['data'], max_chars=10000) + + logger.info(f"Analysiere Ergebnis {i+1}/{len(results)}: {result['title'][:30]}...") + + content_summary = await self._summarize_result(result_data_limited, prompt) + + processed_result = { + "title": result['title'], + "url": result['url'], + "snippet": result['snippet'], + "summary": content_summary + } + + processed_results.append(processed_result) + + # Gesamtzusammenfassung erstellen + all_summaries = "\n\n".join([r["summary"] for r in processed_results]) + all_summaries_limited = self._limit_text(all_summaries, max_chars=10000) + + logger.info("Erstelle Gesamtzusammenfassung der Webrecherche") + + final_summary = await self.ai_service.call_api([ + {"role": "system", "content": "Du erstellst prägnante Zusammenfassungen von Rechercheergebnissen."}, + {"role": "user", "content": f"Bitte fasse diese Erkenntnisse in 5-6 Sätzen zusammen: {all_summaries_limited}\n"} + ]) + + # Sprache der Anfrage ermitteln, um Überschriften in der richtigen Sprache zu verwenden + headers = await self._get_localized_headers(prompt) + + # Endgültiges Ergebnis formatieren + final_result = f"## {headers['web_research_results']}\n\n### {headers['summary']}\n{final_summary}\n\n### {headers['detailed_results']}\n" + + for i, result in enumerate(processed_results, 1): + final_result += f"\n\n[{i}] {result['title']}\n{headers['url']}: {result['url']}\n{headers['snippet']}: {result['snippet']}\n{headers['content']}: {result['summary']}" + + # Inhalt in der Antwort setzen + response["content"] = final_result + + logger.info("Webrecherche erfolgreich abgeschlossen") + + return response + + except Exception as e: + error_msg = f"Fehler bei der Webrecherche: {str(e)}" + logger.error(error_msg) + response["content"] = f"## Fehler bei der Webrecherche\n\n{error_msg}" + return response + + async def _is_web_research_request(self, prompt: str) -> bool: + """ + Verwendet KI, um festzustellen, ob eine Anfrage Webrecherche erfordert. + + Args: + prompt: Die Benutzeranfrage + + Returns: + True, wenn es explizit eine Webrecherche-Anfrage ist, sonst False + """ + if not self.ai_service: + # Fallback zur einfacheren Erkennung, wenn kein KI-Service verfügbar ist + return self._simple_web_detection(prompt) + + try: + # Prompt erstellen, um zu analysieren, ob es sich um eine Webrecherche-Anfrage handelt + analysis_prompt = f""" + Analysiere die folgende Anfrage und bestimme, ob sie explizit eine Webrecherche oder Online-Informationen erfordert. + + ANFRAGE: {prompt} + + Eine Anfrage erfordert Webrecherche, wenn: + 1. Sie explizit nach der Suche von Informationen online fragt + 2. Sie URLs oder Verweise auf Websites enthält + 3. Sie aktuelle Informationen anfordert, die im Web verfügbar wären + 4. Sie nach Informationen aus Web-Quellen fragt + 5. Sie implizit aktuelle Informationen aus dem Internet erfordert + + Antworte NUR mit einem einzelnen Wort - entweder "JA", wenn Webrecherche erforderlich ist, oder "NEIN", wenn nicht. + Füge KEINE Erklärung hinzu, nur die Antwort JA oder NEIN. + """ + + # KI zur Analyse aufrufen + response = await self.ai_service.call_api([ + {"role": "system", "content": "Du bestimmst, ob eine Anfrage Webrecherche erfordert. Antworte immer nur mit JA oder NEIN."}, + {"role": "user", "content": analysis_prompt} + ]) + + # Antwort bereinigen und überprüfen + response = response.strip().upper() + + return "JA" in response + + except Exception as e: + # Fehler protokollieren, aber nicht fehlschlagen, Fallback zur einfacheren Erkennung + logger.warning(f"Fehler bei der KI-Erkennung von Webrecherche-Anfragen: {str(e)}") + return self._simple_web_detection(prompt) + + def _simple_web_detection(self, prompt: str) -> bool: + """ + Einfachere Fallback-Methode zur Erkennung von Webrecherche-Anfragen anhand von URLs. + + Args: + prompt: Die Benutzeranfrage + + Returns: + True, wenn es klare URL-Indikatoren gibt, sonst False + """ + # URLs in der Anfrage deuten stark auf Webrecherche hin + url_indicators = ["http://", "https://", "www.", ".com", ".org", ".net", ".edu", ".gov"] + web_terms = ["search", "find online", "look up", "web", "internet", "website", "suche", "finde", "recherchiere"] + + # Auf URL-Muster in der Anfrage prüfen + contains_url = any(indicator in prompt.lower() for indicator in url_indicators) + contains_web_term = any(term in prompt.lower() for term in web_terms) + + return contains_url or contains_web_term + + async def _create_search_strategy(self, prompt: str) -> Dict[str, List[str]]: + """ + Erstellt eine Suchstrategie basierend auf der Anfrage. + + Args: + prompt: Die Benutzeranfrage + + Returns: + Suchstrategie mit URLs und Suchbegriffen + """ + if not self.ai_service: + # Fallback zur einfachen Strategie + return {"skey": [prompt], "url": []} + + try: + # KI-Prompt zur Erstellung einer Suchstrategie + strategy_prompt = f"""Erstelle eine umfassende Webrecherchestrategie für die Aufgabe = '{prompt.replace("'","")}'. Gib die Ergebnisse als Python-Dictionary mit diesen spezifischen Schlüsseln zurück. Wenn bestimmte URLs angegeben sind und die Aufgabe nur die Analyse dieser URLs erfordert, lass 'skey' leer. + + 'url': Eine Liste von maximal {self.max_url} spezifischen URLs, die aus der Aufgabenstellung extrahiert wurden. + + 'skey': Eine Liste von maximal {self.max_key} Schlüsselsätzen, nach denen im Web gesucht werden soll. Diese sollten präzise, vielfältig und gezielt sein, um die relevantesten Informationen zu erhalten. + + Formatiere deine Antwort als gültiges JSON-Objekt mit diesen beiden Schlüsseln. Füge keinen erklärenden Text oder Markdown außerhalb der Objektdefinition hinzu. + """ + + # KI für Suchstrategie aufrufen + content_text = await self.ai_service.call_api([ + {"role": "system", "content": "Du bist ein Webrecherche-Experte, der präzise Suchstrategien entwickelt."}, + {"role": "user", "content": strategy_prompt} + ]) + + # JSON-Code-Block-Markierungen entfernen, falls vorhanden + if content_text.startswith("```json"): + end_marker = "```" + end_index = content_text.rfind(end_marker) + if end_index != -1: + content_text = content_text[7:end_index].strip() + + # JSON parsen und zurückgeben + strategy = json.loads(content_text) + return strategy + + except Exception as e: + logger.error(f"Fehler bei der Erstellung der Suchstrategie: {str(e)}") + # Einfache Fallback-Strategie + return {"skey": [prompt], "url": []} + + async def _summarize_result(self, result_data: str, original_prompt: str) -> str: + """ + Erstellt eine Zusammenfassung eines Suchergebnisses mit KI. + + Args: + result_data: Die zu zusammenfassenden Daten + original_prompt: Die ursprüngliche Anfrage + + Returns: + Zusammenfassung des Ergebnisses + """ + if not self.ai_service: + return "Keine Zusammenfassung verfügbar (KI-Service nicht verfügbar)" + + try: + # Anweisungen für die Zusammenfassung + summary_prompt = f""" + Fasse dieses Suchergebnis gemäß der ursprünglichen Anfrage in etwa 2000 Zeichen zusammen. Ursprüngliche Anfrage = '{original_prompt.replace("'","")}' + Konzentriere dich auf die wichtigsten Erkenntnisse und verbinde sie mit der ursprünglichen Anfrage. Du kannst jede Einleitung überspringen. + Extrahiere nur relevante und hochwertige Informationen im Zusammenhang mit der Anfrage und präsentiere sie in einem klaren Format. Biete eine ausgewogene Ansicht der recherchierten Informationen. + + Hier ist das Suchergebnis: + {result_data} + """ + + # KI für Zusammenfassung aufrufen + summary = await self.ai_service.call_api([ + {"role": "system", "content": "Du bist ein Informationsanalyst, der Webinhalte präzise und relevant zusammenfasst."}, + {"role": "user", "content": summary_prompt} + ]) + + # Auf ~2000 Zeichen begrenzen + return summary[:2000] + + except Exception as e: + logger.error(f"Fehler bei der Zusammenfassung des Ergebnisses: {str(e)}") + return "Fehler bei der Zusammenfassung" + + async def _get_localized_headers(self, text: str) -> Dict[str, str]: + """ + Ermittelt lokalisierte Überschriften für die Webrecherche-Ergebnisse basierend auf der erkannten Sprache. + + Args: + text: Text zur Spracherkennung + + Returns: + Dictionary mit lokalisierten Überschriften + """ + # Standard-Englische Überschriften + headers = { + "web_research_results": "Web Research Results", + "summary": "Summary", + "detailed_results": "Detailed Results", + "url": "URL", + "snippet": "Snippet", + "content": "Content" + } + + if not self.ai_service: + return headers + + try: + # Sprache erkennen + language_prompt = f"In welcher Sprache ist dieser Text geschrieben? Antworte nur mit dem Sprachnamen: {text[:200]}" + language = await self.ai_service.call_api([ + {"role": "system", "content": "Du bestimmst die Sprache eines Textes und gibst nur den Sprachnamen zurück."}, + {"role": "user", "content": language_prompt} + ]) + + language = language.strip().lower() + + # Englische Sprache oder Spracherkennung fehlgeschlagen, Standardüberschriften zurückgeben + if language in ["english", "en", ""]: + return headers + + # Deutsche Überschriften + if language in ["deutsch", "german", "de"]: + return { + "web_research_results": "Webrecherche-Ergebnisse", + "summary": "Zusammenfassung", + "detailed_results": "Detaillierte Ergebnisse", + "url": "URL", + "snippet": "Ausschnitt", + "content": "Inhalt" + } + + # Französische Überschriften + if language in ["französisch", "french", "fr"]: + return { + "web_research_results": "Résultats de recherche Web", + "summary": "Résumé", + "detailed_results": "Résultats détaillés", + "url": "URL", + "snippet": "Extrait", + "content": "Contenu" + } + + # Überschriften übersetzen, wenn Sprache erkannt, aber keine vordefinierte Übersetzung + translation_prompt = f""" + Übersetze diese Webrecherche-Ergebnisüberschriften ins {language}: + + Web Research Results + Summary + Detailed Results + URL + Snippet + Content + + Gib ein JSON-Objekt mit diesen Schlüsseln zurück: + web_research_results, summary, detailed_results, url, snippet, content + """ + + # KI für Übersetzung aufrufen + response = await self.ai_service.call_api([ + {"role": "system", "content": "Du übersetzt Überschriften in die angegebene Sprache und gibst sie als JSON zurück."}, + {"role": "user", "content": translation_prompt} + ]) + + # JSON extrahieren + import re + json_match = re.search(r'\{.*\}', response, re.DOTALL) + + if json_match: + translated_headers = json.loads(json_match.group(0)) + return translated_headers + + except Exception as e: + # Fehler protokollieren, aber mit englischen Überschriften fortfahren + logger.warning(f"Fehler beim Übersetzen der Überschriften: {str(e)}") + + return headers + + def _search_web(self, query: str) -> List[Dict[str, str]]: + """ + Führt eine Websuche durch und gibt die Ergebnisse zurück. + + Args: + query: Die Suchanfrage + + Returns: + Liste von Suchergebnissen + """ + formatted_query = quote_plus(query) + url = f"{APP_CONFIG.get('Connector_AiWebscraping_SEARCH_ENGINE')}{formatted_query}" + + search_results_soup = self._read_url(url) + if not isinstance(search_results_soup, BeautifulSoup) or not search_results_soup.select('.result'): + logger.warning(f"Keine Suchergebnisse gefunden für: {query}") + return [] + + # Suchergebnisse extrahieren + results = [] + + # Alle Ergebniscontainer finden + result_elements = search_results_soup.select('.result') + + for result in result_elements: + # Titel extrahieren + title_element = result.select_one('.result__a') + title = title_element.text.strip() if title_element else 'Kein Titel' + + # URL extrahieren (DuckDuckGo verwendet Weiterleitungen) + url_element = title_element.get('href') if title_element else '' + extracted_url = 'Keine URL' + + if url_element: + # Tatsächliche URL aus DuckDuckGos Weiterleitung extrahieren + if url_element.startswith('/d.js?q='): + start = url_element.find('?q=') + 3 + end = url_element.find('&', start) if '&' in url_element[start:] else None + extracted_url = unquote(url_element[start:end]) + + # Sicherstellen, dass die URL das korrekte Protokollpräfix hat + if not extracted_url.startswith(('http://', 'https://')): + if not extracted_url.startswith('//'): + extracted_url = 'https://' + extracted_url + else: + extracted_url = 'https:' + extracted_url + else: + extracted_url = url_element + + # Snippet direkt aus der Suchergebnisseite extrahieren + snippet_element = result.select_one('.result__snippet') + snippet = snippet_element.text.strip() if snippet_element else 'Keine Beschreibung' + + # Tatsächlichen Seiteninhalt für das Datenfeld abrufen + target_page_soup = self._read_url(extracted_url) + + # Neue Inhaltsextraktionsmethode verwenden, um Inhaltsgröße zu begrenzen + content = self._extract_main_content(target_page_soup) + + results.append({ + 'title': title, + 'url': extracted_url, + 'snippet': snippet, + 'data': content + }) + + # Anzahl der Ergebnisse bei Bedarf begrenzen + if len(results) >= self.max_result: + break + + return results + + def _read_url(self, url: str) -> BeautifulSoup: + """ + Liest eine URL und gibt einen BeautifulSoup-Parser für den Inhalt zurück. + + Args: + url: Die zu lesende URL + + Returns: + BeautifulSoup-Objekt mit dem Inhalt oder leer bei Fehlern + """ + headers = { + 'User-Agent': APP_CONFIG.get("Connector_AiWebscraping_USER_AGENT"), + 'Accept': 'text/html,application/xhtml+xml,application/xml', + 'Accept-Language': 'en-US,en;q=0.9', + } + + try: + # Initiale Anfrage + response = requests.get(url, headers=headers, timeout=self.timeout) + + # Abfragen für Status 202 + if response.status_code == 202: + # Maximal 3 Versuche mit zunehmenden Intervallen + backoff_times = [0.5, 1.0, 2.0, 5.0] + + for wait_time in backoff_times: + time.sleep(wait_time) # Mit zunehmender Zeit warten + response = requests.get(url, headers=headers, timeout=self.timeout) + + # Wenn kein 202 mehr, dann abbrechen + if response.status_code != 202: + break + + # Für andere Fehlerstatuscodes einen Fehler auslösen + response.raise_for_status() + + # HTML parsen + return BeautifulSoup(response.text, 'html.parser') + + except Exception as e: + logger.error(f"Fehler beim Lesen der URL {url}: {str(e)}") + # Leeres BeautifulSoup-Objekt erstellen + return BeautifulSoup("", 'html.parser') + + def _extract_title(self, soup: BeautifulSoup, url: str) -> str: + """ + Extrahiert den Titel aus einer Webseite. + + Args: + soup: BeautifulSoup-Objekt der Webseite + url: URL der Webseite + + Returns: + Extrahierter Titel + """ + if not isinstance(soup, BeautifulSoup): + return f"Fehler bei {url}" + + # Titel aus dem title-Tag extrahieren + title_tag = soup.find('title') + title = title_tag.text.strip() if title_tag else "Kein Titel" + + # Alternative: Auch nach h1-Tags suchen, wenn der title-Tag fehlt + if title == "Kein Titel": + h1_tag = soup.find('h1') + if h1_tag: + title = h1_tag.text.strip() + + return title + + def _extract_main_content(self, soup: BeautifulSoup, max_chars: int = 10000) -> str: + """ + Extrahiert den Hauptinhalt aus einer HTML-Seite. + + Args: + soup: BeautifulSoup-Objekt der Webseite + max_chars: Maximale Anzahl von Zeichen + + Returns: + Extrahierter Hauptinhalt als String + """ + if not isinstance(soup, BeautifulSoup): + return str(soup)[:max_chars] if soup else "" + + # Versuchen, Hauptinhaltselemente in Prioritätsreihenfolge zu finden + main_content = None + for selector in ['main', 'article', '#content', '.content', '#main', '.main']: + content = soup.select_one(selector) + if content: + main_content = content + break + + # Wenn kein Hauptinhalt gefunden wurde, den Body verwenden + if not main_content: + main_content = soup.find('body') or soup + + # Skript-, Style-, Nav-, Footer-Elemente entfernen, die nicht zum Hauptinhalt beitragen + for element in main_content.select('script, style, nav, footer, header, aside, .sidebar, #sidebar, .comments, #comments, .advertisement, .ads, iframe'): + element.extract() + + # Textinhalt extrahieren + text_content = main_content.get_text(separator=' ', strip=True) + + # Auf max_chars begrenzen + return text_content[:max_chars] + + def _parse_result(self, soup: BeautifulSoup, title: str, url: str) -> Dict[str, str]: + """ + Parst ein BeautifulSoup-Objekt in ein Ergebnis-Dictionary. + + Args: + soup: BeautifulSoup-Objekt der Webseite + title: Seitentitel + url: Seiten-URL + + Returns: + Dictionary mit Ergebnisdaten + """ + # Inhalt extrahieren + content = self._extract_main_content(soup) + + result = { + 'title': title, + 'url': url, + 'snippet': 'Keine Beschreibung', # Standardwert + 'data': content + } + return result + + def _limit_text(self, text: str, max_chars: int = 10000) -> str: + """ + Begrenzt den Text auf eine maximale Anzahl von Zeichen. + + Args: + text: Eingangstext + max_chars: Maximale Anzahl von Zeichen + + Returns: + Begrenzter Text + """ + if not text: + return "" + + # Wenn der Text bereits unter dem Limit liegt, unverändert zurückgeben + if len(text) <= max_chars: + return text + + # Andernfalls den Text auf max_chars begrenzen + return text[:max_chars] + "... [Inhalt aufgrund der Länge gekürzt]" + +# Singleton-Instanz +_webcrawler_agent = None + +def get_webcrawler_agent(): + """Gibt eine Singleton-Instanz des Webcrawler-Agenten zurück""" + global _webcrawler_agent + if _webcrawler_agent is None: + _webcrawler_agent = AgentWebcrawler() + return _webcrawler_agent \ No newline at end of file diff --git a/modules/chat_registry.py b/modules/chat_registry.py new file mode 100644 index 00000000..b9bc0b04 --- /dev/null +++ b/modules/chat_registry.py @@ -0,0 +1,213 @@ +""" +Chat Agent Registry Modul. +Stellt ein zentrales Registry-System für alle verfügbaren Agenten bereit. +""" + +import os +import logging +import importlib +from typing import Dict, Any, List, Optional + +logger = logging.getLogger(__name__) + +class AgentRegistry: + """Zentrale Registry für alle verfügbaren Agenten im System.""" + + _instance = None + + @classmethod + def get_instance(cls): + """Gibt eine Singleton-Instanz der Agent-Registry zurück.""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + """Initialisiert die Agent-Registry.""" + if AgentRegistry._instance is not None: + raise RuntimeError("Singleton-Instanz existiert bereits - verwende get_instance()") + + self.agents = {} + self.ai_service = None + self._load_agents() + + def _load_agents(self): + """Lädt alle verfügbaren Agenten aus den Modulen.""" + logger.info("Lade Agent-Module...") + + # Liste der zu ladenden Agent-Module + agent_modules = [] + agent_dir = os.path.dirname(__file__) + + # Durchsuche das Verzeichnis nach Agent-Modulen + for filename in os.listdir(agent_dir): + if filename.startswith("chat_agent_") and filename.endswith(".py"): + agent_modules.append(filename[:-3]) # Entferne .py-Endung + + if not agent_modules: + logger.warning("Keine Agent-Module gefunden") + return + + logger.info(f"{len(agent_modules)} Agent-Module gefunden") + + # Lade jedes Agent-Modul + for module_name in agent_modules: + try: + # Importiere das Modul + module = importlib.import_module(f"modules.{module_name}") + + # Suche nach der Agent-Klasse oder einer get_*_agent-Funktion + agent_name= module_name.split('_')[-1] + class_name = f"Agent{agent_name.capitalize()}" + getter_name = f"get_{agent_name}_agent" + + agent = None + + # Versuche, den Agenten über die get_*_agent-Funktion zu erhalten + if hasattr(module, getter_name): + getter_func = getattr(module, getter_name) + agent = getter_func() + logger.info(f"Agent '{agent.name}' über {getter_name}() geladen") + + # Alternativ versuche, den Agenten direkt zu instanziieren + elif hasattr(module, class_name): + agent_class = getattr(module, class_name) + agent = agent_class() + logger.info(f"Agent '{agent.name}' (Typ: {agent.name}) direkt instanziert") + + if agent: + # Registriere den Agenten + self.register_agent(agent) + else: + logger.warning(f"Keine Agent-Klasse oder Getter-Funktion in Modul {module_name} gefunden") + + except ImportError as e: + logger.error(f"Modul {module_name} konnte nicht importiert werden: {e}") + except Exception as e: + logger.error(f"Fehler beim Laden des Agenten aus Modul {module_name}: {e}") + + def set_ai_service(self, ai_service): + self.ai_service = ai_service + self.update_agent_dependencies() + + def update_agent_dependencies(self): + """Aktualisiert die Abhängigkeiten für alle registrierten Agenten.""" + for agent_id, agent in self.agents.items(): + if hasattr(agent, 'set_dependencies'): + agent.set_dependencies(ai_service=self.ai_service) + + def register_agent(self, agent): + """ + Registriert einen Agenten in der Registry. + + Args: + agent: Der zu registrierende Agent + """ + agent_id = getattr(agent, 'name', "unknown_agent") + # Initialisiere Agenten mit Abhängigkeiten + if hasattr(agent, 'set_dependencies'): + agent.set_dependencies(ai_service=self.ai_service) + self.agents[agent_id] = agent + logger.debug(f"Agent '{agent.name}' (Typ: {agent_id}, Name: {agent_id}) registriert") + + def get_agent(self, agent_identifier: str): + """ + Gibt eine Agenten-Instanz zurück + Args: + agent_identifier: ID oder Typ des gewünschten Agenten + Returns: + Agenten-Instanz oder None, falls nicht gefunden + """ + if agent_identifier in self.agents: + return self.agents[agent_identifier] + logger.error(f"Agent mit Kennung '{agent_identifier}' nicht gefunden") + return None + + def get_all_agents(self) -> Dict[str, Any]: + """Gibt alle registrierten Agenten zurück.""" + return self.agents + + def get_agent_infos(self) -> List[Dict[str, Any]]: + """Gibt Informationen über alle registrierten Agenten zurück.""" + agent_infos = [] + seen_agents = set() + for agent in self.agents.values(): + if agent not in seen_agents: + # Verwende get_agent_info oder erstelle manuell die Info + if hasattr(agent, 'get_agent_info'): + agent_infos.append(agent.get_agent_info()) + else: + agent_infos.append({ + "name": agent.name, + "capabilities": getattr(agent, 'capabilities', ""), + "result_format": getattr(agent, 'result_format', "Text") + }) + logger.error(f"Agent mit Kennung '{agent.name}' hat keine vollständigen Daten") + seen_agents.add(agent) + return agent_infos + + +# Basis-Agent-Klasse +class AgentBase: + """ + Basis-Klasse für alle Chat-Agenten. + Definiert die grundlegende Schnittstelle und Funktionalität. + """ + + def __init__(self): + """Initialisiere den Basis-Agenten.""" + self.name = "Basis-Agent" + self.capabilities = "Grundlegende Agentenfunktionen" + self.result_format = "Text" + self.ai_service = None + + def set_dependencies(self, ai_service=None): + self.ai_service = ai_service + + def get_config(self) -> Dict[str, Any]: + return { + "name": self.name, + "capabilities": self.capabilities, + "result_format": self.result_format + } + + async def process_message(self, message: Dict[str, Any], context: Dict[str, Any] = None) -> Dict[str, Any]: + # Basisimplementierung - sollte von spezialisierten Agenten überschrieben werden + if not self.ai_service: + logger.warning(f"Agent {self.id} hat keinen konfigurierten AI-Service") + return { + "role": "assistant", + "content": f"Ich bin {self.name}, aber ich bin nicht richtig konfiguriert. Bitte den AI-Service einrichten.", + "agent_name": self.name, + "result_format": "Text" + } + + # Einfachen Prompt erstellen + prompt = message.get("content", "") + + # Antwort generieren + try: + response_content = self.ai_service.call_api([ + {"role": "system", "content": f"Du bist {self.name}, ein spezialisierter {self.name}-Agent mit Fähigkeiten in: {self.capabilities}"}, + {"role": "user", "content": prompt} + ]) + + return { + "role": "assistant", + "content": response_content, + "agent_name": self.name, + "result_format": self.result_format + } + except Exception as e: + logger.error(f"Fehler in Agent {self.id}: {str(e)}") + return { + "role": "assistant", + "content": f"Ich habe einen Fehler festgestellt: {str(e)}", + "agent_name": self.name, + "result_format": "Text" + } + + +# Singleton-Factory für die Agent-Registry +def get_agent_registry(): + return AgentRegistry.get_instance() \ No newline at end of file diff --git a/modules/lucydom_interface.py b/modules/lucydom_interface.py index e47aa3f5..acbd2175 100644 --- a/modules/lucydom_interface.py +++ b/modules/lucydom_interface.py @@ -1,12 +1,10 @@ -import os import logging import uuid -from datetime import datetime, timedelta -import mimetypes -from typing import Dict, Any, List, Optional, Union, BinaryIO, Tuple +from datetime import datetime +from typing import Dict, Any, List, Optional, Union + import importlib import hashlib -from pathlib import Path from connectors.connector_db_json import DatabaseConnector from modules.utility import APP_CONFIG @@ -52,10 +50,6 @@ class LucyDOMInterface: self.mandate_id = mandate_id self.user_id = user_id - # Upload Verzeichnis aus config.ini lesen - self.upload_dir = APP_CONFIG.get('Module_AgentserviceInterface_UPLOAD_DIR') - os.makedirs(self.upload_dir, exist_ok=True) - # Datenmodell-Modul importieren try: self.model_module = importlib.import_module("modules.lucydom_model") @@ -67,7 +61,6 @@ class LucyDOMInterface: # Datenbank initialisieren, falls nötig self._initialize_database() - def _initialize_database(self): """ Initialisiert die Datenbank mit minimalen Objekten für den angemeldeten Benutzer im Mandanten, falls sie noch nicht existiert. @@ -127,123 +120,9 @@ class LucyDOMInterface: 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 - 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 + # Utilities - 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. @@ -255,335 +134,10 @@ class LucyDOMInterface: 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: int) -> 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: int) -> 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: int) -> 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)}") + def _get_current_timestamp(self) -> str: + """Gibt den aktuellen Zeitstempel im ISO-Format zurück""" + return datetime.now().isoformat() # Prompt-Methoden @@ -650,6 +204,262 @@ class LucyDOMInterface: """ return self.db.record_delete("prompts", prompt_id) + + # File Utilities + + def calculate_file_hash(self, file_content: bytes) -> str: + return hashlib.sha256(file_content).hexdigest() + + def check_for_duplicate_file(self, file_hash: str) -> Optional[Dict[str, Any]]: + files = self.db.get_recordset("files", record_filter={"file_hash": file_hash}) + if files: + return files[0] + return None + + def get_mime_type(self, filename: str) -> str: + import os + ext = os.path.splitext(filename)[1].lower()[1:] + 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(ext.lower(), "application/octet-stream") + + + # File Methoden + + def get_all_files(self) -> List[Dict[str, Any]]: + """Gibt alle Dateien des aktuellen Mandanten zurück""" + files = self.db.get_recordset("files") + # Remove binary data from the response to reduce payload size + for file in files: + if "data" in file: + del file["data"] + return files + + def get_file(self, file_id: int) -> Optional[Dict[str, Any]]: + """Gibt eine Datei anhand ihrer ID zurück, ohne Binärdaten""" + files = self.db.get_recordset("files", record_filter={"id": file_id}) + if files: + file = files[0] + # Remove binary data from the response to reduce payload size + if "data" in file: + del file["data"] + return file + return None + + def create_file(self, + name: str, + mime_type: str, + size: int = None, + data: bytes = None, + file_hash: str = None) -> Dict[str, Any]: + """Erstellt einen neuen Dateieintrag in der Datenbank mit Inhalt""" + file_data = { + "mandate_id": self.mandate_id, + "user_id": self.user_id, + "name": name, + "mime_type": mime_type, + "size": size, + "data": data, # Jetzt wird der Dateiinhalt direkt in der Datenbank gespeichert + "file_hash": file_hash, + "creation_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 ohne Binärdaten + """ + # 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 + updated_file = self.db.record_modify("files", file_id, update_data) + + # Binärdaten aus der Antwort entfernen + if "data" in updated_file: + del updated_file["data"] + + return updated_file + + def delete_file(self, file_id: int) -> bool: + """ + Löscht eine Datei aus der Datenbank. + + 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}") + + # Check for other references to this file (by hash) + file_hash = file.get("file_hash") + if file_hash: + other_references = [f for f in self.db.get_recordset("files", record_filter={"file_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}") + + # Lösche den Datenbankeintrag + return self.db.record_delete("files", file_id) + + 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)}") + + def save_uploaded_file(self, file_content: bytes, file_name: str) -> Dict[str, Any]: + """ + Speichert eine hochgeladene Datei direkt in der Datenbank. + + 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}") + + # 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']}") + # Entferne die Binärdaten aus der Antwort + if "data" in existing_file: + existing_file_copy = existing_file.copy() + del existing_file_copy["data"] + return existing_file_copy + return existing_file + + # MIME-Typ bestimmen + mime_type = self.get_mime_type(file_name) + + # Dateigröße bestimmen + file_size = len(file_content) + + # Speichere in der Datenbank + logger.info(f"Saving file metadata to database for file: {file_name}") + db_file = self.create_file( + name=file_name, + mime_type=mime_type, + size=file_size, + data=file_content, # Dateiinhalt direkt in der Datenbank speichern + file_hash=file_hash + ) + + # Debug: Verify database record was created + if not db_file: + logger.warning(f"Database record for file {file_name} was not created properly") + else: + logger.info(f"Database record created for file {file_name}") + + # Entferne die Binärdaten aus der Antwort + if "data" in db_file: + db_file_copy = db_file.copy() + del db_file_copy["data"] + db_file = db_file_copy + + logger.info(f"File upload process completed for: {file_name}") + return db_file + + except Exception as e: + 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)}") + + def download_file(self, file_id: int) -> 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: + # Holen der vollständigen Datei inklusive Binärdaten aus der Datenbank + files = self.db.get_recordset("files", record_filter={"id": file_id}) + + if not files or "data" not in files[0]: + raise FileNotFoundError(f"Datei mit ID {file_id} nicht gefunden oder hat keine Daten") + + file = files[0] + + return { + "id": file_id, + "name": file.get("name", f"file_{file_id}"), + "content_type": file.get("mime_type", "application/octet-stream"), + "size": file.get("size", len(file.get("data", b""))), + "content": file.get("data") + } + 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)}") + # Workflow Methoden @@ -685,6 +495,10 @@ class LucyDOMInterface: if "last_activity" not in workflow_data: workflow_data["last_activity"] = current_time + # Stelle sicher, dass last_message_id gesetzt ist, falls nicht vorhanden + if "last_message_id" not in workflow_data: + workflow_data["last_message_id"] = "" + return self.db.record_create("workflows", workflow_data) def update_workflow(self, workflow_id: str, workflow_data: Dict[str, Any]) -> Dict[str, Any]: @@ -732,6 +546,9 @@ class LucyDOMInterface: # Workflow löschen return self.db.record_delete("workflows", workflow_id) + + # Workflow Messages + 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}) @@ -759,8 +576,23 @@ class LucyDOMInterface: 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() + if "started_at" not in message_data and "created_at" not in message_data: + message_data["started_at"] = self._get_current_timestamp() + + # Wenn "created_at" vorhanden ist, übertrage es nach "started_at" + if "created_at" in message_data and "started_at" not in message_data: + message_data["started_at"] = message_data["created_at"] + del message_data["created_at"] + + # Status setzen, falls nicht vorhanden + if "status" not in message_data: + message_data["status"] = "completed" + + # Sequenznummer setzen, falls nicht vorhanden + if "sequence_no" not in message_data: + # Hole aktuelle Nachrichten, um die nächste Sequenznummer zu bestimmen + existing_messages = self.get_workflow_messages(message_data["workflow_id"]) + message_data["sequence_no"] = len(existing_messages) + 1 # Debug-Log für die zu erstellenden Daten logger.debug(f"Erstelle Workflow-Nachricht mit Daten: {message_data}") @@ -837,6 +669,11 @@ class LucyDOMInterface: if 'id' not in message_data: message_data['id'] = message_id + # Konvertiere created_at zu started_at falls nötig + if "created_at" in message_data and "started_at" not in message_data: + message_data["started_at"] = message_data["created_at"] + del message_data["created_at"] + # Update the message updated_message = self.db.record_modify("workflow_messages", message_id, message_data) if updated_message: @@ -849,183 +686,6 @@ class LucyDOMInterface: 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: """ @@ -1108,9 +768,8 @@ class LucyDOMInterface: 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}") + file_id_value = doc.get("file_id", "unknown") + logger.debug(f"Document {i}: doc_id={doc_id}, file_id={file_id_value}") # Create a new list of documents without the one to delete updated_documents = [] @@ -1118,20 +777,19 @@ class LucyDOMInterface: for doc in documents: doc_id = doc.get("id") - source = doc.get("source", {}) - source_id = source.get("id") + file_id_value = doc.get("file_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) + (file_id_value == file_id) or + (isinstance(doc_id, str) and str(file_id) in doc_id) or + (isinstance(file_id_value, str) and str(file_id) in file_id_value) ) if should_remove: removed = True - logger.info(f"Found file to remove: doc_id={doc_id}, source_id={source_id}") + logger.info(f"Found file to remove: doc_id={doc_id}, file_id={file_id_value}") else: updated_documents.append(doc) @@ -1158,6 +816,186 @@ class LucyDOMInterface: logger.error(f"Error removing file {file_id} from message {message_id}: {str(e)}") return False + + # Workflow Logs + + 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) + + + # Workflow Management + + 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()), + "last_message_id": workflow.get("last_message_id", ""), + "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_name": message.get("agent_name"), + "status": message.get("status", "completed"), + "started_at": message.get("started_at", self._get_current_timestamp()), + "finished_at": message.get("finished_at"), + "parent_message_id": message.get("parent_message_id"), + # 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_id, 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", [])): + file_id = doc.get("file_id", "unknown") + logger.debug(f"Document {i+1}: file_id={file_id}") + + # 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 + # Singleton-Factory für LucyDOMInterface-Instanzen pro Kontext _lucydom_interfaces = {} @@ -1173,4 +1011,4 @@ def get_lucydom_interface(mandate_id: int = 0, user_id: int = 0) -> LucyDOMInter return _lucydom_interfaces[context_key] # Init -get_lucydom_interface() +get_lucydom_interface() \ No newline at end of file diff --git a/modules/lucydom_model copy.py b/modules/lucydom_model copy.py new file mode 100644 index 00000000..b76d0cb8 --- /dev/null +++ b/modules/lucydom_model copy.py @@ -0,0 +1,139 @@ +from pydantic import BaseModel, Field +from typing import List, Dict, Any, Optional +from datetime import datetime + + +class Label(BaseModel): + """Label für ein Attribut oder eine Klasse mit Unterstützung für mehrere Sprachen""" + default: str + translations: Dict[str, str] = {} + + def get_label(self, language: str = None): + """Gibt das Label in der angegebenen Sprache zurück, oder den Standardwert wenn nicht verfügbar""" + if language and language in self.translations: + return self.translations[language] + return self.default + + +class Prompt(BaseModel): + """Datenmodell für einen Prompt""" + id: int = Field(description="Eindeutige ID des Prompts") + mandate_id: int = Field(description="ID des zugehörigen Mandanten") + user_id: int = Field(description="ID des Erstellers") + content: str = Field(description="Inhalt des Prompts") + name: str = Field(description="Anzeigename des Prompts") + + label: Label = Field( + default=Label(default="Prompt", translations={"en": "Prompt", "fr": "Invite"}), + description="Label für die Klasse" + ) + + # Labels für Attribute + field_labels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandate_id": Label(default="Mandanten-ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "user_id": Label(default="Benutzer-ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "content": Label(default="Inhalt", translations={"en": "Content", "fr": "Contenu"}), + "name": Label(default="Name", translations={"en": "Label", "fr": "Nom"}), + } + + +class FileItem(BaseModel): + """Datenmodell für ein Datenobjekt""" + id: int = Field(description="Eindeutige ID des Datenobjekts") + mandate_id: int = Field(description="ID des zugehörigen Mandanten") + user_id: int = Field(description="ID des Erstellers") + name: str = Field(description="Name des Datenobjekts") + mime_type: str = Field(description="Typ des Datenobjekts MIME-Typ") + size: Optional[str] = Field(None, description="Größe des Datenobjekts") + file_hash: str = Field(description="Hash code") + data: bytes = Field(description="Inhalt der Datei") + creation_date: Optional[str] = Field(None, description="Datum des Hochladens") + + label: Label = Field( + default=Label(default="Datenobjekt", translations={"en": "Data Object", "fr": "Objet de données"}), + description="Label für die Klasse" + ) + + # Labels für Attribute + field_labels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandate_id": Label(default="Mandanten-ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "user_id": Label(default="Benutzer-ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), + "mime_type": Label(default="Typ", translations={"en": "Type", "fr": "Type"}), + "size": Label(default="Größe", translations={"en": "Size", "fr": "Taille"}), + "file_hash": Label(default="File-Hash", translations={"en": "Hash", "fr": "Hash"}), + "data": Label(default="Daten", translations={"en": "Data", "fr": "Contenu"}), + "creation_date": Label(default="Upload-Datum", translations={"en": "Upload date", "fr": "Date de téléchargement"}) + } + + +# Workflow-Modellklassen + +class DocumentContent(BaseModel): + """Inhalt eines Dokuments im Workflow""" + sequence_nr: Optional[int] = Field(1,description="Sequenz-Nummer des Inhaltes im Quelldokument") + name: str = Field(description="Optionale Bezeichnung") + ext: str = Field(description="Content extension for export: txt, csv, json, jpg, png") + content_type: str = Field(description="MIME-Typ") + data: bytes = Field(description="Inhalt der Datei") + +class Document(BaseModel): + """Dokument im Workflow """ + id: str = Field(description="Eindeutige ID des Dokuments") + file_id: int = Field(description="Quelldatei") + contents: List[DocumentContent] = Field(description="Dokumentinhalte") + +class DataStats(BaseModel): + """Statistiken für Performance und Datennutzung""" + processing_time: Optional[float] = Field(None, description="Verarbeitungszeit in Sekunden") + token_count: Optional[int] = Field(None, description="Token-Anzahl (für KI-Modelle)") + bytes_sent: Optional[int] = Field(None, description="Gesendete Bytes") + bytes_received: Optional[int] = Field(None, description="Empfangene Bytes") + +class Message(BaseModel): + """Nachrichtenobjekt im Workflow""" + id: str = Field(description="Eindeutige ID der Nachricht") + workflow_id: str = Field(description="Referenz zum übergeordneten Workflow") + parent_message_id: Optional[str] = Field(None, description="Referenz zur beantworteten Nachricht") + started_at: str = Field(description="Zeitstempel für Nachrichtenerstellung") + finished_at: Optional[str] = Field(None, description="Zeitstempel für Nachrichtenabschluss") + sequence_no: int = Field(description="Sequenznummer für Sortierung") + + status: str = Field(description="Status der Nachricht ('processing', 'completed')") + role: str = Field(description="Rolle des Absenders ('system', 'user', 'assistant')") + + data_stats: Optional[DataStats] = Field(None, description="Statistiken") + documents: Optional[List[Document]] = Field(None, description="Dokumente in dieser Nachricht") + content: Optional[str] = Field(None, description="Textinhalt der Nachricht") + agent_name: Optional[str] = Field(None, description="Name des verwendeten Agenten") + +class Workflow(BaseModel): + """Workflow-Objekt für Multi-Agent-System""" + id: str = Field(description="Eindeutige ID des Workflows") + name: Optional[str] = Field(None, description="Name des Workflows") + mandate_id: int = Field(description="ID des Mandanten") + user_id: int = Field(description="ID des Benutzers") + status: str = Field(description="Status des Workflows ('running', 'failed', 'stopped')") + started_at: str = Field(description="Startzeitpunkt") + last_activity: str = Field(description="Zeitpunkt der letzten Aktivität") + last_message_id: str = Field(description="The last registered message") + + data_stats: Optional[Dict[str, Any]] = Field(None, description="Gesamt-Statistiken") + messages: List[Message] = Field(default=[], description="Nachrichtenverlauf") + logs: List[Dict[str, Any]] = Field(default=[], description="Protokolleinträge") + +# Anfragemodelle für die API + +class WorkflowCreateRequest(BaseModel): + """Anfrage zur Erstellung eines neuen Workflows""" + name: Optional[str] = Field(None, description="Name des Workflows") + prompt: str = Field(description="Zu verwendender Prompt") + files: List[int] = Field(default=[], description="Liste von FileItem ID") + +class UserInputRequest(BaseModel): + """Anfrage für Benutzereingabe an einen laufenden Workflow""" + prompt: str = Field(description="Nachricht des Benutzers") + files: List[int] = Field(default=[], description="Liste zusätzlicher FileItem ID") + diff --git a/modules/lucydom_model.py b/modules/lucydom_model.py index 09dc50ae..b9128b27 100644 --- a/modules/lucydom_model.py +++ b/modules/lucydom_model.py @@ -15,37 +15,6 @@ class Label(BaseModel): return self.default -class FileItem(BaseModel): - """Datenmodell für ein Datenobjekt""" - id: int = Field(description="Eindeutige ID des Datenobjekts") - mandate_id: int = Field(description="ID des zugehörigen Mandanten") - user_id: int = Field(description="ID des Erstellers") - name: str = Field(description="Name des Datenobjekts") - type: str = Field(description="Typ des Datenobjekts ('document', 'image', etc.)") - size: Optional[str] = Field(None, description="Größe des Datenobjekts") - upload_date: Optional[str] = Field(None, description="Datum des Hochladens") - content_type: Optional[str] = Field(None, description="Content-Type des Datenobjekts") - path: Optional[str] = Field(None, description="Pfad zum Datenobjekt") - - label: Label = Field( - default=Label(default="Datenobjekt", translations={"en": "Data Object", "fr": "Objet de données"}), - description="Label für die Klasse" - ) - - # Labels für Attribute - field_labels: Dict[str, Label] = { - "id": Label(default="ID", translations={}), - "mandate_id": Label(default="Mandanten-ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), - "user_id": Label(default="Benutzer-ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), - "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), - "type": Label(default="Typ", translations={"en": "Type", "fr": "Type"}), - "size": Label(default="Größe", translations={"en": "Size", "fr": "Taille"}), - "upload_date": Label(default="Upload-Datum", translations={"en": "Upload date", "fr": "Date de téléchargement"}), - "content_type": Label(default="Content-Type", translations={"en": "Content type", "fr": "Type de contenu"}), - "path": Label(default="Pfad", translations={"en": "Path", "fr": "Chemin"}) - } - - class Prompt(BaseModel): """Datenmodell für einen Prompt""" id: int = Field(description="Eindeutige ID des Prompts") @@ -69,29 +38,53 @@ class Prompt(BaseModel): } -# Neue Workflow-Modellklassen +class FileItem(BaseModel): + """Datenmodell für ein Datenobjekt""" + id: int = Field(description="Eindeutige ID des Datenobjekts") + mandate_id: int = Field(description="ID des zugehörigen Mandanten") + user_id: int = Field(description="ID des Erstellers") + name: str = Field(description="Name des Datenobjekts") + mime_type: str = Field(description="Typ des Datenobjekts MIME-Typ") + size: Optional[int] = Field(None, description="Größe des Datenobjekts in Bytes") + file_hash: str = Field(description="Hash code für Deduplizierung") + data: bytes = Field(description="Binärer Inhalt der Datei") + creation_date: Optional[str] = Field(None, description="Datum des Hochladens") + workflow_id: Optional[str] = Field(None, description="ID des zugehörigen Workflows, falls vorhanden") -class DocumentSource(BaseModel): - """Quelle eines Dokuments im Workflow""" - type: str = Field(description="Typ der Quelle ('agent', 'file', 'clipboard')") - path: Optional[str] = Field(None, description="Speicherpfad (nur für type=='file'") - name: str = Field(description="Anzeigename der Datei") - size: Optional[int] = Field(None, description="Größe in Bytes") - lines: Optional[int] = Field(None, description="Zeilenanzahl (für Textdateien)") - content_type: Optional[str] = Field(None, description="MIME-Typ") - upload_date: Optional[str] = Field(None, description="Uploaddatum") + label: Label = Field( + default=Label(default="Datenobjekt", translations={"en": "Data Object", "fr": "Objet de données"}), + description="Label für die Klasse" + ) + + # Labels für Attribute + field_labels: Dict[str, Label] = { + "id": Label(default="ID", translations={}), + "mandate_id": Label(default="Mandanten-ID", translations={"en": "Mandate ID", "fr": "ID de mandat"}), + "user_id": Label(default="Benutzer-ID", translations={"en": "User ID", "fr": "ID d'utilisateur"}), + "name": Label(default="Name", translations={"en": "Name", "fr": "Nom"}), + "mime_type": Label(default="Typ", translations={"en": "Type", "fr": "Type"}), + "size": Label(default="Größe", translations={"en": "Size", "fr": "Taille"}), + "file_hash": Label(default="File-Hash", translations={"en": "Hash", "fr": "Hash"}), + "data": Label(default="Daten", translations={"en": "Data", "fr": "Contenu"}), + "creation_date": Label(default="Upload-Datum", translations={"en": "Upload date", "fr": "Date de téléchargement"}), + "workflow_id": Label(default="Workflow-ID", translations={"en": "Workflow ID", "fr": "ID du workflow"}) + } + + +# Workflow-Modellklassen class DocumentContent(BaseModel): """Inhalt eines Dokuments im Workflow""" - label: Optional[str] = Field(None, description="Optionale Bezeichnung") - is_text: Optional[bool] = Field(False, description="Flag, ob Textdatei") - type: str = Field(description="Typ des Inhalts ('pdf', docx, xlsx, txt, csv, json, jpg, png") - text: Optional[str] = Field(None, description="Textinhalt") + sequence_nr: Optional[int] = Field(1,description="Sequenz-Nummer des Inhaltes im Quelldokument") + name: str = Field(description="Optionale Bezeichnung") + ext: str = Field(description="Content extension for export: txt, csv, json, jpg, png") + content_type: str = Field(description="MIME-Typ") + data: bytes = Field(description="Inhalt der Datei") class Document(BaseModel): - """Dokument im Workflow """ + """Dokument im Workflow - Referenziert direkt eine Datei in der Datenbank""" id: str = Field(description="Eindeutige ID des Dokuments") - source: DocumentSource = Field(description="Quellmetadaten") + file_id: int = Field(description="ID der referenzierten Datei in der Datenbank") contents: List[DocumentContent] = Field(description="Dokumentinhalte") class DataStats(BaseModel): @@ -110,13 +103,13 @@ class Message(BaseModel): finished_at: Optional[str] = Field(None, description="Zeitstempel für Nachrichtenabschluss") sequence_no: int = Field(description="Sequenznummer für Sortierung") - status: str = Field(description="Status der Nachricht ('pending', 'processing', 'completed', 'failed')") + status: str = Field(description="Status der Nachricht ('processing', 'completed')") role: str = Field(description="Rolle des Absenders ('system', 'user', 'assistant')") data_stats: Optional[DataStats] = Field(None, description="Statistiken") - documents: Optional[List[Document]] = Field(None, description="Dokumente in dieser Nachricht") + documents: Optional[List[Document]] = Field(None, description="Dokumente in dieser Nachricht (Referenzen zu Dateien in der Datenbank)") content: Optional[str] = Field(None, description="Textinhalt der Nachricht") - agent_type: Optional[str] = Field(None, description="Typ des verwendeten Agenten") + agent_name: Optional[str] = Field(None, description="Name des verwendeten Agenten") class Workflow(BaseModel): """Workflow-Objekt für Multi-Agent-System""" @@ -127,8 +120,7 @@ class Workflow(BaseModel): status: str = Field(description="Status des Workflows ('running', 'failed', 'stopped')") started_at: str = Field(description="Startzeitpunkt") last_activity: str = Field(description="Zeitpunkt der letzten Aktivität") - current_round: int = Field(description="Aktuelle Runde") - waiting_for_user: bool = Field(False, description="Flag, ob auf Benutzereingabe gewartet wird") + last_message_id: str = Field(description="The last registered message") data_stats: Optional[Dict[str, Any]] = Field(None, description="Gesamt-Statistiken") messages: List[Message] = Field(default=[], description="Nachrichtenverlauf") @@ -140,10 +132,9 @@ class WorkflowCreateRequest(BaseModel): """Anfrage zur Erstellung eines neuen Workflows""" name: Optional[str] = Field(None, description="Name des Workflows") prompt: str = Field(description="Zu verwendender Prompt") - files: List[int] = Field(default=[], description="Liste von Datei-IDs") + files: List[int] = Field(default=[], description="Liste von FileItem ID") class UserInputRequest(BaseModel): """Anfrage für Benutzereingabe an einen laufenden Workflow""" - message: str = Field(description="Nachricht des Benutzers") - additional_files: List[int] = Field(default=[], description="Liste zusätzlicher Datei-IDs") - + prompt: str = Field(description="Nachricht des Benutzers") + files: List[int] = Field(default=[], description="Liste zusätzlicher FileItem ID") \ No newline at end of file diff --git a/notes/changelog.txt b/notes/changelog.txt index 88b0bcb0..6d88bd83 100644 --- a/notes/changelog.txt +++ b/notes/changelog.txt @@ -1,54 +1,11 @@ ....................... TASKS -STEP 1........................... +die agents registry bereinigen inkl agents -Der User liefert im AI Chat eine Anfrage in einem Message Objekt. Dieses beinhaltet seinen Prompt und eine Liste der mitgelieferten Dokumente mit ihnen contents. Ebenfalls verfügbar ist der bisherige Chatverlauf im objekt "workflow". -Wir befinden uns im python Script, wo der User prompt mit dem message objekt "message_user" ankommt. +die file upload & dragdrop bereinigen, dass einfach file in db geschrieben wird mit file im file-object - - -Kannst Du mir bitte den Prompt für den Projektleiter zusammenstellen, welcher dem User die Antwort liefert. Der Prijektleiter soll dies tun: - -Dazu erstellst Du zuerst eine Liste von Resultaten, welche der User benötigt, mit Angabe von Format. - -Dann erstellst Du die Antwort an den Benutzer mit den Resultaten. Dokumente lieferst Du separat als Liste. Falls Du für die Antwort oder die Resultate Inputs von Agenten benötigst, gib bitte als Liste an, wer pro Resultat was liefern muss mit Angabe von Agent,Liste der Inputdokumente, Resultatformat,Liste der Resultatnamen, Prompt für Agent als json. - -Diese Agenten stehen zur Verfügung: -. Loop: Er führt repetitive Aufgaben aus. Er benötigt eine Liste von Dokumenten und einen Prompt zur Anwendung auf jedes Dokument, und Resultatformat -. Coder: Er führt Pyton Code aus. Benötigt Prompt und Resultatformat. - - -STEP 2........................... - -We have here an ai agents workflow. a big problem is document extraction. i uploaded a pdf file with a picture inside. in the database i see, that the document has 1 contents, "text" with a endline, marked as "is_extracted=True". it is missing the picture inside the pdf. - -I would like to have the following implementation for files in a workflow: - -How do documents arrive in the workflow: -a) user input with upload or drag&drop: the file shall be stored in the database (files) and its content stored in the workflow message as documents item with reference to the file_id in the database. all contents of the file will be stored as content items in the document item of the message object. according to the content type whey will be extracted as text or as base64 string (e.g. images). the document id will be a uuid and the document-source id the integer from the object in the database "files" -b) produces documents delivered by the agents: exactly the same like a) - -the content provided to an agent will now be a document consisting of the content of all previous messages including the extracted content of the documents within the messages. the extracted content of the documents is produced for each content of the document: -- for text: An ai call with the extraction prompt delivers the text to be integrated -- for an image (it is available as base64 content) an ai call with the extraction prompt delivers the text to be integrated - -Like this we have not anymore the problem, that file content is not found by the agents. - -For code implementation I see a big opportunity to massively reduce code. To build basic methods to be used everywhere: -1. function "document_store_upload(message_id,filename,filepath...) --> function to store an uploaded or drag&drop document from the user and return the document object. This function does the steps for a) respectively b) like described above and identified the filetype -2. function "document_store_agent(message_id,filename,document_content,document_type...) --> function to store the produced document from the agent and return the document object. This function does the steps like described in section a) above -3. function "document_get_from_message() - -Based on these 3 functions all operations can be done much more comfortable in the workflow, but also in connection with the ui (download file, copy file, preview file), because all references to the files are always ensured. - - -STEP 3........................... - - -All routes: remove error handling details and repeating tasks like user check etc. to pack into auth module, only function calls - -Agent task manager to start at the result and organize tasks backwards +funktion für integration von file in message, als basis db-file-id oder document-part-from-agent; damit alle attribute füllen inkl zusammenfassung pro content --> pro extractor-typ ein file Workflow: - NO-FILES for the workflow! @@ -74,6 +31,7 @@ frontend to react PRIO2: +implement cleanup routines for files in lucydom_interface (File_Management_CLEANUP_INTERVAL): temp older than interval, all orphaned frontend: no labels definition @@ -84,6 +42,158 @@ add connector to myoutlook ----------------------- DONE +annst du bitte den Code Vorschlag von Dir als class "ChatManager" ins modul "chat.py" umbauen und mir diese class liefern. hier zusätzliche infos und dokumente. + +für die implementierung der funktionen bitte die beiliegenden module als grundlage verwenden, aber allen code neu erstellen. denn die heutigen codes sind viel zu lange haben zuviele details auf allen levels drin. die implementierung der funktionen soll ebenfalls high-level sein, indem alle detail-ausführungen in grundlagen-funktionen ausgelagert werden. + +folgende anhänge dazu: +- lucydom_model und lucydom_interface : datenmodell und interface zum datenmodell (wir arbeiten nur mir dem workflow object) +- workflow.py: die routerdatei, welche die funktionen von lucydom_interface über den gateway nutzt +- agentservice_registry (old): registry der agenten, diese bitte neu und kompakt erstellen als "chat_registry.py" +- agentservice_base (old): template für agents definitionen. + +kannst du bitte mit dem datenmodell (es wurde angepasst) folgendes tun: + +1. lcuydom_interface.py überarbeiten, damit es mit dem angepassten datenmodell wieder korrekt funktioniert. + +2. workflow.py überarbeiten, sodass die immer wieder gleichen funktionen der routes in hilfsfunktionen ausgelagert werden und alle routinen umschreiben, dass sie nicht agentservice_workflow_manager.py" aufrufen, sondern "chat.py". in der router funktion "workflow.py" keine implementierungen, sondern diese in die chat.py funktion übergeben. Die route "submit_user_input" umschreiben, dass workflow_id auch leer sein kann. direkt die funktion "workflow_integrate_userinput" aufrufen. + + +3. die funktionen implementieren mit diesen hinweisen: + +workflow_integrate_userinput: + - den parameter workflow umbenennen in optional workflow_id. dieser kann initial None sein, wenn ein neuer workflow startet. daher zuerst die zu implementierende funktion workflow_init(workflow_id) aufrufen, welche das workflow object zurückgibt. + - generell werden 2 kommunikationen geführt: + - a) "log_add" (umbenennen von "send_message_to_user") sendet einen log-eintrag, implementiert in mit der implementierung in "lucydom_interface.create_workflow_log" und gleichzeitig einen "Info" Eintrag im logger erstellen + - b) "message_add" speichert eine message im workflow objekt. Implementierung über lucydom_interface + - Vor Step 1. die message_user im workflow als neue message speichern + - Anstatt "# Send initial response" die "user_response" als message object im workflow speichern und auch gleich den obj_answer und obj_workplan in den logger schreiben mittels einer hilfsfunktion "json2text(), welche das json-Objekt als Strukturobjekt lesbar schreibgeschützt + - send_message_to_user(step_info), dies als log_add schreiben + - format_final_response umbenennen in format_final_message und damit das finale message objekt mit den documents erstellen, dieses dann mit messagE_add dem workflow zufügen +- update_workflow(...) nicht mehr nötig, dafür workflow_finish + + +prompt_project_manager: + - mach nur einen typ "doc_type" und gib dafür eine abschliessende liste von optionen an, welche aus der funktion get_available_document_types() kommen + - der obj_workplan soll pro listenelement doc_input und doc_output ein Dict haben mit den Elementen "label","doc_type". auch hier die abschliessende liste der möglichen werte angeben, welche aus der funktion get_available_document_types() kommt. + + +workflow_init: + - wenn die workflow_id leer ist oder nicht existiert, wird ein neuer workflow erzeugt, andernfalls wird der bestehende workflow geladen + - die statuswerte werden gesetzt: status="running", started_at, last_activity=strated_at + +workflow_finish: + - die statuswerte werden gesetzt: status="stopped", last_activity + +message_add: +- die message dem workflow ergänzen +- die statuswerte werden gesetzt: last_activity, last_message_id + +get_available_agents: +- die function aus der agents_registry aufrufen + +get_available_document_types: +- liste dieser doc-types ausgeben: text, csv, png, html + +summarize_workflow(workflow,prompt): +- in der chronologie der messages von aktuell zu historisch pro message mit der funktion summarize_message(prompt) die zusammenfassung holen. Die zusammenfasusng ausgeben mit agent-name, generierte zusammenfassung, liste der dokumente mit jeweils ihrer zusammenfassung + +summarize_message(prompt): +- mit ai call die zusammenfassung der message mit dem prompt generieren. Die zusammenfasusng ausgeben mit agent-name, generierte zusammenfassung des contents, liste der dokumente mit jeweils ihrer zusammenfassung + +summarize_user_documents: +- pro document mit dem angegebenen prompt den content zusammenfassen und die liste ausgeben mit [document.content: text] + +call_agent: braucht es nicht, ai calls können direkt über den connector erfolgen, welcher initial eingebunden wird: "from connectors.connector_aichat_openai import ChatService" + + + + + +Kannst Du mir die python funktion erstellen, um nachfolgendes zu tun. Ich möchte eine kompakte Funktion, welche keine Details enthält, ausser den Prompt-Teil bis und mit Antwort an den user. Alle nötigen Datenkonversionen und Details bitte in Hilfs-funktionen auslagern. Diese müssen nicht implementiert sein, sondern nur deren input und output definieren. + +# Kontext + +Der User liefert im AI Chat eine Anfrage in einem Message Objekt. Dieses beinhaltet seinen Prompt und eine Liste der mitgelieferten Dokumente mit ihnen contents im "message" objekt. Ebenfalls verfügbar ist der bisherige Chatverlauf im objekt "workflow". + +Wir befinden uns in der python funktion "workflow_integrate_userinput", wo der User prompt ankommt, also diese 2 parameter: "message_user" und "workflow". + +Es steht eine Liste von agents zur Verfügung. Das agents in der Art: +- Loop: Er führt repetitive Aufgaben aus. Er benötigt eine Liste von Dokumenten und einen Prompt zur Anwendung auf jedes Dokument, er liefert eine liste von "content" +. Coder: Er führt Pyton Code aus. Benötigt als Input einen Prompt, content und die spezifikation des resultatformates. +(weitere...) + +# Auftrag + +Kannst Du mir bitte den Prompt für den Projektleiter zusammenstellen, welcher dem User die Antwort liefert. + +Dies soll er tun: + +1. Eine Liste von Resultaten, welche der User für seine Antwort benötigt, als json-Objekt "obj_answer" liefern. Die Antworten des Projektleiters sollen strikt in einem vorgegebenen json-format geliefert werden. + +2. Antwort des Vorgehens an den Benutzer mit den Resultat-Dokumenten als Liste senden + +3. Falls für die Antwort oder die Resultate Inputs von Agenten (diese sind gemäss "obj_agents" mit ihren eigenschaften definiert) benötigt werden, diese als json Liste (ich nenne sie "obj_workplan") angeben, welcher agent welches resultat liefern soll + +Dann soll der Code dies machen: + +4. die agenten gemäss obj_workplan ausführen lassen und den user über jeden schritt informieren. die gelieferten dokumente als liste sammeln "obj_results". Jeden Agenten mit den Datenobjekten gemäss seiner Datenstruktur bedienen. + +Dann anhand der gelieferten Dokumente die finale Antwort an den Benutzer senden. Dokumente vom Typ "text" direkt in die Antwort an den Benutzer integrieren. Die Dokumente referenzieren. + +Dann im Code: + +5. Dem benutzer die antwort mit den dokumenten senden + + +Jedes Dokument soll anhand des Labels eindeutig identifizierbar sein. Du hast alle Dokument-conteot-labels im workflow objekt. + +Diese Objektinformationen dazu: + +- datenmodell für workflow inklusive message: + + - workflow + - messages: list of message + + - message + - agent (who created message) + - input (the input prompt) + - content (text) + - documents: list of document + + - document + - source + - contents: list of content + + - content + - label + - format: formatType + - data: the data of the content in the format according to formatType + + - formatType: [text, csv, jpg, gif, png] + + +- obj_answer: json-Liste mit diesen Attributen: + - label: document label (unique name in the documents list) + - doc_type_src: document type des zu liefernden dokumentes: [text, csv, png, html] + - doc_type_final: document type des dokumentes an den Benutzer: [text, csv, jpg, gif, png, pdf, html, docx, xlsx] + - summary: summary of required document content + +- obj_workplan: json-Liste mit diesen Attributen: + - agent: agent identifier based on the given agent list with the skills of the agents + - doc_output: List of label,doc_type_src (documents to deliver) + - prompt: Prompt to use for answer delivery and document-content-extraction + - doc_input: List of label,doc_type_src (documents to read with prompt) + +- obj_agents: Pro Agent sind diese Informationen verfügbar: + - name: Sein Name, um die entsprechende Funktion aufzurufen + - skills: Was dieser Agent macht + - input: datenformat, in welchem der agent die informationen benötigt + +- obj_result: List of documents with label, format, data + +Es soll durchgängig mit dem content objekt gearbeitet werden, wenn content übergeben wird. + backend: all object actions in interfaces generic for the objects in models for CRU-methods diff --git a/notes/readme.md b/notes/readme.md index 1f628b73..44186a3d 100644 --- a/notes/readme.md +++ b/notes/readme.md @@ -32,6 +32,8 @@ Das Projekt besteht aus zwei Hauptkomponenten: - `requirements.txt` - Python-Abhängigkeiten ### Backend-Installation Lokal +0. lokal dev: (anaconda environment) conda activate poweron + 1. Virtuelle Umgebung erstellen und aktivieren: ```bash python -m venv venv diff --git a/requirements.txt b/requirements.txt index 243e4c80..894e5f6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ fastapi==0.104.1 uvicorn==0.23.2 python-multipart==0.0.6 httpx==0.25.0 -pydantic==2.4.2 +pydantic==1.10.13 # Ältere Version ohne Rust-Abhängigkeit ## Authentication & Security python-jose==3.3.0 @@ -18,12 +18,13 @@ fitz PyPDF2==3.0.1 ## Data Processing & Analysis -pandas==2.2.3 -numpy -scikit-learn==1.4.0 +numpy==1.26.3 # Version die mit pandas und matplotlib kompatibel ist +pandas==2.2.3 # Aktuelle Version beibehalten + +FuzzyTM>=0.4.0 ## Data Visualization -matplotlib==3.8.0 +matplotlib==3.8.0 # Aktuelle Version beibehalten seaborn==0.13.0 plotly==5.18.0 @@ -33,4 +34,4 @@ requests==2.31.0 ## Utilities python-dateutil==2.8.2 -python-dotenv==1.0.0 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/routes/files.py b/routes/files.py index 3345ec5c..00c71c2e 100644 --- a/routes/files.py +++ b/routes/files.py @@ -1,10 +1,13 @@ from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional import logging from datetime import datetime +from dataclasses import dataclass +import io from modules.auth import get_current_active_user, get_user_context +from modules.utility import APP_CONFIG # Import interfaces from modules.lucydom_interface import get_lucydom_interface, FileError, FileNotFoundError, FileStorageError, FilePermissionError, FileDeletionError @@ -27,6 +30,32 @@ def get_model_attributes(model_class): # Modell-Attribute für FileItem file_attributes = get_model_attributes(FileItem) +@dataclass +class AppContext: + """Kontext-Objekt für alle benötigten Verbindungen und Benutzerinformationen""" + mandate_id: int + user_id: int + interface_data: Any # LucyDOM Interface + +async def get_context(current_user: Dict[str, Any]) -> AppContext: + """ + Erstellt ein zentrales Kontext-Objekt mit allen benötigten Interfaces + + Args: + current_user: Aktueller Benutzer aus der Authentifizierung + + Returns: + AppContext-Objekt mit allen benötigten Verbindungen + """ + mandate_id, user_id = await get_user_context(current_user) + interface_data = get_lucydom_interface(mandate_id, user_id) + + return AppContext( + mandate_id=mandate_id, + user_id=user_id, + interface_data=interface_data + ) + # Router für Datei-Endpunkte erstellen router = APIRouter( prefix="/api/files", @@ -44,13 +73,10 @@ router = APIRouter( async def get_files(current_user: Dict[str, Any] = Depends(get_current_active_user)): """Alle verfügbaren Dateien abrufen""" try: - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Alle Dateien generisch abrufen - files = lucy_interface.get_all_files() + files = context.interface_data.get_all_files() return files except Exception as e: logger.error(f"Fehler beim Abrufen der Dateien: {str(e)}") @@ -67,33 +93,29 @@ async def upload_file( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """ - Upload einer Datei für Workflows oder allgemeine Nutzung. + Upload einer Datei """ try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Datei einlesen file_content = await file.read() - # Größenbeschränkung prüfen (z.B. 50MB) - max_size = 50 * 1024 * 1024 # 50MB in Bytes + # Größenbeschränkung prüfen + max_size = int(APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")) * 1024 * 1024 # in Bytes if len(file_content) > max_size: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, - detail=f"Datei zu groß. Maximale Größe: 50MB" + detail=f"Datei zu groß. Maximale Größe: {APP_CONFIG.get("File_Management_MAX_UPLOAD_SIZE_MB")}MB" ) - # Datei über das LucyDOM-Interface speichern - file_meta = lucydom.save_uploaded_file(file_content, file.filename) + # Datei über das LucyDOM-Interface in der Datenbank speichern + file_meta = context.interface_data.save_uploaded_file(file_content, file.filename) # Wenn workflow_id angegeben, aktualisiere die Dateiinformationen if workflow_id: update_data = {"workflow_id": workflow_id} - lucydom.update_file(file_meta["id"], update_data) + context.interface_data.update_file(file_meta["id"], update_data) file_meta["workflow_id"] = workflow_id # Erfolgreiche Antwort @@ -119,36 +141,23 @@ async def get_file( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """ - Gibt eine Datei anhand ihrer ID zurück. + Gibt eine Datei anhand ihrer ID direkt aus der Datenbank zurück. """ try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) - - # Datei über das LucyDOM-Interface abrufen - file_data = lucydom.download_file(file_id) + # Datei über das LucyDOM-Interface aus der Datenbank abrufen + file_data = context.interface_data.download_file(file_id) # Datei zurückgeben - if "path" in file_data and file_data["path"]: - # FileResponse verwenden, wenn ein Pfad vorhanden ist (effizienteres Streaming) - return FileResponse( - path=file_data["path"], - media_type=file_data["content_type"], - filename=file_data["name"] - ) - else: - # Response mit Binärdaten, wenn kein Pfad vorhanden ist - headers = { - "Content-Disposition": f'attachment; filename="{file_data["name"]}"' - } - return Response( - content=file_data["content"], - media_type=file_data["content_type"], - headers=headers - ) + headers = { + "Content-Disposition": f'attachment; filename="{file_data["name"]}"' + } + return Response( + content=file_data["content"], + media_type=file_data["content_type"], + headers=headers + ) except FileNotFoundError as e: logger.warning(f"Datei nicht gefunden: {str(e)}") @@ -182,17 +191,13 @@ async def delete_file( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """ - Löscht eine Datei anhand ihrer ID. + Löscht eine Datei anhand ihrer ID aus der Datenbank. """ try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Datei über das LucyDOM-Interface löschen - lucydom.delete_file(file_id) + context.interface_data.delete_file(file_id) # Erfolgreiche Löschung ohne Inhalt zurückgeben (204 No Content) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -222,80 +227,6 @@ async def delete_file( detail=f"Fehler beim Löschen der Datei: {str(e)}" ) - -@router.get("/cleanup/orphaned", response_model=Dict[str, Any]) -async def cleanup_orphaned_files( - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """ - Bereinigt verwaiste Dateien, die physisch existieren aber keine Einträge in der Datenbank haben. - Nur für Administratoren. - """ - try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) - - # Prüfen, ob der Benutzer Admin-Rechte hat - # TODO: Implementieren einer richtigen Admin-Rechteverwaltung - if user_id != 1: # Temporäre einfache Lösung - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Nur Administratoren können diese Funktion ausführen" - ) - - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) - - # Verwaiste Dateien bereinigen - lucydom.cleanup_orphaned_files() - - # Temporäre Dateien bereinigen - lucydom.cleanup_temp_files() - - return {"status": "success", "message": "Bereinigung abgeschlossen"} - - except Exception as e: - logger.error(f"Fehler bei der Datei-Bereinigung: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Fehler bei der Datei-Bereinigung: {str(e)}" - ) - - -@router.get("/temp/cleanup", response_model=Dict[str, Any]) -async def cleanup_temp_files( - max_age_hours: int = Query(24, ge=1, le=168), - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """ - Bereinigt temporäre Dateien, die älter als die angegebene Zeit sind. - - Args: - max_age_hours: Maximales Alter der temporären Dateien in Stunden (1-168) - """ - try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) - - # Temporäre Dateien bereinigen - lucydom.cleanup_temp_files(max_age_hours) - - return { - "status": "success", - "message": f"Temporäre Dateien älter als {max_age_hours} Stunden wurden bereinigt" - } - - except Exception as e: - logger.error(f"Fehler bei der Bereinigung temporärer Dateien: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Fehler bei der Bereinigung temporärer Dateien: {str(e)}" - ) - - @router.get("/stats", response_model=Dict[str, Any]) async def get_file_stats( current_user: Dict[str, Any] = Depends(get_current_active_user) @@ -304,14 +235,10 @@ async def get_file_stats( Gibt Statistiken über die gespeicherten Dateien zurück. """ try: - # Kontext-Informationen extrahieren - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Kontext holen - lucydom = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Alle Dateien abrufen - all_files = lucydom.get_all_files() + all_files = context.interface_data.get_all_files() # Statistiken berechnen total_files = len(all_files) @@ -320,7 +247,7 @@ async def get_file_stats( # Nach Dateityp gruppieren file_types = {} for file in all_files: - file_type = file.get("type", "unknown") + file_type = file.get("mime_type", "unknown").split("/")[0] if file_type not in file_types: file_types[file_type] = 0 file_types[file_type] += 1 diff --git a/routes/mandates.py b/routes/mandates.py index 6d282032..5b01d92a 100644 --- a/routes/mandates.py +++ b/routes/mandates.py @@ -2,10 +2,10 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path from typing import List, Dict, Any from fastapi import status from datetime import datetime - -from modules.auth import get_current_active_user, get_user_context +from dataclasses import dataclass # Import interfaces +from modules.auth import get_current_active_user, get_user_context from modules.gateway_interface import get_gateway_interface from modules.gateway_model import Mandate @@ -23,6 +23,32 @@ def get_model_attributes(model_class): # Modell-Attribute für Mandate mandate_attributes = get_model_attributes(Mandate) +@dataclass +class AppContext: + """Kontext-Objekt für alle benötigten Verbindungen und Benutzerinformationen""" + mandate_id: int + user_id: int + interface_data: Any # Gateway Interface + +async def get_context(current_user: Dict[str, Any]) -> AppContext: + """ + Erstellt ein zentrales Kontext-Objekt mit allen benötigten Interfaces + + Args: + current_user: Aktueller Benutzer aus der Authentifizierung + + Returns: + AppContext-Objekt mit allen benötigten Verbindungen + """ + mandate_id, user_id = await get_user_context(current_user) + interface_data = get_gateway_interface(mandate_id, user_id) + + return AppContext( + mandate_id=mandate_id, + user_id=user_id, + interface_data=interface_data + ) + # Router für Mandanten-Endpunkte erstellen router = APIRouter( prefix="/api/mandates", @@ -33,10 +59,7 @@ router = APIRouter( @router.get("", response_model=List[Dict[str, Any]]) async def get_mandates(current_user: Dict[str, Any] = Depends(get_current_active_user)): """Alle verfügbaren Mandanten abrufen (nur für SysAdmin-Benutzer)""" - mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, user_id) + context = await get_context(current_user) # Berechtigungsprüfung if current_user.get("privilege") != "sysadmin": @@ -46,7 +69,7 @@ async def get_mandates(current_user: Dict[str, Any] = Depends(get_current_active ) # Mandanten generisch abrufen - return gateway.get_all_mandates() + return context.interface_data.get_all_mandates() @router.post("", response_model=Dict[str, Any]) @@ -55,10 +78,7 @@ async def create_mandate( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen neuen Mandanten erstellen (nur für SysAdmin-Benutzer)""" - mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, user_id) + context = await get_context(current_user) # Berechtigungsprüfung if current_user.get("privilege") != "sysadmin": @@ -80,7 +100,7 @@ async def create_mandate( mandate_data["language"] = "de" # Mandant erstellen - new_mandate = gateway.create_mandate(**mandate_data) + new_mandate = context.interface_data.create_mandate(**mandate_data) return new_mandate @@ -91,16 +111,13 @@ async def get_mandate( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestimmten Mandanten abrufen""" - user_mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(user_mandate_id, user_id) + context = await get_context(current_user) # Berechtigungsprüfung # Admin darf nur seinen eigenen Mandanten sehen, SysAdmin alle is_admin = current_user.get("privilege") == "admin" is_sysadmin = current_user.get("privilege") == "sysadmin" - is_own_mandate = user_mandate_id == mandate_id + is_own_mandate = context.mandate_id == mandate_id if (is_admin and not is_own_mandate) and not is_sysadmin: raise HTTPException( @@ -109,7 +126,7 @@ async def get_mandate( ) # Mandant generisch abrufen - mandate = gateway.get_mandate(mandate_id) + mandate = context.interface_data.get_mandate(mandate_id) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -125,13 +142,10 @@ async def update_mandate( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestehenden Mandanten aktualisieren""" - user_mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(user_mandate_id, user_id) + context = await get_context(current_user) # Mandant existiert? - mandate = gateway.get_mandate(mandate_id) + mandate = context.interface_data.get_mandate(mandate_id) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -141,7 +155,7 @@ async def update_mandate( # Berechtigungsprüfung is_admin = current_user.get("privilege") == "admin" is_sysadmin = current_user.get("privilege") == "sysadmin" - is_own_mandate = user_mandate_id == mandate_id + is_own_mandate = context.mandate_id == mandate_id if (is_admin and not is_own_mandate) and not is_sysadmin: raise HTTPException( @@ -156,7 +170,7 @@ async def update_mandate( update_data[attr] = mandate_data[attr] # Mandant aktualisieren - updated_mandate = gateway.update_mandate( + updated_mandate = context.interface_data.update_mandate( mandate_id=mandate_id, mandate_data=update_data ) @@ -169,13 +183,10 @@ async def delete_mandate( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen Mandanten löschen, inklusive aller zugehörigen Benutzer und referenzierten Objekte""" - user_mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(user_mandate_id, user_id) + context = await get_context(current_user) # Mandant existiert? - mandate = gateway.get_mandate(mandate_id) + mandate = context.interface_data.get_mandate(mandate_id) if not mandate: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -185,7 +196,7 @@ async def delete_mandate( # Berechtigungsprüfung is_admin = current_user.get("privilege") == "admin" is_sysadmin = current_user.get("privilege") == "sysadmin" - is_own_mandate = user_mandate_id == mandate_id + is_own_mandate = context.mandate_id == mandate_id if (is_admin and not is_own_mandate) and not is_sysadmin: raise HTTPException( @@ -194,7 +205,7 @@ async def delete_mandate( ) # Mandant löschen - success = gateway.delete_mandate(mandate_id) + success = context.interface_data.delete_mandate(mandate_id) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/routes/prompts.py b/routes/prompts.py index e8c06788..39d2f013 100644 --- a/routes/prompts.py +++ b/routes/prompts.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Query, Path from typing import List, Dict, Any, Optional from fastapi import status from datetime import datetime +from dataclasses import dataclass # Import auth module from modules.auth import get_current_active_user, get_user_context @@ -24,6 +25,32 @@ def get_model_attributes(model_class): # Modell-Attribute für Prompt prompt_attributes = get_model_attributes(Prompt) +@dataclass +class AppContext: + """Kontext-Objekt für alle benötigten Verbindungen und Benutzerinformationen""" + mandate_id: int + user_id: int + interface_data: Any # LucyDOM Interface + +async def get_context(current_user: Dict[str, Any]) -> AppContext: + """ + Erstellt ein zentrales Kontext-Objekt mit allen benötigten Interfaces + + Args: + current_user: Aktueller Benutzer aus der Authentifizierung + + Returns: + AppContext-Objekt mit allen benötigten Verbindungen + """ + mandate_id, user_id = await get_user_context(current_user) + interface_data = get_lucydom_interface(mandate_id, user_id) + + return AppContext( + mandate_id=mandate_id, + user_id=user_id, + interface_data=interface_data + ) + # Router für Prompt-Endpunkte erstellen router = APIRouter( prefix="/api/prompts", @@ -36,13 +63,10 @@ async def get_prompts( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Alle Prompts abrufen""" - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Prompts generisch abrufen - return lucy_interface.get_all_prompts() + return context.interface_data.get_all_prompts() @router.post("", response_model=Dict[str, Any]) @@ -51,10 +75,7 @@ async def create_prompt( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen neuen Prompt erstellen""" - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Attribute aus dem Request dynamisch setzen prompt_data = {} @@ -67,7 +88,7 @@ async def create_prompt( name = prompt.get("name", "Neuer Prompt") # Prompt erstellen - new_prompt = lucy_interface.create_prompt( + new_prompt = context.interface_data.create_prompt( content=content, name=name ) @@ -85,13 +106,10 @@ async def get_prompt( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestimmten Prompt abrufen""" - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Prompt generisch abrufen - prompt = lucy_interface.get_prompt(prompt_id) + prompt = context.interface_data.get_prompt(prompt_id) if not prompt: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -108,13 +126,10 @@ async def update_prompt( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestehenden Prompt aktualisieren""" - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Prüfe, ob der Prompt existiert - existing_prompt = lucy_interface.get_prompt(prompt_id) + existing_prompt = context.interface_data.get_prompt(prompt_id) if not existing_prompt: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -132,7 +147,7 @@ async def update_prompt( name = prompt_data.get("name") # Prompt aktualisieren - updated_prompt = lucy_interface.update_prompt( + updated_prompt = context.interface_data.update_prompt( prompt_id=prompt_id, content=content, name=name @@ -153,20 +168,17 @@ async def delete_prompt( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen Prompt löschen""" - mandate_id, user_id = await get_user_context(current_user) - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + context = await get_context(current_user) # Prüfe, ob der Prompt existiert - existing_prompt = lucy_interface.get_prompt(prompt_id) + existing_prompt = context.interface_data.get_prompt(prompt_id) if not existing_prompt: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Prompt mit ID {prompt_id} nicht gefunden" ) - success = lucy_interface.delete_prompt(prompt_id) + success = context.interface_data.delete_prompt(prompt_id) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/routes/users.py b/routes/users.py index f4af740c..f60ea108 100644 --- a/routes/users.py +++ b/routes/users.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path from typing import List, Dict, Any, Optional from fastapi import status from datetime import datetime +from dataclasses import dataclass # Import auth module from modules.auth import get_current_active_user, get_user_context @@ -24,6 +25,32 @@ def get_model_attributes(model_class): # Modell-Attribute für User user_attributes = get_model_attributes(User) +@dataclass +class AppContext: + """Kontext-Objekt für alle benötigten Verbindungen und Benutzerinformationen""" + mandate_id: int + user_id: int + interface_data: Any # Gateway Interface + +async def get_context(current_user: Dict[str, Any]) -> AppContext: + """ + Erstellt ein zentrales Kontext-Objekt mit allen benötigten Interfaces + + Args: + current_user: Aktueller Benutzer aus der Authentifizierung + + Returns: + AppContext-Objekt mit allen benötigten Verbindungen + """ + mandate_id, user_id = await get_user_context(current_user) + interface_data = get_gateway_interface(mandate_id, user_id) + + return AppContext( + mandate_id=mandate_id, + user_id=user_id, + interface_data=interface_data + ) + # Router für Benutzer-Endpunkte erstellen router = APIRouter( prefix="/api/users", @@ -34,10 +61,7 @@ router = APIRouter( @router.get("", response_model=List[Dict[str, Any]]) async def get_users(current_user: Dict[str, Any] = Depends(get_current_active_user)): """Alle verfügbaren Benutzer abrufen (nur für Admin/SysAdmin-Benutzer)""" - mandate_id, user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, user_id) + context = await get_context(current_user) # Berechtigungsprüfung if current_user.get("privilege") not in ["admin", "sysadmin"]: @@ -48,9 +72,9 @@ async def get_users(current_user: Dict[str, Any] = Depends(get_current_active_us # Admin sieht nur Benutzer des eigenen Mandanten, SysAdmin sieht alle if current_user.get("privilege") == "admin": - return gateway.get_users_by_mandate(mandate_id) + return context.interface_data.get_users_by_mandate(context.mandate_id) else: # sysadmin - return gateway.get_all_users() + return context.interface_data.get_all_users() @router.post("/register", response_model=Dict[str, Any]) async def register_user(user_data: dict = Body(...)): @@ -100,24 +124,22 @@ async def get_user( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestimmten Benutzer abrufen""" - mandate_id, current_user_id = await get_user_context(current_user) + context = await get_context(current_user) # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, current_user_id) - - # Berechtigungsprüfung - # Benutzer darf nur sich selbst abrufen, Admin nur Benutzer des eigenen Mandanten, SysAdmin alle - user_to_get = gateway.get_user(user_id) + user_to_get = context.interface_data.get_user(user_id) if not user_to_get: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Benutzer mit ID {user_id} nicht gefunden" ) - if user_id == current_user_id: + # Berechtigungsprüfung + # Benutzer darf nur sich selbst abrufen, Admin nur Benutzer des eigenen Mandanten, SysAdmin alle + if user_id == context.user_id: # Benutzer darf sich selbst abrufen pass - elif current_user.get("privilege") == "admin" and user_to_get.get("mandate_id") == mandate_id: + elif current_user.get("privilege") == "admin" and user_to_get.get("mandate_id") == context.mandate_id: # Admin darf Benutzer des eigenen Mandanten abrufen pass elif current_user.get("privilege") == "sysadmin": @@ -138,13 +160,10 @@ async def update_user( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen bestehenden Benutzer aktualisieren""" - mandate_id, current_user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, current_user_id) + context = await get_context(current_user) # Benutzer existiert? - user_to_update = gateway.get_user(user_id) + user_to_update = context.interface_data.get_user(user_id) if not user_to_update: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -152,10 +171,10 @@ async def update_user( ) # Berechtigungsprüfung - is_self_update = user_id == current_user_id + is_self_update = user_id == context.user_id is_admin = current_user.get("privilege") == "admin" is_sysadmin = current_user.get("privilege") == "sysadmin" - same_mandate = user_to_update.get("mandate_id") == mandate_id + same_mandate = user_to_update.get("mandate_id") == context.mandate_id # Filtere erlaubte Felder je nach Berechtigungsstufe allowed_fields = {"username", "email", "full_name", "language"} @@ -194,7 +213,7 @@ async def update_user( update_data = {k: v for k, v in update_data.items() if k in allowed_fields} # User-Daten aktualisieren - updated_user = gateway.update_user(user_id, update_data) + updated_user = context.interface_data.update_user(user_id, update_data) return updated_user @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -203,13 +222,10 @@ async def delete_user( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Einen Benutzer löschen""" - mandate_id, current_user_id = await get_user_context(current_user) - - # Gateway-Interface mit Benutzerkontext initialisieren - gateway = get_gateway_interface(mandate_id, current_user_id) + context = await get_context(current_user) # Benutzer existiert? - user_to_delete = gateway.get_user(user_id) + user_to_delete = context.interface_data.get_user(user_id) if not user_to_delete: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -217,10 +233,10 @@ async def delete_user( ) # Berechtigungsprüfung - is_self_delete = user_id == current_user_id + is_self_delete = user_id == context.user_id is_admin = current_user.get("privilege") == "admin" is_sysadmin = current_user.get("privilege") == "sysadmin" - same_mandate = user_to_delete.get("mandate_id") == mandate_id + same_mandate = user_to_delete.get("mandate_id") == context.mandate_id if is_self_delete: # Benutzer darf sich selbst löschen @@ -238,7 +254,7 @@ async def delete_user( ) # Benutzer und alle referenzierten Objekte löschen - success = gateway.delete_user(user_id) + success = context.interface_data.delete_user(user_id) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, diff --git a/routes/workflows.py b/routes/workflows.py index 5e565cc2..8d4ef5a2 100644 --- a/routes/workflows.py +++ b/routes/workflows.py @@ -7,34 +7,16 @@ import asyncio import uuid from datetime import datetime import logging - -from modules.auth import get_current_active_user, get_user_context +from dataclasses import dataclass # Import interfaces from modules.lucydom_interface import get_lucydom_interface -from modules.agentservice_workflow_manager import get_workflow_manager - -# Import für AI-Service -from connectors.connector_aichat_openai import ChatService as OpenAIChatService -from connectors.connector_aichat_anthropic import ChatService as AnthropicChatService +from modules.auth import get_current_active_user, get_user_context +from modules.chat import get_chat_manager # Import models import modules.lucydom_model as lucydom_model -# Alle Attribute des Models ermitteln (außer interne/spezielle Attribute) -def get_model_attributes(model_class): - return [attr for attr in dir(model_class) - if not callable(getattr(model_class, attr)) - and not attr.startswith('_') - and attr != 'metadata' - and attr != 'query' - and attr != 'query_class' - and attr != 'label' - and attr != 'field_labels'] - -# Modell-Attribute für Workflow -workflow_attributes = get_model_attributes(lucydom_model.Workflow) - # Logger konfigurieren logger = logging.getLogger(__name__) @@ -45,202 +27,113 @@ router = APIRouter( responses={404: {"description": "Not found"}} ) -# Hilfsfunktion zum Erstellen des AI-Services basierend auf der Konfiguration -def get_ai_service(mandate_id: int, user_id: int): - """Gibt den konfigurierten AI-Service zurück""" - import configload - - config = configload.load_config() - ai_provider = config.get('Module_AgentserviceInterface', 'AI_PROVIDER').lower() - if ai_provider == "anthropic": - return AnthropicChatService() - else: - return OpenAIChatService() +@dataclass +class AppContext: + """Kontext-Objekt für alle benötigten Verbindungen und Benutzerinformationen""" + mandate_id: int + user_id: int + interface_data: Any # LucyDOM Interface + interface_chat: Any # Chat Manager -@router.get("", response_model=List[Dict[str, Any]]) -async def list_workflows( - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """Listet alle Workflows des Benutzers auf""" - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Use the refactored list_workflows method that takes mandate_id and user_id - workflows = await workflow_manager.list_workflows(mandate_id, user_id) - return workflows - -@router.post("", response_model=Dict[str, Any]) -async def create_workflow( - workflow_request: lucydom_model.WorkflowCreateRequest = Body(...), - current_user: Dict[str, Any] = Depends(get_current_active_user) -): +async def get_context(current_user: Dict[str, Any]) -> AppContext: """ - Erstellt einen neuen Workflow und führt ihn aus. - """ - mandate_id, user_id = await get_user_context(current_user) + Erstellt ein zentrales Kontext-Objekt mit allen benötigten Interfaces - # Add debug logging for the workflow request - logger.debug(f"Creating workflow with request: {workflow_request}") - logger.debug(f"Files in request: {workflow_request.files}") - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) - - # Prüfen, ob Dateien existieren - files = [] - for file_id in workflow_request.files: - # Add logging before file lookup - logger.debug(f"Looking up file with ID: {file_id}") + Args: + current_user: Aktueller Benutzer aus der Authentifizierung - file = lucy_interface.get_file(file_id) - if not file: - logger.warning(f"File with ID {file_id} not found in database") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Datei mit ID {file_id} nicht gefunden" - ) - - # Add logging on successful file lookup - logger.debug(f"Found file: {file.get('name', 'unknown')} (ID: {file_id})") - files.append(file) + Returns: + AppContext-Objekt mit allen benötigten Verbindungen + """ + mandate_id, user_id = await get_user_context(current_user) + interface_data = get_lucydom_interface(mandate_id, user_id) + interface_chat = get_chat_manager(mandate_id, user_id) - # Workflow ID generieren - workflow_id = str(uuid.uuid4()) - - # AI-Service erstellen - ai_service = get_ai_service(mandate_id, user_id) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id, ai_service) - - # Grundlegende Workflow-Daten erstellen - workflow_data = { - "id": workflow_id, - "mandate_id": mandate_id, - "user_id": user_id, - "name": workflow_request.name or f"Workflow {workflow_id}", - "status": "running", - "started_at": datetime.now().isoformat(), - "last_activity": datetime.now().isoformat(), - "prompt": workflow_request.prompt - } - - # Attribute aus dem Request dynamisch setzen - for attr in workflow_attributes: - if hasattr(workflow_request, attr) and getattr(workflow_request, attr) is not None: - workflow_data[attr] = getattr(workflow_request, attr) - - # Workflow in Datenbank speichern - this should now be handled by initialize_workflow in the manager - if lucy_interface: - try: - lucy_interface.create_workflow(workflow_data) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Fehler beim Speichern des Workflows in der Datenbank: {str(e)}" - ) - - # Log files before executing workflow - logger.info(f"Executing workflow with {len(files)} files:") - for file in files: - logger.debug(f"File: {file.get('name', 'unknown')} (ID: {file.get('id', 'unknown')})") - - # Workflow starten (asynchron) - workflow_task = asyncio.create_task( - workflow_manager.execute_workflow( - message={"content": workflow_request.prompt, "role": "user"}, - files=files, - workflow_id=workflow_id - ) + return AppContext( + mandate_id=mandate_id, + user_id=user_id, + interface_data=interface_data, + interface_chat=interface_chat ) - # Workflow-Namen setzen, falls vorhanden - if workflow_request.name: - if workflow_id in workflow_manager.workflows: - workflow_manager.workflows[workflow_id]["name"] = workflow_request.name - workflow_manager._save_workflow(workflow_manager.workflows[workflow_id]) + +@router.get("", response_model=List[Dict[str, Any]]) +async def list_workflows(current_user: Dict[str, Any] = Depends(get_current_active_user)): + """Listet alle Workflows des Benutzers auf""" + context = await get_context(current_user) - # Sofort eine Antwort zurückgeben - return { - "workflow_id": workflow_id, - "status": "running", - "message": "Workflow wurde gestartet" - } + # Workflows für den Benutzer abrufen + workflows = context.interface_data.get_workflows_by_user(context.user_id) + + return workflows - -@router.get("/{workflow_id}", response_model=Dict[str, Any]) -async def get_workflow( - workflow_id: str, +@router.post("/{workflow_id}/user-input", response_model=Dict[str, Any]) +async def submit_user_input( + workflow_id: Optional[str] = Path(None, description="ID des Workflows (optional)"), + user_input: Dict[str, Any] = Body(...), current_user: Dict[str, Any] = Depends(get_current_active_user) ): """ - Gibt detaillierte Informationen zu einem Workflow zurück. + Ermöglicht es dem Benutzer, Eingaben für einen laufenden Workflow zu senden + oder einen neuen Workflow zu starten. """ - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - workflow = await workflow_manager.load_workflow(workflow_id) - if not workflow: + # Improved logging + logger.info(f"Benutzereingabe für Workflow {workflow_id or 'neu'} empfangen") + + try: + # Workflow mit dem Chat-Manager fortsetzen oder neu starten + workflow = await context.interface_chat.chat_run(user_input, workflow_id) + + if not workflow: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Fehler bei der Verarbeitung der Benutzereingabe" + ) + + return { + "workflow_id": workflow.get("id"), + "status": "processing", + "message": "Benutzereingabe wurde empfangen und wird verarbeitet" + } + except HTTPException: + # HTTP-Exceptions weiterleiten + raise + except Exception as e: + logger.error(f"Fehler bei der Verarbeitung der Benutzereingabe: {str(e)}", exc_info=True) raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Fehler bei der Verarbeitung der Benutzereingabe: {str(e)}" ) - - return workflow -@router.put("/{workflow_id}", response_model=Dict[str, Any]) -async def update_workflow( - workflow_id: str, - workflow_data: Dict[str, Any] = Body(...), +@router.post("/{workflow_id}/stop", response_model=Dict[str, Any]) +async def stop_workflow( + workflow_id: str = Path(..., description="ID des zu stoppenden Workflows"), current_user: Dict[str, Any] = Depends(get_current_active_user) ): - """Aktualisiert Metadaten eines Workflows""" - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) + """Stoppt einen laufenden Workflow""" + context = await get_context(current_user) # Workflow laden - workflow = await workflow_manager.load_workflow(workflow_id) + workflow = context.interface_data.get_workflow(workflow_id) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow mit ID {workflow_id} nicht gefunden" ) - # LucyDOM-Interface mit Benutzerkontext initialisieren, um Datenbank zu aktualisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) + # Status auf "stopped" setzen + workflow["status"] = "stopped" + workflow["last_activity"] = datetime.now().isoformat() - # Attribute aus dem Request dynamisch filtern - update_data = {} - - # Zunächst alle updatebaren Attribute sammeln - for attr in workflow_attributes: - if attr in workflow_data: - update_data[attr] = workflow_data[attr] - - # Besondere Attribute (außerhalb des Models) separat behandeln - if "last_activity" not in update_data: - update_data["last_activity"] = datetime.now().isoformat() - - # Daten im Workflow aktualisieren - for key, value in update_data.items(): - workflow[key] = value - - # Workflow speichern - workflow_manager._save_workflow(workflow) - - # In Datenbank aktualisieren - if lucy_interface: - lucy_interface.update_workflow(workflow_id, update_data) + # Workflow aktualisieren + context.interface_data.update_workflow(workflow_id, workflow) return { "workflow_id": workflow_id, - "message": "Workflow wurde aktualisiert" + "status": "stopped", + "message": "Workflow wurde gestoppt" } @router.delete("/{workflow_id}", response_model=Dict[str, Any]) @@ -249,13 +142,10 @@ async def delete_workflow( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Löscht einen Workflow""" - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Using the refactored delete_workflow method - success = await workflow_manager.delete_workflow(workflow_id) + # Workflow löschen + success = context.interface_data.delete_workflow(workflow_id) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -267,32 +157,66 @@ async def delete_workflow( "message": "Workflow wurde gelöscht" } +@router.get("/{workflow_id}/data-statistics", response_model=Dict[str, Any]) +async def get_workflow_data_statistics( + workflow_id: str, + current_user: Dict[str, Any] = Depends(get_current_active_user) +): + """ + Gibt Statistiken über die übertragenen Datenmengen für einen Workflow zurück. + """ + context = await get_context(current_user) + + # Workflow laden + workflow = context.interface_data.get_workflow(workflow_id) + if not workflow: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Workflow mit ID {workflow_id} nicht gefunden" + ) + + # Datenstatistiken zurückgeben + data_stats = workflow.get("data_stats", {}) + if not data_stats: + data_stats = { + "total_processing_time": 0.0, + "total_token_count": 0, + "total_bytes_sent": 0, + "total_bytes_received": 0 + } + + return { + "workflow_id": workflow_id, + "data_stats": data_stats + } + @router.get("/{workflow_id}/status", response_model=Dict[str, Any]) async def get_workflow_status( workflow_id: str, current_user: Dict[str, Any] = Depends(get_current_active_user) ): - """Status eines laufenden Workflows abrufen""" - mandate_id, user_id = await get_user_context(current_user) + """Status eines Workflows abrufen""" + context = await get_context(current_user) - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) + # Workflow laden + workflow = context.interface_data.get_workflow(workflow_id) + if not workflow: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Workflow mit ID {workflow_id} nicht gefunden" + ) - # Use the refactored get_workflow_status method - status = workflow_manager.get_workflow_status(workflow_id) - if not status: - # Versuche, den Workflow zu laden - workflow = await workflow_manager.load_workflow(workflow_id) - if not workflow: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" - ) - - # Status aus dem geladenen Workflow erstellen - status = workflow_manager.get_workflow_status(workflow_id) + # Status aus dem geladenen Workflow erstellen + status_info = { + "id": workflow.get("id"), + "name": workflow.get("name"), + "status": workflow.get("status"), + "started_at": workflow.get("started_at"), + "last_activity": workflow.get("last_activity"), + "data_stats": workflow.get("data_stats", {}) + } - return status + return status_info @router.get("/{workflow_id}/logs", response_model=List[Dict[str, Any]]) async def get_workflow_logs( @@ -300,24 +224,20 @@ async def get_workflow_logs( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Protokolle eines Workflows abrufen""" - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Use the refactored get_workflow_logs method - logs = workflow_manager.get_workflow_logs(workflow_id) - if logs is None: - # Versuche, den Workflow zu laden - workflow = await workflow_manager.load_workflow(workflow_id) + # Logs abrufen + logs = context.interface_data.get_workflow_logs(workflow_id) + if not logs: + # Prüfen, ob der Workflow existiert + workflow = context.interface_data.get_workflow(workflow_id) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow mit ID {workflow_id} nicht gefunden" ) - - # Logs aus dem geladenen Workflow - logs = workflow.get("logs", []) + # Leere Log-Liste zurückgeben + logs = [] return logs @@ -327,384 +247,23 @@ async def get_workflow_messages( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """Nachrichten eines Workflows abrufen""" - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Use the refactored get_workflow_messages method - messages = workflow_manager.get_workflow_messages(workflow_id) + # Nachrichten abrufen + messages = context.interface_data.get_workflow_messages(workflow_id) if messages is None: - # Versuche, den Workflow zu laden - workflow = await workflow_manager.load_workflow(workflow_id) + # Prüfen, ob der Workflow existiert + workflow = context.interface_data.get_workflow(workflow_id) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow mit ID {workflow_id} nicht gefunden" ) - - # Nachrichten aus dem geladenen Workflow - messages = workflow.get("messages", []) + # Leere Nachrichtenliste zurückgeben + messages = [] return messages -@router.post("/{workflow_id}/stop", response_model=Dict[str, Any]) -async def stop_workflow( - workflow_id: str = Path(..., description="ID des zu stoppenden Workflows"), - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """Stoppt einen laufenden Workflow""" - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Use the refactored stop_workflow method - result = await workflow_manager.stop_workflow(workflow_id) - if not result: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden oder bereits beendet" - ) - - return { - "workflow_id": workflow_id, - "status": "stopped", - "message": "Workflow wurde gestoppt" - } - -@router.post("/{workflow_id}/user-input", response_model=Dict[str, Any]) -async def submit_user_input( - workflow_id: str = Path(..., description="ID des Workflows"), - user_input: Dict[str, Any] = Body(...), - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """ - Ermöglicht es dem Benutzer, Eingaben für einen laufenden Workflow zu senden. - Dies wird verwendet, wenn der User-Agent im Workflow angesprochen wird. - """ - mandate_id, user_id = await get_user_context(current_user) - - # Improved logging - logger.info(f"User input received for workflow {workflow_id}") - logger.debug(f"Input content: {user_input.get('message', '')[:50]}...") - - # LucyDOM-Interface mit Benutzerkontext initialisieren - lucy_interface = get_lucydom_interface(mandate_id, user_id) - - # Ensure message content is valid - message_content = user_input.get("message", "") - if isinstance(message_content, dict) and "content" in message_content: - message_content = message_content["content"] - - # If message content is None or empty, use a default value - if message_content is None or message_content.strip() == "": - logger.warning(f"Empty message received for workflow {workflow_id}, using default") - message_content = "Fortsetzung des Workflows" - - # Process additional files - additional_files = [] - additional_file_ids = user_input.get("additional_files", []) - logger.info(f"Processing {len(additional_file_ids)} additional files") - - # Validate each file ID - for file_id in additional_file_ids: - try: - file = lucy_interface.get_file(file_id) - if not file: - logger.warning(f"File with ID {file_id} not found") - continue - - # Check if file belongs to the current mandate/user - if file.get("mandate_id") != mandate_id: - logger.warning(f"File {file_id} does not belong to mandate {mandate_id}") - continue - - additional_files.append(file) - logger.info(f"Added file {file.get('name', 'unnamed')} (ID: {file_id})") - except Exception as e: - logger.error(f"Error processing file {file_id}: {str(e)}") - # Continue with remaining files instead of failing - continue - - # AI-Service und Web-Service erstellen - ai_service = get_ai_service(mandate_id, user_id) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id, ai_service) - - # Load and verify workflow - try: - workflow = await workflow_manager.load_workflow(workflow_id) - if not workflow: - logger.error(f"Workflow {workflow_id} not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" - ) - - if workflow.get("status") == "running": - logger.info(f"Workflow {workflow_id} is still running but will be continued with new input") - asyncio.create_task(workflow_manager.stop_workflow(workflow_id)) - - # Create message object with proper structure - message_object = { - "content": message_content, - "role": "user" - } - - # Execute workflow with user input - logger.info(f"Executing workflow {workflow_id} with user input") - response = await workflow_manager.execute_workflow( - message=message_object, - files=additional_files, - workflow_id=workflow_id, - is_user_input=True - ) - - logger.info(f"Workflow execution completed: {response.get('status', 'unknown')}") - - return { - "workflow_id": workflow_id, - "status": "processing", - "message": "Benutzereingabe wurde empfangen und wird verarbeitet" - } - except HTTPException: - # Re-raise HTTP exceptions - raise - except Exception as e: - logger.error(f"Error processing user input: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Fehler bei der Verarbeitung der Benutzereingabe: {str(e)}" - ) - - -@router.get("/{workflow_id}/data-statistics", response_model=Dict[str, Any]) -async def get_workflow_data_statistics( - workflow_id: str, - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """ - Gibt Statistiken über die übertragenen Datenmengen für einen Workflow zurück. - """ - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Use the refactored get_workflow_status method to get data stats - status = workflow_manager.get_workflow_status(workflow_id) - if not status: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" - ) - - # Gib nur die Datenstatistiken zurück - if "data_stats" in status: - return { - "workflow_id": workflow_id, - "data_stats": status["data_stats"] - } - else: - return { - "workflow_id": workflow_id, - "data_stats": { - "total_processing_time": 0.0, - "total_token_count": 0, - "total_bytes_sent": 0, - "total_bytes_received": 0 - } - } - -@router.post("/{workflow_id}/export", response_model=Dict[str, Any]) -async def export_workflow( - workflow_id: str, - export_format: str = Query("json", description="Exportformat ('json', 'csv', 'pdf')"), - current_user: Dict[str, Any] = Depends(get_current_active_user) -): - """ - Exportiert einen Workflow in verschiedenen Formaten. - """ - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - - # Workflow laden - workflow = await workflow_manager.load_workflow(workflow_id) - if not workflow: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" - ) - - # Export je nach Format durchführen - export_path = None - - if export_format == "json": - # JSON-Export ist einfach der Workflow selbst - export_path = os.path.join(workflow_manager.results_dir, f"workflow_{workflow_id}_export.json") - with open(export_path, 'w', encoding='utf-8') as f: - json.dump(workflow, f, indent=2, ensure_ascii=False) - - elif export_format == "csv": - # CSV-Export der Messages und Logs - from csv import writer - - export_path = os.path.join(workflow_manager.results_dir, f"workflow_{workflow_id}_messages.csv") - with open(export_path, 'w', newline='', encoding='utf-8') as f: - csv_writer = writer(f) - # Überschriften schreiben - csv_writer.writerow(["ID", "Sequence", "Role", "Content", "Agent Type", "Created At"]) - # Messages schreiben - for msg in workflow.get("messages", []): - csv_writer.writerow([ - msg.get("id", ""), - msg.get("sequence_no", ""), - msg.get("role", ""), - msg.get("content", ""), - msg.get("agent_type", ""), - msg.get("started_at", "") - ]) - - # Logs exportieren - logs_path = os.path.join(workflow_manager.results_dir, f"workflow_{workflow_id}_logs.csv") - with open(logs_path, 'w', newline='', encoding='utf-8') as f: - csv_writer = writer(f) - # Überschriften schreiben - csv_writer.writerow(["ID", "Type", "Message", "Timestamp", "Agent ID", "Agent Name"]) - # Logs schreiben - for log in workflow.get("logs", []): - csv_writer.writerow([ - log.get("id", ""), - log.get("type", ""), - log.get("message", ""), - log.get("timestamp", ""), - log.get("agent_id", ""), - log.get("agent_name", "") - ]) - - export_path = [export_path, logs_path] # Beide Dateien zurückgeben - - elif export_format == "pdf": - # PDF-Export erfordert zusätzliche Bibliotheken (z.B. reportlab) - try: - from reportlab.lib.pagesizes import letter - from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle - from reportlab.lib.styles import getSampleStyleSheet - - export_path = os.path.join(workflow_manager.results_dir, f"workflow_{workflow_id}_export.pdf") - - doc = SimpleDocTemplate(export_path, pagesize=letter) - styles = getSampleStyleSheet() - elements = [] - - # Titel - elements.append(Paragraph(f"Workflow: {workflow.get('name', workflow_id)}", styles['Title'])) - elements.append(Spacer(1, 12)) - - # Workflow-Info - elements.append(Paragraph("Workflow Information", styles['Heading2'])) - elements.append(Paragraph(f"ID: {workflow_id}", styles['Normal'])) - elements.append(Paragraph(f"Status: {workflow.get('status', 'unknown')}", styles['Normal'])) - elements.append(Paragraph(f"Started: {workflow.get('started_at', '')}", styles['Normal'])) - if workflow.get('completed_at'): - elements.append(Paragraph(f"Completed: {workflow.get('completed_at')}", styles['Normal'])) - elements.append(Spacer(1, 12)) - - # Messages - elements.append(Paragraph("Messages", styles['Heading2'])) - - # Tabelle für Messages - message_data = [["Sequence", "Role", "Agent Type", "Content"]] - for msg in sorted(workflow.get("messages", []), key=lambda x: x.get("sequence_no", 0)): - content = msg.get("content", "") - if len(content) > 500: # Zu lange Inhalte kürzen - content = content[:500] + "..." - message_data.append([ - str(msg.get("sequence_no", "")), - msg.get("role", ""), - msg.get("agent_type", ""), - content - ]) - - if len(message_data) > 1: # Nur wenn Messages vorhanden sind - table = Table(message_data, colWidths=[40, 70, 70, 350]) - table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), (0.9, 0.9, 0.9)), - ('TEXTCOLOR', (0, 0), (-1, 0), (0, 0, 0)), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), (0.95, 0.95, 0.95)), - ('GRID', (0, 0), (-1, -1), 1, (0.5, 0.5, 0.5)) - ])) - elements.append(table) - else: - elements.append(Paragraph("No messages found", styles['Normal'])) - - elements.append(Spacer(1, 12)) - - # Logs - elements.append(Paragraph("Logs", styles['Heading2'])) - - # Tabelle für Logs - log_data = [["Type", "Timestamp", "Agent", "Message"]] - for log in sorted(workflow.get("logs", []), key=lambda x: x.get("timestamp", "")): - log_data.append([ - log.get("type", ""), - log.get("timestamp", ""), - log.get("agent_name", ""), - log.get("message", "") - ]) - - if len(log_data) > 1: # Nur wenn Logs vorhanden sind - table = Table(log_data, colWidths=[60, 120, 80, 270]) - table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), (0.9, 0.9, 0.9)), - ('TEXTCOLOR', (0, 0), (-1, 0), (0, 0, 0)), - ('ALIGN', (0, 0), (-1, -1), 'LEFT'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), (0.95, 0.95, 0.95)), - ('GRID', (0, 0), (-1, -1), 1, (0.5, 0.5, 0.5)) - ])) - elements.append(table) - else: - elements.append(Paragraph("No logs found", styles['Normal'])) - - # PDF generieren - doc.build(elements) - - except ImportError: - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="PDF-Export nicht verfügbar (reportlab nicht installiert)" - ) - - else: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Unbekanntes Exportformat: {export_format}" - ) - - if not export_path: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Export konnte nicht durchgeführt werden" - ) - - # Pfad(e) im Ergebnis zurückgeben - export_result = { - "workflow_id": workflow_id, - "format": export_format, - "path": export_path - } - - return export_result - @router.delete("/{workflow_id}/messages/{message_id}", response_model=Dict[str, Any]) async def delete_workflow_message( workflow_id: str = Path(..., description="ID des Workflows"), @@ -716,66 +275,33 @@ async def delete_workflow_message( Diese Funktion entfernt die Nachricht aus dem Workflow und auch aus der Datenbank. """ - mandate_id, user_id = await get_user_context(current_user) - - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) + context = await get_context(current_user) try: - # Workflow laden - workflow = await workflow_manager.load_workflow(workflow_id) + # Prüfen, ob der Workflow existiert + workflow = context.interface_data.get_workflow(workflow_id) if not workflow: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Workflow mit ID {workflow_id} nicht gefunden" ) - # Prüfen, ob die Nachricht im Workflow vorhanden ist - if "messages" not in workflow: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Keine Nachrichten im Workflow {workflow_id} gefunden" - ) + # Nachricht löschen + success = context.interface_data.delete_workflow_message(workflow_id, message_id) - # Nachricht finden - message_index = next((i for i, m in enumerate(workflow["messages"]) - if m.get("id") == message_id), -1) - - if message_index == -1: + if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Nachricht mit ID {message_id} im Workflow {workflow_id} nicht gefunden" ) - # Nachricht aus dem Workflow entfernen - deleted_message = workflow["messages"].pop(message_index) - - # Log über Löschung hinzufügen - using the refactored _add_log method - workflow_manager._add_log( - workflow, - f"Nachricht gelöscht: {deleted_message.get('role', 'unknown')} - {message_id[:8]}...", - "info" - ) - - # Workflow speichern - workflow_manager._save_workflow(workflow) - - # Bei aktivem LucyDOM-Interface auch dort löschen - if workflow_manager.lucydom_interface: - try: - # Diese Methode muss im LucyDOM-Interface implementiert werden - workflow_manager.lucydom_interface.delete_workflow_message(workflow_id, message_id) - except Exception as e: - # Fehler beim Löschen in der Datenbank loggen, aber nicht scheitern lassen - logger.warning(f"Nachricht aus Workflow entfernt, aber Fehler beim Löschen aus der Datenbank: {str(e)}") - return { "workflow_id": workflow_id, "message_id": message_id, "success": True, "message": "Nachricht erfolgreich gelöscht" } - + except HTTPException: # Bekannte HTTP-Exceptions weiterleiten raise @@ -786,7 +312,6 @@ async def delete_workflow_message( detail=f"Fehler beim Löschen der Nachricht: {str(e)}" ) - @router.delete("/{workflow_id}/messages/{message_id}/files/{file_id}", response_model=Dict[str, Any]) async def delete_file_from_message( workflow_id: str = Path(..., description="ID des Workflows"), @@ -795,132 +320,24 @@ async def delete_file_from_message( current_user: Dict[str, Any] = Depends(get_current_active_user) ): """ - Löscht eine einzelne Datei aus einer Nachricht im Workflow. + Löscht eine einzelne Dateireferenz aus einer Nachricht im Workflow. + Die Datei selbst wird nicht aus der Datenbank gelöscht, nur die Referenz in der Nachricht. """ - mandate_id, user_id = await get_user_context(current_user) + context = await get_context(current_user) # Add detailed logging logger.debug(f"DELETE request: Remove file {file_id} from message {message_id} in workflow {workflow_id}") - # WorkflowManager mit Benutzerkontext initialisieren - workflow_manager = get_workflow_manager(mandate_id, user_id) - try: - # Workflow laden - workflow = await workflow_manager.load_workflow(workflow_id) - if not workflow: - logger.error(f"Workflow {workflow_id} not found") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Workflow mit ID {workflow_id} nicht gefunden" - ) + # Datei aus der Nachricht entfernen + success = context.interface_data.delete_file_from_message(workflow_id, message_id, file_id) - # Log workflow info - logger.debug(f"Workflow found: {workflow.get('name', workflow_id)}") - - # Print message structure to debug - if "messages" in workflow: - logger.debug(f"Workflow has {len(workflow['messages'])} messages") - for i, msg in enumerate(workflow['messages']): - logger.debug(f"Message {i+1}: ID={msg.get('id')}, Type={msg.get('agent_type')}") - - # Nachricht finden - try different approaches - message = None - - # First try exact match - message = next((m for m in workflow.get("messages", []) if m.get("id") == message_id), None) - - # If not found, try case-insensitive match - if not message and isinstance(message_id, str): - message = next((m for m in workflow.get("messages", []) - if isinstance(m.get("id"), str) and m.get("id").lower() == message_id.lower()), None) - - # If still not found, try partial match with the beginning of ID - if not message and isinstance(message_id, str): - message = next((m for m in workflow.get("messages", []) - if isinstance(m.get("id"), str) and m.get("id").startswith(message_id)), None) - - if not message: - logger.error(f"Message {message_id} not found in workflow {workflow_id}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Nachricht mit ID {message_id} im Workflow {workflow_id} nicht gefunden" - ) - - # Log message info - logger.debug(f"Message found: {message.get('id')}, type: {message.get('agent_type')}") - - # Check documents array - if "documents" not in message or not message["documents"]: - logger.error(f"No documents in message {message_id}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Keine Dateien in der Nachricht {message_id} gefunden" - ) - - # Debug log documents - logger.debug(f"Message has {len(message['documents'])} documents") - for i, doc in enumerate(message["documents"]): - source = doc.get("source", {}) - logger.debug(f"Document {i+1}: ID={doc.get('id')}, Source ID={source.get('id')}") - - # Search for file with flexible matching - found_file = False - file_index = -1 - found_doc = None - - # Try all possible variations of file references - for i, doc in enumerate(message["documents"]): - doc_id = doc.get("id") - source = doc.get("source", {}) - source_id = source.get("id") - - # Try matching different ID formats - if ((doc_id and doc_id == file_id) or - (source_id and source_id == file_id) or - (doc_id and isinstance(doc_id, str) and file_id in doc_id) or - (source_id and isinstance(source_id, str) and file_id in source_id)): - file_index = i - found_file = True - found_doc = doc - logger.debug(f"Found file at index {i}: doc_id={doc_id}, source_id={source_id}") - break - - if not found_file: - logger.error(f"File {file_id} not found in message {message_id}") + if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Datei mit ID {file_id} in der Nachricht {message_id} nicht gefunden" ) - # Remove file reference - deleted_file = message["documents"].pop(file_index) - - # Log removal - file_name = deleted_file.get("source", {}).get("name", file_id) - logger.info(f"Removed file {file_name} from message {message_id}") - - # Add log entry using the refactored _add_log method - workflow_manager._add_log( - workflow, - f"Datei aus Nachricht entfernt: {file_name} (ID: {file_id})", - "info", - message.get("agent_id"), - message.get("agent_type") - ) - - # Update workflow state - workflow_manager._save_workflow(workflow) - - # Update database if available - if workflow_manager.lucydom_interface: - try: - # Pass the file document and message to LucyDOM interface for more robust handling - workflow_manager.lucydom_interface.update_workflow_message(message["id"], message) - logger.debug(f"Database updated for message {message_id}") - except Exception as e: - logger.warning(f"Database update warning: {str(e)}") - return { "workflow_id": workflow_id, "message_id": message_id, @@ -930,10 +347,10 @@ async def delete_file_from_message( } except HTTPException: - # Re-raise HTTP exceptions + # HTTP-Exceptions weiterleiten raise except Exception as e: - logger.error(f"Error deleting file: {str(e)}") + logger.error(f"Fehler beim Löschen der Datei: {str(e)}") import traceback traceback_str = traceback.format_exc() logger.error(f"Traceback: {traceback_str}") diff --git a/test_gateway.py b/test_gateway.py new file mode 100644 index 00000000..1891836a --- /dev/null +++ b/test_gateway.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Testskript zum Erstellen eines Workflows mit Prompt und Datei über den Gateway. +""" + +import requests +import json +import os +import time +import sys +from datetime import datetime + +# Konfiguration +API_BASE_URL = "http://localhost:8000" # Anpassen an deine Gateway-URL +API_TOKEN = "your_api_token_here" # Dein API-Token + +# Headers für Authentifizierung +HEADERS = { + "Authorization": f"Bearer {API_TOKEN}", + "Content-Type": "application/json" +} + +def log_message(message): + """Gibt eine formatierte Nachricht mit Zeitstempel aus""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] {message}") + +def upload_file(file_path): + """Lädt eine Datei hoch und gibt die Datei-ID zurück""" + log_message(f"Lade Datei hoch: {file_path}") + + if not os.path.exists(file_path): + log_message(f"FEHLER: Datei nicht gefunden: {file_path}") + return None + + # Multipart-Formular für Datei-Upload vorbereiten + file_name = os.path.basename(file_path) + files = { + 'file': (file_name, open(file_path, 'rb'), 'application/octet-stream') + } + + # Datei hochladen + upload_url = f"{API_BASE_URL}/api/files/upload" + response = requests.post( + upload_url, + headers={"Authorization": f"Bearer {API_TOKEN}"}, # Nur Authorization-Header + files=files + ) + + if response.status_code != 200: + log_message(f"FEHLER: Datei-Upload fehlgeschlagen. Status: {response.status_code}") + log_message(f"Response: {response.text}") + return None + + # Datei-ID extrahieren + file_data = response.json() + file_id = file_data.get("id") + log_message(f"Datei erfolgreich hochgeladen. ID: {file_id}") + + return file_id + +def create_workflow(prompt, file_id=None): + """Erstellt einen neuen Workflow mit Prompt und optionaler Datei""" + log_message("Erstelle neuen Workflow...") + + # Nachricht für den Workflow vorbereiten + user_input = { + "message": prompt + } + + # Wenn eine Datei-ID vorhanden ist, füge sie hinzu + if file_id: + user_input["additional_files"] = [file_id] + + # Workflow erstellen + workflow_url = f"{API_BASE_URL}/api/workflows/user-input" + response = requests.post( + workflow_url, + headers=HEADERS, + json=user_input + ) + + if response.status_code >= 400: + log_message(f"FEHLER: Workflow-Erstellung fehlgeschlagen. Status: {response.status_code}") + log_message(f"Response: {response.text}") + return None + + # Workflow-ID extrahieren + workflow_data = response.json() + workflow_id = workflow_data.get("workflow_id") + log_message(f"Workflow erfolgreich erstellt. ID: {workflow_id}") + + return workflow_id + +def poll_workflow_status(workflow_id, max_attempts=20, delay=2): + """Fragt den Status eines Workflows ab und wartet bis zur Fertigstellung""" + log_message(f"Prüfe Status des Workflows {workflow_id}...") + + for attempt in range(1, max_attempts + 1): + status_url = f"{API_BASE_URL}/api/workflows/{workflow_id}/status" + response = requests.get( + status_url, + headers=HEADERS + ) + + if response.status_code != 200: + log_message(f"FEHLER: Status-Abfrage fehlgeschlagen. Status: {response.status_code}") + continue + + status_data = response.json() + current_status = status_data.get("status") + log_message(f"Workflow-Status: {current_status} (Versuch {attempt}/{max_attempts})") + + if current_status in ["completed", "stopped", "failed"]: + log_message(f"Workflow ist abgeschlossen mit Status: {current_status}") + return status_data + + time.sleep(delay) + + log_message(f"Maximale Anzahl von Versuchen erreicht. Letzter Status: {current_status}") + return None + +def get_workflow_messages(workflow_id): + """Ruft alle Nachrichten eines Workflows ab""" + log_message(f"Hole Nachrichten für Workflow {workflow_id}...") + + messages_url = f"{API_BASE_URL}/api/workflows/{workflow_id}/messages" + response = requests.get( + messages_url, + headers=HEADERS + ) + + if response.status_code != 200: + log_message(f"FEHLER: Abrufen der Nachrichten fehlgeschlagen. Status: {response.status_code}") + return [] + + messages = response.json() + log_message(f"{len(messages)} Nachrichten gefunden.") + + return messages + +def print_workflow_results(workflow_id): + """Gibt die Ergebnisse eines Workflows aus""" + log_message("=== WORKFLOW-ERGEBNISSE ===") + + # Status abrufen + status_url = f"{API_BASE_URL}/api/workflows/{workflow_id}/status" + status_response = requests.get(status_url, headers=HEADERS) + + if status_response.status_code == 200: + status_data = status_response.json() + log_message(f"Workflow-Name: {status_data.get('name')}") + log_message(f"Status: {status_data.get('status')}") + log_message(f"Gestartet: {status_data.get('started_at')}") + log_message(f"Letzte Aktivität: {status_data.get('last_activity')}") + + # Nachrichten abrufen und ausgeben + messages = get_workflow_messages(workflow_id) + log_message(f"Anzahl der Nachrichten: {len(messages)}") + + for i, msg in enumerate(messages, 1): + log_message(f"--- Nachricht {i} ---") + log_message(f"Rolle: {msg.get('role')}") + + # Inhalt gekürzt ausgeben (maximal ersten 200 Zeichen) + content = msg.get('content', '') + if content: + if len(content) > 200: + log_message(f"Inhalt: {content[:200]}... [gekürzt]") + else: + log_message(f"Inhalt: {content}") + + # Anzahl der Dokumente ausgeben + docs = msg.get('documents', []) + if docs: + log_message(f"Dokumente: {len(docs)}") + for j, doc in enumerate(docs, 1): + source = doc.get('source', {}) + doc_name = source.get('name', f"Dokument {j}") + log_message(f" - {doc_name}") + +def main(): + """Hauptfunktion zum Testen des Workflows""" + # Beispiel-Datei zum Hochladen (Pfad anpassen) + file_path = "example.csv" # Hier den Pfad zu deiner Testdatei angeben + + # Prompt für den Workflow + test_prompt = """Bitte analysiere die angehängte Datei und erstelle eine Zusammenfassung der wichtigsten Informationen. + Wenn es sich um eine CSV-Datei handelt, identifiziere die Spalten und gib mir einen Überblick über die enthaltenen Daten. + Erstelle außerdem eine Visualisierung, wenn du Zahlenwerte in der Datei findest.""" + + try: + # Datei hochladen + file_id = upload_file(file_path) + if not file_id: + log_message("Test abgebrochen: Datei konnte nicht hochgeladen werden.") + return False + + # Workflow erstellen + workflow_id = create_workflow(test_prompt, file_id) + if not workflow_id: + log_message("Test abgebrochen: Workflow konnte nicht erstellt werden.") + return False + + # Auf Abschluss des Workflows warten + workflow_status = poll_workflow_status(workflow_id) + if not workflow_status: + log_message("Test unvollständig: Timeout beim Warten auf Workflow-Abschluss.") + # Weiter machen, um zumindest Teilergebnisse zu sehen + + # Ergebnisse ausgeben + print_workflow_results(workflow_id) + + log_message("Test abgeschlossen.") + return True + + except Exception as e: + log_message(f"FEHLER: Unerwartete Ausnahme: {str(e)}") + import traceback + log_message(traceback.format_exc()) + return False + +if __name__ == "__main__": + log_message("=== WORKFLOW-TEST GESTARTET ===") + success = main() + log_message(f"=== WORKFLOW-TEST BEENDET (Erfolgreich: {success}) ===") + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/test_workflow.py b/test_workflow.py new file mode 100644 index 00000000..0568159b --- /dev/null +++ b/test_workflow.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +""" +Backend-Testskript für die Workflow-Funktionalität mit Prompt und Datei. +Dieses Skript testet die Backend-Komponenten direkt, ohne über die API zu gehen. +""" + +import os +import sys +import asyncio +import uuid +from datetime import datetime +import logging +import json + +# Pfad zum Projekt-Root hinzufügen, damit Module gefunden werden +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Logging konfigurieren +logging.basicConfig( + level=logging.INFO, + format='[%(asctime)s] %(levelname)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger("backend_test") + +# Imports aus dem Backend +from modules.lucydom_interface import get_lucydom_interface +from modules.chat import get_chat_manager + +# Testparameter +TEST_MANDATE_ID = 1 +TEST_USER_ID = 1 +TEST_FILE_PATH = "d:/temp/prompt_a1.txt" # Pfad zur Testdatei anpassen +TEST_FILE_PATH1 = "d:/temp/LF-Nutshell.png" # Pfad zur Testdatei anpassen +TEST_PROMPT = """Bitte analysiere die angehängte Datei und erstelle eine Zusammenfassung der wichtigsten Informationen. +Erstelle außerdem eine Visualisierung, wenn du Zahlenwerte in der Datei findest.""" + +async def upload_test_file(): + """Lädt eine Testdatei ins Backend hoch und gibt die Datei-ID zurück""" + logger.info(f"Lade Testdatei hoch: {TEST_FILE_PATH}") + + # LucyDOM-Interface initialisieren + lucy_interface = get_lucydom_interface(TEST_MANDATE_ID, TEST_USER_ID) + + try: + # Prüfen, ob die Datei existiert + if not os.path.exists(TEST_FILE_PATH): + logger.error(f"Testdatei nicht gefunden: {TEST_FILE_PATH}") + return None + + # Datei lesen + with open(TEST_FILE_PATH, 'rb') as f: + file_content = f.read() + + # Dateinamen extrahieren + file_name = os.path.basename(TEST_FILE_PATH) + + # Datei hochladen + file_meta = lucy_interface.save_uploaded_file(file_content, file_name) + file_id = file_meta.get('id') + + logger.info(f"Datei erfolgreich hochgeladen. ID: {file_id}") + return file_meta + + except Exception as e: + logger.error(f"Fehler beim Hochladen der Datei: {str(e)}") + return None + +async def create_test_workflow(file_meta): + """Erstellt einen Testworkflow mit dem angegebenen Prompt und der Datei""" + logger.info("Erstelle Testworkflow...") + + # Chat-Manager initialisieren + chat_manager = get_chat_manager(TEST_MANDATE_ID, TEST_USER_ID) + + # Nachrichtenobjekt vorbereiten + message = { + "role": "user", + "content": TEST_PROMPT, + "documents": [file_meta] if file_meta else [] + } + + try: + # Workflow erstellen (neue Workflow-ID wird automatisch generiert) + workflow = await chat_manager.workflow_integrate_userinput(message) + + if not workflow: + logger.error("Workflow konnte nicht erstellt werden") + return None + + workflow_id = workflow.get("id") + logger.info(f"Workflow erfolgreich erstellt. ID: {workflow_id}") + return workflow + + except Exception as e: + logger.error(f"Fehler bei der Workflow-Erstellung: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return None + +def print_workflow_details(workflow): + """Gibt Details zum Workflow aus""" + if not workflow: + logger.warning("Kein Workflow zum Anzeigen vorhanden") + return + + logger.info("=== WORKFLOW-DETAILS ===") + logger.info(f"ID: {workflow.get('id')}") + logger.info(f"Name: {workflow.get('name')}") + logger.info(f"Status: {workflow.get('status')}") + logger.info(f"Mandanten-ID: {workflow.get('mandate_id')}") + logger.info(f"Benutzer-ID: {workflow.get('user_id')}") + logger.info(f"Gestartet: {workflow.get('started_at')}") + logger.info(f"Letzte Aktivität: {workflow.get('last_activity')}") + + # Nachrichten ausgeben + messages = workflow.get("messages", []) + logger.info(f"Anzahl der Nachrichten: {len(messages)}") + + for i, msg in enumerate(messages, 1): + logger.info(f"--- Nachricht {i} ---") + logger.info(f"ID: {msg.get('id')}") + logger.info(f"Rolle: {msg.get('role')}") + logger.info(f"Sequenz: {msg.get('sequence_no')}") + logger.info(f"Agent: {msg.get('agent_name')}") + + # Inhalt gekürzt ausgeben + content = msg.get('content', '') + if content: + preview = content[:200] + ('...' if len(content) > 200 else '') + logger.info(f"Inhalt: {preview}") + + # Dokumente auflisten + documents = msg.get('documents', []) + if documents: + logger.info(f"Dokumente: {len(documents)}") + for j, doc in enumerate(documents, 1): + source = doc.get('source', {}) + doc_name = source.get('name', f"Dokument {j}") + logger.info(f" - {doc_name}") + + # Logs ausgeben + logs = workflow.get("logs", []) + logger.info(f"Anzahl der Logs: {len(logs)}") + if len(logs) > 0: + logger.info("Letzte 3 Logs:") + for log in logs[-3:]: + logger.info(f" - [{log.get('timestamp')}] {log.get('message')}") + +async def main(): + """Hauptfunktion für den Backend-Test""" + logger.info("=== BACKEND WORKFLOW-TEST GESTARTET ===") + + try: + # Schritt 1: Testdatei hochladen + file_meta = await upload_test_file() + if not file_meta: + logger.error("Test abgebrochen: Datei konnte nicht hochgeladen werden") + return False + + # Schritt 2: Workflow erstellen + workflow = await create_test_workflow(file_meta) + if not workflow: + logger.error("Test abgebrochen: Workflow konnte nicht erstellt werden") + return False + + # Schritt 3: Workflow-Details ausgeben + print_workflow_details(workflow) + + logger.info("=== BACKEND WORKFLOW-TEST ERFOLGREICH BEENDET ===") + return True + + except Exception as e: + logger.error(f"Unerwarteter Fehler im Test: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return False + +if __name__ == "__main__": + # Event-Loop ausführen + success = asyncio.run(main()) + sys.exit(0 if success else 1) \ No newline at end of file