"""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 func, 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, DashboardKarte, 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 dashboard_karten(self, user: User) -> list[DashboardKarte]: """Dashboard: offene Dedup-Konflikte (nur Redaktion).""" from redaktionskern.auth.models import Role from redaktionskern.db import Base if user.role not in (Role.ADMIN.value, Role.REDAKTEUR.value): return [] tabelle = Base.metadata.tables.get("planungsliste") if tabelle is None: return [] with self.context.session_factory() as db: offene = db.scalar( select(func.count()) .select_from(tabelle) .where( tabelle.c.pruefung.is_not(None), tabelle.c.status != "abgeschlossen", ) ) or 0 return [ DashboardKarte( titel="Offene Dedup-Konflikte", wert=str(offene), beschreibung="Planungseinträge mit Prüfhinweis", link_url="/planung", link_label="Zur Planung", ton="warnung" if offene else "ok", ) ] 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: token = (os.environ.get("SPIELE_BGG_TOKEN") or "").strip() or None self._bgg_client = BggPruefClient(token=token) 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()