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