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:
@@ -15,6 +15,17 @@ in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
|
||||
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
|
||||
|
||||
@@ -30,6 +41,7 @@ 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
|
||||
|
||||
@@ -59,6 +71,12 @@ class DedupPlugin(BasePlugin):
|
||||
#: 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 ----------
|
||||
@@ -72,11 +90,17 @@ class DedupPlugin(BasePlugin):
|
||||
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 ----------
|
||||
@@ -102,6 +126,7 @@ class DedupPlugin(BasePlugin):
|
||||
id_wert = None
|
||||
|
||||
client = self._hilfsclient()
|
||||
ki_client = self._ki_hilfsclient()
|
||||
try:
|
||||
ergebnis = fuehre_pruefung_durch(
|
||||
self.context.session_factory,
|
||||
@@ -110,15 +135,57 @@ class DedupPlugin(BasePlugin):
|
||||
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()
|
||||
|
||||
345
plugins/dedup/ki_pruefung.py
Normal file
345
plugins/dedup/ki_pruefung.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""KI-Zweitprüfung des Plugins „dedup“ — LLM nur für Grenzfälle.
|
||||
|
||||
Die Regelprüfung (`pruefung.py`) bleibt maßgeblich: Klare Treffer und klare
|
||||
Non-Treffer werden ausschließlich regelbasiert entschieden. Nur wenn die
|
||||
Regeln einen Grenzfall liefern — Titel-Score über 0, aber unter der
|
||||
Fuzzy-Schwelle (z. B. „El Grande Big Box“ vs. „El Grande: 25 Jahre“) oder
|
||||
ein Verlags-Konflikt bei ähnlichem Titel — wird ein LLM als zweite Stufe
|
||||
gefragt.
|
||||
|
||||
Schnittstelle: OpenAI-kompatible Chat-Completions-API (`POST
|
||||
{basis_url}/chat/completions`), konfigurierbar über Umgebungsvariablen:
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `SPIELE_DEDUP_LLM_AKTIV` | `1` = Zweitprüfung an (Standard `0`) |
|
||||
| `SPIELE_DEDUP_LLM_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
|
||||
| `SPIELE_DEDUP_LLM_API_KEY` | API-Key (wird als Bearer gesendet) |
|
||||
| `SPIELE_DEDUP_LLM_MODELL` | Modellname, z. B. `gpt-4o-mini` |
|
||||
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
|
||||
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
|
||||
|
||||
Der Client ist bewusst genauso defensiv wie der BGG-Hilfsclient: Ohne
|
||||
Aktivierung/Key oder bei jedem Fehler liefert `bewerte()` None und die
|
||||
Prüfung fällt auf den reinen Regelbefund zurück — die Prüfung wird nie
|
||||
blockiert. Netzwerkfehler und Server-Störungen werden genau einmal
|
||||
wiederholt (insgesamt zwei Versuche), Antwort-Parsing-Fehler nicht.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger("plugins.dedup")
|
||||
|
||||
#: Standard-Timeout je Chat-Completions-Aufruf in Sekunden.
|
||||
STANDARD_TIMEOUT = 20.0
|
||||
|
||||
#: Gesamtzahl der Versuche (erster Versuch + genau ein Retry).
|
||||
VERSUCHE = 2
|
||||
|
||||
#: HTTP-Statuscodes, die einen Retry rechtfertigen (zeitweilige Störungen).
|
||||
RETRY_STATUS = frozenset({408, 429, 500, 502, 503, 504})
|
||||
|
||||
#: Mindest-Konfidenz, ab der ein KI-Befund übernommen wird.
|
||||
STANDARD_KONFIDENZ_MIN = 0.7
|
||||
|
||||
|
||||
# ---------------- Konfiguration ----------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KiKonfiguration:
|
||||
"""Effektive Konfiguration der KI-Zweitprüfung (aus Env gelesen)."""
|
||||
|
||||
aktiv: bool = False
|
||||
basis_url: str = ""
|
||||
api_key: str = ""
|
||||
modell: str = ""
|
||||
konfidenz_min: float = STANDARD_KONFIDENZ_MIN
|
||||
timeout: float = STANDARD_TIMEOUT
|
||||
|
||||
@property
|
||||
def vollstaendig(self) -> bool:
|
||||
"""True, wenn Aktiv-Schalter und Zugangsdaten vollständig sind."""
|
||||
return bool(self.aktiv and self.basis_url and self.api_key and self.modell)
|
||||
|
||||
|
||||
def lade_konfiguration(quelle: Mapping[str, str] | None = None) -> KiKonfiguration:
|
||||
"""Liest die KI-Konfiguration aus der Umgebung (fehlertolerant).
|
||||
|
||||
Kaputte Zahlenwerte (z. B. `KONFIDENZ_MIN=abc`) führen nicht zum Fehler,
|
||||
sondern zum jeweiligen Standardwert — die Zweitprüfung darf die
|
||||
Regelprüfung niemals blockieren.
|
||||
"""
|
||||
env = os.environ if quelle is None else quelle
|
||||
aktiv = env.get("SPIELE_DEDUP_LLM_AKTIV", "0").strip() == "1"
|
||||
|
||||
try:
|
||||
konfidenz_min = float(env.get("SPIELE_DEDUP_LLM_KONFIDENZ_MIN", ""))
|
||||
except ValueError:
|
||||
konfidenz_min = STANDARD_KONFIDENZ_MIN
|
||||
konfidenz_min = min(1.0, max(0.0, konfidenz_min))
|
||||
|
||||
try:
|
||||
timeout = float(env.get("SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN", ""))
|
||||
except ValueError:
|
||||
timeout = STANDARD_TIMEOUT
|
||||
timeout = max(1.0, timeout)
|
||||
|
||||
return KiKonfiguration(
|
||||
aktiv=aktiv,
|
||||
basis_url=env.get("SPIELE_DEDUP_LLM_BASIS_URL", "").strip().rstrip("/"),
|
||||
api_key=env.get("SPIELE_DEDUP_LLM_API_KEY", "").strip(),
|
||||
modell=env.get("SPIELE_DEDUP_LLM_MODELL", "").strip(),
|
||||
konfidenz_min=konfidenz_min,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
# ---------------- Antwort-Parsing (robust gegen Code-Fences u. Ä.) ----------------
|
||||
|
||||
_FENCE_MUSTER = re.compile(r"```(?:json|JSON)?\s*(.*?)\s*```", re.DOTALL)
|
||||
|
||||
|
||||
def extrahiere_json(text: str) -> dict | None:
|
||||
"""Extrahiert das erste JSON-Objekt aus einer LLM-Antwort.
|
||||
|
||||
Toleriert Code-Fences (```json … ```) und begleitenden Text vor/nach dem
|
||||
Objekt; None, wenn nichts Sinnvolles übrig bleibt.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# 1) Direkter Versuch.
|
||||
versuche = [text]
|
||||
# 2) Inhalt von Code-Fences (alle, der längste gewinnt meistens).
|
||||
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
|
||||
# 3) Erstes „{“ bis letztes „}“ (Text drumherum weg).
|
||||
erstes, letztes = text.find("{"), text.rfind("}")
|
||||
if 0 <= erstes < letztes:
|
||||
versuche.append(text[erstes : letztes + 1])
|
||||
|
||||
for kandidat in versuche:
|
||||
try:
|
||||
daten = json.loads(kandidat)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if isinstance(daten, dict):
|
||||
return daten
|
||||
return None
|
||||
|
||||
|
||||
def _optional_text(wert: Any) -> str | None:
|
||||
"""Normalisiert ein nullable String-Feld der Antwort."""
|
||||
if isinstance(wert, str):
|
||||
wert = wert.strip()
|
||||
return wert or None
|
||||
return None
|
||||
|
||||
|
||||
def validiere_antwort(daten: Any) -> dict | None:
|
||||
"""Prüft und normalisiert die LLM-Antwort gegen das vereinbarte Schema.
|
||||
|
||||
Erwartet (nach JSON-Extraktion):
|
||||
`{ist_gleiches_spiel: bool, konfidenz: float, begruendung: str,
|
||||
empfohlener_verlag: str|null, deutscher_titel: str|null}`
|
||||
|
||||
Kleine Nachsichten: boolesche Werte dürfen als ja/nein-Text kommen,
|
||||
Konfidenz darf auf 0–100-Skala geliefert werden. Alles andere → None.
|
||||
"""
|
||||
if not isinstance(daten, dict):
|
||||
return None
|
||||
|
||||
ist_gleiches_spiel = daten.get("ist_gleiches_spiel")
|
||||
if isinstance(ist_gleiches_spiel, str):
|
||||
ist_gleiches_spiel = {
|
||||
"ja": True, "true": True, "yes": True,
|
||||
"nein": False, "false": False, "no": False,
|
||||
}.get(ist_gleiches_spiel.strip().lower())
|
||||
if not isinstance(ist_gleiches_spiel, bool):
|
||||
return None
|
||||
|
||||
try:
|
||||
konfidenz = float(daten.get("konfidenz"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if 10.0 <= konfidenz <= 100.0: # Prozent-Skala tolerieren (z. B. 92 → 0.92)
|
||||
konfidenz = konfidenz / 100.0
|
||||
if not 0.0 <= konfidenz <= 1.0: # 1 < x < 10 ist auf keiner Skala plausibel
|
||||
return None
|
||||
|
||||
begruendung = daten.get("begruendung")
|
||||
if not isinstance(begruendung, str):
|
||||
begruendung = ""
|
||||
|
||||
return {
|
||||
"ist_gleiches_spiel": ist_gleiches_spiel,
|
||||
"konfidenz": round(konfidenz, 4),
|
||||
"begruendung": begruendung.strip(),
|
||||
"empfohlener_verlag": _optional_text(daten.get("empfohlener_verlag")),
|
||||
"deutscher_titel": _optional_text(daten.get("deutscher_titel")),
|
||||
}
|
||||
|
||||
|
||||
# ---------------- Prompt ----------------
|
||||
|
||||
_SYSTEM_NACHRICHT = (
|
||||
"Du assistierst einer Spielemagazin-Redaktion beim Deduplizieren von "
|
||||
"Brettspieltiteln. Entscheide, ob zwei Einträge dasselbe Spiel meinen "
|
||||
"(auch across Sprachen, Editionen, Big Boxes und Verlags-Aliase) oder "
|
||||
"verschiedene Spiele/Editionen sind. Antworte AUSSCHLIESSLICH mit einem "
|
||||
"JSON-Objekt nach exakt diesem Schema:\n"
|
||||
'{"ist_gleiches_spiel": <bool>, "konfidenz": <float 0.0-1.0>, '
|
||||
'"begruendung": "<kurzer deutscher Satz>", '
|
||||
'"empfohlener_verlag": "<Verlagsname oder null>", '
|
||||
'"deutscher_titel": "<bekannter deutscher Titel oder null>"}\n'
|
||||
"Kein weiterer Text, keine Code-Fences."
|
||||
)
|
||||
|
||||
|
||||
def baue_prompt(
|
||||
*,
|
||||
titel_a: str,
|
||||
verlag_a: str | None = None,
|
||||
alternativen_a: list[str] | None = None,
|
||||
expansionen_a: list[str] | None = None,
|
||||
titel_b: str,
|
||||
verlag_b: str | None = None,
|
||||
alternativen_b: list[str] | None = None,
|
||||
frage: str,
|
||||
) -> str:
|
||||
"""Baut die User-Nachricht mit vollem Kontext beider Einträge."""
|
||||
def seite(name: str, titel: str, verlag, alternativen, expansionen) -> str:
|
||||
zeilen = [f"{name}: „{titel}“"]
|
||||
if verlag:
|
||||
zeilen.append(f"{name} Verlag: {verlag}")
|
||||
if alternativen:
|
||||
zeilen.append(f"{name} Alternate Names: " + "; ".join(alternativen))
|
||||
if expansionen:
|
||||
zeilen.append(f"{name} ergänzt (BGG-Erweiterungs-Relation): " + "; ".join(expansionen))
|
||||
return "\n".join(zeilen)
|
||||
|
||||
teile = [
|
||||
seite("Eintrag A", titel_a, verlag_a, alternativen_a, expansionen_a),
|
||||
seite("Eintrag B", titel_b, verlag_b, alternativen_b, None),
|
||||
frage,
|
||||
]
|
||||
return "\n\n".join(teile)
|
||||
|
||||
|
||||
# ---------------- Client ----------------
|
||||
|
||||
class KiPruefClient:
|
||||
"""OpenAI-kompatibler Chat-Completions-Client für die Zweitprüfung.
|
||||
|
||||
Genauso defensiv wie der BGG-Hilfsclient: `bewerte()` wirft nicht,
|
||||
sondern liefert bei jedem Problem None — die Regelprüfung bleibt dann
|
||||
allein maßgeblich. Transport und Timeout sind injizierbar, damit Tests
|
||||
netzwerkfrei bleiben.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
konfiguration: KiKonfiguration,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._konfiguration = konfiguration
|
||||
self._client = httpx.Client(
|
||||
base_url=konfiguration.basis_url,
|
||||
timeout=konfiguration.timeout,
|
||||
transport=transport,
|
||||
headers={
|
||||
"Authorization": f"Bearer {konfiguration.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def modell(self) -> str:
|
||||
return self._konfiguration.modell
|
||||
|
||||
def schliessen(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
# ---------- Öffentliche API ----------
|
||||
|
||||
def bewerte(
|
||||
self,
|
||||
*,
|
||||
titel_a: str,
|
||||
titel_b: str,
|
||||
verlag_a: str | None = None,
|
||||
verlag_b: str | None = None,
|
||||
alternativen_a: list[str] | None = None,
|
||||
alternativen_b: list[str] | None = None,
|
||||
expansionen_a: list[str] | None = None,
|
||||
frage: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Fragt das LLM, ob zwei Einträge dasselbe Spiel meinen.
|
||||
|
||||
Rückgabe ist das validierte Antwort-Dict (siehe `validiere_antwort`)
|
||||
oder None — ohne Key, bei Netzwerk-/Server-Problemen (nach genau
|
||||
einem Retry) oder bei unbrauchbarer Antwort.
|
||||
"""
|
||||
if not self._konfiguration.vollstaendig:
|
||||
return None
|
||||
|
||||
nutzernachricht = baue_prompt(
|
||||
titel_a=titel_a,
|
||||
verlag_a=verlag_a,
|
||||
alternativen_a=alternativen_a,
|
||||
expansionen_a=expansionen_a,
|
||||
titel_b=titel_b,
|
||||
verlag_b=verlag_b,
|
||||
alternativen_b=alternativen_b,
|
||||
frage=frage or (
|
||||
"Meinen Eintrag A und Eintrag B dasselbe Spiel? Wenn ja, nenne "
|
||||
"bitte auch, welcher Verlag geführt werden sollte und welchen "
|
||||
"deutschen Titel es gibt (falls bekannt)."
|
||||
),
|
||||
)
|
||||
payload = {
|
||||
"model": self._konfiguration.modell,
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_NACHRICHT},
|
||||
{"role": "user", "content": nutzernachricht},
|
||||
],
|
||||
}
|
||||
|
||||
for versuch in range(1, VERSUCHE + 1):
|
||||
try:
|
||||
antwort = self._client.post("/chat/completions", json=payload)
|
||||
except httpx.RequestError as exc: # Netzwerk/Timeout → genau ein Retry
|
||||
_logger.warning("dedup/KI: Versuch %d/%d fehlgeschlagen (%s)", versuch, VERSUCHE, exc)
|
||||
continue
|
||||
if antwort.status_code in RETRY_STATUS: # zeitweilige Störung → genau ein Retry
|
||||
_logger.warning(
|
||||
"dedup/KI: Versuch %d/%d mit Status %d — wiederholt.",
|
||||
versuch, VERSUCHE, antwort.status_code,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
antwort.raise_for_status() # andere 4xx: kein Retry hilft
|
||||
except httpx.HTTPStatusError as exc:
|
||||
_logger.warning("dedup/KI: Anfrage abgelehnt (%s) — nur Regelbefund.", exc)
|
||||
return None
|
||||
try:
|
||||
inhalte = antwort.json()["choices"][0]["message"]["content"]
|
||||
except Exception as exc: # kaputtes Antwort-Layout
|
||||
_logger.warning("dedup/KI: unbrauchbare Antwort (%s)", exc)
|
||||
return None
|
||||
return validiere_antwort(extrahiere_json(inhalte))
|
||||
|
||||
_logger.warning("dedup/KI: alle %d Versuche fehlgeschlagen — nur Regelbefund.", VERSUCHE)
|
||||
return None
|
||||
@@ -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