diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index ba0de43..23cf142 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -193,6 +193,7 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict: try: ledger = bm.load_ai_ledger() entries = ledger.setdefault("entries", {}) + url_hashes = ledger.setdefault("url_hashes", {}) # URL→Byte-Hash-Erinnerung (Bilder) dirty = False # Ledger wird zwischengesichert (nach Textphase + im finally), damit bereits # berechnete Verdikte einen späteren Hang/Abbruch überleben und nicht erneut @@ -206,7 +207,7 @@ def run_ai_analysis(cfg: dict, bm, snap: dict, diff: dict) -> dict: 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, router, executor) + entries, result, router, executor, url_hashes) # Audio/Video: vorbereitet, default aus (siehe _collect_audio/video_candidates). finally: if dirty: @@ -359,8 +360,13 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe # Image analysis # --------------------------------------------------------------------------- -def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor) -> bool: - """Prüft neue/geänderte Bilder (parallel). Returns True wenn Ledger verändert.""" +def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, executor, + url_hashes) -> bool: + """Prüft neue/geänderte Bilder (parallel). Returns True wenn Ledger verändert. + + url_hashes ist die persistente URL→Byte-Hash-Erinnerung: eine bereits gesehene + Bild-URL wird NICHT erneut heruntergeladen (kein redundanter Fetch, keine doppelte + Analyse). Nur nie gesehene Bilder sowie geänderte/geflaggte werden geholt.""" img_cfg = ai_cfg.get("image", {}) models = _models_for(img_cfg) site_context = ai_cfg.get("site_context", "") @@ -378,17 +384,14 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e if e.get("kind") == "image" and e.get("category", "clean") != "clean" and not e.get("dismissed") and e.get("url") in current_img_urls } - # Noch nie analysierte Bilder (URL nicht im Ledger): bauen über mehrere Scans die - # VOLLE Abdeckung auf — sonst bliebe jedes Bild jenseits des ersten Budget-Fensters - # ein dauerhafter blinder Fleck (z. B. ein eingeschleustes strafbares Logo auf einer - # ansonsten stabilen Seite). Bytes-Änderungen bekannter Bilder deckt check-assets ab. - known_img_urls = {e["url"] for e in entries.values() if e.get("kind") == "image"} - coverage_urls = current_img_urls - known_img_urls - # Geänderte/geflaggte Bilder: IMMER prüfen (Echtzeit-Schutz, nie gedeckelt). - # Bestands-Abdeckung: Drossel optional; 0 = unbegrenzt (volle Abdeckung im ersten Scan). + # Geänderte/geflaggte Bilder: IMMER (erneut) prüfen (Echtzeit-Schutz, nie gedeckelt). + # Abdeckung: nur noch NIE gesehene URLs (nicht in url_hashes) — schon bekannte Bilder + # werden nicht erneut geladen (Hashwert gemerkt). Volle Abdeckung baut sich so EINMALIG + # auf; Duplikate (gleiche Bytes an mehreren URLs) werden je URL höchstens einmal geholt + # und nie doppelt analysiert (Ledger ist nach Byte-Hash indiziert). Drossel 0 = unbegrenzt. priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls) - coverage = sorted(coverage_urls - set(priority)) + coverage = sorted((current_img_urls - set(url_hashes)) - set(priority)) backlog_limit = img_cfg.get("max_images_per_scan", 0) if backlog_limit and backlog_limit > 0: coverage = coverage[:backlog_limit] @@ -401,12 +404,17 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e hashes = fetch_asset_hashes(fetch_urls, timeout=cfg.get("request_timeout", 15)) # Nach Bild-Hash (Bytes) gruppieren: identische Bilder werden nur einmal klassifiziert. + # Zugleich URL→Hash merken (auch für Duplikate), damit künftige Scans sie überspringen. + dirty = False fp_assets: dict[str, list[str]] = {} order_fps: list[str] = [] for url in fetch_urls: fp = hashes.get(url, {}).get("sha256") if not fp: - continue # nicht abrufbar — überspringen + continue # nicht abrufbar — überspringen (nicht merken → später erneut versuchen) + if url_hashes.get(url) != fp: + url_hashes[url] = fp # neu gesehen oder Bytes geändert + dirty = True result["checked"] += 1 if fp not in fp_assets: fp_assets[fp] = [] @@ -425,7 +433,6 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e tasks.append((fp, _image_task(fp_assets[fp][0], site_context, models, ai_cfg, api_key, router))) classified = _classify_batch(tasks, executor) - dirty = False for fp in miss_fps: res = classified.get(fp) rep_url = fp_assets[fp][0] diff --git a/tests/test_ai_analyzer.py b/tests/test_ai_analyzer.py index 460869b..4373972 100644 --- a/tests/test_ai_analyzer.py +++ b/tests/test_ai_analyzer.py @@ -230,6 +230,32 @@ class TestImages: if e.get("kind") == "image"} assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt + def test_duplicate_image_urls_fetched_once_analyzed_once(self, tmp_path, monkeypatch): + """Gleiches Bild an mehreren URLs: nur EINE Analyse; danach kein erneuter Fetch + (Byte-Hash gemerkt und geprüft).""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test") + bm = BaselineManager(tmp_path) + cfg = self._cfg_img() + imgs = [{"url": "https://x.de/a.jpg", "class": "internal"}, + {"url": "https://x.de/b.jpg", "class": "internal"}] + snap = _snap("kurz", img=imgs) + same = {"https://x.de/a.jpg": {"sha256": "DUP", "size": 1, "error": None}, + "https://x.de/b.jpg": {"sha256": "DUP", "size": 1, "error": None}} + with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=same), \ + patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()): + res1 = run_ai_analysis(cfg, bm, snap, {}) + assert res1["api_calls"] == 1 # gleiche Bytes → nur EINE Analyse + led = bm.load_ai_ledger() + assert set(led["url_hashes"]) == {"https://x.de/a.jpg", "https://x.de/b.jpg"} + + # zweiter Scan: beide URLs bekannt → gar kein Fetch, keine Analyse mehr + with patch("scanner.ai_analyzer.fetch_asset_hashes") as fetch2, \ + patch("scanner.ai_analyzer._openrouter_chat") as chat2: + res2 = run_ai_analysis(cfg, bm, snap, {}) + fetch2.assert_not_called() + chat2.assert_not_called() + assert res2["api_calls"] == 0 + def test_image_backlog_unlimited_covers_all_in_one_scan(self, tmp_path, monkeypatch): """max_images_per_scan=0 → voller Bildbestand in EINEM Scan (Default).""" monkeypatch.setenv("OPENROUTER_API_KEY", "test")