Bisher: Konnte die KI kein Verdikt liefern (alle Modellstufen scheitern),
ging der Inhalt still als grün durch — für ein Sicherheitswerkzeug ein
Falsch-Negativ-Risiko ("konnte nicht prüfen" ≠ "sauber").
Zwei Maßnahmen:
1. Retry: _classify wiederholt die ganze Modell-Kette bei Komplettausfall
bis max_retries (Default 1) mit Backoff — fängt transiente Aussetzer ab.
2. Tracking: nicht prüfbare Inhalte landen in ai_result["unchecked"] (statt
stillem skip) und werden sichtbar in Terminal, Report (Markdown), diff-only
und E-Mail — jeweils mit URL. score_ai_findings nimmt sie als Grund auf;
mit unchecked_level="yellow" heben sie das Level auf mindestens Gelb an
(Default "warn" = nur Hinweis, Ampel unberührt).
Config: max_retries, retry_backoff_seconds, unchecked_level (ai_analysis).
Tests: 230 grün (+ Retry-Recovery, unchecked-Tracking, unchecked_level).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
477 lines
19 KiB
Python
477 lines
19 KiB
Python
"""
|
|
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 json
|
|
import logging
|
|
import os
|
|
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": {
|
|
"category": {"type": "string", "enum": _CATEGORIES},
|
|
"severity": {"type": "string", "enum": ["none", "low", "medium", "high"]},
|
|
"confidence": {"type": "number"},
|
|
"explanation": {"type": "string"},
|
|
},
|
|
"required": ["category", "severity", "confidence", "explanation"],
|
|
"additionalProperties": False,
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
|
|
try:
|
|
ledger = bm.load_ai_ledger()
|
|
entries = ledger.setdefault("entries", {})
|
|
dirty = False
|
|
|
|
if ai_cfg.get("text", {}).get("enabled", True):
|
|
dirty |= _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result)
|
|
if ai_cfg.get("image", {}).get("enabled", True):
|
|
dirty |= _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result)
|
|
# Audio/Video: vorbereitet, default aus (siehe _collect_audio/video_candidates).
|
|
|
|
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}"
|
|
|
|
return result
|
|
|
|
|
|
def score_ai_findings(ai_result: dict, cfg: dict) -> dict:
|
|
"""
|
|
Bewertet KI-Funde additiv. Das Level ist hart auf 'yellow' gedeckelt —
|
|
KI allein löst niemals ROT aus (ROT bleibt harten Integritäts-Signalen).
|
|
|
|
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
|
|
|
|
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) -> bool:
|
|
"""Prüft neue/geänderte Seitentexte. 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)
|
|
|
|
budget = text_cfg.get("max_pages_per_scan", 20)
|
|
dirty = False
|
|
for url, text, fp in candidates:
|
|
result["checked"] += 1
|
|
entry = entries.get(fp)
|
|
if entry is None:
|
|
if budget <= 0:
|
|
continue # Kostendeckel erreicht — nächster Scan holt den Rest nach
|
|
classified = _classify_text(text, site_context, models, ai_cfg, api_key)
|
|
if classified is None:
|
|
# KI nicht erreichbar → Inhalt bleibt UNGEPRÜFT (nicht still als clean werten).
|
|
result["unchecked"].append({"kind": "text", "url": url})
|
|
continue
|
|
verdict, used_model = classified
|
|
entry = _make_entry("text", url, verdict, used_model)
|
|
entries[fp] = entry
|
|
result["api_calls"] += 1
|
|
budget -= 1
|
|
dirty = True
|
|
else:
|
|
result["cache_hits"] += 1
|
|
_maybe_finding(result, entry, fp, url, ai_cfg)
|
|
return dirty
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Image analysis
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result) -> bool:
|
|
"""Prüft neue/geänderte Bilder. Returns True wenn Ledger verändert."""
|
|
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
|
|
|
|
has_image_entries = any(e.get("kind") == "image" for e in entries.values())
|
|
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
|
|
}
|
|
|
|
if has_image_entries:
|
|
fetch_urls = sorted((changed_img_urls | flagged_img_urls) & current_img_urls)
|
|
else:
|
|
fetch_urls = sorted(current_img_urls) # Erstlauf: vollständiger Sweep
|
|
|
|
fetch_urls = fetch_urls[: img_cfg.get("max_images_per_scan", 15)]
|
|
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))
|
|
|
|
dirty = False
|
|
for url in fetch_urls:
|
|
h = hashes.get(url, {})
|
|
fp = h.get("sha256")
|
|
if not fp:
|
|
continue # nicht abrufbar — überspringen
|
|
result["checked"] += 1
|
|
page_url = url_to_page.get(url, url)
|
|
entry = entries.get(fp)
|
|
if entry is None:
|
|
classified = _classify_image(url, site_context, models, ai_cfg, api_key)
|
|
if classified is None:
|
|
# KI nicht erreichbar → Bild bleibt UNGEPRÜFT (nicht still als clean werten).
|
|
result["unchecked"].append({"kind": "image", "url": page_url, "asset_url": url})
|
|
continue
|
|
verdict, used_model = classified
|
|
entry = _make_entry("image", url, verdict, used_model)
|
|
entries[fp] = entry
|
|
result["api_calls"] += 1
|
|
dirty = True
|
|
else:
|
|
result["cache_hits"] += 1
|
|
_maybe_finding(result, entry, fp, page_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,
|
|
"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. "
|
|
"Antworte ausschließlich im vorgegebenen JSON-Format mit deutscher Begründung."
|
|
)
|
|
|
|
|
|
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) -> tuple[dict, str] | None:
|
|
"""Versucht die Modell-Kette der Reihe nach (zweifache Eskalation).
|
|
Erste gültige Antwort → (verdict, model_used). Alle gescheitert → None.
|
|
Eskaliert auch bei Langsamkeit (Zeitlimit attempt_timeout je Versuch).
|
|
Bei Komplettausfall der ganzen Kette wird bis max_retries wiederholt
|
|
(mit Backoff) — fängt transiente Aussetzer ab, bevor Inhalt ungeprüft bleibt."""
|
|
timeout = ai_cfg.get("attempt_timeout", 30)
|
|
max_retries = ai_cfg.get("max_retries", 1)
|
|
backoff = ai_cfg.get("retry_backoff_seconds", 2.0)
|
|
|
|
for attempt in range(max_retries + 1):
|
|
for i, model in enumerate(models):
|
|
verdict = _openrouter_chat(model, messages, ai_cfg, api_key, timeout=timeout)
|
|
if verdict is not None:
|
|
if i > 0 or attempt > 0:
|
|
logger.info("KI-Verdikt von Stufe %d (%s)%s.", i + 1, model,
|
|
f" nach Wiederholung {attempt}" if attempt else "")
|
|
return verdict, model
|
|
if i + 1 < len(models):
|
|
logger.info("KI-Modell Stufe %d (%s) erfolglos — eskaliere zu Stufe %d.",
|
|
i + 1, model, i + 2)
|
|
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) -> 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)
|
|
|
|
|
|
def _classify_image(image_url, site_context, models, ai_cfg, api_key) -> 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)
|
|
|
|
|
|
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", 30))
|
|
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 = requests.post(
|
|
_OPENROUTER_URL, headers=headers, json=payload, timeout=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
|