- Plugin-Vertrag erweitert: optionaler Hook dashboard_karten(user) + DashboardKarte-Dataclass (Kern bleibt schlank, Fachlogik im Plugin) - Karten: Neuheiten+System-Status (neuheiten), Planungs-Stats bzw. eigene Einträge (planung), offene Dedup-Konflikte (dedup), nächste Redaktionsschlüsse (erinnerung), ungelesene Benachrichtigungen (benachrichtigung), letzte Audit-Einträge (audit-log, nur Admin), Benutzer-Anzahl (Kern, nur Admin) - 'Geladene Plugins' als aufklappbarer Admin-Technikbereich
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""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()
|