Database connection

This commit is contained in:
irrlicht
2026-09-07 12:25:07 +02:00
parent 91931a47b0
commit d1bd1016f7
6 changed files with 531 additions and 0 deletions
+414
View File
@@ -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.