Files
2026-09-07 12:25:07 +02:00

415 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.