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:
@@ -43,6 +43,10 @@ TITEL_SCHWELLE = 85
|
||||
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
|
||||
STATUS_ABGESCHLOSSEN = "abgeschlossen"
|
||||
|
||||
#: Obergrenze für LLM-Aufrufe je Prüfung (Token sparen): höchstens so viele
|
||||
#: Grenzfall-Paarungen plus ein Verlags-/Titel-Call bei Verlags-Konflikt.
|
||||
MAX_KI_PAARUNGEN = 5
|
||||
|
||||
#: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
|
||||
DEUTSCHE_INDIKATOREN = frozenset({
|
||||
"der", "die", "das", "den", "dem", "des",
|
||||
@@ -142,6 +146,67 @@ def _verlags_schluessel(verlag: str) -> str:
|
||||
return normalisiere(verlag)
|
||||
|
||||
|
||||
def _gemeinsame_woerter(a: str, b: str) -> int:
|
||||
"""Anzahl gemeinsamer Wort-Tokens zweiter Titel nach Normalisierung.
|
||||
|
||||
Als „Titel-Score > 0, aber unter Schwelle“ gilt eine Wort-Überschneidung
|
||||
über 0 bei Fuzzy-Score unter TITEL_SCHWELLE — reine Zeichenähnlichkeit
|
||||
ohne gemeinsames Wort („Cascadia“ vs. „Everdell“) bleibt ein klarer
|
||||
Non-Treffer und kostet kein LLM-Token.
|
||||
"""
|
||||
wa = set(normalisiere(a).split())
|
||||
wb = set(normalisiere(b).split())
|
||||
return len(wa & wb)
|
||||
|
||||
|
||||
def _titel_score(titel_a: str, titel_b: str) -> int:
|
||||
if fuzz is None: # pragma: no cover
|
||||
return 0
|
||||
return int(fuzz.token_set_ratio(normalisiere(titel_a), normalisiere(titel_b)))
|
||||
|
||||
|
||||
def _ki_bestaetigt(antwort: dict | None, konfidenz_min: float) -> bool:
|
||||
"""True, wenn ein KI-Befund übernommen wird (gleiches Spiel, genug Konfidenz)."""
|
||||
return bool(
|
||||
antwort
|
||||
and antwort.get("ist_gleiches_spiel")
|
||||
and float(antwort.get("konfidenz") or 0.0) >= konfidenz_min
|
||||
)
|
||||
|
||||
|
||||
def _ki_protokoll(
|
||||
typ: str,
|
||||
titel_a: str,
|
||||
titel_b: str,
|
||||
ki_client,
|
||||
antwort: dict | None,
|
||||
*,
|
||||
konfidenz_min: float,
|
||||
) -> dict:
|
||||
"""Fasst einen KI-Aufruf für Audit-Log/Protokoll zusammen."""
|
||||
if antwort is None:
|
||||
entscheidung = "fehler_verworfen"
|
||||
konfidenz = None
|
||||
elif not antwort.get("ist_gleiches_spiel"):
|
||||
entscheidung = "verschiedene_spiele"
|
||||
konfidenz = antwort.get("konfidenz")
|
||||
elif float(antwort.get("konfidenz") or 0.0) < konfidenz_min:
|
||||
entscheidung = "verworfen_unter_schwelle"
|
||||
konfidenz = antwort.get("konfidenz")
|
||||
else:
|
||||
entscheidung = "gleiches_spiel"
|
||||
konfidenz = antwort.get("konfidenz")
|
||||
return {
|
||||
"typ": typ,
|
||||
"titel_a": titel_a[:300],
|
||||
"titel_b": titel_b[:300],
|
||||
"modell": getattr(ki_client, "modell", "") or "",
|
||||
"konfidenz": konfidenz,
|
||||
"entscheidung": entscheidung,
|
||||
"begruendung": (antwort or {}).get("begruendung", ""),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Prüfergebnis ----------------
|
||||
|
||||
#: Anzeigenamen der Konflikt-Arten (deutsche UI).
|
||||
@@ -181,6 +246,9 @@ class PruefErgebnis:
|
||||
verlags_optionen: list[str] = field(default_factory=list)
|
||||
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
|
||||
titel_empfehlung: str | None = None
|
||||
#: Protokoll der KI-Zweitprüfung (Grenzfälle) — je Eintrag u. a. Modell,
|
||||
#: Konfidenz und Entscheidung. Leer bei rein regelbasierten Prüfungen.
|
||||
ki_befunde: list[dict] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None):
|
||||
@@ -206,6 +274,8 @@ class PruefErgebnis:
|
||||
and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
|
||||
):
|
||||
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}“")
|
||||
if self.ki_befunde:
|
||||
zeilen.append(f"• KI-Zweitprüfung: {len(self.ki_befunde)} Grenzfall/Grenzfälle bewertet.")
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
@@ -269,12 +339,18 @@ def fuehre_pruefung_durch(
|
||||
verlag: str | None = None,
|
||||
bgg_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
ki_client=None,
|
||||
ki_konfidenz_min: float = 0.7,
|
||||
) -> 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.
|
||||
`ki_client` darf None sein (KI-Zweitprüfung deaktiviert/fehlerhaft) —
|
||||
dann läuft ausschließlich die Regelprüfung; sonst werden nur Grenzfälle
|
||||
(Titel-Score > 0 unter Schwelle bzw. Verlags-Konflikt) dem LLM vorgelegt
|
||||
und bestätigte Treffer in die Regelprüfungen zurückgeführt.
|
||||
`user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
|
||||
„schon in Planung“-Konflikt aus.
|
||||
"""
|
||||
@@ -284,6 +360,7 @@ def fuehre_pruefung_durch(
|
||||
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
|
||||
if not titel:
|
||||
return ergebnis
|
||||
konfidenz_min = max(0.0, min(1.0, float(ki_konfidenz_min)))
|
||||
|
||||
with session_factory() as db:
|
||||
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
|
||||
@@ -304,12 +381,58 @@ def fuehre_pruefung_durch(
|
||||
except Exception as exc:
|
||||
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", exc)
|
||||
|
||||
# Kandidaten = Einträge zum selben Spiel (Neuheiten + Planungsliste).
|
||||
# Kandidaten = Einträge zum selben Spiel laut Regeln (Neuheiten + Planung).
|
||||
alle_zeilen = (*neuheiten, *planungen)
|
||||
kandidaten = [
|
||||
zeile for zeile in (*neuheiten, *planungen)
|
||||
zeile for zeile in alle_zeilen
|
||||
if _gleiches_spiel(zeile, titel, id_wert)
|
||||
]
|
||||
|
||||
# ---- KI-Zweitprüfung für Grenzfälle (optional, niemals blockierend) ----
|
||||
# Klare Treffer (oben) und klare Non-Treffer bleiben regelbasiert; nur
|
||||
# Titel mit Wort-Überschneidung über 0, aber Fuzzy-Score unter der
|
||||
# Schwelle, werden dem LLM vorgelegt. Bestätigte Treffer laufen in die
|
||||
# üblichen Prüfungen a)–d) zurück, als wären sie regelbasiert gematcht.
|
||||
ki_bestaetigt: dict[int, dict] = {} # id(zeile) -> KI-Antwort-Dict
|
||||
ki_titel_vorschlag: str | None = None # erster übernommener deutscher Titel
|
||||
if ki_client is not None and alle_zeilen:
|
||||
kandidat_ids = {id(zeile) for zeile in kandidaten}
|
||||
grenzfaelle = []
|
||||
for zeile in alle_zeilen:
|
||||
if id(zeile) in kandidat_ids:
|
||||
continue
|
||||
fremd_titel = (zeile.get("titel") or "").strip()
|
||||
if not fremd_titel:
|
||||
continue
|
||||
if _gemeinsame_woerter(titel, fremd_titel) <= 0:
|
||||
continue # klarer Non-Treffer — kein LLM-Token wert
|
||||
score = _titel_score(titel, fremd_titel)
|
||||
if score >= TITEL_SCHWELLE:
|
||||
continue # wäre ohnehin regelbasiert gematcht
|
||||
grenzfaelle.append((score, fremd_titel, zeile))
|
||||
grenzfaelle.sort(key=lambda eintrag: -eintrag[0])
|
||||
for score, fremd_titel, zeile in grenzfaelle[:MAX_KI_PAARUNGEN]:
|
||||
try:
|
||||
antwort = ki_client.bewerte(
|
||||
titel_a=titel, titel_b=fremd_titel,
|
||||
verlag_a=verlag, verlag_b=zeile.get("verlag"),
|
||||
alternativen_a=alternativen,
|
||||
expansionen_a=[name for _, name in vorgaenger],
|
||||
)
|
||||
except Exception as exc: # Zusatzdaten dürfen nie blockieren
|
||||
_logger.warning("dedup/KI: Grenzfall-Bewertung fehlgeschlagen (%s)", exc)
|
||||
antwort = None
|
||||
ergebnis.ki_befunde.append(_ki_protokoll(
|
||||
"grenzfall", titel, fremd_titel, ki_client, antwort,
|
||||
konfidenz_min=konfidenz_min,
|
||||
))
|
||||
if _ki_bestaetigt(antwort, konfidenz_min):
|
||||
kandidaten.append(zeile)
|
||||
ki_bestaetigt[id(zeile)] = antwort
|
||||
if antwort.get("deutscher_titel"):
|
||||
ki_titel_vorschlag = ki_titel_vorschlag or antwort["deutscher_titel"]
|
||||
|
||||
|
||||
# a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
|
||||
bekannte_verlage: dict[str, str] = {}
|
||||
for zeile in kandidaten:
|
||||
@@ -340,8 +463,9 @@ def fuehre_pruefung_durch(
|
||||
zeile["titel"] for zeile in kandidaten
|
||||
if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
|
||||
]
|
||||
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel(
|
||||
[titel, *varianten, *alternativen]
|
||||
ergebnis.titel_empfehlung = (
|
||||
ki_titel_vorschlag # KI-Empfehlung (bestätigter Grenzfall) hat Vorrang
|
||||
or bevorzuge_deutschen_titel([titel, *varianten, *alternativen])
|
||||
)
|
||||
if varianten and ergebnis.titel_empfehlung \
|
||||
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
|
||||
@@ -361,12 +485,16 @@ def fuehre_pruefung_durch(
|
||||
# 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):
|
||||
if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
|
||||
wer = _rezensent_name(zeile, namen)
|
||||
details = {"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer}
|
||||
if id(zeile) in ki_bestaetigt:
|
||||
details["ki_bestaetigt"] = True
|
||||
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
|
||||
ergebnis.konflikte.append(TitelKonflikt(
|
||||
art="besprochen",
|
||||
beschreibung=f"„{zeile['titel']}“ wurde bereits besprochen ({wer}).",
|
||||
details={"planungseintrag": zeile["id"], "titel": zeile["titel"], "rezensent": wer},
|
||||
details=details,
|
||||
))
|
||||
for fremd_id, fremd_titel in vorgaenger:
|
||||
for zeile in besprochen:
|
||||
@@ -394,20 +522,102 @@ def fuehre_pruefung_durch(
|
||||
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):
|
||||
if _gleiches_spiel(zeile, titel, id_wert) or id(zeile) in ki_bestaetigt:
|
||||
wer = _rezensent_name(zeile, namen)
|
||||
status = zeile.get("status") or ""
|
||||
details = {
|
||||
"planungseintrag": zeile["id"],
|
||||
"rezensent": wer,
|
||||
"status": status,
|
||||
}
|
||||
if id(zeile) in ki_bestaetigt:
|
||||
details["ki_bestaetigt"] = True
|
||||
details["ki_konfidenz"] = ki_bestaetigt[id(zeile)].get("konfidenz")
|
||||
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,
|
||||
},
|
||||
details=details,
|
||||
))
|
||||
|
||||
# ---- KI-Zweitprüfung Teil 2: Verlags-/Titel-Hinweis bei Verlags-Konflikt ----
|
||||
# Verlags-Aliase („Hans im Glück“ vs. „Hans im Glück Verlag“) und
|
||||
# Übersetzungsfragen kann die Regelprüfung nicht entscheiden — ein
|
||||
# ergänzender LLM-Call liefert Empfehlungen, ohne den Befund zu ändern.
|
||||
if ki_client is not None and ergebnis.hat_verlagskonflikt:
|
||||
_ki_verlags_empfehlung(
|
||||
ergebnis, ki_client, konfidenz_min=konfidenz_min,
|
||||
titel=titel, verlag=verlag, kandidaten=kandidaten,
|
||||
alternativen=alternativen, vorgaenger=vorgaenger,
|
||||
)
|
||||
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _uebernimm_deutschen_titel(ergebnis: PruefErgebnis, deutscher_titel: str | None, eigener_titel: str) -> None:
|
||||
"""Übernimmt einen KI-deutschen Titel als Empfehlung (konservativ).
|
||||
|
||||
Nur wenn die Heuristik selbst nichts Deutsches fand (Empfehlung leer oder
|
||||
gleich dem eigenen Titel), weißt das LLM die Redaktion ggf. besser.
|
||||
"""
|
||||
if not deutscher_titel:
|
||||
return
|
||||
aktuell = ergebnis.titel_empfehlung
|
||||
if aktuell and normalisiere(aktuell) != normalisiere(eigener_titel):
|
||||
return # Heuristik/KI hat bereits eine abweichende deutsche Variante gewählt
|
||||
if normalisiere(deutscher_titel) == normalisiere(eigener_titel):
|
||||
return
|
||||
ergebnis.titel_empfehlung = deutscher_titel
|
||||
|
||||
|
||||
def _ki_verlags_empfehlung(
|
||||
ergebnis: PruefErgebnis,
|
||||
ki_client,
|
||||
*,
|
||||
konfidenz_min: float,
|
||||
titel: str,
|
||||
verlag: str | None,
|
||||
kandidaten: list[dict],
|
||||
alternativen: list[str],
|
||||
vorgaenger: list[tuple[int, str]],
|
||||
) -> None:
|
||||
"""Ein ergänzender KI-Call zum Verlags-Konflikt (Verlags-Aliase, Übersetzung)."""
|
||||
fremd_zeile = next(
|
||||
(z for z in kandidaten
|
||||
if (z.get("verlag") or "").strip() and verlag
|
||||
and _verlags_schluessel(z["verlag"]) != _verlags_schluessel(verlag)),
|
||||
None,
|
||||
)
|
||||
if fremd_zeile is None:
|
||||
return
|
||||
fremd_titel = (fremd_zeile.get("titel") or "").strip()
|
||||
try:
|
||||
antwort = ki_client.bewerte(
|
||||
titel_a=titel, titel_b=fremd_titel,
|
||||
verlag_a=verlag, verlag_b=fremd_zeile.get("verlag"),
|
||||
alternativen_a=alternativen,
|
||||
expansionen_a=[name for _, name in vorgaenger],
|
||||
frage=(
|
||||
"Beide Einträge meinen laut Regelprüfung dasselbe Spiel, wurden "
|
||||
"aber unter verschiedenen Verlagen geführt (ggf. Alias oder "
|
||||
"Lokalausgabe). Welcher Verlag sollte geführt werden "
|
||||
"(empfohlener_verlag) und welchen deutschen Titel gibt es?"
|
||||
),
|
||||
)
|
||||
except Exception as exc: # Zusatzdaten dürfen nie blockieren
|
||||
_logger.warning("dedup/KI: Verlags-Bewertung fehlgeschlagen (%s)", exc)
|
||||
antwort = None
|
||||
ergebnis.ki_befunde.append(_ki_protokoll(
|
||||
"verlag", titel, fremd_titel, ki_client, antwort,
|
||||
konfidenz_min=konfidenz_min,
|
||||
))
|
||||
if _ki_bestaetigt(antwort, konfidenz_min):
|
||||
verlags_konflikt = next(
|
||||
(k for k in ergebnis.konflikte if k.art == "verlag"), None
|
||||
)
|
||||
if verlags_konflikt is not None and antwort.get("empfohlener_verlag"):
|
||||
verlags_konflikt.details["ki_empfohlener_verlag"] = antwort["empfohlener_verlag"]
|
||||
verlags_konflikt.details["ki_konfidenz"] = antwort.get("konfidenz")
|
||||
_uebernimm_deutschen_titel(ergebnis, antwort.get("deutscher_titel"), titel)
|
||||
|
||||
Reference in New Issue
Block a user