fix: KI-Robustheit — harter Wanduhr-Deadline + persistenter Ledger

Ein echter Durchlauf auf bredelar.info deckte zwei Bugs auf, die mit
gemockten Tests unsichtbar waren:

1. Hang: attempt_timeout wurde nur als requests-Inaktivitäts-Timeout
   übergeben. Ein Free-Modell, das die Verbindung offen hält oder Tokens
   langsam tröpfeln lässt, umging ihn → der ganze Scan hing unbegrenzt
   (real auf einem Bild >5 min). Fix: _post_with_deadline führt den Call
   in einem Worker-Thread mit future.result(timeout=...) aus — hartes
   Wanduhr-Limit, bei Überschreitung Eskalation statt Hang.

2. Verlust bei Abbruch: Das Ledger wurde nur ganz am Ende gespeichert,
   ein Hang/Crash verwarf alle berechneten Verdikte. Fix: Zwischensicherung
   nach der Textphase + im finally — Verdikte überleben einen Abbruch.

Zusätzlich attempt_timeout 30 → 20 s (flottere Eskalation).

Im erneuten Lauf griff der Deadline 4× sauber, der Scan lief vollständig
durch (Gelb: ein off_topic_commercial-Fund auf einer Gewerbe-Seite).

Tests: 239 grün (+2: Deadline kappt Hang <2s, Textverdikte überleben
Bildphasen-Crash).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 02:11:14 +02:00
commit 31730403b3
4 changed files with 75 additions and 15 deletions

View file

@ -14,6 +14,7 @@ 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
@ -98,15 +99,20 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict:
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)
# Ledger wird zwischengesichert (nach Textphase + im finally), damit bereits
# berechnete Verdikte einen späteren Hang/Abbruch überleben und nicht erneut
# bezahlt werden müssen.
try:
if ai_cfg.get("text", {}).get("enabled", True):
dirty |= _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result)
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)
# 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}"
@ -460,11 +466,31 @@ def _classify_image(image_url, site_context, models, ai_cfg, api_key) -> tuple[d
return _classify(messages, models, ai_cfg, api_key)
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", 30))
timeout = ai_cfg.get("attempt_timeout", ai_cfg.get("request_timeout", 20))
payload = {
"model": model,
"messages": messages,
@ -477,9 +503,7 @@ def _openrouter_chat(model, messages, ai_cfg, api_key, timeout=None) -> dict | N
"X-Title": "integrity-scanner",
}
try:
resp = requests.post(
_OPENROUTER_URL, headers=headers, json=payload, timeout=timeout,
)
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

View file

@ -93,7 +93,7 @@ DEFAULT_CONFIG: dict = {
"red_min_severity": "high",
"red_min_confidence": 0.9,
"request_timeout": 60, # Ceiling (auch für Asset-Fetch der Bilder)
"attempt_timeout": 30, # Zeitlimit je Modell-Versuch → Eskalation bei Langsamkeit
"attempt_timeout": 20, # HARTES Wanduhr-Limit je Modell-Versuch → Eskalation
"max_retries": 1, # Wiederholungen der ganzen Modell-Kette bei Komplettausfall
"retry_backoff_seconds": 2.0,
# Verhalten, wenn Inhalte trotz Retry NICHT geprüft werden konnten (KI nicht erreichbar):