fix: KI-Analyse deckt standardmäßig ALLE Inhalte ab (kein Limit-Risiko)
Das per-Scan-Limit (max_pages/images_per_scan) war ein Sicherheitsrisiko: Inhalte jenseits des Fensters blieben über viele Scans ungeprüft. Eine KI-Bildanalyse ist aber nur sinnvoll, wenn ALLE Bilder geprüft werden. Neu, sauber getrennt: - Geänderte/neue/geflaggte Inhalte werden IMMER sofort geprüft (Echtzeit- Schutz), ungeachtet jeder Drossel — auch wenn das Limit klein ist. - Die Limits drosseln NUR den historischen Altbestand und sind per Default 0 = unbegrenzt → der erste Scan deckt den kompletten Bestand ab. Dank Hash-Cache eine einmalige Ausgabe; Folge-Scans prüfen nur noch Neues. - Positiver Wert bleibt als optionale Drossel nur für sehr große Sites. Tests: 257 grün (+2: geänderte Seite trotz Drossel=1 geprüft; image-Bestand backlog=0 deckt alle Bilder in einem Scan ab). Default-Werte 20/15 → 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6c7e69e635
commit
369a3b8774
5 changed files with 83 additions and 31 deletions
|
|
@ -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 <fp>` 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 <fp>`
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue