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:
345
plugins/dedup/ki_pruefung.py
Normal file
345
plugins/dedup/ki_pruefung.py
Normal 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 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
|
||||
Reference in New Issue
Block a user