Plugins dedup + planung: Prüf-API, Verzahnung mit Benachrichtigung und Audit-Log
Plugin dedup: - Öffentliche Prüf-API check_titel(titel, verlag, bgg_id, user) mit strukturiertem PruefErgebnis (Konflikte, Verlagsauswahl, Titel-Empfehlung) - Vier Prüfungen: Verlags-Konflikt, Titel-Varianten (deutsche Version bevorzugt: Umlaut-/Wort-Heuristik + BGG Alternate-Names), Spiel oder Vorgänger bereits besprochen (BGG boardgameexpansion-Relation + rapidfuzz-Fuzzy-Match), Titel in fremder Planungsliste - BGG-Hilfsclient mit Rate-Limit, netzwerkfrei testbar, degradiert defensiv - Eigene Migration (Prüfprotokoll dedup_pruefungen) + Prüfseite /dedup - Fremde Plugin-Tabellen werden nur lesend über die gemeinsame Metadata gelesen — keine Import-Abhängigkeiten zwischen Plugins Plugin planung: - Migration planungsliste: Titel, Verlag, Ausgabe, Rezensent, Status (offen/in_bearbeitung/abgeschlossen), Quelle, Prüf-Befund (JSON) - Beide Eintragswege durch die dedup-Prüfung: Verschiebung aus den Neuheiten (Button „→ Zur Planung“, Neuheit wechselt auf Status planung) und händisches Nachtragen im Formular - Bei Treffern: send_notification an den eintragenden Rezensenten (benachrichtigung-Plugin) + Audit-Log-Eintrag (audit-log-Plugin) - Verlags-Konflikt → Auswahl-Dialog „Welcher Verlag wird geführt?“, Entscheidung wird auditiert - Planungsliste mit Zuordnung, Statuswechsel, Bearbeiten/Löschen; Rezensenten nur eigene Einträge, Admin/Redakteur alle Tests: 45 neue Tests (Heuristik, alle vier Prüfungen mit Fake-BGG-Client, Protokoll, beide Eintragswege mit Stub-gemockten Abhängigkeiten, Rollen, Integration mit echten Plugins) — 141 Tests grün.
This commit is contained in:
413
plugins/dedup/pruefung.py
Normal file
413
plugins/dedup/pruefung.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""Prüflogik des Plugins „dedup“ — die vier Prüfungen laut Fachanforderung:
|
||||
|
||||
a) **Verlags-Konflikt** — dasselbe Spiel ist bereits unter anderem
|
||||
Verlag/Vertrieb geführt. Ergebnis enthält eine Verlagsauswahl.
|
||||
b) **Titel-Varianten** — dasselbe Spiel mit unterschiedlichem Titel; die
|
||||
deutsche Version wird bevorzugt (Heuristik über deutsche Titel-Indikatoren,
|
||||
ergänzt um BGG Alternate-Names, falls ein Hilfsclient verfügbar ist).
|
||||
c) **Spiel oder Vorgänger bereits besprochen** — abgeschlossene Einträge der
|
||||
Planungsliste bilden das „besprochen“-Korpus; Vorgänger werden über die
|
||||
BGG `boardgameexpansion`-Relation erkannt und zusätzlich fuzzy auf Titel
|
||||
gematcht (rapidfuzz).
|
||||
d) **Titel schon in Planungsliste eines anderen Rezensenten.**
|
||||
|
||||
Die Daten der anderen Plugins (neuheiten, planung) liest dieses Plugin
|
||||
lesend über die gemeinsame SQLAlchemy-Metadata (`Base.metadata.tables`) —
|
||||
so entstehen keine Import-Abhängigkeiten zwischen Plugin-Paketen. Fehlt eine
|
||||
Tabelle (Plugin nicht geladen), entfällt die jeweilige Teilprüfung einfach.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from redaktionskern.db import Base
|
||||
|
||||
try:
|
||||
from rapidfuzz import fuzz
|
||||
except ImportError: # pragma: no cover - rapidfuzz ist eine harte Abhängigkeit
|
||||
fuzz = None
|
||||
|
||||
_logger = logging.getLogger("plugins.dedup")
|
||||
|
||||
#: Fuzzy-Schwelle für „gemeintes Spiel“ (rapidfuzz token_sort_ratio 0–100).
|
||||
TITEL_SCHWELLE = 85
|
||||
|
||||
#: Statuswert des planung-Plugins für „bereits besprochen“ (bewusst als
|
||||
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
|
||||
STATUS_ABGESCHLOSSEN = "abgeschlossen"
|
||||
|
||||
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
|
||||
DEUTSCHE_INDIKATOREN = frozenset({
|
||||
"der", "die", "das", "den", "dem", "des",
|
||||
"ein", "eine", "einen", "einem", "einer", "eines",
|
||||
"und", "für", "von", "vom", "zu", "zum", "zur",
|
||||
"im", "am", "beim", "auf", "mit", "aus",
|
||||
})
|
||||
|
||||
#: Typische Begriffe deutscher Spieltitel (+2 Punkte je Treffer).
|
||||
DEUTSCHE_SPIELWOERTER = frozenset({
|
||||
"abenteuer", "berg", "blitz", "burg", "dorf", "drache", "drachen",
|
||||
"erde", "feuer", "gärten", "haus", "himmel", "insel", "jagd",
|
||||
"kaiser", "könig", "könige", "königreich", "land", "länder",
|
||||
"meister", "mond", "norden", "osten", "pfad", "reich", "ritter",
|
||||
"schiff", "schloss", "see", "spiel", "spiele", "spielen",
|
||||
"stadt", "stern", "süden", "turm", "wald", "weg", "welt",
|
||||
"westen", "wiese", "wilder", "wüste", "zauber", "zeit",
|
||||
})
|
||||
|
||||
#: Wörter, die auf einen englischen Titeltitel hindeuten (−1 Punkt je Treffer).
|
||||
ENGLISCHE_INDIKATOREN = frozenset({
|
||||
"the", "of", "and", "game", "games", "card", "cards", "dice", "board",
|
||||
})
|
||||
|
||||
_UM_LAUTE = frozenset("äöüßÄÖÜ")
|
||||
_WORT_MUSTER = re.compile(r"[^\W\d_]+", re.UNICODE)
|
||||
|
||||
|
||||
# ---------------- Titel-Heuristik ----------------
|
||||
|
||||
def normalisiere(text: str) -> str:
|
||||
"""Vereinheitlicht einen Titel für Vergleiche (Groß-/Satzzeichen egal)."""
|
||||
text = unicodedata.normalize("NFC", text or "")
|
||||
text = text.casefold()
|
||||
text = re.sub(r"[^\w\s]+", " ", text, flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def titel_aehnlich(a: str, b: str, schwelle: int = TITEL_SCHWELLE) -> bool:
|
||||
"""True, wenn zwei Titel dasselbe Spiel meinen (exakt oder fuzzy).
|
||||
|
||||
Nach der Normalisierung zählt `fuzz.token_set_ratio`: Wortreihenfolge
|
||||
und Füllwörter sind egal, damit „Die Siedler von Catan“ und
|
||||
„Siedler von Catan, Die“ zusammenpassen, „Catan Erweiterung“ und
|
||||
„Catan Grundspiel“ aber nicht.
|
||||
"""
|
||||
if not a or not b:
|
||||
return False
|
||||
na, nb = normalisiere(a), normalisiere(b)
|
||||
if not na or not nb:
|
||||
return False
|
||||
if na == nb:
|
||||
return True
|
||||
if fuzz is None: # pragma: no cover
|
||||
return False
|
||||
return fuzz.token_set_ratio(na, nb) >= schwelle
|
||||
|
||||
|
||||
def deutsch_score(titel: str) -> int:
|
||||
"""Heuristik-Score: > 0 spricht für einen deutschen Titel.
|
||||
|
||||
Umlaute/ß zählen am stärksten (+3), deutsche Funktionswörter +1,
|
||||
typische deutsche Spielbegriffe +2, englische Indikatoren −1.
|
||||
"""
|
||||
if not titel:
|
||||
return 0
|
||||
score = 0
|
||||
if any(zeichen in _UM_LAUTE for zeichen in titel):
|
||||
score += 3
|
||||
woerter = {wort.casefold() for wort in _WORT_MUSTER.findall(titel)}
|
||||
score += sum(1 for wort in woerter if wort in DEUTSCHE_INDIKATOREN)
|
||||
score += sum(2 for wort in woerter if wort in DEUTSCHE_SPIELWOERTER)
|
||||
score -= sum(1 for wort in woerter if wort in ENGLISCHE_INDIKATOREN)
|
||||
return score
|
||||
|
||||
|
||||
def ist_deutscher_titel(titel: str) -> bool:
|
||||
return deutsch_score(titel) > 0
|
||||
|
||||
|
||||
def bevorzuge_deutschen_titel(kandidaten: Iterable[str]) -> str | None:
|
||||
"""Wählt aus Kandidaten den deutschesten Titel (bei Gleichstand den ersten)."""
|
||||
bester: str | None = None
|
||||
bester_score = 0
|
||||
for kandidat in kandidaten:
|
||||
kandidat = (kandidat or "").strip()
|
||||
if not kandidat:
|
||||
continue
|
||||
score = deutsch_score(kandidat)
|
||||
if bester is None or score > bester_score:
|
||||
bester, bester_score = kandidat, score
|
||||
return bester
|
||||
|
||||
|
||||
def _verlags_schluessel(verlag: str) -> str:
|
||||
"""Vergleichsschlüssel für Verlage („Kosmos“ == „kosmos “)."""
|
||||
return normalisiere(verlag)
|
||||
|
||||
|
||||
# ---------------- Prüfergebnis ----------------
|
||||
|
||||
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
|
||||
KONFLIKT_ANZEIGE: dict[str, str] = {
|
||||
"verlag": "Verlags-Konflikt",
|
||||
"titel": "Titel-Variante",
|
||||
"besprochen": "Bereits besprochen",
|
||||
"planung": "Schon in Planung",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TitelKonflikt:
|
||||
"""Ein einzelner Befund der Prüfung."""
|
||||
|
||||
art: str # Schlüssel aus KONFLIKT_ANZEIGE
|
||||
beschreibung: str
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
def als_dict(self) -> dict:
|
||||
return {"art": self.art, "beschreibung": self.beschreibung, "details": self.details}
|
||||
|
||||
@property
|
||||
def anzeige(self) -> str:
|
||||
return KONFLIKT_ANZEIGE.get(self.art, self.art)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PruefErgebnis:
|
||||
"""Strukturiertes Ergebnis einer dedup-Prüfung."""
|
||||
|
||||
titel: str
|
||||
verlag: str | None = None
|
||||
bgg_id: int | None = None
|
||||
konflikte: list[TitelKonflikt] = field(default_factory=list)
|
||||
#: Verlage zur Auswahl bei einem Verlags-Konflikt (welcher wird geführt?).
|
||||
verlags_optionen: list[str] = field(default_factory=list)
|
||||
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
|
||||
titel_empfehlung: str | None = None
|
||||
|
||||
@classmethod
|
||||
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
|
||||
return cls(titel=titel, verlag=verlag, bgg_id=bgg_id)
|
||||
|
||||
@property
|
||||
def hat_konflikte(self) -> bool:
|
||||
return bool(self.konflikte)
|
||||
|
||||
@property
|
||||
def hat_verlagskonflikt(self) -> bool:
|
||||
return any(konflikt.art == "verlag" for konflikt in self.konflikte)
|
||||
|
||||
def als_liste(self) -> list[dict]:
|
||||
return [konflikt.als_dict() for konflikt in self.konflikte]
|
||||
|
||||
def als_text(self) -> str:
|
||||
if not self.konflikte:
|
||||
return "Keine Treffer."
|
||||
zeilen = [f"• [{k.anzeige}] {k.beschreibung}" for k in self.konflikte]
|
||||
if (
|
||||
self.titel_empfehlung
|
||||
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
|
||||
):
|
||||
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}“")
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
# ---------------- Datenzugriff (fremde Plugin-Tabellen, nur lesend) ----------------
|
||||
|
||||
def _lade_zeilen(db, tabellenname: str, spalten: tuple[str, ...]) -> list[dict]:
|
||||
"""Liest Zeilen einer Plugin-Tabelle über die gemeinsame Metadata.
|
||||
|
||||
Fehlt die Tabelle (Plugin nicht geladen bzw. Migration nicht gelaufen),
|
||||
wird eine leere Liste zurückgegeben — die jeweilige Teilprüfung entfällt.
|
||||
"""
|
||||
tabelle = Base.metadata.tables.get(tabellenname)
|
||||
if tabelle is None:
|
||||
return []
|
||||
try:
|
||||
zeilen = db.execute(select(*(tabelle.c[name] for name in spalten))).all()
|
||||
except SQLAlchemyError as exc:
|
||||
_logger.warning("dedup: Tabelle '%s' nicht lesbar (%s) — Teilprüfung übersprungen.", tabellenname, exc)
|
||||
return []
|
||||
return [dict(zip(spalten, zeile._tuple())) for zeile in zeilen]
|
||||
|
||||
|
||||
def _lade_benutzernamen(db) -> dict[int, str]:
|
||||
benutzer = Base.metadata.tables.get("users")
|
||||
namen: dict[int, str] = {}
|
||||
if benutzer is None:
|
||||
return namen
|
||||
try:
|
||||
for zeile in db.execute(
|
||||
select(benutzer.c.id, benutzer.c.display_name, benutzer.c.username)
|
||||
).all():
|
||||
id_wert, anzeige, username = zeile._tuple()
|
||||
namen[id_wert] = anzeige or username or f"Benutzer {id_wert}"
|
||||
except SQLAlchemyError: # pragma: no cover - defensive
|
||||
pass
|
||||
return namen
|
||||
|
||||
|
||||
def _rezensent_name(zeile: dict, namen: dict[int, str]) -> str:
|
||||
id_wert = zeile.get("rezensent_id")
|
||||
if id_wert is None:
|
||||
return "unbekannt"
|
||||
return namen.get(id_wert, f"Benutzer {id_wert}")
|
||||
|
||||
|
||||
def _gleiches_spiel(zeile: dict, titel: str, bgg_id: int | None) -> bool:
|
||||
"""Match über eindeutige BGG-ID, sonst fuzzy über den Titel."""
|
||||
fremd_bgg = zeile.get("bgg_id")
|
||||
if bgg_id is not None and fremd_bgg is not None and int(fremd_bgg) == int(bgg_id):
|
||||
return True
|
||||
return titel_aehnlich(titel, zeile.get("titel") or "")
|
||||
|
||||
|
||||
# ---------------- Hauptprüfung ----------------
|
||||
|
||||
def fuehre_pruefung_durch(
|
||||
session_factory,
|
||||
bgg_hilfsclient,
|
||||
*,
|
||||
titel: str,
|
||||
verlag: str | None = None,
|
||||
bgg_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> 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.
|
||||
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
|
||||
„schon in Planung“-Konflikt aus.
|
||||
"""
|
||||
titel = (titel or "").strip()
|
||||
verlag = (verlag or "").strip() or None
|
||||
id_wert = int(bgg_id) if bgg_id else None
|
||||
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
|
||||
if not titel:
|
||||
return ergebnis
|
||||
|
||||
with session_factory() as db:
|
||||
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
|
||||
planungen = _lade_zeilen(
|
||||
db, "planungsliste", ("id", "titel", "verlag", "bgg_id", "status", "rezensent_id")
|
||||
)
|
||||
namen = _lade_benutzernamen(db)
|
||||
|
||||
alternativen: list[str] = []
|
||||
vorgaenger: list[tuple[int, str]] = []
|
||||
if id_wert is not None and bgg_hilfsclient is not None:
|
||||
try:
|
||||
alternativen = list(bgg_hilfsclient.alternativen(id_wert))
|
||||
except Exception as exc: # Zusatzdaten dürfen die Prüfung nie blockieren
|
||||
_logger.warning("dedup: BGG-Alternativen nicht verfügbar (%s)", exc)
|
||||
try:
|
||||
vorgaenger = list(bgg_hilfsclient.vorgaenger(id_wert))
|
||||
except Exception as exc:
|
||||
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
|
||||
|
||||
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste).
|
||||
kandidaten = [
|
||||
zeile for zeile in (*neuheiten, *planungen)
|
||||
if _gleiches_spiel(zeile, titel, id_wert)
|
||||
]
|
||||
|
||||
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
|
||||
bekannte_verlage: dict[str, str] = {}
|
||||
for zeile in kandidaten:
|
||||
kandidat_verlag = (zeile.get("verlag") or "").strip()
|
||||
if kandidat_verlag:
|
||||
bekannte_verlage.setdefault(_verlags_schluessel(kandidat_verlag), kandidat_verlag)
|
||||
abweichend = [
|
||||
name for schluessel, name in bekannte_verlage.items()
|
||||
if verlag and schluessel != _verlags_schluessel(verlag)
|
||||
]
|
||||
if abweichend or len(bekannte_verlage) > 1:
|
||||
optionen = list(bekannte_verlage.values())
|
||||
if verlag and _verlags_schluessel(verlag) not in bekannte_verlage:
|
||||
optionen.append(verlag)
|
||||
ergebnis.verlags_optionen = optionen
|
||||
ergebnis.konflikte.append(TitelKonflikt(
|
||||
art="verlag",
|
||||
beschreibung=(
|
||||
"Gleiches Spiel ist bereits unter anderem Verlag geführt: "
|
||||
+ ", ".join(sorted(bekannte_verlage.values()))
|
||||
+ ". Bitte wählen, welcher Verlag geführt wird."
|
||||
),
|
||||
details={"eigener_verlag": verlag, "bekannte_verlage": list(bekannte_verlage.values())},
|
||||
))
|
||||
|
||||
# b) Titel-Varianten → deutsche Version bevorzugen.
|
||||
varianten = [
|
||||
zeile["titel"] for zeile in kandidaten
|
||||
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
|
||||
]
|
||||
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
|
||||
[titel, *varianten, *alternativen]
|
||||
)
|
||||
if varianten and ergebnis.titel_empfehlung \
|
||||
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
|
||||
ergebnis.konflikte.append(TitelKonflikt(
|
||||
art="titel",
|
||||
beschreibung=(
|
||||
f"Gleiches Spiel unter anderem Titel bekannt — empfohlen: "
|
||||
f"„{ergebnis.titel_empfehlung}“."
|
||||
),
|
||||
details={
|
||||
"empfehlung": ergebnis.titel_empfehlung,
|
||||
"varianten": sorted(set(varianten)),
|
||||
"bgg_alternativen": alternativen[:10],
|
||||
},
|
||||
))
|
||||
|
||||
# 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):
|
||||
wer = _rezensent_name(zeile, namen)
|
||||
ergebnis.konflikte.append(TitelKonflikt(
|
||||
art="besprochen",
|
||||
beschreibung=f"„{zeile['titel']}“ wurde bereits besprochen ({wer}).",
|
||||
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer},
|
||||
))
|
||||
for fremd_id, fremd_titel in vorgaenger:
|
||||
for zeile in besprochen:
|
||||
zeilen_bgg = zeile.get("bgg_id")
|
||||
passt = (
|
||||
(fremd_id and zeilen_bgg is not None and int(zeilen_bgg) == fremd_id)
|
||||
or titel_aehnlich(fremd_titel, zeile.get("titel") or "")
|
||||
)
|
||||
if passt:
|
||||
wer = _rezensent_name(zeile, namen)
|
||||
ergebnis.konflikte.append(TitelKonflikt(
|
||||
art="besprochen",
|
||||
beschreibung=(
|
||||
f"Vorgänger „{fremd_titel}“ wurde bereits besprochen ({wer}) — "
|
||||
"Erweiterung prüfen, ob sie noch Redaktionsstoff bietet."
|
||||
),
|
||||
details={
|
||||
"vorgaenger_bgg_id": fremd_id,
|
||||
"vorgaenger_titel": fremd_titel,
|
||||
"planungseintrag": zeile["id"],
|
||||
},
|
||||
))
|
||||
|
||||
# d) Titel schon in der Planungsliste eines anderen Rezensenten.
|
||||
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):
|
||||
wer = _rezensent_name(zeile, namen)
|
||||
status = zeile.get("status") or ""
|
||||
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,
|
||||
},
|
||||
))
|
||||
|
||||
return ergebnis
|
||||
Reference in New Issue
Block a user