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:
parent
0aaf53135a
commit
cae3dbb985
11 changed files with 1301 additions and 7 deletions
321
tests/test_ai_analyzer.py
Normal file
321
tests/test_ai_analyzer.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue