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:
parent
a8dcaabf7c
commit
31730403b3
4 changed files with 75 additions and 15 deletions
|
|
@ -145,7 +145,7 @@ ai_analysis:
|
|||
red_categories: ["pornography", "defamation_illegal"]
|
||||
red_min_severity: "high"
|
||||
red_min_confidence: 0.9
|
||||
attempt_timeout: 30 # Zeitlimit je Modell-Versuch (Sek.) → Auslöser der Eskalation
|
||||
attempt_timeout: 20 # HARTES Wanduhr-Limit je Modell-Versuch (Sek.) → 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
import time
|
||||
|
||||
from scanner.ai_analyzer import (
|
||||
run_ai_analysis,
|
||||
score_ai_findings,
|
||||
_text_fingerprint,
|
||||
_models_for,
|
||||
_openrouter_chat,
|
||||
)
|
||||
from scanner.baseline import BaselineManager
|
||||
from scanner.config import DEFAULT_CONFIG
|
||||
|
|
@ -304,6 +307,39 @@ class TestRedGate:
|
|||
assert out["level"] == "yellow"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Robustness: hard wall-clock deadline + resilient ledger save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRobustness:
|
||||
def test_hard_deadline_bounds_a_hanging_call(self):
|
||||
"""Ein hängender requests.post wird durch den Wanduhr-Deadline gekappt."""
|
||||
ai_cfg = {"attempt_timeout": 0.2}
|
||||
|
||||
def slow_post(*a, **k):
|
||||
time.sleep(3) # simuliert ein hängendes/tröpfelndes Modell
|
||||
|
||||
start = time.time()
|
||||
with patch("scanner.ai_analyzer.requests.post", side_effect=slow_post):
|
||||
out = _openrouter_chat("m", [{"role": "user", "content": "x"}], ai_cfg, "key", timeout=0.2)
|
||||
elapsed = time.time() - start
|
||||
assert out is None # graceful → None → Eskalation
|
||||
assert elapsed < 2.0 # NICHT die vollen 3s — Deadline hat gegriffen
|
||||
|
||||
def test_text_verdicts_survive_image_crash(self, tmp_path, monkeypatch):
|
||||
"""Textphase wird zwischengesichert → ein Crash der Bildphase verliert sie nicht."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
|
||||
bm = BaselineManager(tmp_path)
|
||||
cfg = _cfg()
|
||||
cfg["ai_analysis"]["image"]["enabled"] = True
|
||||
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()), \
|
||||
patch("scanner.ai_analyzer._analyze_images", side_effect=RuntimeError("boom")):
|
||||
res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
|
||||
# Bildphase krachte, aber das Text-Verdikt liegt persistent im Ledger
|
||||
assert res["skipped"] and "interner Fehler" in res["skipped"]
|
||||
assert len(bm.load_ai_ledger()["entries"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model escalation chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue