diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d95e138 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Nach .env kopieren und ausfuellen (chmod 600). .env ist gitignored. +# +# Dev-Datenbank im lokalen Podman-Container, ueber den gemappten Port. +# Im Produktivbetrieb stattdessen der Containername im gemeinsamen Netz, +# analog zu swp-01-ingest/ingest.env.example. +DATABASE_URL=postgresql://wurzelwerk:GEHEIM@127.0.0.1:5432/wurzelwerk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de734a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +.env diff --git a/check_db.py b/check_db.py new file mode 100755 index 0000000..ec6c7f4 --- /dev/null +++ b/check_db.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Rauchtest fuer den Datenbankzugang. + +Meldet sich an, liest den Bestand und zeigt, worauf Phase 2 aufsetzt. +Nur SELECT - dieses Skript schreibt nichts. + + python3 check_db.py +""" + +import sys + +from db import verbindung + + +def main(): + with verbindung() as conn: + nutzer, datenbank, version = conn.execute( + "select current_user, current_database(), version()" + ).fetchone() + print(f"verbunden als {nutzer}@{datenbank}") + print(f" {version.split(' on ')[0]}") + + erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")] + print(f" Extensions: {', '.join(erw)}") + for fehlend in ("vector",): + if fehlend not in erw: + print(f" fehlt noch fuer Phase 2: {fehlend}") + + print("\nTabellen") + for name, in conn.execute( + """select relname from pg_class c join pg_namespace n on n.oid = c.relnamespace + where c.relkind = 'r' and n.nspname = 'public' order by relname""" + ): + anzahl = conn.execute(f'select count(*) from "{name}"').fetchone()[0] + print(f" {name:22} {anzahl:>8}") + + von, bis, slots, gesamt = conn.execute( + "select min(slot), max(slot), count(distinct slot), count(*) from raw_items" + ).fetchone() + if not gesamt: + print("\nraw_items ist leer - Phase 1 hat hier noch nichts abgelegt.") + return 0 + print(f"\nraw_items: {gesamt} Items in {slots} Slots, {von} bis {bis}") + + # Volltextabdeckung entscheidet, worauf Dubletten und Embeddings + # ueberhaupt rechnen koennen - sie ist je Quelle sehr verschieden. + print("\nje Quelle") + print(f" {'quelle':<16}{'items':>7}{'volltext':>10}{'teaser':>8}{'revidiert':>11}") + for quelle, n, volltext, teaser, revidiert in conn.execute( + """select quelle, count(*), count(volltext), count(teaser), + count(*) filter (where revisionen > 0) + from raw_items group by 1 order by 2 desc""" + ): + print(f" {quelle:<16}{n:>7}{volltext:>10}{teaser:>8}{revidiert:>11}") + + print("\njuengste Items") + for id_, quelle, titel in conn.execute( + "select id, quelle, titel from raw_items order by id desc limit 5" + ): + print(f" {id_:>7} {quelle:<14}{titel[:60]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/db.py b/db.py new file mode 100644 index 0000000..8d67510 --- /dev/null +++ b/db.py @@ -0,0 +1,42 @@ +"""Datenbankzugang fuer Phase 2. + +Eine Stelle, die DATABASE_URL aufloest - aus der Umgebung oder aus .env +daneben. Zugangsdaten stehen nie im Code und nie in einem Argument, das in +der Prozessliste landet. +""" + +import os +import pathlib + +_ENV = pathlib.Path(__file__).with_name(".env") + + +def _aus_env_datei(pfad=_ENV): + """Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen.""" + werte = {} + if not pfad.exists(): + return werte + for zeile in pfad.read_text(encoding="utf-8").splitlines(): + zeile = zeile.strip() + if not zeile or zeile.startswith("#") or "=" not in zeile: + continue + schluessel, _, wert = zeile.partition("=") + werte[schluessel.strip()] = wert.strip().strip('"').strip("'") + return werte + + +def dsn(): + url = os.environ.get("DATABASE_URL") or _aus_env_datei().get("DATABASE_URL") + if not url: + raise RuntimeError( + "DATABASE_URL fehlt. .env.example nach .env kopieren und ausfuellen." + ) + return url + + +def verbindung(**kwargs): + """Verbindung mit Autocommit aus. Aufrufer entscheidet ueber Transaktionen.""" + import psycopg + + kwargs.setdefault("connect_timeout", 5) + return psycopg.connect(dsn(), **kwargs) diff --git a/phase_02_spec.md b/phase_02_spec.md new file mode 100644 index 0000000..bb3a64c --- /dev/null +++ b/phase_02_spec.md @@ -0,0 +1,414 @@ +# Wurzelwerkt — Phase 2: Aufbereitung + +**Status:** Entwurf +**Gilt für:** Stinkwurzpresse / Projekt Wurzelwerkt + +--- + +## 0. Zweck und Abgrenzung + +Phase 2 reichert `raw_items` deterministisch an. Sie erzeugt keine Ereignisse und +keine Aussagen — das ist Phase 3. Sie erzeugt die Vorbedingungen dafür: +aufgelöste Akteure, Orte, Tonalität, Embeddings, und die Erkennung exakter +Übernahmen. + +### Invarianten + +1. **Kein LLM.** Alles regelbasiert, lexikonbasiert oder durch ein festes + Embedding-Modell. Kein generativer Schritt. +2. **Reproduzierbar.** Gleiche Eingabe + gleiche `coder_version` ⇒ bitgleiche + Ausgabe. Kein Sampling, keine Zeitabhängigkeit, keine Netzabfrage zur Laufzeit. +3. **Additiv.** `raw_items` wird nicht verändert. Ergebnisse gehen ausschließlich + nach `codings`. +4. **Verwerfbar.** Der gesamte Phase-2-Bestand muss löschbar und aus + `raw_items` neu baubar sein. +5. **Live.** Läuft auf Bebop, im Anschluss an jeden Poll-Lauf, innerhalb des + 15-Minuten-Slots. + +--- + +## 1. Datenmodell + +Phase 2 schreibt in die vorhandene `codings`-Tabelle. Keine neuen Tabellen außer +den Lexika. + +### 1.1 Ebenen + +Je `raw_item` entsteht **eine** Coding-Zeile pro Ebene: + +| `ebene` | Inhalt der `nutzlast` | +|--------------|----------------------------------------------| +| `dublette` | Zuordnung zu einer Übernahme-Gruppe | +| `akteure` | Liste aufgelöster Akteure mit Offsets | +| `geo` | Liste aufgelöster Orte mit Offsets | +| `tonalitaet` | Tonwerte auf Titel und Teaser | +| `embedding` | Vektor-Referenz und Modellkennung | + +### 1.2 Erforderliche Schemaänderungen + +```sql +-- Re-Runs müssen ersetzen, nicht duplizieren +ALTER TABLE codings + ADD CONSTRAINT codings_uniq UNIQUE (raw_item_id, coder_version, ebene); + +CREATE INDEX codings_ebene_idx ON codings (ebene, kodiert_am); +CREATE INDEX codings_nutzlast_idx ON codings USING gin (nutzlast); +``` + +### 1.3 `coder_version` + +Format: `p2--` + +Der Hash bildet: Regelwerk-Version, Gazetteer-Version, Akteurslexikon-Version, +Sentiment-Lexikon-Version, Embedding-Modell + Quantisierung, Normalisierungsregeln. +Ändert sich eines davon, ändert sich die Version. Alte Codings bleiben liegen. + +Eine Tabelle `coder_versionen (version, komponenten jsonb, erstellt_am)` hält +auf, was hinter einem Hash steht. Ohne sie ist die Versionierung wertlos. + +--- + +## 2. Eingabegrenzen + +Phase 2 arbeitet auf `titel`, `teaser`, `kategorien` und — nur für Tonalität, +Akteure, Geo und Embedding — auf `volltext`, sofern vorhanden. + +**Kein Phase-2-Ergebnis darf Volltext oder Teaser im Klartext enthalten.** +Offsets und Codes ja, Textausschnitte nein. Begründung: `codings` ist +Kandidat für den OpenData-Dump; Snippets darin würden die Veröffentlichungsgrenze +unterlaufen. + +Ausnahme: die extrahierte Oberflächenform eines Akteurs oder Orts +(z. B. `"Robert Habeck"`) ist ein Eigenname, kein Werkausschnitt, und darf +gespeichert werden. + +--- + +## 3. Modul: Exakte Dubletten (`ebene = 'dublette'`) + +### Zweck + +Agenturmeldungen (dpa, AFP, Reuters) erscheinen bei mehreren Quellen nahezu +identisch. Diese Fälle sollen *vor* Phase 3 zusammengefasst werden, damit das +LLM sie nicht mehrfach kodiert. + +Dies ist **kein** thematisches Clustering. Nur near-exact. + +### Verfahren + +1. Normalisierung des Vergleichstexts (Titel + Teaser, oder Volltext falls + vorhanden): Unicode NFKC, Kleinschreibung, Entities aufgelöst, Whitespace + kollabiert, Anführungszeichen vereinheitlicht, redaktionelle Präfixe + (`"Eilmeldung:"`, `"+++ ... +++"`, Ortsmarken wie `"Berlin (dpa) -"`) entfernt. +2. SimHash (64 bit) über Wort-Trigramme des normalisierten Texts. +3. Kandidaten: gleiches Datum ±1 Tag, Hamming-Distanz ≤ 3. +4. Bestätigung: Jaccard über Trigramm-Mengen ≥ 0.85. +5. Union-Find über die bestätigten Paare ⇒ `uebernahme_gruppe` (UUID, + deterministisch aus der sortierten Menge der `raw_item_id` abgeleitet). + +### Nutzlast + +```json +{ + "gruppe": "…", + "gruppengroesse": 4, + "leitartikel": 8814, + "simhash": "a3f1…", + "jaccard_min": 0.91, + "agentur_vermutet": "dpa" +} +``` + +`leitartikel` ist die `raw_item_id` mit dem frühesten `zuerst_gesehen`; bei +Gleichstand die kleinste ID. Rein deterministisch. Phase 3 kodiert nur den +Leitartikel; die übrigen Mitglieder erben die Kodierung, erscheinen aber +weiterhin einzeln als Erwähnung mit eigener Quelle. + +`agentur_vermutet` aus Mustererkennung im Text (`"(dpa)"`, `"Reuters"` etc.), +nullable. Keine Vermutung ohne Textbeleg. + +### Erwartung + +Trefferquote gegen eine handgeprüfte Stichprobe von 200 Paaren: Präzision ≥ 0.98 +ist Bedingung. Recall darf niedrig sein — verpasste Dubletten kosten nur +Rechenzeit in Phase 3, falsche verschmelzen unterschiedliche Vorgänge. + +--- + +## 4. Modul: Akteursauflösung (`ebene = 'akteure'`) + +### Zweck + +Erkennung und Auflösung von Personen, Organisationen, Parteien, Staaten und +Ämtern auf stabile interne IDs. + +### Lexikon + +Neue Tabelle: + +```sql +CREATE TABLE akteure ( + id text PRIMARY KEY, -- z.B. 'per:habeck_robert' + typ text NOT NULL, -- person|organisation|partei|staat|amt + name text NOT NULL, -- kanonische Form + aliase text[] NOT NULL DEFAULT '{}', + land text, -- ISO-3166-1 alpha-3 + rolle text, -- z.B. 'GOV','LEG','OPP','JUD','MIL','MED','BUS','NGO' + gueltig_von date, + gueltig_bis date, + wikidata_qid text, + quelle text NOT NULL, -- woher der Eintrag stammt + notiz text +); +CREATE INDEX akteure_aliase_idx ON akteure USING gin (aliase); +``` + +**Zeitliche Gültigkeit ist nicht optional.** „Der Bundeskanzler" bezeichnet +je nach `pubdate` verschiedene Personen. Auflösung von Ämtern erfolgt +immer gegen das Datum des Items. + +Erstbefüllung: Wikidata-Extrakt (Bundestagsabgeordnete, Kabinettsmitglieder, +Ministerpräsidenten, DAX-Vorstände, Staaten, internationale Organisationen) +plus manuelle Ergänzung. Als versionierter Dump im Repo, nicht als Live-Abfrage. + +### Verfahren + +1. NER über den Text (spaCy `de_core_news_lg` oder Flair). Modellkennung geht + in die `coder_version`. +2. Kandidatensuche im Lexikon über Alias-Match (normalisiert) und Fuzzy-Match + (Levenshtein-Grenze abhängig von der Länge). +3. Disambiguierung über Kontext: gleichzeitig genannte Akteure, Ressort der + Quelle, Land. Regelbasiert und dokumentiert, keine Heuristik ohne Testfall. +4. Ämter (`"der Kanzler"`, `"die Innenministerin"`) über Amtstabelle gegen + `pubdate` auflösen. +5. Nicht auflösbare Erwähnungen bleiben als unaufgelöste Oberflächenform + erhalten — sie sind das Rohmaterial für die Lexikonpflege. + +### Nutzlast + +```json +{ + "erkannt": [ + {"id": "per:habeck_robert", "form": "Robert Habeck", + "typ": "person", "rolle": "GOV", "feld": "titel", + "start": 0, "ende": 13, "konfidenz": 0.97, "methode": "alias"}, + {"id": null, "form": "Verband der Regionalversorger", + "typ": "organisation", "feld": "teaser", + "start": 44, "ende": 73, "konfidenz": 0.4, "methode": "ner_unaufgeloest"} + ], + "ner_modell": "de_core_news_lg-3.7.0", + "lexikon_version": "2026-09-01" +} +``` + +### Betrieb + +Eine Sicht `akteure_unaufgeloest` aggregiert unaufgelöste Formen nach Häufigkeit. +Wöchentliche Durchsicht; häufige Formen wandern ins Lexikon. Das ist der einzige +laufende Handarbeitsposten der Phase. + +--- + +## 5. Modul: Geokodierung (`ebene = 'geo'`) + +### Zweck + +Auflösung von Ortsnennungen auf Koordinaten und Verwaltungsebenen. + +### Gazetteer + +GeoNames-Dump für DE, AT, CH plus `countryInfo` und `admin1CodesASCII`, lokal +und versioniert. Zusätzlich eine Ausschlussliste für notorische Fehlauslöser: +Straßennamen mit Ortsbezug, Zeitungsnamen (`"Frankfurter Allgemeine"`), +Produktnamen, Personennamen die Ortsnamen sind. + +```sql +CREATE TABLE geo_orte ( + geonames_id bigint PRIMARY KEY, + name text NOT NULL, + aliase text[] NOT NULL DEFAULT '{}', + land text NOT NULL, + adm1 text, + adm2 text, + lat double precision NOT NULL, + lon double precision NOT NULL, + einwohner bigint, + feature_class char(1), + feature_code text +); +``` + +### Verfahren + +1. Ortskandidaten aus dem NER-Durchlauf (Typ `LOC`/`GPE`) übernehmen — kein + zweiter NER-Lauf. +2. Ausschlussliste anwenden. +3. Gazetteer-Match. Bei Mehrdeutigkeit Rangfolge: (a) im Text genanntes + übergeordnetes Gebiet, (b) Ressort/Regionalbezug der Quelle, (c) Einwohnerzahl. + Die Regel ist fest und dokumentiert, nicht gelernt. +4. Ableitung eines `hauptort` je Item: der Ort mit der höchsten Gewichtung aus + Position (Titel > Teaser > Volltext) und Nennungshäufigkeit. Bei Gleichstand + der spezifischste (höchste Verwaltungsebene). + +### Nutzlast + +```json +{ + "orte": [ + {"geonames_id": 2879139, "form": "Leipzig", "land": "DEU", + "adm1": "13", "lat": 51.33962, "lon": 12.37129, + "feld": "titel", "start": 12, "ende": 19, "konfidenz": 0.93} + ], + "hauptort": 2879139, + "gazetteer_version": "2026-08-15" +} +``` + +Items ohne Ortsbezug bekommen `"hauptort": null` und eine leere Liste — die +Coding-Zeile entsteht trotzdem, damit „nicht geokodiert" von „kein Ort gefunden" +unterscheidbar bleibt. + +--- + +## 6. Modul: Tonalität (`ebene = 'tonalitaet'`) + +### Zweck + +Ein Tonwert je Item als Aggregatsignal. + +### Warnung zur Interpretation + +Der Wert ist **nicht** GDELTs `AvgTone` und darf nicht als solcher ausgegeben +werden. Er misst lexikalische Polarität eines deutschen Sentimentlexikons auf +einem kurzen Text. Bei Nachrichtensprache ist die Aussagekraft je Einzelitem +gering; erst im Aggregat über Quelle und Zeit wird sie brauchbar. Das gehört +in die Felddokumentation und auf die Website, nicht in eine Fußnote. + +### Verfahren + +SentiWS + GerVADER, beide lokal, beide versioniert. Negation und Intensivierer +über Abhängigkeitsparse. Getrennte Werte für Titel und Teaser, weil +Titelzuspitzung ein eigenständig interessantes Signal ist. + +### Nutzlast + +```json +{ + "titel": {"ton": -2.41, "pos": 0.8, "neg": 3.2, "polar_anteil": 0.18}, + "teaser": {"ton": -0.87, "pos": 1.9, "neg": 2.8, "polar_anteil": 0.11}, + "skala": "sentiws-2.0/gervader-1.1", + "wortzahl": {"titel": 9, "teaser": 41} +} +``` + +Wertebereich −10 bis +10, dokumentiert. `polar_anteil` (Anteil polarer Wörter) +dient als Zuverlässigkeitsindikator — niedrige Werte bedeuten, dass der Ton +auf wenigen Treffern beruht. + +--- + +## 7. Modul: Embeddings (`ebene = 'embedding'`) + +### Zweck + +Vorfilter für die Kandidatengenerierung in Phase 3b. Kein Selbstzweck, kein +Clustering in Phase 2. + +### Verfahren + +Modell: ein mehrsprachiges Satz-Embedding mit guter Deutschabdeckung +(`intfloat/multilingual-e5-large` oder `jina-embeddings-v3`), lokal auf Bebop, +CPU-Inferenz, feste Version. Eingabe: Titel + Teaser, auf feste Tokenzahl +gekürzt. Deterministisch — kein Dropout, feste Präzision. + +Speicherung: `pgvector`. + +```sql +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE item_embeddings ( + raw_item_id bigint NOT NULL, + coder_version text NOT NULL, + vektor vector(1024) NOT NULL, + PRIMARY KEY (raw_item_id, coder_version) +); + +CREATE INDEX item_embeddings_hnsw + ON item_embeddings USING hnsw (vektor vector_cosine_ops); +``` + +Der Vektor liegt bewusst **nicht** in `codings.nutzlast` — JSONB-Vektoren sind +weder indizierbar noch platzsparend. Die Coding-Zeile hält nur die Kennung: + +```json +{"modell": "multilingual-e5-large", "dim": 1024, "gespeichert": true} +``` + +--- + +## 8. Ausführung + +### Auslöser + +Nach jedem Poll-Lauf für die in diesem Slot neuen oder geänderten Items. +Zusätzlich ein Backfill-Modus für den Gesamtbestand bei +`coder_version`-Wechsel. + +### Reihenfolge + +``` +dublette ──► akteure ──► geo ──► tonalitaet ──► embedding + │ ▲ + └────────┘ (geo nutzt NER-Ausgabe von akteure) +``` + +Module sind bis auf diese eine Abhängigkeit unabhängig und einzeln neu baubar. + +### Idempotenz + +`INSERT … ON CONFLICT (raw_item_id, coder_version, ebene) DO UPDATE`. +Ein Modul zweimal laufen zu lassen ändert nichts. + +### Fehlerverhalten + +Ein fehlgeschlagenes Modul blockiert die anderen nicht. Fehler landen in einer +`p2_laeufe`-Tabelle analog zu `poll_laeufe`: Slot, Modul, Anzahl verarbeitet, +Anzahl fehlgeschlagen, Dauer, Fehlermeldung. Keine stillen Ausfälle. + +### Budget + +Ziel: vollständige Verarbeitung eines 15-Minuten-Slots (~200–400 Items bei +40 Feeds) in unter 3 Minuten Wall-Clock auf Bebop. Wird das gerissen, ist das +Embedding-Modul der erste Kandidat zur Auslagerung in einen Nachlauf. + +--- + +## 9. Öffentliche Darstellung + +Die Phase-2-Seite zeigt, was die Anreicherung tut, ohne Volltext zu berühren: + +- Karte der Hauptorte des Tages (aus `geo`) +- Häufigste Akteure der Woche, mit Trend +- Übernahmegruppen: welche Meldung von wie vielen Häusern gleichlautend + ausgespielt wurde, mit Zeitversatz. Das ist der publizistisch interessanteste + Teil. +- Tonalität je Quelle im Zeitverlauf — mit sichtbarem Hinweis auf die + Interpretationsgrenzen aus Abschnitt 6. + +Kein Modul ist ohne die Grenzen seiner Aussagekraft darzustellen. + +--- + +## 10. Abnahmekriterien + +| Kriterium | Schwelle | +|---|---| +| Dubletten: Präzision auf 200er-Stichprobe | ≥ 0.98 | +| Akteure: aufgelöste Nennungen an allen Nennungen | ≥ 0.75 | +| Akteure: Präzision auf 200er-Stichprobe | ≥ 0.95 | +| Geo: Hauptort korrekt auf 200er-Stichprobe | ≥ 0.85 | +| Laufzeit je Slot | < 3 min | +| Bitgleicher Neuaufbau bei gleicher `coder_version` | verpflichtend | +| Kein Text > 100 Zeichen in `codings.nutzlast` | verpflichtend, automatisch geprüft | + +Die letzte Zeile ist als Constraint oder Test zu erzwingen, nicht als Vorsatz. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d24c5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +psycopg[binary]>=3.2