Files
spiele-redaktion/tests/test_neuheiten_ki_crawler.py
Flo Hartmann f27940db8e Plugin neuheiten: optionale LLM-Hilfsstufe für den Web-Quellen-Crawler
Neues Modul quellen/ki_hilfe.py (konsistent zum dedup-LLM-Muster):
- Struktur-Erkennung: bei verdächtig leerem Parser-Ergebnis schlägt das
  LLM Pagination-/Filter-Folge-URLs vor; hart gefiltert auf gleiche
  Domain, max. 20 je Seite, nicht erreichbare Vorschläge brechen den
  Lauf nicht ab.
- Feld-Extraktion: nur unsichere Treffer (fehlender Verlag, verdächtiger
  Titel); korrigiert ausschließlich titel/verlag/autor, niemals die URL.
- Env-Konfiguration SPIELE_NEUHEITEN_KI_* (AKTIV default 0,
  KONFIDENZ_MIN default 0.7), OpenAI-kompatible Chat-Completions via
  httpx mit Timeout und genau einem Retry.
- Fallback-Pflicht: ohne Konfiguration oder bei jedem Fehler läuft exakt
  der klassische Crawler; KI-Fehler blockieren den Sync nie.
- Audit: KI-Eingriffe je Quelle und Lauf ins audit-log (Modell,
  Konfidenz, korrigierte Felder, verfolgte Folge-URLs).

20 neue Tests (gemocktes HTTP/LLM): Fallback-Fälle, Konfidenz-Schwelle,
Domain-Filter, Max-20-Grenze, URL-Unveränderbarkeit, Audit.
2026-08-23 01:07:18 +00:00

545 lines
19 KiB
Python

"""Tests: Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers (neuheiten).
Alle HTTP- und LLM-Antworten sind gemockt (httpx.MockTransport bzw.
Fake-Transports) — es gibt niemals echte Netzwerk- oder LLM-Aufrufe.
Abgedeckt: Fallback-Fälle (aktiv=0, fehlender Key, Timeout mit genau einem
Retry, kaputtes JSON), Konfidenz-Schwelle, Struktur-Erkennung nur mit
gleiche-Domain-URLs (max. 20), Feld-Korrektur ausschließlich unsicherer
Treffer (URL bleibt fix) und die Audit-Protokollierung der KI-Eingriffe.
"""
from __future__ import annotations
import json
import httpx
import pytest
from tests._neuheiten import (
MAX_FOLGE_URLS,
KiHilfe,
KiKonfiguration,
Neuheit,
QuellenFehler,
QuellenTreffer,
SpielboxQuelle,
SammelErgebnis,
WebQuellenClient,
WebQuellenSyncService,
extrahiere_ki_json,
lade_ki_konfiguration,
struktur_verdaechtig,
titel_ist_verdaechtig,
treffer_ist_unsicher,
)
BASIS_URL = "https://www.spielbox.de/neuheiten-neue-spiele"
KONFIG = dict(
aktiv=True,
basis_url="https://llm.example.test/v1",
api_key="test-key",
modell="test-modell",
)
class FakeUhr:
def __init__(self) -> None:
self.zeit = 0.0
def __call__(self) -> float:
return self.zeit
# ---------------- Gemockte Seiten ----------------
SEITE_LEER_VIELE_LINKS = (
"<html><body><nav>"
+ "".join(
f'<a href="/rubrik/{i}">Rubrik {i}</a> ' for i in range(40)
)
+ "</nav><p>Keine Neuheiten erkannt.</p></body></html>"
)
SEITE_MIT_TREFFERN = """
<div class="item">
<div class="page-header"><h2><a href="/x/1">MICROMACRO Kids</a></h2></div>
<p><strong>02.03.2026</strong> - kurz.</p>
</div>
"""
def _antwort(inhalt: bytes | str) -> httpx.Response:
return httpx.Response(
200,
content=inhalt.encode("utf-8") if isinstance(inhalt, str) else inhalt,
request=httpx.Request("GET", BASIS_URL),
)
def _crawler_client(seiten: dict[str, bytes | str]) -> tuple[WebQuellenClient, KartenTransport, FakeUhr]:
uhr = FakeUhr()
transport = KartenTransport({u: _antwort(i) for u, i in seiten.items()}, uhr)
client = WebQuellenClient(
transport=transport, schlaf=lambda s: None, uhr=uhr
)
return client, transport, uhr
class KartenTransport(httpx.BaseTransport):
"""Antworten je URL; zeichnet angefragte URLs auf."""
def __init__(self, antworten: dict[str, httpx.Response], uhr: FakeUhr):
self.antworten = antworten
self.anfragen: list[str] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
url = str(request.url)
self.anfragen.append(url)
antwort = self.antworten.get(url)
if antwort is None and "?" in url:
antwort = self.antworten.get(url.split("?")[0])
if antwort is None:
raise QuellenFehler(f"Keine gemockte Antwort für {url}")
return antwort
# ---------------- LLM-Mocks ----------------
def llm_antwort(objekt: dict, status: int = 200) -> httpx.Response:
body = {"choices": [{"message": {"role": "assistant", "content": json.dumps(objekt)}}]}
return httpx.Response(status, content=json.dumps(body).encode())
class LlmTransport(httpx.BaseTransport):
"""Gestaffelte Antworten für Chat-Completions; zählt Aufrufe.
Elemente: dict → JSON-Inhalt, int → HTTP-Status, Exception → wird geworfen.
"""
def __init__(self, antworten: list):
self.antworten = list(antworten)
self.aufrufe = 0
self.prompts: list[dict] = []
def handle_request(self, request: httpx.Request) -> httpx.Response:
index = min(self.aufrufe, len(self.antworten) - 1)
spezifikation = self.antworten[index]
self.aufrufe += 1
self.prompts.append(json.loads(request.content.decode()))
if isinstance(spezifikation, Exception):
raise spezifikation
if isinstance(spezifikation, int):
return httpx.Response(spezifikation, json={"error": "kaputt"})
return llm_antwort(spezifikation)
def ki_hilfe(transport: httpx.BaseTransport, konfidenz_min: float = 0.7) -> KiHilfe:
return KiHilfe(
KiKonfiguration(konfidenz_min=konfidenz_min, **KONFIG),
transport=transport,
)
TREFFER_UNSICHER = lambda: QuellenTreffer( # noqa: E731
titel="Pegasus Spiele: Next Station Berlin",
quellen_url="https://www.spielbox.de/x/1",
)
TREFFER_SICHER = lambda: QuellenTreffer( # noqa: E731
titel="Galactic Cruise", verlag="Dranda Games",
quellen_url="https://www.spielbox.de/x/2",
)
GUTE_FELDANTWORT = {
"titel": "Next Station Berlin",
"verlag": "Pegasus Spiele",
"autor": None,
"konfidenz": 0.9,
"begruendung": "Verlag stand als Präfix im Titel.",
}
# ---------------- Heuristiken ----------------
def test_struktur_verdaechtig_ohne_treffer_immmer():
assert struktur_verdaechtig(0, "<p>kurz</p>") is True
def test_struktur_verdaechtig_wenige_treffer_nur_bei_linkreicher_seite():
viele_links = "".join('<a href="/x">x</a>' for _ in range(40))
assert struktur_verdaechtig(2, viele_links) is True
assert struktur_verdaechtig(2, "<a href='/x'>x</a>") is False
assert struktur_verdaechtig(5, viele_links) is False
def test_unsichere_treffer_fehlender_verlag_oder_generischer_titel():
assert treffer_ist_unsicher(QuellenTreffer(titel="X", quellen_url="u")) is True
assert treffer_ist_unsicher(TREFFER_UNSICHER()) is True # kein Verlag
assert treffer_ist_unsicher(TREFFER_SICHER()) is False
assert titel_ist_verdaechtig("Mehr") is True
assert titel_ist_verdaechtig("ab") is True
assert titel_ist_verdaechtig("Fadenfroh") is False
def test_extrahiere_json_toleriert_code_fences():
text = "Antwort:\n```json\n{\"folge_urls\": []}\n```\nEnde."
assert extrahiere_ki_json(text) == {"folge_urls": []}
assert extrahiere_ki_json("völlig kaputt") is None
# ---------------- Konfiguration & Fallback ----------------
def test_konfiguration_standard_aus():
k = lade_ki_konfiguration({})
assert not k.aktiv and not k.vollstaendig
assert k.konfidenz_min == 0.7
assert k.timeout >= 1.0
def test_service_ohne_konfiguration_baut_keinen_ki_client(session_factory, monkeypatch):
"""Fallback aktiv=0: kein KI-Client, exakt der klassische Crawler."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "0")
class Streng:
def __call__(self): # pragma: no cover — darf nie aufgerufen werden
raise AssertionError("KI-Client ohne Aktivierung gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
with session_factory() as db:
assert db.query(Neuheit).count() == 1
def test_service_mit_aktiv_ohne_key_laeuft_klassisch(session_factory, monkeypatch):
"""Fallback: AKTIV=1, aber Key fehlt → vollständige Konfiguration fehlt."""
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_AKTIV", "1")
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_BASIS_URL", "https://llm.example.test/v1")
monkeypatch.delenv("SPIELE_NEUHEITEN_KI_API_KEY", raising=False)
monkeypatch.setenv("SPIELE_NEUHEITEN_KI_MODELL", "test-modell")
class Streng:
def __call__(self): # pragma: no cover
raise AssertionError("KI-Client ohne Key gebaut")
def schliessen(self): pass
quelle = StatischeQuelle("spielbox", [TREFFER_SICHER()])
service = WebQuellenSyncService(
session_factory, adapter_klassen=(quelle,),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=Streng(),
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
def test_timeout_genau_ein_retry_dann_klassischer_befund():
"""Timeout: insgesamt zwei Versuche, danach klassischer Treffer unverändert."""
transport = LlmTransport([httpx.ReadTimeout("zu lang"), httpx.ReadTimeout("nochmal")])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 2 # erster Versuch + genau ein Retry
assert eingriffe == []
original = treffer_liste[0]
assert original.verlag is None # klassischer Befund bleibt
assert original.quellen_url == "https://www.spielbox.de/x/1"
def test_kaputtes_json_kein_retry_kein_eingriff():
transport = LlmTransport(["das ist {kaputtes JSON ohne Sinn"])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1 # Parsing-Fehler werden nicht wiederholt
assert eingriffe == []
assert treffer_liste[0].verlag is None
def test_serverfehler_nach_retry_faellt_zurueck():
transport = LlmTransport([503, 503])
hilfe = ki_hilfe(transport)
_, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
folge_urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert transport.aufrufe == 4 # je Aufrufart zwei Versuche
assert eingriffe == [] and folge_urls == []
# ---------------- Konfidenz-Schwelle ----------------
def test_feldkorrektur_unter_konfidenz_min_wird_verworfen():
schwach = dict(GUTE_FELDANTWORT, konfidenz=0.5)
transport = LlmTransport([schwach])
hilfe = ki_hilfe(transport, konfidenz_min=0.7)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert transport.aufrufe == 1
assert eingriffe == []
assert treffer_liste[0].titel == "Pegasus Spiele: Next Station Berlin"
assert treffer_liste[0].verlag is None
def test_url_vorschlaege_unter_konfidenz_min_werden_verworfen():
vorschlaege = {
"folge_urls": ["/neuheiten-neue-spiele?start=6"],
"konfidenz": 0.3,
}
transport = LlmTransport([vorschlaege])
hilfe = ki_hilfe(transport)
urls = hilfe.folge_urls_bei_bedarf(0, SEITE_LEER_VIELE_LINKS, BASIS_URL, set())
assert urls == []
# ---------------- Struktur-Erkennung ----------------
def test_struktur_erkennung_nur_gleiche_domain_urls_werden_verfolgt():
"""Fremde Domains und Nicht-HTTP-Schemata werden hart herausgefiltert."""
gemischt = {
"folge_urls": [
"/neuheiten-neue-spiele?start=6", # gleiche Domain ✓
"https://www.spielbox.de/neuheiten?page=2", # gleiche Domain ✓
"https://boese.example.org/neuheiten", # fremde Domain ✗
"ftp://www.spielbox.de/liste", # Schema ✗
"/neuheiten-neue-spiele?start=6#oben", # Duplikat ✗
"javascript:zeigeListe()", # Schema ✗
],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
transport.antworten["https://www.spielbox.de/neuheiten-neue-spiele?start=6"] = _antwort(SEITE_MIT_TREFFERN)
transport.antworten["https://www.spielbox.de/neuheiten?page=2"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([gemischt]))
ergebnis = adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert "https://www.spielbox.de/neuheiten-neue-spiele?start=6" in gefolgt
assert all(u.startswith("https://www.spielbox.de") for u in gefolgt)
assert not any("boese" in u or u.startswith("ftp") for u in gefolgt)
assert len(ergebnis.treffer) == 2 # aus den beiden nachgeschobenen Seiten
assert ergebnis.ki_folge_urls == gefolgt
def test_struktur_erkennung_maximal_20_folge_urls():
"""Rate-Limit-Schutz: auch bei 30 Vorschlägen werden höchstens 20 verfolgt."""
vorschlaege = {
"folge_urls": [f"/liste?seite={i}" for i in range(30)],
"konfidenz": 0.9,
}
crawler_client, transport, _ = _crawler_client({BASIS_URL: SEITE_LEER_VIELE_LINKS})
for i in range(30):
transport.antworten[f"https://www.spielbox.de/liste?seite={i}"] = _antwort(SEITE_MIT_TREFFERN)
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([vorschlaege]))
adapter.sammle()
gefolgt = [u for u in transport.anfragen if u != BASIS_URL]
assert len(gefolgt) <= MAX_FOLGE_URLS
assert len(gefolgt) == 20
assert "https://www.spielbox.de/liste?seite=25" not in gefolgt
def test_struktur_erkennung_greift_nicht_bei_normalen_ergebnissen():
"""Drei sichere Treffer → keine Verdachts-Heuristik, kein LLM-Aufruf."""
seite = SEITE_MIT_TREFFERN * 3
crawler_client, transport, _ = _crawler_client({BASIS_URL: seite})
adapter = SpielboxQuelle(crawler_client)
adapter.ki_hilfe = ki_hilfe(LlmTransport([])) # würde bei Aufruf zählen
ergebnis = adapter.sammle()
assert len(ergebnis.treffer) == 3
assert len(transport.anfragen) == 1 # nur die Startseite
# ---------------- Feld-Korrektur ----------------
def test_feldkorrektur_nur_bei_unsicheren_treffern_url_bleibt_fix():
"""Sichere Treffer kosten kein Token; korrigiert wird nie die URL."""
transport = LlmTransport([GUTE_FELDANTWORT])
hilfe = ki_hilfe(transport)
sicher = TREFFER_SICHER()
unsicher = TREFFER_UNSICHER()
treffer_liste, eingriffe = hilfe.treffer_nachbessern([sicher, unsicher])
assert transport.aufrufe == 1 # nur der unsichere Treffer ging ans LLM
assert treffer_liste[0] is sicher # unangetastet
korrigiert = treffer_liste[1]
assert korrigiert.titel == "Next Station Berlin"
assert korrigiert.verlag == "Pegasus Spiele"
assert korrigiert.quellen_url == unsicher.quellen_url # URL bleibt fix
assert len(eingriffe) == 1
protokoll = eingriffe[0]
assert protokoll["modell"] == "test-modell"
assert protokoll["konfidenz"] == 0.9
assert protokoll["korrigiert"] == {
"titel": "Next Station Berlin", "verlag": "Pegasus Spiele",
}
assert protokoll["vorher"]["titel"] == "Pegasus Spiele: Next Station Berlin"
def test_feldkorrektur_ohne_aenderung_erzeugt_keinen_eingriff():
identisch = {
"titel": "Pegasus Spiele: Next Station Berlin",
"verlag": None, "autor": None, "konfidenz": 0.9, "begruendung": "passt",
}
transport = LlmTransport([identisch])
hilfe = ki_hilfe(transport)
treffer_liste, eingriffe = hilfe.treffer_nachbessern([TREFFER_UNSICHER()])
assert eingriffe == []
assert treffer_liste[0].quellen_url == "https://www.spielbox.de/x/1"
# ---------------- Sync-Integration + Audit ----------------
class StatischeQuelle:
"""Adapter-Ersatz mit fester Trefferliste (Service-Tests ohne Netz)."""
def __init__(self, name: str = "spielbox", treffer: list[QuellenTreffer] | None = None):
self.name = name
self.anzeigename = name.title()
self.start_urls = (f"https://{name}.example.org/liste",)
self._treffer = treffer if treffer is not None else []
def __call__(self, client):
return self
def sammle(self) -> SammelErgebnis:
return SammelErgebnis(treffer=list(self._treffer), seiten=1)
def _ohne_netz_client() -> WebQuellenClient:
return WebQuellenClient(transport=None, schlaf=lambda s: None, uhr=FakeUhr())
class FakeAudit:
def __init__(self):
self.eintraege: list[tuple] = []
def log_sync(self, actor, action, objekt_typ, objekt_id, details, **kwargs):
self.eintraege.append((actor, action, objekt_typ, objekt_id, details))
def test_sync_auditert_ki_eingriffe_mit_modell_und_konfidenz():
"""End-to-End: Crawler-Seite mit unsicherem Treffer → KI-Korrektur → Audit.
„Ringträger“ ohne Doppelpunkt: der Parser findet keinen Verlag → Treffer
ist unsicher → das LLM darf titel/verlag korrigieren.
"""
seite = (
'<div class="item"><div class="page-header"><h2>'
'<a href="/x/1">Ringträger</a></h2></div>'
"<p><strong>15.08.2026</strong> - Text.</p></div>"
)
crawler_client, _, _ = _crawler_client({BASIS_URL: seite})
audit = FakeAudit()
fabrik = _session_factory()
service = WebQuellenSyncService(
fabrik,
adapter_klassen=(SpielboxQuelle,),
client_fabrik=lambda validatoren: crawler_client,
ki_fabrik=lambda: ki_hilfe(LlmTransport([GUTE_FELDANTWORT])),
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "https://llm.example.test/v1",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
with fabrik() as db:
eintrag = db.query(Neuheit).one()
assert eintrag.titel == "Next Station Berlin"
assert eintrag.verlag == "Pegasus Spiele"
assert eintrag.erscheinungsjahr == 2026
assert len(audit.eintraege) == 1
actor, action, objekt_typ, objekt_id, details = audit.eintraege[0]
assert action == "korrigiert" and objekt_typ == "neuheiten_ki_crawler"
assert objekt_id == "spielbox"
assert details["modell"] == "test-modell"
assert details["eingriffe"][0]["konfidenz"] == 0.9
assert details["eingriffe"][0]["korrigiert"]["verlag"] == "Pegasus Spiele"
def _session_factory():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from redaktionskern.db import Base
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
fabrik = sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
fabrik.engine_zum_aufraeumen = engine # type: ignore[attr-defined]
return fabrik
def test_sync_ohne_ki_eingriff_schreibt_kein_audit():
audit = FakeAudit()
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=None,
audit=audit,
)
zeilen = service.laufe()
assert zeilen[0].neu == 1
assert audit.eintraege == []
def test_ki_ausfall_blockiert_den_sync_nie():
"""KI-Fabrik crasht → Sync läuft trotzdem komplett klassisch."""
def kaputte_fabrik():
raise RuntimeError("LLM nicht erreichbar")
service = WebQuellenSyncService(
_session_factory(),
adapter_klassen=(StatischeQuelle("spielbox", [TREFFER_SICHER()]),),
client_fabrik=lambda v: _ohne_netz_client(),
ki_fabrik=kaputte_fabrik,
umgebung={
"SPIELE_NEUHEITEN_KI_AKTIV": "1",
"SPIELE_NEUHEITEN_KI_BASIS_URL": "u",
"SPIELE_NEUHEITEN_KI_API_KEY": "k",
"SPIELE_NEUHEITEN_KI_MODELL": "m",
},
)
zeilen = service.laufe()
assert zeilen[0].neu == 1 and zeilen[0].fehlermeldung is None
@pytest.fixture
def session_factory():
fabrik = _session_factory()
yield fabrik
fabrik.engine_zum_aufraeumen.dispose() # type: ignore[attr-defined]