gateway/gwserver/modules/agentservice_filehandling.py
2025-04-06 23:57:10 +02:00

518 lines
21 KiB
Python

"""
Zentrales Filehandling-Modul für den Agentservice.
Enthält alle Funktionen für das Verarbeiten von Dateien.
Angepasst, um mit LucyDOMInterface als zentrale Datei-Autorität zu arbeiten.
"""
import os
import logging
import base64
import json
import uuid
from datetime import datetime
from typing import Dict, Any, List, Optional, Tuple, Union, BinaryIO
from io import BytesIO # Import BytesIO at the top level
# Bibliotheken für Dateiverarbeitung
try:
import pandas as pd
except ImportError:
pd = None
logger = logging.getLogger(__name__)
# Custom exception für das File-Handling
class FileProcessingError(Exception):
"""Basisklasse für Fehler bei der Dateiverarbeitung im AgentService."""
pass
class FileExtractionError(FileProcessingError):
"""Fehler bei der Textextraktion aus Dateien."""
pass
class FileAnalysisError(FileProcessingError):
"""Fehler bei der Analyse von Dateien."""
pass
def encode_to_base64(content: bytes, mime_type: str = None) -> str:
"""
Kodiert Binärdaten als Base64-String.
Args:
content: Die zu kodierenden Binärdaten
mime_type: Optionaler MIME-Typ für das Encoding
Returns:
Base64-kodierter String
"""
base64_data = base64.b64encode(content).decode('utf-8')
return base64_data
def prepare_file_contexts(files: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Bereitet die Dateikontexte basierend auf Metadaten vor.
Akzeptiert keine Pfade mehr, sondern nur Metadaten aus der Datenbank.
Args:
files: Liste von Dateien mit Metadaten (Dict mit id, name, type, content_type)
Returns:
Liste von Dateikontexten für die Verarbeitung
"""
file_contexts = []
logger.info(f"Preparing file contexts for {len(files)} files")
for file in files:
file_id = file.get("id")
file_name = file.get("name")
file_type = file.get("type")
# Create a comprehensive context with all available metadata
context = {
"id": file_id,
"name": file_name,
"type": file_type,
"size": file.get("size", "Unbekannt"),
"content_type": file.get("content_type"),
"path": file.get("path"),
"upload_date": file.get("upload_date"),
"hash": file.get("hash"),
"mandate_id": file.get("mandate_id"),
"user_id": file.get("user_id")
}
# Log for debugging
logger.info(f"Created file context: {file_name} (ID: {file_id}, Type: {file_type})")
file_contexts.append(context)
return file_contexts
def extract_text_from_file_content(file_content: bytes, file_name: str, content_type: str = None) -> str:
"""
Extrahiert Text aus verschiedenen Dateiformaten basierend auf dem Binärinhalt.
Args:
file_content: Binärinhalt der Datei
file_name: Name der Datei für die Erkennung des Formats
content_type: Optional MIME-Typ der Datei
Returns:
Extrahierter Text oder Fehlermeldung
"""
try:
# Einfache Textdateien
if file_name.endswith(('.txt', '.md', '.json', '.xml', '.html', '.htm', '.css', '.js', '.py')):
try:
return file_content.decode('utf-8')
except UnicodeDecodeError:
try:
return file_content.decode('latin1')
except:
return file_content.decode('cp1252', errors='replace')
# Excel-Dateien
elif file_name.endswith(('.xlsx', '.xls')):
if pd is not None:
# Temporäre Datei im Speicher erstellen
file_obj = BytesIO(file_content)
df = pd.read_excel(file_obj)
result = f"Excel file with {len(df)} rows and {len(df.columns)} columns.\n"
result += f"Columns: {', '.join(df.columns.tolist())}\n\n"
result += df.to_string(index=False)
return result
else:
return f"[Excel-Datei: {file_name} - pandas nicht installiert]"
# CSV-Dateien
elif file_name.endswith('.csv'):
if pd is not None:
try:
# Temporäre Datei im Speicher erstellen
file_obj = BytesIO(file_content)
df = pd.read_csv(file_obj, encoding='utf-8')
except UnicodeDecodeError:
file_obj = BytesIO(file_content)
try:
df = pd.read_csv(file_obj, encoding='latin1')
except:
file_obj = BytesIO(file_content)
df = pd.read_csv(file_obj, encoding='cp1252')
result = f"CSV file with {len(df)} rows and {len(df.columns)} columns.\n"
result += f"Columns: {', '.join(df.columns.tolist())}\n\n"
result += df.to_string(index=False)
return result
else:
return f"[CSV-Datei: {file_name} - pandas nicht installiert]"
# PDF-Dateien
elif file_name.endswith('.pdf'):
try:
try:
from PyPDF2 import PdfReader
# BytesIO is already imported at the top level
reader = PdfReader(BytesIO(file_content))
text = ""
for page in reader.pages:
text += page.extract_text() + "\n\n"
return text
except ImportError:
try:
import fitz # PyMuPDF
# BytesIO is already imported at the top level
doc = fitz.open(stream=file_content, filetype="pdf")
text = ""
for page in doc:
text += page.get_text() + "\n\n"
return text
except ImportError:
return f"[PDF: {file_name} - Keine PDF-Bibliothek installiert]"
except Exception as e:
raise FileExtractionError(f"Fehler beim Lesen der PDF-Datei {file_name}: {str(e)}")
# Sonstige Dateien
else:
return f"[Datei: {file_name} - Textextraktion nicht unterstützt]"
except Exception as e:
logger.error(f"Fehler beim Extrahieren von Text aus {file_name}: {str(e)}")
raise FileExtractionError(f"Fehler beim Extrahieren von Text aus {file_name}: {str(e)}")
async def extract_and_analyze_pdf_images(
pdf_content: bytes,
prompt: str,
ai_service
) -> List[Dict[str, Any]]:
"""
Extrahiert Bilder aus einer PDF-Datei und analysiert sie.
Arbeitet mit Binärdaten statt Dateipfaden.
Args:
pdf_content: Binärdaten der PDF-Datei
prompt: Prompt für die Bildanalyse
ai_service: AI-Service für die Bildanalyse
Returns:
Liste mit Analyseergebnissen für jedes Bild
"""
image_responses = []
temp_files = [] # Liste der temporären Dateien zur Bereinigung
try:
# PDF mit PyMuPDF öffnen
import fitz # PyMuPDF
# BytesIO is already imported at the top level
import tempfile
# PDF im Speicher öffnen
doc = fitz.open(stream=pdf_content, filetype="pdf")
logger.info(f"PDF geöffnet mit {len(doc)} Seiten")
for page_num, page in enumerate(doc, 1):
# Alle Bilder auf der Seite finden
image_list = page.get_images(full=True)
if image_list:
logger.info(f"Seite {page_num}: {len(image_list)} Bilder gefunden")
for img_index, img in enumerate(image_list):
try:
# Bild-Referenz
xref = img[0]
# Bild und Metadaten extrahieren
base_image = doc.extract_image(xref)
image_bytes = base_image["image"] # Tatsächliche Bilddaten
image_ext = base_image["ext"] # Dateiendung (jpg, png, etc.)
# Erstelle temporäre Datei
fd, temp_img_path = tempfile.mkstemp(suffix=f".{image_ext}")
temp_files.append(temp_img_path) # Zur Bereinigungsliste hinzufügen
with os.fdopen(fd, 'wb') as img_file:
img_file.write(image_bytes)
logger.debug(f"Bild temporär gespeichert: {temp_img_path}")
# Analysiere mit AI-Service
try:
analysis_result = await ai_service.analyze_image(
image_data=image_bytes, # Direktes Übergeben der Bilddaten
prompt=prompt,
mime_type=f"image/{image_ext}"
)
logger.debug(f"Bildanalyse für Bild {img_index} auf Seite {page_num} abgeschlossen")
except Exception as analyze_error:
logger.error(f"Fehler bei der Bildanalyse: {str(analyze_error)}")
analysis_result = f"[Fehler bei der Bildanalyse: {str(analyze_error)}]"
# Ergebnis speichern
try:
# Versuche zuerst, die Größe aus base_image zu bekommen
if 'width' in base_image and 'height' in base_image:
image_size = f"{base_image['width']}x{base_image['height']}"
else:
# Alternative: Öffne das temporäre Bild, um die Größe zu bestimmen
from PIL import Image
with Image.open(temp_img_path) as img:
width, height = img.size
image_size = f"{width}x{height}"
except Exception as e:
logger.warning(f"Konnte Bildgröße nicht ermitteln: {str(e)}")
image_size = "unbekannt"
image_responses.append({
"page": page_num,
"image_index": img_index,
"format": image_ext,
"image_size": image_size,
"response": analysis_result
})
except Exception as e:
logger.warning(f"Fehler bei der Extraktion von Bild {img_index} auf Seite {page_num}: {str(e)}")
continue
logger.info(f"Extrahiert und analysiert: {len(image_responses)} Bilder aus PDF")
except ImportError:
logger.error("PyMuPDF (fitz) ist nicht installiert. Installiere es mit 'pip install pymupdf'")
raise FileExtractionError("PyMuPDF (fitz) ist nicht installiert")
except Exception as e:
logger.error(f"Fehler beim Extrahieren von PDF-Bildern: {str(e)}")
raise FileExtractionError(f"Fehler beim Extrahieren von PDF-Bildern: {str(e)}")
finally:
# Bereinige alle temporären Dateien
for temp_file in temp_files:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
except Exception as e:
logger.warning(f"Konnte temporäre Datei nicht entfernen: {temp_file} - {str(e)}")
return image_responses
def add_file_to_message(message: Dict[str, Any], file_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Fügt eine Datei zu einer Nachricht hinzu.
Funktion für Workflow-Manager und interne Verwendung.
Args:
message: Die zu erweiternde Nachricht
file_data: Dateimetadaten und Inhalt
Returns:
Die aktualisierte Nachricht mit der Datei
"""
# Detailed logging for debugging
logger.info(f"Adding file to message: {file_data.get('name', 'unnamed_file')} (ID: {file_data.get('id', 'unknown')})")
# Initialize documents array if needed
if "documents" not in message:
message["documents"] = []
logger.debug("Initialized empty documents array in message")
# Create a unique ID for the document if not provided
doc_id = file_data.get("id", f"file_{uuid.uuid4()}")
# Extract file size if available
file_size = file_data.get("size")
if isinstance(file_size, str) and file_size.isdigit():
file_size = int(file_size)
elif file_size is None and file_data.get("content"):
# Estimate size from content if not provided
file_size = len(file_data.get("content", ""))
# Create standard document structure that matches the data model
document = {
"id": doc_id, # Add an ID to the document itself
"source": {
"type": "file",
"id": file_data.get("id", doc_id),
"name": file_data.get("name", "unnamed_file"),
"content_type": file_data.get("content_type"),
"size": file_size,
"upload_date": file_data.get("upload_date", datetime.now().isoformat())
},
"contents": [
{
"type": "text",
"text": file_data.get("content", "No content available")
}
]
}
# Log document structure for debugging
logger.debug(f"Created document structure: {json.dumps({k: v for k, v in document.items() if k != 'contents'})}")
# Check if file is already in the message to avoid duplicates
file_already_added = any(
doc.get("source", {}).get("id") == file_data.get("id")
for doc in message.get("documents", [])
)
if not file_already_added:
message["documents"].append(document)
logger.info(f"File {file_data.get('name')} successfully added to message (total: {len(message.get('documents', []))} files)")
else:
logger.info(f"File {file_data.get('name')} already exists in message, skipping")
return message
def extract_files_from_message(message: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Extrahiert Dateiinformationen aus einer Nachricht.
Funktion für Workflow-Manager und interne Verwendung.
Args:
message: Die Nachricht, aus der Dateien extrahiert werden sollen
Returns:
Liste der extrahierten Dateiinformationen
"""
files = []
if "documents" not in message:
logger.debug("No documents found in message")
return files
# Log for debugging
logger.debug(f"Extracting files from message with {len(message.get('documents', []))} documents")
for doc in message.get("documents", []):
doc_source = doc.get("source", {})
# Nur Dateien extrahieren
if doc_source.get("type") == "file":
file_info = {
"id": doc_source.get("id", f"file_{uuid.uuid4()}"),
"name": doc_source.get("name", "unnamed_file"),
"content_type": doc_source.get("content_type"),
"size": doc_source.get("size")
}
# Inhalt extrahieren, falls vorhanden
doc_contents = doc.get("contents", [])
for content in doc_contents:
if content.get("type") == "text":
file_info["content"] = content.get("text", "")
break
logger.debug(f"Extracted file: {file_info.get('name')} (ID: {file_info.get('id')})")
files.append(file_info)
else:
logger.debug(f"Skipping non-file document of type: {doc_source.get('type')}")
logger.info(f"Extracted {len(files)} files from message")
return files
async def read_file_contents(
file_contexts: List[Dict[str, Any]],
lucydom_interface,
workflow_id: str = None,
add_log_func = None,
ai_service = None # AI service parameter for image analysis
) -> Dict[str, str]:
"""
Liest den Inhalt aller Dateien und führt bei Bildern und Dokumenten Analysen durch.
Verwendet LucyDOM-Interface statt direkter Dateizugriffe.
Args:
file_contexts: Liste der Dateikontexte mit Metadaten
lucydom_interface: LucyDOM-Interface für Dateizugriffe
workflow_id: Optionale ID des Workflows für Logging
add_log_func: Optionale Funktion für das Hinzufügen von Logs
ai_service: Optionaler AI-Service für die Bildanalyse
Returns:
Dictionary mit Dateiinhalten (file_id -> content)
"""
file_contents = {}
# Add debug logging
logger.info(f"Reading contents of {len(file_contexts)} files for workflow {workflow_id}")
for file in file_contexts:
file_id = file["id"]
file_name = file["name"]
file_type = file.get("type", "unknown")
try:
# Dateiinhalt über LucyDOM-Interface abrufen
file_data = await lucydom_interface.read_file_content(file_id)
if not file_data:
_log(add_log_func, workflow_id, f"Datei {file_name} nicht gefunden", "warning")
file_contents[file_id] = f"File content not available (File not found)"
continue
logger.info(f"Successfully read file: {file_name} (ID: {file_id}, Type: {file_type})")
# Image files - always perform image analysis if AI service is available
if file_type == "image" or file_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
if ai_service:
try:
#_log(add_log_func, workflow_id, f"Analyzing image {file_name} {len(file_data)}B...", "info")
logger.info(f"ai_service type: {type(ai_service)}")
logger.info(f"ai_service methods: {dir(ai_service)}")
logger.info(f"ai_service has analyze_image method: {'analyze_image' in dir(ai_service)}")
image_analysis = await ai_service.analyze_image(
image_data=file_data,
prompt="Describe this image in detail",
mime_type=file.get("content_type")
)
logger.debug(f"Image analysis successfully generated for {file_name}")
file_contents[file_id] = f"Image Analysis:\n{image_analysis}"
_log(add_log_func, workflow_id, f"Image {file_name} analyzed successfully", "info")
except Exception as e:
logger.error(f"Error analyzing image {file_name}: {str(e)}")
_log(add_log_func, workflow_id, f"Error analyzing image {file_name}: {str(e)}", "error")
file_contents[file_id] = f"Image file: {file_name} (Analysis failed: {str(e)})"
else:
file_contents[file_id] = f"Image file: {file_name} (AI analysis not available)"
# Document files
elif file_type == "document" or not file_type:
# Verwende die zentrale Textextraktionsfunktion mit Dateiinhalt
content = extract_text_from_file_content(file_data, file_name, file.get("content_type"))
file_contents[file_id] = content
_log(add_log_func, workflow_id, f"File {file_name} read successfully", "info")
# Other file types - just store metadata
else:
file_contents[file_id] = f"File: {file_name} (Type: {file_type}, content not available)"
_log(add_log_func, workflow_id, f"Unsupported file type: {file_type} for {file_name}", "warning")
except Exception as e:
logger.error(f"Error reading file {file_name}: {str(e)}")
_log(add_log_func, workflow_id, f"Error reading file {file_name}: {str(e)}", "error")
file_contents[file_id] = f"File content not available (Error: {str(e)})"
return file_contents
def _log(add_log_func, workflow_id, message, log_type, agent_id=None, agent_name=None):
"""Hilfsfunktion zum Loggen mit unterschiedlichen Log-Funktionen"""
# Log über die Logger-Instanz
if log_type == "error":
logger.error(message)
elif log_type == "warning":
logger.warning(message)
else:
logger.info(message)
# Log über die bereitgestellte Log-Funktion (falls vorhanden)
if add_log_func and workflow_id:
add_log_func(workflow_id, message, log_type, agent_id, agent_name)