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.
This commit is contained in:
Flo Hartmann
2026-08-23 01:07:18 +00:00
parent c6f9b19f6b
commit f27940db8e
7 changed files with 1321 additions and 7 deletions

View File

@@ -65,6 +65,12 @@ Beim ersten Start wird automatisch ein Admin-Konto angelegt:
| `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_NEUHEITEN_KI_AKTIV` | `0` | Optionale LLM-Hilfsstufe des Web-Quellen-Crawlers an (`1`) oder aus (`0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | *(leer)* | Basis-URL einer OpenAI-kompatiblen Chat-Completions-API, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | *(leer)* | API-Key, wird als `Authorization: Bearer …` gesendet |
| `SPIELE_NEUHEITEN_KI_MODELL` | *(leer)* | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | `0.7` | Mindest-Konfidenz; darunter wird das KI-Ergebnis verworfen und der klassische Parser-Befund behalten |
| `SPIELE_NEUHEITEN_KI_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_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`) |
@@ -331,6 +337,44 @@ Autor, Erscheinungsjahr, Status, aktualisiert am):
- Jeder Titel verlinkt direkt auf den BGG-Eintrag.
- Sync-Steuerung nur für Rolle Admin/Redakteur.
### Web-Quellen-Crawler mit optionaler KI-Hilfsstufe
Neben BGG crawlt das Plugin zusätzlich redaktionell gepflegte Web-Quellen
(spielbox.de, brettspielbox.de, spiel-essen.de, cliquenabend.de) über je einen
Adapter in `plugins/neuheiten/quellen/` — mit Rate-Limit (1 Request/s),
Retry/Backoff, konditionalen Requests (ETag/304), Fehler-Isolation pro Quelle
und Duplikat-Gate gegen die BGG-Liste. Der Crawler arbeitet standardmäßig rein
regelbasiert.
Optional kann eine **LLM-Hilfsstufe** (`plugins/neuheiten/quellen/ki_hilfe.py`)
zugeschaltet werden — konsistent zur Dedup-KI über die
`SPIELE_NEUHEITEN_KI_*`-Variablen (siehe Tabelle oben). Das LLM wird nur in
zwei Situationen genutzt:
- **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig wenige
Einträge (0 Treffer; 12 Treffer auf einer sehr linkreichen Seite), darf das
LLM aus dem gelieferten HTML Pagination-/Filter-Folge-URLs vorschlagen.
Der Vorschlag wird im Code hart gefiltert: nur URLs derselben Domain wie die
geparste Seite, maximal 20 je Seite, bereits besuchte/geplante URLs werden
übersprungen, nicht erreichbare Vorschläge brechen den Lauf nicht ab —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
- **Feld-Extraktion:** Einzelne Treffer sind unsicher (fehlender Verlag oder
verdächtiger/generischer Titel). Nur solche Treffer gehen ans LLM, das
höchstens `titel`, `verlag` und `autor` korrigiert — die URL ist strukturell
nicht änderbar.
Unter der Mindest-Konfidenz (`SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN`,
Standard 0.7) wird jedes KI-Ergebnis verworfen; der klassische Parser-Befund
bleibt dann unverändert bestehen.
- **Fallback-Pflicht:** Standardmäßig aus (`SPIELE_NEUHEITEN_KI_AKTIV=0`).
Ohne vollständige Konfiguration oder bei jedem Fehler (Timeout,
Server-Störung nach genau einem Retry, kaputtes JSON) läuft EXAKT der
klassische Crawler — ein KI-Fehler kann den Sync nie blockieren.
- **Audit:** KI-Eingriffe eines Sync-Laufs landen gebündelt im audit-log
(`korrigiert` / `neuheiten_ki_crawler`) mit Quelle, Modell, Konfidenz und
den jeweils korrigierten Feldern sowie den verfolgten KI-Folge-URLs.
## Plugin „dedup“ (implementiert)
Dedup-Prüfungen bei Import und händigem Eintrag. Die Prüf-Logik liegt

View File

@@ -340,12 +340,16 @@ class NeuheitenPlugin(BasePlugin):
def _quellen_sync_ausfuehren(self) -> list[QuellenLaufZeile]:
"""Ein Lauf über alle aktiven Web-Quellen (Fehler pro Quelle isoliert)."""
dedup = (
self.context.registry.get("dedup")
registry = (
self.context.registry
if self.context is not None and self.context.registry is not None
else None
)
service = WebQuellenSyncService(self.context.session_factory, dedup=dedup)
dedup = registry.get("dedup") if registry is not None else None
audit = registry.get("audit-log") if registry is not None else None
service = WebQuellenSyncService(
self.context.session_factory, dedup=dedup, audit=audit
)
return service.laufe()
def _quellen_sync_job(self) -> None:

View File

@@ -21,6 +21,7 @@ die Testsuite führt keine echten Netzwerkaufrufe durch.
"""
from __future__ import annotations
import logging
import re
import time
import xml.etree.ElementTree # noqa: F401 (dokumentiert: keine XML-Nutzung hier)
@@ -31,6 +32,10 @@ from typing import ClassVar
import httpx
from rapidfuzz import fuzz
from .ki_hilfe import KiHilfe
_logger = logging.getLogger(__name__)
#: Freundlicher User-Agent für alle Redaktions-Crawler (Courtesy-Regeln).
USER_AGENT = "SpieleRedaktionBot/1.0 (Redaktions-Tool; Kontakt siehe Repo)"
@@ -76,6 +81,12 @@ class SammelErgebnis:
#: True = mindestens eine Seite kam per 304 als unverändert zurück;
#: der Lauf wurde dann abgekürzt (Quelle übersprungen, kein Fehler).
unveraendert: bool = False
#: KI-Feldkorrekturen dieses Laufs (Protokoll fürs Audit-Log); leer bei
#: rein regelbasiertem Lauf.
ki_eingriffe: list[dict] = field(default_factory=list)
#: Von der KI vorgeschlagene und tatsächlich verfolgte Folge-URLs
#: (Struktur-Erkennung); leer bei rein regelbasiertem Lauf.
ki_folge_urls: list[str] = field(default_factory=list)
def jahr_aus_datumsangabe(angabe: str | None) -> int | None:
@@ -236,18 +247,40 @@ class QuellenAdapter:
def __init__(self, client: WebQuellenClient) -> None:
self.client = client
#: Optionale LLM-Hilfsstufe (`ki_hilfe.KiHilfe` oder gleiches Protokoll).
#: None (Standard) = rein regelbasiert; KI-Fehler blockieren den Lauf nie.
self.ki_hilfe: KiHilfe | None = None
def sammle(self) -> SammelErgebnis:
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS)."""
"""Crawlt die Quelle: Startseiten + gefundene Folge-Links (BFS).
Mit gesetzter `ki_hilfe`: Bei verdächtig leer geparsten Seiten darf
das LLM Pagination-/Filter-Folge-URLs vorschlagen (nur gleiche
Domain, max. 20), unsichere Treffer werden per LLM nachgebessert
(nur titel/verlag/autor, nie die URL). Jeder KI-Fehler fällt auf den
klassischen Befund zurück.
"""
ergebnis = SammelErgebnis()
warteschlange = list(self.start_urls)
besucht: set[str] = set()
ki_vorschlaege: set[str] = set()
while warteschlange and len(besucht) < self.max_seiten:
url = warteschlange.pop(0)
if url in besucht:
continue
besucht.add(url)
antwort = self.client.hole(url)
try:
antwort = self.client.hole(url)
except Exception:
if url in ki_vorschlaege:
# Eine nicht erreichbare KI-Suggestion darf den klassischen
# Lauf nie abbrechen — überspringen statt Fehler.
_logger.warning(
"KI-vorgeschlagene URL nicht erreichbar (%s) — übersprungen.",
url,
)
continue
raise
if antwort.status_code == 304:
# Unverändert → Quelle überspringen (kein Fehler).
ergebnis.unveraendert = True
@@ -258,8 +291,48 @@ class QuellenAdapter:
for link in folge_links:
if link not in besucht:
warteschlange.append(link)
if self.ki_hilfe is not None:
for link in self._ki_folge_urls(treffer, antwort.content, url, besucht):
if link not in besucht and link not in warteschlange:
warteschlange.append(link)
ki_vorschlaege.add(link)
ergebnis.ki_folge_urls.append(link)
if self.ki_hilfe is not None:
self._ki_nachbessern(ergebnis)
return ergebnis
def _ki_folge_urls(
self,
treffer: list[QuellenTreffer],
inhalt: bytes,
url: str,
besucht: set[str],
) -> list[str]:
"""Struktur-Erkennung der Hilfsstufe (best effort — wirft nicht)."""
try:
return self.ki_hilfe.folge_urls_bei_bedarf(
len(treffer), inhalt, url, besucht
)
except Exception:
_logger.exception(
"KI-Struktur-Erkennung fehlgeschlagen (%s) — klassischer Lauf bleibt.",
url,
)
return []
def _ki_nachbessern(self, ergebnis: SammelErgebnis) -> None:
"""Feld-Korrektur unsicherer Treffer (best effort — wirft nicht)."""
try:
korrigiert, eingriffe = self.ki_hilfe.treffer_nachbessern(ergebnis.treffer)
except Exception:
_logger.exception(
"KI-Feldkorrektur fehlgeschlagen — klassischer Parserbefund bleibt."
)
return
if korrigiert is not None:
ergebnis.treffer = korrigiert
ergebnis.ki_eingriffe = eingriffe or []
def seite_verarbeiten(
self, inhalt: bytes, url: str
) -> tuple[list[QuellenTreffer], list[str]]:

View File

@@ -0,0 +1,575 @@
"""Optionale LLM-Hilfsstufe für den Web-Quellen-Crawler des Neuheiten-Plugins.
Der Crawler (`basis.py` + Adapter) bleibt maßgeblich und arbeitet rein
regelbasiert. Das LLM wird ausschließlich in zwei Situationen eingeschaltet:
1. **Struktur-Erkennung:** Liefert der Regel-Parser keine oder verdächtig
wenige Einträge (0 Treffer, oder wenige Treffer auf einer sehr link-
reichen Seite), darf das LLM Pagination-/Filter-Folge-URLs aus dem
gelieferten HTML vorschlagen. Der Vorschlag wird im Code hart gefiltert:
nur URLs derselben Domain wie die geparste Seite, maximal 20 je Seite —
Schutz vor Rate-Limit-Überlastung und Domain-Ausbruch.
2. **Feld-Extraktion:** Ein einzelner Treffer ist *unsicher* (fehlender
Verlag oder verdächtiger Titel). Dann darf das LLM höchstens `titel`,
`verlag` und `autor` korrigieren — niemals die URL.
Konfiguration konsistent zur dedup-KI über Umgebungsvariablen:
| Variable | Bedeutung |
|----------|-----------|
| `SPIELE_NEUHEITEN_KI_AKTIV` | `1` = Hilfsstufe an (Standard `0`) |
| `SPIELE_NEUHEITEN_KI_BASIS_URL` | Basis-URL, z. B. `https://api.openai.com/v1` |
| `SPIELE_NEUHEITEN_KI_API_KEY` | API-Key (wird als Bearer gesendet) |
| `SPIELE_NEUHEITEN_KI_MODELL` | Modellname, z. B. `gpt-4o-mini` |
| `SPIELE_NEUHEITEN_KI_KONFIDENZ_MIN` | Mindest-Konfidenz (Standard `0.7`) |
| `SPIELE_NEUHEITEN_KI_TIMEOUT_SEKUNDEN` | Timeout je Aufruf (Standard `20`) |
**Fallback-Pflicht:** Ohne Konfiguration oder bei jedem Fehler läuft EXAKT
der klassische Crawler weiter — ein KI-Fehler blockiert den Sync nie.
Ergebnisse unterhalb der Mindest-Konfidenz werden verworfen; der klassische
Parser-Befund bleibt dann unverändert bestehen.
Schnittstelle: OpenAI-kompatible Chat-Completions-API via httpx;
Netzwerk-/Server-Fehler werden genau einmal wiederholt (insgesamt zwei
Versuche), Antwort-Parsing-Fehler nicht.
"""
from __future__ import annotations
import dataclasses
import json
import logging
import os
import re
from dataclasses import dataclass
from typing import Any, Mapping
from urllib.parse import urljoin, urlsplit
import httpx
_logger = logging.getLogger("plugins.neuheiten")
#: 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
#: Maximalzahl der KI-vorgeschlagenen Folge-URLs je Seite (Rate-Limit-Schutz).
MAX_FOLGE_URLS = 20
#: Obergrenze des HTML-Ausschnitts, der pro Struktur-Anfrage ans LLM geht.
HTML_MAX_ZEICHEN = 12000
# ---------------- Verdachts-Heuristiken (wann hilft das LLM?) ----------------
#: Weniger Treffer als das gilt als „verdächtig wenig“ …
STRUKTUR_MIN_TREFFER = 3
#: … aber nur, wenn die Seite zugleich sehr linkreich ist (Indiz für eine
#: nicht erkannte Liste/Pagination statt einer wirklich leeren Seite).
STRUKTUR_LINK_SCHWELLE = 30
_LINK_MUSTER = re.compile(r"<a\s", re.IGNORECASE)
#: Generische Navigations-/Platzhaltertexte, die als Spieltitel verdächtig sind.
GENERISCHE_TITEL = frozenset(
{
"hier", "mehr", "mehr infos", "details", "weiterlesen", "weiter",
"klick", "klick hier", "link", "produkt", "neuheit", "unbekannt",
"titel", "n/a", "-", "",
}
)
def _als_text(inhalt: bytes | str) -> str:
if isinstance(inhalt, bytes):
return inhalt.decode("utf-8", errors="replace")
return inhalt
def titel_ist_verdaechtig(titel: str | None) -> bool:
"""True bei fehlendem, zu kurzem oder generischem Navigationstext."""
if not titel or not titel.strip():
return True
gekuerzt = titel.strip()
if len(gekuerzt) < 3:
return True
return gekuerzt.rstrip(".!? ").lower() in GENERISCHE_TITEL
def treffer_ist_unsicher(treffer: Any) -> bool:
"""Ein Treffer ist unsicher, wenn der Verlag fehlt oder der Titel verdächtig ist.
Nur solche Treffer gehen in die KI-Feld-Extraktion; sichere Treffer
kosten kein Token und bleiben garantiert unangetastet.
"""
verlag = getattr(treffer, "verlag", None)
if not verlag or not str(verlag).strip():
return True
return titel_ist_verdaechtig(getattr(treffer, "titel", None))
def struktur_verdaechtig(anzahl_treffer: int, inhalt: bytes | str) -> bool:
"""True, wenn der Parser keine oder verdächtig wenige Einträge lieferte.
0 Treffer sind immer verdächtig. 12 Treffer nur dann, wenn die Seite
sehr viele Links enthält — sonst ist sie schlicht klein/leer.
"""
if anzahl_treffer <= 0:
return True
if anzahl_treffer >= STRUKTUR_MIN_TREFFER:
return False
links = len(_LINK_MUSTER.findall(_als_text(inhalt)))
return links > STRUKTUR_LINK_SCHWELLE
# ---------------- Konfiguration ----------------
@dataclass(frozen=True)
class KiKonfiguration:
"""Effektive Konfiguration der KI-Hilfsstufe (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 führen nicht zum Fehler, sondern zum Standardwert —
die Hilfsstufe darf den Crawler niemals blockieren.
"""
env = os.environ if quelle is None else quelle
aktiv = env.get("SPIELE_NEUHEITEN_KI_AKTIV", "0").strip() == "1"
try:
konfidenz_min = float(env.get("SPIELE_NEUHEITEN_KI_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_NEUHEITEN_KI_TIMEOUT_SEKUNDEN", ""))
except ValueError:
timeout = STANDARD_TIMEOUT
timeout = max(1.0, timeout)
return KiKonfiguration(
aktiv=aktiv,
basis_url=env.get("SPIELE_NEUHEITEN_KI_BASIS_URL", "").strip().rstrip("/"),
api_key=env.get("SPIELE_NEUHEITEN_KI_API_KEY", "").strip(),
modell=env.get("SPIELE_NEUHEITEN_KI_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 und begleitenden Text; None, wenn nichts
Sinnvolles übrig bleibt.
"""
if not isinstance(text, str):
return None
text = text.strip()
if not text:
return None
versuche = [text]
versuche.extend(m.group(1).strip() for m in _FENCE_MUSTER.finditer(text))
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 _normalisiere_konfidenz(daten: Mapping[str, Any]) -> float | None:
"""Konfidenz aus der Antwort; Prozent-Skalen werden toleriert."""
try:
konfidenz = float(daten.get("konfidenz"))
except (TypeError, ValueError):
return None
if 10.0 <= konfidenz <= 100.0: # 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
return round(konfidenz, 4)
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_url_vorschlaege(daten: Any, seiten_url: str) -> dict | None:
"""Prüft die Struktur-Antwort und filtert hart auf gleiche Domain + max 20.
Erwartet `{folge_urls: [...], konfidenz: float}`. Relative URLs werden
gegen die Seiten-URL aufgelöst; fremde Domains, Nicht-HTTP-Schemata und
Duplikate (inkl. Fragment-Unterschiede) werden verworfen — auch wenn das
LLM sie vorschlägt.
"""
if not isinstance(daten, dict):
return None
roh = daten.get("folge_urls")
if not isinstance(roh, list):
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
basis_domain = urlsplit(seiten_url).netloc.lower()
if not basis_domain:
return None
gefiltert: list[str] = []
gesehen: set[str] = set()
for eintrag in roh:
if not isinstance(eintrag, str) or not eintrag.strip():
continue
absolut = urljoin(seiten_url, eintrag.strip())
teile = urlsplit(absolut)
if teile.scheme not in ("http", "https"):
continue
if teile.netloc.lower() != basis_domain:
continue
ohne_fragment = absolut.split("#", 1)[0]
if ohne_fragment in gesehen:
continue
gesehen.add(ohne_fragment)
gefiltert.append(ohne_fragment)
if len(gefiltert) >= MAX_FOLGE_URLS:
break
return {"folge_urls": gefiltert, "konfidenz": konfidenz}
def validiere_feld_antwort(daten: Any) -> dict | None:
"""Prüft die Feld-Antwort gegen das Schema (titel/verlag/autor/konfidenz).
Die URL ist bewusst kein Teil des Schemas — eine Korrektur kann sie
strukturell nie ändern.
"""
if not isinstance(daten, dict):
return None
titel = daten.get("titel")
if not isinstance(titel, str) or not titel.strip():
return None
konfidenz = _normalisiere_konfidenz(daten)
if konfidenz is None:
return None
begruendung = daten.get("begruendung")
if not isinstance(begruendung, str):
begruendung = ""
return {
"titel": titel.strip(),
"verlag": _optional_text(daten.get("verlag")),
"autor": _optional_text(daten.get("autor")),
"konfidenz": konfidenz,
"begruendung": begruendung.strip(),
}
# ---------------- Prompt ----------------
_SYSTEM_STRUKTUR = (
"Du hilfst einem Crawler einer Spielemagazin-Redaktion, Brettspiel-"
"Neuheitenlisten im Web zu finden. Der Regel-Parser hat die folgende "
"Seite fast leer geparst — vermutlich nutzt die Liste Pagination, "
"Archiv- oder Filter-Links. Identifiziere aus dem HTML die URLs, unter "
"denen weitere Neuheiten-Einträge zu erwarten sind. Antworte "
"AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt diesem Schema:\n"
'{"folge_urls": ["<absolute oder relative URL>", …], '
'"konfidenz": <float 0.0-1.0>}\n'
f"Höchstens {MAX_FOLGE_URLS} URLs und nur Links derselben Domain wie die "
"Seiten-URL. Wenn du nichts Sinnvolles findest, liefere eine leere Liste "
"mit niedriger Konfidenz. Kein weiterer Text, keine Code-Fences."
)
_SYSTEM_FELDER = (
"Du assistierst einer Spielemagazin-Redaktion beim Parsen von Brettspiel-"
"Neuheiten. Der folgende Treffer eines regelbasierten Crawlers ist "
"unsicher. Bereinige die Felder: trenne „Verlag: Titel“-Muster, entferne "
"Navigationstext und ergänze den Verlag, falls er sicher erkennbar ist. "
"Korrigiere ausschließlich titel, verlag und autor — ändere niemals URLs "
"und erfinde nichts. Ein Feld, das du nicht sicher bestimmen kannst, "
"bleibt null. Antworte AUSSCHLIESSLICH mit einem JSON-Objekt nach exakt "
"diesem Schema:\n"
'{"titel": "<bereinigter Titel>", "verlag": "<Verlagsname oder null>", '
'"autor": "<Autor oder null>", "konfidenz": <float 0.0-1.0>, '
'"begruendung": "<kurzer deutscher Satz>"}\n'
"Kein weiterer Text, keine Code-Fences."
)
def baue_struktur_prompt(seiten_url: str, html_text: str) -> str:
"""User-Nachricht für die Struktur-Erkennung (gekürzter HTML-Ausschnitt)."""
ausschnitt = html_text[:HTML_MAX_ZEICHEN]
return (
f"Seiten-URL: {seiten_url}\n\n"
"HTML-Ausschnitt der geparsten Seite:\n"
f"{ausschnitt}"
)
def baue_feld_prompt(titel: str, verlag: str | None, autor: str | None) -> str:
"""User-Nachricht mit dem unsicheren Treffer (ohne URL — bleibt fix)."""
zeilen = [f"Unsicherer Treffer:", f"titel: {titel}"]
zeilen.append(f"verlag: {verlag if verlag else '(leer)'}")
zeilen.append(f"autor: {autor if autor else '(leer)'}")
zeilen.append(
"Gib die bereinigten Felder zurück; die quellen_url selbst bleibt "
"nicht veränderbar und ist nicht Teil der Aufgabe."
)
return "\n".join(zeilen)
# ---------------- Client ----------------
class KiHilfe:
"""OpenAI-kompatibler Chat-Completions-Client für die Crawler-Hilfsstufe.
Bewusst genauso defensiv wie der dedup-KI-Client: Alle öffentlichen
Methoden werfen nicht (Ausnahmen werden intern geschluckt) — ohne
Konfiguration oder bei jedem Problem greift exakt der klassische
Crawler-Befund. Transport 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()
# ---------- Struktur-Erkennung ----------
def folge_urls_bei_bedarf(
self,
anzahl_treffer: int,
inhalt: bytes | str,
seiten_url: str,
bereits_geprueft: set[str] | None = None,
) -> list[str]:
"""Pagination-/Filter-URLs bei verdächtig leerem Parser-Ergebnis.
Prüft selbst die Verdachts-Heuristik (sonst []), fragt dann das LLM
und filtert dessen Vorschläge hart: gleiche Domain, max. 20, bereits
geplante/besuchte URLs raus, Konfidenz unter der Schwelle → alles
verwerfen. Fehler → [] (klassischer Lauf bleibt).
"""
try:
if not struktur_verdaechtig(anzahl_treffer, inhalt):
return []
if not self._konfiguration.vollstaendig:
return []
nutzernachricht = baue_struktur_prompt(seiten_url, _als_text(inhalt))
daten = self._chat(_SYSTEM_STRUKTUR, nutzernachricht)
ergebnis = validiere_url_vorschlaege(daten, seiten_url)
if ergebnis is None:
return []
if ergebnis["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: URL-Vorschläge verworfen (Konfidenz %.2f < %.2f).",
ergebnis["konfidenz"], self._konfiguration.konfidenz_min,
)
return []
bekannt = bereits_geprueft or set()
return [
url for url in ergebnis["folge_urls"] if url not in bekannt
]
except Exception:
_logger.exception(
"neuheiten/KI: Struktur-Erkennung fehlgeschlagen — klassischer Lauf bleibt."
)
return []
# ---------- Feld-Extraktion ----------
def treffer_nachbessern(
self, treffer_liste: list
) -> tuple[list, list[dict]]:
"""Korrigiert ausschließlich unsichere Treffer (titel/verlag/autor).
Sichere Treffer werden nicht angefasst und kosten kein Token. Die
quellen_url bleibt immer unangetastet (kein Teil des Antwort-Schemas).
Rückgabe: (neue Trefferliste, Eingriffs-Protokoll fürs Audit-Log mit
Modell, Konfidenz und den jeweils korrigierten Feldern).
"""
if not treffer_liste:
return [], []
if not self._konfiguration.vollstaendig:
return list(treffer_liste), []
neue_liste: list = []
eingriffe: list[dict] = []
for einzel in treffer_liste:
if not treffer_ist_unsicher(einzel):
neue_liste.append(einzel)
continue
ergebnis = self._einzeln_korrigieren(einzel)
if ergebnis is None:
neue_liste.append(einzel) # klassischer Befund bleibt
continue
korrigiert, antwort = ergebnis
if korrigiert is einzel: # nichts tatsächlich geändert
neue_liste.append(einzel)
continue
neue_liste.append(korrigiert)
eingriffe.append(self._eingriff_protokoll(einzel, korrigiert, antwort))
return neue_liste, eingriffe
def _einzeln_korrigieren(self, treffer: Any) -> tuple[Any, dict] | None:
"""Eine Feld-Korrektur; Rückgabe (neuer Treffer, validierte Antwort).
None bzw. der unveränderte Treffer bedeutet: klassischer Befund bleibt.
"""
try:
nutzernachricht = baue_feld_prompt(
getattr(treffer, "titel", "") or "",
getattr(treffer, "verlag", None),
getattr(treffer, "autor", None),
)
daten = self._chat(_SYSTEM_FELDER, nutzernachricht)
antwort = validiere_feld_antwort(daten)
if antwort is None:
return None
if antwort["konfidenz"] < self._konfiguration.konfidenz_min:
_logger.info(
"neuheiten/KI: Feldkorrektur verworfen (Konfidenz %.2f < %.2f).",
antwort["konfidenz"], self._konfiguration.konfidenz_min,
)
return None
felder: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
alter_wert = getattr(treffer, feld, None)
alter_norm = (alter_wert or "").strip() or None
neu_norm = (antwort[feld] or "").strip() or None
if neu_norm != alter_norm:
felder[feld] = neu_norm
if not felder:
return treffer, antwort # nichts tatsächlich korrigiert
try:
return dataclasses.replace(treffer, **felder), antwort
except Exception:
return None
except Exception:
_logger.exception(
"neuheiten/KI: Feldkorrektur fehlgeschlagen — klassischer Befund bleibt."
)
return None
def _eingriff_protokoll(self, alt: Any, neu: Any, antwort: dict) -> dict:
"""Audit-Zeile: was wurde korrigiert, mit welchem Modell/welcher Konfidenz."""
korrigiert: dict[str, str | None] = {}
for feld in ("titel", "verlag", "autor"):
vorher = getattr(alt, feld, None)
nachher = getattr(neu, feld, None)
if vorher != nachher:
korrigiert[feld] = nachher
return {
"quellen_url": getattr(alt, "quellen_url", ""),
"modell": self.modell,
"konfidenz": antwort["konfidenz"],
"begruendung": antwort.get("begruendung", ""),
"korrigiert": korrigiert,
"vorher": {f: getattr(alt, f, None) for f in korrigiert},
}
# ---------- Gemeinsamer Chat-Completions-Aufruf ----------
def _chat(self, system: str, nutzer: str) -> dict | None:
"""POST /chat/completions mit genau einem Retry; None bei jedem Problem.
Netzwerk-/Timeout-Fehler und zeitweilige Server-Störungen werden
genau einmal wiederholt (insgesamt zwei Versuche); andere 4xx und
kaputte Antworten nicht — dort hilft kein Retry.
"""
payload = {
"model": self._konfiguration.modell,
"temperature": 0,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": nutzer},
],
}
for versuch in range(1, VERSUCHE + 1):
try:
antwort = self._client.post("/chat/completions", json=payload)
except httpx.RequestError as exc:
_logger.warning(
"neuheiten/KI: Versuch %d/%d fehlgeschlagen (%s)",
versuch, VERSUCHE, exc,
)
continue
if antwort.status_code in RETRY_STATUS:
_logger.warning(
"neuheiten/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("neuheiten/KI: Anfrage abgelehnt (%s).", exc)
return None
try:
inhalte = antwort.json()["choices"][0]["message"]["content"]
except Exception as exc: # kaputtes Antwort-Layout
_logger.warning("neuheiten/KI: unbrauchbare Antwort (%s)", exc)
return None
return extrahiere_json(inhalte)
_logger.warning(
"neuheiten/KI: alle %d Versuche fehlgeschlagen — klassischer Befund.", VERSUCHE
)
return None

View File

@@ -32,6 +32,7 @@ from typing import Callable
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from .quellen.ki_hilfe import KiHilfe, lade_konfiguration as lade_ki_konfiguration
from .models import STATUS_NEUHEIT, Neuheit, QuellenStatus
from .quellen import (
ADAPTER_KLASSEN,
@@ -108,20 +109,27 @@ class WebQuellenSyncService:
dedup=None,
client_fabrik: Callable[[dict], WebQuellenClient] | None = None,
umgebung=None,
ki_fabrik: Callable[[], KiHilfe] | None = None,
audit=None,
) -> None:
self.session_factory = session_factory
self.adapter_klassen = adapter_klassen
self.dedup = dedup
self.audit = audit
self.umgebung = umgebung if umgebung is not None else os.environ
self._client_fabrik = client_fabrik or self._standard_client_fabrik
# KI-Hilfsstufe: Fabrik für Tests; ohne vollständige Konfiguration wird
# nie ein Client gebaut (und damit nie ein LLM-Aufruf getätigt).
self._ki_fabrik = ki_fabrik
# ---------- Gesamtdurchlauf ----------
def laufe(self) -> list[QuellenLaufZeile]:
zeilen: list[QuellenLaufZeile] = []
ki = self._ki_hilfe()
for klasse in self.adapter_klassen:
try:
zeile = self._eine_quelle(klasse)
zeile = self._eine_quelle(klasse, ki)
except Exception as exc: # letzte Verteidigungslinie pro Quelle
_logger.exception("Quellen-Sync fehlgeschlagen für %s", klasse.name)
zeile = QuellenLaufZeile(
@@ -141,7 +149,28 @@ class WebQuellenSyncService:
# ---------- Eine Quelle ----------
def _eine_quelle(self, klasse: type[QuellenAdapter]) -> QuellenLaufZeile:
def _ki_hilfe(self) -> KiHilfe | None:
"""KI-Hilfsstufe nur bei vollständiger Konfiguration (sonst None).
Die Aktivierungsprüfung gilt vor jeder injizierten Fabrik — ist die
Hilfsstufe aus oder unvollständig konfiguriert, wird nie ein Client
gebaut und damit nie ein LLM-Aufruf getätigt.
"""
try:
konfiguration = lade_ki_konfiguration(self.umgebung)
if not konfiguration.vollstaendig:
return None
if self._ki_fabrik is not None:
return self._ki_fabrik()
return KiHilfe(konfiguration)
except Exception:
# Auch ein kaputtes KI-Setup darf den Sync nie blockieren.
_logger.exception("neuheiten/KI: Hilfsstufe nicht verfügbar — klassischer Lauf.")
return None
def _eine_quelle(
self, klasse: type[QuellenAdapter], ki: KiHilfe | None = None
) -> QuellenLaufZeile:
zeile = QuellenLaufZeile(klasse.name, klasse.anzeigename)
zeile.url = klasse.start_urls[0] if klasse.start_urls else None
if not quelle_aktiv(klasse.name, self.umgebung):
@@ -152,6 +181,7 @@ class WebQuellenSyncService:
client = self._client_fabrik(self._validatoren_laden(klasse.name))
try:
adapter = klasse(client)
adapter.ki_hilfe = ki # None = rein regelbasiert (Fallback-Pflicht)
try:
ergebnis = adapter.sammle()
except Exception as exc:
@@ -160,6 +190,7 @@ class WebQuellenSyncService:
zeile.fehlermeldung = str(exc)[:300]
return zeile
zeile.seiten = ergebnis.seiten
self._ki_auditieren(klasse.name, ergebnis)
if ergebnis.unveraendert:
zeile.unveraendert = True
else:
@@ -263,6 +294,37 @@ class WebQuellenSyncService:
return True
return not ergebnis.hat_konflikte
# ---------- KI-Audit ----------
def _ki_auditieren(self, quelle: str, ergebnis) -> None:
"""KI-Eingriffe des Laufs ins audit-log schreiben (best effort).
Ein Eintrag je Quelle und Lauf mit Modell, Konfidenz, korrigierten
Feldern und den verfolgten KI-Folge-URLs. Ohne audit-log (Plugin
nicht geladen) oder bei Fehlern wird nur protokolliert — der Sync
wird nie blockiert.
"""
eingriffe = list(getattr(ergebnis, "ki_eingriffe", []) or [])
folge_urls = list(getattr(ergebnis, "ki_folge_urls", []) or [])
if not eingriffe and not folge_urls:
return
if self.audit is None:
_logger.info(
"Quelle %s: %d KI-Korrekturen, %d KI-Folge-URLs (kein audit-log geladen).",
quelle, len(eingriffe), len(folge_urls),
)
return
details = {
"quelle": quelle,
"modell": eingriffe[0].get("modell", "") if eingriffe else "",
"eingriffe": eingriffe,
"folge_urls": folge_urls,
}
try:
self.audit.log_sync(None, "korrigiert", "neuheiten_ki_crawler", quelle, details)
except Exception:
_logger.exception("neuheiten/KI: Audit-Eintrag konnte nicht geschrieben werden.")
# ---------- Statusprotokoll ----------
def _status_speichern(self, zeile: QuellenLaufZeile) -> None:

View File

@@ -59,6 +59,18 @@ jahr_aus_datumsangabe = quellen_basis.jahr_aus_datumsangabe
titel_aehnlichkeit = quellen_basis.titel_aehnlichkeit
titel_normalisieren = quellen_basis.titel_normalisieren
ki_hilfe_modul = _neuheiten.quellen.ki_hilfe
KiHilfe = ki_hilfe_modul.KiHilfe
KiKonfiguration = ki_hilfe_modul.KiKonfiguration
lade_ki_konfiguration = ki_hilfe_modul.lade_konfiguration
extrahiere_ki_json = ki_hilfe_modul.extrahiere_json
struktur_verdaechtig = ki_hilfe_modul.struktur_verdaechtig
treffer_ist_unsicher = ki_hilfe_modul.treffer_ist_unsicher
titel_ist_verdaechtig = ki_hilfe_modul.titel_ist_verdaechtig
validiere_url_vorschlaege = ki_hilfe_modul.validiere_url_vorschlaege
validiere_feld_antwort = ki_hilfe_modul.validiere_feld_antwort
MAX_FOLGE_URLS = ki_hilfe_modul.MAX_FOLGE_URLS
SpielboxQuelle = _neuheiten.quellen.spielbox.SpielboxQuelle
BrettspielboxQuelle = _neuheiten.quellen.brettspielbox.BrettspielboxQuelle
SpielEssenQuelle = _neuheiten.quellen.spielessen.SpielEssenQuelle

View File

@@ -0,0 +1,544 @@
"""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]