gateway/modules/chat_agent_analyst.py
2025-04-20 22:22:22 +02:00

777 lines
No EOL
31 KiB
Python

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