Prod fuehrt Phase 1 als Quadlet unter
~/.config/containers/systemd/wurzelwerk-ingest.container; Phase 2 legt
sich daneben. Damit faellt die eigene .service-Unit weg - zwei Wege,
dasselbe zu tun, sind einer zuviel.
Drei Dinge, die dabei nicht offensichtlich sind:
Type=oneshot Quadlet setzt sonst Type=notify, und die Unit
gaelte als gestartet, sobald der Container
laeuft, nicht wenn er durch ist.
kein RemainAfterExit Das Handbuch empfiehlt es fuer oneshot, aber bei
Timer-Aktivierung bliebe der Auftrag im Zustand
"started" und der Timer loeste ihn nie wieder
aus.
--env-file EnvironmentFile= im Abschnitt [Container] ist
nicht systemds EnvironmentFile=. Quadlet reicht
die Datei an podman weiter, und podman entfernt
keine Anfuehrungszeichen um Werte.
Network=bebop-net steht jetzt direkt in der Unit; P2_NETZ entfaellt.
Type=oneshot schaltet ausserdem die Startzeitgrenze ab, womit der Grund
fuer TimeoutStartSec=1800 wegfaellt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
522 lines
24 KiB
Markdown
522 lines
24 KiB
Markdown
# Wurzelwerk — Phase 2: Aufbereitung
|
||
|
||
Reichert `raw_items` deterministisch an: Dubletten, Akteure, Orte, Tonalität,
|
||
Embeddings. Kein LLM, kein Sampling, keine Netzabfrage zur Laufzeit — das
|
||
Ergebnis hängt allein an der Eingabe und der `coder_version`.
|
||
|
||
Setzt das Ingest-Schema aus [swp-01-ingest](https://git.bnd.wtf/irrlicht/swp-01-ingest)
|
||
voraus und schreibt ausschließlich nach `codings`. `raw_items` bleibt unberührt.
|
||
|
||
Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
|
||
|
||
## Dateien
|
||
|
||
| Datei | Zweck |
|
||
|---|---|
|
||
| `phase_02_spec.md` | Spezifikation der fünf Module und ihrer Abnahmekriterien |
|
||
| `migrations/` | Schemaänderungen, aufsteigend anzuwenden |
|
||
| `db.py` | Auflösung von `DATABASE_URL`, Verbindung |
|
||
| `check_db.py` | Rauchtest: Anmeldung, Bestand, fehlende Voraussetzungen |
|
||
| `normalisierung.py` | Textnormalisierung und Trigramme, gemeinsam für alle Module |
|
||
| `version.py` | `coder_version` aus den Komponenten |
|
||
| `dubletten.py` | Modul 1: exakte Übernahmen |
|
||
| `test_dubletten.py` | Testfälle dazu, ohne Datenbank |
|
||
| `revisionen.py` | Modul: nachträglich geänderte Schlagzeilen |
|
||
| `test_revisionen.py` | Testfälle dazu, ohne Datenbank |
|
||
| `embeddings.py` | Modul 5: Satzvektoren |
|
||
| `akteure.py` | Modul 4: Akteursauflösung gegen das Lexikon |
|
||
| `test_akteure.py` | Testfälle dazu, ohne Datenbank und ohne spaCy |
|
||
| `lexikon.py` | Wikidata spiegeln (`--spiegeln`), von Hand, nicht im Takt |
|
||
| `lexikon_laden.py` | Spiegel in `akteure` und `amtsinhaber` laden |
|
||
| `lexikon/` | Der Spiegel selbst, versioniert, plus `hand.json` |
|
||
| `phase2.py` | Läufer: alle Module in fester Reihenfolge |
|
||
| `Containerfile` | Laufzeitumgebung, identisch auf Dev und Prod |
|
||
| `deploy/` | systemd-Units und Umgebungsbeispiel für Prod |
|
||
|
||
## Einrichtung
|
||
|
||
```sh
|
||
python3 -m venv .venv
|
||
.venv/bin/pip install -r requirements.txt
|
||
cp .env.example .env && chmod 600 .env && $EDITOR .env
|
||
.venv/bin/python check_db.py
|
||
```
|
||
|
||
## Migrationen
|
||
|
||
Aufsteigend anwenden, auf Dev und Prod dieselben Dateien in derselben
|
||
Reihenfolge. Welche schon liefen, steht in `schema_migrationen`:
|
||
|
||
```sh
|
||
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-000-migrationsbuch.sql
|
||
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-001-codings-mehrschichtig.sql
|
||
|
||
# Stand abfragen
|
||
psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order by name'
|
||
```
|
||
|
||
Die Zählung trägt das Präfix `p2-`, weil die Ingest-Migrationen eine eigene
|
||
haben und beide auf dieselbe Datenbank laufen.
|
||
|
||
Zwei Migrationen brauchen einen Superuser, weil sie Rechte vergeben, die sie
|
||
selbst noch nicht haben: `p2-002` (`create extension vector` — pgvector ist
|
||
nicht als `trusted` gekennzeichnet) und `p2-003` (Besitzerwechsel). Im
|
||
Dev-Container über den lokalen Socket:
|
||
|
||
```sh
|
||
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-003-eigentuemer.sql
|
||
```
|
||
|
||
Alle übrigen laufen über `DATABASE_URL`. Seit `p2-003` gehören die Tabellen
|
||
der Rolle `wurzelwerk` statt `postgres` — vorher scheiterte jedes `alter table`
|
||
mit `must be owner of table codings`, und die Datenbank war damit der einzige
|
||
Ausreißer auf der Instanz: `gitea` und `hedgedoc` besitzen ihre Tabellen
|
||
vollständig. Die Erweiterungen selbst (`pg_trgm`, `vector`) bleiben beim
|
||
Superuser, wo sie hingehören.
|
||
|
||
## Voraussetzungen an die Datenbank
|
||
|
||
Das Embedding-Modul braucht `pgvector`. Im offiziellen Image `postgres:latest`
|
||
ist die Erweiterung nicht enthalten; der Postgres-Container läuft deshalb auf
|
||
`docker.io/pgvector/pgvector:pg18-trixie` — derselbe Build wie das offizielle
|
||
Image (`18.6-1.pgdg13+2`, Debian 13, glibc 2.41), nur mit pgvector zusätzlich.
|
||
|
||
Der Tag ist mit Bedacht gewählt. Auf derselben Instanz liegen weitere Dienste;
|
||
ein Wechsel der Hauptversion oder der Distribution wäre ein Ausfall, ein
|
||
Wechsel der glibc ein Collation-Problem in den Textindizes aller Datenbanken
|
||
(`datlocprovider = 'c'`). `pg18-trixie` schließt beides aus und zieht nur
|
||
Minor-Updates nach. Die versionierten Tags (`0.8.4-pg18-trixie`) taugen dafür
|
||
nicht: sie hängen der Postgres-Minor-Version hinterher und hätten 18.6 auf
|
||
18.4 zurückgedreht.
|
||
|
||
Das Anlegen der Erweiterung in `wurzelwerk` ist davon getrennt und geschieht
|
||
in `p2-002`, nicht beim Containerstart.
|
||
|
||
Ein Vektor belegt 4100 Byte. Bei den derzeit rund 1400 Items pro Tag sind das
|
||
etwa 2 GB im Jahr, mit HNSW-Index eher das Doppelte — tragbar. Die in der
|
||
Spezifikation angenommenen 200–400 Items je Slot wären rund 38.000 am Tag und
|
||
damit etwa 55 GB im Jahr, **je `coder_version`**. Ein Modellwechsel verdoppelt
|
||
den Bedarf, bis der alte Bestand gelöscht ist. Sollte es eng werden, halbiert
|
||
`halfvec(1024)` den Platz.
|
||
|
||
## Grenzen des Datensatzes
|
||
|
||
Volltext liefert nur ein Teil der Quellen — Ingest liest RSS, und viele Häuser
|
||
geben dort keinen `content:encoded` heraus (Stand 7.9.2026: faz 311/489,
|
||
heise 174/196, aber derstandard, welt, dlf, taz, sz jeweils 0). Das ist keine
|
||
Lücke, die sich schließen lässt, sondern eine Eigenschaft der Quellen. Module
|
||
müssen auf Titel und Teaser allein tragfähig bleiben und dürfen die
|
||
Vergleichsbasis nicht davon abhängig machen, ob ein Item zufällig Volltext hat.
|
||
|
||
## Modul 1: Dubletten
|
||
|
||
```sh
|
||
.venv/bin/python dubletten.py --backfill # Gesamtbestand
|
||
.venv/bin/python dubletten.py --slot 2026-09-07T08:00 # ein Poll-Slot
|
||
.venv/bin/python dubletten.py --backfill --trocken --stichprobe 200
|
||
.venv/bin/python dubletten.py --backfill --basis titel+teaser # die andere Basis
|
||
.venv/bin/python -m unittest test_dubletten
|
||
```
|
||
|
||
Normalisierung, SimHash über Wort-Trigramme, Kandidaten über gemeinsame
|
||
SimHash-Bänder, Bestätigung über Jaccard, Union-Find. Jedes verarbeitete Item
|
||
bekommt eine Zeile, auch ein Einzelstück — sonst ließe sich „keine Dublette"
|
||
nicht von „nicht verarbeitet" unterscheiden. Unveränderte Zeilen werden nicht
|
||
angefasst, `kodiert_am` bleibt also stehen, wenn sich nichts geändert hat.
|
||
|
||
Gesamtbestand (3407 Items) 0,2 s, ein Slot 0,4 s. Das Zeitbudget von 3 Minuten
|
||
je Slot ist für dieses Modul kein Thema.
|
||
|
||
**Ergebnis auf dem Bestand vom 4.–7.9.2026:** 135 Gruppen mit 290 Mitgliedern,
|
||
davon 63 Gruppen mit 143 Items über mehrere Häuser hinweg — das ist der
|
||
eigentliche Zweck. Größte Gruppe: fünf Häuser mit derselben Zeile über den
|
||
Start der Isar-Aerospace-Rakete.
|
||
|
||
### Vier Abweichungen von der Spezifikation
|
||
|
||
**Vergleichsbasis ist der Titel, nicht Titel + Teaser.** Gemessen wurde beides.
|
||
Über Titel + Teaser findet das Verfahren auf diesem Bestand *keine einzige*
|
||
hausübergreifende Übernahme; über den Titel allein 63 Gruppen. Grund: die
|
||
Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den Teaser
|
||
selbst, der Teaser verdünnt also genau das Signal, das trägt. Die Zahlen
|
||
stehen in `NOTES.md`. `--basis titel+teaser` schaltet auf die alte Basis
|
||
zurück; sie ergibt eine eigene `coder_version` und überschreibt nichts.
|
||
|
||
**Das Ressortkürzel vor dem Titel wird abgetrennt.** `Raumfahrt: Deutsche
|
||
Rakete …` und `Deutsche Rakete …` sind dieselbe Meldung, `Notfälle:` und
|
||
`Flugverkehr:` stehen vor derselben dpa-Zeile. Die Regel ist eng gefasst:
|
||
höchstens vier Wörter vor dem Doppelpunkt, mindestens fünf danach — sonst
|
||
verlöre `Habeck: Wir haben uns geirrt` seine Aussage.
|
||
|
||
**Die Gruppen-UUID kommt vom Leitartikel, nicht aus der Mitgliedermenge.**
|
||
Sonst änderte ein später eintreffendes Mitglied die Kennung einer Gruppe, die
|
||
Phase 3 bereits kodiert hat. Der Leitartikel ist das älteste Mitglied; ein
|
||
neues Mitglied ist zwangsläufig jünger und verschiebt das Minimum nicht.
|
||
|
||
**Dasselbe Haus an verschiedenen Tagen wird nicht gepaart.** Wiederkehrende
|
||
Formate — „tagesschau in 100 Sekunden", „Wetter", „Die Nachrichten" — sind
|
||
zeichengleich, aber jeweils ein eigener Vorgang. Ohne diese Regel wuchsen sie
|
||
transitiv zu einer Gruppe aus 19 Sendungen über drei Tage zusammen.
|
||
|
||
### Warum ein Slot-Lauf seinen Rand wachsen lässt
|
||
|
||
Die Items eines Slots brauchen ihre Partner im Datumsfenster, diese Partner
|
||
ihrerseits ihr eigenes Fenster. Ein fester Rand genügt trotzdem nicht:
|
||
Übernahmen bilden Ketten — dieselbe Schlagzeile erscheint am Montag bei einem,
|
||
am Dienstag beim zweiten, am Mittwoch beim dritten Haus. Wird eine solche
|
||
Kette am geladenen Rand abgeschnitten, fällt die Gruppe anders aus als im
|
||
Backfill.
|
||
|
||
Der Lauf lädt deshalb, gruppiert, und lädt erneut, solange eine Gruppe bis an
|
||
den Rand reicht. Geschrieben wird nur der innere Bereich, in dem jedes Item
|
||
seine vollständige Nachbarschaft gesehen hat.
|
||
|
||
Nachgewiesen: nach einem Backfill ändern beliebig viele Slot-Läufe nichts mehr
|
||
(`neu 0, geändert 0`), und ein anschließender Backfill ebenso wenig. Live-Lauf
|
||
und Neuaufbau liefern dasselbe Ergebnis — Invariante 2 und 4.
|
||
|
||
### Was bleibt
|
||
|
||
Auf dem gemessenen Tag findet das Verfahren 42 von 45 hausübergreifenden
|
||
Paaren. Die drei fehlenden gehen am SimHash-Bandvorfilter verloren, nicht an
|
||
den Schwellen — bei sechs bis acht Trigrammen je Titel ist SimHash grob. Ein
|
||
Trigramm-Index als zweiter Kandidatenweg würde sie einfangen; das ist nicht
|
||
gebaut, weil die Spezifikation Präzision über Recall stellt.
|
||
|
||
Die Stichprobe (`--stichprobe 200`) schreibt die Paare zum Prüfen von Hand
|
||
heraus, die zweifelhaftesten zuerst. Sie enthält Titel im Klartext und ist
|
||
deshalb nicht eingecheckt.
|
||
|
||
## Modul: Revisionen
|
||
|
||
Nicht in der Spezifikation. Der Befund fällt aus einer Eigenschaft der
|
||
Phase-1-Umsetzung ab: Ingest schreibt jede geänderte Fassung eines Items nach
|
||
`raw_item_versionen` fort, statt sie zu überschreiben. Damit steht in der
|
||
Datenbank, was sonst nur mit eigener Erhebung zu bekommen ist — welches Haus
|
||
seine Schlagzeile nachträglich umschreibt, und wie.
|
||
|
||
```bash
|
||
python3 revisionen.py --backfill
|
||
python3 revisionen.py --slot 2026-09-07T08:00
|
||
python3 revisionen.py --backfill --trocken --stichprobe 140
|
||
```
|
||
|
||
Das Modul braucht kein Modell, kein Lexikon und keinen Netzzugriff — nur einen
|
||
Wortvergleich zweier Fassungen desselben Artikels (`difflib`).
|
||
|
||
### Die acht Einstufungen
|
||
|
||
Zwischen zwei aufeinanderfolgenden Fassungen, von der engsten Prüfung zur
|
||
weitesten:
|
||
|
||
| Einstufung | Bedeutung |
|
||
|---|---|
|
||
| `unveraendert` | nichts geändert |
|
||
| `formal` | nur Zeichensetzung, Anführungszeichen, Schreibung |
|
||
| `tippfehler` | ein Wort korrigiert (»Waldesaster« → »Wahldesaster«) |
|
||
| `kopfwechsel` | nur der Teil vor dem Doppelpunkt |
|
||
| `erweiterung` | nur ergänzt — meist eine neue Tatsache |
|
||
| `kuerzung` | nur gestrichen |
|
||
| `umformulierung` | beides, die Aussage steht noch |
|
||
| `neufassung` | die Schlagzeile ist ausgetauscht |
|
||
|
||
Dazu `zurueckgenommen`, das nur auf Item-Ebene vorkommt: zwischendurch geändert,
|
||
am Ende steht wieder die erste Fassung. Genau ein Fall im Bestand — die FAZ
|
||
setzte ein »dessen« ein und strich es wieder.
|
||
|
||
`art` und `aehnlichkeit` vergleichen die **erste mit der letzten** Fassung —
|
||
was ist aus der Schlagzeile geworden. `arten` hält jeden einzelnen Schritt
|
||
fest — auf welchem Weg.
|
||
|
||
### Fortschreibungen müssen abziehbar sein
|
||
|
||
Ein Liveticker wechselt seine Überschrift stündlich, ohne dass eine Redaktion
|
||
ihre Darstellung revidiert hätte. Als »Neufassung« gezählt überdeckt er genau
|
||
den Befund, um den es geht. Zwei Merkmale in der Nutzlast machen ihn abziehbar:
|
||
|
||
* `ticker_markiert` — der Titel nennt sich selbst so (»Liveticker«,
|
||
»Newsblog«, »Liveblog«, »Livestream«, »Live:«). Trifft 10 Items.
|
||
* `kopf_stabil` — der Text vor dem ersten Doppelpunkt blieb über alle Fassungen
|
||
derselbe. Zusammen mit `art = neufassung` ist das die Bauform einer
|
||
Fortschreibung: feste Rubrik, ausgetauschter Stand. Trifft 33 Items.
|
||
|
||
Absichtlich zwei Merkmale statt einer Kategorie »Ticker«: das erste ist eine
|
||
Selbstauskunft des Hauses, das zweite eine Beobachtung am Satzbau. Sie zu
|
||
einem Urteil zu verschmelzen würde eine Sicherheit behaupten, die keins von
|
||
beiden hat.
|
||
|
||
Auf `+++`-Kästen wird bewusst nicht geprüft: das Handelsblatt setzt
|
||
»+++ USA +++:« als Ressortmarke, nicht als Tickerkennzeichen.
|
||
|
||
### Zwei Kopfbegriffe, mit Absicht
|
||
|
||
`normalisierung.ressort_teilen()` prüft, ob der Kopf höchstens vier Wörter
|
||
misst und dahinter noch ein vollständiger Satz steht. Diese Vorsicht ist nötig,
|
||
wo der Kopf *abgeschnitten* wird — »Habeck: Wir haben uns geirrt« darf seinen
|
||
nicht verlieren.
|
||
|
||
`revisionen.teilen()` verzichtet darauf, weil hier nichts abgeschnitten, nur
|
||
verglichen wird. Die Wortgrenze schadete dort: »CDU-Desaster in Sachsen-Anhalt:«
|
||
hat drei Wörter, »CDU-Desaster bei Wahl in Sachsen-Anhalt:« fünf, und der
|
||
Wechsel zwischen beiden fände sonst nicht statt.
|
||
|
||
`kopfwechsel` ist damit eine Aussage über den Satzbau, nicht über das Gewicht:
|
||
bei »Fußball-Bundesliga: X« → »X« wechselt eine Rubrik, bei »Habeck: X« →
|
||
»Scholz: X« der Sprecher. Welcher Fall vorliegt, steht in `hinzu` und
|
||
`entfernt`.
|
||
|
||
### Kein Rand nötig
|
||
|
||
Anders als beim Dublettenmodul hängt eine Zeile allein an den Fassungen ihres
|
||
eigenen Items — keine Nachbarschaft, kein Datumsfenster, kein mitzuladender
|
||
Rand. Ein Slot-Lauf lädt die Items, von denen im Slot eine neue Fassung
|
||
eintraf, samt ihrer *älteren* Fassungen, und rechnet für sie dasselbe wie ein
|
||
Backfill.
|
||
|
||
Nachgewiesen über den ganzen Bestand: 106 Slot-Läufe nacheinander erzeugen für
|
||
alle 3407 Items bitgleich dieselbe Nutzlast wie ein Backfill — 0 fehlend,
|
||
0 überzählig, 0 abweichend (Invariante 2 und 4).
|
||
|
||
### `konfidenz` bleibt leer
|
||
|
||
Die Einstufung ist regelbasiert und trifft zu oder nicht; eine Zahl daneben
|
||
behauptete eine Wahrscheinlichkeit, die das Verfahren nicht kennt. Das Maß der
|
||
Änderung steht als `aehnlichkeit` in der Nutzlast, wo es hingehört.
|
||
|
||
### Wortlisten in der Nutzlast
|
||
|
||
`hinzu` und `entfernt` halten die geänderten Wörter fest, auf je zwölf gekappt
|
||
(`gekappt` sagt, ob gekürzt wurde). Ohne sie wäre die Angabe »umformulierung«
|
||
nicht nachprüfbar; aus zwei Listen von je einem Dutzend Wörtern lässt sich
|
||
keine Schlagzeile zurückbauen. Der Constraint `codings_kein_volltext` bleibt
|
||
gewahrt — ein Testfall prüft es mit.
|
||
|
||
## Container
|
||
|
||
Phase 2 läuft auf Prod in einem rootless Podman-Container, der selbst rootless
|
||
liegt. Für die Module ist das folgenlos: Encoder-Inferenz ist reine
|
||
Userspace-Rechnung — keine Geräteknoten, keine Kernelmodule, keine
|
||
privilegierten Syscalls, kein GPU-Durchgriff.
|
||
|
||
```bash
|
||
podman build -t wurzelwerk-p2 .
|
||
podman volume create wurzelwerk-modelle
|
||
|
||
podman run --rm --network=pasta \
|
||
--volume wurzelwerk-modelle:/cache:U \
|
||
--memory=8g --shm-size=1g \
|
||
--env DATABASE_URL="postgresql://wurzelwerk:…@host.containers.internal:5432/wurzelwerk" \
|
||
localhost/wurzelwerk-p2 phase2.py --aufholen
|
||
```
|
||
|
||
`host.containers.internal` zeigt aus dem Container auf den Host, wo Postgres
|
||
seinen Port veröffentlicht. Das `:U` am Volume ist nicht optional — der
|
||
Container läuft als UID 10001, ein frisch angelegtes Volume gehört root im
|
||
User-Namespace, und ohne die Umschreibung scheitert der erste Schreibzugriff
|
||
auf den Modell-Cache.
|
||
|
||
### Vier Dinge, die der Container ausdrücklich braucht
|
||
|
||
* **`OMP_NUM_THREADS`.** PyTorch liest die CPU-Zahl am Host ab, nicht die
|
||
cgroup-Quota; ohne feste Zahl startet es mehr Threads als es Kontingent hat.
|
||
Wichtiger noch: die Threadzahl bestimmt die Reihenfolge der
|
||
Gleitkommaadditionen und damit die letzten Stellen jedes Vektors. Sie steht
|
||
deshalb in der `coder_version`.
|
||
* **Ein Volume für `/cache`.** 2,2 GB Modellgewichte im Container-Layer wären
|
||
bei jedem Neubau weg.
|
||
* **`--memory`.** Damit ein Ausrutscher den Host nicht mitnimmt.
|
||
* **`--shm-size`.** Podmans Vorgabe von 64 MB reicht nicht, sobald je
|
||
Worker-Prozesse dazukommen.
|
||
|
||
### torch aus dem CPU-Index
|
||
|
||
`pip install torch` von PyPI zieht mehrere Gigabyte CUDA-Bibliotheken nach, die
|
||
auf dem Server nichts ausrichten können — der SM750 ist ein Anzeigechip. Das
|
||
Containerfile installiert deshalb ausdrücklich aus
|
||
`https://download.pytorch.org/whl/cpu`, in einer eigenen Schicht vor dem
|
||
Kopieren der Anforderungsdateien, damit 196 MB nicht bei jeder Änderung neu
|
||
geladen werden.
|
||
|
||
### Nachgewiesene Portabilität
|
||
|
||
Die regelbasierten Module liefern im Container (Debian trixie, Python 3.13)
|
||
für alle 3407 Items bitgleich dieselbe Nutzlast wie auf der
|
||
Entwicklungsmaschine (Fedora, Python 3.14) — `neu 0, geändert 0, unverändert
|
||
3407` bei Dubletten wie Revisionen.
|
||
|
||
### Betrieb
|
||
|
||
`deploy/` enthält `wurzelwerk-p2.container` und `.timer` als Vorlage,
|
||
dazu `INBETRIEBNAHME.md` mit der Reihenfolge. Quadlet, wie Phase 1 — aber
|
||
mit `Type=oneshot` und ohne `RemainAfterExit`: Phase 2 ist kein Dienst, der
|
||
läuft, sondern ein Auftrag, der fällig wird und danach verschwindet. Mit
|
||
`RemainAfterExit=yes` bliebe die Unit im Zustand „started" stehen, und der
|
||
Timer könnte sie nie wieder auslösen.
|
||
|
||
Der Timer feuert versetzt zum 15-Minuten-Raster von Phase 1
|
||
(`*:03,18,33,48`), damit der Slot vollständig in `raw_items` steht, bevor
|
||
daraus kodiert wird. `phase2.py --aufholen` arbeitet alle Slots nach, denen
|
||
eine Kodierung fehlt — ein ausgefallener Lauf holt sich beim nächsten Mal
|
||
selbst ein.
|
||
|
||
Zugangsdaten stehen in `~/.config/wurzelwerk/database.env` und
|
||
`p2.env` (chmod 600), nicht im Repo und nicht in der Unit. Vorlage:
|
||
`deploy/p2.env.beispiel`. Beide gehen als `--env-file` an podman, nicht über
|
||
systemds `EnvironmentFile=` im Abschnitt `[Service]` — podman entfernt keine
|
||
Anführungszeichen um Werte, systemd schon.
|
||
|
||
## Modul 5: Embeddings
|
||
|
||
```bash
|
||
python3 embeddings.py --backfill --index
|
||
python3 embeddings.py --slot 2026-09-07T08:00
|
||
python3 embeddings.py --nachweis 64 # Determinismus messen, nichts schreiben
|
||
```
|
||
|
||
`intfloat/multilingual-e5-large`, 1024 Dimensionen, MIT-Lizenz. Der Vektor
|
||
liegt in `item_embeddings`, die Coding-Zeile der Schicht `embedding` hält nur
|
||
die Kennung — ein Vektor in JSONB wäre weder indizierbar noch platzsparend.
|
||
|
||
Kein LLM: ein Encoder. Text hinein, Vektor heraus — kein Prompt, keine
|
||
Sampling-Temperatur, keine Textausgabe, feste Gewichte, nach dem ersten Laden
|
||
kein Netzzugriff (`HF_HUB_OFFLINE=1`, Modell lädt in 2 s aus dem Volume).
|
||
|
||
### Warum feste Tokenlänge das Kernstück ist
|
||
|
||
Ein Encoder rechnet stapelweise. Füllt man nur bis zur längsten Zeile des
|
||
Stapels auf — was jede Bibliothek von sich aus tut —, ändert sich die Form der
|
||
Matrizen mit der Zusammensetzung des Stapels und damit die Reihenfolge der
|
||
Gleitkommaadditionen. Ein Slot-Lauf über 31 Items lieferte dann andere letzte
|
||
Stellen als ein Backfill über 3407, und die Zusage »Live-Lauf gleich Neuaufbau«
|
||
wäre hin.
|
||
|
||
`embeddings.py` füllt deshalb **jede** Zeile auf feste 128 Token auf. Jeder
|
||
Stapel hat dieselbe Form, eine Zeile hängt nicht mehr von ihren Nachbarn ab.
|
||
|
||
Gemessen (`--nachweis 64`, im Container):
|
||
|
||
```
|
||
bitgleich einzeln: 64/64 groesste Abweichung 0.00e+00
|
||
bitgleich rueckwaerts:64/64 groesste Abweichung 0.00e+00
|
||
```
|
||
|
||
Dieselben Zeilen einzeln kodiert, im vollen Stapel und in umgekehrter
|
||
Reihenfolge — bitidentisch. Deshalb wird `transformers` direkt benutzt und
|
||
nicht `sentence-transformers`: letzteres kapselt genau diese Stelle weg (und
|
||
zöge scipy und scikit-learn nach, die hier nichts tun).
|
||
|
||
128 Token ist gemessen, nicht geraten: Median 62, p95 90, p99 107. Drei von
|
||
3407 Items reißen die Grenze, davon zwei Sendungsabläufe (»tagesschau 20:00
|
||
Uhr«), die alle Themen der Sendung auflisten. Der Lauf meldet die Zahl und
|
||
warnt erst oberhalb von einem Prozent.
|
||
|
||
### Was in die `coder_version` eingeht
|
||
|
||
Modell **und Modellrevision** (`3d7cfbda…`), Tokenlänge, Füllart, Pooling,
|
||
Normierung, dtype, Stapelgröße, **Threadzahl** und **torch-Version**. Die
|
||
letzten beiden, weil sie die Reihenfolge der Summen bzw. die gewählten Kernel
|
||
bestimmen. Der Hash sagt damit nicht nur, welches Modell gemeint war, sondern
|
||
welche Gewichte in welcher Umgebung gerechnet haben.
|
||
|
||
### Was die Vektoren leisten
|
||
|
||
Sie finden dieselbe Geschichte in anderer Formulierung — genau das, was das
|
||
Dublettenmodul nicht kann. Paare über 0.94 Kosinus, die Modul 1 nicht
|
||
zusammengeführt hat:
|
||
|
||
```
|
||
0.992 [zeit] Michael Mendl: Schauspieler Michael Mendl mit 82 Jahren gestorben
|
||
[faz] Im Alter von 82 Jahren: Charakterdarsteller Michael Mendl gestorben
|
||
0.991 [tagesschau] Friedensbemühungen - Trump-Gesandte reisen nach Moskau und Kiew
|
||
[welt] Trump-Gesandte in Moskau und Kiew erwartet
|
||
```
|
||
|
||
Phase 2 clustert daraus nichts. Der Vektor ist Vorfilter für die
|
||
Kandidatengenerierung in Phase 3b (Abschnitt 7 der Spezifikation).
|
||
|
||
### Laufzeit und Platz, gemessen
|
||
|
||
3407 Items in **683 s** bei 4 Threads (5 Items/s). Beim jetzigen Aufkommen von
|
||
durchschnittlich 32 Items je Slot sind das ~7 s, beim größten Slot des
|
||
Bestands (812 Items) ~160 s — bei einem 15-Minuten-Raster unkritisch.
|
||
|
||
`item_embeddings` belegt 19 MB Heap für 3407 Vektoren, der HNSW-Index weitere
|
||
26 MB. Der Index kostet also mehr als die Daten; die Faustregel »mit HNSW eher
|
||
das Doppelte« aus dem Abschnitt zum Platzbedarf ist damit bestätigt.
|
||
|
||
|
||
## Modul 4: Akteure
|
||
|
||
Löst Personen, Organisationen, Parteien, Staaten und Ämter auf stabile IDs
|
||
auf. Kein Netzzugriff zur Laufzeit, kein Modell, das etwas benennt — die NER
|
||
liefert nur, *wo* etwas steht, das Lexikon entscheidet, *was* es ist.
|
||
|
||
```
|
||
lexikon.py --spiegeln # Wikidata abfragen, von Hand, selten
|
||
lexikon_laden.py # Spiegel in die Tabellen
|
||
akteure.py --backfill # Gesamtbestand kodieren
|
||
akteure.py --slot '<ts>' # ein Slot
|
||
```
|
||
|
||
### Der Spiegel ist eine Abschrift, keine Auslegung
|
||
|
||
`lexikon/*.json` enthält, was Wikidata geantwortet hat — unverändert, mit
|
||
`MANIFEST.json` als Beleg (Stand, Zeilenzahl, Hash der Abfrage). Jede
|
||
Ableitung daraus — IDs, Rollen, Aliase, Amtszeiten — geschieht in
|
||
`lexikon_laden.py`. Wer eine Zuordnung ändern will, ändert den Lader und
|
||
lädt neu; Wikidata wird dafür nicht ein zweites Mal befragt.
|
||
|
||
Das ist kein Selbstzweck. Die Wikidata Query Service ist ein
|
||
spendenfinanzierter öffentlicher Dienst, und eine Pipeline, die alle
|
||
15 Minuten dagegenläuft, wäre Schmarotzertum.
|
||
|
||
### Warum das Lexikon den Typ bestimmt, nicht das Modell
|
||
|
||
`de_core_news_lg` hält `Merz` für einen Ort, `Landtagswahl` für einen Ort und
|
||
`Ukraine-Krieg` für eine Person. Die NER-Typen sind deshalb nur
|
||
Herkunftsvermerk; sie stehen in der Nutzlast als `ner_typ`, entscheiden aber
|
||
nichts. Für die Geokodierung bleiben sie als Vorfilter nützlich.
|
||
|
||
### Was eine Auflösung wert ist
|
||
|
||
| Methode | Konfidenz | Bedeutung |
|
||
|---|---|---|
|
||
| `alias` | 0,95 | volle Namensform oder Alias aus dem Lexikon |
|
||
| `alias_ohne_anrede` | 0,90 | nach Abzug von »US-Präsident«, »Bundesminister« |
|
||
| `amt` | 0,90 | Amt, gegen `pubdate` auf eine Person aufgelöst |
|
||
| `nachname` | 0,80 | Nachname, im Lexikon eindeutig |
|
||
| `fuzzy` | 0,60 | Editierdistanz, längenabhängig begrenzt |
|
||
|
||
Bei Mehrdeutigkeit entscheidet der Kontext (wird die Partei eines Kandidaten
|
||
im selben Item genannt?), und wenn auch der nicht reicht, bleibt die Nennung
|
||
**unaufgelöst**. Ein Münzwurf wäre hier der schlimmere Fehler, weil er in
|
||
der Zeitreihe nicht auffällt.
|
||
|
||
### Ämter sind zeitabhängig, und das ist der Punkt
|
||
|
||
`amtsinhaber` hält fest, wer wann welches Amt hatte. »Der Kanzler« löst am
|
||
1. März 2023 auf Scholz auf und am 7. September 2026 auf Merz. Die Intervalle
|
||
sind halboffen: der Tag der Übergabe gehört dem Nachfolger. Ohne diese
|
||
Konvention wäre jeder Amtswechsel eine Überschneidung, weil Wikidata den
|
||
Übergabetag bei beiden einträgt.
|
||
|
||
### Keine Ausschlussbeschränkung gegen Überschneidungen
|
||
|
||
Sie wäre falsch. Doppelspitzen sind im deutschen Parteiensystem der
|
||
Normalfall, und Bremen führt »Bürgermeister« als kollegiales Amt mit mehreren
|
||
gleichzeitigen Inhabern. Gleichzeitig *ist* eine Überschneidung beim
|
||
Kanzleramt ein Datenfehler. Die Unterscheidung hängt am Amt, nicht an der
|
||
Zeile, und lässt sich in einer Beschränkung nicht ausdrücken — also macht die
|
||
Sicht `amtsinhaber_ueberschneidungen` sie sichtbar, statt sie zu verbieten.
|
||
|
||
### Handpflege
|
||
|
||
`lexikon/hand.json` ergänzt, was durch jede Spiegelabfrage fällt: die NATO
|
||
(bei Wikidata ein Militärbündnis, keine internationale Organisation), der
|
||
Bundestag (ein Parlament), Apple und Microsoft (haben kein deutsches Label).
|
||
Jeder Eintrag trägt eine Begründung. QIDs gehören **geprüft** hinein — eine
|
||
geratene QID hängt den Eintrag an ein fremdes Objekt.
|
||
|
||
Die Sicht `akteure_unaufgeloest` zählt aus, was die NER fand und das Lexikon
|
||
nicht kannte. Das ist der laufende Handarbeitsposten der Phase.
|