feat: KI-Absicherung gegen Falsch-Negative bei Dienstausfall
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>
This commit is contained in:
parent
822f1ca41d
commit
d06e3d3dfd
9 changed files with 138 additions and 18 deletions
|
|
@ -17,6 +17,7 @@ KI darf den Kern-Scan nie brechen.
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
|
@ -79,7 +80,8 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict:
|
|||
"api_calls": int, "skipped": str|None}.
|
||||
Bricht nie mit einer Exception — bei Problemen wird "skipped" gesetzt.
|
||||
"""
|
||||
result = {"findings": [], "checked": 0, "cache_hits": 0, "api_calls": 0, "skipped": None}
|
||||
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"):
|
||||
|
|
@ -139,6 +141,16 @@ def score_ai_findings(ai_result: dict, cfg: dict) -> dict:
|
|||
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}
|
||||
|
||||
|
||||
|
|
@ -175,7 +187,8 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result) -> bool:
|
|||
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:
|
||||
result["skipped"] = result["skipped"] or "API-Fehler (Text)"
|
||||
# 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)
|
||||
|
|
@ -239,7 +252,8 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result) -> bool:
|
|||
if entry is None:
|
||||
classified = _classify_image(url, site_context, models, ai_cfg, api_key)
|
||||
if classified is None:
|
||||
result["skipped"] = result["skipped"] or "API-Fehler (Bild)"
|
||||
# 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)
|
||||
|
|
@ -383,17 +397,28 @@ def _models_for(modality_cfg: dict) -> list[str]:
|
|||
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)."""
|
||||
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)
|
||||
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:
|
||||
logger.info("KI-Modell-Eskalation: Stufe %d (%s) lieferte das Verdikt.", i + 1, model)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue