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

@@ -28,6 +28,9 @@ def _hintergrundjobs_deaktiviert(monkeypatch):
monkeypatch.setenv("SPIELE_ERINNERUNG_JOB_AKTIV", "0")
# Auch der BGG-Hilfsclient des dedup-Plugins bleibt in Tests offline.
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

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 == []