integrity_scanner_fuer_stat.../scanner/ai_analyzer.py
Dieter Schlüter 989f5a933f feat: Inhaltsinventar in SQLite — KI-Inhaltsangabe je Objekt (URL + Hash)
Vereinheitlicht den bisherigen JSON-Ledger zu EINEM abfragbaren SQLite-Store
pro Site (data/content_inventory.db): jedes geprüfte Objekt mit Inhalts-Hash,
URL(s), Sicherheits-Verdikt UND neutraler KI-Inhaltsangabe.

- baseline.py: load/save_ai_ledger jetzt SQLite-gestützt (Dict-Schnittstelle
  bleibt → Analyzer unverändert). Tabellen objects + object_urls. Einmalige
  Migration eines vorhandenen ai_ledger.json → SQLite (.migrated). UPSERT mit
  first_seen-Erhalt; object_urls transaktional ersetzt. Neu: query_inventory,
  export_inventory_csv (beide migrieren failsafe).
- ai_analyzer.py: Antwort-Schema + Prompt um neutrales Feld 'description'
  erweitert (im selben Call, keine Extrakosten); _make_entry speichert es.
- __main__.py: neues Kommando 'inventory' (Übersicht, --search, --kind, --csv).
- Doku: README + Bedienungsanleitung.

Audio/Video sind im Schema (kind) vorbereitet (Phase 2: Extractor + Modalitäten).
bredelar.info real migriert: 245 Objekte (200 Bilder, 45 Texte).

Tests: 266 grün (+6: Round-Trip mit description, Migration, first_seen-Erhalt,
invalidate, Suche/CSV-Export, description landet im Ledger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 05:12:19 +02:00

709 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
KI-gestützte semantische Inhaltsanalyse via OpenRouter.
Ergänzung zum Integritäts-Kern, niemals dessen Ersatz. Erkennt problematische
*Inhalte ohne Link-Signal*: Pornografie, Propaganda, diffamierende/strafbare
Texte, versteckten Spam, thematisch unpassende Werbung, widersprüchliche
Aussagen — in Text und in Bildern (inkl. eingebettetem Text via OCR).
Kostengate: Jeder Kandidat bekommt einen Fingerprint (SHA-256). Ein bereits
geprüfter Fingerprint liegt mit seinem Verdikt im Ledger (data/ai_ledger.json)
→ Cache-Treffer → KEIN API-Call. Nur neue/geänderte Inhalte kosten etwas.
Robustheit: Fehlt der API-Key oder schlägt ein Call fehl, wird die Analyse
übersprungen und der Scan läuft unverändert weiter (graceful degradation).
KI darf den Kern-Scan nie brechen.
"""
import concurrent.futures
import json
import logging
import os
import threading
import time
from datetime import datetime, timezone
import requests
from .checker import fetch_asset_hashes
from .differ import normalize_text
logger = logging.getLogger(__name__)
_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# Erlaubte Kategorien, die das Modell zurückgeben darf.
_CATEGORIES = [
"clean", "pornography", "propaganda", "defamation_illegal",
"hidden_spam", "off_topic_commercial", "contradiction",
]
# Kategorie → Scoring-Schlüssel (Fallback-Punkte, falls cfg sie nicht liefert).
_CATEGORY_SCORE_KEY = {
"pornography": ("ai_pornography", 50),
"defamation_illegal": ("ai_defamation_illegal", 50),
"propaganda": ("ai_propaganda", 40),
"hidden_spam": ("ai_hidden_spam", 40),
"off_topic_commercial": ("ai_off_topic_commercial", 30),
"contradiction": ("ai_contradiction", 20),
}
_SEVERITY_ORDER = {"none": 0, "low": 1, "medium": 2, "high": 3}
# JSON-Schema für die strukturierte Modell-Antwort.
_RESPONSE_SCHEMA = {
"type": "json_schema",
"json_schema": {
"name": "content_verdict",
"strict": True,
"schema": {
"type": "object",
"properties": {
"description": {"type": "string"},
"category": {"type": "string", "enum": _CATEGORIES},
"severity": {"type": "string", "enum": ["none", "low", "medium", "high"]},
"confidence": {"type": "number"},
"explanation": {"type": "string"},
},
"required": ["description", "category", "severity", "confidence", "explanation"],
"additionalProperties": False,
},
},
}
_MODELS_URL = "https://openrouter.ai/api/v1/models"
# ---------------------------------------------------------------------------
# Adaptive model router (circuit-breaker + latency telemetry + /models pruning)
# ---------------------------------------------------------------------------
class ModelRouter:
"""Wählt programmatisch das beste Modell aus einer Kette: gesunde zuerst,
nach Latenz; rate-limited/ausgefallene Modelle bekommen einen Cooldown
(Circuit-Breaker) und werden so lange übersprungen. Tote Slugs werden über
den /models-Katalog failsafe aussortiert. Thread-sicher (für Parallelität)."""
def __init__(self, ai_cfg: dict, state: dict | None = None):
self._lock = threading.Lock()
self._threshold = ai_cfg.get("breaker_failure_threshold", 2)
self._cooldown = ai_cfg.get("breaker_cooldown_seconds", 120)
self._refresh = ai_cfg.get("refresh_models", True)
state = state or {}
self._models: dict = state.get("models", {}) or {}
self._catalog: dict = state.get("catalog", {"slugs": [], "fetched_at": None})
def state(self) -> dict:
with self._lock:
return {"models": self._models, "catalog": self._catalog}
def record_success(self, model: str, latency: float) -> None:
with self._lock:
m = self._models.setdefault(model, {})
m["failures"] = 0
m["open_until"] = 0
prev = m.get("latency")
m["latency"] = latency if prev is None else 0.7 * prev + 0.3 * latency
def record_failure(self, model: str) -> None:
with self._lock:
m = self._models.setdefault(model, {})
m["failures"] = m.get("failures", 0) + 1
m["last_failure"] = time.time()
if m["failures"] >= self._threshold:
m["open_until"] = time.time() + self._cooldown
logger.info("KI-Router: Breaker für %s offen (%.0fs Cooldown).",
model, self._cooldown)
def order(self, models: list[str]) -> list[str]:
"""Reiht die Kette: geschlossener Breaker zuerst, dann nach Latenz aufsteigend.
Tote Slugs werden entfernt. Gibt nie eine leere Liste zurück."""
with self._lock:
candidates = self._prune_locked(models)
now = time.time()
def sort_key(slug):
m = self._models.get(slug, {})
is_open = m.get("open_until", 0) > now
# Unbekannte Latenz → inf: auf dem Erstlauf bleibt die Config-Reihenfolge
# erhalten (free zuerst); ein bewährtes (gemessenes) Modell schlägt ein
# noch untestetes. sorted() ist stabil → Ties = Config-Reihenfolge.
latency = m.get("latency")
latency = latency if latency is not None else float("inf")
return (is_open, latency)
ordered = sorted(candidates, key=sort_key)
return ordered or list(models)
def _prune_locked(self, models: list[str]) -> list[str]:
slugs = self._catalog.get("slugs") or []
if not slugs:
return list(models) # kein Katalog → nicht filtern (failsafe)
keep = [m for m in models if m in slugs]
return keep or list(models) # nie alles wegfiltern
def refresh_catalog(self) -> None:
"""Einmal pro Lauf /models ziehen (24 h gecacht). Failsafe: blockiert nie."""
if not self._refresh:
return
fetched_at = self._catalog.get("fetched_at")
if fetched_at and self._catalog.get("slugs") and (time.time() - fetched_at) < 86400:
return # Cache frisch
try:
resp = requests.get(_MODELS_URL, timeout=15)
if resp.status_code == 200:
slugs = [m["id"] for m in resp.json().get("data", []) if m.get("id")]
if slugs:
with self._lock:
self._catalog = {"slugs": slugs, "fetched_at": time.time()}
logger.info("KI-Router: /models-Katalog aktualisiert (%d Modelle).", len(slugs))
except (requests.exceptions.RequestException, ValueError, KeyError) as exc:
logger.warning("KI-Router: /models nicht abrufbar (failsafe, ungefiltert): %s", exc)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict:
"""
Hash-gegate KI-Analyse über neue/geänderte Texte und Bilder.
Returns {"findings": [...], "checked": int, "cache_hits": int,
"api_calls": int, "skipped": str|None}.
Bricht nie mit einer Exception — bei Problemen wird "skipped" gesetzt.
"""
result = {"findings": [], "unchecked": [], "checked": 0, "cache_hits": 0,
"api_calls": 0, "skipped": None}
ai_cfg = cfg.get("ai_analysis", {})
if not ai_cfg.get("enabled"):
result["skipped"] = "deaktiviert"
return result
api_key = os.environ.get(ai_cfg.get("api_key_env", "OPENROUTER_API_KEY"), "")
if not api_key:
result["skipped"] = f"kein API-Key ({ai_cfg.get('api_key_env', 'OPENROUTER_API_KEY')})"
logger.warning("KI-Analyse übersprungen: %s", result["skipped"])
return result
router = ModelRouter(ai_cfg, bm.load_ai_router_state())
router.refresh_catalog() # einmal /models (24 h gecacht), failsafe
max_workers = max(1, int(ai_cfg.get("max_concurrency", 8)))
try:
ledger = bm.load_ai_ledger()
entries = ledger.setdefault("entries", {})
url_hashes = ledger.setdefault("url_hashes", {}) # URL→Byte-Hash-Erinnerung (Bilder)
dirty = False
# Ledger wird zwischengesichert (nach Textphase + im finally), damit bereits
# berechnete Verdikte einen späteren Hang/Abbruch überleben und nicht erneut
# bezahlt werden müssen.
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
try:
if ai_cfg.get("text", {}).get("enabled", True):
dirty |= _analyze_text(cfg, ai_cfg, api_key, snap, diff,
entries, result, router, executor)
if dirty:
bm.save_ai_ledger(ledger)
if ai_cfg.get("image", {}).get("enabled", True):
dirty |= _analyze_images(cfg, ai_cfg, api_key, snap, diff,
entries, result, router, executor, url_hashes)
# Audio/Video: vorbereitet, default aus (siehe _collect_audio/video_candidates).
finally:
if dirty:
bm.save_ai_ledger(ledger)
except Exception as exc: # pragma: no cover - Schutzschirm, darf Scan nie brechen
logger.error("KI-Analyse mit unerwartetem Fehler abgebrochen: %s", exc)
result["skipped"] = f"interner Fehler: {exc}"
# Deterministische Report-Reihenfolge (unabhängig von der Completion-Reihenfolge)
result["findings"].sort(key=lambda f: (f.get("kind", ""), f.get("url", ""), f.get("asset_url", "")))
result["unchecked"].sort(key=lambda u: (u.get("kind", ""), u.get("url", "")))
bm.save_ai_router_state(router.state())
return result
def score_ai_findings(ai_result: dict, cfg: dict) -> dict:
"""
Bewertet KI-Funde additiv. Das Level ist auf 'yellow' gedeckelt — AUSSER
schwerwiegende Kategorien (red_categories, default Pornografie/strafbar) lösen
bei hoher Schwere + hoher Konfidenz ROT aus. Die Punktsumme selbst ergibt nie
Rot; nur dieser kategoriebasierte Override tut das.
Returns {score, level, reasons, exit_code}.
"""
sc = cfg.get("scoring", {})
thr = cfg.get("thresholds", {"yellow": 20, "red": 60})
yellow = thr.get("yellow", 20)
score = 0
reasons: list[str] = []
for f in ai_result.get("findings", []):
score += (pts := _finding_points(f, sc))
conf = f.get("confidence", 0.0)
reasons.append(
f"KI [{f.get('kind', '?')}] {f.get('url', '?')}: "
f"{f.get('category', '?')} ({f.get('severity', '?')}, {conf:.0%}) (+{pts})"
)
# Deckelung auf gelb: auch bei Score ≥ rot-Schwelle bleibt es gelb.
if score >= yellow:
level, exit_code = "yellow", 1
else:
level, exit_code = "green", 0
# Ungeprüfte Inhalte: "konnte nicht prüfen" ≠ "sauber".
unchecked = ai_result.get("unchecked", [])
if unchecked:
reasons.append(
f"{len(unchecked)} Inhalt(e) konnten von der KI nicht geprüft werden "
"(Dienst nicht erreichbar) — bitte später erneut prüfen."
)
if cfg.get("ai_analysis", {}).get("unchecked_level") == "yellow" and level == "green":
level, exit_code = "yellow", 1
# Grave-Override: nur die schwersten Kategorien lösen ROT aus (Vorrang vor gelb).
ai_cfg = cfg.get("ai_analysis", {})
red_cats = set(ai_cfg.get("red_categories", []))
red_min_sev = _SEVERITY_ORDER.get(ai_cfg.get("red_min_severity", "high"), 3)
red_min_conf = ai_cfg.get("red_min_confidence", 0.9)
for f in ai_result.get("findings", []):
if (f.get("category") in red_cats
and _SEVERITY_ORDER.get(f.get("severity", "none"), 0) >= red_min_sev
and f.get("confidence", 0.0) >= red_min_conf):
level, exit_code = "red", 2
reasons.append(
f"Schwerwiegender KI-Fund ({f['category']}, {f.get('confidence', 0):.0%}) "
f"auf {f.get('url', '?')} → ROT"
)
break
return {"score": score, "level": level, "reasons": reasons, "exit_code": exit_code}
# ---------------------------------------------------------------------------
# Text analysis
# ---------------------------------------------------------------------------
def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor) -> bool:
"""Prüft neue/geänderte Seitentexte (parallel). Returns True wenn Ledger verändert."""
text_cfg = ai_cfg.get("text", {})
min_chars = text_cfg.get("min_chars", 200)
models = _models_for(text_cfg)
site_context = ai_cfg.get("site_context", "")
# Fingerprint je Seite (lokal, gratis). Geänderte/neue Seiten zuerst (Risiko).
changed = {pd["url"] for pd in diff.get("page_diffs", [])} | set(diff.get("new_internal_urls", []))
candidates: list[tuple[str, str, str]] = []
for url, page in snap.get("pages", {}).items():
if page.get("status") != 200:
continue
text = page.get("text", "") or ""
if len(text) < min_chars:
continue
candidates.append((url, text, _text_fingerprint(text, cfg)))
candidates.sort(key=lambda c: c[0] not in changed) # changed first (False < True)
# Nach Fingerprint gruppieren: identischer Text wird nur EINMAL klassifiziert,
# die Findings aber für alle betroffenen URLs emittiert (wie sequenziell).
fp_urls: dict[str, list[str]] = {}
fp_text: dict[str, str] = {}
order_fps: list[str] = []
for url, text, fp in candidates:
result["checked"] += 1
if fp not in fp_urls:
fp_urls[fp] = []
fp_text[fp] = text
order_fps.append(fp)
fp_urls[fp].append(url)
# Drossel NUR für den historischen Bestand; 0 = unbegrenzt. Geänderte/neue Seiten
# werden IMMER geprüft (Echtzeit-Schutz), ungeachtet des Limits.
backlog_limit = text_cfg.get("max_pages_per_scan", 0)
backlog_used = 0
tasks, miss_fps = [], []
for fp in order_fps:
entry = entries.get(fp)
if entry is not None:
for url in fp_urls[fp]:
result["cache_hits"] += 1
_maybe_finding(result, entry, fp, url, ai_cfg)
continue
is_changed = any(u in changed for u in fp_urls[fp])
if not is_changed and backlog_limit and backlog_used >= backlog_limit:
continue # Bestands-Drossel erreicht — nächster Scan holt den Rest nach
miss_fps.append(fp)
tasks.append((fp, _text_task(fp_text[fp], site_context, models, ai_cfg, api_key, router)))
if not is_changed:
backlog_used += 1
classified = _classify_batch(tasks, executor)
dirty = False
for fp in miss_fps:
res = classified.get(fp)
if res is None:
for url in fp_urls[fp]:
result["unchecked"].append({"kind": "text", "url": url})
continue
verdict, used_model = res
entry = _make_entry("text", fp_urls[fp][0], verdict, used_model)
entries[fp] = entry
result["api_calls"] += 1
dirty = True
for url in fp_urls[fp]:
_maybe_finding(result, entry, fp, url, ai_cfg)
return dirty
# ---------------------------------------------------------------------------
# Image analysis
# ---------------------------------------------------------------------------
def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor,
url_hashes) -> bool:
"""Prüft neue/geänderte Bilder (parallel). Returns True wenn Ledger verändert.
url_hashes ist die persistente URL→Byte-Hash-Erinnerung: eine bereits gesehene
Bild-URL wird NICHT erneut heruntergeladen (kein redundanter Fetch, keine doppelte
Analyse). Nur nie gesehene Bilder sowie geänderte/geflaggte werden geholt."""
img_cfg = ai_cfg.get("image", {})
models = _models_for(img_cfg)
site_context = ai_cfg.get("site_context", "")
current_img_urls = _all_image_urls(snap)
if not current_img_urls:
return False
changed = {pd["url"] for pd in diff.get("page_diffs", [])} | set(diff.get("new_internal_urls", []))
changed_img_urls = _all_image_urls(snap, only_pages=changed)
# Markierte Bilder erneut holen: ersetztes Bild → neue Bytes → Neubewertung
# (verhindert Dauer-Fehlalarm nach Bereinigung); unverändert → Cache-Treffer.
flagged_img_urls = {
e["url"] for e in entries.values()
if e.get("kind") == "image" and e.get("category", "clean") != "clean"
and not e.get("dismissed") and e.get("url") in current_img_urls
}
# Geänderte/geflaggte Bilder: IMMER (erneut) prüfen (Echtzeit-Schutz, nie gedeckelt).
# Abdeckung: nur noch NIE gesehene URLs (nicht in url_hashes) — schon bekannte Bilder
# werden nicht erneut geladen (Hashwert gemerkt). Volle Abdeckung baut sich so EINMALIG
# auf; Duplikate (gleiche Bytes an mehreren URLs) werden je URL höchstens einmal geholt
# und nie doppelt analysiert (Ledger ist nach Byte-Hash indiziert). Drossel 0 = unbegrenzt.
priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls)
coverage = sorted((current_img_urls - set(url_hashes)) - set(priority))
backlog_limit = img_cfg.get("max_images_per_scan", 0)
if backlog_limit and backlog_limit > 0:
coverage = coverage[:backlog_limit]
fetch_urls = priority + coverage
if not fetch_urls:
return False
# url → Seite(n) für den Report
url_to_page = _image_url_sources(snap)
hashes = fetch_asset_hashes(fetch_urls, timeout=cfg.get("request_timeout", 15))
# Nach Bild-Hash (Bytes) gruppieren: identische Bilder werden nur einmal klassifiziert.
# Zugleich URL→Hash merken (auch für Duplikate), damit künftige Scans sie überspringen.
dirty = False
fp_assets: dict[str, list[str]] = {}
order_fps: list[str] = []
for url in fetch_urls:
fp = hashes.get(url, {}).get("sha256")
if not fp:
continue # nicht abrufbar — überspringen (nicht merken → später erneut versuchen)
if url_hashes.get(url) != fp:
url_hashes[url] = fp # neu gesehen oder Bytes geändert
dirty = True
result["checked"] += 1
if fp not in fp_assets:
fp_assets[fp] = []
order_fps.append(fp)
fp_assets[fp].append(url)
tasks, miss_fps = [], []
for fp in order_fps:
entry = entries.get(fp)
if entry is not None:
for url in fp_assets[fp]:
result["cache_hits"] += 1
_maybe_finding(result, entry, fp, url_to_page.get(url, url), ai_cfg, asset_url=url)
else:
miss_fps.append(fp)
tasks.append((fp, _image_task(fp_assets[fp][0], site_context, models, ai_cfg, api_key, router)))
classified = _classify_batch(tasks, executor)
for fp in miss_fps:
res = classified.get(fp)
rep_url = fp_assets[fp][0]
if res is None:
for url in fp_assets[fp]:
result["unchecked"].append(
{"kind": "image", "url": url_to_page.get(url, url), "asset_url": url})
continue
verdict, used_model = res
entry = _make_entry("image", rep_url, verdict, used_model)
entries[fp] = entry
result["api_calls"] += 1
dirty = True
for url in fp_assets[fp]:
_maybe_finding(result, entry, fp, url_to_page.get(url, url), ai_cfg, asset_url=url)
return dirty
# ---------------------------------------------------------------------------
# Audio / Video — vorbereitete Erweiterungspunkte (default deaktiviert)
# ---------------------------------------------------------------------------
def _collect_audio_candidates(snap: dict, cfg: dict) -> list[str]:
"""TODO: Audio-Quellen sammeln. Erfordert <audio>/<source>-Extraktion in
extractor.py (heute nicht extrahiert). Aktivierbar über ai_analysis.audio.enabled."""
return []
def _collect_video_candidates(snap: dict, cfg: dict) -> list[str]:
"""TODO: Video-Quellen sammeln. Erfordert <video>/<source>-Extraktion in
extractor.py + Frame-Sampling. Aktivierbar über ai_analysis.video.enabled."""
return []
# ---------------------------------------------------------------------------
# Fingerprinting & helpers
# ---------------------------------------------------------------------------
def _text_fingerprint(text: str, cfg: dict) -> str:
"""SHA-256 des normalisierten Texts (Reuse von differ.normalize_text → Cache-
Buster wie CF7-Platzhalter ändern den Hash nicht)."""
import hashlib
norm = normalize_text(text, cfg)
return hashlib.sha256(norm.encode("utf-8")).hexdigest()
def _make_entry(kind: str, url: str, verdict: dict, model: str) -> dict:
return {
"kind": kind,
"url": url,
"description": verdict.get("description", ""),
"category": verdict.get("category", "clean"),
"severity": verdict.get("severity", "none"),
"confidence": float(verdict.get("confidence", 0.0)),
"explanation": verdict.get("explanation", ""),
"checked_at": datetime.now(timezone.utc).isoformat(),
"model": model,
"dismissed": False,
}
def _maybe_finding(result, entry, fp, url, ai_cfg, asset_url=None) -> None:
"""Hängt einen Fund an, wenn das Verdikt gewertet werden soll
(nicht clean, nicht quittiert, severity≥medium, confidence≥Schwelle)."""
if entry.get("dismissed"):
return
if entry.get("category", "clean") == "clean":
return
if _SEVERITY_ORDER.get(entry.get("severity", "none"), 0) < _SEVERITY_ORDER["medium"]:
return
if entry.get("confidence", 0.0) < ai_cfg.get("ai_confidence_min", 0.7):
return
finding = {
"kind": entry["kind"],
"url": url,
"fingerprint": fp,
"category": entry["category"],
"severity": entry["severity"],
"confidence": entry["confidence"],
"explanation": entry.get("explanation", ""),
}
if asset_url:
finding["asset_url"] = asset_url
result["findings"].append(finding)
def _finding_points(finding: dict, sc: dict) -> int:
"""Punkte für einen Fund. Bilder mindestens ai_suspicious_image."""
key, default = _CATEGORY_SCORE_KEY.get(finding.get("category", ""), (None, 30))
pts = sc.get(key, default) if key else 30
if finding.get("kind") == "image":
pts = max(pts, sc.get("ai_suspicious_image", 40))
return pts
def _all_image_urls(snap: dict, only_pages: set | None = None) -> set[str]:
urls: set[str] = set()
for page_url, page in snap.get("pages", {}).items():
if only_pages is not None and page_url not in only_pages:
continue
if page.get("status") != 200:
continue
for link in page.get("links", {}).get("img", []):
if isinstance(link, dict) and link.get("url"):
urls.add(link["url"])
return urls
def _image_url_sources(snap: dict) -> dict[str, str]:
"""Map image-URL → erste Seite, auf der es vorkommt (für den Report)."""
sources: dict[str, str] = {}
for page_url, page in snap.get("pages", {}).items():
for link in page.get("links", {}).get("img", []):
if isinstance(link, dict) and link.get("url"):
sources.setdefault(link["url"], page_url)
return sources
# ---------------------------------------------------------------------------
# OpenRouter classification
# ---------------------------------------------------------------------------
_SYSTEM_PROMPT = (
"Du bist ein Sicherheits-Prüfer für Website-Inhalte. Die zu prüfende Website "
"hat folgendes Thema:\n\"{context}\"\n\n"
"Beurteile, ob der folgende Inhalt problematisch ist. Achte auf: sexuell "
"expliziten Text/Pornografie, politische Propaganda, diffamierende oder "
"strafbare Aussagen, versteckten Spam, thematisch völlig unpassende "
"kommerzielle Werbung, in sich widersprüchliche Aussagen.\n"
"WICHTIG: Beurteile die thematische PASSUNG zum oben genannten Thema, nicht "
"einzelne Schlüsselwörter. Heimat-, Orts- oder Vereinsbegriffe allein machen "
"einen Werbe- oder Spam-Text nicht unproblematisch — ein Angreifer kann sie "
"gezielt einstreuen. Bewerte den Gesamtcharakter des Inhalts.\n"
"Gib 'clean' zurück, wenn der Inhalt zum Thema passt und unproblematisch ist. "
"Gib im Feld 'description' eine knappe, neutrale Inhaltsangabe (12 Sätze, deutsch): "
"was ist auf dem Bild zu sehen bzw. worum geht es im Text — sachlich, unabhängig vom "
"Sicherheitsurteil. Antworte ausschließlich im vorgegebenen JSON-Format."
)
def _models_for(modality_cfg: dict) -> list[str]:
"""Modell-Kette einer Modalität. Rückwärts-kompatibel: einzelnes `model` → Liste."""
models = modality_cfg.get("models")
if models:
return list(models)
single = modality_cfg.get("model")
return [single] if single else []
def _classify(messages, models, ai_cfg, api_key, router) -> tuple[dict, str] | None:
"""Klassifiziert über die vom Router gereihte Modell-Kette.
Erste gültige Antwort → (verdict, model_used). Alle gescheitert → None.
Der Router reiht gesunde/schnelle Modelle nach vorn und überspringt
rate-limited/ausgefallene (Circuit-Breaker). Harter Deadline je Versuch;
bei Komplettausfall bis max_retries Wiederholung (Backoff)."""
timeout = ai_cfg.get("attempt_timeout", 20)
max_retries = ai_cfg.get("max_retries", 1)
backoff = ai_cfg.get("retry_backoff_seconds", 2.0)
for attempt in range(max_retries + 1):
ordered = router.order(models)
for model in ordered:
t0 = time.time()
verdict = _openrouter_chat(model, messages, ai_cfg, api_key, timeout=timeout)
if verdict is not None:
router.record_success(model, time.time() - t0)
return verdict, model
router.record_failure(model)
if attempt < max_retries:
logger.info("KI-Kette komplett erfolglos — Wiederholung %d/%d in %.1fs.",
attempt + 1, max_retries, backoff)
time.sleep(backoff)
return None
def _classify_text(text, site_context, models, ai_cfg, api_key, router) -> tuple[dict, str] | None:
messages = [
{"role": "system", "content": _SYSTEM_PROMPT.format(context=site_context or "(nicht angegeben)")},
{"role": "user", "content": f"Zu prüfender Seitentext:\n\n{text[:8000]}"},
]
return _classify(messages, models, ai_cfg, api_key, router)
def _classify_image(image_url, site_context, models, ai_cfg, api_key, router) -> tuple[dict, str] | None:
messages = [
{"role": "system", "content": _SYSTEM_PROMPT.format(context=site_context or "(nicht angegeben)")},
{"role": "user", "content": [
{"type": "text", "text": "Prüfe dieses Bild auf problematische Inhalte. "
"Beziehe auch im Bild sichtbaren Text mit ein (OCR)."},
{"type": "image_url", "image_url": {"url": image_url}},
]},
]
return _classify(messages, models, ai_cfg, api_key, router)
def _text_task(text, site_context, models, ai_cfg, api_key, router):
"""Factory: gibt einen parameterlosen Aufruf für den Thread-Pool zurück."""
return lambda: _classify_text(text, site_context, models, ai_cfg, api_key, router)
def _image_task(image_url, site_context, models, ai_cfg, api_key, router):
return lambda: _classify_image(image_url, site_context, models, ai_cfg, api_key, router)
def _classify_batch(tasks, executor) -> dict:
"""Führt [(key, fn)] nebenläufig im Pool aus. Returns {key: fn()-Ergebnis|None}.
Eine fehlgeschlagene Aufgabe blubbert nie hoch (→ None, der Aufrufer wertet das
als 'ungeprüft')."""
if not tasks:
return {}
futures = {executor.submit(fn): key for key, fn in tasks}
out: dict = {}
for fut in concurrent.futures.as_completed(futures):
key = futures[fut]
try:
out[key] = fut.result()
except Exception as exc: # pragma: no cover - Schutzschirm
logger.warning("KI-Klassifikation fehlgeschlagen (%s): %s", key, exc)
out[key] = None
return out
def _post_with_deadline(headers, payload, timeout):
"""requests.post in einem Worker-Thread mit HARTEM Wanduhr-Limit.
Der requests-Timeout ist nur ein Inaktivitäts-Timeout: ein Server, der die
Verbindung offen hält oder Tokens langsam tröpfeln lässt, kann ihn umgehen und
den Aufruf unbegrenzt hängen lassen. Dieser Deadline kappt die Gesamtdauer hart;
bei Überschreitung läuft der verwaiste Thread im Hintergrund aus und wir eskalieren.
"""
ex = concurrent.futures.ThreadPoolExecutor(max_workers=1)
fut = ex.submit(requests.post, _OPENROUTER_URL, headers=headers,
json=payload, timeout=timeout)
try:
resp = fut.result(timeout=timeout)
ex.shutdown(wait=False)
return resp
except concurrent.futures.TimeoutError:
ex.shutdown(wait=False) # Thread nicht abwarten — wir machen weiter
raise requests.exceptions.Timeout(f"Hartes Zeitlimit {timeout}s überschritten")
def _openrouter_chat(model, messages, ai_cfg, api_key, timeout=None) -> dict | None:
"""Einzelner OpenRouter-Call mit strukturierter JSON-Antwort.
Returns das Verdikt-Dict oder None bei jedem Fehler/Timeout (graceful)."""
if timeout is None:
timeout = ai_cfg.get("attempt_timeout", ai_cfg.get("request_timeout", 20))
payload = {
"model": model,
"messages": messages,
"response_format": _RESPONSE_SCHEMA,
"temperature": 0,
}
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-Title": "integrity-scanner",
}
try:
resp = _post_with_deadline(headers, payload, timeout)
if resp.status_code != 200:
logger.warning("OpenRouter HTTP %s (%s): %s", resp.status_code, model, resp.text[:200])
return None
content = resp.json()["choices"][0]["message"]["content"]
verdict = json.loads(content)
# Mindest-Validierung
if verdict.get("category") not in _CATEGORIES:
logger.warning("OpenRouter (%s): unbekannte Kategorie %r", model, verdict.get("category"))
return None
return verdict
except (requests.exceptions.RequestException, KeyError, ValueError, json.JSONDecodeError) as exc:
logger.warning("OpenRouter-Call fehlgeschlagen (%s): %s", model, exc)
return None