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:
Flo Hartmann
2026-08-22 23:43:33 +00:00
parent b6d67e04e5
commit 8e5f65d864
6 changed files with 1201 additions and 12 deletions

View File

@@ -59,6 +59,12 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) | | `SPIELE_BGG_MAX_TREFFER_PRO_SUCHE` | `25` | Obergrenze Treffer je Suchbegriff (schont das BGG-Rate-Limit) |
| `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen | | `SPIELE_BGG_TOKEN` | *(leer)* | API-Token für die BGG-XML-API2, wird als `Authorization: Bearer …`-Header gesendet; seit der Token-Pflicht von BGG erforderlich (siehe [BGG-Thread 3602374](https://boardgamegeek.com/thread/3602374)) — ohne Token wird der Sync übersprungen |
| `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen | | `SPIELE_DEDUP_BGG_AKTIV` | `1` | BGG-Zusatzdaten für die Dedup-Prüfung an (`1`) oder aus (`0`): Alternate-Names und Erweiterungs-Relationen |
| `SPIELE_DEDUP_LLM_AKTIV` | `0` | LLM-Zweitprüfung der Dedup-Grenzfälle an (`1`) oder aus (`0`) |
| `SPIELE_DEDUP_LLM_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
| `SPIELE_DEDUP_LLM_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
| `SPIELE_DEDUP_LLM_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_DEDUP_LLM_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das LLM-Ergebnis verworfen und nur der Regelbefund angezeigt |
| `SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN` | `20` | Timeout je LLM-Aufruf; Netzwerk-/Server-Fehler werden genau einmal wiederholt |
| `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) | | `SPIELE_ARCHIV_JOB_AKTIV` | `1` | Täglicher Archivierungs-Job an (`1`) oder aus (`0`) |
| `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` | | `SPIELE_ARCHIV_JOB_UHRZEIT` | `03:00` | Tageszeit des täglichen Archiv-Laufs im Format `HH:MM` |
| `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) | | `SPIELE_ERINNERUNG_JOB_AKTIV` | `1` | Täglicher Erinnerungs-Check an (`1`) oder aus (`0`) |
@@ -356,6 +362,37 @@ Teilprüfung. BGG-Störungen blockieren nie: der Hilfsclient degradiert auf
die reine Heuristik und protokolliert nur. Abschaltbar über die reine Heuristik und protokolliert nur. Abschaltbar über
`SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s). `SPIELE_DEDUP_BGG_AKTIV=0` (Standard: an, Rate-Limit 1 s).
### LLM-Zweitprüfung für Grenzfälle
Regeln stoßen bei Editionen, Big Boxes, Übersetzungen und Verlags-Aliasen an
Grenzen („El Grande Big Box“ vs. „El Grande: 25 Jahre“, „Catan Das schnelle
Spiel“ vs. „Catan Fast Edition“). Deshalb kann optional ein LLM als **zweite
Stufe** eingeschaltet werden — die Regelprüfung bleibt maßgeblich:
- **Nur Grenzfälle gehen ans LLM:** Titel mit gemeinsamen Wort-Tokens, aber
Fuzzy-Score unter der Schwelle (max. 5 Paarungen je Prüfung), sowie ein
ergänzender Call zum Verlags-/Titel-Hinweis bei einem regelbasierten
Verlags-Konflikt. Klare Treffer und klare Non-Treffer kosten kein Token.
- **Kontext:** beide Titel inkl. Alternate-Names (BGG-Sync), Verlage und ggf.
BGG-Expansion-Relationen.
- **Strukturierte Antwort** (JSON): `ist_gleiches_spiel`, `konfidenz`,
`begruendung`, `empfohlener_verlag`, `deutscher_titel`. Bestätigte Treffer
laufen in die üblichen Prüfungen a)d) zurück; ein übernommener deutscher
Titel füllt die Titel-Empfehlung, ein Verlags-Hinweis landet im Detail des
Verlags-Konflikts. Unter der Mindest-Konfidenz
(`SPIELE_DEDUP_LLM_KONFIDENZ_MIN`, Standard 0.7) wird das Ergebnis
verworfen und nur der Regelbefund angezeigt.
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_DEDUP_LLM_AKTIV=0`). Ohne
Key oder bei jedem Fehler (Timeout, Server-Störung nach genau einem Retry,
kaputtes JSON) verhält sich die Prüfung exakt wie rein regelbasiert — das
LLM kann die Prüfung nie blockieren.
- **Audit:** Jede Prüfung mit LLM-Befunden schreibt einen Eintrag ins
audit-log (`geprüft` / `dedup_ki`) mit Modell, Konfidenz und Entscheidung
je bewertetem Grenzfall.
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx, konfiguriert
über die `SPIELE_DEDUP_LLM_*`-Variablen (siehe Tabelle oben).
### Prüfprotokoll & Prüfseite ### Prüfprotokoll & Prüfseite
Jede `check_titel`-Prüfung landet in der eigenen Migration Jede `check_titel`-Prüfung landet in der eigenen Migration

View File

@@ -15,6 +15,17 @@ in `pruefung.py` beschrieben. Jede Prüfung wird in der eigenen Tabelle
Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen) Zusatzdaten von BoardGameGeek (Alternate-Names, Erweiterungs-Relationen)
können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an); können über SPIELE_DEDUP_BGG_AKTIV=0 abgeschaltet werden (Standard: an);
ohne Netzwerk degradiert die Prüfung automatisch auf die Heuristik. 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 from __future__ import annotations
@@ -30,6 +41,7 @@ from redaktionskern.auth.models import User
from redaktionskern.contracts import BasePlugin, Migration, NavEntry from redaktionskern.contracts import BasePlugin, Migration, NavEntry
from .bgg import BggPruefClient from .bgg import BggPruefClient
from .ki_pruefung import KiPruefClient, lade_konfiguration
from .models import Pruefung from .models import Pruefung
from .pruefung import KONFLIKT_ANZEIGE, PruefErgebnis, fuehre_pruefung_durch 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. #: Von Tests überschreibbare Fabrik für den BGG-Hilfsclient.
self.bgg_client_fabrik = None 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() self._routen_registrieren()
# ---------- Plugin-Vertrag ---------- # ---------- Plugin-Vertrag ----------
@@ -72,11 +90,17 @@ class DedupPlugin(BasePlugin):
def on_load(self, context) -> None: def on_load(self, context) -> None:
super().on_load(context) super().on_load(context)
self._bgg_aktiv = os.environ.get("SPIELE_DEDUP_BGG_AKTIV", "1").strip() != "0" 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: def on_unload(self) -> None:
if self._bgg_client is not None: if self._bgg_client is not None:
self._bgg_client.schliessen() self._bgg_client.schliessen()
self._bgg_client = None self._bgg_client = None
if self._ki_client is not None:
self._ki_client.schliessen()
self._ki_client = None
super().on_unload() super().on_unload()
# ---------- Öffentliche Prüf-API für andere Plugins ---------- # ---------- Öffentliche Prüf-API für andere Plugins ----------
@@ -102,6 +126,7 @@ class DedupPlugin(BasePlugin):
id_wert = None id_wert = None
client = self._hilfsclient() client = self._hilfsclient()
ki_client = self._ki_hilfsclient()
try: try:
ergebnis = fuehre_pruefung_durch( ergebnis = fuehre_pruefung_durch(
self.context.session_factory, self.context.session_factory,
@@ -110,15 +135,57 @@ class DedupPlugin(BasePlugin):
verlag=verlag, verlag=verlag,
bgg_id=id_wert, bgg_id=id_wert,
user_id=user.id if user is not None else None, user_id=user.id if user is not None else None,
ki_client=ki_client,
ki_konfidenz_min=self.ki_konfiguration.konfidenz_min,
) )
finally: finally:
if client is not None: if client is not None:
client.schliessen() client.schliessen()
if ki_client is not None:
ki_client.schliessen()
self._protokolliere(ergebnis, user) self._protokolliere(ergebnis, user)
await self._auditiere_ki_befunde(ergebnis, user)
return ergebnis return ergebnis
# ---------- Internas ---------- # ---------- 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: def _hilfsclient(self) -> BggPruefClient | None:
if self.bgg_client_fabrik is not None: if self.bgg_client_fabrik is not None:
return self.bgg_client_fabrik() return self.bgg_client_fabrik()

View 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 0100-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

View File

@@ -43,6 +43,10 @@ TITEL_SCHWELLE = 85
#: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht). #: Literal, damit keine Import-Abhängigkeit zwischen den Plugins entsteht).
STATUS_ABGESCHLOSSEN = "abgeschlossen" 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. #: Funktionswörter, die stark auf einen deutschen Spieltitel hindeuten.
DEUTSCHE_INDIKATOREN = frozenset({ DEUTSCHE_INDIKATOREN = frozenset({
"der", "die", "das", "den", "dem", "des", "der", "die", "das", "den", "dem", "des",
@@ -142,6 +146,67 @@ def _verlags_schluessel(verlag: str) -> str:
return normalisiere(verlag) 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 ---------------- # ---------------- Prüfergebnis ----------------
#: Anzeigenamen der Konflikt-Arten (deutsche UI). #: Anzeigenamen der Konflikt-Arten (deutsche UI).
@@ -181,6 +246,9 @@ class PruefErgebnis:
verlags_optionen: list[str] = field(default_factory=list) verlags_optionen: list[str] = field(default_factory=list)
#: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind. #: Empfohlener (deutschester) Titel, falls Titel-Varianten bekannt sind.
titel_empfehlung: str | None = None 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 @classmethod
def ohne_befund(cls, titel: str, verlag: str | None = None, bgg_id: int | None = None): 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) and normalisiere(self.titel_empfehlung) != normalisiere(self.titel)
): ):
zeilen.append(f"• Empfohlener Titel: „{self.titel_empfehlung}") 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) return "\n".join(zeilen)
@@ -269,12 +339,18 @@ def fuehre_pruefung_durch(
verlag: str | None = None, verlag: str | None = None,
bgg_id: int | None = None, bgg_id: int | None = None,
user_id: int | None = None, user_id: int | None = None,
ki_client=None,
ki_konfidenz_min: float = 0.7,
) -> PruefErgebnis: ) -> PruefErgebnis:
"""Führt alle vier Prüfungen durch und liefert ein PruefErgebnis. """Führt alle vier Prüfungen durch und liefert ein PruefErgebnis.
`bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann `bgg_hilfsclient` darf None sein (BGG-Zusatzdaten deaktiviert) — dann
entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und entfallen Alternate-Names und Vorgänger-Relationen, die Heuristik und
der Fuzzy-Match arbeiten weiter. 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 `user_id` ist der Eintragende: eigene Planungseinträge lösen keinen
„schon in Planung“-Konflikt aus. „schon in Planung“-Konflikt aus.
""" """
@@ -284,6 +360,7 @@ def fuehre_pruefung_durch(
ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert) ergebnis = PruefErgebnis.ohne_befund(titel, verlag, id_wert)
if not titel: if not titel:
return ergebnis return ergebnis
konfidenz_min = max(0.0, min(1.0, float(ki_konfidenz_min)))
with session_factory() as db: with session_factory() as db:
neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id")) neuheiten = _lade_zeilen(db, "neuheiten", ("id", "titel", "verlag", "bgg_id"))
@@ -304,12 +381,58 @@ def fuehre_pruefung_durch(
except Exception as exc: except Exception as exc:
_logger.warning("dedup: BGG-Vorgänger nicht verfügbar (%s)", 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 = [ kandidaten = [
zeile for zeile in (*neuheiten, *planungen) zeile for zeile in alle_zeilen
if _gleiches_spiel(zeile, titel, id_wert) 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. # a) Verlags-Konflikt: gleiches Spiel unter anderem Verlag/Vertrieb.
bekannte_verlage: dict[str, str] = {} bekannte_verlage: dict[str, str] = {}
for zeile in kandidaten: for zeile in kandidaten:
@@ -340,8 +463,9 @@ def fuehre_pruefung_durch(
zeile["titel"] for zeile in kandidaten zeile["titel"] for zeile in kandidaten
if normalisiere(zeile.get("titel") or "") != normalisiere(titel) if normalisiere(zeile.get("titel") or "") != normalisiere(titel)
] ]
ergebnis.titel_empfehlung = bevorzuge_deutschen_titel( ergebnis.titel_empfehlung = (
[titel, *varianten, *alternativen] ki_titel_vorschlag # KI-Empfehlung (bestätigter Grenzfall) hat Vorrang
or bevorzuge_deutschen_titel([titel, *varianten, *alternativen])
) )
if varianten and ergebnis.titel_empfehlung \ if varianten and ergebnis.titel_empfehlung \
and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel): and normalisiere(ergebnis.titel_empfehlung) != normalisiere(titel):
@@ -361,12 +485,16 @@ def fuehre_pruefung_durch(
# c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“). # c) Spiel oder Vorgänger bereits besprochen (Planungsstatus „abgeschlossen“).
besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN] besprochen = [z for z in planungen if z.get("status") == STATUS_ABGESCHLOSSEN]
for zeile in besprochen: 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) 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( ergebnis.konflikte.append(TitelKonflikt(
art="besprochen", art="besprochen",
beschreibung=f"{zeile['titel']}“ wurde bereits besprochen ({wer}).", 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 fremd_id, fremd_titel in vorgaenger:
for zeile in besprochen: for zeile in besprochen:
@@ -394,20 +522,102 @@ def fuehre_pruefung_durch(
for zeile in planungen: for zeile in planungen:
if user_id is not None and zeile.get("rezensent_id") == user_id: if user_id is not None and zeile.get("rezensent_id") == user_id:
continue 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) wer = _rezensent_name(zeile, namen)
status = zeile.get("status") or "" 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( ergebnis.konflikte.append(TitelKonflikt(
art="planung", art="planung",
beschreibung=( beschreibung=(
f"{zeile['titel']}“ ist bereits in der Planungsliste " f"{zeile['titel']}“ ist bereits in der Planungsliste "
f"von {wer} (Status: {status})." f"von {wer} (Status: {status})."
), ),
details={ details=details,
"planungseintrag": zeile["id"],
"rezensent": wer,
"status": status,
},
)) ))
# ---- 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 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)

View File

@@ -28,6 +28,9 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0") monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline. # Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0") monkeypatch.setenv("SPIELE_DEDUP_BGG_AKTIV", "0")
# Die LLM-Zweitprüfung des dedup-Plugins bleibt in Tests aus; einzelne
# Tests schalten sie explizit per SPIELE_DEDUP_LLM_AKTIV=1 ein.
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "0")
@pytest.fixture @pytest.fixture

527
tests/test_dedup_ki.py Normal file
View File

@@ -0,0 +1,527 @@
"""Tests: KI-Zweitprüfung des Plugins „dedup“.
Alle LLM-Antworten werden über httpx.MockTransport bzw. Fake-Clients gestellt —
in CI läuft niemals ein echter LLM-Call. Getestet werden: Konfiguration,
JSON-Parsing-Robustheit (Code-Fences, Zusatztext), Retry-/Fallback-Verhalten,
Konfidenz-Schwelle und die Rückführung bestätigter Treffer in die Regelprüfung.
"""
from __future__ import annotations
import asyncio
import json
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from redaktionskern.app import create_app
from plugins.dedup.ki_pruefung import (
VERSUCHE,
KiKonfiguration,
KiPruefClient,
extrahiere_json,
lade_konfiguration,
validiere_antwort,
)
from tests.conftest import lege_benutzer_an
from tests.test_dedup import benutzer_objekt, neuheit_anlegen, planung_anlegen, plugin_modul
# ---------------- Hilfen ----------------
def gueltige_llm_antwort(inhalt: str, status: int = 200) -> httpx.Response:
"""Wrappt einen Assistant-Text in eine Chat-Completions-Antwort."""
body = {"choices": [{"message": {"role": "assistant", "content": inhalt}}]}
return httpx.Response(status, content=json.dumps(body).encode())
def mock_transport(antworten: list) -> tuple[httpx.MockTransport, dict]:
"""Transport mit gestaffelten Antworten: str = Assistant-Inhalt,
int = HTTP-Status, Exception = wird geworfen. Zählt die Aufrufe mit."""
zaehler = {"aufrufe": 0}
def handler(request: httpx.Request) -> httpx.Response:
index = min(zaehler["aufrufe"], len(antworten) - 1)
zaehler["aufrufe"] += 1
spezifikation = antworten[index]
if isinstance(spezifikation, Exception):
raise spezifikation
if isinstance(spezifikation, int):
return httpx.Response(spezifikation, json={"error": "kaputt"})
return gueltige_llm_antwort(spezifikation)
return httpx.MockTransport(handler), zaehler
class FakeKiClient:
"""Ersetzt den KI-Client komplett: feste Antwort, zählt Aufrufe."""
modell = "fake-modell"
def __init__(self, antwort=None):
self.antwort = antwort
self.aufrufe: list[dict] = []
def bewerte(self, **kwargs):
self.aufrufe.append(kwargs)
return self.antwort
def schliessen(self):
pass
GLEICHES_SPIEL_HOCH = {
"ist_gleiches_spiel": True, "konfidenz": 0.9,
"begruendung": "Beides El Grande-Jubiläumsausgaben.",
"empfohlener_verlag": None, "deutscher_titel": None,
}
def app_mit_ki(settings, monkeypatch, *, konfidenz_min=None):
"""App, deren Plugin-Umgebung die KI-Zweitprüfung aktiviert."""
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_BASIS_URL", "https://llm.example.test/v1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_API_KEY", "test-key")
monkeypatch.setenv("SPIELE_DEDUP_LLM_MODELL", "test-modell")
if konfidenz_min is not None:
monkeypatch.setenv("SPIELE_DEDUP_LLM_KONFIDENZ_MIN", str(konfidenz_min))
return create_app(settings)
def pruefe_mit_ki(app, antworten, *args, **kwargs):
"""check_titel mit per Fabrik eingehängtem Mock-LLM (Antwort-Stafette)."""
transport, zaehler = mock_transport(antworten)
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: KiPruefClient(
KiKonfiguration(
aktiv=True, basis_url="https://llm.example.test/v1",
api_key="test-key", modell="test-modell",
konfidenz_min=dedup.ki_konfiguration.konfidenz_min,
),
transport=transport,
)
try:
ergebnis = asyncio.run(dedup.check_titel(*args, **kwargs))
finally:
dedup.ki_client_fabrik = None
return ergebnis, zaehler
# ---------------- Konfiguration ----------------
def test_konfiguration_standard_aus():
k = lade_konfiguration({})
assert not k.aktiv
assert not k.vollstaendig
assert k.konfidenz_min == 0.7
assert k.timeout >= 1.0
def test_konfiguration_aus_env_und_fehlertolerant():
k = lade_konfiguration({
"SPIELE_DEDUP_LLM_AKTIV": "1",
"SPIELE_DEDUP_LLM_BASIS_URL": "https://api.example.test/v1/",
"SPIELE_DEDUP_LLM_API_KEY": " sk-123 ",
"SPIELE_DEDUP_LLM_MODELL": " gpt-test ",
# Kaputte Zahlen fallen auf die Standards zurück:
"SPIELE_DEDUP_LLM_KONFIDENZ_MIN": "abc",
"SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN": "-5",
})
assert k.vollstaendig
assert k.basis_url == "https://api.example.test/v1"
assert k.api_key == "sk-123"
assert k.modell == "gpt-test"
assert k.konfidenz_min == 0.7 # Standard bei Unsinn
assert k.timeout >= 1.0
def test_konfiguration_schwellenwerte_werden_geklemmt():
k = lade_konfiguration({"SPIELE_DEDUP_LLM_KONFIDENZ_MIN": "1.5"})
assert k.konfidenz_min == 1.0
k = lade_konfiguration({"SPIELE_DEDUP_LLM_TIMEOUT_SEKUNDEN": "3.5"})
assert k.timeout == 3.5
# ---------------- JSON-Parsing-Robustheit ----------------
def test_extrahiere_json_plain():
assert extrahiere_json('{"ist_gleiches_spiel": true}') == {"ist_gleiches_spiel": True}
def test_extrahiere_json_code_fence():
text = 'Hier die Bewertung:\n\n```json\n{"ist_gleiches_spiel": false}\n```\n\nViele Grüße'
assert extrahiere_json(text) == {"ist_gleiches_spiel": False}
def test_extrahiere_json_text_um_json():
text = 'Klar! {"ist_gleiches_spiel": true, "konfidenz": 0.8} — hoffe das hilft.'
assert extrahiere_json(text)["konfidenz"] == 0.8
def test_extrahiere_json_unbrauchbar():
assert extrahiere_json("Ich weiß es leider nicht.") is None
assert extrahiere_json("[1, 2, 3]") is None # kein Objekt
assert extrahiere_json("") is None
assert extrahiere_json(None) is None
def test_validiere_antwort_normalisiert():
roh = {
"ist_gleiches_spiel": "ja", # Text statt Bool
"konfidenz": 92, # Prozent-Skala
"begruendung": " Gleiche Edition. ",
"empfohlener_verlag": " Kosmos ",
"deutscher_titel": "",
}
sauber = validiere_antwort(roh)
assert sauber == {
"ist_gleiches_spiel": True,
"konfidenz": 0.92,
"begruendung": "Gleiche Edition.",
"empfohlener_verlag": "Kosmos",
"deutscher_titel": None,
}
@pytest.mark.parametrize("roh", [
{"ist_gleiches_spiel": True}, # konfidenz fehlt
{"ist_gleiches_spiel": "vielleicht", "konfidenz": 0.9},
{"ist_gleiches_spiel": True, "konfidenz": "hoch"},
{"ist_gleiches_spiel": True, "konfidenz": 7}, # weder 01 noch 0100
["kein", "objekt"],
])
def test_validiere_antwort_lehnt_unbrauchbares_ab(roh):
assert validiere_antwort(roh) is None
# ---------------- Client: Retry, Fehler, Header ----------------
KONFIG = KiKonfiguration(
aktiv=True, basis_url="https://llm.example.test/v1",
api_key="geheim", modell="test-modell",
)
ANTWORT_JSON = '{"ist_gleiches_spiel": true, "konfidenz": 0.9, "begruendung": "b", ' \
'"empfohlener_verlag": null, "deutscher_titel": null}'
def test_client_sendet_modell_und_key():
gesehen = {}
def handler(request: httpx.Request) -> httpx.Response:
gesehen["url"] = str(request.url)
gesehen["auth"] = request.headers.get("Authorization")
gesehen["body"] = json.loads(request.content.decode())
return gueltige_llm_antwort(ANTWORT_JSON)
client = KiPruefClient(KONFIG, transport=httpx.MockTransport(handler))
try:
ergebnis = client.bewerte(titel_a="A", titel_b="B")
finally:
client.schliessen()
assert ergebnis["ist_gleiches_spiel"] is True
assert gesehen["url"].endswith("/v1/chat/completions")
assert gesehen["auth"] == "Bearer geheim"
assert gesehen["body"]["model"] == "test-modell"
def test_client_retryt_genau_einmal_bei_serverstoerung():
transport, zaehler = mock_transport([500, ANTWORT_JSON])
client = KiPruefClient(KONFIG, transport=transport)
try:
ergebnis = client.bewerte(titel_a="A", titel_b="B")
finally:
client.schliessen()
assert ergebnis is not None
assert zaehler["aufrufe"] == 2 # erster Versuch + genau ein Retry
def test_client_gibt_nach_zweitem_fehler_auf():
transport, zaehler = mock_transport([503, 503])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == VERSUCHE
def test_client_timeout_wird_genau_einmal_wiederholt():
transport, zaehler = mock_transport([
httpx.ReadTimeout("zu langsam"), httpx.ReadTimeout("weiter zu langsam"),
])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 2
def test_client_kein_retry_bei_clientfehler_oder_kaputtem_json():
# 404 ist nicht wiederholbar:
transport, zaehler = mock_transport([404, 404])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 1
# Kaputtes JSON hilft kein Retry:
transport, zaehler = mock_transport(["Leider weiß ich das nicht."])
client = KiPruefClient(KONFIG, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 1
def test_client_ohne_vollstaendige_konfig_ruft_nichts():
transport, zaehler = mock_transport([ANTWORT_JSON])
leer = KiKonfiguration(aktiv=True, basis_url="", api_key="", modell="")
client = KiPruefClient(leer, transport=transport)
try:
assert client.bewerte(titel_a="A", titel_b="B") is None
finally:
client.schliessen()
assert zaehler["aufrufe"] == 0
# ---------------- Integration: Fallback & Verhalten ----------------
def test_ohne_ki_verhaelt_sich_alles_wie_bisher(settings, monkeypatch):
"""Standard (aktiv=0): Grenzfälle bleiben regelbasiert, kein LLM-Aufruf."""
app = create_app(settings)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
dedup = app.state.registry.get("dedup")
def aufschrei(): # dürfte nie aufgerufen werden
raise AssertionError("KI war deaktiviert, wurde aber aufgerufen!")
dedup.ki_client_fabrik = lambda: aufschrei()
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre"))
finally:
dedup.ki_client_fabrik = None
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde == []
# Empfehlung bleibt beim eigenen Titel (Heuristik ohne Varianten):
assert ergebnis.titel_empfehlung == "El Grande: 25 Jahre"
def test_aktiv_ohne_key_faellt_auf_regeln_zurueck(settings, monkeypatch):
"""AKTIV=1, aber kein API-Key → Zweitprüfung bleibt aus, kein Fehler."""
monkeypatch.setenv("SPIELE_DEDUP_LLM_AKTIV", "1")
monkeypatch.setenv("SPIELE_DEDUP_LLM_BASIS_URL", "https://llm.example.test/v1")
# bewusst KEIN SPIELE_DEDUP_LLM_API_KEY / MODELL:
app = create_app(settings)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: (_ for _ in ()).throw(
AssertionError("Ohne Key darf kein KI-Client gebaut werden.")
)
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre"))
finally:
dedup.ki_client_fabrik = None
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde == []
def test_llm_fehler_blockiert_nie(settings, monkeypatch):
"""Netzwerk-/Server-Probleme: Ergebnis identisch zur reinen Regelprüfung."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id, verlag="Rio Grande Games")
ergebnis, zaehler = pruefe_mit_ki(
app,
[httpx.ConnectError("offline")] * 6,
"El Grande: 25 Jahre", "Devir", None,
)
assert zaehler["aufrufe"] >= 1
# Ohne funktionierende Zweitprüfung bleibt es beim Regelbefund (hier leer —
# die Grenzfall-Paarung matchen die Regeln ja gerade nicht):
assert not ergebnis.hat_konflikte
assert all(b["entscheidung"] == "fehler_verworfen" for b in ergebnis.ki_befunde)
# Exakt dasselbe Ergebnis wie ohne KI:
dedup = app.state.registry.get("dedup")
referenz = asyncio.run(dedup.check_titel("El Grande: 25 Jahre", "Devir", None))
assert referenz.als_liste() == ergebnis.als_liste()
assert referenz.titel_empfehlung == ergebnis.titel_empfehlung
assert referenz.verlags_optionen == ergebnis.verlags_optionen
# ---------------- Integration: Grenzfälle & Schwelle ----------------
def test_grenzfall_wird_durch_ki_bestaetigt(settings, monkeypatch):
"""Big Box vs. Jubiläumsausgabe: Regeln finden nichts, das LLM schon."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH)
antwort["begruendung"] = "Beides sind El Grande-Ausgaben der Jubiläumsreihe."
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
arten = [k.art for k in ergebnis.konflikte]
assert "planung" in arten
planungs_konflikt = next(k for k in ergebnis.konflikte if k.art == "planung")
assert planungs_konflikt.details["ki_bestaetigt"] is True
assert planungs_konflikt.details["ki_konfidenz"] == 0.9
assert ergebnis.ki_befunde[0]["entscheidung"] == "gleiches_spiel"
assert ergebnis.ki_befunde[0]["modell"] == "test-modell"
assert ergebnis.ki_befunde[0]["konfidenz"] == 0.9
def test_grenzfall_unter_konfidenzschwelle_wird_verworfen(settings, monkeypatch):
"""Gleicher Befund, aber Konfidenz 0.5 < 0.7 → nur der Regelbefund zählt."""
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH, konfidenz=0.5)
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verworfen_unter_schwelle"
def test_konfidenzschwelle_laesst_sich_per_env_verschieben(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch, konfidenz_min=0.95)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH) # 0.9 reicht jetzt nicht mehr
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verworfen_unter_schwelle"
def test_llm_sagt_verschiedene_spiele(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent")
rez1 = benutzer_objekt(app, "rez1")
planung_anlegen(app, "El Grande Big Box", rez1.id)
antwort = dict(GLEICHES_SPIEL_HOCH, ist_gleiches_spiel=False, begruendung="Big Box ≠ Jubiläum.")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande: 25 Jahre")
assert not ergebnis.hat_konflikte
assert ergebnis.ki_befunde[0]["entscheidung"] == "verschiedene_spiele"
def test_uebersetzungsgrenzfall_kriegt_deutschen_titel(settings, monkeypatch):
"""Catan Fast Edition vs. Catan Das schnelle Spiel: KI liefert den deutschen Titel."""
app = app_mit_ki(settings, monkeypatch)
neuheit_anlegen(app, "El Grande: 25 Jahre", verlag="Hans im Glück", bgg_id=111)
antwort = dict(GLEICHES_SPIEL_HOCH, deutscher_titel="El Grande: 25 Jahre")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande Big Box")
# Ohne KI wäre hier nichts gefunden worden (beide Titel heuristisch neutral);
# mit KI gibt es eine Titel-Variante samt deutscher Empfehlung:
assert "titel" in [k.art for k in ergebnis.konflikte]
assert ergebnis.titel_empfehlung == "El Grande: 25 Jahre"
dedup = app.state.registry.get("dedup")
referenz = asyncio.run(dedup.check_titel("El Grande Big Box"))
assert not referenz.hat_konflikte
# Ohne KI bleibt die Empfehlung beim eigenen Titel:
assert referenz.titel_empfehlung == "El Grande Big Box"
def test_verlagskonflikt_kriegt_ki_empfehlung(settings, monkeypatch):
"""Verlags-Alias: Regelprüfung findet den Konflikt, das LLM den richtigen Verlag."""
app = app_mit_ki(settings, monkeypatch)
neuheit_anlegen(app, "El Grande", verlag="Hans im Glück Verlag", bgg_id=21)
antwort = dict(GLEICHES_SPIEL_HOCH, empfohlener_verlag="Hans im Glück Verlag")
ergebnis, _ = pruefe_mit_ki(app, [json.dumps(antwort)], "El Grande", "Hans im Glück", 21)
assert ergebnis.hat_verlagskonflikt
verlags_konflikt = next(k for k in ergebnis.konflikte if k.art == "verlag")
assert verlags_konflikt.details["ki_empfohlener_verlag"] == "Hans im Glück Verlag"
assert ergebnis.ki_befunde[-1]["typ"] == "verlag"
# ---------------- Integration: Budget & Audit ----------------
def test_ki_budget_begrenzt_anzahl_aufrufe(settings, monkeypatch):
from plugins.dedup.pruefung import MAX_KI_PAARUNGEN
app = app_mit_ki(settings, monkeypatch)
for nr in range(MAX_KI_PAARUNGEN + 3):
neuheit_anlegen(app, f"Catan Ausgabe Nummer {nr}", bgg_id=200 + nr)
fake = FakeKiClient(dict(GLEICHES_SPIEL_HOCH))
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: fake
try:
ergebnis = asyncio.run(dedup.check_titel("Catan Neuauflage"))
finally:
dedup.ki_client_fabrik = None
assert len(fake.aufrufe) <= MAX_KI_PAARUNGEN
assert len(ergebnis.ki_befunde) == len(fake.aufrufe)
def test_ki_befunde_landen_im_audit_log(settings, monkeypatch):
app = app_mit_ki(settings, monkeypatch)
lege_benutzer_an(app, "rez1", "rezensent") # fremder Eintrag → echter Konflikt
lege_benutzer_an(app, "anna", "redakteur") # prüfende Person
rez1 = benutzer_objekt(app, "rez1")
anna = benutzer_objekt(app, "anna")
planung_anlegen(app, "El Grande Big Box", rez1.id)
fake = FakeKiClient(dict(GLEICHES_SPIEL_HOCH))
dedup = app.state.registry.get("dedup")
dedup.ki_client_fabrik = lambda: fake
try:
ergebnis = asyncio.run(dedup.check_titel("El Grande: 25 Jahre", user=anna))
finally:
dedup.ki_client_fabrik = None
assert ergebnis.hat_konflikte
AuditEintrag = plugin_modul(app, "audit-log").AuditEintrag
with app.state.session_factory() as db:
eintraege = db.scalars(
select(AuditEintrag).where(AuditEintrag.objekt_typ == "dedup_ki")
).all()
assert len(eintraege) == 1
eintrag = eintraege[0]
assert eintrag.action == "geprüft"
assert eintrag.actor_name == "anna"
assert eintrag.details["modell"] == "fake-modell"
befaelle = eintrag.details["befunde"]
assert befaelle[0]["entscheidung"] == "gleiches_spiel"
assert befaelle[0]["konfidenz"] == 0.9
assert befaelle[0]["titel_a"] == "El Grande: 25 Jahre"
def test_keine_ki_befunde_heissen_kein_audit_eintrag(settings, monkeypatch):
"""Rein regelbasierte Prüfung (KI aus) schreibt keinen dedup_ki-Eintrag."""
app = create_app(settings)
asyncio.run(app.state.registry.get("dedup").check_titel("Beliebiges Spiel"))
AuditEintrag = plugin_modul(app, "audit-log").AuditEintrag
with app.state.session_factory() as db:
eintraege = db.scalars(
select(AuditEintrag).where(AuditEintrag.objekt_typ == "dedup_ki")
).all()
assert eintraege == []