From 31730403b3717755b3b77050e288ae4ef6910a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Sat, 13 Jun 2026 02:11:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20KI-Robustheit=20=E2=80=94=20harter=20Wan?= =?UTF-8?q?duhr-Deadline=20+=20persistenter=20Ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- meine-seite.de/config.yaml | 2 +- scanner/ai_analyzer.py | 50 ++++++++++++++++++++++++++++---------- scanner/config.py | 2 +- tests/test_ai_analyzer.py | 36 +++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 15 deletions(-) diff --git a/meine-seite.de/config.yaml b/meine-seite.de/config.yaml index a60bb59..5218bbc 100644 --- a/meine-seite.de/config.yaml +++ b/meine-seite.de/config.yaml @@ -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): diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index 449a658..636426d 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -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 diff --git a/scanner/config.py b/scanner/config.py index a1d3ddd..9ee8f74 100644 --- a/scanner/config.py +++ b/scanner/config.py @@ -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): diff --git a/tests/test_ai_analyzer.py b/tests/test_ai_analyzer.py index bb41cf6..3412718 100644 --- a/tests/test_ai_analyzer.py +++ b/tests/test_ai_analyzer.py @@ -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 # ---------------------------------------------------------------------------