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

@@ -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: