Files
spiele-redaktion/plugins/dedup/ki_pruefung.py
Flo Hartmann 8e5f65d864 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.
2026-08-22 23:43:33 +00:00

346 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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