gateway/gwserver/connector_aichat_openai.py

423 lines
No EOL
18 KiB
Python

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