fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21
backend/llm_gateway/services/__init__.py
Normal file
21
backend/llm_gateway/services/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
LLM Gateway Services.
|
||||
"""
|
||||
|
||||
from .inference import InferenceService, get_inference_service
|
||||
from .playbook_service import PlaybookService
|
||||
from .pii_detector import PIIDetector, get_pii_detector, PIIType, RedactionResult
|
||||
from .tool_gateway import ToolGateway, get_tool_gateway, SearchDepth
|
||||
|
||||
__all__ = [
|
||||
"InferenceService",
|
||||
"get_inference_service",
|
||||
"PlaybookService",
|
||||
"PIIDetector",
|
||||
"get_pii_detector",
|
||||
"PIIType",
|
||||
"RedactionResult",
|
||||
"ToolGateway",
|
||||
"get_tool_gateway",
|
||||
"SearchDepth",
|
||||
]
|
||||
614
backend/llm_gateway/services/communication_service.py
Normal file
614
backend/llm_gateway/services/communication_service.py
Normal file
@@ -0,0 +1,614 @@
|
||||
"""
|
||||
Communication Service - KI-gestützte Lehrer-Eltern-Kommunikation.
|
||||
|
||||
Unterstützt Lehrkräfte bei der Erstellung professioneller, rechtlich fundierter
|
||||
Kommunikation mit Eltern. Basiert auf den Prinzipien der gewaltfreien Kommunikation
|
||||
(GFK nach Marshall Rosenberg) und deutschen Schulgesetzen.
|
||||
|
||||
Die rechtlichen Referenzen werden dynamisch aus der Datenbank geladen
|
||||
(edu_search_documents Tabelle), nicht mehr hardcoded.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum, auto
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Legal Crawler API URL (für dynamische Rechtsinhalte)
|
||||
LEGAL_CRAWLER_API_URL = os.getenv(
|
||||
"LEGAL_CRAWLER_API_URL",
|
||||
"http://localhost:8000/v1/legal-crawler"
|
||||
)
|
||||
|
||||
|
||||
class CommunicationType(str, Enum):
|
||||
"""Arten von Eltern-Kommunikation."""
|
||||
GENERAL_INFO = "general_info" # Allgemeine Information
|
||||
BEHAVIOR = "behavior" # Verhalten/Disziplin
|
||||
ACADEMIC = "academic" # Schulleistungen
|
||||
ATTENDANCE = "attendance" # Anwesenheit/Fehlzeiten
|
||||
MEETING_INVITE = "meeting_invite" # Einladung zum Gespräch
|
||||
POSITIVE_FEEDBACK = "positive_feedback" # Positives Feedback
|
||||
CONCERN = "concern" # Bedenken äußern
|
||||
CONFLICT = "conflict" # Konfliktlösung
|
||||
SPECIAL_NEEDS = "special_needs" # Förderbedarf
|
||||
|
||||
|
||||
class CommunicationTone(str, Enum):
|
||||
"""Tonalität der Kommunikation."""
|
||||
FORMAL = "formal" # Sehr förmlich
|
||||
PROFESSIONAL = "professional" # Professionell-freundlich
|
||||
WARM = "warm" # Warmherzig
|
||||
CONCERNED = "concerned" # Besorgt
|
||||
APPRECIATIVE = "appreciative" # Wertschätzend
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegalReference:
|
||||
"""Rechtliche Referenz für Kommunikation."""
|
||||
law: str # z.B. "SchulG NRW"
|
||||
paragraph: str # z.B. "§ 42"
|
||||
title: str # z.B. "Pflichten der Eltern"
|
||||
summary: str # Kurzzusammenfassung
|
||||
relevance: str # Warum relevant für diesen Fall
|
||||
|
||||
|
||||
@dataclass
|
||||
class GFKPrinciple:
|
||||
"""Prinzip der Gewaltfreien Kommunikation."""
|
||||
principle: str # z.B. "Beobachtung"
|
||||
description: str # Erklärung
|
||||
example: str # Beispiel im Kontext
|
||||
|
||||
|
||||
# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer)
|
||||
# Die primäre Quelle sind gecrawlte Dokumente in der edu_search_documents Tabelle
|
||||
FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = {
|
||||
"DEFAULT": {
|
||||
"elternpflichten": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Pflichten der Eltern",
|
||||
summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstützen.",
|
||||
relevance="Grundlage für Kooperationsaufforderungen"
|
||||
),
|
||||
"schulpflicht": LegalReference(
|
||||
law="Landesschulgesetz",
|
||||
paragraph="(je nach Bundesland)",
|
||||
title="Schulpflicht",
|
||||
summary="Kinder sind schulpflichtig. Eltern sind verantwortlich für regelmäßigen Schulbesuch.",
|
||||
relevance="Bei Fehlzeiten und Anwesenheitsproblemen"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Lädt rechtliche Referenzen aus der Datenbank (via Legal Crawler API).
|
||||
|
||||
Args:
|
||||
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
|
||||
|
||||
Returns:
|
||||
Liste von Rechtsdokumenten mit Paragraphen
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{LEGAL_CRAWLER_API_URL}/references/{state}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("documents", [])
|
||||
else:
|
||||
logger.warning(f"Legal API returned {response.status_code} for state {state}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Laden rechtlicher Referenzen für {state}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def parse_db_references_to_legal_refs(
|
||||
db_docs: List[Dict[str, Any]],
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Konvertiert DB-Dokumente in LegalReference-Objekte.
|
||||
|
||||
Filtert nach relevanten Paragraphen basierend auf dem Topic.
|
||||
"""
|
||||
references = []
|
||||
|
||||
# Topic zu relevanten Paragraph-Nummern mapping
|
||||
topic_keywords = {
|
||||
"elternpflichten": ["42", "76", "85", "eltern", "pflicht"],
|
||||
"schulpflicht": ["41", "35", "schulpflicht", "pflicht"],
|
||||
"ordnungsmassnahmen": ["53", "ordnung", "erzieh", "maßnahm"],
|
||||
"datenschutz": ["120", "daten", "schutz"],
|
||||
"foerderung": ["2", "förder", "bildung", "auftrag"],
|
||||
}
|
||||
|
||||
keywords = topic_keywords.get(topic, ["eltern"])
|
||||
|
||||
for doc in db_docs:
|
||||
law_name = doc.get("law_name", doc.get("title", "Schulgesetz"))
|
||||
paragraphs = doc.get("paragraphs", [])
|
||||
|
||||
if not paragraphs:
|
||||
# Wenn keine Paragraphen extrahiert, allgemeine Referenz erstellen
|
||||
references.append(LegalReference(
|
||||
law=law_name,
|
||||
paragraph="(siehe Gesetzestext)",
|
||||
title=doc.get("title", "Schulgesetz"),
|
||||
summary=f"Rechtliche Grundlage aus {law_name}",
|
||||
relevance=f"Relevant für {topic}"
|
||||
))
|
||||
continue
|
||||
|
||||
# Relevante Paragraphen finden
|
||||
for para in paragraphs[:10]: # Max 10 Paragraphen prüfen
|
||||
para_nr = para.get("nr", "")
|
||||
para_title = para.get("title", "")
|
||||
|
||||
# Prüfen ob Paragraph relevant ist
|
||||
is_relevant = False
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in para_nr.lower() or keyword.lower() in para_title.lower():
|
||||
is_relevant = True
|
||||
break
|
||||
|
||||
if is_relevant:
|
||||
references.append(LegalReference(
|
||||
law=law_name,
|
||||
paragraph=para_nr,
|
||||
title=para_title[:100],
|
||||
summary=f"{para_title[:150]}",
|
||||
relevance=f"Relevant für {topic}"
|
||||
))
|
||||
|
||||
return references
|
||||
|
||||
# GFK-Prinzipien
|
||||
GFK_PRINCIPLES = [
|
||||
GFKPrinciple(
|
||||
principle="Beobachtung",
|
||||
description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation",
|
||||
example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Gefühle",
|
||||
description="Eigene Gefühle ausdrücken (Ich-Botschaften)",
|
||||
example="'Ich mache mir Sorgen...' statt 'Sie müssen endlich...'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Bedürfnisse",
|
||||
description="Dahinterliegende Bedürfnisse benennen",
|
||||
example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie müssen mehr kontrollieren.'"
|
||||
),
|
||||
GFKPrinciple(
|
||||
principle="Bitten",
|
||||
description="Konkrete, erfüllbare Bitten formulieren",
|
||||
example="'Wären Sie bereit, täglich die Hausaufgaben zu prüfen?' statt 'Tun Sie endlich etwas!'"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Kommunikationsvorlagen
|
||||
COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = {
|
||||
CommunicationType.GENERAL_INFO: {
|
||||
"subject": "Information: {topic}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über folgendes informieren:",
|
||||
"closing": "Bei Fragen stehe ich Ihnen gerne zur Verfügung.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.BEHAVIOR: {
|
||||
"subject": "Gesprächswunsch: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.",
|
||||
"closing": "Ich bin überzeugt, dass wir gemeinsam eine gute Lösung finden können. Ich würde mich über ein Gespräch freuen.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.ACADEMIC: {
|
||||
"subject": "Schulische Entwicklung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über die schulische Entwicklung von {student_name} informieren.",
|
||||
"closing": "Ich würde mich freuen, wenn wir gemeinsam überlegen könnten, wie wir {student_name} optimal unterstützen können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.ATTENDANCE: {
|
||||
"subject": "Fehlzeiten: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezüglich der Anwesenheit von {student_name}.",
|
||||
"closing": "Gemäß {legal_reference} sind regelmäßige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Lösung finden.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.MEETING_INVITE: {
|
||||
"subject": "Einladung zum Elterngespräch",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich würde mich freuen, Sie zu einem persönlichen Gespräch einzuladen.",
|
||||
"closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine für Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.POSITIVE_FEEDBACK: {
|
||||
"subject": "Positive Rückmeldung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu können.",
|
||||
"closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu dürfen.\n\nMit herzlichen Grüßen",
|
||||
},
|
||||
CommunicationType.CONCERN: {
|
||||
"subject": "Gemeinsame Sorge: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen würde.",
|
||||
"closing": "Ich bin überzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Lösung finden werden.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.CONFLICT: {
|
||||
"subject": "Bitte um ein klärendes Gespräch",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte das Gespräch mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.",
|
||||
"closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin überzeugt, dass wir im Dialog eine für alle Seiten gute Lösung finden können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
CommunicationType.SPECIAL_NEEDS: {
|
||||
"subject": "Förderung: {student_name}",
|
||||
"opening": "Sehr geehrte/r {parent_name},\n\nich möchte mit Ihnen über die individuelle Förderung von {student_name} sprechen.",
|
||||
"closing": "Gemäß dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu fördern. Lassen Sie uns gemeinsam überlegen, wie wir {student_name} bestmöglich unterstützen können.\n\nMit freundlichen Grüßen",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CommunicationService:
|
||||
"""
|
||||
Service zur Unterstützung von Lehrer-Eltern-Kommunikation.
|
||||
|
||||
Generiert professionelle, rechtlich fundierte und empathische Nachrichten
|
||||
basierend auf den Prinzipien der gewaltfreien Kommunikation.
|
||||
|
||||
Rechtliche Referenzen werden dynamisch aus der DB geladen (via Legal Crawler API).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.fallback_references = FALLBACK_LEGAL_REFERENCES
|
||||
self.gfk_principles = GFK_PRINCIPLES
|
||||
self.templates = COMMUNICATION_TEMPLATES
|
||||
# Cache für DB-Referenzen (um wiederholte API-Calls zu vermeiden)
|
||||
self._cached_references: Dict[str, List[LegalReference]] = {}
|
||||
|
||||
async def get_legal_references_async(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Gibt relevante rechtliche Referenzen für ein Bundesland und Thema zurück.
|
||||
Lädt aus DB via Legal Crawler API.
|
||||
|
||||
Args:
|
||||
state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW")
|
||||
topic: Themenbereich (z.B. "elternpflichten", "schulpflicht")
|
||||
|
||||
Returns:
|
||||
Liste relevanter LegalReference-Objekte
|
||||
"""
|
||||
cache_key = f"{state}:{topic}"
|
||||
|
||||
# Cache prüfen
|
||||
if cache_key in self._cached_references:
|
||||
return self._cached_references[cache_key]
|
||||
|
||||
# Aus DB laden
|
||||
db_docs = await fetch_legal_references_from_db(state)
|
||||
|
||||
if db_docs:
|
||||
# DB-Dokumente in LegalReference konvertieren
|
||||
references = parse_db_references_to_legal_refs(db_docs, topic)
|
||||
if references:
|
||||
self._cached_references[cache_key] = references
|
||||
return references
|
||||
|
||||
# Fallback wenn DB leer
|
||||
logger.info(f"Keine DB-Referenzen für {state}/{topic}, nutze Fallback")
|
||||
return self._get_fallback_references(state, topic)
|
||||
|
||||
def get_legal_references(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""
|
||||
Synchrone Methode für Rückwärtskompatibilität.
|
||||
Nutzt nur Fallback-Referenzen (für non-async Kontexte).
|
||||
|
||||
Für dynamische DB-Referenzen bitte get_legal_references_async() verwenden.
|
||||
"""
|
||||
return self._get_fallback_references(state, topic)
|
||||
|
||||
def _get_fallback_references(
|
||||
self,
|
||||
state: str,
|
||||
topic: str
|
||||
) -> List[LegalReference]:
|
||||
"""Gibt Fallback-Referenzen zurück."""
|
||||
state_refs = self.fallback_references.get("DEFAULT", {})
|
||||
|
||||
if topic in state_refs:
|
||||
return [state_refs[topic]]
|
||||
|
||||
return list(state_refs.values())
|
||||
|
||||
def get_gfk_guidance(
|
||||
self,
|
||||
comm_type: CommunicationType
|
||||
) -> List[GFKPrinciple]:
|
||||
"""
|
||||
Gibt GFK-Leitlinien für einen Kommunikationstyp zurück.
|
||||
"""
|
||||
return self.gfk_principles
|
||||
|
||||
def get_template(
|
||||
self,
|
||||
comm_type: CommunicationType
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Gibt die Vorlage für einen Kommunikationstyp zurück.
|
||||
"""
|
||||
return self.templates.get(comm_type, self.templates[CommunicationType.GENERAL_INFO])
|
||||
|
||||
def build_system_prompt(
|
||||
self,
|
||||
comm_type: CommunicationType,
|
||||
state: str,
|
||||
tone: CommunicationTone
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt den System-Prompt für die KI-gestützte Nachrichtengenerierung.
|
||||
|
||||
Args:
|
||||
comm_type: Art der Kommunikation
|
||||
state: Bundesland für rechtliche Referenzen
|
||||
tone: Gewünschte Tonalität
|
||||
|
||||
Returns:
|
||||
System-Prompt für LLM
|
||||
"""
|
||||
# Rechtliche Referenzen sammeln
|
||||
topic_map = {
|
||||
CommunicationType.ATTENDANCE: "schulpflicht",
|
||||
CommunicationType.BEHAVIOR: "ordnungsmassnahmen",
|
||||
CommunicationType.ACADEMIC: "foerderung",
|
||||
CommunicationType.SPECIAL_NEEDS: "foerderung",
|
||||
CommunicationType.CONCERN: "elternpflichten",
|
||||
CommunicationType.CONFLICT: "elternpflichten",
|
||||
}
|
||||
topic = topic_map.get(comm_type, "elternpflichten")
|
||||
legal_refs = self.get_legal_references(state, topic)
|
||||
|
||||
legal_context = ""
|
||||
if legal_refs:
|
||||
legal_context = "\n\nRechtliche Grundlagen:\n"
|
||||
for ref in legal_refs:
|
||||
legal_context += f"- {ref.law} {ref.paragraph} ({ref.title}): {ref.summary}\n"
|
||||
|
||||
# Tonalität beschreiben
|
||||
tone_descriptions = {
|
||||
CommunicationTone.FORMAL: "Verwende eine sehr formelle, sachliche Sprache.",
|
||||
CommunicationTone.PROFESSIONAL: "Verwende eine professionelle, aber freundliche Sprache.",
|
||||
CommunicationTone.WARM: "Verwende eine warmherzige, einladende Sprache.",
|
||||
CommunicationTone.CONCERNED: "Drücke aufrichtige Sorge und Empathie aus.",
|
||||
CommunicationTone.APPRECIATIVE: "Betone Wertschätzung und positives Feedback.",
|
||||
}
|
||||
tone_desc = tone_descriptions.get(tone, tone_descriptions[CommunicationTone.PROFESSIONAL])
|
||||
|
||||
system_prompt = f"""Du bist ein erfahrener Kommunikationsberater für Lehrkräfte im deutschen Schulsystem.
|
||||
Deine Aufgabe ist es, professionelle, empathische und rechtlich fundierte Elternbriefe zu verfassen.
|
||||
|
||||
GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg):
|
||||
|
||||
1. BEOBACHTUNG: Beschreibe konkrete Handlungen ohne Bewertung
|
||||
Beispiel: "Ich habe bemerkt, dass..." statt "Das Kind ist..."
|
||||
|
||||
2. GEFÜHLE: Drücke Gefühle als Ich-Botschaften aus
|
||||
Beispiel: "Ich mache mir Sorgen..." statt "Sie müssen..."
|
||||
|
||||
3. BEDÜRFNISSE: Benenne dahinterliegende Bedürfnisse
|
||||
Beispiel: "Mir ist wichtig, dass..." statt "Sie sollten..."
|
||||
|
||||
4. BITTEN: Formuliere konkrete, erfüllbare Bitten
|
||||
Beispiel: "Wären Sie bereit, ...?" statt "Tun Sie endlich...!"
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Immer die Würde aller Beteiligten wahren
|
||||
- Keine Schuldzuweisungen oder Vorwürfe
|
||||
- Lösungsorientiert statt problemfokussiert
|
||||
- Auf Augenhöhe kommunizieren
|
||||
- Kooperation statt Konfrontation
|
||||
- Deutsche Sprache, förmliche Anrede (Sie)
|
||||
- Sachlich, aber empathisch
|
||||
{legal_context}
|
||||
|
||||
TONALITÄT:
|
||||
{tone_desc}
|
||||
|
||||
FORMAT:
|
||||
- Verfasse den Brief als vollständigen, versandfertigen Text
|
||||
- Beginne mit der Anrede
|
||||
- Strukturiere den Inhalt klar und verständlich
|
||||
- Schließe mit einer freundlichen Grußformel
|
||||
- Die Signatur (Name der Lehrkraft) wird später hinzugefügt
|
||||
|
||||
WICHTIG: Der Brief soll professionell und rechtlich einwandfrei sein, aber gleichzeitig
|
||||
menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit."""
|
||||
|
||||
return system_prompt
|
||||
|
||||
def build_user_prompt(
|
||||
self,
|
||||
comm_type: CommunicationType,
|
||||
context: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt den User-Prompt aus dem Kontext.
|
||||
|
||||
Args:
|
||||
comm_type: Art der Kommunikation
|
||||
context: Kontextinformationen (student_name, parent_name, situation, etc.)
|
||||
|
||||
Returns:
|
||||
User-Prompt für LLM
|
||||
"""
|
||||
student_name = context.get("student_name", "das Kind")
|
||||
parent_name = context.get("parent_name", "Frau/Herr")
|
||||
situation = context.get("situation", "")
|
||||
additional_info = context.get("additional_info", "")
|
||||
|
||||
type_descriptions = {
|
||||
CommunicationType.GENERAL_INFO: "eine allgemeine Information",
|
||||
CommunicationType.BEHAVIOR: "ein Verhalten, das besprochen werden sollte",
|
||||
CommunicationType.ACADEMIC: "die schulische Entwicklung",
|
||||
CommunicationType.ATTENDANCE: "Fehlzeiten oder Anwesenheitsprobleme",
|
||||
CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespräch",
|
||||
CommunicationType.POSITIVE_FEEDBACK: "positives Feedback",
|
||||
CommunicationType.CONCERN: "eine Sorge oder ein Anliegen",
|
||||
CommunicationType.CONFLICT: "eine konflikthafte Situation",
|
||||
CommunicationType.SPECIAL_NEEDS: "Förderbedarf oder besondere Unterstützung",
|
||||
}
|
||||
type_desc = type_descriptions.get(comm_type, "ein Anliegen")
|
||||
|
||||
user_prompt = f"""Schreibe einen Elternbrief zu folgendem Anlass: {type_desc}
|
||||
|
||||
Schülername: {student_name}
|
||||
Elternname: {parent_name}
|
||||
|
||||
Situation:
|
||||
{situation}
|
||||
"""
|
||||
|
||||
if additional_info:
|
||||
user_prompt += f"\nZusätzliche Informationen:\n{additional_info}\n"
|
||||
|
||||
user_prompt += """
|
||||
Bitte verfasse einen professionellen, empathischen Brief nach den GFK-Prinzipien.
|
||||
Der Brief sollte:
|
||||
- Die Situation sachlich beschreiben (Beobachtung)
|
||||
- Verständnis und Sorge ausdrücken (Gefühle)
|
||||
- Das gemeinsame Ziel betonen (Bedürfnisse)
|
||||
- Einen konstruktiven Vorschlag machen (Bitte)
|
||||
"""
|
||||
|
||||
return user_prompt
|
||||
|
||||
def validate_communication(self, text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validiert eine generierte Kommunikation auf GFK-Konformität.
|
||||
|
||||
Args:
|
||||
text: Der zu prüfende Text
|
||||
|
||||
Returns:
|
||||
Validierungsergebnis mit Verbesserungsvorschlägen
|
||||
"""
|
||||
issues = []
|
||||
suggestions = []
|
||||
|
||||
# Prüfe auf problematische Formulierungen
|
||||
problematic_patterns = [
|
||||
("Sie müssen", "Vorschlag: 'Wären Sie bereit, ...' oder 'Ich bitte Sie, ...'"),
|
||||
("Sie sollten", "Vorschlag: 'Ich würde mir wünschen, ...'"),
|
||||
("Das Kind ist", "Vorschlag: 'Ich habe beobachtet, dass ...'"),
|
||||
("immer", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
|
||||
("nie", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"),
|
||||
("faul", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
("unverschämt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
("respektlos", "Vorschlag: Verhalten konkret beschreiben statt bewerten"),
|
||||
]
|
||||
|
||||
for pattern, suggestion in problematic_patterns:
|
||||
if pattern.lower() in text.lower():
|
||||
issues.append(f"Problematische Formulierung gefunden: '{pattern}'")
|
||||
suggestions.append(suggestion)
|
||||
|
||||
# Prüfe auf positive Elemente
|
||||
positive_elements = []
|
||||
positive_patterns = [
|
||||
("Ich habe bemerkt", "Gute Beobachtung"),
|
||||
("Ich möchte", "Gute Ich-Botschaft"),
|
||||
("gemeinsam", "Gute Kooperationsorientierung"),
|
||||
("wichtig", "Gutes Bedürfnis-Statement"),
|
||||
("freuen", "Positive Tonalität"),
|
||||
("Wären Sie bereit", "Gute Bitte-Formulierung"),
|
||||
]
|
||||
|
||||
for pattern, feedback in positive_patterns:
|
||||
if pattern.lower() in text.lower():
|
||||
positive_elements.append(feedback)
|
||||
|
||||
return {
|
||||
"is_valid": len(issues) == 0,
|
||||
"issues": issues,
|
||||
"suggestions": suggestions,
|
||||
"positive_elements": positive_elements,
|
||||
"gfk_score": max(0, 100 - len(issues) * 15 + len(positive_elements) * 10) / 100
|
||||
}
|
||||
|
||||
def get_all_communication_types(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Kommunikationstypen zurück."""
|
||||
return [
|
||||
{"value": ct.value, "label": self._get_type_label(ct)}
|
||||
for ct in CommunicationType
|
||||
]
|
||||
|
||||
def _get_type_label(self, ct: CommunicationType) -> str:
|
||||
"""Gibt das deutsche Label für einen Kommunikationstyp zurück."""
|
||||
labels = {
|
||||
CommunicationType.GENERAL_INFO: "Allgemeine Information",
|
||||
CommunicationType.BEHAVIOR: "Verhalten/Disziplin",
|
||||
CommunicationType.ACADEMIC: "Schulleistungen",
|
||||
CommunicationType.ATTENDANCE: "Fehlzeiten",
|
||||
CommunicationType.MEETING_INVITE: "Einladung zum Gespräch",
|
||||
CommunicationType.POSITIVE_FEEDBACK: "Positives Feedback",
|
||||
CommunicationType.CONCERN: "Bedenken äußern",
|
||||
CommunicationType.CONFLICT: "Konfliktlösung",
|
||||
CommunicationType.SPECIAL_NEEDS: "Förderbedarf",
|
||||
}
|
||||
return labels.get(ct, ct.value)
|
||||
|
||||
def get_all_tones(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Tonalitäten zurück."""
|
||||
labels = {
|
||||
CommunicationTone.FORMAL: "Sehr förmlich",
|
||||
CommunicationTone.PROFESSIONAL: "Professionell-freundlich",
|
||||
CommunicationTone.WARM: "Warmherzig",
|
||||
CommunicationTone.CONCERNED: "Besorgt",
|
||||
CommunicationTone.APPRECIATIVE: "Wertschätzend",
|
||||
}
|
||||
return [
|
||||
{"value": t.value, "label": labels.get(t, t.value)}
|
||||
for t in CommunicationTone
|
||||
]
|
||||
|
||||
def get_states(self) -> List[Dict[str, str]]:
|
||||
"""Gibt alle verfügbaren Bundesländer zurück."""
|
||||
return [
|
||||
{"value": "NRW", "label": "Nordrhein-Westfalen"},
|
||||
{"value": "BY", "label": "Bayern"},
|
||||
{"value": "BW", "label": "Baden-Württemberg"},
|
||||
{"value": "NI", "label": "Niedersachsen"},
|
||||
{"value": "HE", "label": "Hessen"},
|
||||
{"value": "SN", "label": "Sachsen"},
|
||||
{"value": "RP", "label": "Rheinland-Pfalz"},
|
||||
{"value": "SH", "label": "Schleswig-Holstein"},
|
||||
{"value": "BE", "label": "Berlin"},
|
||||
{"value": "BB", "label": "Brandenburg"},
|
||||
{"value": "MV", "label": "Mecklenburg-Vorpommern"},
|
||||
{"value": "ST", "label": "Sachsen-Anhalt"},
|
||||
{"value": "TH", "label": "Thüringen"},
|
||||
{"value": "HH", "label": "Hamburg"},
|
||||
{"value": "HB", "label": "Bremen"},
|
||||
{"value": "SL", "label": "Saarland"},
|
||||
]
|
||||
|
||||
|
||||
# Singleton-Instanz
|
||||
_communication_service: Optional[CommunicationService] = None
|
||||
|
||||
|
||||
def get_communication_service() -> CommunicationService:
|
||||
"""Gibt die Singleton-Instanz des CommunicationService zurück."""
|
||||
global _communication_service
|
||||
if _communication_service is None:
|
||||
_communication_service = CommunicationService()
|
||||
return _communication_service
|
||||
522
backend/llm_gateway/services/inference.py
Normal file
522
backend/llm_gateway/services/inference.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""
|
||||
Inference Service - Kommunikation mit LLM Backends.
|
||||
|
||||
Unterstützt:
|
||||
- Ollama (lokal)
|
||||
- vLLM (remote, OpenAI-kompatibel)
|
||||
- Anthropic Claude API (Fallback)
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..config import get_config, LLMBackendConfig
|
||||
from ..models.chat import (
|
||||
ChatCompletionRequest,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionChunk,
|
||||
ChatMessage,
|
||||
ChatChoice,
|
||||
StreamChoice,
|
||||
ChatChoiceDelta,
|
||||
Usage,
|
||||
ModelInfo,
|
||||
ModelListResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceResult:
|
||||
"""Ergebnis einer Inference-Anfrage."""
|
||||
content: str
|
||||
model: str
|
||||
backend: str
|
||||
usage: Optional[Usage] = None
|
||||
finish_reason: str = "stop"
|
||||
|
||||
|
||||
class InferenceService:
|
||||
"""Service für LLM Inference über verschiedene Backends."""
|
||||
|
||||
def __init__(self):
|
||||
self.config = get_config()
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def get_client(self) -> httpx.AsyncClient:
|
||||
"""Lazy initialization des HTTP Clients."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=120.0)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Schließt den HTTP Client."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _get_available_backend(self, preferred_model: Optional[str] = None) -> Optional[LLMBackendConfig]:
|
||||
"""Findet das erste verfügbare Backend basierend auf Priorität."""
|
||||
for backend_name in self.config.backend_priority:
|
||||
backend = getattr(self.config, backend_name, None)
|
||||
if backend and backend.enabled:
|
||||
return backend
|
||||
return None
|
||||
|
||||
def _map_model_to_backend(self, model: str) -> tuple[str, LLMBackendConfig]:
|
||||
"""
|
||||
Mapped ein Modell-Name zum entsprechenden Backend.
|
||||
|
||||
Beispiele:
|
||||
- "breakpilot-teacher-8b" → Ollama/vLLM mit llama3.1:8b
|
||||
- "claude-3-5-sonnet" → Anthropic
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
|
||||
# Explizite Claude-Modelle → Anthropic
|
||||
if "claude" in model_lower:
|
||||
if self.config.anthropic and self.config.anthropic.enabled:
|
||||
return self.config.anthropic.default_model, self.config.anthropic
|
||||
raise ValueError("Anthropic backend not configured")
|
||||
|
||||
# BreakPilot Modelle → primäres Backend
|
||||
if "breakpilot" in model_lower or "teacher" in model_lower:
|
||||
backend = self._get_available_backend()
|
||||
if backend:
|
||||
# Map zu tatsächlichem Modell-Namen
|
||||
if "70b" in model_lower:
|
||||
actual_model = "llama3.1:70b" if backend.name == "ollama" else "meta-llama/Meta-Llama-3.1-70B-Instruct"
|
||||
else:
|
||||
actual_model = "llama3.1:8b" if backend.name == "ollama" else "meta-llama/Meta-Llama-3.1-8B-Instruct"
|
||||
return actual_model, backend
|
||||
raise ValueError("No LLM backend available")
|
||||
|
||||
# Mistral Modelle
|
||||
if "mistral" in model_lower:
|
||||
backend = self._get_available_backend()
|
||||
if backend:
|
||||
actual_model = "mistral:7b" if backend.name == "ollama" else "mistralai/Mistral-7B-Instruct-v0.2"
|
||||
return actual_model, backend
|
||||
raise ValueError("No LLM backend available")
|
||||
|
||||
# Fallback: verwende Modell-Name direkt
|
||||
backend = self._get_available_backend()
|
||||
if backend:
|
||||
return model, backend
|
||||
raise ValueError("No LLM backend available")
|
||||
|
||||
async def _call_ollama(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
) -> InferenceResult:
|
||||
"""Ruft Ollama API auf (nicht OpenAI-kompatibel)."""
|
||||
client = await self.get_client()
|
||||
|
||||
# Ollama verwendet eigenes Format
|
||||
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
},
|
||||
}
|
||||
|
||||
if request.max_tokens:
|
||||
payload["options"]["num_predict"] = request.max_tokens
|
||||
|
||||
response = await client.post(
|
||||
f"{backend.base_url}/api/chat",
|
||||
json=payload,
|
||||
timeout=backend.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return InferenceResult(
|
||||
content=data.get("message", {}).get("content", ""),
|
||||
model=model,
|
||||
backend="ollama",
|
||||
usage=Usage(
|
||||
prompt_tokens=data.get("prompt_eval_count", 0),
|
||||
completion_tokens=data.get("eval_count", 0),
|
||||
total_tokens=data.get("prompt_eval_count", 0) + data.get("eval_count", 0),
|
||||
),
|
||||
finish_reason="stop" if data.get("done") else "length",
|
||||
)
|
||||
|
||||
async def _stream_ollama(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
response_id: str,
|
||||
) -> AsyncIterator[ChatCompletionChunk]:
|
||||
"""Streamt von Ollama."""
|
||||
client = await self.get_client()
|
||||
|
||||
messages = [{"role": m.role, "content": m.content or ""} for m in request.messages]
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"options": {
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
},
|
||||
}
|
||||
|
||||
if request.max_tokens:
|
||||
payload["options"]["num_predict"] = request.max_tokens
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{backend.base_url}/api/chat",
|
||||
json=payload,
|
||||
timeout=backend.timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(line)
|
||||
content = data.get("message", {}).get("content", "")
|
||||
done = data.get("done", False)
|
||||
|
||||
yield ChatCompletionChunk(
|
||||
id=response_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(content=content),
|
||||
finish_reason="stop" if done else None,
|
||||
)
|
||||
],
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
async def _call_openai_compatible(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
) -> InferenceResult:
|
||||
"""Ruft OpenAI-kompatible API auf (vLLM, etc.)."""
|
||||
client = await self.get_client()
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if backend.api_key:
|
||||
headers["Authorization"] = f"Bearer {backend.api_key}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
|
||||
"stream": False,
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
}
|
||||
|
||||
if request.max_tokens:
|
||||
payload["max_tokens"] = request.max_tokens
|
||||
if request.stop:
|
||||
payload["stop"] = request.stop
|
||||
|
||||
response = await client.post(
|
||||
f"{backend.base_url}/v1/chat/completions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=backend.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
choice = data.get("choices", [{}])[0]
|
||||
usage_data = data.get("usage", {})
|
||||
|
||||
return InferenceResult(
|
||||
content=choice.get("message", {}).get("content", ""),
|
||||
model=model,
|
||||
backend=backend.name,
|
||||
usage=Usage(
|
||||
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
),
|
||||
finish_reason=choice.get("finish_reason", "stop"),
|
||||
)
|
||||
|
||||
async def _stream_openai_compatible(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
response_id: str,
|
||||
) -> AsyncIterator[ChatCompletionChunk]:
|
||||
"""Streamt von OpenAI-kompatibler API."""
|
||||
client = await self.get_client()
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if backend.api_key:
|
||||
headers["Authorization"] = f"Bearer {backend.api_key}"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [m.model_dump(exclude_none=True) for m in request.messages],
|
||||
"stream": True,
|
||||
"temperature": request.temperature,
|
||||
"top_p": request.top_p,
|
||||
}
|
||||
|
||||
if request.max_tokens:
|
||||
payload["max_tokens"] = request.max_tokens
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{backend.base_url}/v1/chat/completions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=backend.timeout,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
data_str = line[6:] # Remove "data: " prefix
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
choice = data.get("choices", [{}])[0]
|
||||
delta = choice.get("delta", {})
|
||||
|
||||
yield ChatCompletionChunk(
|
||||
id=response_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(
|
||||
role=delta.get("role"),
|
||||
content=delta.get("content"),
|
||||
),
|
||||
finish_reason=choice.get("finish_reason"),
|
||||
)
|
||||
],
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
async def _call_anthropic(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
) -> InferenceResult:
|
||||
"""Ruft Anthropic Claude API auf."""
|
||||
# Anthropic SDK verwenden (bereits installiert)
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise ImportError("anthropic package required for Claude API")
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
|
||||
|
||||
# System message extrahieren
|
||||
system_content = ""
|
||||
messages = []
|
||||
for msg in request.messages:
|
||||
if msg.role == "system":
|
||||
system_content += (msg.content or "") + "\n"
|
||||
else:
|
||||
messages.append({"role": msg.role, "content": msg.content or ""})
|
||||
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=request.max_tokens or 4096,
|
||||
system=system_content.strip() if system_content else None,
|
||||
messages=messages,
|
||||
temperature=request.temperature,
|
||||
top_p=request.top_p,
|
||||
)
|
||||
|
||||
content = ""
|
||||
if response.content:
|
||||
content = response.content[0].text if response.content[0].type == "text" else ""
|
||||
|
||||
return InferenceResult(
|
||||
content=content,
|
||||
model=model,
|
||||
backend="anthropic",
|
||||
usage=Usage(
|
||||
prompt_tokens=response.usage.input_tokens,
|
||||
completion_tokens=response.usage.output_tokens,
|
||||
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
||||
),
|
||||
finish_reason="stop" if response.stop_reason == "end_turn" else response.stop_reason or "stop",
|
||||
)
|
||||
|
||||
async def _stream_anthropic(
|
||||
self,
|
||||
backend: LLMBackendConfig,
|
||||
model: str,
|
||||
request: ChatCompletionRequest,
|
||||
response_id: str,
|
||||
) -> AsyncIterator[ChatCompletionChunk]:
|
||||
"""Streamt von Anthropic Claude API."""
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError:
|
||||
raise ImportError("anthropic package required for Claude API")
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=backend.api_key)
|
||||
|
||||
# System message extrahieren
|
||||
system_content = ""
|
||||
messages = []
|
||||
for msg in request.messages:
|
||||
if msg.role == "system":
|
||||
system_content += (msg.content or "") + "\n"
|
||||
else:
|
||||
messages.append({"role": msg.role, "content": msg.content or ""})
|
||||
|
||||
async with client.messages.stream(
|
||||
model=model,
|
||||
max_tokens=request.max_tokens or 4096,
|
||||
system=system_content.strip() if system_content else None,
|
||||
messages=messages,
|
||||
temperature=request.temperature,
|
||||
top_p=request.top_p,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
yield ChatCompletionChunk(
|
||||
id=response_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(content=text),
|
||||
finish_reason=None,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Final chunk with finish_reason
|
||||
yield ChatCompletionChunk(
|
||||
id=response_id,
|
||||
model=model,
|
||||
choices=[
|
||||
StreamChoice(
|
||||
index=0,
|
||||
delta=ChatChoiceDelta(),
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
async def complete(self, request: ChatCompletionRequest) -> ChatCompletionResponse:
|
||||
"""
|
||||
Führt Chat Completion durch (non-streaming).
|
||||
"""
|
||||
actual_model, backend = self._map_model_to_backend(request.model)
|
||||
|
||||
logger.info(f"Inference request: model={request.model} → {actual_model} via {backend.name}")
|
||||
|
||||
if backend.name == "ollama":
|
||||
result = await self._call_ollama(backend, actual_model, request)
|
||||
elif backend.name == "anthropic":
|
||||
result = await self._call_anthropic(backend, actual_model, request)
|
||||
else:
|
||||
result = await self._call_openai_compatible(backend, actual_model, request)
|
||||
|
||||
return ChatCompletionResponse(
|
||||
model=request.model, # Original requested model name
|
||||
choices=[
|
||||
ChatChoice(
|
||||
index=0,
|
||||
message=ChatMessage(role="assistant", content=result.content),
|
||||
finish_reason=result.finish_reason,
|
||||
)
|
||||
],
|
||||
usage=result.usage,
|
||||
)
|
||||
|
||||
async def stream(self, request: ChatCompletionRequest) -> AsyncIterator[ChatCompletionChunk]:
|
||||
"""
|
||||
Führt Chat Completion mit Streaming durch.
|
||||
"""
|
||||
import uuid
|
||||
response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
actual_model, backend = self._map_model_to_backend(request.model)
|
||||
|
||||
logger.info(f"Streaming request: model={request.model} → {actual_model} via {backend.name}")
|
||||
|
||||
if backend.name == "ollama":
|
||||
async for chunk in self._stream_ollama(backend, actual_model, request, response_id):
|
||||
yield chunk
|
||||
elif backend.name == "anthropic":
|
||||
async for chunk in self._stream_anthropic(backend, actual_model, request, response_id):
|
||||
yield chunk
|
||||
else:
|
||||
async for chunk in self._stream_openai_compatible(backend, actual_model, request, response_id):
|
||||
yield chunk
|
||||
|
||||
async def list_models(self) -> ModelListResponse:
|
||||
"""Listet verfügbare Modelle."""
|
||||
models = []
|
||||
|
||||
# BreakPilot Modelle (mapped zu verfügbaren Backends)
|
||||
backend = self._get_available_backend()
|
||||
if backend:
|
||||
models.extend([
|
||||
ModelInfo(
|
||||
id="breakpilot-teacher-8b",
|
||||
owned_by="breakpilot",
|
||||
description="Llama 3.1 8B optimiert für Schulkontext",
|
||||
context_length=8192,
|
||||
),
|
||||
ModelInfo(
|
||||
id="breakpilot-teacher-70b",
|
||||
owned_by="breakpilot",
|
||||
description="Llama 3.1 70B für komplexe Aufgaben",
|
||||
context_length=8192,
|
||||
),
|
||||
])
|
||||
|
||||
# Claude Modelle (wenn Anthropic konfiguriert)
|
||||
if self.config.anthropic and self.config.anthropic.enabled:
|
||||
models.append(
|
||||
ModelInfo(
|
||||
id="claude-3-5-sonnet",
|
||||
owned_by="anthropic",
|
||||
description="Claude 3.5 Sonnet - Fallback für höchste Qualität",
|
||||
context_length=200000,
|
||||
)
|
||||
)
|
||||
|
||||
return ModelListResponse(data=models)
|
||||
|
||||
|
||||
# Singleton
|
||||
_inference_service: Optional[InferenceService] = None
|
||||
|
||||
|
||||
def get_inference_service() -> InferenceService:
|
||||
"""Gibt den Inference Service Singleton zurück."""
|
||||
global _inference_service
|
||||
if _inference_service is None:
|
||||
_inference_service = InferenceService()
|
||||
return _inference_service
|
||||
290
backend/llm_gateway/services/legal_crawler.py
Normal file
290
backend/llm_gateway/services/legal_crawler.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Legal Content Crawler Service.
|
||||
|
||||
Crawlt Schulgesetze und rechtliche Inhalte von den Seed-URLs
|
||||
und speichert sie in der Datenbank für den Communication-Service.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrawledDocument:
|
||||
"""Repräsentiert ein gecrawltes Dokument."""
|
||||
url: str
|
||||
canonical_url: Optional[str]
|
||||
title: str
|
||||
content: str
|
||||
content_hash: str
|
||||
category: str
|
||||
doc_type: str
|
||||
state: Optional[str]
|
||||
law_name: Optional[str]
|
||||
paragraphs: Optional[List[Dict]]
|
||||
trust_score: float
|
||||
|
||||
|
||||
class LegalCrawler:
|
||||
"""Crawler für rechtliche Bildungsinhalte."""
|
||||
|
||||
def __init__(self, db_pool=None):
|
||||
self.db_pool = db_pool
|
||||
self.user_agent = "BreakPilot-Crawler/1.0 (Educational Purpose)"
|
||||
self.timeout = 30.0
|
||||
self.rate_limit_delay = 1.0 # Sekunden zwischen Requests
|
||||
|
||||
async def crawl_url(self, url: str, seed_info: Dict) -> Optional[CrawledDocument]:
|
||||
"""
|
||||
Crawlt eine URL und extrahiert den Inhalt.
|
||||
|
||||
Args:
|
||||
url: Die zu crawlende URL
|
||||
seed_info: Metadaten vom Seed (category, state, trust_boost)
|
||||
|
||||
Returns:
|
||||
CrawledDocument oder None bei Fehler
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True,
|
||||
timeout=self.timeout,
|
||||
headers={"User-Agent": self.user_agent}
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(f"HTTP {response.status_code} für {url}")
|
||||
return None
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
|
||||
# PDF-Handling (für Saarland etc.)
|
||||
if "pdf" in content_type.lower():
|
||||
return await self._process_pdf(response, url, seed_info)
|
||||
|
||||
# HTML-Handling
|
||||
if "html" in content_type.lower():
|
||||
return await self._process_html(response, url, seed_info)
|
||||
|
||||
logger.warning(f"Unbekannter Content-Type: {content_type} für {url}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler beim Crawlen von {url}: {e}")
|
||||
return None
|
||||
|
||||
async def _process_html(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
url: str,
|
||||
seed_info: Dict
|
||||
) -> Optional[CrawledDocument]:
|
||||
"""Verarbeitet HTML-Inhalte."""
|
||||
html = response.text
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
# Titel extrahieren
|
||||
title = ""
|
||||
title_tag = soup.find("title")
|
||||
if title_tag:
|
||||
title = title_tag.get_text(strip=True)
|
||||
|
||||
# Haupt-Content extrahieren (verschiedene Strategien)
|
||||
content = ""
|
||||
|
||||
# Strategie 1: main oder article Tag
|
||||
main = soup.find("main") or soup.find("article")
|
||||
if main:
|
||||
content = main.get_text(separator="\n", strip=True)
|
||||
else:
|
||||
# Strategie 2: Body ohne Navigation etc.
|
||||
for tag in soup.find_all(["nav", "header", "footer", "aside", "script", "style"]):
|
||||
tag.decompose()
|
||||
body = soup.find("body")
|
||||
if body:
|
||||
content = body.get_text(separator="\n", strip=True)
|
||||
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# Paragraphen extrahieren (für Schulgesetze)
|
||||
paragraphs = self._extract_paragraphs(soup, content)
|
||||
|
||||
# Law name ermitteln
|
||||
law_name = seed_info.get("name", "")
|
||||
if not law_name and title:
|
||||
# Aus Titel extrahieren
|
||||
law_patterns = [
|
||||
r"(SchulG\s+\w+)",
|
||||
r"(Schulgesetz\s+\w+)",
|
||||
r"(BayEUG)",
|
||||
r"(\w+SchulG)",
|
||||
]
|
||||
for pattern in law_patterns:
|
||||
match = re.search(pattern, title)
|
||||
if match:
|
||||
law_name = match.group(1)
|
||||
break
|
||||
|
||||
# Content Hash berechnen
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()[:64]
|
||||
|
||||
return CrawledDocument(
|
||||
url=url,
|
||||
canonical_url=str(response.url),
|
||||
title=title,
|
||||
content=content[:100000], # Max 100k Zeichen
|
||||
content_hash=content_hash,
|
||||
category=seed_info.get("category", "legal"),
|
||||
doc_type="schulgesetz",
|
||||
state=seed_info.get("state"),
|
||||
law_name=law_name,
|
||||
paragraphs=paragraphs,
|
||||
trust_score=seed_info.get("trust_boost", 0.9),
|
||||
)
|
||||
|
||||
async def _process_pdf(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
url: str,
|
||||
seed_info: Dict
|
||||
) -> Optional[CrawledDocument]:
|
||||
"""Verarbeitet PDF-Inhalte (Placeholder - benötigt PDF-Library)."""
|
||||
# TODO: PDF-Extraktion mit PyPDF2 oder pdfplumber
|
||||
logger.info(f"PDF erkannt: {url} - PDF-Extraktion noch nicht implementiert")
|
||||
return None
|
||||
|
||||
def _extract_paragraphs(
|
||||
self,
|
||||
soup: BeautifulSoup,
|
||||
content: str
|
||||
) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Extrahiert Paragraphen aus Gesetzestexten.
|
||||
|
||||
Sucht nach Mustern wie:
|
||||
- § 42 Titel
|
||||
- Paragraph 42
|
||||
"""
|
||||
paragraphs = []
|
||||
|
||||
# Pattern für Paragraphen
|
||||
paragraph_pattern = r"(§\s*\d+[a-z]?)\s*([^\n§]+)"
|
||||
matches = re.findall(paragraph_pattern, content, re.MULTILINE)
|
||||
|
||||
for nr, title in matches[:50]: # Max 50 Paragraphen
|
||||
paragraphs.append({
|
||||
"nr": nr.strip(),
|
||||
"title": title.strip()[:200],
|
||||
})
|
||||
|
||||
return paragraphs if paragraphs else None
|
||||
|
||||
async def crawl_legal_seeds(self, db_pool) -> Dict:
|
||||
"""
|
||||
Crawlt alle Seeds der Kategorie 'legal'.
|
||||
|
||||
Returns:
|
||||
Statistik über gecrawlte Dokumente
|
||||
"""
|
||||
stats = {
|
||||
"total": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
# Seeds aus DB laden
|
||||
async with db_pool.acquire() as conn:
|
||||
seeds = await conn.fetch("""
|
||||
SELECT s.id, s.url, s.name, s.state, s.trust_boost,
|
||||
c.name as category
|
||||
FROM edu_search_seeds s
|
||||
LEFT JOIN edu_search_categories c ON s.category_id = c.id
|
||||
WHERE c.name = 'legal' AND s.enabled = true
|
||||
""")
|
||||
|
||||
stats["total"] = len(seeds)
|
||||
logger.info(f"Crawle {len(seeds)} Legal-Seeds...")
|
||||
|
||||
for seed in seeds:
|
||||
# Rate Limiting
|
||||
await asyncio.sleep(self.rate_limit_delay)
|
||||
|
||||
seed_info = {
|
||||
"name": seed["name"],
|
||||
"state": seed["state"],
|
||||
"trust_boost": seed["trust_boost"],
|
||||
"category": seed["category"],
|
||||
}
|
||||
|
||||
doc = await self.crawl_url(seed["url"], seed_info)
|
||||
|
||||
if doc:
|
||||
# In DB speichern
|
||||
try:
|
||||
await conn.execute("""
|
||||
INSERT INTO edu_search_documents
|
||||
(url, canonical_url, title, content, content_hash,
|
||||
category, doc_type, state, law_name, paragraphs,
|
||||
trust_score, seed_id, last_crawled_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, NOW())
|
||||
ON CONFLICT (url) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
content = EXCLUDED.content,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
paragraphs = EXCLUDED.paragraphs,
|
||||
last_crawled_at = NOW(),
|
||||
content_updated_at = CASE
|
||||
WHEN edu_search_documents.content_hash != EXCLUDED.content_hash
|
||||
THEN NOW()
|
||||
ELSE edu_search_documents.content_updated_at
|
||||
END
|
||||
""",
|
||||
doc.url, doc.canonical_url, doc.title, doc.content,
|
||||
doc.content_hash, doc.category, doc.doc_type, doc.state,
|
||||
doc.law_name,
|
||||
str(doc.paragraphs) if doc.paragraphs else None,
|
||||
doc.trust_score, seed["id"]
|
||||
)
|
||||
stats["success"] += 1
|
||||
logger.info(f"✓ Gecrawlt: {doc.title[:50]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"DB-Fehler für {doc.url}: {e}")
|
||||
stats["failed"] += 1
|
||||
else:
|
||||
stats["failed"] += 1
|
||||
|
||||
# Seed-Status aktualisieren
|
||||
await conn.execute("""
|
||||
UPDATE edu_search_seeds
|
||||
SET last_crawled_at = NOW(),
|
||||
last_crawl_status = $1
|
||||
WHERE id = $2
|
||||
""", "success" if doc else "failed", seed["id"])
|
||||
|
||||
logger.info(f"Crawl abgeschlossen: {stats}")
|
||||
return stats
|
||||
|
||||
|
||||
# Singleton-Instanz
|
||||
_crawler_instance: Optional[LegalCrawler] = None
|
||||
|
||||
|
||||
def get_legal_crawler() -> LegalCrawler:
|
||||
"""Gibt die Singleton-Instanz des Legal Crawlers zurück."""
|
||||
global _crawler_instance
|
||||
if _crawler_instance is None:
|
||||
_crawler_instance = LegalCrawler()
|
||||
return _crawler_instance
|
||||
249
backend/llm_gateway/services/pii_detector.py
Normal file
249
backend/llm_gateway/services/pii_detector.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
PII Detector Service.
|
||||
|
||||
Erkennt und redaktiert personenbezogene Daten (PII) in Texten
|
||||
bevor sie an externe Services wie Tavily gesendet werden.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PIIType(Enum):
|
||||
"""Typen von PII."""
|
||||
EMAIL = "email"
|
||||
PHONE = "phone"
|
||||
IBAN = "iban"
|
||||
CREDIT_CARD = "credit_card"
|
||||
SSN = "ssn" # Sozialversicherungsnummer
|
||||
NAME = "name"
|
||||
ADDRESS = "address"
|
||||
DATE_OF_BIRTH = "date_of_birth"
|
||||
IP_ADDRESS = "ip_address"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PIIMatch:
|
||||
"""Ein gefundenes PII-Element."""
|
||||
type: PIIType
|
||||
value: str
|
||||
start: int
|
||||
end: int
|
||||
replacement: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedactionResult:
|
||||
"""Ergebnis der PII-Redaktion."""
|
||||
original_text: str
|
||||
redacted_text: str
|
||||
matches: list[PIIMatch] = field(default_factory=list)
|
||||
pii_found: bool = False
|
||||
|
||||
|
||||
class PIIDetector:
|
||||
"""
|
||||
Service zur Erkennung und Redaktion von PII.
|
||||
|
||||
Verwendet Regex-Pattern für deutsche und internationale Formate.
|
||||
"""
|
||||
|
||||
# Regex Patterns für verschiedene PII-Typen
|
||||
PATTERNS = {
|
||||
PIIType.EMAIL: r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
|
||||
|
||||
# Deutsche Telefonnummern (verschiedene Formate)
|
||||
PIIType.PHONE: r'(?:\+49|0049|0)[\s\-/]?(?:\d{2,5})[\s\-/]?(?:\d{3,8})[\s\-/]?(?:\d{0,5})',
|
||||
|
||||
# IBAN (deutsch und international)
|
||||
PIIType.IBAN: r'\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){4,7}\d{0,2}\b',
|
||||
|
||||
# Kreditkarten (Visa, Mastercard, Amex)
|
||||
PIIType.CREDIT_CARD: r'\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b',
|
||||
|
||||
# Deutsche Sozialversicherungsnummer
|
||||
PIIType.SSN: r'\b\d{2}[\s]?\d{6}[\s]?[A-Z][\s]?\d{3}\b',
|
||||
|
||||
# IP-Adressen (IPv4)
|
||||
PIIType.IP_ADDRESS: r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b',
|
||||
|
||||
# Geburtsdatum (deutsche Formate)
|
||||
PIIType.DATE_OF_BIRTH: r'\b(?:0?[1-9]|[12]\d|3[01])\.(?:0?[1-9]|1[0-2])\.(?:19|20)\d{2}\b',
|
||||
}
|
||||
|
||||
# Ersetzungstexte
|
||||
REPLACEMENTS = {
|
||||
PIIType.EMAIL: "[EMAIL_REDACTED]",
|
||||
PIIType.PHONE: "[PHONE_REDACTED]",
|
||||
PIIType.IBAN: "[IBAN_REDACTED]",
|
||||
PIIType.CREDIT_CARD: "[CARD_REDACTED]",
|
||||
PIIType.SSN: "[SSN_REDACTED]",
|
||||
PIIType.NAME: "[NAME_REDACTED]",
|
||||
PIIType.ADDRESS: "[ADDRESS_REDACTED]",
|
||||
PIIType.DATE_OF_BIRTH: "[DOB_REDACTED]",
|
||||
PIIType.IP_ADDRESS: "[IP_REDACTED]",
|
||||
}
|
||||
|
||||
# Priorität für überlappende Matches (höher = wird bevorzugt)
|
||||
PRIORITY = {
|
||||
PIIType.EMAIL: 100,
|
||||
PIIType.IBAN: 90,
|
||||
PIIType.CREDIT_CARD: 85,
|
||||
PIIType.SSN: 80,
|
||||
PIIType.IP_ADDRESS: 70,
|
||||
PIIType.DATE_OF_BIRTH: 60,
|
||||
PIIType.PHONE: 50, # Niedrigere Priorität wegen False Positives
|
||||
PIIType.NAME: 40,
|
||||
PIIType.ADDRESS: 30,
|
||||
}
|
||||
|
||||
def __init__(self, enabled_types: Optional[list[PIIType]] = None):
|
||||
"""
|
||||
Initialisiert den PII Detector.
|
||||
|
||||
Args:
|
||||
enabled_types: Liste der zu erkennenden PII-Typen.
|
||||
None = alle Typen aktiviert.
|
||||
Leere Liste = keine Erkennung.
|
||||
"""
|
||||
if enabled_types is not None:
|
||||
self.enabled_types = enabled_types
|
||||
else:
|
||||
self.enabled_types = list(PIIType)
|
||||
|
||||
self._compiled_patterns = {
|
||||
pii_type: re.compile(pattern, re.IGNORECASE)
|
||||
for pii_type, pattern in self.PATTERNS.items()
|
||||
if pii_type in self.enabled_types
|
||||
}
|
||||
|
||||
def detect(self, text: str) -> list[PIIMatch]:
|
||||
"""
|
||||
Erkennt PII in einem Text.
|
||||
|
||||
Bei überlappenden Matches wird der Match mit höherer Priorität
|
||||
bevorzugt (z.B. IBAN über Telefon).
|
||||
|
||||
Args:
|
||||
text: Der zu analysierende Text.
|
||||
|
||||
Returns:
|
||||
Liste der gefundenen PII-Matches.
|
||||
"""
|
||||
all_matches = []
|
||||
|
||||
for pii_type, pattern in self._compiled_patterns.items():
|
||||
for match in pattern.finditer(text):
|
||||
all_matches.append(PIIMatch(
|
||||
type=pii_type,
|
||||
value=match.group(),
|
||||
start=match.start(),
|
||||
end=match.end(),
|
||||
replacement=self.REPLACEMENTS[pii_type],
|
||||
))
|
||||
|
||||
# Überlappende Matches filtern (höhere Priorität gewinnt)
|
||||
matches = self._filter_overlapping(all_matches)
|
||||
|
||||
# Nach Position sortieren (für korrekte Redaktion)
|
||||
matches.sort(key=lambda m: m.start)
|
||||
return matches
|
||||
|
||||
def _filter_overlapping(self, matches: list[PIIMatch]) -> list[PIIMatch]:
|
||||
"""
|
||||
Filtert überlappende Matches, bevorzugt höhere Priorität.
|
||||
|
||||
Args:
|
||||
matches: Alle gefundenen Matches.
|
||||
|
||||
Returns:
|
||||
Gefilterte Liste ohne Überlappungen.
|
||||
"""
|
||||
if not matches:
|
||||
return []
|
||||
|
||||
# Nach Priorität sortieren (höchste zuerst)
|
||||
sorted_matches = sorted(
|
||||
matches,
|
||||
key=lambda m: self.PRIORITY.get(m.type, 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
result = []
|
||||
used_ranges: list[tuple[int, int]] = []
|
||||
|
||||
for match in sorted_matches:
|
||||
# Prüfen ob dieser Match mit einem bereits akzeptierten überlappt
|
||||
overlaps = False
|
||||
for start, end in used_ranges:
|
||||
# Überlappung wenn: match.start < end AND match.end > start
|
||||
if match.start < end and match.end > start:
|
||||
overlaps = True
|
||||
break
|
||||
|
||||
if not overlaps:
|
||||
result.append(match)
|
||||
used_ranges.append((match.start, match.end))
|
||||
|
||||
return result
|
||||
|
||||
def redact(self, text: str) -> RedactionResult:
|
||||
"""
|
||||
Erkennt und redaktiert PII in einem Text.
|
||||
|
||||
Args:
|
||||
text: Der zu redaktierende Text.
|
||||
|
||||
Returns:
|
||||
RedactionResult mit originalem und redaktiertem Text.
|
||||
"""
|
||||
matches = self.detect(text)
|
||||
|
||||
if not matches:
|
||||
return RedactionResult(
|
||||
original_text=text,
|
||||
redacted_text=text,
|
||||
matches=[],
|
||||
pii_found=False,
|
||||
)
|
||||
|
||||
# Von hinten nach vorne ersetzen (um Indizes zu erhalten)
|
||||
redacted = text
|
||||
for match in reversed(matches):
|
||||
redacted = redacted[:match.start] + match.replacement + redacted[match.end:]
|
||||
|
||||
return RedactionResult(
|
||||
original_text=text,
|
||||
redacted_text=redacted,
|
||||
matches=matches,
|
||||
pii_found=True,
|
||||
)
|
||||
|
||||
def contains_pii(self, text: str) -> bool:
|
||||
"""
|
||||
Prüft schnell, ob Text PII enthält.
|
||||
|
||||
Args:
|
||||
text: Der zu prüfende Text.
|
||||
|
||||
Returns:
|
||||
True wenn PII gefunden wurde.
|
||||
"""
|
||||
for pattern in self._compiled_patterns.values():
|
||||
if pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton Instance
|
||||
_pii_detector: Optional[PIIDetector] = None
|
||||
|
||||
|
||||
def get_pii_detector() -> PIIDetector:
|
||||
"""Gibt Singleton-Instanz des PII Detectors zurück."""
|
||||
global _pii_detector
|
||||
if _pii_detector is None:
|
||||
_pii_detector = PIIDetector()
|
||||
return _pii_detector
|
||||
322
backend/llm_gateway/services/playbook_service.py
Normal file
322
backend/llm_gateway/services/playbook_service.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Playbook Service - Verwaltung von System Prompts.
|
||||
|
||||
Playbooks sind versionierte System-Prompt-Vorlagen für spezifische Schulkontexte.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Playbook:
|
||||
"""Ein Playbook mit System Prompt."""
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
system_prompt: str
|
||||
prompt_version: str
|
||||
recommended_models: list[str] = field(default_factory=list)
|
||||
tool_policy: dict = field(default_factory=dict)
|
||||
status: str = "published" # draft, review, approved, published
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
# Initiale Playbooks (später aus DB laden)
|
||||
DEFAULT_PLAYBOOKS: dict[str, Playbook] = {
|
||||
"pb_default": Playbook(
|
||||
id="pb_default",
|
||||
name="Standard-Assistent",
|
||||
description="Allgemeiner Assistent für Lehrkräfte",
|
||||
system_prompt="""Du bist ein hilfreicher Assistent für Lehrkräfte an deutschen Schulen.
|
||||
|
||||
Richtlinien:
|
||||
- Antworte präzise und verständlich
|
||||
- Berücksichtige den deutschen Schulkontext
|
||||
- Beachte datenschutzrechtliche Aspekte (DSGVO)
|
||||
- Verwende geschlechtergerechte Sprache
|
||||
- Gib bei rechtlichen Fragen den Hinweis, dass du keine Rechtsberatung ersetzen kannst""",
|
||||
prompt_version="1.0.0",
|
||||
recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"],
|
||||
tool_policy={"allow_web_search": True, "no_pii_in_output": True},
|
||||
),
|
||||
"pb_elternbrief": Playbook(
|
||||
id="pb_elternbrief",
|
||||
name="Elternbrief",
|
||||
description="Professionelle Elternkommunikation verfassen",
|
||||
system_prompt="""Du bist ein erfahrener Schulassistent, der Lehrkräften hilft, professionelle Elternbriefe zu verfassen.
|
||||
|
||||
Richtlinien für Elternbriefe:
|
||||
- Höflicher, respektvoller Ton
|
||||
- Klare, verständliche Sprache (kein Fachjargon)
|
||||
- Strukturierte Gliederung mit Datum, Betreff, Anrede
|
||||
- Wichtige Informationen hervorheben
|
||||
- Handlungsaufforderungen klar formulieren
|
||||
- Kontaktmöglichkeiten angeben
|
||||
- Keine personenbezogenen Daten einzelner Schüler*innen nennen
|
||||
- DSGVO-konform formulieren
|
||||
|
||||
Format:
|
||||
- Briefkopf mit Schule, Datum
|
||||
- Betreff-Zeile
|
||||
- Anrede "Sehr geehrte Eltern und Erziehungsberechtigte,"
|
||||
- Haupttext in Absätzen
|
||||
- Grußformel
|
||||
- Unterschrift mit Name und Funktion""",
|
||||
prompt_version="1.1.0",
|
||||
recommended_models=["breakpilot-teacher-8b"],
|
||||
tool_policy={"allow_web_search": False, "no_pii_in_output": True},
|
||||
),
|
||||
"pb_arbeitsblatt": Playbook(
|
||||
id="pb_arbeitsblatt",
|
||||
name="Arbeitsblatt erstellen",
|
||||
description="Arbeitsblätter für verschiedene Klassenstufen und Fächer",
|
||||
system_prompt="""Du bist ein erfahrener Didaktiker, der Lehrkräften bei der Erstellung von Arbeitsblättern hilft.
|
||||
|
||||
Bei der Erstellung von Arbeitsblättern beachte:
|
||||
- Klassenstufe und Lernstand berücksichtigen
|
||||
- Klare, verständliche Aufgabenstellungen
|
||||
- Differenzierungsmöglichkeiten anbieten (leicht/mittel/schwer)
|
||||
- Platz für Antworten einplanen
|
||||
- Visualisierungen wo sinnvoll vorschlagen
|
||||
- Bezug zum Lehrplan herstellen
|
||||
- Zeitaufwand realistisch einschätzen
|
||||
|
||||
Format für Arbeitsblätter:
|
||||
- Titel und Thema
|
||||
- Klassenstufe/Fach
|
||||
- Lernziele (für Lehrkraft)
|
||||
- Aufgaben mit Nummerierung
|
||||
- Platzhalter für Antworten [___]
|
||||
- Optionale Zusatzaufgaben
|
||||
- Lösungshinweise (optional, für Lehrkraft)""",
|
||||
prompt_version="1.2.0",
|
||||
recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"],
|
||||
tool_policy={"allow_web_search": True, "no_pii_in_output": True},
|
||||
),
|
||||
"pb_foerderplan": Playbook(
|
||||
id="pb_foerderplan",
|
||||
name="Förderplan",
|
||||
description="Individuelle Förderpläne erstellen",
|
||||
system_prompt="""Du bist ein erfahrener Sonderpädagoge/Förderschullehrer, der bei der Erstellung von Förderplänen unterstützt.
|
||||
|
||||
WICHTIG: Förderpläne enthalten sensible Daten. Erstelle nur Vorlagen und Strukturen, keine echten Schülerdaten.
|
||||
|
||||
Struktur eines Förderplans:
|
||||
1. Ausgangslage
|
||||
- Stärken des Kindes
|
||||
- Entwicklungsbereiche
|
||||
- Bisherige Fördermaßnahmen
|
||||
|
||||
2. Förderziele (SMART formuliert)
|
||||
- Spezifisch, Messbar, Attraktiv, Realistisch, Terminiert
|
||||
- Kurzfristige Ziele (4-6 Wochen)
|
||||
- Mittelfristige Ziele (Halbjahr)
|
||||
|
||||
3. Maßnahmen
|
||||
- Konkrete Fördermaßnahmen
|
||||
- Methoden und Materialien
|
||||
- Verantwortlichkeiten
|
||||
|
||||
4. Evaluation
|
||||
- Beobachtungskriterien
|
||||
- Dokumentation
|
||||
- Anpassungszeitpunkte
|
||||
|
||||
Rechtliche Hinweise:
|
||||
- Förderpläne sind vertrauliche Dokumente
|
||||
- Eltern haben Einsichtsrecht
|
||||
- Regelmäßige Fortschreibung erforderlich""",
|
||||
prompt_version="1.0.0",
|
||||
recommended_models=["breakpilot-teacher-70b"],
|
||||
tool_policy={"allow_web_search": False, "no_pii_in_output": True},
|
||||
),
|
||||
"pb_rechtlich": Playbook(
|
||||
id="pb_rechtlich",
|
||||
name="Rechtliche Fragen",
|
||||
description="Schulrechtliche und datenschutzrechtliche Fragen",
|
||||
system_prompt="""Du bist ein Experte für Schulrecht und Datenschutz im Bildungsbereich.
|
||||
|
||||
WICHTIGER HINWEIS: Du gibst allgemeine Informationen, keine Rechtsberatung. Bei konkreten Rechtsfragen sollte immer ein Fachanwalt oder die Schulbehörde konsultiert werden.
|
||||
|
||||
Themengebiete:
|
||||
- DSGVO im Schulkontext
|
||||
- Schulgesetze der Bundesländer
|
||||
- Aufsichtspflicht
|
||||
- Urheberrecht im Unterricht
|
||||
- Elternrechte und -pflichten
|
||||
- Dokumentationspflichten
|
||||
- Datenschutz bei digitalen Medien
|
||||
|
||||
Bei Antworten:
|
||||
- Auf Bundesland-spezifische Regelungen hinweisen
|
||||
- Rechtsquellen nennen (z.B. SchulG, DSGVO-Artikel)
|
||||
- Auf Aktualität der Informationen hinweisen
|
||||
- Immer empfehlen, aktuelle Regelungen zu prüfen
|
||||
- Bei Unsicherheit an zuständige Stellen verweisen""",
|
||||
prompt_version="1.0.0",
|
||||
recommended_models=["breakpilot-teacher-70b", "claude-3-5-sonnet"],
|
||||
tool_policy={"allow_web_search": True, "no_pii_in_output": True},
|
||||
),
|
||||
"pb_kommunikation": Playbook(
|
||||
id="pb_kommunikation",
|
||||
name="Elternkommunikation",
|
||||
description="Kommunikation mit Eltern in verschiedenen Situationen",
|
||||
system_prompt="""Du bist ein erfahrener Schulberater, der bei der Kommunikation mit Eltern unterstützt.
|
||||
|
||||
Kommunikationssituationen:
|
||||
- Elterngespräche vorbereiten
|
||||
- Schwierige Gespräche führen
|
||||
- Konflikte deeskalieren
|
||||
- Positive Rückmeldungen formulieren
|
||||
- Unterstützung einfordern
|
||||
|
||||
Kommunikationsgrundsätze:
|
||||
- Wertschätzender, respektvoller Ton
|
||||
- Sachlich bleiben, auch bei Emotionen
|
||||
- Ich-Botschaften verwenden
|
||||
- Konkrete Beobachtungen statt Bewertungen
|
||||
- Gemeinsame Lösungen suchen
|
||||
- Ressourcen und Stärken betonen
|
||||
- Vertraulichkeit wahren
|
||||
|
||||
Struktur für Elterngespräche:
|
||||
1. Begrüßung und Gesprächsrahmen
|
||||
2. Positiver Einstieg
|
||||
3. Beobachtungen mitteilen
|
||||
4. Perspektive der Eltern hören
|
||||
5. Gemeinsame Ziele definieren
|
||||
6. Konkrete Vereinbarungen treffen
|
||||
7. Positiver Abschluss""",
|
||||
prompt_version="1.0.0",
|
||||
recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"],
|
||||
tool_policy={"allow_web_search": False, "no_pii_in_output": True},
|
||||
),
|
||||
"mail_analysis": Playbook(
|
||||
id="mail_analysis",
|
||||
name="E-Mail-Analyse",
|
||||
description="Analyse eingehender E-Mails für Schulleiter/innen",
|
||||
system_prompt="""Du bist ein intelligenter Assistent für Schulleitungen in Niedersachsen.
|
||||
|
||||
Deine Aufgabe ist die Analyse eingehender E-Mails:
|
||||
|
||||
1. ABSENDER-KLASSIFIKATION:
|
||||
Erkenne den Absender-Typ:
|
||||
- kultusministerium: Kultusministerium (MK)
|
||||
- landesschulbehoerde: Landesschulbehörde (NLSchB)
|
||||
- rlsb: Regionales Landesamt für Schule und Bildung
|
||||
- schulamt: Schulamt
|
||||
- nibis: Niedersächsischer Bildungsserver
|
||||
- schultraeger: Schulträger/Kommune
|
||||
- elternvertreter: Elternvertreter/Elternrat
|
||||
- gewerkschaft: GEW, VBE, etc.
|
||||
- fortbildungsinstitut: NLQ, etc.
|
||||
- privatperson: Privatperson
|
||||
- unternehmen: Firma
|
||||
- unbekannt: Nicht einzuordnen
|
||||
|
||||
2. FRISTEN-ERKENNUNG:
|
||||
Extrahiere alle genannten Fristen und Termine:
|
||||
- Datum im Format YYYY-MM-DD
|
||||
- Beschreibung der Frist
|
||||
- Verbindlichkeit (ja/nein)
|
||||
|
||||
3. KATEGORISIERUNG:
|
||||
Ordne die E-Mail einer Kategorie zu:
|
||||
- dienstlich: Offizielle Dienstangelegenheiten
|
||||
- personal: Personalangelegenheiten
|
||||
- finanzen: Haushalts-/Finanzthemen
|
||||
- eltern: Elternkommunikation
|
||||
- schueler: Schülerangelegenheiten
|
||||
- fortbildung: Fortbildungen
|
||||
- veranstaltung: Termine/Events
|
||||
- sicherheit: Sicherheit/Hygiene
|
||||
- technik: IT/Digitales
|
||||
- newsletter: Informationen
|
||||
- sonstiges: Andere
|
||||
|
||||
4. PRIORITÄT:
|
||||
Schlage eine Priorität vor:
|
||||
- urgent: Sofort bearbeiten
|
||||
- high: Zeitnah bearbeiten
|
||||
- medium: Normale Bearbeitung
|
||||
- low: Kann warten
|
||||
|
||||
Antworte präzise im geforderten Format. Keine langen Erklärungen.
|
||||
Beachte deutsche Datums- und Behördenformate.""",
|
||||
prompt_version="1.0.0",
|
||||
recommended_models=["breakpilot-teacher-8b", "llama-3.1-8b-instruct"],
|
||||
tool_policy={"allow_web_search": False, "no_pii_in_output": True},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PlaybookService:
|
||||
"""Service für Playbook-Verwaltung."""
|
||||
|
||||
def __init__(self):
|
||||
# In-Memory Storage (später DB)
|
||||
self._playbooks = DEFAULT_PLAYBOOKS.copy()
|
||||
|
||||
def list_playbooks(self, status: Optional[str] = "published") -> list[Playbook]:
|
||||
"""Listet alle Playbooks mit optionalem Status-Filter."""
|
||||
playbooks = list(self._playbooks.values())
|
||||
if status:
|
||||
playbooks = [p for p in playbooks if p.status == status]
|
||||
return playbooks
|
||||
|
||||
def get_playbook(self, playbook_id: str) -> Optional[Playbook]:
|
||||
"""Holt ein Playbook by ID."""
|
||||
return self._playbooks.get(playbook_id)
|
||||
|
||||
def get_system_prompt(self, playbook_id: str) -> Optional[str]:
|
||||
"""Holt nur den System Prompt eines Playbooks."""
|
||||
playbook = self.get_playbook(playbook_id)
|
||||
return playbook.system_prompt if playbook else None
|
||||
|
||||
def create_playbook(self, playbook: Playbook) -> Playbook:
|
||||
"""Erstellt ein neues Playbook."""
|
||||
if playbook.id in self._playbooks:
|
||||
raise ValueError(f"Playbook with id {playbook.id} already exists")
|
||||
self._playbooks[playbook.id] = playbook
|
||||
logger.info(f"Created playbook: {playbook.id}")
|
||||
return playbook
|
||||
|
||||
def update_playbook(self, playbook_id: str, **updates) -> Optional[Playbook]:
|
||||
"""Aktualisiert ein Playbook."""
|
||||
playbook = self._playbooks.get(playbook_id)
|
||||
if not playbook:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(playbook, key):
|
||||
setattr(playbook, key, value)
|
||||
|
||||
playbook.updated_at = datetime.now()
|
||||
logger.info(f"Updated playbook: {playbook_id}")
|
||||
return playbook
|
||||
|
||||
def delete_playbook(self, playbook_id: str) -> bool:
|
||||
"""Löscht ein Playbook."""
|
||||
if playbook_id in self._playbooks:
|
||||
del self._playbooks[playbook_id]
|
||||
logger.info(f"Deleted playbook: {playbook_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton
|
||||
_playbook_service: Optional[PlaybookService] = None
|
||||
|
||||
|
||||
def get_playbook_service() -> PlaybookService:
|
||||
"""Gibt den Playbook Service Singleton zurück."""
|
||||
global _playbook_service
|
||||
if _playbook_service is None:
|
||||
_playbook_service = PlaybookService()
|
||||
return _playbook_service
|
||||
285
backend/llm_gateway/services/tool_gateway.py
Normal file
285
backend/llm_gateway/services/tool_gateway.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Tool Gateway Service.
|
||||
|
||||
Bietet sichere Schnittstelle zu externen Tools wie Tavily Web Search.
|
||||
Alle Anfragen werden vor dem Versand auf PII geprüft und redaktiert.
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
from .pii_detector import PIIDetector, get_pii_detector, RedactionResult
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchDepth(str, Enum):
|
||||
"""Suchtiefe für Tavily."""
|
||||
BASIC = "basic"
|
||||
ADVANCED = "advanced"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Ein einzelnes Suchergebnis."""
|
||||
title: str
|
||||
url: str
|
||||
content: str
|
||||
score: float = 0.0
|
||||
published_date: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResponse:
|
||||
"""Antwort einer Suche."""
|
||||
query: str
|
||||
redacted_query: Optional[str] = None
|
||||
results: list[SearchResult] = field(default_factory=list)
|
||||
answer: Optional[str] = None
|
||||
pii_detected: bool = False
|
||||
pii_types: list[str] = field(default_factory=list)
|
||||
response_time_ms: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolGatewayConfig:
|
||||
"""Konfiguration für den Tool Gateway."""
|
||||
tavily_api_key: Optional[str] = None
|
||||
tavily_base_url: str = "https://api.tavily.com"
|
||||
timeout: int = 30
|
||||
max_results: int = 5
|
||||
search_depth: SearchDepth = SearchDepth.BASIC
|
||||
include_answer: bool = True
|
||||
include_images: bool = False
|
||||
pii_redaction_enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ToolGatewayConfig":
|
||||
"""Erstellt Config aus Umgebungsvariablen."""
|
||||
return cls(
|
||||
tavily_api_key=os.getenv("TAVILY_API_KEY"),
|
||||
tavily_base_url=os.getenv("TAVILY_BASE_URL", "https://api.tavily.com"),
|
||||
timeout=int(os.getenv("TAVILY_TIMEOUT", "30")),
|
||||
max_results=int(os.getenv("TAVILY_MAX_RESULTS", "5")),
|
||||
search_depth=SearchDepth(os.getenv("TAVILY_SEARCH_DEPTH", "basic")),
|
||||
include_answer=os.getenv("TAVILY_INCLUDE_ANSWER", "true").lower() == "true",
|
||||
include_images=os.getenv("TAVILY_INCLUDE_IMAGES", "false").lower() == "true",
|
||||
pii_redaction_enabled=os.getenv("PII_REDACTION_ENABLED", "true").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
class ToolGatewayError(Exception):
|
||||
"""Fehler im Tool Gateway."""
|
||||
pass
|
||||
|
||||
|
||||
class TavilyError(ToolGatewayError):
|
||||
"""Fehler bei Tavily API."""
|
||||
pass
|
||||
|
||||
|
||||
class ToolGateway:
|
||||
"""
|
||||
Gateway für externe Tools mit PII-Schutz.
|
||||
|
||||
Alle Anfragen werden vor dem Versand auf personenbezogene Daten
|
||||
geprüft und diese redaktiert. Dies gewährleistet DSGVO-Konformität.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[ToolGatewayConfig] = None,
|
||||
pii_detector: Optional[PIIDetector] = None,
|
||||
):
|
||||
"""
|
||||
Initialisiert den Tool Gateway.
|
||||
|
||||
Args:
|
||||
config: Konfiguration. None = aus Umgebungsvariablen.
|
||||
pii_detector: PII Detector. None = Standard-Detector.
|
||||
"""
|
||||
self.config = config or ToolGatewayConfig.from_env()
|
||||
self.pii_detector = pii_detector or get_pii_detector()
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
@property
|
||||
def tavily_available(self) -> bool:
|
||||
"""Prüft ob Tavily konfiguriert ist."""
|
||||
return bool(self.config.tavily_api_key)
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""Lazy-init HTTP Client."""
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=self.config.timeout,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""Schließt HTTP Client."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def _redact_query(self, query: str) -> RedactionResult:
|
||||
"""
|
||||
Redaktiert PII aus einer Suchanfrage.
|
||||
|
||||
Args:
|
||||
query: Die originale Suchanfrage.
|
||||
|
||||
Returns:
|
||||
RedactionResult mit redaktiertem Text.
|
||||
"""
|
||||
if not self.config.pii_redaction_enabled:
|
||||
return RedactionResult(
|
||||
original_text=query,
|
||||
redacted_text=query,
|
||||
matches=[],
|
||||
pii_found=False,
|
||||
)
|
||||
return self.pii_detector.redact(query)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
search_depth: Optional[SearchDepth] = None,
|
||||
max_results: Optional[int] = None,
|
||||
include_domains: Optional[list[str]] = None,
|
||||
exclude_domains: Optional[list[str]] = None,
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Führt eine Web-Suche mit Tavily durch.
|
||||
|
||||
PII wird automatisch aus der Anfrage entfernt bevor sie
|
||||
an Tavily gesendet wird.
|
||||
|
||||
Args:
|
||||
query: Die Suchanfrage.
|
||||
search_depth: Suchtiefe (basic/advanced).
|
||||
max_results: Maximale Anzahl Ergebnisse.
|
||||
include_domains: Nur diese Domains durchsuchen.
|
||||
exclude_domains: Diese Domains ausschließen.
|
||||
|
||||
Returns:
|
||||
SearchResponse mit Ergebnissen.
|
||||
|
||||
Raises:
|
||||
TavilyError: Bei API-Fehlern.
|
||||
ToolGatewayError: Bei Konfigurationsfehlern.
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
if not self.tavily_available:
|
||||
raise ToolGatewayError("Tavily API key not configured")
|
||||
|
||||
# PII redaktieren
|
||||
redaction = self._redact_query(query)
|
||||
|
||||
if redaction.pii_found:
|
||||
logger.warning(
|
||||
f"PII detected in search query. Types: {[m.type.value for m in redaction.matches]}"
|
||||
)
|
||||
|
||||
# Request an Tavily
|
||||
client = await self._get_client()
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"api_key": self.config.tavily_api_key,
|
||||
"query": redaction.redacted_text,
|
||||
"search_depth": (search_depth or self.config.search_depth).value,
|
||||
"max_results": max_results or self.config.max_results,
|
||||
"include_answer": self.config.include_answer,
|
||||
"include_images": self.config.include_images,
|
||||
}
|
||||
|
||||
if include_domains:
|
||||
payload["include_domains"] = include_domains
|
||||
if exclude_domains:
|
||||
payload["exclude_domains"] = exclude_domains
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{self.config.tavily_base_url}/search",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Tavily API error: {e.response.status_code} - {e.response.text}")
|
||||
raise TavilyError(f"Tavily API error: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Tavily request error: {e}")
|
||||
raise TavilyError(f"Failed to connect to Tavily: {e}")
|
||||
|
||||
# Response parsen
|
||||
results = [
|
||||
SearchResult(
|
||||
title=r.get("title", ""),
|
||||
url=r.get("url", ""),
|
||||
content=r.get("content", ""),
|
||||
score=r.get("score", 0.0),
|
||||
published_date=r.get("published_date"),
|
||||
)
|
||||
for r in data.get("results", [])
|
||||
]
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
redacted_query=redaction.redacted_text if redaction.pii_found else None,
|
||||
results=results,
|
||||
answer=data.get("answer"),
|
||||
pii_detected=redaction.pii_found,
|
||||
pii_types=[m.type.value for m in redaction.matches],
|
||||
response_time_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
async def health_check(self) -> dict[str, Any]:
|
||||
"""
|
||||
Prüft Verfügbarkeit der Tools.
|
||||
|
||||
Returns:
|
||||
Dict mit Status der einzelnen Tools.
|
||||
"""
|
||||
status = {
|
||||
"tavily": {
|
||||
"configured": self.tavily_available,
|
||||
"healthy": False,
|
||||
},
|
||||
"pii_redaction": {
|
||||
"enabled": self.config.pii_redaction_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
# Tavily Health Check (einfache Suche)
|
||||
if self.tavily_available:
|
||||
try:
|
||||
result = await self.search("test", max_results=1)
|
||||
status["tavily"]["healthy"] = True
|
||||
status["tavily"]["response_time_ms"] = result.response_time_ms
|
||||
except Exception as e:
|
||||
status["tavily"]["error"] = str(e)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# Singleton Instance
|
||||
_tool_gateway: Optional[ToolGateway] = None
|
||||
|
||||
|
||||
def get_tool_gateway() -> ToolGateway:
|
||||
"""Gibt Singleton-Instanz des Tool Gateways zurück."""
|
||||
global _tool_gateway
|
||||
if _tool_gateway is None:
|
||||
_tool_gateway = ToolGateway()
|
||||
return _tool_gateway
|
||||
Reference in New Issue
Block a user