@@ -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-<ISO-Datum>-<hash8>`
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.