feat: KI-gestützte Inhaltsanalyse via OpenRouter (hash-gegated)

Optionale semantische Prüfung von Text und Bildern (inkl. OCR) auf
problematische Inhalte ohne Link-Signal: Pornografie, Propaganda,
diffamierende/strafbare Texte, versteckter Spam, themenfremde Werbung,
widersprüchliche Aussagen. Bewertet die thematische Passung zum
deklarierten site_context — eingestreute Heimat-Begriffe täuschen die
Erkennung nicht.

Kern:
- scanner/ai_analyzer.py: run_ai_analysis + score_ai_findings.
  Hash-Gate über data/ai_ledger.json → unveränderte Inhalte = Cache-
  Treffer = kein API-Call. Nur neue/geänderte Inhalte kosten etwas.
- Modell-Kette mit zweifacher Eskalation (Stufe 1+2 free, Stufe 3
  günstig bezahlt); eskaliert bei Fehler ODER Timeout (attempt_timeout).
  Erfolgreiches Modell wird im Ledger vermerkt.
- KI-Funde sind auf GELB gedeckelt — ROT bleibt harten Integritäts-
  Signalen vorbehalten. Graceful degradation: ohne Key/bei Fehler wird
  übersprungen, Scan läuft unverändert weiter.

Integration:
- baseline.py: load/save_ai_ledger, dismiss_ai_entries.
- config.py: ai_analysis-Block + ai_* Scoring-Schlüssel.
- __main__.py: Einhängung in cmd_scan/cmd_check, ai-dismiss-Subcommand,
  approve quittiert zugehörige Funde, status-Anzeige, diff-only + report.
- alerter.py + __main__.py: beanstandete Dateien erscheinen mit URL,
  Begründung und Quittier-Fingerprint in E-Mail UND Markdown-Report.
- plain.py: laienverständliche KI-Sätze.

API-Key nur aus Umgebungsvariable (OPENROUTER_API_KEY). Audio/Video als
abschaltbare Hooks vorbereitet (default aus).

Modelle live gegen OpenRouter verifiziert; Demo auf bredelar.info zeigte
korrekte Erkennung (echter Inhalt clean, eingeschleuster Casino-Spam mit
Heimat-Begriffen als hidden_spam erkannt).

Tests: 227 grün (+26 für ai_analyzer, +1 für E-Mail-KI-Abschnitt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 00:04:33 +02:00
commit cae3dbb985
11 changed files with 1301 additions and 7 deletions

321
tests/test_ai_analyzer.py Normal file
View file

@ -0,0 +1,321 @@
"""Tests for scanner/ai_analyzer.py — hash-gated AI content analysis."""
import copy
from unittest.mock import patch
import pytest
from scanner.ai_analyzer import (
run_ai_analysis,
score_ai_findings,
_text_fingerprint,
_models_for,
)
from scanner.baseline import BaselineManager
from scanner.config import DEFAULT_CONFIG
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _cfg(**ai_overrides) -> dict:
cfg = copy.deepcopy(DEFAULT_CONFIG)
cfg["ai_analysis"]["enabled"] = True
cfg["ai_analysis"]["site_context"] = "Heimat- und Vereinswebsite über Bergbau"
cfg["ai_analysis"]["image"]["enabled"] = False # Text-Tests: Bild aus
cfg["ai_analysis"].update(ai_overrides)
return cfg
def _snap(text: str, url: str = "https://x.de/", img: list | None = None) -> dict:
return {
"pages": {
url: {
"url": url,
"status": 200,
"text": text,
"links": {"img": img or []},
}
}
}
_LONG = "Lorem ipsum dolor sit amet. " * 20 # > 200 Zeichen
def _verdict(category="clean", severity="none", confidence=0.9, explanation="ok"):
return {"category": category, "severity": severity,
"confidence": confidence, "explanation": explanation}
# ---------------------------------------------------------------------------
# Gating: disabled / no key / graceful degradation
# ---------------------------------------------------------------------------
class TestGating:
def test_disabled_skips(self, tmp_path):
cfg = _cfg()
cfg["ai_analysis"]["enabled"] = False
bm = BaselineManager(tmp_path)
with patch("scanner.ai_analyzer._openrouter_chat") as m:
res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
assert res["skipped"] == "deaktiviert"
m.assert_not_called()
def test_no_api_key_skips(self, tmp_path, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
bm = BaselineManager(tmp_path)
with patch("scanner.ai_analyzer._openrouter_chat") as m:
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert "kein API-Key" in res["skipped"]
m.assert_not_called()
def test_api_failure_is_graceful(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
with patch("scanner.ai_analyzer._openrouter_chat", return_value=None):
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert res["findings"] == []
assert res["api_calls"] == 0
assert res["skipped"] # ein Hinweis wurde gesetzt, aber keine Exception
# ---------------------------------------------------------------------------
# Hash-gate: the core cost promise
# ---------------------------------------------------------------------------
class TestHashGate:
def test_new_fingerprint_calls_api_once(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m:
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert res["api_calls"] == 1
assert res["cache_hits"] == 0
assert m.call_count == 1
assert len(bm.load_ai_ledger()["entries"]) == 1
def test_second_scan_unchanged_zero_api_calls(self, tmp_path, monkeypatch):
"""Kernversprechen: unveränderter Inhalt → 0 API-Calls beim Folge-Scan."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
snap = _snap(_LONG)
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
run_ai_analysis(_cfg(), bm, snap, {})
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m2:
res2 = run_ai_analysis(_cfg(), bm, snap, {})
assert res2["api_calls"] == 0
assert res2["cache_hits"] == 1
m2.assert_not_called()
def test_budget_cap_limits_api_calls(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
pages = {f"https://x.de/p{i}": {"url": f"https://x.de/p{i}", "status": 200,
"text": _LONG + str(i), "links": {"img": []}}
for i in range(5)}
snap = {"pages": pages}
cfg = _cfg()
cfg["ai_analysis"]["text"]["max_pages_per_scan"] = 2
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
res = run_ai_analysis(cfg, bm, snap, {})
assert res["api_calls"] == 2 # gedeckelt
# ---------------------------------------------------------------------------
# Findings filter: category / severity / confidence / dismissed
# ---------------------------------------------------------------------------
class TestFindingsFilter:
def test_flagged_text_produces_finding(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
v = _verdict("pornography", "high", 0.95, "explizit")
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert len(res["findings"]) == 1
f = res["findings"][0]
assert f["kind"] == "text" and f["category"] == "pornography"
def test_clean_produces_no_finding(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict("clean", "none", 0.99)):
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert res["findings"] == []
def test_low_confidence_no_finding(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
v = _verdict("propaganda", "high", 0.5) # < 0.7
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert res["findings"] == []
def test_low_severity_no_finding(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
v = _verdict("off_topic_commercial", "low", 0.95) # severity < medium
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
res = run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
assert res["findings"] == []
def test_dismissed_entry_no_finding_and_no_recheck(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
snap = _snap(_LONG)
v = _verdict("hidden_spam", "high", 0.9)
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
res1 = run_ai_analysis(_cfg(), bm, snap, {})
assert len(res1["findings"]) == 1
assert bm.dismiss_ai_entries(None) == 1
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v) as m2:
res2 = run_ai_analysis(_cfg(), bm, snap, {})
assert res2["findings"] == [] # quittiert → kein Fund mehr
assert res2["api_calls"] == 0 # Cache-Treffer, kein erneuter Call
m2.assert_not_called()
# ---------------------------------------------------------------------------
# Image path
# ---------------------------------------------------------------------------
class TestImages:
def _cfg_img(self):
cfg = _cfg()
cfg["ai_analysis"]["text"]["enabled"] = False
cfg["ai_analysis"]["image"]["enabled"] = True
return cfg
def test_flagged_image_produces_finding(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
snap = _snap("kurz", img=[{"url": "https://x.de/bild.jpg", "class": "internal"}])
v = _verdict("pornography", "high", 0.95, "explizites Bild")
with patch("scanner.ai_analyzer.fetch_asset_hashes",
return_value={"https://x.de/bild.jpg": {"sha256": "deadbeef", "size": 1, "error": None}}), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
res = run_ai_analysis(self._cfg_img(), bm, snap, {})
assert len(res["findings"]) == 1
assert res["findings"][0]["kind"] == "image"
assert res["findings"][0]["asset_url"] == "https://x.de/bild.jpg"
def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
snap = _snap("kurz", img=[{"url": "https://x.de/bild.jpg", "class": "internal"}])
hashes = {"https://x.de/bild.jpg": {"sha256": "deadbeef", "size": 1, "error": None}}
with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
run_ai_analysis(self._cfg_img(), bm, snap, {})
with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()) as m2:
res2 = run_ai_analysis(self._cfg_img(), bm, snap, {})
assert res2["api_calls"] == 0
m2.assert_not_called()
# ---------------------------------------------------------------------------
# Scoring: cap at yellow
# ---------------------------------------------------------------------------
class TestScoring:
def test_empty_is_green(self):
out = score_ai_findings({"findings": []}, _cfg())
assert out["level"] == "green" and out["score"] == 0
def test_single_flag_is_yellow(self):
findings = [{"kind": "text", "url": "u", "category": "propaganda",
"severity": "high", "confidence": 0.9}]
out = score_ai_findings({"findings": findings}, _cfg())
assert out["level"] == "yellow"
def test_score_capped_at_yellow_never_red(self):
# Zwei schwere Funde → Summe ≥ rot-Schwelle, aber Cap hält es bei gelb.
findings = [
{"kind": "text", "url": "u1", "category": "pornography", "severity": "high", "confidence": 0.95},
{"kind": "text", "url": "u2", "category": "defamation_illegal", "severity": "high", "confidence": 0.95},
]
out = score_ai_findings({"findings": findings}, _cfg())
assert out["score"] >= 60 # Punktsumme über rot-Schwelle
assert out["level"] == "yellow" # trotzdem nur gelb
assert out["exit_code"] == 1
def test_image_gets_at_least_suspicious_image_points(self):
# off_topic_commercial=30, aber Bild-Untergrenze ai_suspicious_image=40
findings = [{"kind": "image", "url": "u", "category": "off_topic_commercial",
"severity": "medium", "confidence": 0.8}]
out = score_ai_findings({"findings": findings}, _cfg())
assert out["score"] == 40
# ---------------------------------------------------------------------------
# Model escalation chain
# ---------------------------------------------------------------------------
class TestEscalation:
def test_models_for_list(self):
assert _models_for({"models": ["a", "b", "c"]}) == ["a", "b", "c"]
def test_models_for_backward_compat_single(self):
assert _models_for({"model": "x"}) == ["x"]
def test_models_for_empty(self):
assert _models_for({}) == []
def test_escalates_to_second_model_on_failure(self, tmp_path, monkeypatch):
"""Stufe 1 scheitert → Stufe 2 (free) liefert das Verdikt."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = _cfg()
cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
v = _verdict("propaganda", "high", 0.9)
with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, v]) as m:
res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
assert res["api_calls"] == 1
assert len(res["findings"]) == 1
assert m.call_count == 2 # erste Stufe scheiterte, zweite griff
# Ledger merkt sich das tatsächlich erfolgreiche Modell
entry = next(iter(bm.load_ai_ledger()["entries"].values()))
assert entry["model"] == "free-2"
def test_escalates_to_paid_third_stage(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = _cfg()
cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
v = _verdict("clean", "none", 0.9)
with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, None, v]) as m:
res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
assert m.call_count == 3
entry = next(iter(bm.load_ai_ledger()["entries"].values()))
assert entry["model"] == "paid-3"
def test_all_stages_fail_is_graceful(self, tmp_path, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = _cfg()
cfg["ai_analysis"]["text"]["models"] = ["free-1", "free-2", "paid-3"]
with patch("scanner.ai_analyzer._openrouter_chat", side_effect=[None, None, None]):
res = run_ai_analysis(cfg, bm, _snap(_LONG), {})
assert res["findings"] == []
assert res["api_calls"] == 0
assert res["skipped"] # Hinweis gesetzt
assert bm.load_ai_ledger()["entries"] == {} # nichts gespeichert
# ---------------------------------------------------------------------------
# Fingerprint stability
# ---------------------------------------------------------------------------
class TestFingerprint:
def test_ignore_pattern_keeps_fingerprint_stable(self):
cfg = _cfg()
cfg["normalization"]["ignore_patterns"] = [r"Kommentar oder Nachricht \* \w+"]
a = "Hallo Welt. Kommentar oder Nachricht * Comment Ende."
b = "Hallo Welt. Kommentar oder Nachricht * Name Ende."
assert _text_fingerprint(a, cfg) == _text_fingerprint(b, cfg)
def test_different_text_different_fingerprint(self):
cfg = _cfg()
assert _text_fingerprint("abc", cfg) != _text_fingerprint("xyz", cfg)

View file

@ -103,6 +103,23 @@ class TestFormatBody:
body = _format_body(_report("yellow"))
assert "python -m scanner" in body
def test_ai_finding_includes_url_and_reason(self):
"""Beanstandete Dateien müssen mit URL und Begründung im Body stehen."""
rep = _report("yellow")
rep["ai_result"] = {
"checked": 1, "cache_hits": 0, "api_calls": 1, "skipped": None,
"findings": [{
"kind": "image", "url": "https://x.de/galerie",
"asset_url": "https://x.de/img/bad.jpg", "fingerprint": "a1b2c3d4",
"category": "pornography", "severity": "high", "confidence": 0.95,
"explanation": "Explizite Darstellung, passt nicht zum Thema.",
}],
}
body = _format_body(rep)
assert "https://x.de/img/bad.jpg" in body # URL der Datei
assert "Explizite Darstellung" in body # Begründung
assert "ai-dismiss --hash a1b2c3d4" in body # Quittier-Hinweis
# ---------------------------------------------------------------------------
# send_alert — level filtering