# 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.