feat: check-assets ↔ KI gekoppelt — byte-getauschte Bilder neu bewerten

Da bekannte Bilder aus Effizienzgründen nicht jeden Scan neu geladen werden
(url_hashes), prüfte die KI einen späteren Byte-Tausch eines bekannten Bildes
nicht von selbst erneut. Lücke geschlossen: Erkennt die Datei-Prüfung
(check-assets) eine geänderte Bilddatei, wird deren URL über
BaselineManager.invalidate_ai_url_hashes aus der Hash-Erinnerung entfernt →
nächster Scan lädt das Bild neu, neue Bytes = neuer Hash = Cache-Miss = die KI
bewertet den Inhalt neu.

Einhängung in cmd_scan (vor run_ai_analysis → Neubewertung noch im selben Scan)
und cmd_check_assets. Integritäts- und Inhaltsprüfung greifen so ineinander.

Tests: 260 grün (+2: invalidate entfernt nur die genannten URLs; geändertes
Bild wird nach Invalidierung neu geladen und beanstandet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-13 04:22:38 +02:00
commit 78dc79ceab
4 changed files with 80 additions and 2 deletions

View file

@ -1032,8 +1032,12 @@ einer ansonsten stabilen Seite). Deshalb gilt:
akzeptiert. Da die Quittung am **Byte-Hash** hängt, wird ein später ausgetauschtes Bild
(andere Bytes → neuer Hash) automatisch neu geprüft und nicht stillschweigend mitakzeptiert.
Hinweis zur Byte-Änderung bekannter Bilder auf stabilen Seiten: Diese deckt zusätzlich die
Datei-Prüfung (`check-assets`) ab — sie schlägt an, wenn sich der Inhalt einer bekannten Datei ändert.
**Byte-Tausch bekannter Bilder (z. B. ein bestehendes Logo wird heimlich gegen ein strafbares
ausgetauscht):** Bekannte Bilder werden aus Effizienzgründen nicht bei jedem Scan neu geladen
(URL→Byte-Hash gemerkt). Damit ein Austausch trotzdem nicht durchrutscht, ist die Datei-Prüfung
(`check-assets`) an die KI **gekoppelt**: Erkennt sie eine geänderte Bilddatei, wird deren URL aus
der Hash-Erinnerung entfernt → die KI lädt das Bild neu und **bewertet seinen Inhalt neu** (neue
Bytes → neuer Hash → Analyse). So greifen Integritäts- und Inhaltsprüfung ineinander.
Audio- und Video-Prüfung sind als abschaltbare Erweiterungspunkte vorbereitet
(`ai_analysis.audio` / `ai_analysis.video`, default deaktiviert) und können aktiviert werden,

View file

@ -689,6 +689,17 @@ def _run_assets_core(cfg: dict, bm: BaselineManager, snap: dict) -> tuple[dict,
return asset_diff, score_asset_diff(asset_diff, cfg), bool(baseline_hashes)
def _invalidate_ai_for_changed_assets(bm: BaselineManager, asset_diff: dict) -> None:
"""Geänderte Bild-Assets (byte-getauscht) aus der KI-url_hashes-Erinnerung entfernen,
damit die KI sie neu lädt UND inhaltlich neu bewertet. Nicht-Bild-URLs sind nicht in
url_hashes werden ignoriert."""
changed_urls = [c["url"] for c in (asset_diff or {}).get("changed", []) if c.get("url")]
n = bm.invalidate_ai_url_hashes(changed_urls)
if n:
logging.getLogger("scanner.assets").info(
"KI-Cache für %d geänderte Bild(er) invalidiert → inhaltliche Neubewertung.", n)
def cmd_check(args: argparse.Namespace, cfg: dict) -> int:
logger = logging.getLogger("scanner.check")
bm = BaselineManager(cfg["data_dir"])
@ -762,6 +773,9 @@ def cmd_scan(args: argparse.Namespace, cfg: dict) -> int:
levels.append(asset_assess["level"])
score_total += asset_assess.get("score", 0)
bm.mark_check_run("assets")
# Geänderte Bilder → KI-Cache invalidieren, damit die KI sie inhaltlich neu
# bewertet (läuft hier vor run_ai_analysis → Neubewertung noch im selben Scan).
_invalidate_ai_for_changed_assets(bm, asset_diff)
if pc.get("ext_links", True) and bm.is_check_due("ext_links", interval):
print(" Wöchentliche Prüfung externer Links läuft mit ...")
ext_links = _run_ext_links_core(cfg, snap) # informativ, hebt Level nicht an
@ -1148,6 +1162,7 @@ def cmd_check_assets(args: argparse.Namespace, cfg: dict) -> int:
print("Prüfe die Dateien Ihrer Website (Bilder/Skripte/Stylesheets) ...")
asset_diff, assessment, has_baseline = _run_assets_core(cfg, bm, snap)
bm.mark_check_run("assets")
_invalidate_ai_for_changed_assets(bm, asset_diff)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),

View file

@ -292,6 +292,22 @@ class BaselineManager:
json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8"
)
def invalidate_ai_url_hashes(self, urls: list[str]) -> int:
"""Entfernt URLs aus der url_hashes-Erinnerung. Folge: Die KI lädt diese Bilder
beim nächsten Lauf erneut und bewertet sie inhaltlich neu (geänderte Bytes
neuer Hash Cache-Miss Analyse). Wird von der Datei-Prüfung (check-assets)
für geänderte Bild-Assets aufgerufen. Returns Anzahl entfernter Einträge."""
if not urls:
return 0
ledger = self.load_ai_ledger()
uh = ledger.get("url_hashes", {})
removed = [u for u in urls if u in uh]
for u in removed:
del uh[u]
if removed:
self.save_ai_ledger(ledger)
return len(removed)
def dismiss_ai_entries(self, fingerprints: list[str] | None = None) -> int:
"""
Mark ledger entries as dismissed (false-positive acknowledgement).

View file

@ -287,6 +287,49 @@ class TestImages:
m2.assert_not_called()
# ---------------------------------------------------------------------------
# check-assets ↔ KI-Kopplung: geänderte Bilder werden neu bewertet
# ---------------------------------------------------------------------------
class TestAssetAiCoupling:
def _cfg_img(self):
cfg = _cfg()
cfg["ai_analysis"]["text"]["enabled"] = False
cfg["ai_analysis"]["image"]["enabled"] = True
return cfg
def test_invalidate_removes_only_given_urls(self, tmp_path):
bm = BaselineManager(tmp_path)
bm.save_ai_ledger({"entries": {}, "url_hashes": {"a": "h1", "b": "h2", "c": "h3"}})
assert bm.invalidate_ai_url_hashes(["b", "x"]) == 1 # x ist nicht vorhanden
assert set(bm.load_ai_ledger()["url_hashes"]) == {"a", "c"}
def test_changed_image_reanalyzed_after_invalidation(self, tmp_path, monkeypatch):
"""Byte-Tausch eines bekannten Bildes → invalidieren → KI lädt+bewertet neu."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
bm = BaselineManager(tmp_path)
cfg = self._cfg_img()
snap = _snap("kurz", img=[{"url": "https://x.de/logo.png", "class": "internal"}])
with patch("scanner.ai_analyzer.fetch_asset_hashes",
return_value={"https://x.de/logo.png": {"sha256": "OLD", "size": 1, "error": None}}), \
patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()):
run_ai_analysis(cfg, bm, snap, {})
# check-assets erkennt Byte-Änderung → Kopplung invalidiert die URL
assert bm.invalidate_ai_url_hashes(["https://x.de/logo.png"]) == 1
new = _verdict("pornography", "high", 0.95)
with patch("scanner.ai_analyzer.fetch_asset_hashes",
return_value={"https://x.de/logo.png": {"sha256": "NEW", "size": 1, "error": None}}) as f2, \
patch("scanner.ai_analyzer._openrouter_chat", return_value=new) as c2:
res = run_ai_analysis(cfg, bm, snap, {})
f2.assert_called() # neu geladen (URL war invalidiert)
c2.assert_called() # neue Bytes (NEW) → Cache-Miss → neu analysiert
assert res["api_calls"] == 1
assert len(res["findings"]) == 1 # jetzt beanstandet
# ---------------------------------------------------------------------------
# Scoring: cap at yellow
# ---------------------------------------------------------------------------