import io import os import json import uuid import logging import httpx import base64 import mimetypes from typing import Dict, Any, List, Optional from fastapi import HTTPException import configload as configload import pandas as pd from PIL import Image import PyPDF2 import fitz # Logger konfigurieren logger = logging.getLogger(__name__) # Konfigurationsdaten laden def load_config_data(): config = configload.load_config() return { "api_key": config.get('Connector_AiOpenai', 'API_KEY'), "api_url": config.get('Connector_AiOpenai', 'API_URL'), "model_name": config.get('Connector_AiOpenai', 'MODEL_NAME'), "temperature": float(config.get('Connector_AiOpenai', 'TEMPERATURE')), "max_tokens": int(config.get('Connector_AiOpenai', 'MAX_TOKENS')) } class ChatService: """ Connector für die Kommunikation mit der OpenAI API. """ def __init__(self): # Konfiguration laden self.config = load_config_data() self.api_key = self.config["api_key"] self.api_url = self.config["api_url"] self.model_name = self.config["model_name"] # HttpClient für API-Aufrufe self.http_client = httpx.AsyncClient( timeout=120.0, # Längeres Timeout für komplexe Anfragen headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json" } ) logger.info(f"OpenAI Connector initialisiert mit Modell: {self.model_name}") async def call_api(self, messages: List[Dict[str, Any]], temperature: float = None, max_tokens: int = None) -> Dict[str, Any]: """ Ruft die OpenAI API mit den gegebenen Nachrichten auf. Args: messages: Liste von Nachrichten im OpenAI-Format (role, content) temperature: Temperatur für die Antwortgenerierung (0.0-1.0) max_tokens: Maximale Anzahl der Token in der Antwort Returns: Die Antwort der OpenAI API Raises: HTTPException: Bei Fehlern in der API-Kommunikation """ try: # Verwende Parameter aus der Konfiguration, falls keine überschrieben wurden if temperature is None: temperature = self.config.get("temperature", 0.2) if max_tokens is None: max_tokens = self.config.get("max_tokens", 2000) payload = { "model": self.model_name, "messages": messages, "temperature": temperature, "max_tokens": max_tokens } response = await self.http_client.post( self.api_url, json=payload ) if response.status_code != 200: logger.error(f"OpenAI API-Fehler: {response.status_code} - {response.text}") raise HTTPException(status_code=500, detail="Fehler bei der Kommunikation mit OpenAI API") return response.json() except Exception as e: logger.error(f"Fehler beim Aufruf der OpenAI API: {str(e)}") raise HTTPException(status_code=500, detail=f"Fehler beim Aufruf der OpenAI API: {str(e)}") async def close(self): """Schließt den HTTP-Client beim Beenden der Anwendung""" await self.http_client.close() async def analyze_image(self, image_path: str, prompt: str) -> str: """ Analysiert ein Bild mit der OpenAI Vision API. Args: image_path: Pfad zum Bild prompt: Der Prompt für die Analyse Returns: Die Antwort der OpenAI Vision API als Text """ try: # Bild einlesen und als Base64 kodieren with open(image_path, "rb") as f: image_data = f.read() # In Base64-String konvertieren base64_data = base64.b64encode(image_data).decode('utf-8') # MIME-Typ bestimmen mime_type, _ = mimetypes.guess_type(image_path) if not mime_type: # Standard ist PNG, wenn Typ nicht bestimmt werden kann mime_type = "image/png" # Vision-fähiges Modell für die Anfrage verwenden vision_model = "gpt-4o" # oder aus der Konfiguration holen # Bereite den Payload für die Vision API vor messages = [ { "role": "user", "content": [ {"type": "text", "text": prompt}, { "type": "image_url", "image_url": { "url": f"data:{mime_type};base64,{base64_data}" } } ] } ] # Verwende die bestehende call_api Funktion mit dem Vision-Modell response = await self.call_api(messages) # Inhalt extrahieren und zurückgeben return response["choices"][0]["message"]["content"] except Exception as e: logger.error(f"Fehler bei der Bildanalyse {image_path}: {str(e)}", exc_info=True) return f"[Fehler bei der Bildanalyse: {str(e)}]" async def extract_and_analyze_pdf_images(self, pdf_path: str, prompt: str) -> List[Dict[str, Any]]: """ Extrahiert Bilder aus einer PDF-Datei mit PyMuPDF und analysiert sie mit der Vision API. Args: pdf_path: Pfad zur PDF-Datei prompt: Der Prompt für die Bildanalyse Returns: Eine Liste mit Analyseergebnissen für jedes Bild """ import uuid import os image_responses = [] try: # PDF öffnen doc = fitz.open(pdf_path) logger.info(f"PDF geöffnet: {pdf_path} 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.) # Speichere als temporäre Datei unique_id = uuid.uuid4() temp_img_path = f"temp_img_{page_num}_{img_index}_{unique_id}.{image_ext}" with open(temp_img_path, "wb") as img_file: img_file.write(image_bytes) logger.debug(f"Bild temporär gespeichert: {temp_img_path}") # Analysiere mit Vision API try: analysis_result = await self.analyze_image(temp_img_path, prompt) 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 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, # Der key wird immer gesetzt, entweder mit tatsächlicher Größe oder "unbekannt" "response": analysis_result }) # Temporäre Datei löschen try: if os.path.exists(temp_img_path): os.remove(temp_img_path) logger.debug(f"Temporäre Bilddatei entfernt: {temp_img_path}") except Exception as cleanup_error: logger.warning(f"Temporäre Datei konnte nicht entfernt werden: {cleanup_error}") 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 {os.path.basename(pdf_path)}") except ImportError: logger.error("PyMuPDF (fitz) ist nicht installiert. Installiere es mit 'pip install pymupdf'") except Exception as e: logger.error(f"Fehler beim Extrahieren von PDF-Bildern: {str(e)}") return image_responses async def parse_filedata(self, file_contexts: List[Dict[str, Any]], prompt_text: str, file_contents: Dict[str, str] = None) -> Dict[str, Any]: """ Bereitet eine vollständige Nachricht mit allen Dateiinhalten für das AI-Modell vor. Args: file_contexts: Liste der Dateikontexte mit Metadaten prompt_text: Der Text-Prompt file_contents: Dictionary mit bereits geladenen Dateiinhalten Returns: Eine vollständig formatierte Nachricht für das AI-Modell """ # Basisstruktur für die Nachricht in OpenAI-Format message = { "role": "user", "content": [] } # Text-Prompt hinzufügen if prompt_text: message["content"].append({ "type": "text", "text": prompt_text }) if not file_contents: file_contents = {} # Dateien als Anhänge hinzufügen for file_info in file_contexts: file_path = file_info.get("path", "") file_name = file_info.get("name", "") file_id = file_info.get("id", "") file_type = file_info.get("type", "") # Prüfen, ob Dateiinhalt bereits vorhanden ist if file_id in file_contents: content = file_contents[file_id] # Problematische Unicode-Zeichen ersetzen if isinstance(content, str): content = content.encode('utf-8', errors='replace').decode('utf-8') else: content = str(content) # Besondere Verarbeitung für PDF-Dateien mit Bildern if file_name.endswith('.pdf') and file_path and os.path.exists(file_path): try: # Bildanalyse der PDF durchführen image_prompt = prompt_text or "Beschreibe detailliert, was du in diesem Bild siehst." image_responses = await self.extract_and_analyze_pdf_images(file_path, image_prompt) # Nur wenn Bilder gefunden wurden, füge die Analyse hinzu if image_responses: image_details = "\n\n".join([ f"Bild auf Seite {resp['page']} (Größe: {resp['image_size']}): {resp['response']}" for resp in image_responses ]) logger.debug("Image description: "+image_details) message["content"].append({ "type": "text", "text": f"PDF-Bildanalyse für {file_name}:\n{image_details}" }) except Exception as e: logger.error(f"Fehler bei der PDF-Bildanalyse für {file_name}: {str(e)}") # Text zur Nachricht hinzufügen message["content"].append({ "type": "text", "text": f"--- DATEI: {file_name} ---\n\n{content}" }) logger.info(f"Inhalt für Datei {file_name} zur Nachricht hinzugefügt") continue # Wenn kein Inhalt vorhanden ist, füge einen Hinweis hinzu if not file_path or not os.path.exists(file_path): message["content"].append({ "type": "text", "text": f"[Datei {file_name} wurde nicht gefunden oder ist nicht zugänglich]" }) logger.warning(f"Datei {file_name} nicht gefunden: {file_path}") continue # Direktes Hinzufügen von Bildern bei OpenAI (für Bilder außerhalb von PDFs) if file_type == "image" or file_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): try: # Bild als Base64 kodieren with open(file_path, "rb") as f: file_data = f.read() base64_data = base64.b64encode(file_data).decode('utf-8') # MIME-Typ bestimmen mime_type, _ = mimetypes.guess_type(file_path) if not mime_type: mime_type = "application/octet-stream" # Bild zur Nachricht hinzufügen if mime_type.startswith("image/"): # Füge zunächst die Bildanalyse als Text hinzu image_prompt = prompt_text or "Beschreibe detailliert, was du in diesem Bild siehst." analysis_result = await self.analyze_image(file_path, image_prompt) message["content"].append({ "type": "text", "text": f"Bildanalyse für {file_name}:\n{analysis_result}" }) # Dann füge das Bild selbst hinzu message["content"].append({ "type": "image_url", "image_url": { "url": f"data:{mime_type};base64,{base64_data}" } }) logger.info(f"Bild {file_name} analysiert und zur Nachricht hinzugefügt") except Exception as e: logger.error(f"Fehler beim Hinzufügen des Bildes {file_name}: {str(e)}") message["content"].append({ "type": "text", "text": f"[Fehler beim Laden des Bildes {file_name}: {str(e)}]" }) # Optimiere das Message-Format für die API basierend auf dem Inhalt if not message["content"]: # Leerer Inhalt - setze auf leeren String message["content"] = prompt_text or "" elif len(message["content"]) == 1 and message["content"][0]["type"] == "text": # Nur ein Text-Element - vereinfache die Struktur message["content"] = message["content"][0]["text"] # Bei komplizierten Inhalten (Mischung aus Text und Bildern) wird das Array-Format beibehalten logger.debug(f"Message-Objekt für OpenAI erstellt mit {len(message['content']) if isinstance(message['content'], list) else 'String'}-Inhalt") return message def get_mime_type_from_extension(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" } return extension_to_mime.get(extension, "application/octet-stream")