Regelbasierte Dedup-Prüfung bleibt maßgeblich; nur Grenzfälle gehen an ein LLM: Titel mit Wort-Überschneidung unter der Fuzzy-Schwelle (Editionen, Big Boxes, Übersetzungen wie 'El Grande Big Box' vs. 'El Grande: 25 Jahre') und ein ergänzender Call zum Verlags-/Titel-Hinweis bei Verlags-Konflikt (Verlags-Aliase). Klare Treffer und Non-Treffer bleiben regelbasiert. - Neu: ki_pruefung.py — Env-Konfiguration (SPIELE_DEDUP_LLM_*), robuster JSON-Parser (Code-Fences, Zusatztext), OpenAI-kompatibler Client via httpx mit Timeout und genau einem Retry; liefert bei jedem Fehler None. - pruefung.py: bestätigte Grenzfälle laufen in die üblichen Prüfungen a)-d) zurück; deutscher Titel füllt die Titel-Empfehlung, Verlags-Empfehlung landet im Konflikt-Detail. Neues Feld PruefErgebnis.ki_befunde. - __init__.py: Aktivierungsprüfung vor jeder Fabrik — aus/unvollständig heißt nie ein LLM-Aufruf; KI-Befunde werden über die audit-log-API protokolliert (Modell, Konfidenz, Entscheidung), best effort. - Tests: gemockte HTTP-Antworten (MockTransport/Fakes), Fallback-Fälle (aktiv=0, kein Key, Timeout, kaputtes JSON), Konfidenz-Schwelle, Parsing-Robustheit; kein echter LLM-Call in CI. - README.md um die neuen Env-Variablen und den Zweitprüfungs-Abschnitt ergänzt.
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""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": <bool>, "konfidenz": <float 0.0-1.0>, '
|
||
'"begruendung": "<kurzer deutscher Satz>", '
|
||
'"empfohlener_verlag": "<Verlagsname oder null>", '
|
||
'"deutscher_titel": "<bekannter deutscher Titel oder null>"}\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
|