diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 1c0c4ee..fc5000d 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1012,24 +1012,28 @@ Wirkung: Der Erst-Scan einer Site (der den ganzen Bestand prüft) wird deutlich rate-limited Free-Modelle sofort übersprungen werden und die verbleibenden Anfragen nebenläufig laufen. -### Vollständige Bildabdeckung (kein blinder Fleck) +### Vollständige Abdeckung (kein blinder Fleck) -Pro Scan werden maximal `image.max_images_per_scan` Bilder geprüft (Kostendeckel). Damit jenseits -dieses Fensters **kein Bild dauerhaft ungeprüft** bleibt — sonst könnte ein Angreifer z. B. ein -strafbares Logo auf einer ansonsten stabilen Seite platzieren —, holt **jeder** Scan zusätzlich -noch nicht analysierte Bilder nach, bis der gesamte Bildbestand abgedeckt ist. Reihenfolge: -zuerst Bilder auf geänderten Seiten und bereits geflaggte, dann mit dem Restbudget der noch -unbekannte Rest. +Eine KI-Bildanalyse ist nur sinnvoll, wenn **alle** Bilder geprüft werden — sonst bliebe ein +Bild jenseits eines Limits dauerhaft ungeprüft (z. B. ein eingeschleustes strafbares Logo auf +einer ansonsten stabilen Seite). Deshalb gilt: -**Initiale Einrichtung großer Bildbestände:** Hat eine Site sehr viele Bilder (z. B. 200), dauert -die volle Abdeckung mehrere Scans. Zum schnelleren Aufbau kann `max_images_per_scan` vorübergehend -erhöht werden. In dieser Phase markiert die KI auch legitime Inhalte (z. B. Marken-/Partnerlogos -auf Firmenseiten) — diese werden einmalig mit `ai-dismiss --hash ` 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. +- **Geänderte/neue Inhalte werden IMMER sofort geprüft** — ungeachtet jeder Drossel. Fügt jemand + ein Bild auf einer Seite ein, ändert sich diese Seite → das Bild wird im selben Scan analysiert. +- **`max_pages_per_scan` / `max_images_per_scan` drosseln nur den historischen Altbestand**, und + ihr **Default ist `0` = unbegrenzt**: Der erste Scan deckt den kompletten Bestand ab. Dank des + Hash-Caches ist das eine einmalige Ausgabe (wenige Cent); Folge-Scans prüfen nur noch Neues. +- Ein **positiver** Wert ist nur für *sehr große* Sites (z. B. 10.000 Bilder) gedacht, um die + erste Volldurchsicht über mehrere Scans zu strecken. Selbst dann werden geänderte/neue Inhalte + weiter sofort geprüft. -Hinweis zur Byte-Änderung bekannter Bilder auf stabilen Seiten: Diese deckt die Datei-Prüfung -(`check-assets`) ab — sie schlägt an, wenn sich der Inhalt einer bekannten Datei ändert. +**Initiale Einrichtung:** Beim ersten vollen Durchlauf markiert die KI auch legitime Inhalte +(z. B. Marken-/Partnerlogos auf Firmenseiten) — diese werden einmalig mit `ai-dismiss --hash ` +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. Audio- und Video-Prüfung sind als abschaltbare Erweiterungspunkte vorbereitet (`ai_analysis.audio` / `ai_analysis.video`, default deaktiviert) und können aktiviert werden, diff --git a/meine-seite.de/config.yaml b/meine-seite.de/config.yaml index a02eea1..cebaf4b 100644 --- a/meine-seite.de/config.yaml +++ b/meine-seite.de/config.yaml @@ -169,14 +169,18 @@ ai_analysis: - "meta-llama/llama-3.3-70b-instruct:free" # Stufe 2: free - "google/gemini-2.5-flash-lite" # Stufe 3: günstig bezahlt, zuverlässig min_chars: 200 - max_pages_per_scan: 20 + # Drossel NUR für den Altbestand; 0 = unbegrenzt (volle Abdeckung im ersten Scan). + # Geänderte/neue Seiten werden IMMER geprüft. Positiver Wert nur für sehr große Sites. + max_pages_per_scan: 0 image: enabled: true models: - "google/gemma-4-31b-it:free" # Stufe 1: free, multimodal + OCR - "nvidia/nemotron-nano-12b-v2-vl:free" # Stufe 2: free, Vision - "google/gemini-2.5-flash-lite" # Stufe 3: günstig bezahlt, Vision - max_images_per_scan: 15 + # Drossel NUR für den Bild-Altbestand; 0 = unbegrenzt. Geänderte/neue/geflaggte + # Bilder werden IMMER geprüft. Positiver Wert nur für sehr große Bildbestände. + max_images_per_scan: 0 # Audio/Video: vorbereitet, default aus (Medien auf statischen Seiten selten). audio: enabled: false diff --git a/scanner/ai_analyzer.py b/scanner/ai_analyzer.py index abe56f9..ba0de43 100644 --- a/scanner/ai_analyzer.py +++ b/scanner/ai_analyzer.py @@ -317,7 +317,10 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe order_fps.append(fp) fp_urls[fp].append(url) - budget = text_cfg.get("max_pages_per_scan", 20) + # Drossel NUR für den historischen Bestand; 0 = unbegrenzt. Geänderte/neue Seiten + # werden IMMER geprüft (Echtzeit-Schutz), ungeachtet des Limits. + backlog_limit = text_cfg.get("max_pages_per_scan", 0) + backlog_used = 0 tasks, miss_fps = [], [] for fp in order_fps: entry = entries.get(fp) @@ -325,11 +328,14 @@ def _analyze_text(cfg, ai_cfg, api_key, snap, diff, entries, result, router, exe for url in fp_urls[fp]: result["cache_hits"] += 1 _maybe_finding(result, entry, fp, url, ai_cfg) - elif budget > 0: - miss_fps.append(fp) - tasks.append((fp, _text_task(fp_text[fp], site_context, models, ai_cfg, api_key, router))) - budget -= 1 - # else: Kostendeckel erreicht — nächster Scan holt den Rest nach + continue + is_changed = any(u in changed for u in fp_urls[fp]) + if not is_changed and backlog_limit and backlog_used >= backlog_limit: + continue # Bestands-Drossel erreicht — nächster Scan holt den Rest nach + miss_fps.append(fp) + tasks.append((fp, _text_task(fp_text[fp], site_context, models, ai_cfg, api_key, router))) + if not is_changed: + backlog_used += 1 classified = _classify_batch(tasks, executor) dirty = False @@ -379,11 +385,14 @@ def _analyze_images(cfg, ai_cfg, api_key, snap, diff, entries, result, router, e known_img_urls = {e["url"] for e in entries.values() if e.get("kind") == "image"} coverage_urls = current_img_urls - known_img_urls - # Priorität: geänderte/geflaggte Bilder zuerst, dann mit dem Restbudget Abdeckung aufbauen. + # Geänderte/geflaggte Bilder: IMMER prüfen (Echtzeit-Schutz, nie gedeckelt). + # Bestands-Abdeckung: Drossel optional; 0 = unbegrenzt (volle Abdeckung im ersten Scan). priority = sorted((changed_img_urls | flagged_img_urls) & current_img_urls) coverage = sorted(coverage_urls - set(priority)) - budget = img_cfg.get("max_images_per_scan", 15) - fetch_urls = (priority + coverage)[:budget] + backlog_limit = img_cfg.get("max_images_per_scan", 0) + if backlog_limit and backlog_limit > 0: + coverage = coverage[:backlog_limit] + fetch_urls = priority + coverage if not fetch_urls: return False diff --git a/scanner/config.py b/scanner/config.py index 921cb66..a0ee425 100644 --- a/scanner/config.py +++ b/scanner/config.py @@ -119,7 +119,9 @@ DEFAULT_CONFIG: dict = { "google/gemini-2.5-flash-lite", # Stufe 3: günstig bezahlt, zuverlässig ], "min_chars": 200, # nur Seitentexte ab dieser Größe prüfen - "max_pages_per_scan": 20, # Kostendeckel pro Lauf + # Drossel NUR für den historischen Bestand; 0 = unbegrenzt (volle Abdeckung im + # ersten Scan). Geänderte/neue Seiten werden IMMER geprüft, ungeachtet dieses Werts. + "max_pages_per_scan": 0, }, "image": { "enabled": True, @@ -128,7 +130,9 @@ DEFAULT_CONFIG: dict = { "nvidia/nemotron-nano-12b-v2-vl:free", # Stufe 2: free, Vision "google/gemini-2.5-flash-lite", # Stufe 3: günstig bezahlt, Vision ], - "max_images_per_scan": 15, + # Drossel NUR für den Bild-Altbestand; 0 = unbegrenzt (volle Abdeckung im ersten + # Scan). Geänderte/neue/geflaggte Bilder werden IMMER geprüft, ungeachtet des Werts. + "max_images_per_scan": 0, }, # Vorbereitet, default aus — Medien sind auf statischen Heimat-Seiten selten. "audio": { diff --git a/tests/test_ai_analyzer.py b/tests/test_ai_analyzer.py index f8821b4..460869b 100644 --- a/tests/test_ai_analyzer.py +++ b/tests/test_ai_analyzer.py @@ -230,6 +230,22 @@ class TestImages: if e.get("kind") == "image"} assert len(analysed) == 5 # nach 3 Scans sind alle 5 Bilder abgedeckt + 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") + bm = BaselineManager(tmp_path) + cfg = self._cfg_img() + cfg["ai_analysis"]["image"]["max_images_per_scan"] = 0 # unbegrenzt + imgs = [{"url": f"https://x.de/img{i}.jpg", "class": "internal"} for i in range(7)] + hashes = {f"https://x.de/img{i}.jpg": {"sha256": f"h{i}", "size": 1, "error": None} + for i in range(7)} + with patch("scanner.ai_analyzer.fetch_asset_hashes", return_value=hashes), \ + patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()): + run_ai_analysis(cfg, bm, _snap("kurz", img=imgs), {}) + imgcount = sum(1 for e in bm.load_ai_ledger()["entries"].values() + if e.get("kind") == "image") + assert imgcount == 7 + def test_image_cache_hit_second_scan(self, tmp_path, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "test") bm = BaselineManager(tmp_path) @@ -421,6 +437,21 @@ class TestParallel: assert len(res["findings"]) == 2 # aber Fund für beide URLs assert {f["url"] for f in res["findings"]} == {"https://x.de/a", "https://x.de/b"} + def test_changed_page_always_analyzed_despite_backlog_limit(self, tmp_path, monkeypatch): + """Geänderte Seiten werden IMMER geprüft — die Bestands-Drossel gilt nicht für sie.""" + monkeypatch.setenv("OPENROUTER_API_KEY", "test") + bm = BaselineManager(tmp_path) + cfg = _cfg() + cfg["ai_analysis"]["text"]["max_pages_per_scan"] = 1 # strenge Bestands-Drossel + 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)} + diff = {"new_internal_urls": ["https://x.de/p3"], "page_diffs": []} + with patch("scanner.ai_analyzer._openrouter_chat", return_value=_verdict()): + run_ai_analysis(cfg, bm, {"pages": pages}, diff) + urls = {e["url"] for e in bm.load_ai_ledger()["entries"].values()} + assert "https://x.de/p3" in urls # geänderte Seite trotz Drossel=1 geprüft + def test_findings_sorted_deterministically(self, tmp_path, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "test") bm = BaselineManager(tmp_path)