Files
swp-02-aufbereitung/phase_02_spec.md
T
2026-09-07 12:25:07 +02:00

14 KiB
Raw Blame History

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

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

{
  "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:

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

{
  "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.

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

{
  "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

{
  "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.

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:

{"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.