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

@@ -15,6 +15,17 @@ in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik.
Als zweite Stufe kann ein LLM Grenzfälle bewerten — Titel mit
Wort-Überschneidung unter der Fuzzy-Schwelle (Editionen, Big Boxes,
Übersetzungen) und Verlags-Konflikte mit ähnlichem Titel (Verlags-Aliase).
Standardmäßig aus (`SPIELE_DEDUP_LLM_AKTIV=0`); konfiguriert wird eine
OpenAI-kompatible Chat-Completions-API über `SPIELE_DEDUP_LLM_*`-Variablen
(siehe ki_pruefung.py / README). Ohne Aktivierung, ohne Key oder bei jedem
Fehler verhält sich die Prüfung exakt wie rein regelbasiert; KI-Befunde
unterhalb der Mindest-Konfidenz (`SPIELE_DEDUP_LLM_KONFIDENZ_MIN`,
Standard 0.7) werden verworfen. Übernommene Befunde laufen in die üblichen
Prüfungen a)d) zurück und werden im audit-log protokolliert.
"""
from __future__ import annotations
@@ -30,6 +41,7 @@ from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration
from .models import Pruefung
from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch
@@ -59,6 +71,12 @@ class DedupPlugin(BasePlugin):
#: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient.
self.bgg_client_fabrik = None
#: Konfiguration der KI-Zweitprüfung (wird in on_load aus Env gelesen).
self.ki_konfiguration = lade_konfiguration({})
self._ki_client: KiPruefClient | None = None
#: Von Tests überschreibbare Fabrik für den KI-Client (Mock-Transport).
self.ki_client_fabrik = None
self._routen_registrieren()
# ---------- Plugin-Vertrag ----------
@@ -72,11 +90,17 @@ class DedupPlugin(BasePlugin):
def on_load(self, context) -> None:
super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0"
# KI-Zweitprüfung (Standard aus, siehe ki_pruefung.py): ohne Key oder
# mit SPIELE_DEDUP_LLM_AKTIV=0 läuft die Prüfung exakt wie bisher.
self.ki_konfiguration = lade_konfiguration()
def on_unload(self) -> None:
if self._bgg_client is not None:
self._bgg_client.schliessen()
self._bgg_client = None
if self._ki_client is not None:
self._ki_client.schliessen()
self._ki_client = None
super().on_unload()
# ---------- Öffentliche Prüf-API für andere Plugins ----------
@@ -102,6 +126,7 @@ class DedupPlugin(BasePlugin):
id_wert = None
client = self._hilfsclient()
ki_client = self._ki_hilfsclient()
try:
ergebnis = fuehre_pruefung_durch(
self.context.session_factory,
@@ -110,15 +135,57 @@ class DedupPlugin(BasePlugin):
verlag=verlag,
bgg_id=id_wert,
user_id=user.id if user is not None else None,
ki_client=ki_client,
ki_konfidenz_min=self.ki_konfiguration.konfidenz_min,
)
finally:
if client is not None:
client.schliessen()
if ki_client is not None:
ki_client.schliessen()
self._protokolliere(ergebnis, user)
await self._auditiere_ki_befunde(ergebnis, user)
return ergebnis
# ---------- Internas ----------
def _ki_hilfsclient(self) -> KiPruefClient | None:
"""KI-Client nur bei aktivierter, vollständiger Konfiguration (sonst None).
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
Zweitprüfung deaktiviert oder unvollständig konfiguriert, wird nie
ein Client gebaut und damit nie ein LLM-Aufruf getätigt.
"""
if self.context is None or not self.ki_konfiguration.vollstaendig:
return None
if self.ki_client_fabrik is not None:
return self.ki_client_fabrik()
if self._ki_client is None:
self._ki_client = KiPruefClient(self.ki_konfiguration)
return self._ki_client
async def _auditiere_ki_befunde(self, ergebnis: PruefErgebnis, user) -> None:
"""LLM-Befunde ins audit-log schreiben (best effort, nie blockierend).
Ein Eintrag je Prüfung mit allen KI-Entscheidungen: Modell, Konfidenz
und Entscheidung je bewertetem Grenzfall.
"""
if not ergebnis.ki_befunde:
return
registry = self.context.registry if self.context is not None else None
audit = registry.get("audit-log") if registry is not None else None
if audit is None:
return
details = {
"titel": ergebnis.titel,
"modell": ergebnis.ki_befunde[0].get("modell", ""),
"befunde": ergebnis.ki_befunde,
}
try:
await audit.log(user, "geprüft", "dedup_ki", None, details)
except Exception:
_logger.exception("dedup: KI-Befunde konnten nicht auditiert werden.")
def _hilfsclient(self) -> BggPruefClient | None:
if self.bgg_client_fabrik is not None:
return self.bgg_client_fabrik()