Files
spiele-redaktion/plugins/dedup/__init__.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

289 lines
11 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.
"""Plugin „dedup“ — Prüfungen bei Import und händischem Eintrag.
Öffentliche API für andere Plugins (z. B. „planung“):
dedup = request.app.state.registry.get("dedup")
ergebnis = await dedup.check_titel(titel, verlag, bgg_id, user=user)
# ergebnis.hat_konflikte / ergebnis.konflikte / ergebnis.verlags_optionen
# ergebnis.titel_empfehlung / ergebnis.als_text()
Die vier Prüfungen (Verlags-Konflikt, deutsche Titel-Variante, bereits
besprochen inkl. Vorgänger-Erkennung, schon in fremder Planungsliste) sind
in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
`dedup_pruefungen` protokolliert.
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
import logging
import os
from fastapi import Depends, Form, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from redaktionskern.auth.deps import get_db, require_user
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
_logger = logging.getLogger("plugins.dedup")
ANZAHL_PROTOKOLL = 20
def _protokoll_tabelle(conn) -> None:
"""Migration 0001: legt die Protokoll-Tabelle an (portabel, idempotent)."""
Pruefung.__table__.create(conn, checkfirst=True)
class DedupPlugin(BasePlugin):
name = "dedup"
title = "Dedup-Prüfung"
description = (
"Prüfungen bei Import und händigem Eintrag: Verlags-Konflikt, "
"Titel-Varianten (deutsche Version bevorzugt), besprochene Spiele "
"inkl. Vorgänger und fremde Planungseinträge."
)
def __init__(self) -> None:
super().__init__()
self._bgg_aktiv = True
self._bgg_client: BggPruefClient | None = None
#: 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 ----------
def migrations(self) -> list[Migration]:
return [Migration(version="0001_pruefprotokoll", up=_protokoll_tabelle)]
def navigation(self) -> list[NavEntry]:
return [NavEntry(label=self.title, url="/dedup")]
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 ----------
async def check_titel(
self,
titel: str,
verlag: str | None = None,
bgg_id: int | str | None = None,
*,
user: User | None = None,
) -> PruefErgebnis:
"""Prüft einen Titel gegen Neuheiten- und Planungsliste.
Liefert ein strukturiertes `PruefErgebnis` mit Konflikt-Details;
wirft nicht (BGG-Probleme werden protokolliert und degradieren die
Prüfung auf die Titel-Heuristik). Jede Prüfung landet im Protokoll.
"""
id_wert: int | None
try:
id_wert = int(bgg_id) if bgg_id not in (None, "") else None
except (TypeError, ValueError):
id_wert = None
client = self._hilfsclient()
ki_client = self._ki_hilfsclient()
try:
ergebnis = fuehre_pruefung_durch(
self.context.session_factory,
client,
titel=titel,
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()
if not self._bgg_aktiv or self.context is None:
return None
if self._bgg_client is None:
self._bgg_client = BggPruefClient()
return self._bgg_client
def _protokolliere(self, ergebnis: PruefErgebnis, user: User | None) -> None:
"""Schreibt den Prüfbefund ins eigene Protokoll (best effort)."""
try:
with self.context.session_factory() as db:
db.add(
Pruefung(
titel=ergebnis.titel[:300],
verlag=ergebnis.verlag,
bgg_id=ergebnis.bgg_id,
konflikte=ergebnis.als_liste() or None,
geprueft_durch_id=user.id if user is not None else None,
geprueft_durch=user.username if user is not None else "System",
)
)
db.commit()
except Exception:
_logger.exception("dedup: Prüfung konnte nicht protokolliert werden.")
def _seite_rendern(
self,
request: Request,
user: User,
*,
ergebnis: PruefErgebnis | None = None,
meldung: str = "",
fehler: str = "",
):
with self.context.session_factory() as db:
protokoll = db.scalars(
select(Pruefung)
.order_by(Pruefung.erstellt_am.desc(), Pruefung.id.desc())
.limit(ANZAHL_PROTOKOLL)
).all()
return self.context.templates.TemplateResponse(
request=request,
name="dedup/index.html",
context={
"user": user,
"titel": self.title,
"ergebnis": ergebnis,
"konflikt_anzeige": KONFLIKT_ANZEIGE,
"protokoll": protokoll,
"meldung": meldung[:300],
"fehler": fehler[:300],
},
)
def _routen_registrieren(self) -> None:
@self.router.get("/dedup")
def seite(request: Request, user: User = Depends(require_user)):
"""Prüfseite: Manuelle Prüfung + Protokoll der letzten Prüfungen."""
return self._seite_rendern(request, user)
@self.router.post("/dedup/pruefen")
async def pruefen(
request: Request,
user: User = Depends(require_user),
titel: str = Form(""),
verlag: str = Form(""),
bgg_id: str = Form(""),
):
"""Führt eine manuelle Prüfung durch und zeigt das Ergebnis."""
roh_titel = titel.strip()
if not roh_titel:
return self._seite_rendern(
request, user, fehler="Bitte einen Spieltitel angeben."
)
try:
id_wert = int(bgg_id.strip()) if bgg_id.strip() else None
except ValueError:
return self._seite_rendern(
request, user,
fehler="Die BGG-ID muss eine Zahl sein.",
meldung="",
)
ergebnis = await self.check_titel(
roh_titel, verlag.strip() or None, id_wert, user=user
)
return self._seite_rendern(
request,
user,
ergebnis=ergebnis,
meldung="Prüfung abgeschlossen."
if not ergebnis.hat_konflikte
else "",
)
plugin = DedupPlugin()