"""KI-Zweitprüfung des Plugins „dedup“ — LLM nur für Grenzfälle. Die Regelprüfung (`pruefung.py`) bleibt maßgeblich: Klare Treffer und klare Non-Treffer werden ausschließlich regelbasiert entschieden. Nur wenn die Regeln einen Grenzfall liefern — Titel-Score über 0, aber unter der Fuzzy-Schwelle (z. B. „El Grande Big Box“ vs. „El Grande: 25 Jahre“) oder ein Verlags-Konflikt bei ähnlichem Titel — wird ein LLM als zweite Stufe gefragt. Schnittstelle: OpenAI-kompatible Chat-Completions-API (`POST {basis_url}/chat/completions`), konfigurierbar über Umgebungsvariablen: | Variable | Bedeutung | |----------|-----------| | `SPIELE_DEDUP_LLM_AKTIV` | `1` = Zweitprüfung an (Standard `0`) | | `SPIELE_DEDUP_LLM_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` | | `SPIELE_DEDUP_LLM_API_KEY` | API-Key (wird als Bearer gesendet) | | `SPIELE_DEDUP_LLM_MODELL` | Modellname, z. B. `gpt-4o-mini` | | `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) | | `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) | Der Client ist bewusst genauso defensiv wie der BGG-Hilfsclient: Ohne Aktivierung/Key oder bei jedem Fehler liefert `bewerte()` None und die Prüfung fällt auf den reinen Regelbefund zurück — die Prüfung wird nie blockiert. Netzwerkfehler und Server-Störungen werden genau einmal wiederholt (insgesamt zwei Versuche), Antwort-Parsing-Fehler nicht. """ from __future__ import annotations import json import logging import os import re from dataclasses import dataclass from typing import Any, Mapping import httpx _logger = logging.getLogger("plugins.dedup") #: Standard-Timeout je Chat-Completions-Aufruf in Sekunden. STANDARD_TIMEOUT = 20.0 #: Gesamtzahl der Versuche (erster Versuch + genau ein Retry). VERSUCHE = 2 #: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen). RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504}) #: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird. STANDARD_KONFIDENZ_MIN = 0.7 # ---------------- Konfiguration ---------------- @dataclass(frozen=True) class KiKonfiguration: """Effektive Konfiguration der KI-Zweitprüfung (aus Env gelesen).""" aktiv: bool = False basis_url: str = "" api_key: str = "" modell: str = "" konfidenz_min: float = STANDARD_KONFIDENZ_MIN timeout: float = STANDARD_TIMEOUT @property def vollstaendig(self) -> bool: """True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind.""" return bool(self.aktiv and self.basis_url and self.api_key and self.modell) def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration: """Liest die KI-Konfiguration aus der Umgebung (fehlertolerant). Kaputte Zahlenwerte (z. B. `KONFIDENZ_MIN=abc`) führen nicht zum Fehler, sondern zum jeweiligen Standardwert — die Zweitprüfung darf die Regelprüfung niemals blockieren. """ env = os.environ if quelle is None else quelle aktiv = env.get("SPIELE_DEDUP_LLM_AKTIV", "0").strip() == "1" try: konfidenz_min = float(env.get("SPIELE_DEDUP_LLM_KONFIDENZ_MIN", "")) except ValueError: konfidenz_min = STANDARD_KONFIDENZ_MIN konfidenz_min = min(1.0, max(0.0, konfidenz_min)) try: timeout = float(env.get("SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN", "")) except ValueError: timeout = STANDARD_TIMEOUT timeout = max(1.0, timeout) return KiKonfiguration( aktiv=aktiv, basis_url=env.get("SPIELE_DEDUP_LLM_BASIS_URL", "").strip().rstrip("/"), api_key=env.get("SPIELE_DEDUP_LLM_API_KEY", "").strip(), modell=env.get("SPIELE_DEDUP_LLM_MODELL", "").strip(), konfidenz_min=konfidenz_min, timeout=timeout, ) # ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ---------------- _FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL) def extrahiere_json(text: str) -> dict | None: """Extrahiert das erste JSON-Objekt aus einer LLM-Antwort. Toleriert Code-Fences (```json … ```) und begleitenden Text vor/nach dem Objekt; None, wenn nichts Sinnvolles übrig bleibt. """ if not isinstance(text, str): return None text = text.strip() if not text: return None # 1) Direkter Versuch. versuche = [text] # 2) Inhalt von Code-Fences (alle, der längste gewinnt meistens). versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text)) # 3) Erstes „{“ bis letztes „}“ (Text drumherum weg). erstes, letztes = text.find("{"), text.rfind("}") if 0 <= erstes < letztes: versuche.append(text[erstes : letztes + 1]) for kandidat in versuche: try: daten = json.loads(kandidat) except (ValueError, TypeError): continue if isinstance(daten, dict): return daten return None def _optional_text(wert: Any) -> str | None: """Normalisiert ein nullable String-Feld der Antwort.""" if isinstance(wert, str): wert = wert.strip() return wert or None return None def validiere_antwort(daten: Any) -> dict | None: """Prüft und normalisiert die LLM-Antwort gegen das vereinbarte Schema. Erwartet (nach JSON-Extraktion): `{ist_gleiches_spiel: bool, konfidenz: float, begruendung: str, empfohlener_verlag: str|null, deutscher_titel: str|null}` Kleine Nachsichten: boolesche Werte dürfen als ja/nein-Text kommen, Konfidenz darf auf 0–100-Skala geliefert werden. Alles andere → None. """ if not isinstance(daten, dict): return None ist_gleiches_spiel = daten.get("ist_gleiches_spiel") if isinstance(ist_gleiches_spiel, str): ist_gleiches_spiel = { "ja": True, "true": True, "yes": True, "nein": False, "false": False, "no": False, }.get(ist_gleiches_spiel.strip().lower()) if not isinstance(ist_gleiches_spiel, bool): return None try: konfidenz = float(daten.get("konfidenz")) except (TypeError, ValueError): return None if 10.0 <= konfidenz <= 100.0: # Prozent-Skala tolerieren (z. B. 92 → 0.92) konfidenz = konfidenz / 100.0 if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel return None begruendung = daten.get("begruendung") if not isinstance(begruendung, str): begruendung = "" return { "ist_gleiches_spiel": ist_gleiches_spiel, "konfidenz": round(konfidenz, 4), "begruendung": begruendung.strip(), "empfohlener_verlag": _optional_text(daten.get("empfohlener_verlag")), "deutscher_titel": _optional_text(daten.get("deutscher_titel")), } # ---------------- Prompt ---------------- _SYSTEM_NACHRICHT = ( "Du assistierst einer Spielemagazin-Redaktion beim Deduplizieren von " "Brettspieltiteln. Entscheide, ob zwei Einträge dasselbe Spiel meinen " "(auch across Sprachen, Editionen, Big Boxes und Verlags-Aliase) oder " "verschiedene Spiele/Editionen sind. Antworte AUSSCHLIESSLICH mit einem " "JSON-Objekt nach exakt diesem Schema:\n" '{"ist_gleiches_spiel": , "konfidenz": , ' '"begruendung": "", ' '"empfohlener_verlag": "", ' '"deutscher_titel": ""}\n' "Kein weiterer Text, keine Code-Fences." ) def baue_prompt( *, titel_a: str, verlag_a: str | None = None, alternativen_a: list[str] | None = None, expansionen_a: list[str] | None = None, titel_b: str, verlag_b: str | None = None, alternativen_b: list[str] | None = None, frage: str, ) -> str: """Baut die User-Nachricht mit vollem Kontext beider Einträge.""" def seite(name: str, titel: str, verlag, alternativen, expansionen) -> str: zeilen = [f"{name}: „{titel}“"] if verlag: zeilen.append(f"{name} Verlag: {verlag}") if alternativen: zeilen.append(f"{name} Alternate Names: " + "; ".join(alternativen)) if expansionen: zeilen.append(f"{name} ergänzt (BGG-Erweiterungs-Relation): " + "; ".join(expansionen)) return "\n".join(zeilen) teile = [ seite("Eintrag A", titel_a, verlag_a, alternativen_a, expansionen_a), seite("Eintrag B", titel_b, verlag_b, alternativen_b, None), frage, ] return "\n\n".join(teile) # ---------------- Client ---------------- class KiPruefClient: """OpenAI-kompatibler Chat-Completions-Client für die Zweitprüfung. Genauso defensiv wie der BGG-Hilfsclient: `bewerte()` wirft nicht, sondern liefert bei jedem Problem None — die Regelprüfung bleibt dann allein maßgeblich. Transport und Timeout sind injizierbar, damit Tests netzwerkfrei bleiben. """ def __init__( self, konfiguration: KiKonfiguration, *, transport: httpx.BaseTransport | None = None, ) -> None: self._konfiguration = konfiguration self._client = httpx.Client( base_url=konfiguration.basis_url, timeout=konfiguration.timeout, transport=transport, headers={ "Authorization": f"Bearer {konfiguration.api_key}", "Content-Type": "application/json", }, ) @property def modell(self) -> str: return self._konfiguration.modell def schliessen(self) -> None: self._client.close() # ---------- Öffentliche API ---------- def bewerte( self, *, titel_a: str, titel_b: str, verlag_a: str | None = None, verlag_b: str | None = None, alternativen_a: list[str] | None = None, alternativen_b: list[str] | None = None, expansionen_a: list[str] | None = None, frage: str | None = None, ) -> dict | None: """Fragt das LLM, ob zwei Einträge dasselbe Spiel meinen. Rückgabe ist das validierte Antwort-Dict (siehe `validiere_antwort`) oder None — ohne Key, bei Netzwerk-/Server-Problemen (nach genau einem Retry) oder bei unbrauchbarer Antwort. """ if not self._konfiguration.vollstaendig: return None nutzernachricht = baue_prompt( titel_a=titel_a, verlag_a=verlag_a, alternativen_a=alternativen_a, expansionen_a=expansionen_a, titel_b=titel_b, verlag_b=verlag_b, alternativen_b=alternativen_b, frage=frage or ( "Meinen Eintrag A und Eintrag B dasselbe Spiel? Wenn ja, nenne " "bitte auch, welcher Verlag geführt werden sollte und welchen " "deutschen Titel es gibt (falls bekannt)." ), ) payload = { "model": self._konfiguration.modell, "temperature": 0, "messages": [ {"role": "system", "content": _SYSTEM_NACHRICHT}, {"role": "user", "content": nutzernachricht}, ], } for versuch in range(1, VERSUCHE + 1): try: antwort = self._client.post("/chat/completions", json=payload) except httpx.RequestError as exc: # Netzwerk/Timeout → genau ein Retry _logger.warning("dedup/KI: Versuch %d/%d fehlgeschlagen (%s)", versuch, VERSUCHE, exc) continue if antwort.status_code in RETRY_STATUS: # zeitweilige Störung → genau ein Retry _logger.warning( "dedup/KI: Versuch %d/%d mit Status %d — wiederholt.", versuch, VERSUCHE, antwort.status_code, ) continue try: antwort.raise_for_status() # andere 4xx: kein Retry hilft except httpx.HTTPStatusError as exc: _logger.warning("dedup/KI: Anfrage abgelehnt (%s) — nur Regelbefund.", exc) return None try: inhalte = antwort.json()["choices"][0]["message"]["content"] except Exception as exc: # kaputtes Antwort-Layout _logger.warning("dedup/KI: unbrauchbare Antwort (%s)", exc) return None return validiere_antwort(extrahiere_json(inhalte)) _logger.warning("dedup/KI: alle %d Versuche fehlgeschlagen — nur Regelbefund.", VERSUCHE) return None