feat: Inhaltsinventar in SQLite — KI-Inhaltsangabe je Objekt (URL + Hash)
Vereinheitlicht den bisherigen JSON-Ledger zu EINEM abfragbaren SQLite-Store pro Site (data/content_inventory.db): jedes geprüfte Objekt mit Inhalts-Hash, URL(s), Sicherheits-Verdikt UND neutraler KI-Inhaltsangabe. - baseline.py: load/save_ai_ledger jetzt SQLite-gestützt (Dict-Schnittstelle bleibt → Analyzer unverändert). Tabellen objects + object_urls. Einmalige Migration eines vorhandenen ai_ledger.json → SQLite (.migrated). UPSERT mit first_seen-Erhalt; object_urls transaktional ersetzt. Neu: query_inventory, export_inventory_csv (beide migrieren failsafe). - ai_analyzer.py: Antwort-Schema + Prompt um neutrales Feld 'description' erweitert (im selben Call, keine Extrakosten); _make_entry speichert es. - __main__.py: neues Kommando 'inventory' (Übersicht, --search, --kind, --csv). - Doku: README + Bedienungsanleitung. Audio/Video sind im Schema (kind) vorbereitet (Phase 2: Extractor + Modalitäten). bredelar.info real migriert: 245 Objekte (200 Bilder, 45 Texte). Tests: 266 grün (+6: Round-Trip mit description, Migration, first_seen-Erhalt, invalidate, Suche/CSV-Export, description landet im Ledger). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
78dc79ceab
commit
989f5a933f
7 changed files with 262 additions and 22 deletions
|
|
@ -506,6 +506,17 @@ 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_description_stored_in_ledger(self, tmp_path, monkeypatch):
|
||||
"""Die neutrale Inhaltsangabe aus dem Verdikt landet im Inventar."""
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test")
|
||||
bm = BaselineManager(tmp_path)
|
||||
v = {"description": "Eine Seite über die Dorfgeschichte.", "category": "clean",
|
||||
"severity": "none", "confidence": 0.9, "explanation": "ok"}
|
||||
with patch("scanner.ai_analyzer._openrouter_chat", return_value=v):
|
||||
run_ai_analysis(_cfg(), bm, _snap(_LONG), {})
|
||||
entry = next(iter(bm.load_ai_ledger()["entries"].values()))
|
||||
assert entry["description"] == "Eine Seite über die Dorfgeschichte."
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -159,3 +159,67 @@ class TestRebuildBaseline:
|
|||
|
||||
baseline = bm.load_baseline()
|
||||
assert baseline["pages"]["https://b.info/"]["text"] == "Neu"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI content inventory (SQLite store: verdict + description + url→hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _entry(kind="image", url="https://b.info/x.jpg", category="clean", desc="Ein Logo"):
|
||||
return {"kind": kind, "url": url, "description": desc, "category": category,
|
||||
"severity": "none", "confidence": 0.9, "explanation": "ok",
|
||||
"model": "m", "dismissed": False, "checked_at": "2026-01-01T00:00:00"}
|
||||
|
||||
|
||||
class TestAiInventory:
|
||||
def test_roundtrip_preserves_entries_and_url_hashes(self, tmp_path):
|
||||
bm = BaselineManager(tmp_path)
|
||||
bm.save_ai_ledger({"entries": {"H1": _entry(desc="Schützenfest-Plakat")},
|
||||
"url_hashes": {"https://b.info/x.jpg": "H1"}})
|
||||
led = bm.load_ai_ledger()
|
||||
assert led["entries"]["H1"]["description"] == "Schützenfest-Plakat"
|
||||
assert led["entries"]["H1"]["dismissed"] is False
|
||||
assert led["url_hashes"] == {"https://b.info/x.jpg": "H1"}
|
||||
|
||||
def test_migrates_json_ledger_once(self, tmp_path):
|
||||
(tmp_path).mkdir(parents=True, exist_ok=True)
|
||||
old = tmp_path / "ai_ledger.json"
|
||||
old.write_text(json.dumps({"entries": {"H1": _entry()}, "url_hashes": {"u": "H1"}}),
|
||||
encoding="utf-8")
|
||||
bm = BaselineManager(tmp_path)
|
||||
led = bm.load_ai_ledger()
|
||||
assert "H1" in led["entries"]
|
||||
assert not old.exists() # umbenannt
|
||||
assert (tmp_path / "ai_ledger.json.migrated").exists()
|
||||
assert (tmp_path / "content_inventory.db").exists()
|
||||
|
||||
def test_first_seen_preserved_last_seen_updated(self, tmp_path):
|
||||
bm = BaselineManager(tmp_path)
|
||||
bm.save_ai_ledger({"entries": {"H1": _entry()}, "url_hashes": {}})
|
||||
rows1 = bm.query_inventory()
|
||||
first1, last1 = rows1[0]["first_seen"], rows1[0]["last_seen"]
|
||||
import time; time.sleep(0.01)
|
||||
bm.save_ai_ledger({"entries": {"H1": _entry(category="propaganda")}, "url_hashes": {}})
|
||||
rows2 = bm.query_inventory()
|
||||
assert rows2[0]["first_seen"] == first1 # bleibt
|
||||
assert rows2[0]["last_seen"] >= last1 # aktualisiert
|
||||
assert rows2[0]["category"] == "propaganda" # Feld aktualisiert
|
||||
|
||||
def test_invalidate_removes_url(self, tmp_path):
|
||||
bm = BaselineManager(tmp_path)
|
||||
bm.save_ai_ledger({"entries": {}, "url_hashes": {"a": "H1", "b": "H2"}})
|
||||
assert bm.invalidate_ai_url_hashes(["a"]) == 1
|
||||
assert bm.load_ai_ledger()["url_hashes"] == {"b": "H2"}
|
||||
|
||||
def test_query_search_and_csv_export(self, tmp_path):
|
||||
bm = BaselineManager(tmp_path)
|
||||
bm.save_ai_ledger({"entries": {
|
||||
"H1": _entry(url="https://b.info/kloster.jpg", desc="Foto des Klosters Bredelar"),
|
||||
"H2": _entry(url="https://b.info/auto.jpg", desc="Ein Auto"),
|
||||
}, "url_hashes": {}})
|
||||
hits = bm.query_inventory(search="kloster")
|
||||
assert len(hits) == 1 and "Kloster" in hits[0]["description"]
|
||||
out = tmp_path / "inv.csv"
|
||||
assert bm.export_inventory_csv(out) == 2
|
||||
content = out.read_text(encoding="utf-8")
|
||||
assert "description" in content and "Kloster" in content
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue