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

@@ -43,6 +43,10 @@ TITEL_SCHWELLE = 85
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
STATUS_ABGESCHLOSSEN = "abgeschlossen"
#: Obergrenze für LLM-Aufrufe je Prüfung (Token sparen): höchstens so viele
#: Grenzfall-Paarungen plus ein Verlags-/Titel-Call bei Verlags-Konflikt.
MAX_KI_PAARUNGEN = 5
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des",
@@ -142,6 +146,67 @@ def _verlags_schluessel(verlag: str) -> str:
return normalisiere(verlag)
def _gemeinsame_woerter(a: str, b: str) -> int:
"""Anzahl gemeinsamer Wort-Tokens zweiter Titel nach Normalisierung.
Als „Titel-Score > 0, aber unter Schwelle“ gilt eine Wort-Überschneidung
über 0 bei Fuzzy-Score unter TITEL_SCHWELLE — reine Zeichenähnlichkeit
ohne gemeinsames Wort („Cascadia“ vs. „Everdell“) bleibt ein klarer
Non-Treffer und kostet kein LLM-Token.
"""
wa = set(normalisiere(a).split())
wb = set(normalisiere(b).split())
return len(wa & wb)
def _titel_score(titel_a: str, titel_b: str) -> int:
if fuzz is None: # pragma: no cover
return 0
return int(fuzz.token_set_ratio(normalisiere(titel_a), normalisiere(titel_b)))
def _ki_bestaetigt(antwort: dict | None, konfidenz_min: float) -> bool:
"""True, wenn ein KI-Befund übernommen wird (gleiches Spiel, genug Konfidenz)."""
return bool(
antwort
and antwort.get("ist_gleiches_spiel")
and float(antwort.get("konfidenz") or 0.0) >= konfidenz_min
)
def _ki_protokoll(
typ: str,
titel_a: str,
titel_b: str,
ki_client,
antwort: dict | None,
*,
konfidenz_min: float,
) -> dict:
"""Fasst einen KI-Aufruf für Audit-Log/Protokoll zusammen."""
if antwort is None:
entscheidung = "fehler_verworfen"
konfidenz = None
elif not antwort.get("ist_gleiches_spiel"):
entscheidung = "verschiedene_spiele"
konfidenz = antwort.get("konfidenz")
elif float(antwort.get("konfidenz") or 0.0) < konfidenz_min:
entscheidung = "verworfen_unter_schwelle"
konfidenz = antwort.get("konfidenz")
else:
entscheidung = "gleiches_spiel"
konfidenz = antwort.get("konfidenz")
return {
"typ": typ,
"titel_a": titel_a[:300],
"titel_b": titel_b[:300],
"modell": getattr(ki_client, "modell", "") or "",
"konfidenz": konfidenz,
"entscheidung": entscheidung,
"begruendung": (antwort or {}).get("begruendung", ""),
}
# ---------------- Prüfergebnis ----------------
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
@@ -181,6 +246,9 @@ class PruefErgebnis:
verlags_optionen: list[str] = field(default_factory=list)
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
titel_empfehlung: str | None = None
#: Protokoll der KI-Zweitprüfung (Grenzfälle) — je Eintrag u. a. Modell,
#: Konfidenz und Entscheidung. Leer bei rein regelbasierten Prüfungen.
ki_befunde: list[dict] = field(default_factory=list)
@classmethod
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
@@ -206,6 +274,8 @@ class PruefErgebnis:
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
):
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}")
if self.ki_befunde:
zeilen.append(f"• KI-Zweitprüfung: {len(self.ki_befunde)} Grenzfall/Grenzfälle bewertet.")
return "\n".join(zeilen)
@@ -269,12 +339,18 @@ def fuehre_pruefung_durch(
verlag: str | None = None,
bgg_id: int | None = None,
user_id: int | None = None,
ki_client=None,
ki_konfidenz_min: float = 0.7,
) -> PruefErgebnis:
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
der Fuzzy-Match arbeiten weiter.
`ki_client` darf None sein (KI-Zweitprüfung deaktiviert/fehlerhaft) —
dann läuft ausschließlich die Regelprüfung; sonst werden nur Grenzfälle
(Titel-Score > 0 unter Schwelle bzw. Verlags-Konflikt) dem LLM vorgelegt
und bestätigte Treffer in die Regelprüfungen zurückgeführt.
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
„schon in Planung“-Konflikt aus.
"""
@@ -284,6 +360,7 @@ def fuehre_pruefung_durch(
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
if not titel:
return ergebnis
konfidenz_min = max(0.0, min(1.0, float(ki_konfidenz_min)))
with session_factory() as db:
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
@@ -304,12 +381,58 @@ def fuehre_pruefung_durch(
except Exception as exc:
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste).
# Kandidaten = Einträge zum selben Spiel laut Regeln (Neuheiten + Planung).
alle_zeilen = (*neuheiten, *planungen)
kandidaten = [
zeile for zeile in (*neuheiten, *planungen)
zeile for zeile in alle_zeilen
if _gleiches_spiel(zeile, titel, id_wert)
]
# ---- KI-Zweitprüfung für Grenzfälle (optional, niemals blockierend) ----
# Klare Treffer (oben) und klare Non-Treffer bleiben regelbasiert; nur
# Titel mit Wort-Überschneidung über 0, aber Fuzzy-Score unter der
# Schwelle, werden dem LLM vorgelegt. Bestätigte Treffer laufen in die
# üblichen Prüfungen a)d) zurück, als wären sie regelbasiert gematcht.
ki_bestaetigt: dict[int, dict] = {} # id(zeile) -> KI-Antwort-Dict
ki_titel_vorschlag: str | None = None # erster übernommener deutscher Titel
if ki_client is not None and alle_zeilen:
kandidat_ids = {id(zeile) for zeile in kandidaten}
grenzfaelle = []
for zeile in alle_zeilen:
if id(zeile) in kandidat_ids:
continue
fremd_titel = (zeile.get("titel") or "").strip()
if not fremd_titel:
continue
if _gemeinsame_woerter(titel, fremd_titel) <= 0:
continue # klarer Non-Treffer — kein LLM-Token wert
score = _titel_score(titel, fremd_titel)
if score >= TITEL_SCHWELLE:
continue # wäre ohnehin regelbasiert gematcht
grenzfaelle.append((score, fremd_titel, zeile))
grenzfaelle.sort(key=lambda eintrag: -eintrag[0])
for score, fremd_titel, zeile in grenzfaelle[:MAX_KI_PAARUNGEN]:
try:
antwort = ki_client.bewerte(
titel_a=titel, titel_b=fremd_titel,
verlag_a=verlag, verlag_b=zeile.get("verlag"),
alternativen_a=alternativen,
expansionen_a=[name for _, name in vorgaenger],
)
except Exception as exc: # Zusatzdaten dürfen nie blockieren
_logger.warning("dedup/KI: Grenzfall-Bewertung fehlgeschlagen (%s)", exc)
antwort = None
ergebnis.ki_befunde.append(_ki_protokoll(
"grenzfall", titel, fremd_titel, ki_client, antwort,
konfidenz_min=konfidenz_min,
))
if _ki_bestaetigt(antwort, konfidenz_min):
kandidaten.append(zeile)
ki_bestaetigt[id(zeile)] = antwort
if antwort.get("deutscher_titel"):
ki_titel_vorschlag = ki_titel_vorschlag or antwort["deutscher_titel"]
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
bekannte_verlage: dict[str, str] = {}
for zeile in kandidaten:
@@ -340,8 +463,9 @@ def fuehre_pruefung_durch(
zeile["titel"] for zeile in kandidaten
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
]
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
[titel, *varianten, *alternativen]
ergebnis.titel_empfehlung = (
ki_titel_vorschlag # KI-Empfehlung (bestätigter Grenzfall) hat Vorrang
or bevorzuge_deutschen_titel([titel, *varianten, *alternativen])
)
if varianten and ergebnis.titel_empfehlung \
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
@@ -361,12 +485,16 @@ def fuehre_pruefung_durch(
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
for zeile in besprochen:
if _gleiches_spiel(zeile, titel, id_wert):
if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
wer = _rezensent_name(zeile, namen)
details = {"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer}
if id(zeile) in ki_bestaetigt:
details["ki_bestaetigt"] = True
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
ergebnis.konflikte.append(TitelKonflikt(
art="besprochen",
beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).",
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer},
details=details,
))
for fremd_id, fremd_titel in vorgaenger:
for zeile in besprochen:
@@ -394,20 +522,102 @@ def fuehre_pruefung_durch(
for zeile in planungen:
if user_id is not None and zeile.get("rezensent_id") == user_id:
continue
if _gleiches_spiel(zeile, titel, id_wert):
if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
wer = _rezensent_name(zeile, namen)
status = zeile.get("status") or ""
details = {
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
}
if id(zeile) in ki_bestaetigt:
details["ki_bestaetigt"] = True
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
ergebnis.konflikte.append(TitelKonflikt(
art="planung",
beschreibung=(
f"{zeile['titel']}“ ist bereits in der Planungsliste "
f"von {wer} (Status: {status})."
),
details={
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
},
details=details,
))
# ---- KI-Zweitprüfung Teil 2: Verlags-/Titel-Hinweis bei Verlags-Konflikt ----
# Verlags-Aliase („Hans im Glück“ vs. „Hans im Glück Verlag“) und
# Übersetzungsfragen kann die Regelprüfung nicht entscheiden — ein
# ergänzender LLM-Call liefert Empfehlungen, ohne den Befund zu ändern.
if ki_client is not None and ergebnis.hat_verlagskonflikt:
_ki_verlags_empfehlung(
ergebnis, ki_client, konfidenz_min=konfidenz_min,
titel=titel, verlag=verlag, kandidaten=kandidaten,
alternativen=alternativen, vorgaenger=vorgaenger,
)
return ergebnis
def _uebernimm_deutschen_titel(ergebnis: PruefErgebnis, deutscher_titel: str | None, eigener_titel: str) -> None:
"""Übernimmt einen KI-deutschen Titel als Empfehlung (konservativ).
Nur wenn die Heuristik selbst nichts Deutsches fand (Empfehlung leer oder
gleich dem eigenen Titel), weißt das LLM die Redaktion ggf. besser.
"""
if not deutscher_titel:
return
aktuell = ergebnis.titel_empfehlung
if aktuell and normalisiere(aktuell) != normalisiere(eigener_titel):
return # Heuristik/KI hat bereits eine abweichende deutsche Variante gewählt
if normalisiere(deutscher_titel) == normalisiere(eigener_titel):
return
ergebnis.titel_empfehlung = deutscher_titel
def _ki_verlags_empfehlung(
ergebnis: PruefErgebnis,
ki_client,
*,
konfidenz_min: float,
titel: str,
verlag: str | None,
kandidaten: list[dict],
alternativen: list[str],
vorgaenger: list[tuple[int, str]],
) -> None:
"""Ein ergänzender KI-Call zum Verlags-Konflikt (Verlags-Aliase, Übersetzung)."""
fremd_zeile = next(
(z for z in kandidaten
if (z.get("verlag") or "").strip() and verlag
and _verlags_schluessel(z["verlag"]) != _verlags_schluessel(verlag)),
None,
)
if fremd_zeile is None:
return
fremd_titel = (fremd_zeile.get("titel") or "").strip()
try:
antwort = ki_client.bewerte(
titel_a=titel, titel_b=fremd_titel,
verlag_a=verlag, verlag_b=fremd_zeile.get("verlag"),
alternativen_a=alternativen,
expansionen_a=[name for _, name in vorgaenger],
frage=(
"Beide Einträge meinen laut Regelprüfung dasselbe Spiel, wurden "
"aber unter verschiedenen Verlagen geführt (ggf. Alias oder "
"Lokalausgabe). Welcher Verlag sollte geführt werden "
"(empfohlener_verlag) und welchen deutschen Titel gibt es?"
),
)
except Exception as exc: # Zusatzdaten dürfen nie blockieren
_logger.warning("dedup/KI: Verlags-Bewertung fehlgeschlagen (%s)", exc)
antwort = None
ergebnis.ki_befunde.append(_ki_protokoll(
"verlag", titel, fremd_titel, ki_client, antwort,
konfidenz_min=konfidenz_min,
))
if _ki_bestaetigt(antwort, konfidenz_min):
verlags_konflikt = next(
(k for k in ergebnis.konflikte if k.art == "verlag"), None
)
if verlags_konflikt is not None and antwort.get("empfohlener_verlag"):
verlags_konflikt.details["ki_empfohlener_verlag"] = antwort["empfohlener_verlag"]
verlags_konflikt.details["ki_konfidenz"] = antwort.get("konfidenz")
_uebernimm_deutschen_titel(ergebnis, antwort.get("deutscher_titel"), titel)