Plugin dedup: LLM als zweite Stufe für Grenzfälle

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.
This commit is contained in:
Flo Hartmann
2026-08-22 23:43:33 +00:00
parent b6d67e04e5
commit 8e5f65d864
6 changed files with 1201 additions and 12 deletions

View File

@@ -0,0 +1,345 @@
"""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 0100-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