Compare commits

..
11 Commits
Author SHA1 Message Date
irrlichtandClaude Opus 5 62e1923a72 Quadlet statt eigener Service-Unit, zwei Umgebungsdateien
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
2026-09-08 16:37:43 +02:00
irrlichtandClaude Opus 5 c18e94e61e Podman-Netz konfigurierbar, Vorgabe pasta, auf Prod bebop-net
Prod laesst Phase 1 und Postgres in einem eigenen Netz laufen; die
Verbindung zeigt dort auf den Rechnernamen "postgres", den nur Podmans
DNS innerhalb von bebop-net aufloest. Die Unit hatte --network=pasta
fest verdrahtet - damit waere der erste Lauf an der Namensaufloesung
gescheitert, nicht an der Anmeldung.

Jetzt ${P2_NETZ}, mit Environment=P2_NETZ=pasta als Vorgabe. Die
Umgebungsdateien werden nach Environment= gelesen und ueberschreiben sie;
p2.env.beispiel setzt bebop-net.

Damit ist auch die Warnung vor host.containers.internal hinfaellig: sie
gilt nur ohne eigenes Netz. Die Checkliste sagt das jetzt so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 16:27:43 +02:00
irrlichtandClaude Opus 5 7cecc5c0cd Inbetriebnahme auf Prod: Checkliste und zwei Umgebungsdateien
Die Unit las bisher nur p2.env. Auf Prod steht die Datenbankverbindung
schon in ~/.config/wurzelwerk/database.env und gehoert Phase 1 mit; die
Unit liest jetzt beide, database.env zuerst. p2.env kann DATABASE_URL
ueberschreiben, falls database.env auf 127.0.0.1 zeigt - aus dem
Container heraus laeuft das ins Leere, dort muss
host.containers.internal stehen.

deploy/INBETRIEBNAHME.md haelt die Reihenfolge fest. Zwei Schritte darin
sind nicht offensichtlich und beide teuer, wenn man sie auslaesst:

  lexikon.py --laden   akteure.py liest aus den Tabellen, nicht aus den
                       JSON-Dateien. Ohne diesen Schritt laeuft Modul 4
                       durch und loest nichts auf, ohne zu scheitern.
  Erstbestand von Hand Der erste Lauf laedt 2,2 GB Modellgewichte und
                       kodiert den gesamten Korpus, ~17 min je 5000
                       Items. Aus dem Timer heraus liefe er in
                       TimeoutStartSec=1800.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 12:32:40 +02:00
irrlichtandClaude Opus 5 3a9c000a0e Landtage und Regierungen im Lexikon, Rang als Gleichstandsregel
Drei neue Spiegelabfragen schliessen die gemessene Luecke im
Akteurslexikon: landtage (1539), regierung (2003), sondergesandte (5).
Lexikon 5540 -> 8856 Akteure, Aufloesung 21,1 % -> 23,2 % ueber 5018
Items. Der Gewinn steckt in wenigen Personen - Siegmund 123, Witkoff
102, Kushner 93, Lawrow 21.

Drei Abfragen, weil Wikidata drei Wege verlangt:

  landtage        P31/P279*, nicht P279* - "Mitglied des Landtag
                  Sachsen-Anhalt" ist eine Instanz von Q1939559, keine
                  Unterklasse. Dazu ein schaerferer Datumsfilter: von
                  11464 je gewaehlten Personen tragen 9700 keinerlei
                  Zeitangabe, ein blosses "kein Ende gesetzt" liesse die
                  Weimarer Republik herein.
  regierung       P1308, Amt -> derzeitiger Inhaber. Ueber die Amtszeit
                  der Person faellt Lawrow (amtiert seit 2004) aus jedem
                  Datumsfenster, ohne Fenster kommen Tausende Minister
                  herein, deren Amtszeit nie zu Ende gepflegt wurde.
  sondergesandte  Der andere Weg, fuer Witkoff - sein Amt traegt kein
                  P1308. Beide Wege in einer Abfrage laufen in die
                  Zeitgrenze der WDQS.

Kushner steht in hand.json: sein Amt endete 2021, seine Rolle nicht.

Die Erweiterung hat zuerst Schaden angerichtet. Mit den Landtagen kamen
Janine und Heiner Merz ins Lexikon, "Merz" wurde mehrdeutig, und 125
Nennungen des Bundeskanzlers fielen aus. Aus jedem "Hamburg" wurde eine
Kultusministerin (22-mal), aus "KI" ein Nachname (16-mal). Daher sieben
neue Sperreintraege und eine dritte Regel in waehle(): traegt genau ein
Kandidat den niedrigsten Rang, gewinnt er. Der Rang kommt aus der
Spiegelabfrage, Staatsfuehrung vor Bundestag vor Landtag - keine
Wichtigkeitsordnung, sondern eine Erwartung darueber, wen eine deutsche
Schlagzeile mit blossem Nachnamen meint. Abschlag 0,15, Grund steht in
der Nutzlast. Die Amtstabelle bekommt den schlechtesten Rang: sie reicht
bis 1794 zurueck, mit Rang 0 wurde aus "Teufel" ein
Ministerpraesident a. D.

Dabei fiel ein Loch im Fingerabdruck auf: NACHNAMEN_SPERRE stand nicht
in EINSTELLUNGEN_FEST. Ein Eintrag mehr aenderte das Ergebnis von 4868
auf 4820 aufgeloeste Nennungen unter derselben coder_version - genau
der Fehler, den die Version verhindern soll. Sperrliste und Rangordnung
gehen jetzt als Hash ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:11:56 +02:00
irrlichtandClaude Opus 5 a2e62a7d5e Neues Backup-Namensschema in .gitignore
Der Prod-Spiegel vom 08.09. heisst 2026-09-08_bebop-backup und faellt
nicht unter bebop_database_*. Er lag ungeschuetzt im Arbeitsverzeichnis
und traegt Rollen samt Passworthashes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:11:34 +02:00
irrlichtandClaude Opus 5 1ce7610e55 Akteure im Container, Kontakt im User-Agent
Das Abbild traegt jetzt spaCy und de_core_news_lg 3.8.0 mit fester Version
sowie den Wikidata-Spiegel. Es waechst dadurch von 1,28 auf 2,12 GB. Das
Modell liegt im Abbild statt im Cache-Volume: 568 MB, die sich nie aendern,
und der erste Lauf auf Prod soll nicht an einem Download haengen.

Gemessen: ein Slot-Lauf im Container erzeugt bitgleich dieselbe Nutzlast wie
die Workstation - 3407 Codings, 0 Abweichungen, gleiche coder_version.

Der User-Agent fuer die WDQS traegt jetzt die Impressumsadresse des
Projekts, wie es die Nutzungsrichtlinie erbittet.

NOTES.md haelt fest, woher die Zeichenkette "None" kommt: bei leerer
<description> faellt der Poller auf content:encoded zurueck, und die ZEIT
liefert dort einen Anker, dessen Text "None" lautet. Phase 1 schreibt
getreu mit, der Fehler ist stromaufwaerts. Am Live-Feed reproduziert,
8 von 15 Items.

Der Prod-Cluster-Dump ist per .gitignore ausgeschlossen - er enthaelt die
Rollen samt Passworthashes, auch die von gitea und hedgedoc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 08:31:46 +02:00
irrlichtandClaude Opus 5 7e4c31912c Akteursmodul mit Wikidata-Spiegel
Modul 4 der Spezifikation, dazu das Lexikon, auf das es sich stuetzt.

Drei Schritte, streng getrennt: die NER liefert Kandidatenspannen, das
Lexikon entscheidet den Typ, die Amtstabelle loest Aemter gegen das Datum
des Items auf. Die Trennung ist gemessen begruendet und nicht uebernommen:
de_core_news_lg haelt "Merz" fuer einen Ort, "Landtagswahl" fuer einen Ort
und "Ukraine-Krieg" fuer eine Person. Der NER-Typ steht als ner_typ in der
Nutzlast, entscheidet aber nichts.

Modellwahl gemessen: sm und lg finden fast gleich viele Entitaeten (14249
gegen 14261), aber nicht dieselben - nur 10,7 Prozent der Items bekommen
von beiden dasselbe Ergebnis, der Jaccard ueber alle Spannen liegt bei
0,590. lg findet 170-mal zusaetzlich "AfD" sowie Merz, Putin, Trump,
Kushner, Witkoff. Zeitlich kostet das nichts (7,3 gegen 8,0 Sekunden fuer
den Gesamtbestand), an Plattenplatz 568 MB.

Der Spiegel ist eine Abschrift, keine Auslegung: lexikon/*.json enthaelt
die Wikidata-Antworten unveraendert, mit MANIFEST.json als Beleg. Jede
Ableitung geschieht in lexikon_laden.py. Nur lexikon.py --spiegeln fasst
das Netz an, und es laeuft von Hand - die WDQS ist ein spendenfinanzierter
Dienst, kein Endpunkt fuer eine Pipeline im Viertelstundentakt.

p2-005 legt akteure und amtsinhaber an, dazu zwei Betriebssichten. Keine
Ausschlussbeschraenkung gegen ueberlappende Amtszeiten: Doppelspitzen sind
im deutschen Parteiensystem der Normalfall, und Bremen fuehrt
"Buergermeister" als kollegiales Amt. Die Sicht
amtsinhaber_ueberschneidungen macht Ueberlappungen sichtbar, statt sie zu
verbieten. Amtszeiten sind halboffen - der Tag der Uebergabe gehoert dem
Nachfolger, sonst waere jeder Amtswechsel eine Ueberschneidung.

Messung ueber den Gesamtbestand: 3407 Items in 21,1 Sekunden, 14652
Nennungen, 2889 aufgeloest. Ein Slot-Lauf erzeugt bitgleich dieselbe
Nutzlast wie der Gesamtlauf, 0 Abweichungen. spaCy ist stapelunabhaengig
(Stapelgroesse 64, 7, 1 und rueckwaerts: identische Spannen), der
Kunstgriff mit der festen Tokenlaenge aus dem Embeddingmodul ist hier
nicht noetig.

Zwei Fallen sind in NOTES.md festgehalten: eine geratene QID haengt einen
Eintrag an ein fremdes Objekt (Q157617 ist die Commerzbank, nicht der
Bundesrat), und FILTER (lang(?name) = "de") verliert lautlos alles ohne
deutsches Label - Apple, Microsoft und Meta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 08:27:05 +02:00
irrlichtandClaude Opus 5 cf89e012b2 Revisionsmodul, Embeddings und Container
Drei Schritte, die zusammen Phase 2 lauffaehig machen.

Revisionen (migrations/p2-004, revisionen.py)
    Ingest schreibt jede geaenderte Fassung eines Items nach
    raw_item_versionen fort. Daraus faellt ein Befund ab, den die
    Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
    nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
    Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
    kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.

    Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
    absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
    Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.

Embeddings (embeddings.py)
    multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
    128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
    haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
    ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
    Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
    Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
    gegen den 3407er-Backfill.

    Darum transformers statt sentence-transformers: letzteres kapselt genau
    diese Stelle weg.

Container (Containerfile, phase2.py, deploy/)
    Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
    Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
    dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
    gemessen, nicht behauptet.

version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
2026-09-08 07:41:27 +02:00
irrlicht 50d497925f Dublettenprüfung 2026-09-07 14:30:02 +02:00
irrlicht 861caae57c database migrations 2026-09-07 12:40:12 +02:00
irrlicht c42db46973 Readme 2026-09-07 12:40:02 +02:00
44 changed files with 59830 additions and 6 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.venv
.env
__pycache__
stichprobe-*.txt
*.md
!README.md
bebop_database_*
lexikon/MANIFEST.json.bak
+7
View File
@@ -1,3 +1,10 @@
.venv/ .venv/
__pycache__/ __pycache__/
.env .env
stichprobe-*.txt
# Datenbankspiegel von Prod. Enthaelt Rollen samt Passworthashes.
bebop_database_*
*_bebop-backup
*.dump
*.sql.gz
+62
View File
@@ -0,0 +1,62 @@
# Phase 2 als Container.
#
# podman build -t wurzelwerk-p2 .
#
# Debian trixie wie der Postgres-Container - dieselbe glibc, dieselbe
# Zeichensortierung. Fuer die Module ohne Modell spielt das keine Rolle,
# fuer kuenftige Vergleiche in der Datenbank schon.
FROM docker.io/library/python:3.13-slim-trixie
# OMP_NUM_THREADS ist keine Bequemlichkeit, sondern Voraussetzung:
# - PyTorch liest die CPU-Zahl am Host ab, nicht die cgroup-Quota. Ohne
# feste Zahl startet es im Container mehr Threads als es Kontingent hat.
# - Die Threadzahl bestimmt die Reihenfolge der Gleitkommaadditionen und
# damit die letzten Stellen jedes Vektors. Sie gehoert deshalb in die
# coder_version (siehe embeddings.py).
# 4 laeuft auf jeder Maschine, die hier in Frage kommt - Prod hat 16 Threads,
# die Entwicklungsmaschine 32.
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HF_HOME=/cache/huggingface \
OMP_NUM_THREADS=4 \
MKL_NUM_THREADS=4 \
TOKENIZERS_PARALLELISM=false
WORKDIR /app
# torch zuerst und in einer eigenen Schicht: 196 MB, die sich nicht aendern,
# wenn eine Anforderungsdatei angefasst wird. Ausdruecklich aus dem CPU-Index -
# die Vorgabe von PyPI zoege mehrere Gigabyte CUDA-Bibliotheken nach, die auf
# diesem Server nichts ausrichten koennen (der SM750 ist ein Anzeigechip).
RUN python3 -m pip install --no-cache-dir --upgrade pip \
&& python3 -m pip install --no-cache-dir \
--index-url https://download.pytorch.org/whl/cpu torch==2.14.0+cpu
COPY requirements.txt requirements-embedding.txt requirements-akteure.txt ./
RUN python3 -m pip install --no-cache-dir -r requirements.txt \
&& python3 -m pip install --no-cache-dir -r requirements-embedding.txt \
&& python3 -m pip install --no-cache-dir -r requirements-akteure.txt
# Das NER-Modell mit fester Version, direkt aus der Modellfreigabe. Es liegt
# im Abbild und nicht im Cache-Volume: 568 MB, die sich nie aendern, und der
# erste Lauf auf Prod soll nicht an einem Download haengen.
RUN python3 -m pip install --no-cache-dir \
https://github.com/explosion/spacy-models/releases/download/de_core_news_lg-3.8.0/de_core_news_lg-3.8.0-py3-none-any.whl \
&& python3 -m pip freeze > /app/requirements-lock.txt
COPY *.py ./
COPY migrations/ ./migrations/
# Der Wikidata-Spiegel gehoert ins Abbild: akteure.py laedt aus der
# Datenbank, aber lexikon_laden.py soll im Container laufen koennen, ohne
# dass jemand dafuer Wikidata anfasst.
COPY lexikon/ ./lexikon/
# Eigener Benutzer, kein root. /cache traegt die Modellgewichte und muss ein
# Volume sein - 2,2 GB im Container-Layer waeren bei jedem Neubau weg.
RUN useradd --uid 10001 --create-home wurzelwerk \
&& mkdir -p /cache/huggingface \
&& chown -R 10001:10001 /cache /app
USER 10001
ENTRYPOINT ["python3"]
CMD ["check_db.py"]
+507
View File
@@ -0,0 +1,507 @@
# Befunde aus dem Dublettenmodul
Gemessen am Bestand vom 4.–7.9.2026: 3407 Items, 12 Quellen, 106 Slots.
## Das Verfahren der Spezifikation findet keine Übernahmen
67 Gruppen, 147 Mitglieder, **null davon über Häuser hinweg**. Alle gefundenen
Gruppen sind Wiederholungen innerhalb eines Feeds.
Das ist kein Schwellenproblem, das sich durch Nachjustieren lösen ließe. Ein
vollständiger Vergleich aller 599.004 hausübergreifenden Paare des stärksten
Tages (6.9., 1162 vergleichbare Items) ergibt:
| Jaccard über Titel+Teaser | Paare über Häuser |
|---|---|
| ≥ 0.85 (Schwelle der Spec) | **0** |
| ≥ 0.7 | 1 |
| ≥ 0.5 | 3 |
| ≥ 0.3 | 8 |
Der höchste erreichte Wert im gesamten Tag liegt bei 0.65. Die Schwelle wird
nie berührt.
Die Hamming-Grenze ist dabei unschuldig: von 224 Paaren mit Jaccard ≥ 0.85
verwirft sie 6. Der Vorfilter über SimHash-Bänder kostet also fast nichts —
er ist gut, er hat nur nichts zu tun.
## Warum: der Titel wird übernommen, der Teaser nicht
Die Übernahmen sind da, sie sehen nur anders aus als angenommen. Beispiele
vom 6.9., alle mit **zeichengleichem Titel** nach Normalisierung:
```
zeit / handelsblatt Wahl in Sachsen-Anhalt: Klüssendorf: Frage nach SPD-Vorsitz …
zeit / handelsblatt Ukraine-Krieg: Selenskyj mit wenig Hoffnung auf baldigen Frieden
heise / tagesschau Deutsche Isar-Aerospace-Rakete erfolgreich ins All gestartet
handelsblatt / tagesschau +++ Iran-Krieg +++: Tote und Verletzte bei neuen Angriffen
```
Die Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den
Teaser selbst. Werden beide in einen Sack geworfen, verdünnt der Teaser genau
das Signal, das trägt: dieselben Paare landen bei Jaccard 0.29 bis 0.38.
Gemessen, wenn **nur der Titel** verglichen wird (6.9., 1144 Items mit
mindestens fünf Titelwörtern):
| Kriterium | Paare | davon über Häuser |
|---|---|---|
| Titel zeichengleich | 61 | **22** |
| Titel-Jaccard ≥ 0.75 | 16 | **9** |
31 hausübergreifende Übernahmen an einem Tag statt null. Die Differenz
zwischen „zeichengleich" und „≥ 0.75" ist fast immer ein vorangestelltes
Ressortkürzel des Hauses: `Raumfahrt: Deutsche Isar-Aerospace-Rakete …` gegen
`Deutsche Isar-Aerospace-Rakete …`, oder `Notfälle:` gegen `Flugverkehr:` vor
derselben dpa-Zeile.
## Vorschlag
Vergleichsbasis auf den **Titel** umstellen, den Teaser nur noch als
Bestätigung heranziehen, wo er auf beiden Seiten vorliegt. Zusätzlich das
führende Ressortpräfix (`Wort:` am Anfang) in der Normalisierung abtrennen —
danach fällt ein Teil der 0.75er-Fälle in die Zeichengleichheit.
Das ist eine Änderung an Abschnitt 3 der Spezifikation, nicht an ihrer Absicht:
„near-exact, kein thematisches Clustering" bleibt. Die Abnahmekriterien wären
neu zu setzen, weil sich mit der Basis auch die Grundgesamtheit ändert.
## Umgesetzt, mit diesem Ergebnis
Der Vorschlag ist gebaut und ist jetzt die Vorgabe (`--basis titel`, dazu das
abgetrennte Ressortkürzel). Auf demselben Bestand:
| | Titel + Teaser | Titel |
|---|---|---|
| Gruppen gesamt | 67 | 135 |
| Mitglieder | 147 | 290 |
| **Gruppen über mehrere Häuser** | **0** | **63** |
| Items in solchen Gruppen | 0 | 143 |
Gruppengrößen: 240 Paare, 33 Dreier, 12 Vierer, 5 Fünfer. Die größte ist
dieselbe Zeile über den Start der Isar-Aerospace-Rakete bei handelsblatt,
heise, tagesschau und zeit.
Auf dem gemessenen Tag (6.9.) erreicht das Verfahren 42 von 45
hausübergreifenden Paaren mit Jaccard ≥ 0.70. Die Schwellen kosten dabei
nichts: an der Jaccard-Schwelle gehen 0 Paare verloren, an der
Hamming-Grenze 0. Die drei fehlenden scheitern am SimHash-Bandvorfilter —
bei sechs bis acht Trigrammen je Titel ist SimHash ein grobes Werkzeug. Ein
Trigramm-Index als zweiter Kandidatenweg würde sie einfangen.
Die Schwelle selbst ist unkritisch: zwischen Jaccard 0.70 und 0.85 liegt auf
diesem Bestand kein einziges Paar. Nach dem Abtrennen des Ressortkürzels sind
die Übernahmen entweder zeichengleich oder deutlich verschieden.
## Nebenbefund: Volltext hilft hier nicht
Auf den ersten Blick läge nahe, den Volltext zu nehmen, wo er da ist. Genau
das verbietet sich: die Abdeckung ist ungleich (faz 311/489, heise 174/196,
aber derstandard, welt, dlf, taz, sz jeweils 0), und ein Paar aus einem Haus
mit und einem ohne Volltext ist der häufigste Fall überhaupt. Eine je Item
verschiedene Vergleichsbasis macht genau die Paare unauffindbar, um die es
geht.
## Nebenbefund: `agentur_vermutet` greift kaum
Zwei Belege im gesamten Bestand, beide `reuters`. Die Agenturkennung steht in
RSS-Titeln und -Teasern praktisch nie; sie taucht erst im Volltext oder in der
Bildunterschrift auf. Das Feld bleibt, liefert aber auf RSS-Basis fast immer
`null`.
## Was beim Aufräumen weggefallen ist
Nach den Experimenten einmal gemessen, welche Regel auf den 3407 Items
überhaupt jemals greift:
| Regel | Treffer |
|---|---|
| Ressortkürzel abtrennen | 1870 |
| Anführungszeichen/Striche vereinheitlichen | 654 |
| `+++ … +++` entfernen | 8 |
| NFKC | 4 |
| `Eilmeldung:` entfernen | **0** |
| Ortsmarke `Berlin (dpa) -` entfernen | **0** |
| HTML-Tags entfernen | **0** (auch im Teaser) |
| Entities auflösen im Titel | **0** |
Entfernt wurden `_EILMELDUNG` und `_ORTSMARKE`. Beide stehen so in Abschnitt 3
der Spezifikation, greifen aber auf RSS-Titeln nie — die Ortsmarke steht im
Volltext einer Agenturmeldung, nicht in der Überschrift. Kommt Volltext als
Quelle hinzu, gehören sie zurück.
Das Entfernen von HTML-Tags bleibt trotz null Treffern: Phase 1 zieht die
`links` aus dem Teaser-HTML, HTML ist im Feed also grundsätzlich zu erwarten.
Der doppelte Entity-Durchlauf (vor *und* nach dem Tag-Entfernen) ist auf einen
reduziert.
Ebenfalls raus: der ungenutzte `anzahl`-Parameter von `baender()`, zwei
ungenutzte Argumente von `stichprobe()`, der `version`-Parameter von
`eintragen()`, ein `__main__`-Block in `version.py`, der eine Version ohne
Moduleinstellungen ausgab und damit nie die echte.
Zusammengelegt: `lade()` hatte am Ende selbst gruppiert, und `main()` rechnete
dieselbe Gruppierung anschließend ein zweites Mal. Beides steckt jetzt in
`lade_und_gruppiere()` — beim Slot-Lauf gehört es ohnehin zusammen, weil sich
erst an den Gruppen zeigt, wieviel geladen werden muss.
Zwei Kommentarblöcke zur Vergleichsbasis widersprachen sich nach der
Umstellung; der ältere behauptete noch „verglichen wird immer Titel + Teaser".
Die Schwellen selbst sind alle belegt. Von 778 Kandidatenpaaren verwirft das
Datumsfenster 223, die Regel „gleiches Haus, anderer Tag" 48 und die
Hamming-Grenze 330; 177 werden bestätigt. Nur die Jaccard-Bestätigung verwirft
nichts mehr — sie bleibt als Präzisionszusage, weil SimHash kollidieren kann.
Der Kettenschnitt trifft genau eine von 135 Komponenten und bleibt aus
demselben Grund.
# Befunde aus dem Revisionsmodul
Bestand vom 4.–7.9.2026, 3407 Items, 3974 Fassungen.
## Wieviel überhaupt geändert wird
| | Items |
|---|---|
| nie geändert | 3084 |
| mindestens eine neue Fassung | 323 |
| davon mit geändertem **Titel** | 206 (357 Übergänge) |
| davon nur Teaser geändert | 111 |
| URL geändert | 78 Übergänge |
Rund 6 Prozent aller Schlagzeilen werden nach der Veröffentlichung noch
angefasst. Das ist häufiger, als man erwartet, und es verteilt sich über alle
zwölf Häuser.
## Art der Änderung (erste gegen letzte Fassung)
| Einstufung | gesamt | ohne Fortschreibung |
|---|---|---|
| umformulierung | 83 | 77 |
| neufassung | 56 | 22 |
| kopfwechsel | 26 | 26 |
| erweiterung | 14 | 13 |
| kuerzung | 11 | 11 |
| formal | 9 | 9 |
| tippfehler | 6 | 6 |
| zurueckgenommen | 1 | 0 |
»Ohne Fortschreibung« zieht die 10 selbstbenannten Ticker und die 33 Items mit
stabiler Rubrik und ausgetauschtem Stand ab. Die Spalte zeigt, wozu das nötig
ist: bei `neufassung` bleiben von 56 nur 22 übrig — zwei Drittel der scheinbar
komplett neu geschriebenen Schlagzeilen sind Liveticker, die von einem Stand
zum nächsten weiterzählen.
## Umgeschriebene Schlagzeilen je Haus (ohne Fortschreibung)
spiegel 39, zeit 26, derstandard 18, faz 17, ntv 13, tagesschau 10, welt 10,
heise 9, taz 9, handelsblatt 8, dlf 3, sz 2.
Vorsicht bei der Deutung: die Zahlen hängen an der Feedgröße (zeit 550 Items,
sz 73) und an der Frage, ob ein Haus überhaupt denselben Item-Key beibehält,
wenn es die Überschrift ändert. Wer die URL mitwechselt, erscheint bei Ingest
als neues Item und taucht hier gar nicht auf. Als Rangliste taugt das nicht,
als Nachweis, dass es geschieht, sehr wohl.
## Warum der Pluskasten hier stehen bleiben muss
`normalisierung.normalisiere()` entfernt für das Dublettenmodul »++ … ++«, weil
verschiedene Häuser denselben Agenturtext verschieden einrahmen. Die tagesschau
führt ihren Liveticker aber vollständig darin:
++ Liveticker zur Sachsen-Anhalt-Wahl: Wahllokale haben geöffnet ++
Nach der Dublettennormalisierung bleibt davon der leere String. 31 der 357
Übergänge wären damit unvergleichbar gewesen. Deshalb `pluskasten=False`.
## Was das Verfahren nicht kann
* **Unmarkierte Fortschreibungen.** Die Welt schrieb »54,4 Prozent am
Nachmittag …« zu »Schon 65,3 Prozent …« um — ein Wahlabend-Dauerartikel ohne
jedes Tickerwort und ohne feste Rubrik. Er zählt als `neufassung`. `fassungen`
und `zahlen_geaendert` stehen in der Nutzlast, damit sich das nachträglich
eingrenzen lässt; automatisch erkannt wird es nicht.
* **Getrennt- und Zusammenschreibung.** »Drohnenattacken:« → »Drohnen-Attacken:«
gilt als `kopfwechsel`, nicht als `formal`, weil die Normalisierung
Satzzeichen entfernt und daraus zwei Wörter werden. Ein Fall im Bestand.
* **Das Gewicht einer Änderung.** `kopfwechsel` sagt, *wo* geändert wurde, nicht
wie schwer es wiegt. Für die Unterscheidung sind `hinzu` und `entfernt` da.
## Nebenwirkung auf die coder_version
`version.KOMPONENTEN` bekam den Schlüssel `revision`. Weil der Hash über den
ganzen Komponentensatz läuft, ändert sich damit auch die Version des
Dublettenmoduls (`10cea100` → `51e03f9b`), obwohl an dessen Verfahren nichts
anders ist. Das ist die in `version.py` beschriebene Semantik der
Spezifikation, kein Versehen. Der Backfill kostet 0,3 Sekunden; die alte
Version wurde verworfen.
# Befunde aus dem Embeddingmodul
## Die Stapelzusammensetzung ist das eigentliche Problem
Nicht die CPU, nicht die Bibliothek, nicht der Container: dass ein Encoder
stapelweise rechnet und die Matrizenform mit dem Stapel wechselt. Wer nur bis
zur längsten Zeile des Stapels auffüllt, bekommt für dieselbe Zeile
verschiedene letzte Stellen, je nachdem, wer mit ihr im Stapel lag.
Mit fester Länge (128 Token für jede Zeile) verschwindet der Effekt
vollständig — nicht »klein«, sondern exakt null:
| Vergleich | bitgleich | größte Abweichung |
|---|---|---|
| voller Stapel gegen einzeln | 64/64 | 0.00e+00 |
| voller Stapel gegen rückwärts | 64/64 | 0.00e+00 |
Der Preis: jede Zeile kostet 128 Token Rechenzeit, auch wenn sie 62 braucht.
Bei Median 62 ist das gut Faktor zwei. Für die Zusage aus Invariante 2 ist er
es wert — und bei 7 s je Slot fällt er nicht auf.
## Tokenlängen im Bestand
Median 62, p95 90, p99 107, Maximum 233. Über 128 liegen 3 von 3407 Items:
zweimal »tagesschau 20:00 Uhr« (Sendungsablauf mit vollständiger Themenliste)
und einmal eine DLF-Hochrechnung. 256 statt 128 würde die Rechenzeit
verdoppeln, um 0,09 Prozent der Items vollständig zu erfassen — nicht
lohnend.
## Was die Vektoren finden, was SimHash nicht findet
Hausübergreifende Paare über 0.94 Kosinus, die das Dublettenmodul nicht
zusammengeführt hat — dieselbe Geschichte, anderer Wortlaut:
0.994 faz / handelsblatt Wasserdefizit in Deutschland
0.992 spiegel / handelsblatt Drohne trifft Geheimdienstzentrale in Kiew
0.992 zeit / faz Michael Mendl gestorben ("Schauspieler" / "Charakterdarsteller")
0.991 tagesschau / welt Trump-Gesandte reisen nach Moskau und Kiew
0.985 heise / handelsblatt Isar-Aerospace-Rakete erfolgreich gestartet
Das ist der Beleg dafür, dass Modul 1 und Modul 5 verschiedene Fragen
beantworten und keins das andere ersetzt.
## Container
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). Damit ist die Portabilität nicht
behauptet, sondern gemessen.
`sentence-transformers` fiel zugunsten von `transformers` weg: es kapselt das
Auffüllen der Stapel weg — also genau die Stelle, an der hier die
Reproduzierbarkeit entschieden wird — und zieht scipy und scikit-learn nach.
Ein Stolperstein beim Volume: der Container läuft als UID 10001, ein frisch
angelegtes Podman-Volume gehört root im User-Namespace. Ohne `:U` am
Volume-Argument scheitert der erste Schreibzugriff auf den Modell-Cache.
Dieselbe Klasse Problem wie bei pgAdmin, nur mit anderem Symptom.
# Befunde aus der Vorarbeit zu Akteuren
## Ein Datenfehler aus Phase 1: `"None"` als Teaser
382 der 550 zeit-Items tragen als Teaser die Zeichenkette `None` — nicht
`NULL`, sondern Pythons `None` in Text verwandelt. Das sind 69 Prozent des
Hauses.
Der Fehler entsteht im Ingest und ist in Phase 2 bereits wirksam geworden:
- die Embeddings dieser 382 Items kodieren ein bedeutungsloses Wort mit,
- die Dublettenprüfung vergleicht es mit,
- die NER hält es 112-mal für einen Ort oder eine Organisation.
Zu reparieren ist er richtigerweise in Phase 1. Phase 2 kann ihn abfangen,
das entwertet aber jede bestehende `coder_version` und erzwingt einen
vollständigen Neuaufbau.
Bemerkenswert ist, wie lange er unentdeckt blieb: keine der drei bisherigen
Auswertungen schaute je auf einen einzelnen Teaser, und in der Aggregation
ist `None` ein Wort wie jedes andere.
## `de_core_news_sm` gegen `de_core_news_lg`, 3407 Items
| | sm | lg |
|--------------------------|--------|--------|
| Entitäten | 14 249 | 14 261 |
| Durchsatz | 465/s | 428/s |
| Items ohne jede Entität | 81 | — |
Die Gesamtzahlen sind fast gleich. Das ist eine Falle: die beiden Modelle
finden **nicht dieselben** Entitäten.
| Vergleich | Wert |
|------------------------------------|-------|
| Items mit identischem Ergebnis | 10,7 %|
| Items mit identischen Spannen | 18,2 %|
| Jaccard über alle Spannen | 0,590 |
Auch wenn man die Typen ignoriert und nur die Spannen vergleicht, stimmen
die Modelle bei zwei von fünf Nennungen nicht überein.
Entscheidend ist, *welche* Nennungen das sind. `lg` findet 170-mal
zusätzlich `AfD` und je 80-mal `Kushner` und `Witkoff`, dazu `Merz`, `Putin`,
`Trump`, `Siegmund` — also genau die Akteure, um die es in diesem Korpus
geht. `sm` stuft `AfD` 226-mal als MISC und 23-mal als LOC ein, `lg` 392-mal
als ORG.
Daraus folgen zwei Dinge:
1. **`lg`**, denn der Zeitunterschied ist mit 7,3 zu 8,0 Sekunden für den
Gesamtbestand kein Argument. Der Preis sind 568 MB im Image.
2. **Den NER-Typen ist nicht zu trauen.** Beide Modelle halten `Merz` für
einen Ort, `Landtagswahl` für einen Ort und `Ukraine-Krieg` für eine
Person. Die NER liefert deshalb nur *Kandidatenspannen*; welchen Typ eine
Nennung hat, entscheidet das Lexikon, nicht das Modell.
## Was das Akteursmodul auflöst — und was nicht
Gesamtbestand, 3407 Items, 21,1 Sekunden:
| | |
|---|---|
| Nennungen | 14 652 |
| aufgelöst | 2 889 (19,7 %) |
| Lexikon | 5540 Akteure, 416 Amtszeiten |
Ein Slot-Lauf erzeugt bitgleich dieselbe Nutzlast wie der Gesamtlauf
(3407 Codings, 0 Abweichungen).
Die 19,7 Prozent klingen niedrig und sind es nicht. Die häufigsten
unaufgelösten Formen zeigen, warum:
| Nennungen | Form | wessen Zuständigkeit |
|---|---|---|
| 567 | Sachsen-Anhalt | Geokodierung |
| 382 | `None` | Ingest-Fehler, siehe oben |
| 170 | Landtagswahl in Sachsen-Anhalt | Ereignis, kein Akteur |
| 143 | Berlin | Geokodierung |
| 119 | Kiew | Geokodierung |
| 95 | Witkoff | echte Lexikonlücke |
| 87 | Kushner | echte Lexikonlücke |
| 56 | Siegmund | echte Lexikonlücke |
Drei verschiedene Dinge, die als eine Zahl erscheinen: Orte gehören dem
nächsten Modul, `None` ist ein Datenfehler, und nur der Rest ist wirklich
eine Lücke im Lexikon.
### Die Lücke hat ein Muster
`Witkoff`, `Kushner`, `Siegmund` — US-Sondergesandte und
Landtagsabgeordnete. Der Spiegel deckt Bundestag, Staats- und
Regierungschefs, Parteien, Staaten, Leitindex-Unternehmen und internationale
Organisationen ab. **Landtagsabgeordnete fehlen**, und ausgerechnet dieser
Korpus wird von einer Landtagswahl beherrscht.
Die Spezifikation nennt Landtagsabgeordnete nicht; die Auslassung ist also
regelkonform, aber für diesen Bestand teuer. Eine zusätzliche
Spiegelabfrage würde sie schließen.
### Zwei Fallen beim Spiegeln
**Geratene QIDs.** `Q157617` ist nicht der Bundesrat, sondern die
Commerzbank. Weil der Lader Einträge über die QID zusammenführt, trug er
prompt »Bundesrat« als Alias der Commerzbank ein. Handgesetzte QIDs gehören
geprüft, und `hand.json` sagt das jetzt in seinem Kopf.
**`FILTER (lang(?name) = "de")`.** Apple, Microsoft und Meta haben kein
deutsches Label in Wikidata und fallen deshalb aus jeder Spiegelabfrage —
lautlos. Ein Rückfall auf `en` wäre beim nächsten Spiegellauf die bessere
Wahl.
### spaCy ist stapelunabhängig
Bei Stapelgröße 64, 7 und 1 sowie rückwärts durchlaufen liefert
`de_core_news_lg` für 400 Items identische Spannen. Der Kunstgriff mit der
festen Tokenlänge, den die Embeddings brauchen, ist hier nicht nötig.
## Woher `None` wirklich kommt
Reproduziert am Live-Feed, nicht erschlossen: bei Items mit leerer
`<description>` fällt der Poller korrekt auf `content:encoded` zurück, und
dort liefert die ZEIT
```
<a href="https://www.zeit.de/news/..."><img ...>None</a>
```
Der Ankertext *ist* die Zeichenkette `None` — ein Renderfehler im CMS der
ZEIT. `klartext()` entfernt die Tags, und übrig bleibt `None`. Phase 1
schreibt also getreu mit; der Fehler ist stromaufwärts.
Am 8. September 2026 betraf das 8 von 15 Items im Feed. Im Prod-Bestand:
| Tag | `None` / zeit-Items |
|---|---|
| 2026-09-04 | 12 / 20 |
| 2026-09-05 | 83 / 136 |
| 2026-09-06 | 187 / 262 |
| 2026-09-07 | 305 / 400 |
| 2026-09-08 | 67 / 79 |
Gleichbleibend rund 70 bis 85 Prozent — der Fehler läuft weiter.
## Container mit spaCy
Das Abbild wächst durch `de_core_news_lg` von 1,28 auf 2,12 GB. Das Modell
liegt im Abbild, nicht im Cache-Volume: 568 MB, die sich nie ändern, und der
erste Lauf auf Prod soll nicht an einem Download hängen.
Gemessen, nicht behauptet: ein Slot-Lauf im Container (Debian trixie,
Python 3.13) erzeugt für die Akteure bitgleich dieselbe Nutzlast wie die
Workstation (Fedora, Python 3.14) — 3407 Codings, 0 Abweichungen, gleiche
`coder_version`.
## Landtage und Regierungen: was die Lexikonerweiterung gekostet hat
Zwei neue Spiegelabfragen, 08.09.2026, gemessen an 5018 Items:
| | vorher | nachher |
|---|---|---|
| Akteure im Lexikon | 5540 | 8856 |
| aufgelöste Nennungen | 4539 (21,1 %) | 4982 (23,2 %) |
Der Gewinn steckt fast vollständig in wenigen Personen: Witkoff 102,
Kushner 93, Siegmund 123, Lawrow 21. Der lange Schwanz bleibt liegen —
von 1757 unaufgelösten PER-Formen trugen 1344 genau eine Nennung.
### Zwei Wege zum amtierenden Minister, und beide werden gebraucht
`P1308` (Amt → derzeitiger Inhaber) fängt Lawrow, der seit 2004 amtiert
und aus jedem Datumsfenster fällt. Der Weg über die Amtszeit der Person
fängt Witkoff, dessen Amt kein `P1308` trägt. Beides in einer Abfrage zu
vereinen, läuft in die Zeitgrenze der WDQS — deshalb zwei Dateien.
Die Landtagslisten sind nicht über Unterklassen erreichbar: "Mitglied des
Landtag Sachsen-Anhalt" ist eine *Instanz* von Q1939559, keine
Unterklasse. `P31/P279*` statt `P279*`.
### Die Erweiterung hat zuerst Schaden angerichtet
Mit den Landtagen kamen Janine und Heiner Merz ins Lexikon. "Merz" war
damit mehrdeutig, und **125 Nennungen des Bundeskanzlers fielen aus** —
der teuerste Einzelposten des Korpus, verloren durch einen Zugewinn.
Ebenso wurde aus jedem "Hamburg" eine niedersächsische Kultusministerin
(22-mal) und aus "KI" ein südkoreanischer Nachname (16-mal).
Daraus zwei Änderungen:
1. Die Sperrliste wuchs um sieben Einträge, alle mit Begründung.
2. `waehle()` hat eine dritte Regel: trägt genau ein Kandidat den
niedrigsten Rang, gewinnt er. Der Rang kommt aus der Spiegelabfrage —
Staatsführung vor Bundestag vor Landtag. Das ist kein Münzwurf,
sondern eine Erwartung darüber, wen eine deutsche Schlagzeile mit
blossem Nachnamen meint; Abschlag 0,15 auf die Konfidenz, und der
Grund steht als `grund` in der Nutzlast.
Die Amtstabelle bekam dabei den *schlechtesten* Rang, nicht den besten.
Sie reicht bis 1794 zurück und besteht überwiegend aus Verstorbenen; mit
Rang 0 wurde aus "Teufel" ein Ministerpräsident a. D. und aus "Otto" ein
Bremer Bürgermeister des 18. Jahrhunderts.
### Ein Loch im Fingerabdruck
`NACHNAMEN_SPERRE` stand nicht in `EINSTELLUNGEN_FEST`. Ein Eintrag mehr
änderte das Ergebnis von 4868 auf 4820 aufgelöste Nennungen — unter
derselben `coder_version`. Genau der Fehler, den die Version verhindern
soll, und er war zwei Module lang unentdeckt. Die Sperrliste geht jetzt
als Hash in den Fingerabdruck ein, die Rangordnung ebenso.
+521
View File
@@ -0,0 +1,521 @@
# 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.
+519
View File
@@ -0,0 +1,519 @@
"""Modul 4: Akteursaufloesung (ebene = 'akteure').
Drei Schritte, streng getrennt:
NER liefert Kandidatenspannen - nur wo, nicht was
Lexikon entscheidet, was eine Spanne ist
Amtstabelle loest Aemter gegen das Datum des Items auf
Die Trennung ist gemessen begruendet: de_core_news_lg haelt "Merz" fuer
einen Ort, "Landtagswahl" fuer einen Ort und "Ukraine-Krieg" fuer eine
Person (siehe NOTES.md). Der Typ einer Nennung kommt deshalb aus dem
Lexikon, nie aus dem Modell. Der NER-Typ wird trotzdem mitgeschrieben -
die Geokodierung braucht ihn als Vorfilter, und er dokumentiert, woher
eine Spanne stammt.
Nicht aufloesbare Nennungen bleiben mit id null stehen. Sie sind kein
Ausschuss, sondern das Rohmaterial der Lexikonpflege; die Sicht
akteure_unaufgeloest zaehlt sie aus.
"""
import argparse
import collections
import datetime
import hashlib
import re
import sys
import time
import unicodedata
import db
import normalisierung
import version
EBENE = "akteure"
NER_MODELL = "de_core_news_lg"
STAPEL = 64
# Obergrenze fuer eine Oberflaechenform in der Nutzlast.
FORM_MAX = 100
# Nur diese NER-Typen werden als Kandidaten betrachtet. MISC bleibt drin,
# weil beide Modelle Parteien regelmaessig dort einsortieren - AfD steht
# 226-mal als MISC im sm-Lauf.
TYPEN = ("PER", "ORG", "LOC", "MISC")
# Fuzzy-Match: erlaubte Editierdistanz nach Laenge der Oberflaechenform.
# Kurze Formen vertragen keinen Fehler - aus "Wolf" wuerde sonst "Wolff",
# "Rolf" und "Golf".
def fuzzy_grenze(laenge):
if laenge < 6:
return 0
if laenge < 12:
return 1
return 2
# Woerter, die als Nachname im Lexikon stehen, in Schlagzeilen aber fast
# immer etwas anderes meinen. Handpflege, mit Begruendung je Eintrag.
NACHNAMEN_SPERRE = {
"wolf", # Katja Wolf, aber auch das Tier - in dieser Saison haeufig
"merkel", # nicht im Lexikon, aber als Nachname mehrdeutig belegt
"bund",
"land",
"recht",
"kraft",
"post",
"stark",
"reich",
"gross",
"klein",
"lang",
"kurz",
"jung",
"alt",
"neu",
"weiss",
"schwarz",
"braun",
"gruen",
"hacker", # Thomas Hacker, MdB - in Schlagzeilen fast nie gemeint
"un", # Nachname von Kim Jong-un, in Schlagzeilen die UN
"partei",
"post",
"bahn",
"welt",
"zeit",
"spiegel",
"focus",
# Staedtenamen, die zugleich Nachnamen von Abgeordneten sind. Erst mit
# den Landtagen kamen sie ins Lexikon, und sofort wurde aus jedem
# "Hamburg" eine niedersaechsische Kultusministerin - 22-mal im Korpus
# vom 08.09.2026.
"hamburg", # Julia Willie Hamburg, MdL Niedersachsen
"rostock", # Clemens Rostock, MdL Brandenburg
"oldenburg", # Simone Oldenburg, MdL Mecklenburg-Vorpommern
"ki", # Ki-hong Nam - in deutschen Schlagzeilen die KI
"nothing", # Volker Nothing, MdL - sonst das englische Wort
"andrew", # Stuart Andrew, MP - sonst ein Vorname
"champagne", # Francois-Philippe Champagne - sonst das Getraenk
}
# Aliase, die Wikidata fuehrt, die in Schlagzeilen aber regelmaessig etwas
# anderes meinen. Wirkt auf den Alias-Match, nicht nur auf Nachnamen.
FORMEN_SPERRE = {
"europa", # Wikidata fuehrt es als Alias der EU. Meist ist der
# Kontinent gemeint, und der ist Sache der Geokodierung.
"europas",
"union", # CDU/CSU oder Europaeische Union - ohne Kontext nicht zu
# entscheiden, und beide Lesarten sind haeufig.
}
# Rangordnung der Spiegelabfragen fuer den Gleichstandsfall. Sie ist eine
# Aussage darueber, wen eine deutsche Schlagzeile mit blossem Nachnamen
# meint, nicht darueber, wer wichtiger ist. Der Anlass ist messbar: mit den
# Landtagen kamen Janine und Heiner Merz ins Lexikon, und "Merz" war
# schlagartig mehrdeutig - 125 Nennungen des Bundeskanzlers fielen aus.
#
# Ein Rang entscheidet nur, wenn genau ein Kandidat den niedrigsten traegt.
# Bei Gleichstand innerhalb eines Rangs bleibt es beim Muenzwurfverbot.
RANG = {
"hand": 0,
"staatsfuehrung": 1,
"regierung": 1,
"sondergesandte": 1,
"bundestag": 2,
"landtage": 3,
# Die Amtstabelle reicht bis 1794 zurueck und besteht ueberwiegend aus
# Verstorbenen. Sie darf einen Gleichstand nicht gewinnen: sonst wird
# aus "Teufel" ein Ministerpraesident a.D. und aus "Otto" ein Bremer
# Buergermeister des 18. Jahrhunderts. Wer heute im Amt ist, steht
# ohnehin in einer der Personenabfragen.
"aemter": 5,
}
RANG_SONST = 4
def rang(quelle):
"""'wikidata:landtage@2026-09-08' -> 3."""
name = (quelle or "").split(":")[-1].split("@")[0]
return RANG.get(name, RANG_SONST)
EINSTELLUNGEN_FEST = {
"ner_modell": NER_MODELL,
"basis": "titel+teaser",
"text": "bereinigt",
"ner_typen": list(TYPEN),
"typ_aus": "lexikon",
"fuzzy": "editierdistanz_nach_laenge",
"nachnamen": "eindeutig_kontext_rang",
"rang": "|".join(f"{k}={v}" for k, v in sorted(RANG.items())),
# Die Sperrliste gehoert in die Version, nicht nur in den Quelltext.
# Sie aendert das Ergebnis - ein Eintrag mehr, und aus 22 Nennungen
# der Hansestadt wird keine Ministerin mehr. Ohne sie im Fingerabdruck
# traegt dieselbe coder_version zwei verschiedene Auszaehlungen, und
# genau das soll die Version verhindern.
"nachnamen_sperre": hashlib.blake2b(
"|".join(sorted(NACHNAMEN_SPERRE)).encode(), digest_size=8
).hexdigest(),
}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
_UNRAT = re.compile(r"[^a-z0-9 ]+")
_MEHRFACH = re.compile(r"\s+")
# Titel und Anreden, die vor einem Namen stehen und nicht zu ihm gehoeren.
_ANREDE = re.compile(
r"^(?:der|die|das|dem|den|des|ex|alt)?[- ]?"
r"(?:bundes|landes|us|eu|nato|un|uno)?[- ]?"
r"(?:kanzler(?:in)?|praesident(?:in)?|minister(?:in)?|ministerpraesident(?:in)?|"
r"aussenminister(?:in)?|innenminister(?:in)?|chef(?:in)?|sprecher(?:in)?|"
r"vorsitzende[rn]?|abgeordnete[rn]?|spitzenkandidat(?:in)?|kandidat(?:in)?|"
r"parteichef(?:in)?|fraktionschef(?:in)?|gesandte[rn]?|premier(?:minister)?)"
r"[- ]",
)
def vergleichsform(text):
"""Form, in der Oberflaeche und Lexikon miteinander verglichen werden."""
text = unicodedata.normalize("NFKC", text).casefold().translate(_UMLAUTE)
text = unicodedata.normalize("NFKD", text)
text = "".join(z for z in text if not unicodedata.combining(z))
return _MEHRFACH.sub(" ", _UNRAT.sub(" ", text)).strip()
def ohne_anrede(form):
"""'us-praesident trump' -> 'trump'. Mehrfach, fuer Ketten."""
vorher = None
while vorher != form:
vorher = form
form = _ANREDE.sub("", form).strip()
return form or vorher
def distanz(a, b, grenze):
"""Levenshtein, abgebrochen sobald die Grenze ueberschritten ist."""
if abs(len(a) - len(b)) > grenze:
return grenze + 1
vorige = list(range(len(b) + 1))
for i, za in enumerate(a, 1):
zeile = [i]
for j, zb in enumerate(b, 1):
zeile.append(min(vorige[j] + 1, zeile[j - 1] + 1,
vorige[j - 1] + (za != zb)))
if min(zeile) > grenze:
return grenze + 1
vorige = zeile
return vorige[-1]
class Lexikon:
"""Nachschlagewerk im Speicher. Wird einmal je Lauf gebaut."""
def __init__(self, akteure, amtszeiten):
self.akteure = {a["id"]: a for a in akteure}
self.formen = collections.defaultdict(set)
self.nachnamen = collections.defaultdict(set)
self.amtszeiten = collections.defaultdict(list)
for a in akteure:
for form in [a["name"], *a["aliase"]]:
v = vergleichsform(form)
if v:
self.formen[v].add(a["id"])
if a["typ"] == "person":
teile = vergleichsform(a["name"]).split()
if len(teile) > 1 and teile[-1] not in NACHNAMEN_SPERRE:
self.nachnamen[teile[-1]].add(a["id"])
for z in amtszeiten:
self.amtszeiten[z["amt_id"]].append(z)
for eintraege in self.amtszeiten.values():
eintraege.sort(key=lambda z: z["von"])
# Fuzzy-Suche nur innerhalb eines Laengenfensters, sonst waere sie
# ein Kreuzprodukt aus 14000 Spannen und 12000 Formen.
self.nach_laenge = collections.defaultdict(list)
for v in self.formen:
self.nach_laenge[len(v)].append(v)
def inhaber(self, amt_id, datum):
"""Wer hatte dieses Amt an diesem Tag."""
treffer = []
for z in self.amtszeiten.get(amt_id, ()):
# Halboffen, wie in der Sicht amtsinhaber_ueberschneidungen:
# am Tag der Uebergabe zaehlt der Nachfolger.
if z["von"] <= datum and (z["bis"] is None or datum < z["bis"]):
treffer.append(z["person_id"])
return treffer
def schlage_nach(self, form):
"""(ids, methode) fuer eine Vergleichsform."""
if form in FORMEN_SPERRE:
return set(), None
if form in self.formen:
return self.formen[form], "alias"
kurz = ohne_anrede(form)
if kurz in FORMEN_SPERRE:
return set(), None
if kurz != form and kurz in self.formen:
return self.formen[kurz], "alias_ohne_anrede"
if kurz in self.nachnamen:
return self.nachnamen[kurz], "nachname"
grenze = fuzzy_grenze(len(kurz))
if grenze:
beste, bester_abstand = set(), grenze + 1
for laenge in range(len(kurz) - grenze, len(kurz) + grenze + 1):
for kandidat in self.nach_laenge.get(laenge, ()):
if kandidat[0] != kurz[0]:
continue
d = distanz(kurz, kandidat, grenze)
if d > grenze:
# distanz() liefert bei Abbruch grenze+1. Ohne diese
# Zeile zaehlt jeder Abbruch als Gleichstand mit
# jedem anderen Abbruch, und die Kandidatenliste
# fuellt sich mit allem, was zufaellig gleich lang
# ist und mit demselben Buchstaben beginnt.
continue
if d < bester_abstand:
beste, bester_abstand = set(self.formen[kandidat]), d
elif d == bester_abstand:
beste |= self.formen[kandidat]
if beste:
return beste, "fuzzy"
return set(), None
def lade_lexikon(conn):
with conn.cursor() as k:
k.execute("select id, typ, name, aliase, land, rolle, quelle from akteure")
akteure = [
{"id": r[0], "typ": r[1], "name": r[2], "aliase": r[3],
"land": r[4], "rolle": r[5], "rang": rang(r[6])}
for r in k.fetchall()
]
k.execute("select amt_id, person_id, von, bis from amtsinhaber")
amtszeiten = [
{"amt_id": r[0], "person_id": r[1], "von": r[2], "bis": r[3]}
for r in k.fetchall()
]
return Lexikon(akteure, amtszeiten)
def waehle(lexikon, ids, kontext):
"""Aus mehreren Kandidaten einen machen - oder keinen.
Regelbasiert und in dieser Reihenfolge, wie die Spezifikation es
verlangt:
1. Ist genau einer gemeint, ist die Sache erledigt.
2. Wird die Partei eines Kandidaten im selben Item genannt, gewinnt er.
3. Traegt genau einer den niedrigsten Rang, gewinnt er - siehe RANG.
4. Sonst bleibt die Nennung unaufgeloest. Ein Muenzwurf waere hier der
schlimmere Fehler, weil er in der Zeitreihe nicht auffaellt.
"""
if len(ids) == 1:
return next(iter(ids)), "eindeutig"
mit_partei = [i for i in ids
if (lexikon.akteure[i].get("notiz") or "") and
vergleichsform(lexikon.akteure[i]["notiz"]) in kontext]
if len(mit_partei) == 1:
return mit_partei[0], "kontext_partei"
beste = min(lexikon.akteure[i].get("rang", RANG_SONST) for i in ids)
vorn = [i for i in ids
if lexikon.akteure[i].get("rang", RANG_SONST) == beste]
if len(vorn) == 1:
return vorn[0], "rang"
return None, "mehrdeutig"
def erkennungen(nlp, titel, teaser):
"""Kandidatenspannen je Feld, mit Offsets im bereinigten Text."""
felder = [("titel", normalisierung.bereinige(titel)),
("teaser", normalisierung.bereinige(teaser))]
ergebnis = []
for feld, text in felder:
if not text:
continue
for ent in nlp(text).ents:
if ent.label_ in TYPEN:
ergebnis.append({"feld": feld,
# Die Nutzlast darf keinen Volltext tragen
# (Constraint p2_nutzlast_ohne_text). Eine
# Nennung ist nie so lang; start/ende machen
# den Urtext ohnehin auffindbar.
"form": ent.text[:FORM_MAX],
"start": ent.start_char, "ende": ent.end_char,
"ner_typ": ent.label_})
return ergebnis
def aufloesen(lexikon, spannen, datum):
"""Spannen gegen das Lexikon aufloesen. Reine Funktion, testbar."""
kontext = " ".join(vergleichsform(s["form"]) for s in spannen)
erkannt = []
for s in spannen:
form = vergleichsform(s["form"])
ids, methode = lexikon.schlage_nach(form)
eintrag = dict(s)
if not ids:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": "ner_unaufgeloest", "konfidenz": 0.4})
erkannt.append(eintrag)
continue
gewaehlt, grund = waehle(lexikon, ids, kontext)
if gewaehlt is None:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": f"{methode}_{grund}", "konfidenz": 0.3,
"kandidaten": sorted(ids)[:5]})
erkannt.append(eintrag)
continue
a = lexikon.akteure[gewaehlt]
konfidenz = {"alias": 0.95, "alias_ohne_anrede": 0.9,
"nachname": 0.8, "fuzzy": 0.6}[methode]
# Abschlaege fuer die Gleichstandsregeln. Der Rang ist die
# schwaechere der beiden: die Partei steht im Text, der Rang ist
# nur eine Erwartung darueber, wer gemeint sein duerfte.
if grund == "kontext_partei":
konfidenz -= 0.1
elif grund == "rang":
konfidenz -= 0.15
eintrag.update({"id": gewaehlt, "typ": a["typ"], "rolle": a["rolle"],
"methode": methode, "konfidenz": round(konfidenz, 2)})
if grund != "eindeutig":
eintrag["grund"] = grund
# Aemter gegen das Datum des Items aufloesen. Das Amt bleibt stehen,
# die Person kommt daneben - beides ist eine Aussage ueber den Text.
if a["typ"] == "amt" and datum:
inhaber = lexikon.inhaber(gewaehlt, datum)
if len(inhaber) == 1:
eintrag["inhaber"] = inhaber[0]
eintrag["methode"] = "amt"
elif inhaber:
eintrag["inhaber_mehrdeutig"] = sorted(inhaber)
erkannt.append(eintrag)
return erkannt
def nutzlast(erkannt, lexikon_stand):
aufgeloest = [e for e in erkannt if e["id"]]
return {
"erkannt": erkannt,
"ner_modell": NER_MODELL,
"lexikon_version": lexikon_stand,
"nennungen": len(erkannt),
"aufgeloest": len(aufgeloest),
"typen": dict(collections.Counter(e["typ"] for e in aufgeloest)),
}
_SPALTEN = "select id, titel, teaser, pubdate from raw_items"
def lade(conn, slot=None):
with conn.cursor() as k:
if slot:
k.execute(_SPALTEN + " where slot = %s order by id", (slot,))
else:
k.execute(_SPALTEN + " order by id")
return k.fetchall()
def lexikon_stand(conn):
"""Fingerabdruck des Lexikons.
Geht in die coder_version ein, und zwar inhaltlich: ein einziger neuer
Alias aendert den Hash und damit die Version. Eine blosse Zeilenzahl
taete das nicht - dann waere eine Korrektur am Lexikon von aussen
unsichtbar, und alte Codings behaupteten eine Grundlage, die es nicht
mehr gibt.
"""
with conn.cursor() as k:
k.execute("""select id, typ, name, aliase, land, rolle
from akteure order by id""")
akteure_zeilen = k.fetchall()
k.execute("""select amt_id, person_id, von, bis
from amtsinhaber order by amt_id, person_id, von""")
amtszeilen = k.fetchall()
roh = repr(akteure_zeilen) + repr(amtszeilen)
kurz = hashlib.blake2b(roh.encode("utf-8"), digest_size=6).hexdigest()
return f"{len(akteure_zeilen)}+{len(amtszeilen)}/{kurz}"
def einstellungen(stand):
return dict(EINSTELLUNGEN_FEST, lexikon=stand)
def verarbeite(conn, slot=None, trocken=False):
import spacy
beginn = time.time()
lexikon = lade_lexikon(conn)
stand = lexikon_stand(conn)
v = version.coder_version(version.komponenten(akteure=einstellungen(stand)))
if not trocken:
version.eintragen(conn, version.komponenten(akteure=einstellungen(stand)))
nlp = spacy.load(NER_MODELL, exclude=["lemmatizer"])
zeilen = lade(conn, slot)
print(f"{EBENE}: {len(zeilen)} Items, Version {v}, Lexikon {stand}")
saetze, zahlen = [], collections.Counter()
for item_id, titel, teaser, pubdate in zeilen:
datum = pubdate.date() if isinstance(pubdate, datetime.datetime) else pubdate
spannen = erkennungen(nlp, titel, teaser)
erkannt = aufloesen(lexikon, spannen, datum)
zahlen["nennungen"] += len(erkannt)
zahlen["aufgeloest"] += sum(1 for e in erkannt if e["id"])
saetze.append((item_id, v, nutzlast(erkannt, stand)))
if trocken:
print(f" trocken, nichts geschrieben: {dict(zahlen)}")
return zahlen
from psycopg.types.json import Jsonb
with conn.cursor() as k:
k.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene) do update
set nutzlast = excluded.nutzlast, kodiert_am = now()""",
[(i, ver, EBENE, Jsonb(n)) for i, ver, n in saetze],
)
k.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler)
values (%s, %s, %s, %s, %s, %s, 0)""",
(slot, EBENE, v, int((time.time() - beginn) * 1000),
len(zeilen), len(saetze)),
)
conn.commit()
quote = zahlen["aufgeloest"] / zahlen["nennungen"] if zahlen["nennungen"] else 0
print(f" {len(saetze)} Codings, {zahlen['nennungen']} Nennungen, "
f"{zahlen['aufgeloest']} aufgeloest ({quote:.1%}), "
f"{time.time() - beginn:.1f}s")
return zahlen
def main(argumente=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--backfill", action="store_true")
g.add_argument("--slot")
p.add_argument("--trocken", action="store_true")
a = p.parse_args(argumente)
with db.verbindung() as conn:
verarbeite(conn, slot=a.slot, trocken=a.trocken)
return 0
if __name__ == "__main__":
sys.exit(main())
+12 -3
View File
@@ -22,9 +22,18 @@ def main():
erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")] erw = [r[0] for r in conn.execute("select extname from pg_extension order by 1")]
print(f" Extensions: {', '.join(erw)}") print(f" Extensions: {', '.join(erw)}")
for fehlend in ("vector",):
if fehlend not in erw: # Zwischen "im Image nicht vorhanden" und "nur noch nicht angelegt"
print(f" fehlt noch fuer Phase 2: {fehlend}") # unterscheiden - das erste ist ein Image-Tausch, das zweite eine
# Migration.
if "vector" not in erw:
verfuegbar = conn.execute(
"select default_version from pg_available_extensions where name = 'vector'"
).fetchone()
if verfuegbar:
print(f" vector {verfuegbar[0]} liegt bereit, ist aber nicht angelegt")
else:
print(" vector fehlt im Image - Phase 2 kann keine Embeddings ablegen")
print("\nTabellen") print("\nTabellen")
for name, in conn.execute( for name, in conn.execute(
+3 -3
View File
@@ -11,12 +11,12 @@ import pathlib
_ENV = pathlib.Path(__file__).with_name(".env") _ENV = pathlib.Path(__file__).with_name(".env")
def _aus_env_datei(pfad=_ENV): def _aus_env_datei():
"""Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen.""" """Minimaler .env-Leser. Kein Shell-Parsing, keine Ersetzungen."""
werte = {} werte = {}
if not pfad.exists(): if not _ENV.exists():
return werte return werte
for zeile in pfad.read_text(encoding="utf-8").splitlines(): for zeile in _ENV.read_text(encoding="utf-8").splitlines():
zeile = zeile.strip() zeile = zeile.strip()
if not zeile or zeile.startswith("#") or "=" not in zeile: if not zeile or zeile.startswith("#") or "=" not in zeile:
continue continue
+144
View File
@@ -0,0 +1,144 @@
# Phase 2 auf Prod in Betrieb nehmen
Reihenfolge ist verbindlich. Jeder Schritt hat eine Prüfung; schlägt sie
fehl, hilft der nächste Schritt nicht.
Alles läuft als Benutzer `podman`, dessen Home `/home/podman` ist — `%h` in
den Units löst dorthin auf. Die Datenbankverbindung steht bereits in
`~/.config/wurzelwerk/database.env`.
## 0. Migrationen — erledigt
```sh
psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order by name'
```
Sechs Zeilen, `p2-000` bis `p2-005`. Fehlt eine, siehe README, Abschnitt
„Migrationen" — `p2-002` und `p2-003` brauchen einen Superuser.
## 1. Abbild bauen
```sh
cd /pfad/zum/repo && git pull
podman build -t wurzelwerk-p2 .
```
2,12 GB, davon 568 MB spaCy-Modell und 196 MB torch. Prüfung:
```sh
podman run --rm --entrypoint ls localhost/wurzelwerk-p2 /app/lexikon/
```
Zwölf Dateien. Fehlen `landtage.json`, `regierung.json` oder
`sondergesandte.json`, ist das Abbild älter als der Stand vom 08.09.2026.
## 2. Lexikon in die Tabellen laden
`akteure.py` liest aus `akteure` und `amtsinhaber`, nicht aus den
JSON-Dateien. Ohne diesen Schritt läuft Modul 4 durch und löst nichts auf,
**ohne zu scheitern** — der teuerste stille Fehler der ganzen Einrichtung.
```sh
podman run --rm --network=bebop-net \
--env-file ~/.config/wurzelwerk/database.env \
localhost/wurzelwerk-p2 lexikon.py --laden
```
Erwartet: `person 5631`, `organisation 2319`, `partei 682`, `staat 195`,
`amt 29`, `amtsinhaber 418`. Die `Ueberschneidung:`-Zeilen sind die
Betriebssicht auf Doppelspitzen und Bremer Bürgermeister, kein Fehler.
Das Netz ist nicht schmückend: `DATABASE_URL` zeigt auf den Rechnernamen
`postgres`, und den löst nur Podmans DNS innerhalb von `bebop-net` auf.
Ohne `--network=bebop-net` scheitert die Namensauflösung, nicht die
Anmeldung — die Fehlermeldung nennt dann den Namen, nicht das Passwort.
## 3. Modell-Volume anlegen und einmal füllen
```sh
podman volume create wurzelwerk-modelle
```
Der erste Lauf braucht Netz und lädt 2,2 GB. Er muss **von Hand** laufen,
nicht aus dem Timer: der Erstbestand über den gesamten Korpus dauert etwa
17 Minuten je 5000 Items, und `TimeoutStartSec=1800` ist dafür knapp.
```sh
podman run --rm --network=bebop-net --memory=8g --shm-size=1g \
--volume wurzelwerk-modelle:/cache:U \
--env-file ~/.config/wurzelwerk/database.env \
--env OMP_NUM_THREADS=4 --env HF_HUB_OFFLINE=0 \
localhost/wurzelwerk-p2 phase2.py --backfill
```
Prüfung — vier Ebenen, je eine `coder_version`, alle gleich hoch:
```sh
psql "$DATABASE_URL" -c \
'select ebene, coder_version, count(*) from codings group by 1,2 order by 1'
psql "$DATABASE_URL" -c 'select count(*) from item_embeddings'
```
Stehen für eine Ebene zwei Versionen nebeneinander, ist der Lauf
unterbrochen worden und wurde mit geändertem Code fortgesetzt. Dann die
ältere löschen und den Backfill wiederholen.
## 4. Umgebung für Phase 2
```sh
cp deploy/p2.env.beispiel ~/.config/wurzelwerk/p2.env
chmod 600 ~/.config/wurzelwerk/p2.env
$EDITOR ~/.config/wurzelwerk/p2.env # HF_HUB_OFFLINE=1 setzen
```
`HF_HUB_OFFLINE=1` erst jetzt, nach dem erfolgreichen Erstlauf. Danach geht
das Modell nie wieder ins Netz und lädt in zwei Sekunden aus dem Volume.
## 5. Units einhängen
```sh
cp deploy/wurzelwerk-p2.container ~/.config/containers/systemd/
cp deploy/wurzelwerk-p2.timer ~/.config/systemd/user/
systemctl --user daemon-reload
# Was Quadlet daraus erzeugt hat, vor dem ersten Start ansehen:
/usr/lib/systemd/user-generators/podman-user-generator --dryrun
systemctl --user start wurzelwerk-p2.service # ein Lauf von Hand
journalctl --user -u wurzelwerk-p2 -n 40
```
Erst wenn dieser eine Lauf sauber durchgeht — er sollte `kein offener Slot`
melden, weil der Backfill alles kodiert hat —, den Timer scharfstellen:
```sh
systemctl --user enable --now wurzelwerk-p2.timer
systemctl --user list-timers wurzelwerk-p2.timer
```
Feuert `*:03,18,33,48`, versetzt zum 15-Minuten-Raster von Phase 1.
Damit die Units auch ohne angemeldete Sitzung laufen:
```sh
loginctl enable-linger podman
```
## 6. Nach der ersten Stunde
```sh
psql "$DATABASE_URL" -c \
"select modul, coder_version, items_gesehen, items_kodiert, items_fehler,
begonnen_am
from p2_laeufe order by begonnen_am desc limit 12"
```
Vier Zeilen je Slot, `items_fehler = 0`. Ein Modul, das dauerhaft fehlt,
ist abgestürzt, ohne die anderen mitzureissen — genau dafür ist die
Schichtung da, aber es fällt dann eben auch nur hier auf.
## Was diese Phase nicht tut
`raw_items` wird nie geschrieben. Der gesamte Phase-2-Bestand — `codings`,
`item_embeddings`, `p2_laeufe` — ist verwerfbar und aus `raw_items` neu
herstellbar. Ein Rückbau ist deshalb `truncate`, kein Wiederherstellen aus
einem Backup.
+21
View File
@@ -0,0 +1,21 @@
# Nach ~/.config/wurzelwerk/p2.env kopieren und ausfuellen. chmod 600.
# Enthaelt Zugangsdaten und gehoert nicht ins Repo.
# Das Netz steht in der Quadlet-Unit (Network=bebop-net), nicht hier.
#
# Die Datenbankverbindung steht auf Prod bereits in
# ~/.config/wurzelwerk/database.env; die Unit liest beide Dateien. Hier nur
# setzen, wenn Phase 2 eine andere braucht.
#
# Anfuehrungszeichen um den Wert weglassen: podman --env-file entfernt sie
# nicht, anders als systemd.
#
# DATABASE_URL=postgresql://wurzelwerk:PASSWORT@postgres:5432/wurzelwerk
# Threadzahl. Geht in die coder_version ein - eine Aenderung erzeugt einen
# neuen Vektorbestand. Prod hat 16 Threads, 4 laeuft ueberall.
OMP_NUM_THREADS=4
# Nach dem ersten Lauf auf 1 setzen: dann geht das Modell nie wieder ins Netz,
# sondern nur noch ins Volume wurzelwerk-modelle.
HF_HUB_OFFLINE=0
+63
View File
@@ -0,0 +1,63 @@
# Phase 2 als Quadlet, neben wurzelwerk-ingest.container.
#
# cp deploy/wurzelwerk-p2.container ~/.config/containers/systemd/
# cp deploy/wurzelwerk-p2.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
#
# Quadlet erzeugt daraus wurzelwerk-p2.service; der Timer traegt denselben
# Namen und findet sie deshalb ohne Unit=-Zeile.
#
# Kein [Install]: die Unit soll nicht beim Anmelden starten, sondern nur,
# wenn der Timer sie faellig macht.
[Unit]
Description=Wurzelwerk Phase 2 (Aufbereitung)
# Nicht Requires: laeuft Postgres gerade nicht, soll der Slot ausfallen und
# beim naechsten Mal ueber --aufholen nachgeholt werden, nicht die
# Abhaengigkeit neu starten.
After=postgres.service
[Container]
Image=localhost/wurzelwerk-p2
# Das Abbild hat ENTRYPOINT python3; Exec= liefert nur das Argument.
Exec=phase2.py --aufholen
# Ohne dieses Netz loest der Rechnername "postgres" aus DATABASE_URL nicht
# auf. Der Fehler waere dann eine Namensaufloesung, keine Anmeldung.
Network=bebop-net
# Zwei Dateien, in dieser Reihenfolge - die spaetere gewinnt bei gleichem
# Schluessel. database.env haelt die Datenbankverbindung und gehoert Phase 1
# mit; sie wird hier nur gelesen, nicht angefasst. p2.env traegt, was nur
# Phase 2 angeht.
#
# Achtung, anderer Mechanismus als bei EnvironmentFile= im Abschnitt
# [Service]: Quadlet reicht diese Dateien als "podman run --env-file" weiter.
# Podman entfernt keine Anfuehrungszeichen. Steht in database.env
# DATABASE_URL="postgresql://..." mit Anfuehrungszeichen, landen sie im Wert
# und die Verbindung scheitert. systemd wuerde sie entfernen, podman nicht.
EnvironmentFile=%h/.config/wurzelwerk/database.env
EnvironmentFile=%h/.config/wurzelwerk/p2.env
# 2,2 GB Modellgewichte. Muessen ein Volume sein, sonst waeren sie bei jedem
# Neubau des Abbilds weg.
Volume=wurzelwerk-modelle:/cache:U
Memory=8g
ShmSize=1g
[Service]
# Der Container laeuft und ist fertig; nichts bleibt stehen. Ohne diese Zeile
# setzt Quadlet Type=notify, und die Unit gaelte als gestartet, sobald der
# Container laeuft - nicht, wenn er durch ist.
#
# Kein RemainAfterExit=yes, obwohl das Handbuch es fuer oneshot sonst
# empfiehlt: bei Timer-Aktivierung bliebe der Auftrag im Zustand "started"
# stehen und der Timer koennte ihn nie wieder ausloesen.
#
# Type=oneshot schaltet die Startzeitgrenze ab. Der Erstbestand ueber den
# gesamten Korpus - rund 17 Minuten je 5000 Items - liefe hier also nicht in
# TimeoutStartSec. Trotzdem gehoert er von Hand gefahren, siehe
# INBETRIEBNAHME.md: beim ersten Lauf werden 2,2 GB Modellgewichte geladen,
# und das will man sehen, nicht im Journal nachlesen.
Type=oneshot
+15
View File
@@ -0,0 +1,15 @@
# Phase 1 sammelt im 15-Minuten-Raster. Phase 2 laeuft versetzt hinterher,
# damit der Slot vollstaendig in raw_items steht, bevor daraus kodiert wird.
[Unit]
Description=Wurzelwerk Phase 2, viertelstuendlich
[Timer]
OnCalendar=*:03,18,33,48
# Persistent: nach einem Neustart wird der verpasste Lauf einmal nachgeholt.
# --aufholen findet die uebrigen Luecken ohnehin.
Persistent=true
AccuracySec=30s
[Install]
WantedBy=timers.target
Executable
+659
View File
@@ -0,0 +1,659 @@
#!/usr/bin/env python3
"""Phase 2, Modul 1: exakte Uebernahmen (ebene = 'dublette').
Agenturmeldungen erscheinen bei mehreren Haeusern nahezu gleichlautend.
Dieses Modul fasst sie zusammen, damit Phase 3 nur den Leitartikel kodiert.
Kein thematisches Clustering. Nur near-exact: erkannt wird, dass zwei Haeuser
denselben Text ausgespielt haben, nicht dass sie ueber dasselbe schreiben.
python3 dubletten.py --backfill # Gesamtbestand
python3 dubletten.py --slot 2026-09-07T08:00
python3 dubletten.py --backfill --trocken --stichprobe 200
Verfahren nach Abschnitt 3 der Spezifikation: Normalisierung, SimHash ueber
Wort-Trigramme, Kandidaten ueber Hamming-Distanz, Bestaetigung ueber Jaccard,
Union-Find.
Wo das Verfahren von der Spezifikation abweicht, steht die Begruendung an
Ort und Stelle: BASIS_VORGABE, gruppen_id(), die Paarregel in paare() und
_zerlege_ketten(). README.md fasst sie zusammen, NOTES.md haelt die Messungen
fest, auf denen sie beruhen.
"""
import argparse
import datetime as dt
import hashlib
import itertools
import re
import sys
import time
import uuid
from psycopg.types.json import Jsonb
import normalisierung
import version as versionsmodul
from db import verbindung
EBENE = "dublette"
# --------------------------------------------------------------------------
# Vergleichsbasis: "titel" oder "titel+teaser".
#
# Die Spezifikation sagt "Titel + Teaser, oder Volltext falls vorhanden".
# Beides taugt hier nicht.
#
# Volltext nicht, weil ihn nur ein Teil der Quellen liefert - Ingest liest
# RSS, und viele Haeuser spielen dort kein content:encoded aus (Stand
# 7.9.2026: faz 311/489, heise 174/196, aber derstandard, welt, dlf, taz, sz
# jeweils 0). Eine je Item verschiedene Basis macht ausgerechnet den
# haeufigsten Fall unauffindbar: dieselbe Meldung bei einem Haus mit und bei
# einem ohne Volltext.
#
# Titel + Teaser nicht, weil die Haeuser die Agenturueberschrift woertlich
# uebernehmen, den Teaser aber selbst schreiben. Gemessen wurde beides: ueber
# Titel + Teaser findet das Verfahren auf diesem Bestand keine einzige
# hausuebergreifende Uebernahme, ueber den Titel allein 63 Gruppen. Der
# Teaser verduennt genau das Signal, das traegt. Zahlen in NOTES.md.
# --------------------------------------------------------------------------
BASIS_VORGABE = "titel"
# Mindestwortzahl je Basis. Wenige Trigramme erreichen leicht einen hohen
# Jaccard-Wert, ohne dass die Meldungen etwas miteinander zu tun haetten -
# lieber eine Dublette verpassen als zwei Vorgaenge verschmelzen
# (Abschnitt 3, "Erwartung"). Titel sind kuerzer, die Schwelle entsprechend.
MINDESTWOERTER = {"titel": 5, "titel+teaser": 8}
# Reissleine fuer die Randerweiterung beim Slot-Lauf.
RANDERWEITERUNGEN = 6
EINSTELLUNGEN = {
"basis": BASIS_VORGABE,
"simhash_bits": 64,
"trigramm": "wort-3",
"hamming_max": 3,
"jaccard_min": 0.85,
"fenster_tage": 1,
"mindestwoerter": MINDESTWOERTER[BASIS_VORGABE],
"ressort_abgetrennt": True,
}
_AGENTUREN = [
("dpa", re.compile(r"\((?:dpa|dpa-AFX)[^)]{0,20}\)|\bdpa\b")),
("afp", re.compile(r"\(AFP[^)]{0,20}\)|\bAFP\b")),
("reuters", re.compile(r"\bReuters\b")),
("ap", re.compile(r"\(AP\)|\bAssociated Press\b")),
("epd", re.compile(r"\bepd\b")),
("kna", re.compile(r"\bKNA\b")),
("sid", re.compile(r"\bSID\b")),
("dts", re.compile(r"\bdts Nachrichtenagentur\b")),
]
# Fester Namensraum, damit Gruppen-UUIDs ueber Laeufe und Maschinen hinweg
# gleich bleiben. Willkuerlich gewaehlt, aber unveraenderlich.
NAMENSRAUM = uuid.UUID("6f9b4d2e-1c53-4a7f-9e18-2b0d7c5a3e41")
# --------------------------------------------------------------------------
# SimHash
# --------------------------------------------------------------------------
def _merkmalshash(trigramm):
"""64 Bit je Trigramm.
blake2b und nicht hash(): Pythons hash() fuer Zeichenketten ist je
Prozess zufaellig gesalzen. Damit waere nichts reproduzierbar.
"""
roh = "\x1f".join(trigramm).encode("utf-8")
return int.from_bytes(hashlib.blake2b(roh, digest_size=8).digest(), "big")
def simhash(trigramme):
if not trigramme:
return 0
gewichte = [0] * 64
for tri in trigramme:
h = _merkmalshash(tri)
for bit in range(64):
gewichte[bit] += 1 if (h >> bit) & 1 else -1
wert = 0
for bit in range(64):
# Gleichstand faellt auf 0. Willkuerlich, aber deterministisch.
if gewichte[bit] > 0:
wert |= 1 << bit
return wert
def hamming(a, b):
return (a ^ b).bit_count()
BAENDER = 4
def baender(wert):
"""SimHash in vier Stuecke zu 16 Bit zerlegen.
Schubfachschluss: unterscheiden sich zwei Werte in hoechstens 3 Bits, so
ist bei vier Baendern mindestens eines gleich. Nur Paare, die sich ein
Band teilen, muessen ueberhaupt verglichen werden - das ersetzt den
Vergleich jeder Meldung mit jeder. Die Zahl der Baender haengt an
hamming_max und darf nicht ohne dieses geaendert werden.
"""
breite = 64 // BAENDER
maske = (1 << breite) - 1
return tuple((i, (wert >> (i * breite)) & maske) for i in range(BAENDER))
def jaccard(a, b):
if not a or not b:
return 0.0
schnitt = len(a & b)
return schnitt / (len(a) + len(b) - schnitt)
class UnionFind:
def __init__(self):
self.eltern = {}
def finde(self, x):
self.eltern.setdefault(x, x)
while self.eltern[x] != x:
self.eltern[x] = self.eltern[self.eltern[x]]
x = self.eltern[x]
return x
def vereinige(self, a, b):
wa, wb = self.finde(a), self.finde(b)
if wa != wb:
# Kleinere Wurzel gewinnt: macht das Ergebnis unabhaengig von der
# Reihenfolge, in der die Paare hereinkommen.
hoch, tief = max(wa, wb), min(wa, wb)
self.eltern[hoch] = tief
def agentur(*texte):
"""Agentur nur mit Textbeleg. Keine Vermutung ins Blaue."""
text = " ".join(t for t in texte if t)
for name, muster in _AGENTUREN:
if muster.search(text):
return name
return None
def gruppen_id(leitartikel_id):
"""UUID aus dem Leitartikel, nicht aus der Mitgliedermenge.
Die Spezifikation leitet sie aus der sortierten Menge der raw_item_id ab.
Das haelt nicht: erscheint dieselbe Meldung drei Stunden spaeter bei einem
weiteren Haus, aendert sich die Menge und damit die UUID - eine Gruppe,
die Phase 3 bereits kodiert hat, hiesse ploetzlich anders.
Der Leitartikel ist das aeltestgesehene Mitglied. Kommt ein Mitglied
hinzu, ist es zwangslaeufig juenger und aendert das Minimum nicht. Die
Kennung bleibt damit stabil, solange die Gruppe waechst. Sie wechselt
nur, wenn zwei bestehende Gruppen verschmelzen - dann ist es richtig,
dass sich etwas aendert.
"""
return str(uuid.uuid5(NAMENSRAUM, f"dublette:{leitartikel_id}"))
# --------------------------------------------------------------------------
# Laden
# --------------------------------------------------------------------------
class Item:
__slots__ = ("id", "quelle", "titel", "teaser", "gesehen", "datum",
"inhalt_hash", "basis", "text", "trigramme", "simhash", "woerter")
def __init__(self, id, quelle, titel, teaser, gesehen, datum, inhalt_hash,
basis=None):
self.id = id
self.quelle = quelle
self.titel = titel
self.teaser = teaser
self.gesehen = gesehen
self.datum = datum
self.inhalt_hash = inhalt_hash
self.basis = basis or BASIS_VORGABE
if self.basis == "titel":
# Ressortkuerzel abtrennen: dieselbe Agenturzeile bekommt bei
# jedem Haus ein anderes vorangestellt, oder gar keins.
self.text = normalisierung.normalisiere(titel, ressort_abtrennen=True)
else:
self.text = normalisierung.normalisiere(titel, teaser)
self.woerter = len(normalisierung.woerter(self.text))
self.trigramme = normalisierung.trigramme(self.text)
self.simhash = simhash(self.trigramme)
@property
def vergleichbar(self):
return self.woerter >= MINDESTWOERTER[self.basis]
_SPALTEN = """id, quelle, titel, teaser, abgerufen_am,
coalesce(pubdate, abgerufen_am)::date as datum, inhalt_hash"""
def lade_bereich(conn, basis, von=None, bis=None):
"""Items eines Datumsbereichs, ohne Grenzen der Gesamtbestand.
Das Datum kommt aus pubdate mit Rueckfall auf abgerufen_am: Phase 1
vermerkt ausdruecklich, dass pubdate fehlen oder luegen kann.
"""
if von is None:
zeilen = conn.execute(f"select {_SPALTEN} from raw_items order by id")
else:
zeilen = conn.execute(
f"""select {_SPALTEN} from raw_items
where coalesce(pubdate, abgerufen_am)::date between %s and %s
order by id""", (von, bis))
return [Item(*z, basis=basis) for z in zeilen]
def _beruehrt_rand(ergebnis, lade_von, lade_bis):
"""Reicht eine Gruppe bis an den Rand des geladenen Bereichs?
Dann kann jenseits davon noch ein Mitglied liegen, und die Gruppe ist
unvollstaendig gerechnet.
"""
for gruppe in ergebnis.values():
if len(gruppe["mitglieder"]) < 2:
continue
for m in gruppe["mitglieder"]:
if m.datum <= lade_von or m.datum >= lade_bis:
return True
return False
def lade_und_gruppiere(conn, slot=None, fenster_tage=None, basis=None, **grenzen):
"""Liefert (items, uf, ergebnis, schreibbereich).
Laden und Gruppieren haengen beim Slot-Lauf zusammen und sind darum eine
Einheit: wieviel geladen werden muss, zeigt sich erst an den Gruppen.
Ohne slot der Gesamtbestand, dann ist alles beschreibbar.
Mit slot wird ein Rand mitgeladen und nur der innere Bereich beschrieben -
dort, wo jedes Item seine vollstaendige Nachbarschaft gesehen hat. Eine
unvollstaendig gerechnete Zeile darf eine vollstaendige nicht
ueberschreiben.
Ein fester Rand genuegt dabei nicht. Uebernahmen bilden Ketten: dieselbe
Schlagzeile erscheint am Montag bei einem, am Dienstag beim zweiten, am
Mittwoch beim dritten Haus. Wo eine Kette am geladenen Rand abgeschnitten
wird, faellt die Gruppe anders aus als im Backfill - und genau das darf
nicht sein (Invariante 2 und 4). Also wird geladen, gruppiert, und wenn
eine Gruppe bis an den Rand reicht, weiter geladen. In der Praxis laeuft
das ein- bis zweimal.
"""
fenster_tage = EINSTELLUNGEN["fenster_tage"] if fenster_tage is None else fenster_tage
if slot is None:
items = lade_bereich(conn, basis)
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
return items, uf, ergebnis, None
von, bis = conn.execute(
"""select min(coalesce(pubdate, abgerufen_am)::date),
max(coalesce(pubdate, abgerufen_am)::date)
from raw_items where slot = %s""", (slot,)).fetchone()
if von is None:
return [], None, None, None
fenster = dt.timedelta(days=fenster_tage)
schreibbereich = (von - fenster, bis + fenster)
rand = 2 * fenster_tage
for versuch in range(RANDERWEITERUNGEN):
lade_von = von - dt.timedelta(days=rand)
lade_bis = bis + dt.timedelta(days=rand)
items = lade_bereich(conn, basis, lade_von, lade_bis)
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
if not _beruehrt_rand(ergebnis, lade_von, lade_bis):
return items, uf, ergebnis, schreibbereich
rand += fenster_tage
# Reissleine: lieber den ganzen Bestand rechnen als etwas Falsches
# schreiben. Bisher nie erreicht.
items = lade_bereich(conn, basis)
uf, ergebnis = gruppiere(items, fenster_tage=fenster_tage, **grenzen)
return items, uf, ergebnis, schreibbereich
# --------------------------------------------------------------------------
# Gruppieren
# --------------------------------------------------------------------------
def paare(items, fenster_tage=EINSTELLUNGEN["fenster_tage"],
hamming_max=EINSTELLUNGEN["hamming_max"],
jaccard_min=EINSTELLUNGEN["jaccard_min"]):
"""Bestaetigte Paare finden.
Erst Kandidaten ueber gemeinsame SimHash-Baender, dann die drei Pruefungen
aus der Spezifikation: Datumsnaehe, Hamming-Distanz, Jaccard-Wert.
"""
nach_band = {}
brauchbar = [i for i in items if i.vergleichbar]
for item in brauchbar:
for band in baender(item.simhash):
nach_band.setdefault(band, []).append(item)
gesehen = set()
fenster = dt.timedelta(days=fenster_tage)
for eimer in nach_band.values():
if len(eimer) < 2:
continue
for a, b in itertools.combinations(eimer, 2):
schluessel = (a.id, b.id) if a.id < b.id else (b.id, a.id)
if schluessel in gesehen:
continue
gesehen.add(schluessel)
if abs(a.datum - b.datum) > fenster:
continue
# Dasselbe Haus an verschiedenen Tagen ist keine Uebernahme,
# sondern ein wiederkehrendes Format: "tagesschau in 100
# Sekunden", "Wetter", "Die Nachrichten". Gleicher Text, anderer
# Vorgang - kein Textverfahren kann die auseinanderhalten, also
# muss die Regel es tun.
#
# Die Regel sitzt bewusst hier auf Paarebene und nicht auf der
# fertigen Gruppe: sie haengt allein an den beiden Items und
# faellt damit gleich aus, egal wieviel Bestand geladen ist.
# Eine Regel auf Gruppenebene waere fensterabhaengig - und dann
# lieferte ein Slot-Lauf andere Gruppen als ein Backfill.
if a.quelle == b.quelle and a.datum != b.datum:
continue
if hamming(a.simhash, b.simhash) > hamming_max:
continue
# Bestaetigung. Auf dem Bestand vom 4.-7.9.2026 verwirft sie
# kein einziges Paar mehr, das die Hamming-Grenze passiert hat -
# sie ist trotzdem die eigentliche Praezisionszusage. SimHash
# kann kollidieren, Jaccard nicht.
j = jaccard(a.trigramme, b.trigramme)
if j < jaccard_min:
continue
yield a, b, j
def _zerlege_ketten(gruppe, fenster_tage):
"""Gruppen aufbrechen, die nur ueber eine Kette zusammenhaengen.
Union-Find ist transitiv, das Datumsfenster ist es nicht: A passt zu B,
B zu C, und schon haengt A mit C zusammen, obwohl mehr als ein Tag
dazwischen liegt. Eine Uebernahme ist ein Vorgang, der bei mehreren
Haeusern gleichzeitig erscheint - keine Schlagzeile, die drei Tage lang
weitergereicht wird. Eine Gruppe darf darum nicht mehr Tage umspannen
als das Fenster.
Auf dem Bestand vom 4.-7.9.2026 trifft das genau eine von 135
Komponenten: dieselbe Newsblog-Zeile bei handelsblatt (4.9.), sz (5.9.)
und tagesschau (6.9.). Der Schnitt ist also kein Massenphaenomen,
sondern verhindert, dass Phase 3 den Stand vom Mittwoch mit der
Kodierung vom Montag versieht.
Getrennt wird nach Datum, damit das Ergebnis nicht von der
Ladereihenfolge abhaengt.
"""
spanne = dt.timedelta(days=fenster_tage)
geordnet = sorted(gruppe, key=lambda i: (i.datum, i.gesehen, i.id))
teile, aktuell = [], [geordnet[0]]
for item in geordnet[1:]:
if item.datum - aktuell[0].datum > spanne:
teile.append(aktuell)
aktuell = [item]
else:
aktuell.append(item)
teile.append(aktuell)
return teile
def gruppiere(items, fenster_tage=EINSTELLUNGEN["fenster_tage"], **grenzen):
"""Union-Find ueber die bestaetigten Paare.
Liefert je Item die Gruppe, ihre Mitglieder, den Leitartikel und den
kleinsten Jaccard-Wert, der die Gruppe zusammenhaelt.
"""
uf = UnionFind()
schwaechste = {}
for a, b, j in paare(items, fenster_tage=fenster_tage, **grenzen):
uf.vereinige(a.id, b.id)
schwaechste[(a.id, b.id)] = j
roh = {}
for item in items:
roh.setdefault(uf.finde(item.id), []).append(item)
# Ketten aufbrechen und die Zugehoerigkeit neu festschreiben. Danach ist
# uf.finde() wieder die Wahrheit ueber die Gruppen.
mitglieder = {}
for gruppe in roh.values():
for teil in _zerlege_ketten(gruppe, fenster_tage):
wurzel = min(i.id for i in teil)
for item in teil:
uf.eltern[item.id] = wurzel
mitglieder[wurzel] = teil
ergebnis = {}
for wurzel, gruppe in mitglieder.items():
# Leitartikel: aeltestes gesehenes Item, bei Gleichstand die kleinste
# id. Rein deterministisch, unabhaengig von der Ladereihenfolge.
leit = min(gruppe, key=lambda i: (i.gesehen, i.id))
ids = {i.id for i in gruppe}
werte = [j for (x, y), j in schwaechste.items() if x in ids and y in ids]
ergebnis[wurzel] = {
"mitglieder": gruppe,
"leitartikel": leit,
"jaccard_min": round(min(werte), 4) if werte else None,
}
return uf, ergebnis
def nutzlast(item, gruppe):
leit = gruppe["leitartikel"]
groesse = len(gruppe["mitglieder"])
return {
"gruppe": gruppen_id(leit.id),
"gruppengroesse": groesse,
"leitartikel": leit.id,
"simhash": f"{item.simhash:016x}",
"jaccard_min": gruppe["jaccard_min"],
"agentur_vermutet": agentur(item.titel, item.teaser),
# Basis und Stand des Texts, gegen den kodiert wurde. Ohne das laesst
# sich spaeter nicht sagen, ob eine Kodierung noch zum Item passt:
# raw_items haelt den *aktuellen* Titel, Redaktionen aendern ihn nach
# (in diesem Bestand 3 von 10 Items), und dann ist der Vergleich von
# gestern gegen einen Text gelaufen, den es so nicht mehr gibt.
"basis": item.basis,
"basis_hash": item.inhalt_hash,
"woerter": item.woerter,
"vergleichbar": item.vergleichbar,
# Eine Gruppe aus einem einzigen Haus ist keine Uebernahme, sondern
# eine Wiederholung im selben Feed. Fuer Phase 3 ist der Unterschied
# wesentlich: bei einer Uebernahme erben die Mitglieder die Kodierung
# des Leitartikels, eine Wiederholung ist derselbe Vorgang zweimal.
"haeuser": len({i.quelle for i in gruppe["mitglieder"]}),
}
# --------------------------------------------------------------------------
# Schreiben
# --------------------------------------------------------------------------
def schreibe(conn, items, ergebnis, uf, coder_version, trocken=False, schreibbereich=None):
"""Codings ablegen, aber nur wo sich etwas geaendert hat.
Jedes verarbeitete Item bekommt eine Zeile, auch ein Einzelstueck: sonst
liesse sich "keine Dublette" nicht von "nicht verarbeitet" unterscheiden.
Unveraenderte Zeilen werden nicht angefasst. Ein Slot-Lauf rechnet ueber
das ganze Datumsfenster, und ohne diesen Vergleich schriebe er bei jedem
Durchgang einige tausend Zeilen neu - mit neuem kodiert_am, ohne dass
sich etwas geaendert haette.
"""
vorhanden = {
r[0]: r[1]
for r in conn.execute(
"select raw_item_id, nutzlast from codings"
" where coder_version = %s and ebene = %s", (coder_version, EBENE))
}
if schreibbereich is None:
zu_schreiben = items
else:
von, bis = schreibbereich
zu_schreiben = [i for i in items if von <= i.datum <= bis]
neu = geaendert = unveraendert = 0
stapel = []
for item in zu_schreiben:
gruppe = ergebnis[uf.finde(item.id)]
last = nutzlast(item, gruppe)
alt = vorhanden.get(item.id)
if alt == last:
unveraendert += 1
continue
if alt is None:
neu += 1
else:
geaendert += 1
stapel.append((item.id, coder_version, EBENE, last["jaccard_min"], Jsonb(last)))
if stapel and not trocken:
with conn.cursor() as cur:
cur.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, konfidenz, nutzlast)
values (%s, %s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene)
do update set nutzlast = excluded.nutzlast,
konfidenz = excluded.konfidenz,
kodiert_am = now()""",
stapel)
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert,
"ausserhalb": len(items) - len(zu_schreiben)}
def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None):
conn.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s)""",
(slot, EBENE, coder_version, dauer_ms, gesehen, kodiert, 0 if not fehler else 1, fehler))
# --------------------------------------------------------------------------
# Bericht und Stichprobe
# --------------------------------------------------------------------------
def bericht(items, ergebnis):
gruppen = [g for g in ergebnis.values() if len(g["mitglieder"]) > 1]
in_gruppen = sum(len(g["mitglieder"]) for g in gruppen)
ueber = [g for g in gruppen if len({i.quelle for i in g["mitglieder"]}) > 1]
kurz = sum(1 for i in items if not i.vergleichbar)
mindest = MINDESTWOERTER[items[0].basis] if items else 0
print(f" Items: {len(items)}")
print(f" zu kurz: {kurz} (< {mindest} Woerter, nicht verglichen)")
print(f" Uebernahmegruppen:{len(gruppen):>4} mit {in_gruppen} Mitgliedern")
print(f" davon ueber mehrere Haeuser: {len(ueber)} <- der eigentliche Zweck")
if gruppen:
groessen = sorted((len(g["mitglieder"]) for g in gruppen), reverse=True)
print(f" groesste Gruppe: {groessen[0]}")
for g in sorted(gruppen, key=lambda g: -len(g["mitglieder"]))[:5]:
haeuser = sorted({i.quelle for i in g["mitglieder"]})
leit = g["leitartikel"]
print(f" [{len(g['mitglieder'])}] j={g['jaccard_min']} {', '.join(haeuser)}")
print(f" {leit.titel[:88]}")
def stichprobe(ergebnis, anzahl, pfad):
"""Paare zum Nachprüfen von Hand ausschreiben.
Die Spezifikation verlangt Praezision >= 0.98 auf 200 handgeprueften
Paaren. Pruefen kann das nur ein Mensch; diese Datei ist die Vorlage
dafuer. Sie enthaelt Titel im Klartext und gehoert deshalb nicht in die
Datenbank, sondern neben sie.
"""
zeilen = []
for gruppe in ergebnis.values():
m = gruppe["mitglieder"]
if len(m) < 2:
continue
for a, b in itertools.combinations(sorted(m, key=lambda i: i.id), 2):
zeilen.append((jaccard(a.trigramme, b.trigramme),
hamming(a.simhash, b.simhash), a, b))
zeilen.sort(key=lambda z: (z[0], z[3].id)) # schwaechste zuerst - dort sitzen die Fehler
with open(pfad, "w", encoding="utf-8") as f:
f.write("# Dubletten-Stichprobe. Spalte 'urteil' von Hand fuellen: j = Dublette, n = keine.\n")
f.write("# Absteigend nach Zweifel sortiert: die obersten Paare sind die knappsten.\n\n")
for j, h, a, b in zeilen[:anzahl]:
f.write(f"urteil=_ jaccard={j:.3f} hamming={h}\n")
f.write(f" {a.id:>7} {a.quelle:<14}{a.titel}\n")
f.write(f" {b.id:>7} {b.quelle:<14}{b.titel}\n\n")
return min(len(zeilen), anzahl), len(zeilen)
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
p.add_argument("--backfill", action="store_true", help="Gesamtbestand")
p.add_argument("--basis", choices=("titel", "titel+teaser"), default=BASIS_VORGABE,
help=f"Vergleichsbasis (Vorgabe {BASIS_VORGABE})")
p.add_argument("--jaccard", type=float, default=EINSTELLUNGEN["jaccard_min"],
help="Bestaetigungsschwelle (Vorgabe %(default)s)")
p.add_argument("--hamming", type=int, default=EINSTELLUNGEN["hamming_max"],
help="hoechste Hamming-Distanz fuer Kandidaten (Vorgabe %(default)s)")
p.add_argument("--fenster", type=int, default=1, help="Datumsfenster in Tagen (Vorgabe 1)")
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
p.add_argument("--stichprobe", type=int, metavar="N", help="N Paare zum Nachpruefen ausschreiben")
p.add_argument("--stichprobe-datei", default="stichprobe-dubletten.txt")
a = p.parse_args(argv)
if not a.backfill and not a.slot:
p.error("--backfill oder --slot angeben")
slot = dt.datetime.fromisoformat(a.slot) if a.slot else None
begonnen = time.monotonic()
einstellungen = dict(EINSTELLUNGEN,
basis=a.basis,
jaccard_min=a.jaccard,
hamming_max=a.hamming,
fenster_tage=a.fenster,
mindestwoerter=MINDESTWOERTER[a.basis])
with verbindung() as conn:
# Die Einstellungen gehen in den Hash ein: eine andere Basis oder eine
# andere Schwelle ist eine andere Kodierung und darf die vorhandene
# nicht ueberschreiben.
v = versionsmodul.eintragen(
conn, komponenten=versionsmodul.komponenten(dubletten=einstellungen))
print(f"coder_version {v} basis={a.basis} j>={a.jaccard} h<={a.hamming}"
f"{' (trocken)' if a.trocken else ''}")
items, uf, ergebnis, schreibbereich = lade_und_gruppiere(
conn, slot=slot, fenster_tage=a.fenster, basis=a.basis,
jaccard_min=a.jaccard, hamming_max=a.hamming)
if not items:
print(" nichts zu tun")
return 0
bericht(items, ergebnis)
zahlen = schreibe(conn, items, ergebnis, uf, v, trocken=a.trocken,
schreibbereich=schreibbereich)
dauer = int((time.monotonic() - begonnen) * 1000)
print(f" codings: neu {zahlen['neu']}, geaendert {zahlen['geaendert']},"
f" unveraendert {zahlen['unveraendert']}"
+ (f", Kranz uebergangen {zahlen['ausserhalb']}" if zahlen["ausserhalb"] else ""))
print(f" Dauer: {dauer} ms")
if a.stichprobe:
n, gesamt = stichprobe(ergebnis, a.stichprobe, a.stichprobe_datei)
print(f" Stichprobe: {n} von {gesamt} Paaren nach {a.stichprobe_datei}")
if not a.trocken:
protokolliere(conn, slot, v, dauer, len(items),
zahlen["neu"] + zahlen["geaendert"])
conn.commit()
else:
conn.rollback()
return 0
if __name__ == "__main__":
sys.exit(main())
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""Phase 2, Modul 5: Satzvektoren (ebene = 'embedding').
Ein Encoder bildet Titel und Teaser auf einen Vektor ab. Zwei Meldungen ueber
denselben Vorgang liegen dann nah beieinander, auch wenn kein Wort uebereinstimmt
- genau das, was das Dublettenmodul konstruktionsbedingt nicht kann.
python3 embeddings.py --backfill
python3 embeddings.py --slot 2026-09-07T08:00
python3 embeddings.py --backfill --index # HNSW-Index danach anlegen
python3 embeddings.py --nachweis # Determinismus messen
Phase 2 clustert nicht. Der Vektor ist Vorfilter fuer die
Kandidatengenerierung in Phase 3b, kein Selbstzweck (Abschnitt 7 der
Spezifikation).
Kein LLM: multilingual-e5-large ist ein Encoder. Text hinein, Vektor heraus -
kein Prompt, keine Sampling-Temperatur, keine Textausgabe, feste Gewichte,
kein Netzzugriff nach dem ersten Laden.
"""
import argparse
import hashlib
import os
import struct
import sys
import time
import normalisierung
import version as versionsmodul
from db import verbindung
EBENE = "embedding"
MODELL = "intfloat/multilingual-e5-large"
DIMENSIONEN = 1024
# e5 erwartet ein Praefix. Die Modellkarte unterscheidet "query: " und
# "passage: " fuer asymmetrische Suche - Frage gegen Dokument. Hier werden
# Meldungen mit Meldungen verglichen, also symmetrisch, und dafuer nennt
# dieselbe Karte "query: " auf beiden Seiten.
PRAEFIX = "query: "
# Feste Laenge fuer *jede* Zeile, nicht nur je Stapel.
#
# Das ist der Kern der Reproduzierbarkeit. Ein Encoder rechnet stapelweise;
# fuellt man nur bis zur laengsten Zeile des Stapels auf, aendert sich die
# Form der Matrizen mit der Zusammensetzung des Stapels, und damit die
# Reihenfolge der Gleitkommaadditionen. Ein Slot-Lauf ueber 31 Items lieferte
# dann andere letzte Stellen als ein Backfill ueber 3407 - und die Zusage
# "Live-Lauf gleich Neuaufbau" waere hin.
#
# Bei fester Laenge hat jeder Stapel dieselbe Form, und eine Zeile haengt
# nicht mehr von ihren Nachbarn ab.
#
# 128 Token: Titel und Teaser zusammen liegen bei etwa 31 Woertern, im
# Bestand ueberschreitet keine Zeile die Grenze (nachgeprueft beim Lauf,
# siehe `gekuerzt` im Bericht).
MAX_TOKEN = 128
STAPEL = 32
# Threadzahl. Sie bestimmt, wie die Summen aufgeteilt werden, und gehoert
# darum in den Versions-Hash. Der Container setzt sie ueber OMP_NUM_THREADS.
THREADS = int(os.environ.get("OMP_NUM_THREADS", "4"))
def einstellungen(revision=None, torch_version=None):
"""Was in die coder_version eingeht.
Auch die Bibliotheksversion: ein anderer torch kann andere Kernel waehlen
und damit andere letzte Stellen liefern. Das entwertet die Vektoren
nicht, aber es macht sie zu einem anderen Bestand, und genau das soll der
Hash sagen.
"""
return {
"modell": MODELL,
"modell_revision": revision,
"dimensionen": DIMENSIONEN,
"praefix": PRAEFIX,
"basis": "titel+teaser",
"text": "bereinigt",
"max_token": MAX_TOKEN,
"fuellung": "feste_laenge",
"pooling": "mittel_maskiert",
"l2_normiert": True,
"dtype": "float32",
"stapel": STAPEL,
"threads": THREADS,
"torch": torch_version,
}
# --------------------------------------------------------------------------
# Modell
# --------------------------------------------------------------------------
def lade_modell():
"""(tokenizer, modell, revision, torch_version).
Der Import steht hier und nicht oben: die uebrigen Module dieses Repos
laufen ohne torch, und `python3 -m unittest` soll nicht 2 GB laden
muessen, um die Normalisierung zu pruefen.
"""
import torch
from transformers import AutoModel, AutoTokenizer
torch.set_num_threads(THREADS)
torch.set_grad_enabled(False)
tokenizer = AutoTokenizer.from_pretrained(MODELL)
modell = AutoModel.from_pretrained(MODELL, dtype=torch.float32)
modell.eval()
# Die aufgeloeste Revision aus dem Cache. Ohne sie sagt "e5-large" nur,
# welches Modell gemeint war, nicht welche Gewichte gerechnet haben.
revision = getattr(getattr(modell, "config", None), "_commit_hash", None)
return tokenizer, modell, revision, torch.__version__
def kodiere(tokenizer, modell, texte, stapel=STAPEL):
"""Vektoren zu den Texten, in derselben Reihenfolge.
Mittelwert ueber die nicht aufgefuellten Positionen, dann L2-normiert -
das Verfahren der e5-Modellkarte. Nach der Normierung ist das
Skalarprodukt die Kosinus-Aehnlichkeit, und pgvector kann mit
vector_cosine_ops direkt darauf suchen.
"""
import torch
alle = []
for anfang in range(0, len(texte), stapel):
teil = texte[anfang:anfang + stapel]
eingabe = tokenizer(teil, padding="max_length", truncation=True,
max_length=MAX_TOKEN, return_tensors="pt")
ausgabe = modell(**eingabe).last_hidden_state
maske = eingabe["attention_mask"].unsqueeze(-1).to(ausgabe.dtype)
mittel = (ausgabe * maske).sum(1) / maske.sum(1).clamp(min=1e-9)
alle.append(torch.nn.functional.normalize(mittel, p=2, dim=1))
return torch.cat(alle) if alle else torch.empty(0, DIMENSIONEN)
def zu_lang(tokenizer, texte):
"""Wieviele Texte die Tokengrenze reissen. Sollte 0 sein."""
return sum(1 for t in texte
if len(tokenizer(t, truncation=False)["input_ids"]) > MAX_TOKEN)
# --------------------------------------------------------------------------
# Laden und Schreiben
# --------------------------------------------------------------------------
_SPALTEN = "select id, quelle, titel, teaser from raw_items"
_ORDNUNG = " order by id"
def lade(conn, slot=None):
if slot is None:
zeilen = conn.execute(_SPALTEN + _ORDNUNG).fetchall()
else:
zeilen = conn.execute(_SPALTEN + " where slot = %s" + _ORDNUNG, (slot,)).fetchall()
return zeilen
def text(titel, teaser):
"""Was kodiert wird.
bereinige() statt normalisiere(): der Encoder ist auf natuerlichem Text
trainiert. Grossschreibung unterscheidet im Deutschen Wortarten, und die
Anfuehrungszeichen um ein Zitat sind Bedeutung, kein Rauschen.
"""
return PRAEFIX + normalisierung.bereinige(titel, teaser)
def vektor_hash(werte):
"""Kennung des Vektors, damit sich ohne Fliesskommavergleich sagen laesst,
ob sich etwas geaendert hat - und damit der Determinismus pruefbar ist.
struct statt numpy: dieselben Bytes (float32, little endian), aber ohne
Abhaengigkeit. Die Funktion laesst sich damit auch dort pruefen, wo kein
torch installiert ist.
"""
liste = werte.tolist() if hasattr(werte, "tolist") else list(werte)
roh = struct.pack(f"<{len(liste)}f", *liste)
return hashlib.blake2b(roh, digest_size=8).hexdigest()
def als_vector(werte):
"""pgvector-Literal. Spart die Abhaengigkeit auf das Adapterpaket."""
return "[" + ",".join(repr(float(w)) for w in werte) + "]"
def schreibe(conn, zeilen, vektoren, coder_version, revision, trocken=False):
"""Vektor nach item_embeddings, Kennung nach codings.
Zwei Tabellen, weil ein Vektor in JSONB weder indizierbar noch
platzsparend waere (siehe p2-002). Die Coding-Zeile ist der Nachweis,
dass ein Item verarbeitet wurde, und traegt, womit.
"""
from psycopg.types.json import Jsonb
vorhanden = {
r[0]: r[1]
for r in conn.execute(
"select raw_item_id, nutzlast from codings"
" where coder_version = %s and ebene = %s", (coder_version, EBENE))
}
neu = geaendert = unveraendert = 0
vektorstapel, codingstapel = [], []
for (item_id, _quelle, titel, teaser), v in zip(zeilen, vektoren):
last = {
"modell": MODELL,
"modell_revision": revision,
"dimensionen": DIMENSIONEN,
"basis": "titel+teaser",
"vektor_hash": vektor_hash(v),
"woerter": len(normalisierung.bereinige(titel, teaser).split()),
}
alt = vorhanden.get(item_id)
if alt == last:
unveraendert += 1
continue
if alt is None:
neu += 1
else:
geaendert += 1
vektorstapel.append((item_id, coder_version, als_vector(v)))
codingstapel.append((item_id, coder_version, EBENE, Jsonb(last)))
if codingstapel and not trocken:
with conn.cursor() as cur:
cur.executemany(
"""insert into item_embeddings (raw_item_id, coder_version, vektor)
values (%s, %s, %s::vector)
on conflict (raw_item_id, coder_version)
do update set vektor = excluded.vektor, erzeugt_am = now()""",
vektorstapel)
cur.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene)
do update set nutzlast = excluded.nutzlast, kodiert_am = now()""",
codingstapel)
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert}
def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None):
conn.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s)""",
(slot, EBENE, coder_version, dauer_ms, gesehen, kodiert,
0 if not fehler else 1, fehler))
# --------------------------------------------------------------------------
# Nachweis
# --------------------------------------------------------------------------
def nachweis(tokenizer, modell, zeilen, stapel=STAPEL):
"""Haengt ein Vektor von der Zusammensetzung seines Stapels ab?
Die Zusage aus Invariante 2 lautet, dass ein Slot-Lauf dasselbe liefert
wie ein Neuaufbau. Bei den regelbasierten Modulen folgt das aus dem
Verfahren; hier muss es gemessen werden.
Geprueft wird das Aeusserste, was im Betrieb vorkommt: dieselben Zeilen
einmal einzeln, einmal im vollen Stapel, einmal in umgekehrter
Reihenfolge.
"""
texte = [text(t, s) for _, _, t, s in zeilen]
voll = kodiere(tokenizer, modell, texte, stapel=stapel)
einzeln = kodiere(tokenizer, modell, texte, stapel=1)
rueckwaerts = kodiere(tokenizer, modell, texte[::-1], stapel=stapel).flip(0)
gleich_einzeln = sum(vektor_hash(a) == vektor_hash(b)
for a, b in zip(voll, einzeln))
gleich_rueck = sum(vektor_hash(a) == vektor_hash(b)
for a, b in zip(voll, rueckwaerts))
abstand_einzeln = float((voll - einzeln).abs().max())
abstand_rueck = float((voll - rueckwaerts).abs().max())
# Bei bitgleichen Vektoren liegt dieser Wert knapp unter 1 - nicht weil
# sie sich unterscheiden, sondern weil die Summe von 1024 Quadraten in
# float32 nicht exakt auf 1 faellt. Aussagekraft hat er nur, wenn die
# Zeilen darueber eine Abweichung melden.
kosinus = float((voll * einzeln).sum(1).min())
print(f" Zeilen geprueft: {len(texte)}")
print(f" bitgleich einzeln: {gleich_einzeln}/{len(texte)}"
f" groesste Abweichung {abstand_einzeln:.2e}")
print(f" bitgleich rueckwaerts:{gleich_rueck}/{len(texte)}"
f" groesste Abweichung {abstand_rueck:.2e}")
print(f" kleinster Kosinus zwischen den Fassungen: {kosinus:.9f}"
+ (" (Rundung der Quadratsumme, keine Abweichung)"
if abstand_einzeln == 0.0 and abstand_rueck == 0.0 else ""))
return gleich_einzeln == len(texte) and gleich_rueck == len(texte)
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
p.add_argument("--backfill", action="store_true", help="Gesamtbestand")
p.add_argument("--nachweis", type=int, nargs="?", const=64, metavar="N",
help="Determinismus an N Zeilen messen, nichts schreiben")
p.add_argument("--index", action="store_true",
help="HNSW-Index fuer diese Version anlegen")
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
a = p.parse_args(argv)
if not (a.backfill or a.slot or a.nachweis):
p.error("--backfill, --slot oder --nachweis angeben")
import datetime as dt
slot = dt.datetime.fromisoformat(a.slot) if a.slot else None
begonnen = time.monotonic()
print(f"Modell {MODELL} laedt, {THREADS} Threads ...")
tokenizer, modell, revision, torch_version = lade_modell()
print(f" Revision {revision} torch {torch_version}"
f" ({int(time.monotonic() - begonnen)} s)")
with verbindung() as conn:
if a.nachweis:
zeilen = conn.execute(_SPALTEN + _ORDNUNG + " limit %s",
(a.nachweis,)).fetchall()
return 0 if nachweis(tokenizer, modell, zeilen) else 1
v = versionsmodul.eintragen(
conn, komponenten=versionsmodul.komponenten(
embedding=einstellungen(revision, torch_version)))
print(f"coder_version {v}{' (trocken)' if a.trocken else ''}")
zeilen = lade(conn, slot=slot)
if not zeilen:
print(" nichts zu tun")
return 0
texte = [text(t, s) for _, _, t, s in zeilen]
lang = zu_lang(tokenizer, texte)
gestartet = time.monotonic()
vektoren = kodiere(tokenizer, modell, texte)
dauer_kodierung = time.monotonic() - gestartet
print(f" Items: {len(zeilen)}")
# Ein paar gekuerzte Zeilen sind erwartbar (Sendungsablaeufe wie
# "tagesschau 20:00 Uhr" listen alle Themen auf). Gewarnt wird erst,
# wenn es mehr als ein Prozent trifft - dann stimmt die Grenze nicht.
print(f" ueber {MAX_TOKEN} Token: {lang}"
+ (" <- Grenze pruefen" if lang > len(zeilen) / 100 else ""))
print(f" Kodierung: {dauer_kodierung:.1f} s"
f" ({len(zeilen) / max(dauer_kodierung, 1e-9):.0f} Items/s)")
zahlen = schreibe(conn, zeilen, vektoren, v, revision, trocken=a.trocken)
dauer = int((time.monotonic() - begonnen) * 1000)
print(f" Vektoren: neu {zahlen['neu']},"
f" geaendert {zahlen['geaendert']},"
f" unveraendert {zahlen['unveraendert']}")
if a.index and not a.trocken:
name = conn.execute("select p2_embedding_index(%s)", (v,)).fetchone()[0]
print(f" Index: {name}")
print(f" Dauer gesamt: {dauer} ms")
if not a.trocken:
protokolliere(conn, slot, v, dauer, len(zeilen),
zahlen["neu"] + zahlen["geaendert"])
conn.commit()
else:
conn.rollback()
return 0
if __name__ == "__main__":
sys.exit(main())
+367
View File
@@ -0,0 +1,367 @@
"""Akteurslexikon: Wikidata spiegeln, Spiegel laden.
Zwei streng getrennte Betriebsarten, und die Trennung ist der Zweck der
Datei:
lexikon.py --spiegeln fragt Wikidata ab und schreibt lexikon/*.json
lexikon.py --laden liest lexikon/*.json und fuellt die Tabellen
Nur --spiegeln fasst das Netz an, und es laeuft von Hand, nicht im Takt.
Der Slot-Lauf der Phase 2 sieht Wikidata nie. Das ist keine Feinheit: die
Wikidata Query Service ist ein von Spenden getragener oeffentlicher Dienst,
und eine Pipeline, die alle 15 Minuten dagegenlaeuft, ist Schmarotzertum.
Der Spiegel ist eine getreue Abschrift, keine Auslegung. Was in
lexikon/*.json steht, steht so in Wikidata; jede Ableitung - Kurzformen,
Rollenzuordnung, Normalisierung - geschieht beim Laden und ist damit
nachvollziehbar und wiederholbar, ohne erneut abzufragen.
"""
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
VERZEICHNIS = pathlib.Path(__file__).parent / "lexikon"
ENDPUNKT = "https://query.wikidata.org/sparql"
# Die Nutzungsrichtlinie der WDQS bittet um einen sprechenden User-Agent mit
# Kontaktangabe. Hier steht die Impressumsadresse des Projekts - eine
# oeffentliche Adresse, die genau dafuer da ist.
KENNUNG = os.environ.get(
"WDQS_USER_AGENT",
"wurzelwerk-p2/0.1 (Datenjournalismus-Pipeline Wurzelwerkt; stress@bnd.wtf)",
)
# Pause zwischen zwei Abfragen. Die WDQS erlaubt mehr, aber wir haben es
# nicht eilig - der Spiegel entsteht einmal und liegt dann Monate.
PAUSE = 2.0
VERSUCHE = 3
ZEITGRENZE = 120
# Stichtag fuer Personen: wer vor 2017 aus allen Aemtern ausgeschieden ist,
# taucht in Nachrichten von 2026 hoechstens als Rueckblick auf. Die Grenze
# ist willkuerlich, aber sie steht hier und nicht verstreut in Abfragen.
STICHTAG = "2017-01-01"
# Anfang der Wahlperioden, die 2026 noch nachwirken. Dient als Untergrenze
# fuer laufende Mandate ohne Endedatum - siehe die Landtagsabfrage.
WAHLPERIODE = "2016-01-01"
# Aemter, die in deutschen Schlagzeilen ohne Namen auftauchen ("der
# Kanzler", "die Aussenministerin"). Handverlesen, weil eine generische
# Abfrage hier tausende Aemter liefert, von denen keines je in einer
# Schlagzeile steht. Das ist die "manuelle Ergaenzung" aus der Spezifikation.
AEMTER = {
"Q4970706": ("amt:bundeskanzler", "Bundeskanzler", "GOV"),
"Q25223": ("amt:bundespraesident", "Bundespraesident", "GOV"),
"Q56024": ("amt:bundestagspraesident", "Praesident des Deutschen Bundestages", "LEG"),
"Q568605": ("amt:aussenminister", "Bundesminister des Auswaertigen", "GOV"),
"Q22575628": ("amt:innenminister", "Bundesminister des Innern", "GOV"),
"Q22575092": ("amt:finanzminister", "Bundesminister der Finanzen", "GOV"),
"Q23016036": ("amt:verteidigungsminister", "Bundesminister der Verteidigung", "GOV"),
"Q30533368": ("amt:gesundheitsminister", "Bundesminister fuer Gesundheit", "GOV"),
"Q30543924": ("amt:verkehrsminister", "Bundesminister fuer Verkehr", "GOV"),
"Q30533351": ("amt:arbeitsminister", "Bundesminister fuer Arbeit und Soziales", "GOV"),
"Q30544097": ("amt:umweltminister", "Bundesminister fuer Umwelt", "GOV"),
# Justiz und Wirtschaft fuehrt Wikidata je Zuschnitt als eigenes Amt.
# Fuer uns ist es dasselbe Amt: die Ressortnamen wechseln, die Rolle in
# der Schlagzeile nicht. Mehrere QIDs zeigen deshalb auf eine amt_id.
"Q30533247": ("amt:justizminister", "Bundesminister der Justiz", "GOV"),
"Q30542236": ("amt:justizminister", "Bundesminister der Justiz", "GOV"),
"Q30543192": ("amt:wirtschaftsminister", "Bundesminister fuer Wirtschaft", "GOV"),
"Q30533307": ("amt:wirtschaftsminister", "Bundesminister fuer Wirtschaft", "GOV"),
"Q30319173": ("amt:wirtschaftsminister", "Bundesminister fuer Wirtschaft", "GOV"),
# Regierungschefs der Laender. Jedes Land fuehrt sein eigenes Amt als
# eigenes Wikidata-Objekt; einen gemeinsamen Oberbegriff, ueber den sich
# alle sechzehn einsammeln liessen, gibt es nicht. Also stehen sie hier.
"Q30968305": ("amt:mp_sachsen_anhalt", "Ministerpraesident Sachsen-Anhalts", "GOV"),
"Q30569887": ("amt:mp_sachsen", "Ministerpraesident Sachsens", "GOV"),
"Q30584228": ("amt:mp_thueringen", "Ministerpraesident Thueringens", "GOV"),
"Q30562601": ("amt:mp_brandenburg", "Ministerpraesident Brandenburgs", "GOV"),
"Q14917672": ("amt:mp_mecklenburg_vorpommern", "Ministerpraesident Mecklenburg-Vorpommerns", "GOV"),
"Q15303555": ("amt:mp_bayern", "Ministerpraesident Bayerns", "GOV"),
"Q31096505": ("amt:mp_baden_wuerttemberg", "Ministerpraesident Baden-Wuerttembergs", "GOV"),
"Q30534793": ("amt:mp_nordrhein_westfalen", "Ministerpraesident Nordrhein-Westfalens", "GOV"),
"Q31524732": ("amt:mp_niedersachsen", "Niedersaechsischer Ministerpraesident", "GOV"),
"Q1615935": ("amt:mp_hessen", "Ministerpraesident Hessens", "GOV"),
"Q15720142": ("amt:mp_rheinland_pfalz", "Ministerpraesident von Rheinland-Pfalz", "GOV"),
"Q31068298": ("amt:mp_saarland", "Ministerpraesident des Saarlandes", "GOV"),
"Q30263013": ("amt:mp_schleswig_holstein", "Ministerpraesident Schleswig-Holsteins", "GOV"),
"Q641159": ("amt:rb_berlin", "Regierender Buergermeister von Berlin", "GOV"),
"Q1362118": ("amt:eb_hamburg", "Erster Buergermeister von Hamburg", "GOV"),
"Q31388880": ("amt:b_bremen", "Bremer Buergermeister", "GOV"),
}
_PERSONEN_RUMPF = """
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
OPTIONAL { ?p wdt:P102 ?pt . ?pt rdfs:label ?ptl . FILTER (lang(?ptl) = "de") }
"""
ABFRAGEN = {
# Bundestagsabgeordnete, alle seit dem Stichtag amtierenden.
"bundestag": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
(SAMPLE(?ptl) AS ?partei)
WHERE {
?p p:P39 ?s . ?s ps:P39 ?a . ?a wdt:P279* wd:Q1939555 .
OPTIONAL { ?s pq:P582 ?ende . }
FILTER (!BOUND(?ende) || ?ende >= "%(stichtag)s"^^xsd:dateTime)
%(rumpf)s
} GROUP BY ?p ?name
""",
# Parteien in Deutschland, Oesterreich und der Schweiz.
"parteien": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
(SAMPLE(?landc) AS ?land)
WHERE {
?p wdt:P31/wdt:P279* wd:Q7278 .
?p wdt:P17 ?l . VALUES ?l { wd:Q183 wd:Q40 wd:Q39 }
OPTIONAL { ?l wdt:P298 ?landc . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
FILTER NOT EXISTS { ?p wdt:P576 ?aufgeloest . FILTER (?aufgeloest < "%(stichtag)s"^^xsd:dateTime) }
} GROUP BY ?p ?name
""",
# Souveraene Staaten mit ISO-3166-1 alpha-3.
"staaten": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase) ?land
WHERE {
?p wdt:P31 wd:Q3624078 ; wdt:P298 ?land .
FILTER NOT EXISTS { ?p wdt:P576 ?ende . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name ?land
""",
# Amtierende Staats- und Regierungschefs aller Staaten. Ohne sie fehlen
# die haeufigsten Personen des Korpus, denn im Bundestag sass keiner von
# ihnen. Nur der aktuelle Stand - Vorgaenger holt die Amtstabelle.
"staatsfuehrung": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase) ?land
WHERE {
?l wdt:P31 wd:Q3624078 ; wdt:P298 ?land .
{ ?l wdt:P35 ?p } UNION { ?l wdt:P6 ?p }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name ?land
""",
# Landtagsabgeordnete aller sechzehn Laender. Die Landeslisten sind
# nicht ueber Unterklassen erreichbar: "Mitglied des Landtag
# Sachsen-Anhalt" ist eine *Instanz* von Q1939559, keine Unterklasse.
# Deshalb P31/P279* statt P279* wie beim Bundestag.
#
# Der Datumsfilter ist schaerfer als beim Bundestag, und das mit Grund:
# von den 11464 Personen, die je in einem Landtag sassen, tragen 9700
# ueberhaupt keine Zeitangabe. Ein blosses "kein Ende gesetzt" liesse
# die Abgeordneten der Weimarer Republik herein. Es zaehlt also, wer
# nachweislich nach dem Stichtag ausgeschieden ist oder wer ohne
# Endedatum seit der Wahlperiode ab 2016 im Amt steht.
"landtage": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
(SAMPLE(?ptl) AS ?partei)
WHERE {
?a wdt:P31/wdt:P279* wd:Q1939559 .
?p p:P39 ?s . ?s ps:P39 ?a .
OPTIONAL { ?s pq:P580 ?von . }
OPTIONAL { ?s pq:P582 ?bis . }
FILTER (?bis >= "%(stichtag)s"^^xsd:dateTime
|| (!BOUND(?bis) && ?von >= "%(wahlperiode)s"^^xsd:dateTime))
%(rumpf)s
} GROUP BY ?p ?name
""",
# Amtierende Regierungsmitglieder unterhalb der Staatsspitze, weltweit.
# Ohne sie fehlt Lawrow - er sass in keinem Bundestag und fuehrt keinen
# Staat.
#
# Ueber P1308, das vom Amt auf seinen derzeitigen Inhaber zeigt. Der
# naheliegende Weg ueber die Amtszeit der Person taugt nicht: wer wie
# Lawrow seit 2004 amtiert, faellt aus jedem Datumsfenster, und ohne
# Fenster kommen Tausende Minister herein, deren Amtszeit nie zu Ende
# gepflegt wurde. P1308 ist Wikidatas eigene Aussage darueber, wer
# gerade im Amt ist, und damit die ehrlichere Quelle.
#
# Minister nur zwei Ebenen tief: "russischer Aussenminister" ist eine
# Unterklasse von "Aussenminister", das wiederum direkt von Q83307.
# Das volle P279* laeuft in die Zeitgrenze der WDQS - 9122 Aemter, und
# der Kreuzschnitt mit den Amtstraegern bricht ab.
"regierung": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
(SAMPLE(?landc) AS ?land)
WHERE {
?a wdt:P279/wdt:P279? wd:Q83307 ; wdt:P1308 ?p .
OPTIONAL { ?a wdt:P1001 ?l . ?l wdt:P298 ?landc . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name
""",
# Sondergesandte. Eine eigene Abfrage, weil sie den anderen Weg
# brauchen: Witkoffs Amt traegt kein P1308, seine Amtszeit dagegen
# steht sauber da. Die Klasse ist klein und flach, hier ist P279*
# billig und ein Datumsfenster unnoetig - es gibt vier amtierende.
"sondergesandte": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
(SAMPLE(?landc) AS ?land)
WHERE {
?a (wdt:P31|wdt:P279)/wdt:P279* wd:Q117403755 .
?p p:P39 ?s . ?s ps:P39 ?a .
FILTER NOT EXISTS { ?s pq:P582 ?bis . }
OPTIONAL { ?a wdt:P1001 ?l . ?l wdt:P298 ?landc . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name
""",
# Boersennotierte Unternehmen aus den Leitindizes. Sie sind Akteure wie
# Parteien es sind - "VW streicht Stellen" ist eine Handlung. Ueber die
# Indexmitgliedschaft statt ueber "Unternehmen in Deutschland", weil
# letzteres Hunderttausende liefert und keine Auswahl trifft.
"unternehmen": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase) (SAMPLE(?landc) AS ?land)
WHERE {
?p wdt:P361 ?idx .
VALUES ?idx { wd:Q155718 wd:Q457539 wd:Q457540 wd:Q642856 wd:Q319415
wd:Q180816 wd:Q242345 }
OPTIONAL { ?p wdt:P17 ?l . ?l wdt:P298 ?landc . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name
""",
# Internationale Organisationen.
"organisationen": """
SELECT ?p ?name (GROUP_CONCAT(DISTINCT ?alias; separator="|") AS ?aliase)
WHERE {
?p wdt:P31/wdt:P279* wd:Q484652 .
FILTER NOT EXISTS { ?p wdt:P576 ?ende . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
OPTIONAL { ?p skos:altLabel ?alias . FILTER (lang(?alias) = "de") }
} GROUP BY ?p ?name
""",
}
# Amtsinhaber je Amt, mit Anfang und Ende. Ein Amt je Abfrage - das ist
# langsamer, aber jede Antwort bleibt klein und nachvollziehbar.
ABFRAGE_INHABER = """
SELECT ?p ?name ?von ?bis WHERE {
?p p:P39 ?s . ?s ps:P39 wd:%(qid)s .
OPTIONAL { ?s pq:P580 ?von . }
OPTIONAL { ?s pq:P582 ?bis . }
?p rdfs:label ?name . FILTER (lang(?name) = "de")
}
"""
def _abfragetext(name):
if name in ABFRAGEN:
return ABFRAGEN[name] % {
"stichtag": STICHTAG,
"wahlperiode": WAHLPERIODE,
"rumpf": _PERSONEN_RUMPF,
}
raise KeyError(name)
def frage(sparql):
"""Eine SPARQL-Abfrage, mit Wiederholung bei 429 und 5xx."""
ziel = ENDPUNKT + "?" + urllib.parse.urlencode({"query": sparql, "format": "json"})
anfrage = urllib.request.Request(
ziel,
headers={"Accept": "application/sparql-results+json", "User-Agent": KENNUNG},
)
for versuch in range(1, VERSUCHE + 1):
try:
with urllib.request.urlopen(anfrage, timeout=ZEITGRENZE) as antwort:
return json.load(antwort)["results"]["bindings"]
except urllib.error.HTTPError as fehler:
if fehler.code not in (429, 500, 502, 503, 504) or versuch == VERSUCHE:
raise
# Retry-After ernst nehmen, sonst geometrisch zurueckweichen.
warte = float(fehler.headers.get("Retry-After") or 0) or PAUSE * 2**versuch
print(f" HTTP {fehler.code}, warte {warte:.0f}s", file=sys.stderr)
time.sleep(warte)
raise RuntimeError("unerreichbar")
def _werte(bindungen):
"""SPARQL-Bindungen auf schlichte Dicts eindampfen."""
return [{k: v["value"] for k, v in b.items()} for b in bindungen]
def spiegeln(nur=None, erneuern=False):
VERZEICHNIS.mkdir(exist_ok=True)
stand = datetime.date.today().isoformat()
manifest = {}
manifestdatei = VERZEICHNIS / "MANIFEST.json"
if manifestdatei.exists():
manifest = json.loads(manifestdatei.read_text())
auftraege = [(n, _abfragetext(n)) for n in ABFRAGEN if not nur or n in nur]
if not nur or "aemter" in nur:
auftraege.append(("aemter", None))
for name, sparql in auftraege:
datei = VERZEICHNIS / f"{name}.json"
if datei.exists() and not erneuern:
print(f"{name}: Spiegel vorhanden, uebersprungen (--erneuern erzwingt)")
continue
if name == "aemter":
zeilen = []
for qid, (_id, _n, _r) in AEMTER.items():
zeilen_amt = _werte(frage(ABFRAGE_INHABER % {"qid": qid}))
for z in zeilen_amt:
z["amt_qid"] = qid
zeilen.extend(zeilen_amt)
print(f" {qid}: {len(zeilen_amt)} Inhaber")
time.sleep(PAUSE)
fingerabdruck = hashlib.blake2b(
json.dumps(sorted(AEMTER), ensure_ascii=False).encode(), digest_size=8
).hexdigest()
else:
zeilen = _werte(frage(sparql))
fingerabdruck = hashlib.blake2b(sparql.encode(), digest_size=8).hexdigest()
time.sleep(PAUSE)
datei.write_text(
json.dumps(zeilen, ensure_ascii=False, indent=1, sort_keys=True) + "\n"
)
manifest[name] = {
"stand": stand,
"zeilen": len(zeilen),
"abfrage_hash": fingerabdruck,
"endpunkt": ENDPUNKT,
}
print(f"{name}: {len(zeilen)} Zeilen -> {datei}")
manifestdatei.write_text(
json.dumps(manifest, ensure_ascii=False, indent=1, sort_keys=True) + "\n"
)
return manifest
def main(argumente=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--spiegeln", action="store_true", help="Wikidata abfragen")
p.add_argument("--laden", action="store_true", help="Spiegel in die Tabellen laden")
p.add_argument("--nur", nargs="*", help="nur diese Abfragen")
p.add_argument("--erneuern", action="store_true", help="vorhandenen Spiegel ersetzen")
a = p.parse_args(argumente)
if a.spiegeln:
spiegeln(nur=a.nur, erneuern=a.erneuern)
if a.laden:
import lexikon_laden
lexikon_laden.laden(nur=a.nur)
if not (a.spiegeln or a.laden):
p.error("--spiegeln oder --laden angeben")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
{
"aemter": {
"abfrage_hash": "03b28864ca56461b",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 470
},
"bundestag": {
"abfrage_hash": "84bcbbab1d0e3f86",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 1680
},
"landtage": {
"abfrage_hash": "e098d7d5626c6d33",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 1539
},
"organisationen": {
"abfrage_hash": "681d745f816308b4",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 1763
},
"parteien": {
"abfrage_hash": "1dc49b8fe318776c",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 682
},
"regierung": {
"abfrage_hash": "cec1167e7cacca2d",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 2003
},
"sondergesandte": {
"abfrage_hash": "a2a5dc75d2250ad4",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 5
},
"staaten": {
"abfrage_hash": "9520842107b5208c",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 195
},
"staatsfuehrung": {
"abfrage_hash": "d7a4539b35f11e04",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 345
},
"unternehmen": {
"abfrage_hash": "c55321560a74f980",
"endpunkt": "https://query.wikidata.org/sparql",
"stand": "2026-09-08",
"zeilen": 552
}
}
+3161
View File
File diff suppressed because it is too large Load Diff
+10075
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
{
"_hinweis": "Handpflege. Wird von lexikon_laden.py nach den Spiegeldateien gelesen und ergaenzt sie, ueberschreibt nichts. Jeder Eintrag braucht eine Begruendung, warum der Wikidata-Spiegel ihn nicht hergibt. QIDs sind gegen Wikidata geprueft - eine geratene QID traegt den Eintrag an ein fremdes Objekt (Q157617 ist die Commerzbank, nicht der Bundesrat). Wo keine geprueft vorliegt, steht null.",
"akteure": [
{
"id": "org:nato",
"typ": "organisation",
"name": "NATO",
"land": null,
"rolle": "INT",
"wikidata_qid": "Q7184",
"aliase": [
"Nordatlantikpakt",
"Nordatlantische Allianz"
],
"notiz": "Wikidata fuehrt die NATO als Militaerbuendnis, nicht als internationale Organisation - die Spiegelabfrage findet nur ihre Unteragenturen. Der Eintrag wird ueber die QID mit dem Spiegel zusammengefuehrt."
},
{
"id": "org:bundestag",
"typ": "organisation",
"name": "Deutscher Bundestag",
"land": "DEU",
"rolle": "LEG",
"wikidata_qid": "Q154797",
"aliase": [
"Bundestag"
],
"notiz": "Parlament, keine internationale Organisation - faellt durch jede Spiegelabfrage."
},
{
"id": "org:bundesrat",
"typ": "organisation",
"name": "Bundesrat",
"land": "DEU",
"rolle": "LEG",
"wikidata_qid": null,
"aliase": [],
"notiz": "wie Bundestag. QID nicht geprueft, deshalb keine."
},
{
"id": "org:bundesregierung",
"typ": "organisation",
"name": "Bundesregierung",
"land": "DEU",
"rolle": "GOV",
"wikidata_qid": null,
"aliase": [],
"notiz": "wie Bundestag. QID nicht geprueft, deshalb keine."
},
{
"id": "org:bundesverfassungsgericht",
"typ": "organisation",
"name": "Bundesverfassungsgericht",
"land": "DEU",
"rolle": "JUD",
"wikidata_qid": "Q56025",
"aliase": [
"Verfassungsgericht",
"BVerfG"
],
"notiz": "Gericht, keine internationale Organisation."
},
{
"id": "org:openai",
"typ": "organisation",
"name": "OpenAI",
"land": "USA",
"rolle": "BUS",
"wikidata_qid": "Q21708200",
"aliase": [
"Open AI"
],
"notiz": "Nicht boersennotiert, steht im Technikressort aber staendig in der Zeile."
},
{
"id": "org:apple",
"typ": "organisation",
"name": "Apple",
"land": "USA",
"rolle": "BUS",
"wikidata_qid": "Q312",
"aliase": [
"Apple Inc."
],
"notiz": "Hat in Wikidata kein deutsches Label. Die Spiegelabfragen filtern auf lang(?name)='de' und verlieren solche Objekte stillschweigend - siehe NOTES.md."
},
{
"id": "org:microsoft",
"typ": "organisation",
"name": "Microsoft",
"land": "USA",
"rolle": "BUS",
"wikidata_qid": "Q2283",
"aliase": [],
"notiz": "Hat in Wikidata kein deutsches Label. Die Spiegelabfragen filtern auf lang(?name)='de' und verlieren solche Objekte stillschweigend - siehe NOTES.md."
},
{
"id": "org:meta",
"typ": "organisation",
"name": "Meta Platforms",
"land": "USA",
"rolle": "BUS",
"wikidata_qid": "Q380",
"aliase": [
"Meta",
"Facebook"
],
"notiz": "Hat in Wikidata kein deutsches Label. Die Spiegelabfragen filtern auf lang(?name)='de' und verlieren solche Objekte stillschweigend - siehe NOTES.md."
},
{
"id": "per:kushner_jared",
"typ": "person",
"name": "Jared Kushner",
"land": "USA",
"rolle": "GOV",
"wikidata_qid": "Q13628723",
"aliase": [],
"notiz": "Sein Amt - Senior Advisor to the President - endete 2021, seine Rolle in den Nahost-Verhandlungen nicht. Damit faellt er durch die Regierungsabfrage, die nur laufende Aemter kennt. 109 Nennungen im Korpus vom 08.09.2026."
}
],
"aliase": {
"sta:vereinigte_staaten": [
"USA",
"US"
],
"org:europaeische_union": [
"EU"
],
"org:vereinte_nationen": [
"UN",
"UNO"
],
"org:weltgesundheitsorganisation": [
"WHO"
],
"org:volkswagen_ag": [
"VW"
],
"org:alphabet_inc": [
"Google",
"Alphabet"
]
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+11756
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
[
{
"aliase": "Shaye Lu",
"name": "Lu Shaye",
"p": "http://www.wikidata.org/entity/Q10910089"
},
{
"aliase": "",
"name": "Jean Todt",
"p": "http://www.wikidata.org/entity/Q171998"
},
{
"aliase": "",
"name": "Steve Witkoff",
"p": "http://www.wikidata.org/entity/Q16189572"
},
{
"aliase": "",
"land": "YEM",
"name": "Hans Grundberg",
"p": "http://www.wikidata.org/entity/Q111507151"
},
{
"aliase": "",
"name": "Amandeep Singh Gill",
"p": "http://www.wikidata.org/entity/Q115671639"
}
]
+1172
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
"""Spiegel in die Tabellen akteure und amtsinhaber laden.
Liest ausschliesslich lexikon/*.json. Kein Netzzugriff - wer den Spiegel
erneuern will, nimmt lexikon.py --spiegeln.
Hier und nur hier geschieht die Auslegung: aus Wikidata-Labels werden IDs,
aus Abfragenamen werden Rollen, aus Zeitangaben werden Amtszeiten. Der
Spiegel bleibt unberuehrt, damit sich jede dieser Ableitungen spaeter
aendern laesst, ohne Wikidata erneut zu behelligen.
"""
import json
import re
import unicodedata
import lexikon
VERZEICHNIS = lexikon.VERZEICHNIS
# Welche Abfrage welchen Typ und welche Rolle traegt. Die Rolle ist eine
# grobe Zuordnung nach CAMEO-Manier; sie beschreibt, in welcher Eigenschaft
# jemand ueblicherweise in Nachrichten auftaucht, nicht seine Biographie.
HERKUNFT = {
"bundestag": ("person", "LEG"),
"landtage": ("person", "LEG"),
"staatsfuehrung": ("person", "GOV"),
"regierung": ("person", "GOV"),
"sondergesandte": ("person", "GOV"),
"unternehmen": ("organisation", "BUS"),
"parteien": ("partei", None),
"staaten": ("staat", None),
"organisationen": ("organisation", "INT"),
}
PRAEFIX = {
"person": "per",
"partei": "par",
"staat": "sta",
"organisation": "org",
"amt": "amt",
}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
_UNRAT = re.compile(r"[^a-z0-9]+")
def schluessel(text):
"""Namensbestandteil zu einem ID-tauglichen Wort."""
text = text.casefold().translate(_UMLAUTE)
text = unicodedata.normalize("NFKD", text)
text = "".join(z for z in text if not unicodedata.combining(z))
return _UNRAT.sub("_", text).strip("_")
def personen_id(name):
"""'Robert Habeck' -> 'per:habeck_robert', wie in der Spezifikation."""
teile = name.split()
if len(teile) < 2:
return f"per:{schluessel(name)}"
nachname, vornamen = teile[-1], " ".join(teile[:-1])
return f"per:{schluessel(nachname)}_{schluessel(vornamen)}"
def qid(uri):
return uri.rsplit("/", 1)[-1]
def aliase(zeile, name):
"""Aliase aus dem Spiegel, ohne den kanonischen Namen selbst."""
roh = zeile.get("aliase") or ""
menge = {a.strip() for a in roh.split("|") if a.strip()}
menge.discard(name)
return sorted(menge)
_ISO = re.compile(r"^-?\d{4}-\d{2}-\d{2}")
def _datum(wert):
"""Nur echte Datumsangaben.
Wikidata kennt "unbekannter Wert" und liefert dafuer einen anonymen
Knoten - im JSON eine URI. Als Datum ist der wertlos, und als Text
faengt er sich sonst bis in die Tabelle durch.
"""
if not wert or not _ISO.match(wert):
return None
return wert[:10]
def _stand(manifest, name):
return (manifest.get(name) or {}).get("stand", "unbekannt")
def _handpflege(akteure):
"""lexikon/hand.json einarbeiten.
Zweierlei: eigene Eintraege fuer Akteure, die durch jede Spiegelabfrage
fallen (die NATO ist bei Wikidata ein Militaerbuendnis, kein
internationale Organisation), und zusaetzliche Aliase fuer Abkuerzungen,
die in deutschen Schlagzeilen selbstverstaendlich sind, in den deutschen
altLabels aber fehlen - "USA", "EU", "UN".
Ergaenzt, ueberschreibt nicht: was der Spiegel liefert, bleibt stehen.
"""
datei = VERZEICHNIS / "hand.json"
if not datei.exists():
return
hand = json.loads(datei.read_text())
nach_qid = {a["wikidata_qid"]: a["id"] for a in akteure.values()
if a.get("wikidata_qid")}
for eintrag in hand.get("akteure", []):
eintrag = dict(eintrag)
eintrag.setdefault("aliase", [])
eintrag["quelle"] = "hand"
# Dasselbe Wikidata-Objekt kann im Spiegel unter anderem Namen
# stecken: die NATO steht dort als
# "Nordatlantikvertragsorganisation" - ohne den Alias "NATO".
# Dann gehoeren nur die Aliase dazu, kein zweiter Eintrag.
vorhanden = nach_qid.get(eintrag.get("wikidata_qid"))
if vorhanden:
ziel = akteure[vorhanden]
ziel["aliase"] = sorted(set(ziel["aliase"]) | set(eintrag["aliase"])
| {eintrag["name"]} - {ziel["name"]})
continue
akteure.setdefault(eintrag["id"], eintrag)
for kennung, zusatz in hand.get("aliase", {}).items():
if kennung in akteure:
akteure[kennung]["aliase"] = sorted(
set(akteure[kennung]["aliase"]) | set(zusatz))
else:
print(f" hand.json: {kennung} gibt es nicht, Aliase ignoriert")
def zeilen_aus_spiegel(nur=None):
"""(akteure, amtsinhaber) aus den Spiegeldateien ableiten."""
manifest = json.loads((VERZEICHNIS / "MANIFEST.json").read_text())
akteure, gesehen = {}, {}
# QID -> ID. Ein Wikidata-Objekt darf nur einen Akteur ergeben, sonst
# zerfaellt es in zwei Zeitreihen. Australien etwa kommt sowohl in der
# Staaten- als auch in der Organisationsabfrage vor.
nach_qid, doppelt = {}, []
for name, (typ, rolle) in HERKUNFT.items():
if nur and name not in nur:
continue
datei = VERZEICHNIS / f"{name}.json"
if not datei.exists():
continue
quelle = f"wikidata:{name}@{_stand(manifest, name)}"
for z in json.loads(datei.read_text()):
bezeichnung = z["name"]
if typ == "person":
kennung = personen_id(bezeichnung)
else:
kennung = f"{PRAEFIX[typ]}:{schluessel(bezeichnung)}"
q = qid(z["p"])
# Schon unter anderem Namen gesehen: Aliase dazu, sonst nichts.
# Die erste Abfrage gewinnt, und die Reihenfolge von HERKUNFT
# ist deshalb eine Aussage - Staat vor Organisation.
if q in nach_qid:
vorhanden = akteure[nach_qid[q]]
vorhanden["aliase"] = sorted(
set(vorhanden["aliase"]) | set(aliase(z, bezeichnung))
| {bezeichnung} - {vorhanden["name"]})
doppelt.append((q, nach_qid[q], name))
continue
# Namensgleichheit ist bei Abgeordneten keine Seltenheit. Die
# zweite Person bekommt das QID angehaengt, statt die erste zu
# ueberschreiben - stumm zusammenfallen waere der schlimmere
# Fehler, weil er als Zeitreihe unauffaellig bleibt.
if kennung in gesehen and gesehen[kennung] != q:
kennung = f"{kennung}_{q.lower()}"
gesehen[kennung] = q
nach_qid[q] = kennung
eintrag = akteure.setdefault(
kennung,
{
"id": kennung,
"typ": typ,
"name": bezeichnung,
"aliase": [],
"land": z.get("land"),
"rolle": rolle,
"wikidata_qid": q,
"quelle": quelle,
"notiz": z.get("partei"),
},
)
eintrag["aliase"] = sorted(set(eintrag["aliase"]) | set(aliase(z, bezeichnung)))
_handpflege(akteure)
if doppelt:
print(f" {len(doppelt)} Objekte in mehreren Abfragen, erste gewinnt "
f"(z.B. {doppelt[0][0]} -> {doppelt[0][1]} aus {doppelt[0][2]})")
inhaber, verworfen = [], []
if (not nur or "aemter" in nur) and (VERZEICHNIS / "aemter.json").exists():
quelle = f"wikidata:aemter@{_stand(manifest, 'aemter')}"
for amt_qid, (amt_id, amt_name, amt_rolle) in lexikon.AEMTER.items():
akteure.setdefault(
amt_id,
{
"id": amt_id,
"typ": "amt",
"name": amt_name,
"aliase": [],
"land": "DEU",
"rolle": amt_rolle,
"wikidata_qid": amt_qid,
"quelle": quelle,
"notiz": None,
},
)
for z in json.loads((VERZEICHNIS / "aemter.json").read_text()):
amt_id = lexikon.AEMTER[z["amt_qid"]][0]
person = nach_qid.get(qid(z["p"])) or personen_id(z["name"])
# Amtsinhaber, die in keiner Personenabfrage vorkamen - etwa
# Bundespraesidenten, die nie im Bundestag sassen.
nach_qid.setdefault(qid(z["p"]), person)
akteure.setdefault(
person,
{
"id": person,
"typ": "person",
"name": z["name"],
"aliase": [],
"land": None,
"rolle": "GOV",
"wikidata_qid": qid(z["p"]),
"quelle": quelle,
"notiz": None,
},
)
von, bis = _datum(z.get("von")), _datum(z.get("bis"))
if not von:
# Ohne Anfang ist die Zeile fuer eine Amtsaufloesung wertlos.
continue
if bis and bis < von:
# Kommt vor, wenn jemand dasselbe Amt zweimal innehatte und
# Wikidata die Zeitangaben an einer Aussage buendelt: Max
# Brauer, Erster Buergermeister von Hamburg 1946-1953 und
# 1957-1960. Die Paarung ist dann nicht rekonstruierbar,
# und geraten wird hier nicht.
verworfen.append((amt_id, person, von, bis))
continue
inhaber.append(
{
"amt_id": amt_id,
"person_id": person,
"von": von,
"bis": bis,
"quelle": quelle,
"notiz": None,
}
)
if verworfen:
print(f" {len(verworfen)} Amtszeiten mit Ende vor Anfang verworfen "
f"(z.B. {verworfen[0][1]} in {verworfen[0][0]})")
return list(akteure.values()), inhaber
def laden(nur=None, conn=None):
import db
akteure, inhaber = zeilen_aus_spiegel(nur=nur)
eigen = conn is None
verbindung = db.verbindung() if eigen else conn
try:
with verbindung.cursor() as k:
k.executemany(
"""insert into akteure
(id, typ, name, aliase, land, rolle, wikidata_qid, quelle, notiz)
values (%(id)s, %(typ)s, %(name)s, %(aliase)s, %(land)s,
%(rolle)s, %(wikidata_qid)s, %(quelle)s, %(notiz)s)
on conflict (id) do update set
typ = excluded.typ, name = excluded.name,
aliase = excluded.aliase, land = excluded.land,
rolle = excluded.rolle, wikidata_qid = excluded.wikidata_qid,
quelle = excluded.quelle, notiz = excluded.notiz""",
akteure,
)
if inhaber:
k.executemany(
"""insert into amtsinhaber (amt_id, person_id, von, bis, quelle, notiz)
values (%(amt_id)s, %(person_id)s, %(von)s, %(bis)s,
%(quelle)s, %(notiz)s)
on conflict (amt_id, person_id, von) do update set
bis = excluded.bis, quelle = excluded.quelle""",
inhaber,
)
verbindung.commit()
k.execute("select typ, count(*) from akteure group by 1 order by 1")
for typ, n in k.fetchall():
print(f" {typ:13} {n:6}")
k.execute("select count(*) from amtsinhaber")
print(f" amtsinhaber {k.fetchone()[0]:6}")
k.execute("select * from amtsinhaber_ueberschneidungen")
for r in k.fetchall():
print(f" Ueberschneidung: {r}")
finally:
if eigen:
verbindung.close()
return len(akteure), len(inhaber)
if __name__ == "__main__":
laden()
+26
View File
@@ -0,0 +1,26 @@
-- Migration p2-000: Buch ueber angewandte Migrationen
--
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-000-migrationsbuch.sql
--
-- Dev und Prod teilen dasselbe Schema; abweichende Staende sind der Weg, auf
-- dem sich das aendert, ohne dass es jemand merkt. Diese Tabelle macht den
-- Stand abfragbar. Sie traegt das Praefix p2-, weil die Ingest-Migrationen
-- (swp-01-ingest/migrations) eine eigene Zaehlung haben und beide auf
-- dieselbe Datenbank laufen.
begin;
create table if not exists schema_migrationen (
name text primary key, -- Dateiname ohne .sql
angewandt_am timestamptz not null default now(),
notiz text
);
comment on table schema_migrationen is
'Nachtraeglich gepflegt: Ingest-Migrationen 001/002 liefen vor dieser Tabelle';
insert into schema_migrationen (name, notiz) values
('p2-000-migrationsbuch', null)
on conflict (name) do nothing;
commit;
+128
View File
@@ -0,0 +1,128 @@
-- Migration p2-001: codings fuer die fuenf Phase-2-Schichten oeffnen
--
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-001-codings-mehrschichtig.sql
--
-- Setzt das Ingest-Schema voraus (swp-01-ingest/schema.sql). Aendert an
-- raw_items nichts und loescht keine Zeile.
--
-- Zwei Dinge im Bestand verhindern Phase 2 vollstaendig:
--
-- unique (raw_item_id, coder_version) - laesst genau eine Zeile je Item
-- und Version zu, Phase 2 braucht fuenf
-- check ebene in ('keine','quad',...) - kennt nur die CAMEO-Ebenen aus
-- Phase 3, kein 'dublette'
--
-- Beides wird ersetzt, nicht ergaenzt. `ebene` traegt damit zwei Bedeutungen:
-- fuer Phase 3 die erreichte CAMEO-Ebene, fuer Phase 2 den Modulnamen.
-- Auseinander zu halten sind sie ueber coder_version (Phase 2: 'p2-...').
-- Eine eigene Spalte waere sauberer, kostet aber einen Eingriff in Phase 3,
-- die es noch nicht gibt - wenn, dann dort und dann.
begin;
alter table codings drop constraint if exists codings_raw_item_id_coder_version_key;
alter table codings drop constraint if exists codings_ebene_check;
alter table codings
add constraint codings_uniq unique (raw_item_id, coder_version, ebene);
alter table codings
add constraint codings_ebene_check check (ebene in (
-- Phase 3, CAMEO
'keine', 'quad', 'root', 'base', 'event',
-- Phase 2, Anreicherung
'dublette', 'akteure', 'geo', 'tonalitaet', 'embedding'
));
comment on column codings.ebene is
'Phase 3: erreichte CAMEO-Ebene. Phase 2: Modulname. Trennung ueber coder_version';
create index if not exists codings_ebene_idx on codings (ebene, kodiert_am);
create index if not exists codings_nutzlast_idx on codings using gin (nutzlast);
-- --------------------------------------------------------------------------
-- Keine Werkausschnitte in der Nutzlast.
--
-- codings ist Kandidat fuer den OpenData-Dump. Offsets und Codes duerfen
-- hinaus, Text nicht - ausser Eigennamen (Akteurs- und Ortsformen), die kurz
-- sind. 100 Zeichen ist die Grenze, unter der kein Teaser wiederherstellbar
-- ist und ueber der kein Organisationsname liegen muss.
--
-- Als Constraint, nicht als Vorsatz: Abnahmekriterium aus Abschnitt 10.
-- --------------------------------------------------------------------------
create or replace function p2_nutzlast_ohne_text(nutzlast jsonb, grenze integer default 100)
returns boolean language sql immutable parallel safe as $$
select not exists (
select 1
from jsonb_path_query(nutzlast, '$.**') as wert
where jsonb_typeof(wert) = 'string'
and length(wert #>> '{}') > grenze
)
$$;
comment on function p2_nutzlast_ohne_text is
'Wahr, wenn keine Zeichenkette in der Nutzlast laenger als grenze ist';
alter table codings
add constraint codings_kein_volltext
check (p2_nutzlast_ohne_text(nutzlast));
-- --------------------------------------------------------------------------
-- Was hinter einem coder_version-Hash steht. Ohne diese Tabelle ist die
-- Versionierung wertlos: der Hash sagt dann nur, dass sich etwas geaendert
-- hat, nicht was.
-- --------------------------------------------------------------------------
create table if not exists coder_versionen (
version text primary key, -- 'p2-2026-09-07-a3f1b2c4'
komponenten jsonb not null, -- Regelwerk, Lexika, Modelle, Quantisierung
erstellt_am timestamptz not null default now(),
notiz text
);
-- --------------------------------------------------------------------------
-- Betriebsprotokoll, analog poll_laeufe. Anders als dort ist die Einheit
-- nicht die Quelle, sondern Slot und Modul: Phase 2 arbeitet itemweise ueber
-- alle Quellen hinweg.
-- --------------------------------------------------------------------------
create table if not exists p2_laeufe (
id bigserial primary key,
slot timestamptz, -- null im Backfill ueber den Gesamtbestand
modul text not null,
coder_version text not null,
begonnen_am timestamptz not null default now(),
dauer_ms integer,
items_gesehen integer not null default 0,
items_kodiert integer not null default 0,
items_fehler integer not null default 0,
fehler text
);
create index if not exists p2_laeufe_slot_idx on p2_laeufe (slot desc nulls last);
create index if not exists p2_laeufe_modul_idx on p2_laeufe (modul, begonnen_am desc);
-- --------------------------------------------------------------------------
-- Arbeitsvorrat je Schicht. `unkodiert(v)` aus dem Ingest-Schema fragt nur,
-- ob es *irgendeine* Zeile gibt, und liefert nach dem ersten Modul nichts
-- mehr zurueck.
--
-- select * from unkodiert('p2-2026-09-07-a3f1b2c4', 'akteure') limit 500;
-- --------------------------------------------------------------------------
create or replace function unkodiert(v text, schicht text)
returns setof raw_items language sql stable as $$
select r.* from raw_items r
where not exists (
select 1 from codings c
where c.raw_item_id = r.id
and c.coder_version = v
and c.ebene = schicht
)
order by r.id
$$;
-- Verwerfbarkeit (Invariante 4): der gesamte Phase-2-Bestand geht so weg.
-- delete from codings where coder_version like 'p2-%';
insert into schema_migrationen (name) values ('p2-001-codings-mehrschichtig')
on conflict (name) do nothing;
commit;
+87
View File
@@ -0,0 +1,87 @@
-- Migration p2-002: pgvector und Ablage der Item-Embeddings
--
-- psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-002-embeddings.sql
--
-- Setzt p2-001 voraus und ein Image, das pgvector mitbringt (siehe README):
-- docker.io/pgvector/pgvector:pg18-trixie. Ohne die Erweiterung bricht die
-- Migration in der ersten Zeile ab und hinterlaesst nichts.
--
-- Die Embeddings sind Vorfilter fuer die Kandidatengenerierung in Phase 3b,
-- kein Selbstzweck. Phase 2 clustert nicht.
begin;
create extension if not exists vector;
-- --------------------------------------------------------------------------
-- Der Vektor liegt bewusst nicht in codings.nutzlast: JSONB-Vektoren sind
-- weder indizierbar noch platzsparend. Die Coding-Zeile der Schicht
-- 'embedding' haelt nur die Kennung, der Vektor steht hier.
--
-- 1024 Dimensionen deckt beide in der Spezifikation genannten Modelle ab
-- (multilingual-e5-large, jina-embeddings-v3). Ein Modell mit anderer
-- Dimension braucht eine eigene Migration - das ist gewollt, denn es waere
-- ohnehin eine neue coder_version.
-- --------------------------------------------------------------------------
create table if not exists item_embeddings (
raw_item_id bigint not null references raw_items(id) on delete cascade,
coder_version text not null,
erzeugt_am timestamptz not null default now(),
vektor vector(1024) not null,
primary key (raw_item_id, coder_version)
);
comment on table item_embeddings is
'Satz-Embedding je Item und Kodierer-Version. Vorfilter fuer Phase 3b';
comment on column item_embeddings.coder_version is
'Ohne Fremdschluessel auf coder_versionen, wie codings auch - die Herkunft '
'steht dort, erzwungen wird sie nicht';
-- Der Fremdschluessel auf raw_items ist nicht Kosmetik: ohne ihn bleiben
-- Vektoren als Waisen liegen, wenn ein Item aus raw_items verschwindet.
-- --------------------------------------------------------------------------
-- Kein globaler HNSW-Index.
--
-- Ein Index ueber alle coder_version hinweg legt Vektoren verschiedener
-- Modelle in dieselbe Nachbarschaftsstruktur. Eine Suche muss dann nach
-- Version filtern, und pgvector filtert bei HNSW erst *nach* dem Durchlauf
-- der Kandidatenliste - das kostet Treffer, nicht nur Zeit.
--
-- Stattdessen ein partieller Index je Version, angelegt wenn die Version in
-- Betrieb geht:
--
-- select p2_embedding_index('p2-2026-09-07-a3f1b2c4');
--
-- Der Aufbau ist speicherhungrig; bei grossen Bestaenden vorher
-- set maintenance_work_mem = '2GB';
-- Und die Version wieder loswerden (Invariante 4, verwerfbar):
-- drop index if exists item_embeddings_hnsw_<version>;
-- delete from item_embeddings where coder_version = '...';
-- --------------------------------------------------------------------------
create or replace function p2_embedding_index(version text)
returns text language plpgsql as $$
declare
idx text;
begin
-- Nicht-alphanumerisches im Versionsnamen wird zu _, damit der
-- Indexname ohne Anfuehrungszeichen auskommt.
idx := 'item_embeddings_hnsw_' || regexp_replace(version, '[^a-zA-Z0-9]+', '_', 'g');
if to_regclass(idx) is not null then
return idx || ' (bestand bereits)';
end if;
execute format(
'create index %I on item_embeddings using hnsw (vektor vector_cosine_ops) where coder_version = %L',
idx, version);
return idx;
end;
$$;
comment on function p2_embedding_index is
'Legt den HNSW-Index fuer genau eine coder_version an. Idempotent';
insert into schema_migrationen (name) values ('p2-002-embeddings')
on conflict (name) do nothing;
commit;
+91
View File
@@ -0,0 +1,91 @@
-- Migration p2-003: Schemaobjekte der Anwendungsrolle uebereignen
--
-- psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-003-eigentuemer.sql
--
-- Die Datenbank wurzelwerk gehoert der Rolle wurzelwerk, ihre Tabellen aber
-- postgres: schema.sql wurde beim Ingest-Deploy als Superuser eingespielt.
-- Folge ist, dass die Anwendungsrolle an ihrem eigenen Schema nichts aendern
-- darf - jedes `alter table` scheitert mit "must be owner of table".
--
-- Die uebrigen Dienste auf derselben Instanz halten es anders herum: gitea
-- und hedgedoc besitzen ihre Tabellen vollstaendig. Diese Migration stellt
-- denselben Zustand her. Der Radius bleibt auf die Datenbank wurzelwerk
-- beschraenkt; an gitea und hedgedoc wird nicht geruehrt.
--
-- Danach laufen Migrationen ohne Superuser, also ueber DATABASE_URL statt
-- ueber den Socket im Container. Ausnahme bleibt `create extension` in
-- p2-002, sofern die Erweiterung nicht als trusted gefuehrt wird.
--
-- Diese Migration ist die einzige, die zwingend als Superuser laeuft - sie
-- verschenkt Rechte, die sie selbst nicht mehr braeuchte.
begin;
do $$
declare
ziel constant name := 'wurzelwerk';
o record;
n integer := 0;
begin
if not exists (select 1 from pg_roles where rolname = ziel) then
raise exception 'Rolle % existiert nicht', ziel;
end if;
-- Tabellen, Sichten und freistehende Sequenzen.
--
-- Ausgenommen sind zwei Gruppen. Erstens Objekte einer Erweiterung
-- (pg_trgm, vector): die gehoeren dem Superuser und werden mit der
-- Erweiterung verwaltet, nicht von Hand. Zweitens Sequenzen, die an
-- einer Spalte haengen - alles aus bigserial. Die folgen dem Eigentuemer
-- ihrer Tabelle von selbst und lassen sich nicht einzeln uebereignen
-- ("is linked to table"). Deshalb erst die Tabellen, dann der Rest.
for o in
select c.oid::regclass::text as name,
case c.relkind when 'S' then 'sequence'
when 'v' then 'view'
when 'm' then 'materialized view'
else 'table' end as art
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p', 'S', 'v', 'm')
and pg_get_userbyid(c.relowner) <> ziel
and not exists (select 1 from pg_depend d
where d.classid = 'pg_class'::regclass
and d.objid = c.oid and d.deptype = 'e')
and not exists (select 1 from pg_depend d
where d.classid = 'pg_class'::regclass
and d.objid = c.oid and d.deptype = 'a'
and d.refclassid = 'pg_class'::regclass)
order by case c.relkind when 'r' then 0 when 'p' then 0
when 'v' then 1 when 'm' then 1 else 2 end
loop
execute format('alter %s %s owner to %I', o.art, o.name, ziel);
n := n + 1;
end loop;
-- Funktionen ebenso. Ohne sie duerfte die Rolle ihre eigenen Helfer
-- spaeter nicht mehr ersetzen.
for o in
select p.oid::regprocedure::text as name
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and pg_get_userbyid(p.proowner) <> ziel
and not exists (select 1 from pg_depend d
where d.classid = 'pg_proc'::regclass
and d.objid = p.oid and d.deptype = 'e')
loop
execute format('alter function %s owner to %I', o.name, ziel);
n := n + 1;
end loop;
raise notice 'uebereignet: % Objekte an %', n, ziel;
end $$;
-- Neue Objekte gehoeren ohnehin dem, der sie anlegt; das Recht dazu im
-- Schema public ist seit PostgreSQL 15 nicht mehr selbstverstaendlich.
grant create, usage on schema public to wurzelwerk;
insert into schema_migrationen (name) values ('p2-003-eigentuemer')
on conflict (name) do nothing;
commit;
+40
View File
@@ -0,0 +1,40 @@
-- Migration p2-004: Ebene 'revision' zulassen
--
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-004-revisionen.sql
--
-- Aendert an raw_items und raw_item_versionen nichts und loescht keine Zeile.
--
-- Die Spezifikation kennt fuenf Phase-2-Module. Dies ist ein sechstes, und
-- zwar eines, das die Spezifikation nicht vorsehen konnte: dass Ingest in
-- raw_item_versionen jede geaenderte Fassung eines Items mitschreibt, ist
-- eine Eigenschaft der Phase-1-Umsetzung, nicht des Entwurfs.
--
-- Daraus faellt ein Befund ab, den man sonst nur mit erheblichem Aufwand
-- erhebt: welches Haus seine Schlagzeile nachtraeglich umschreibt, und wie.
-- Er kostet kein Modell, kein Lexikon und keinen Netzzugriff - nur einen
-- Wortvergleich zwischen zwei Fassungen desselben Artikels.
begin;
alter table codings drop constraint if exists codings_ebene_check;
alter table codings
add constraint codings_ebene_check check (ebene in (
-- Phase 3, CAMEO
'keine', 'quad', 'root', 'base', 'event',
-- Phase 2, Anreicherung
'dublette', 'akteure', 'geo', 'tonalitaet', 'embedding',
-- Phase 2, ausserhalb der Spezifikation (siehe Kopf)
'revision'
));
-- Der Slot-Lauf des Moduls sucht die Items, von denen in einem Slot eine
-- neue Fassung eingetroffen ist. Ohne Index ist das ein Seq Scan ueber
-- raw_item_versionen.
create index if not exists raw_item_versionen_slot_idx
on raw_item_versionen (slot);
insert into schema_migrationen (name) values ('p2-004-revisionen')
on conflict (name) do nothing;
commit;
+117
View File
@@ -0,0 +1,117 @@
-- Migration p2-005: Akteurslexikon und Amtsinhaber
--
-- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-005-akteure.sql
--
-- Aendert an raw_items und raw_item_versionen nichts und loescht keine Zeile.
--
-- Die Ebene 'akteure' ist seit p2-004 erlaubt; hier entsteht nur, worauf das
-- Modul sich stuetzt. Das Lexikon ist Stammdatenbestand, keine Ableitung:
-- es ueberlebt einen coder_version-Wechsel und wird beim Neuaufbau der
-- Phase-2-Ausgabe nicht mit weggeworfen.
begin;
-- Lexikon. Struktur wie in der Spezifikation, Abschnitt 4.
--
-- id traegt ein Typkuerzel: per: organisation: par: sta: amt:. Das ist
-- Absicht - eine ID soll ohne Nachschlagen lesbar sein, und die Nutzlast
-- der Codings wird von Menschen gelesen.
create table if not exists akteure (
id text primary key,
typ text not null
check (typ in ('person', 'organisation', 'partei', 'staat', 'amt')),
name text not null,
aliase text[] not null default '{}',
land text, -- ISO-3166-1 alpha-3
rolle text, -- GOV LEG OPP JUD MIL MED BUS NGO INT
gueltig_von date,
gueltig_bis date,
wikidata_qid text,
quelle text not null, -- 'wikidata:<Abfrage>@<Stand>' oder 'hand'
notiz text,
check (gueltig_von is null or gueltig_bis is null or gueltig_bis >= gueltig_von)
);
-- Der Alias-Match des Moduls ist ein Nachschlagen ueber dieses Feld.
create index if not exists akteure_aliase_idx on akteure using gin (aliase);
create index if not exists akteure_typ_idx on akteure (typ);
-- Ein QID darf nicht zweimal auftauchen, sonst zerfaellt eine Person in
-- zwei IDs und die Zeitreihen zerfallen mit ihr. Teilindex, weil handisch
-- ergaenzte Eintraege kein QID haben.
create unique index if not exists akteure_qid_idx
on akteure (wikidata_qid) where wikidata_qid is not null;
-- Amtsinhaber: welche Person hatte wann welches Amt.
--
-- Ohne diese Tabelle bezeichnet "der Bundeskanzler" eine Person statt eines
-- Amtes zu einem Zeitpunkt. Der Korpus wird Amtswechsel ueberspannen, und
-- rueckwirkend falsch aufgeloeste Aemter waeren nicht wiederzuerkennen.
--
-- bis is null heisst amtierend, nicht unbekannt. Wo das Ende unbekannt ist,
-- gehoert eine Notiz dazu.
create table if not exists amtsinhaber (
amt_id text not null references akteure (id) on delete cascade,
person_id text not null references akteure (id) on delete cascade,
von date not null,
bis date,
quelle text not null,
notiz text,
primary key (amt_id, person_id, von),
check (bis is null or bis >= von)
);
create index if not exists amtsinhaber_amt_idx on amtsinhaber (amt_id, von);
create index if not exists amtsinhaber_person_idx on amtsinhaber (person_id);
-- Bewusst *keine* Ausschlussbeschraenkung gegen ueberlappende Amtszeiten.
--
-- Eine solche waere hier schlicht falsch: Doppelspitzen sind im deutschen
-- Parteiensystem der Normalfall, nicht die Ausnahme. Gleichzeitig ist eine
-- Ueberlappung beim Kanzleramt ein Datenfehler. Die Unterscheidung haengt
-- am Amt, nicht an der Zeile, und laesst sich in einer Beschraenkung nicht
-- ausdruecken. Also wird sie sichtbar gemacht statt verboten - der Lader
-- meldet, was hier auftaucht.
create or replace view amtsinhaber_ueberschneidungen as
select a.amt_id,
k.name as amt,
a.person_id as person_a,
b.person_id as person_b,
greatest(a.von, b.von) as ab,
-- null heisst "beide noch offen". 'infinity'::date waere praeziser,
-- laesst sich aber von keinem Client als Datum lesen.
case when a.bis is null or b.bis is null then null
else least(a.bis, b.bis) end as bis
from amtsinhaber a
join amtsinhaber b
on a.amt_id = b.amt_id
and a.person_id < b.person_id
-- Halboffen: der Tag der Amtsuebergabe gehoert dem Nachfolger.
-- Wikidata traegt ihn bei beiden ein, und mit '[]' waere jeder
-- Amtswechsel eine Ueberschneidung.
and daterange(a.von, a.bis, '[)') && daterange(b.von, b.bis, '[)')
join akteure k on k.id = a.amt_id;
-- Betrieb, Spezifikation Abschnitt 4: was die NER gefunden, das Lexikon
-- aber nicht aufgeloest hat. Haeufige Formen wandern von Hand ins Lexikon.
-- Die Sicht liest nur die jeweils juengste coder_version, sonst zaehlt sie
-- alte Laeufe mit.
create or replace view akteure_unaufgeloest as
select e ->> 'form' as form,
e ->> 'typ' as typ,
count(*) as nennungen,
count(distinct c.raw_item_id) as items,
min(r.pubdate) as zuerst,
max(r.pubdate) as zuletzt
from codings c
join raw_items r on r.id = c.raw_item_id
cross join lateral jsonb_array_elements(c.nutzlast -> 'erkannt') e
where c.ebene = 'akteure'
and c.coder_version = (select max(coder_version) from codings where ebene = 'akteure')
and e ->> 'id' is null
group by 1, 2;
insert into schema_migrationen (name) values ('p2-005-akteure')
on conflict (name) do nothing;
commit;
+144
View File
@@ -0,0 +1,144 @@
"""Textnormalisierung fuer Phase 2.
Gemeinsame Vorstufe der Module. Jede Aenderung hier aendert die
coder_version - siehe version.py.
Grundsatz: die Normalisierung ist rein und ohne Zustand. Gleiche Eingabe,
gleiche Ausgabe, auf jeder Maschine und in jedem Prozess.
"""
import html
import re
import unicodedata
# Was hier steht, geht in den Versions-Hash ein.
REGELWERK_VERSION = "norm-3"
_TAGS = re.compile(r"<[^>]{1,200}>")
_MEHRFACH_LEER = re.compile(r"\s+")
# Anfuehrungszeichen und Striche vereinheitlichen. Verschiedene Haeuser setzen
# fuer dieselbe Agenturmeldung verschiedene Zeichen; ohne das scheitert schon
# die Trigrammbildung.
_ZEICHEN = str.maketrans({
"„": '"', "“": '"', "”": '"', "»": '"', "«": '"', "‟": '"',
"‚": "'", "‘": "'", "’": "'", "‹": "'", "›": "'", "´": "'", "`": "'",
"–": "-", "—": "-", "‑": "-", "‒": "-", "−": "-",
"…": "...", " ": " ", " ": " ", " ": " ", "": " ",
})
# "+++ Newsblog +++" und Verwandtes: redaktioneller Aufmacher, der nichts
# ueber den Inhalt sagt. Dieselbe Meldung traegt ihn bei einem Haus und beim
# naechsten nicht.
_PLUSKASTEN = re.compile(r"\+{2,}[^+]{0,120}?\+{2,}")
# Satzzeichen fallen weg: sie sind der haeufigste Unterschied zwischen zwei
# Fassungen derselben Meldung und tragen nichts zur Unterscheidung bei.
_KEIN_WORT = re.compile(r"[^\w\s]", re.UNICODE)
# Ressortkuerzel, das Haeuser ihren Titeln voranstellen: "Raumfahrt: Deutsche
# Rakete ...", "Notfaelle: Fuenf Tote ...", "Fussball: FIFA-Praesident ...".
# Dieselbe Agenturzeile bekommt bei jedem Haus ein anderes - oder gar keins.
#
# Bewusst eng gefasst: hoechstens vier Woerter vor dem Doppelpunkt, und was
# danach kommt, muss fuer sich stehen koennen. Sonst frisst das Muster
# Titel, bei denen der Teil vor dem Doppelpunkt die eigentliche Aussage
# traegt ("Habeck: Wir haben uns geirrt").
_RESSORT = re.compile(r"^\s*(?P<kopf>[^:]{2,34}):\s+(?P<rest>.+)$", re.DOTALL)
_RESSORT_MAX_WOERTER = 4
_REST_MIN_WOERTER = 5
def ressort_teilen(text):
"""(kopf, rest) - kopf ist None, wenn kein Ressortkuerzel zu erkennen ist.
Das Modul revisionen braucht beide Teile getrennt: ob ein Haus die
Ressortmarke oder die Schlagzeile selbst geaendert hat, sind zwei sehr
verschiedene Vorgaenge.
"""
treffer = _RESSORT.match(text)
if not treffer:
return None, text
kopf, rest = treffer.group("kopf"), treffer.group("rest")
if len(kopf.split()) > _RESSORT_MAX_WOERTER:
return None, text
if len(rest.split()) < _REST_MIN_WOERTER:
return None, text
return kopf, rest
def _ressort_abtrennen(text):
return ressort_teilen(text)[1]
def normalisiere(*teile, ressort_abtrennen=False, pluskasten=True):
"""Vergleichstext aus einem oder mehreren Feldern.
Reihenfolge ist bedeutsam: Tags vor Entities, NFKC vor der
Zeichentabelle, Kleinschreibung vor den Mustern, und das Ressortkuerzel
vor dem Entfernen der Satzzeichen - danach gibt es keinen Doppelpunkt
mehr, an dem es zu erkennen waere.
ressort_abtrennen gilt nur fuer Titel. Im Teaser steht der Doppelpunkt
fuer gewoehnlich mitten im Satz.
pluskasten=False laesst "++ ... ++" stehen. Beim Dublettenvergleich ist
der Kasten Beiwerk, das die Haeuser verschieden setzen. Bei den
Revisionen ist er der Text: die tagesschau fuehrt ihren Liveticker
vollstaendig darin ("++ Liveticker zur Wahl: ... ++"), und mit Entfernung
bliebe von der Schlagzeile nichts uebrig, was sich vergleichen liesse.
"""
text = " ".join(t for t in teile if t)
if not text:
return ""
text = _TAGS.sub(" ", text)
text = html.unescape(text)
text = unicodedata.normalize("NFKC", text)
text = text.translate(_ZEICHEN)
text = text.casefold()
if pluskasten:
text = _PLUSKASTEN.sub(" ", text)
if ressort_abtrennen:
text = _ressort_abtrennen(text)
text = _KEIN_WORT.sub(" ", text)
return _MEHRFACH_LEER.sub(" ", text).strip()
def bereinige(*teile):
"""Text fuer neuronale Encoder: aufgeraeumt, aber nicht zerlegt.
normalisiere() macht Text vergleichbar, indem es ihn abschleift -
Kleinschreibung, keine Satzzeichen, keine Ressortmarke. Fuer einen
Encoder ist das Zerstoerung: er ist auf natuerlichem Text trainiert,
Grossschreibung unterscheidet im Deutschen Wortarten, und die
Anfuehrungszeichen um ein Zitat sind Bedeutung.
Entfernt wird deshalb nur, was kein Text ist: Auszeichnung, Entities,
uneinheitliche Unicode-Varianten, ueberzaehliger Leerraum.
"""
text = " ".join(t for t in teile if t)
if not text:
return ""
text = _TAGS.sub(" ", text)
text = html.unescape(text)
text = unicodedata.normalize("NFKC", text)
text = text.translate(_ZEICHEN)
return _MEHRFACH_LEER.sub(" ", text).strip()
def woerter(text):
return text.split()
def trigramme(text):
"""Wort-Trigramme als Menge.
Menge, nicht Liste: der Jaccard-Wert braucht sie so, und Wiederholungen
innerhalb einer kurzen Meldung sind kein Signal.
"""
w = woerter(text)
if len(w) < 3:
# Zu kurz fuer Trigramme. Das Wort selbst ist besser als nichts,
# aber der Aufrufer prueft ohnehin auf Mindestlaenge.
return {(x,) for x in w}
return {tuple(w[i:i + 3]) for i in range(len(w) - 2)}
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Phase 2 als Ganzes: alle Module in fester Reihenfolge.
python3 phase2.py --backfill
python3 phase2.py --slot 2026-09-07T08:00
python3 phase2.py --neueste # der juengste Slot in raw_items
python3 phase2.py --aufholen # jeder Slot, dem eine Kodierung fehlt
Das ist der Einstiegspunkt im Betrieb; die Module lassen sich weiter einzeln
aufrufen. Die Reihenfolge ist nicht beliebig: die regelbasierten Module sind
in Millisekunden fertig, das Embedding braucht Modell und Rechenzeit. Bricht
es ab, stehen die uebrigen Ergebnisse trotzdem.
Jedes Modul haelt seine eigene coder_version. Ein Fehlschlag in einem Modul
laesst die anderen unberuehrt - dafuer ist die Schichtung in codings da.
"""
import argparse
import sys
# Reihenfolge der Spezifikation, um die Revisionen ergaenzt. akteure laeuft
# vor embeddings, weil die Geokodierung spaeter auf seiner NER-Ausgabe
# aufsetzt - und weil das teuerste Modul zuletzt kommt.
MODULE = ["dubletten", "revisionen", "akteure", "embeddings"]
def slots_ohne_kodierung(conn):
"""Slots, in denen Items liegen, die noch keine Dublettenzeile haben.
Absichtlich am ersten Modul festgemacht und nicht an allen: laeuft das
Embedding einmal nicht durch, soll der Aufholvorgang nicht dauerhaft
dieselben Slots wiederholen. Fehlende Vektoren holt ein Backfill.
"""
return [r[0] for r in conn.execute(
"""select distinct r.slot from raw_items r
where not exists (select 1 from codings c
where c.raw_item_id = r.id and c.ebene = 'dublette')
order by 1""")]
def fuehre_aus(name, argumente):
modul = __import__(name)
print(f"\n--- {name} {' '.join(argumente)}")
try:
rueck = modul.main(argumente)
except Exception as fehler: # noqa: BLE001
# Ein Modul darf die uebrigen nicht mitreissen. Der Fehler steht im
# Protokoll und im Rueckgabewert, nicht in einem abgebrochenen Lauf.
print(f" FEHLER in {name}: {fehler.__class__.__name__}: {fehler}",
file=sys.stderr)
return 1
return rueck or 0
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
gruppe = p.add_mutually_exclusive_group(required=True)
gruppe.add_argument("--backfill", action="store_true", help="Gesamtbestand")
gruppe.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
gruppe.add_argument("--neueste", action="store_true",
help="der juengste Slot in raw_items")
gruppe.add_argument("--aufholen", action="store_true",
help="jeder Slot, dem eine Kodierung fehlt")
p.add_argument("--ohne", action="append", default=[], metavar="MODUL",
help="Modul auslassen, mehrfach angebbar")
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
a = p.parse_args(argv)
module = [m for m in MODULE if m not in a.ohne]
zusatz = ["--trocken"] if a.trocken else []
if a.backfill:
laeufe = [["--backfill"]]
elif a.slot:
laeufe = [["--slot", a.slot]]
else:
from db import verbindung
with verbindung() as conn:
if a.neueste:
zeile = conn.execute("select max(slot) from raw_items").fetchone()
slots = [zeile[0]] if zeile and zeile[0] else []
else:
slots = slots_ohne_kodierung(conn)
if not slots:
print("kein offener Slot")
return 0
print(f"{len(slots)} Slot(s): {slots[0]} bis {slots[-1]}")
laeufe = [["--slot", s.isoformat()] for s in slots]
schlecht = 0
for lauf in laeufe:
for name in module:
schlecht += fuehre_aus(name, lauf + zusatz)
if schlecht:
print(f"\n{schlecht} Modullauf/-laeufe mit Fehler", file=sys.stderr)
return 1 if schlecht else 0
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
# Nur fuer akteure.py.
#
# spaCy statt Flair: die Spezifikation laesst beides zu, spaCy laeuft auf
# der CPU in einem Bruchteil der Zeit (3407 Items in 8 Sekunden) und ist
# stapelunabhaengig - dieselbe Eingabe ergibt dieselben Spannen, egal wie
# die Stapel geschnitten sind. Das ist gemessen, siehe NOTES.md.
#
# Das Modell wird nicht ueber pip aufgeloest, sondern als Rad von der
# Modellfreigabe geholt: so steht die Version im Containerfile und nicht in
# einem Aufloesungsvorgang zur Bauzeit.
spacy
+8
View File
@@ -0,0 +1,8 @@
# Nur fuer embeddings.py.
#
# transformers und nicht sentence-transformers: letzteres zieht scipy und
# scikit-learn nach (rund 60 MB), die hier nichts tun, und kapselt genau die
# Stelle weg, auf die es ankommt - das Auffuellen der Stapel. Mittelwert und
# L2-Normierung sind zwei Zeilen; die Kontrolle ueber die feste Tokenlaenge
# ist die Reproduzierbarkeit wert (siehe embeddings.MAX_TOKEN).
transformers
+514
View File
@@ -0,0 +1,514 @@
#!/usr/bin/env python3
"""Phase 2, Modul: nachtraeglich geaenderte Schlagzeilen (ebene = 'revision').
Ingest schreibt jede geaenderte Fassung eines Items nach raw_item_versionen
fort. Dieses Modul vergleicht die Fassungen und sagt, *wie* ein Haus seine
Schlagzeile umgeschrieben hat: Tippfehler, Kuerzung, Zuspitzung, Neufassung.
python3 revisionen.py --backfill
python3 revisionen.py --slot 2026-09-07T08:00
python3 revisionen.py --backfill --trocken --stichprobe 120
Anders als beim Dublettenmodul haengt eine Zeile hier allein an den Fassungen
*ihres eigenen* Items - es gibt keine Nachbarschaft, kein Datumsfenster und
darum auch keinen Rand, der mitgeladen werden muesste. Ein Slot-Lauf rechnet
fuer jedes beruehrte Item genau das, was ein Backfill fuer dasselbe Item
rechnet (Invariante 2). Er muss nur die Items finden, von denen im Slot eine
neue Fassung eingetroffen ist.
Der Befund ist ausserhalb der Spezifikation; die Begruendung steht in
migrations/p2-004-revisionen.sql.
"""
import argparse
import collections
import datetime as dt
import difflib
import re
import sys
import time
from psycopg.types.json import Jsonb
import normalisierung
import version as versionsmodul
from db import verbindung
EBENE = "revision"
# --------------------------------------------------------------------------
# Wortlisten in der Nutzlast
#
# codings darf keine Werkausschnitte enthalten (Constraint
# codings_kein_volltext, hoechstens 100 Zeichen je Zeichenkette). Einzelne
# hinzugefuegte und gestrichene Woerter sind keine Ausschnitte, sondern der
# Befund selbst - ohne sie waere die Angabe "umformulierung" nicht
# nachpruefbar. Gekappt wird trotzdem: aus zwei Wortlisten von je hoechstens
# einem Dutzend Woertern laesst sich keine Schlagzeile zurueckbauen.
# --------------------------------------------------------------------------
WOERTER_MAX = 12
# Grenze zwischen "umgeschrieben" und "neu geschrieben". 0.4 ist gesetzt,
# nicht gemessen: unterhalb davon teilen zwei Schlagzeilen weniger als die
# Haelfte ihrer Woerter, und von der alten Aussage bleibt nichts stehen.
AEHNLICHKEIT_MIN = 0.4
# Tippfehler: genau ein Wort weicht ab, und die beiden Woerter sind einander
# so aehnlich, dass es eine Korrektur und keine Ersetzung ist. Die
# Laengengrenze traegt dabei die Arbeit - ohne sie gilt auch
# "ARD-Sondersendung" -> "ARD-Sendung" als Tippfehler, und das ist eine
# Kuerzung.
TIPPFEHLER_RATIO = 0.7
TIPPFEHLER_LAENGENDELTA = 2
EINSTELLUNGEN = {
"basis": "titel",
"diff": "wort-sequenzvergleich",
"aehnlichkeit_min": AEHNLICHKEIT_MIN,
"tippfehler_ratio": TIPPFEHLER_RATIO,
"tippfehler_laengendelta": TIPPFEHLER_LAENGENDELTA,
"woerter_max": WOERTER_MAX,
# Siehe normalisierung.normalisiere(): der Pluskasten *ist* hier die
# Schlagzeile, nicht ihr Beiwerk.
"pluskasten_entfernt": False,
"teaser_mitklassifiziert": True,
}
# Formatmarken fuer fortlaufend fortgeschriebene Artikel. Ein Liveticker
# wechselt seine Ueberschrift stuendlich, ohne dass eine Redaktion ihre
# Darstellung revidiert haette - fuer die Frage, wer nachtraeglich
# umformuliert, ist er Rauschen und muss abziehbar sein.
#
# Wortgrenzen sind noetig: "Liverpool" enthaelt "live".
_TICKER = re.compile(
r"\blive(?:ticker|blog|stream)\b|\bnewsblog\b|\bticker\b|\blive\b\s*[-:]",
re.IGNORECASE)
# Bewusst nicht ueber das +++-Muster: handelsblatt setzt "+++ USA +++:" als
# Ressortmarke, nicht als Tickerkennzeichen. Die tagesschau fuehrt ihren
# Ticker zwar in "++ ... ++", schreibt aber "Liveticker" hinein und wird
# darueber erkannt.
_ZAHL = re.compile(r"\d+")
# Text vor dem ersten Doppelpunkt - die Rubrik, unter der ein Haus einen
# Artikel fuehrt: "Liveblog zur Wahl in Sachsen-Anhalt: ...".
#
# Absichtlich nicht normalisierung.ressort_teilen(): das prueft zusaetzlich,
# ob der Kopf hoechstens vier Woerter misst und dahinter noch ein
# vollstaendiger Satz steht. Diese Vorsicht ist dort noetig, wo der Kopf
# *abgeschnitten* wird - "Habeck: Wir haben uns geirrt" darf seinen nicht
# verlieren. Hier wird nichts abgeschnitten, nur verglichen, und die Grenzen
# schadeten: "CDU-Desaster in Sachsen-Anhalt:" hat drei Woerter,
# "CDU-Desaster bei Wahl in Sachsen-Anhalt:" fuenf, und der Wechsel zwischen
# beiden faende sonst nicht statt.
_KOPF = re.compile(r"^\s*([^:]{2,60}):\s+\S")
def teilen(titel):
"""(kopf, rest), beide normalisiert. Ohne Doppelpunkt ist kopf None."""
treffer = _KOPF.match(titel or "")
if not treffer:
return None, _norm(titel)
return _norm(treffer.group(1)), _norm(titel[treffer.end(1) + 1:])
def kopf(titel):
return teilen(titel)[0]
def _norm(text):
return normalisierung.normalisiere(text, pluskasten=False)
def ticker_markiert(*texte):
return any(_TICKER.search(t) for t in texte if t)
def zahlen(text):
"""Zahlen als Multimenge. '54,4 Prozent' -> {'54': 1, '4': 1}."""
return collections.Counter(_ZAHL.findall(text or ""))
def wortdiff(alt, neu):
"""(hinzugekommene, gestrichene) Woerter, in Reihenfolge des Vorkommens."""
a, b = _norm(alt).split(), _norm(neu).split()
hinzu, entfernt = [], []
for marke, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b).get_opcodes():
if marke in ("delete", "replace"):
entfernt.extend(a[i1:i2])
if marke in ("insert", "replace"):
hinzu.extend(b[j1:j2])
return hinzu, entfernt
def aehnlichkeit(alt, neu):
"""Wortweise Uebereinstimmung zweier Fassungen, 0 bis 1."""
a, b = _norm(alt).split(), _norm(neu).split()
if not a and not b:
return 1.0
return difflib.SequenceMatcher(None, a, b).ratio()
def klassifiziere(alt, neu):
"""Art der Aenderung zwischen zwei Fassungen.
Die Reihenfolge der Pruefungen ist die Aussage: das Engste zuerst, damit
eine Korrektur nicht als Umformulierung durchgeht.
unveraendert nichts geaendert
formal nur Zeichensetzung, Anfuehrungszeichen, Schreibung
tippfehler ein Wort korrigiert
kopfwechsel nur der Teil vor dem Doppelpunkt
erweiterung nur ergaenzt - meist eine neue Tatsache
kuerzung nur gestrichen
umformulierung beides, aber die Aussage steht noch
neufassung die Schlagzeile ist ausgetauscht
'kopfwechsel' ist eine Aussage ueber den Satzbau, nicht ueber das
Gewicht: bei "Fussball-Bundesliga: X" -> "X" wechselt eine Rubrik, bei
"Habeck: X" -> "Scholz: X" der Sprecher. Welcher Fall vorliegt, steht in
`hinzu` und `entfernt`.
Eine neunte Einstufung vergibt nur nutzlast(): 'zurueckgenommen', wenn
zwischendurch geaendert wurde und am Ende die erste Fassung wieder
dasteht. Zwischen zwei Fassungen ist sie nicht zu sehen.
"""
if alt == neu:
return "unveraendert"
na, nn = _norm(alt), _norm(neu)
if na == nn:
return "formal"
if not na or not nn:
# Kein vergleichbarer Wortbestand. Kommt vor, wenn eine Fassung nur
# aus Satzzeichen besteht; lieber offen lassen als raten.
return "unbestimmt"
wa, wb = na.split(), nn.split()
if len(wa) == len(wb):
abweichend = [(x, y) for x, y in zip(wa, wb) if x != y]
if len(abweichend) == 1:
x, y = abweichend[0]
if (abs(len(x) - len(y)) <= TIPPFEHLER_LAENGENDELTA
and difflib.SequenceMatcher(None, x, y).ratio() >= TIPPFEHLER_RATIO):
return "tippfehler"
kopf_a, rest_a = teilen(alt)
kopf_b, rest_b = teilen(neu)
if rest_a == rest_b and kopf_a != kopf_b:
return "kopfwechsel"
vergleich = difflib.SequenceMatcher(None, wa, wb)
hinzu = entfernt = 0
for marke, i1, i2, j1, j2 in vergleich.get_opcodes():
if marke in ("delete", "replace"):
entfernt += i2 - i1
if marke in ("insert", "replace"):
hinzu += j2 - j1
if hinzu and not entfernt:
return "erweiterung"
if entfernt and not hinzu:
return "kuerzung"
return "umformulierung" if vergleich.ratio() >= AEHNLICHKEIT_MIN else "neufassung"
# --------------------------------------------------------------------------
# Laden
# --------------------------------------------------------------------------
class Fassung:
__slots__ = ("raw_item_id", "quelle", "gesehen_am", "slot", "titel", "teaser", "url")
def __init__(self, raw_item_id, quelle, gesehen_am, slot, titel, teaser, url):
self.raw_item_id = raw_item_id
self.quelle = quelle
self.gesehen_am = gesehen_am
self.slot = slot
self.titel = titel or ""
self.teaser = teaser or ""
self.url = url or ""
_SPALTEN = """
select v.raw_item_id, r.quelle, v.gesehen_am, v.slot, v.titel, v.teaser, v.url
from raw_item_versionen v
join raw_items r on r.id = v.raw_item_id
"""
# gesehen_am, id: die Fassungen eines Items sind eine Folge, und ihre
# Reihenfolge darf nicht davon abhaengen, wie die Datenbank die Zeilen
# ausliefert. Die id entscheidet, falls zwei Fassungen dieselbe Uhrzeit
# tragen - im Bestand kommt das nicht vor, verlassen wollen wir uns nicht.
_ORDNUNG = " order by v.raw_item_id, v.gesehen_am, v.id"
def lade(conn, slot=None):
"""Fassungen je Item, in zeitlicher Folge.
Ohne slot der Gesamtbestand. Mit slot alle Fassungen der Items, von denen
in diesem Slot eine neue Fassung eingetroffen ist - die *aelteren*
Fassungen gehoeren dazu, sonst laesst sich nicht sagen, was sich geaendert
hat.
"""
if slot is None:
zeilen = conn.execute(_SPALTEN + _ORDNUNG).fetchall()
else:
zeilen = conn.execute(
_SPALTEN + """ where v.raw_item_id in (
select raw_item_id from raw_item_versionen where slot = %s)"""
+ _ORDNUNG, (slot,)).fetchall()
nach_item = {}
for z in zeilen:
nach_item.setdefault(z[0], []).append(Fassung(*z))
return nach_item
# --------------------------------------------------------------------------
# Auswerten
# --------------------------------------------------------------------------
def nutzlast(fassungen):
"""Befund zu einem Item.
Zwei Blickwinkel, weil sie verschiedene Fragen beantworten: `art` und
`aehnlichkeit` vergleichen die *erste mit der letzten* Fassung - was ist
aus der Schlagzeile geworden. `arten` haelt jeden einzelnen Schritt fest -
auf welchem Weg.
"""
erst, letzt = fassungen[0], fassungen[-1]
uebergaenge = list(zip(fassungen, fassungen[1:]))
arten = [klassifiziere(a.titel, b.titel) for a, b in uebergaenge
if a.titel != b.titel]
gesamt = klassifiziere(erst.titel, letzt.titel)
if arten and gesamt == "unveraendert":
# Die Schlagzeile wurde geaendert und wieder zurueckgedreht. Als
# "unveraendert" waere das schlicht falsch: es ist der einzige Fall,
# in dem ein Haus seine eigene Aenderung verwirft, und damit der
# interessanteste.
gesamt = "zurueckgenommen"
hinzu, entfernt = wortdiff(erst.titel, letzt.titel) if arten else ([], [])
koepfe = {teilen(f.titel)[0] for f in fassungen}
teaser_arten = [klassifiziere(a.teaser, b.teaser) for a, b in uebergaenge
if a.teaser != b.teaser]
return {
"fassungen": len(fassungen),
"titel_aenderungen": len(arten),
"teaser_aenderungen": len(teaser_arten),
"url_aenderungen": sum(1 for a, b in uebergaenge if a.url != b.url),
"art": gesamt,
"arten": arten,
"teaser_art": klassifiziere(erst.teaser, letzt.teaser) if teaser_arten else None,
"aehnlichkeit": round(aehnlichkeit(erst.titel, letzt.titel), 4),
"hinzu": hinzu[:WOERTER_MAX],
"entfernt": entfernt[:WOERTER_MAX],
"gekappt": len(hinzu) > WOERTER_MAX or len(entfernt) > WOERTER_MAX,
"woerter_erst": len(_norm(erst.titel).split()),
"woerter_letzt": len(_norm(letzt.titel).split()),
"zahlen_geaendert": zahlen(erst.titel) != zahlen(letzt.titel),
# Blieb die Rubrik ueber alle Fassungen dieselbe? Zusammen mit
# `art` = neufassung ist das die Bauform einer Fortschreibung:
# feste Rubrik, wechselnder Stand.
"kopf_stabil": len(koepfe) == 1,
"ticker_markiert": ticker_markiert(*(f.titel for f in fassungen)),
"erstmals": erst.gesehen_am.isoformat(),
"spanne_min": int((letzt.gesehen_am - erst.gesehen_am).total_seconds() // 60),
}
# --------------------------------------------------------------------------
# Schreiben
# --------------------------------------------------------------------------
def schreibe(conn, nach_item, coder_version, trocken=False):
"""Codings ablegen, aber nur wo sich etwas geaendert hat.
Jedes verarbeitete Item bekommt eine Zeile, auch ein nie geaendertes:
sonst liesse sich "nicht umgeschrieben" nicht von "nicht verarbeitet"
unterscheiden.
konfidenz bleibt leer. Die Einstufung ist regelbasiert und trifft zu oder
nicht; eine Zahl daneben behauptete eine Wahrscheinlichkeit, die das
Verfahren nicht kennt. Das Mass der Aenderung steht als `aehnlichkeit` in
der Nutzlast, wo es hingehoert.
"""
vorhanden = {
r[0]: r[1]
for r in conn.execute(
"select raw_item_id, nutzlast from codings"
" where coder_version = %s and ebene = %s", (coder_version, EBENE))
}
neu = geaendert = unveraendert = 0
stapel = []
for item_id, fassungen in nach_item.items():
last = nutzlast(fassungen)
alt = vorhanden.get(item_id)
if alt == last:
unveraendert += 1
continue
if alt is None:
neu += 1
else:
geaendert += 1
stapel.append((item_id, coder_version, EBENE, Jsonb(last)))
if stapel and not trocken:
with conn.cursor() as cur:
cur.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene)
do update set nutzlast = excluded.nutzlast,
kodiert_am = now()""",
stapel)
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert}
def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None):
conn.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s)""",
(slot, EBENE, coder_version, dauer_ms, gesehen, kodiert,
0 if not fehler else 1, fehler))
# --------------------------------------------------------------------------
# Bericht und Stichprobe
# --------------------------------------------------------------------------
def bericht(nach_item):
lasten = {i: nutzlast(f) for i, f in nach_item.items()}
geaendert = {i: l for i, l in lasten.items() if l["titel_aenderungen"]}
ticker = {i for i, l in geaendert.items() if l["ticker_markiert"]}
fortschreibung = {i for i, l in geaendert.items()
if l["kopf_stabil"] and l["art"] == "neufassung"}
print(f" Items: {len(lasten)}")
print(f" mit Titelaenderung: {len(geaendert)}"
f" ({sum(l['titel_aenderungen'] for l in geaendert.values())} Uebergaenge)")
print(f" nur Teaser geaendert: "
f"{sum(1 for i, l in lasten.items() if l['teaser_aenderungen'] and i not in geaendert)}")
print(f" davon Ticker/Blog markiert: {len(ticker)}")
print(f" Bauform Fortschreibung: {len(fortschreibung)}"
f" (feste Rubrik, ausgetauschter Stand)")
zaehler = collections.Counter(l["art"] for l in geaendert.values())
ohne = collections.Counter(l["art"] for i, l in geaendert.items()
if i not in ticker and i not in fortschreibung)
print("\n Art der Aenderung (erste gegen letzte Fassung):")
print(f" {'':<16}{'gesamt':>8}{'ohne Fortschreibung':>22}")
for art, n in zaehler.most_common():
print(f" {art:<16}{n:>8}{ohne.get(art, 0):>22}")
haeuser = collections.Counter(f[0].quelle for i, f in nach_item.items()
if i in geaendert and i not in ticker
and i not in fortschreibung)
if haeuser:
print("\n Umgeschriebene Schlagzeilen je Haus (ohne Fortschreibung):")
for q, n in haeuser.most_common():
print(f" {q:<16}{n:>4}")
def stichprobe(nach_item, anzahl, pfad):
"""Fassungspaare zum Nachpruefen von Hand ausschreiben.
Die Einstufung ist eine Behauptung ueber Sprache, und die kann nur ein
Mensch pruefen. Nach Art gruppiert und je Art nach Aehnlichkeit sortiert,
damit die Grenzfaelle einer Kategorie beieinander stehen.
"""
zeilen = []
for fassungen in nach_item.values():
for a, b in zip(fassungen, fassungen[1:]):
if a.titel == b.titel:
continue
zeilen.append((klassifiziere(a.titel, b.titel),
aehnlichkeit(a.titel, b.titel), a, b))
zeilen.sort(key=lambda z: (z[0], z[1], z[2].raw_item_id))
nach_art = {}
for z in zeilen:
nach_art.setdefault(z[0], []).append(z)
# Gleichmaessig ueber die Arten ziehen: eine Stichprobe, die nur aus den
# 147 Umformulierungen besteht, prueft die seltenen Kategorien nie.
je_art = max(1, anzahl // max(1, len(nach_art)))
gewaehlt = []
for art in sorted(nach_art):
gewaehlt.extend(nach_art[art][:je_art])
for art in sorted(nach_art):
for z in nach_art[art][je_art:]:
if len(gewaehlt) >= anzahl:
break
gewaehlt.append(z)
gewaehlt.sort(key=lambda z: (z[0], z[1]))
with open(pfad, "w", encoding="utf-8") as f:
f.write("# Revisions-Stichprobe. Spalte 'urteil' von Hand fuellen:"
" j = Einstufung trifft zu, n = trifft nicht zu.\n")
f.write("# Je Art aufsteigend nach Aehnlichkeit - der erste Fall einer Art"
" ist ihr aeusserster.\n\n")
art_zuvor = None
for art, aehn, a, b in gewaehlt:
if art != art_zuvor:
f.write(f"\n### {art} ({len(nach_art[art])} Uebergaenge insgesamt)\n\n")
art_zuvor = art
f.write(f"urteil=_ art={art} aehnlichkeit={aehn:.3f} "
f"item={a.raw_item_id} [{a.quelle}]\n")
f.write(f" - {a.titel}\n")
f.write(f" + {b.titel}\n\n")
return len(gewaehlt), len(zeilen)
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
p.add_argument("--backfill", action="store_true", help="Gesamtbestand")
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
p.add_argument("--stichprobe", type=int, metavar="N",
help="N Uebergaenge zum Nachpruefen ausschreiben")
p.add_argument("--stichprobe-datei", default="stichprobe-revisionen.txt")
a = p.parse_args(argv)
if not a.backfill and not a.slot:
p.error("--backfill oder --slot angeben")
slot = dt.datetime.fromisoformat(a.slot) if a.slot else None
begonnen = time.monotonic()
with verbindung() as conn:
v = versionsmodul.eintragen(
conn, komponenten=versionsmodul.komponenten(revision=EINSTELLUNGEN))
print(f"coder_version {v}{' (trocken)' if a.trocken else ''}")
nach_item = lade(conn, slot=slot)
if not nach_item:
print(" nichts zu tun")
return 0
bericht(nach_item)
zahlen_ = schreibe(conn, nach_item, v, trocken=a.trocken)
dauer = int((time.monotonic() - begonnen) * 1000)
print(f"\n codings: neu {zahlen_['neu']},"
f" geaendert {zahlen_['geaendert']},"
f" unveraendert {zahlen_['unveraendert']}")
print(f" Dauer: {dauer} ms")
if a.stichprobe:
n, gesamt = stichprobe(nach_item, a.stichprobe, a.stichprobe_datei)
print(f" Stichprobe: {n} von {gesamt} Uebergaengen"
f" nach {a.stichprobe_datei}")
if not a.trocken:
protokolliere(conn, slot, v, dauer, len(nach_item),
zahlen_["neu"] + zahlen_["geaendert"])
conn.commit()
else:
conn.rollback()
return 0
if __name__ == "__main__":
sys.exit(main())
+249
View File
@@ -0,0 +1,249 @@
"""Tests fuer die Akteursaufloesung. Ohne spaCy und ohne Datenbank lauffaehig:
geprueft wird die Aufloesung, nicht das Modell.
Die Beispiele stammen aus dem Bestand, nicht aus der Phantasie.
"""
import datetime
import json
import unittest
import akteure
def lexikon():
"""Kleines Lexikon mit genau den Faellen, die hier gebraucht werden."""
eintraege = [
{"id": "per:merz_friedrich", "typ": "person", "name": "Friedrich Merz",
"aliase": [], "land": "DEU", "rolle": "GOV", "notiz": "CDU"},
{"id": "per:putin_wladimir", "typ": "person", "name": "Wladimir Putin",
"aliase": ["Wladimir Wladimirowitsch Putin"], "land": "RUS",
"rolle": "GOV", "notiz": None},
{"id": "per:wolf_katja", "typ": "person", "name": "Katja Wolf",
"aliase": [], "land": "DEU", "rolle": "GOV", "notiz": "BSW"},
{"id": "per:mueller_anna", "typ": "person", "name": "Anna Mueller",
"aliase": [], "land": "DEU", "rolle": "LEG", "notiz": "SPD"},
{"id": "per:mueller_bernd", "typ": "person", "name": "Bernd Mueller",
"aliase": [], "land": "DEU", "rolle": "LEG", "notiz": "CDU"},
{"id": "par:afd", "typ": "partei", "name": "Alternative fuer Deutschland",
"aliase": ["AfD"], "land": "DEU", "rolle": None, "notiz": None},
{"id": "par:cdu", "typ": "partei", "name": "CDU",
"aliase": ["Christlich Demokratische Union"], "land": "DEU",
"rolle": None, "notiz": None},
{"id": "sta:ukraine", "typ": "staat", "name": "Ukraine",
"aliase": [], "land": "UKR", "rolle": None, "notiz": None},
{"id": "amt:bundeskanzler", "typ": "amt", "name": "Bundeskanzler",
"aliase": ["Kanzler"], "land": "DEU", "rolle": "GOV", "notiz": None},
]
zeiten = [
{"amt_id": "amt:bundeskanzler", "person_id": "per:merz_friedrich",
"von": datetime.date(2025, 5, 6), "bis": None},
{"amt_id": "amt:bundeskanzler", "person_id": "per:scholz_olaf",
"von": datetime.date(2021, 12, 8), "bis": datetime.date(2025, 5, 6)},
]
return akteure.Lexikon(eintraege, zeiten)
class Vergleichsform(unittest.TestCase):
def test_umlaute_und_fall(self):
self.assertEqual(akteure.vergleichsform("Grüne"), "gruene")
self.assertEqual(akteure.vergleichsform("STRASSE"), "strasse")
def test_satzzeichen_fallen_weg(self):
self.assertEqual(akteure.vergleichsform("F.A.Z."), "f a z")
self.assertEqual(akteure.vergleichsform("Merz:"), "merz")
def test_akzente(self):
self.assertEqual(akteure.vergleichsform("Émile Zola"), "emile zola")
class Anrede(unittest.TestCase):
def test_amtsbezeichnung_faellt_weg(self):
self.assertEqual(akteure.ohne_anrede("us-praesident trump"), "trump")
self.assertEqual(akteure.ohne_anrede("bundesinnenminister dobrindt"),
"dobrindt")
def test_kette(self):
self.assertEqual(akteure.ohne_anrede("der bundeskanzler merz"), "merz")
def test_ohne_anrede_unveraendert(self):
self.assertEqual(akteure.ohne_anrede("friedrich merz"), "friedrich merz")
def test_reine_anrede_bleibt_stehen(self):
# Sonst bliebe nichts uebrig, womit sich nachschlagen liesse.
self.assertTrue(akteure.ohne_anrede("bundeskanzler"))
class Distanz(unittest.TestCase):
def test_gleich(self):
self.assertEqual(akteure.distanz("merz", "merz", 2), 0)
def test_ein_zeichen(self):
self.assertEqual(akteure.distanz("merz", "mertz", 2), 1)
def test_abbruch_ueber_grenze(self):
self.assertGreater(akteure.distanz("merz", "schulze", 1), 1)
def test_grenze_nach_laenge(self):
self.assertEqual(akteure.fuzzy_grenze(4), 0)
self.assertEqual(akteure.fuzzy_grenze(8), 1)
self.assertEqual(akteure.fuzzy_grenze(15), 2)
class Nachschlagen(unittest.TestCase):
def setUp(self):
self.l = lexikon()
def test_voller_name(self):
ids, methode = self.l.schlage_nach("friedrich merz")
self.assertEqual(ids, {"per:merz_friedrich"})
self.assertEqual(methode, "alias")
def test_alias(self):
ids, methode = self.l.schlage_nach("afd")
self.assertEqual(ids, {"par:afd"})
self.assertEqual(methode, "alias")
def test_nachname_eindeutig(self):
ids, methode = self.l.schlage_nach("putin")
self.assertEqual(ids, {"per:putin_wladimir"})
self.assertEqual(methode, "nachname")
def test_nachname_mehrdeutig(self):
ids, methode = self.l.schlage_nach("mueller")
self.assertEqual(len(ids), 2)
self.assertEqual(methode, "nachname")
def test_gesperrter_nachname(self):
# "Wolf" steht auf der Sperrliste; der volle Name geht trotzdem.
ids, _ = self.l.schlage_nach("wolf")
self.assertEqual(ids, set())
ids, _ = self.l.schlage_nach("katja wolf")
self.assertEqual(ids, {"per:wolf_katja"})
def test_unbekannt(self):
ids, methode = self.l.schlage_nach("michael mendl")
self.assertEqual(ids, set())
self.assertIsNone(methode)
def test_kurze_form_ohne_fuzzy(self):
# Vier Zeichen, Grenze 0: "golf" darf nicht zu "wolf" werden.
ids, _ = self.l.schlage_nach("golf")
self.assertEqual(ids, set())
class Amtszeit(unittest.TestCase):
def setUp(self):
self.l = lexikon()
def test_inhaber_heute(self):
self.assertEqual(self.l.inhaber("amt:bundeskanzler",
datetime.date(2026, 9, 7)),
["per:merz_friedrich"])
def test_inhaber_frueher(self):
self.assertEqual(self.l.inhaber("amt:bundeskanzler",
datetime.date(2023, 3, 1)),
["per:scholz_olaf"])
def test_amt_wird_gegen_datum_aufgeloest(self):
spannen = [{"feld": "titel", "form": "Kanzler", "start": 0, "ende": 7,
"ner_typ": "MISC"}]
alt = akteure.aufloesen(self.l, spannen, datetime.date(2023, 3, 1))
neu = akteure.aufloesen(self.l, spannen, datetime.date(2026, 9, 7))
self.assertEqual(alt[0]["id"], "amt:bundeskanzler")
self.assertEqual(alt[0]["inhaber"], "per:scholz_olaf")
self.assertEqual(neu[0]["inhaber"], "per:merz_friedrich")
def test_ohne_datum_kein_inhaber(self):
spannen = [{"feld": "titel", "form": "Kanzler", "start": 0, "ende": 7,
"ner_typ": "MISC"}]
e = akteure.aufloesen(self.l, spannen, None)
self.assertNotIn("inhaber", e[0])
class Aufloesung(unittest.TestCase):
def setUp(self):
self.l = lexikon()
def spanne(self, form, feld="titel"):
return {"feld": feld, "form": form, "start": 0, "ende": len(form),
"ner_typ": "PER"}
def test_unaufgeloest_bleibt_stehen(self):
e = akteure.aufloesen(self.l, [self.spanne("Michael Mendl")], None)
self.assertIsNone(e[0]["id"])
self.assertEqual(e[0]["methode"], "ner_unaufgeloest")
self.assertEqual(e[0]["form"], "Michael Mendl")
def test_mehrdeutig_bleibt_offen(self):
e = akteure.aufloesen(self.l, [self.spanne("Müller")], None)
self.assertIsNone(e[0]["id"])
self.assertIn("mehrdeutig", e[0]["methode"])
self.assertEqual(len(e[0]["kandidaten"]), 2)
def test_kontext_entscheidet(self):
# Steht die SPD daneben, ist Anna Mueller gemeint - nicht Bernd.
spannen = [self.spanne("Müller"), self.spanne("SPD", "teaser")]
self.l.akteure["per:mueller_anna"]["notiz"] = "SPD"
e = akteure.aufloesen(self.l, spannen, None)
self.assertEqual(e[0]["id"], "per:mueller_anna")
self.assertEqual(e[0]["methode"], "nachname")
def test_ner_typ_wird_nicht_uebernommen(self):
# Das Modell haelt "Merz" oft fuer einen Ort. Das Lexikon nicht.
s = self.spanne("Merz")
s["ner_typ"] = "LOC"
e = akteure.aufloesen(self.l, [s], None)
self.assertEqual(e[0]["typ"], "person")
self.assertEqual(e[0]["ner_typ"], "LOC")
def test_konfidenz_faellt_mit_der_methode(self):
voll = akteure.aufloesen(self.l, [self.spanne("Friedrich Merz")], None)
kurz = akteure.aufloesen(self.l, [self.spanne("Putin")], None)
self.assertGreater(voll[0]["konfidenz"], kurz[0]["konfidenz"])
class Nutzlast(unittest.TestCase):
def setUp(self):
self.l = lexikon()
def test_zaehlt_mit(self):
spannen = [{"feld": "titel", "form": f, "start": 0, "ende": 1,
"ner_typ": "PER"} for f in ("Friedrich Merz", "AfD", "Mendl")]
n = akteure.nutzlast(akteure.aufloesen(self.l, spannen, None), "test/9")
self.assertEqual(n["nennungen"], 3)
self.assertEqual(n["aufgeloest"], 2)
self.assertEqual(n["typen"], {"person": 1, "partei": 1})
self.assertEqual(n["lexikon_version"], "test/9")
def test_keine_zeichenkette_ueber_hundert(self):
"""Spiegelt den Constraint p2_nutzlast_ohne_text."""
spannen = [{"feld": "titel", "form": "x" * 500, "start": 0, "ende": 500,
"ner_typ": "ORG"}]
def pruefe(wert):
if isinstance(wert, str):
self.assertLessEqual(len(wert), 100, wert[:40])
elif isinstance(wert, dict):
for v in wert.values():
pruefe(v)
elif isinstance(wert, list):
for v in wert:
pruefe(v)
# Die Begrenzung sitzt in erkennungen(); hier wird geprueft, dass
# eine bereits gekappte Form die Nutzlast nicht sprengt.
spannen[0]["form"] = spannen[0]["form"][:akteure.FORM_MAX]
n = akteure.nutzlast(akteure.aufloesen(self.l, spannen, None), "test/9")
pruefe(n)
def test_ist_serialisierbar(self):
spannen = [{"feld": "titel", "form": "AfD", "start": 0, "ende": 3,
"ner_typ": "ORG"}]
n = akteure.nutzlast(akteure.aufloesen(self.l, spannen, None), "test/9")
self.assertIsInstance(json.dumps(n), str)
if __name__ == "__main__":
unittest.main()
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""Testfaelle fuer Normalisierung und Dublettenerkennung.
python3 -m unittest test_dubletten -v
Ohne Datenbank. Was die Datenbank angeht, prueft dubletten.py selbst beim
Lauf: die Zahlen im Bericht sind die Kontrolle.
"""
import datetime as dt
import unittest
import dubletten as d
import normalisierung as n
class Normalisierung(unittest.TestCase):
def test_entities_und_tags(self):
self.assertEqual(n.normalisiere("<b>Habeck</b> &amp; Co."), "habeck co")
def test_anfuehrungszeichen_vereinheitlicht(self):
self.assertEqual(n.normalisiere("„Wende“ und »Wende«"),
n.normalisiere('"Wende" und "Wende"'))
def test_gedankenstriche_vereinheitlicht(self):
self.assertEqual(n.normalisiere("Bund – Land"), n.normalisiere("Bund - Land"))
def test_nfkc(self):
# dieselbe Zeichenfolge einmal zusammengesetzt, einmal vorkomponiert
self.assertEqual(n.normalisiere("Grün"), n.normalisiere("Grün"))
def test_pluskasten_faellt_weg(self):
self.assertEqual(n.normalisiere("+++ Newsblog +++ Kanzler tritt zurueck"),
n.normalisiere("Kanzler tritt zurueck"))
def test_trigramme(self):
self.assertEqual(n.trigramme("a b c d"), {("a", "b", "c"), ("b", "c", "d")})
class SimHash(unittest.TestCase):
def test_gleicher_text_gleicher_hash(self):
t = n.trigramme(n.normalisiere("Der Bundestag hat das Gesetz beschlossen"))
self.assertEqual(d.simhash(t), d.simhash(t))
def test_reproduzierbar_ueber_prozesse(self):
# Der Wert ist festgeschrieben. Aendert sich die Hashfunktion oder die
# Normalisierung, faellt dieser Test - und dann muss die
# coder_version steigen, sonst stimmen alte Codings nicht mehr.
t = n.trigramme(n.normalisiere("Der Bundestag hat das Gesetz beschlossen"))
self.assertEqual(f"{d.simhash(t):016x}", ERWARTETER_SIMHASH)
def test_kleine_aenderung_naeher_als_fremder_text(self):
"""SimHash ordnet richtig - aber nicht so scharf, wie man hofft.
Ein einziges geaendertes Wort verruecken drei Trigramme, und bei
einer Meldung aus wenigen Dutzend Woertern sind das schnell zehn Bit
Abstand. Die Grenze von 3 aus der Spezifikation verlangt darum
praktisch identischen Text; sie ist eine Praezisions-, keine
Aehnlichkeitsschwelle. Der Test haelt nur die Ordnung fest.
"""
basis = "Der Bundestag hat das Gesetz am Freitag beschlossen und damit den Weg frei gemacht"
nah = basis.replace("frei gemacht", "freigemacht")
fern = "Ein Sturmtief zieht am Wochenende ueber Norddeutschland hinweg und bringt Regen"
h = lambda x, y: d.hamming(d.simhash(n.trigramme(n.normalisiere(x))),
d.simhash(n.trigramme(n.normalisiere(y))))
self.assertEqual(h(basis, basis), 0)
self.assertLess(h(basis, nah), h(basis, fern))
def test_verschiedene_texte_grosse_distanz(self):
a = d.simhash(n.trigramme(n.normalisiere(
"Der Bundestag hat das Gesetz am Freitag beschlossen")))
b = d.simhash(n.trigramme(n.normalisiere(
"Ein Sturmtief zieht am Wochenende ueber Norddeutschland hinweg")))
self.assertGreater(d.hamming(a, b), 3)
def test_baender_schubfachschluss(self):
# Zwei Werte mit genau 3 abweichenden Bits muessen sich mindestens
# ein Band teilen - darauf beruht die Kandidatensuche.
a = 0
b = 0b111
gemeinsam = set(d.baender(a)) & set(d.baender(b))
self.assertTrue(gemeinsam)
class Jaccard(unittest.TestCase):
def test_identisch(self):
s = {("a", "b", "c")}
self.assertEqual(d.jaccard(s, s), 1.0)
def test_disjunkt(self):
self.assertEqual(d.jaccard({("a",)}, {("b",)}), 0.0)
def test_leer(self):
self.assertEqual(d.jaccard(set(), {("a",)}), 0.0)
class UnionFind(unittest.TestCase):
def test_transitiv(self):
uf = d.UnionFind()
uf.vereinige(3, 1)
uf.vereinige(2, 3)
self.assertEqual(uf.finde(1), uf.finde(2))
def test_reihenfolge_egal(self):
a, b = d.UnionFind(), d.UnionFind()
for x, y in [(1, 2), (2, 3), (4, 5)]:
a.vereinige(x, y)
for x, y in [(4, 5), (3, 2), (2, 1)]:
b.vereinige(x, y)
self.assertEqual([a.finde(i) for i in range(1, 6)],
[b.finde(i) for i in range(1, 6)])
class Ressortkuerzel(unittest.TestCase):
"""Das Kuerzel, das Haeuser der Agenturzeile voranstellen.
Es abzutrennen ist der Unterschied zwischen "findet die Uebernahme" und
"findet sie nicht" - siehe NOTES.md.
"""
def test_kuerzel_faellt_weg(self):
self.assertEqual(
n.normalisiere("Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
ressort_abtrennen=True),
n.normalisiere("Deutsche Rakete erfolgreich ins All gestartet",
ressort_abtrennen=True))
def test_verschiedene_kuerzel_gleicher_kern(self):
a = n.normalisiere("Notfaelle: Fuenf Tote bei Unfall mit Frachtflugzeug in Miami",
ressort_abtrennen=True)
b = n.normalisiere("Flugverkehr: Fuenf Tote bei Unfall mit Frachtflugzeug in Miami",
ressort_abtrennen=True)
self.assertEqual(a, b)
def test_kurzer_rest_bleibt_unangetastet(self):
# "Habeck: Wir haben uns geirrt" - der Kopf ist die Aussage, nicht
# ein Ressort. Vier Woerter Rest sind zu wenig, um ihn wegzuwerfen.
self.assertIn("habeck", n.normalisiere("Habeck: Wir haben uns geirrt",
ressort_abtrennen=True))
def test_langer_kopf_bleibt_unangetastet(self):
titel = "Die lange Vorrede mit sehr vielen Woertern: der eigentliche Kern der Meldung"
self.assertIn("vorrede", n.normalisiere(titel, ressort_abtrennen=True))
def test_ohne_schalter_bleibt_alles_stehen(self):
self.assertIn("raumfahrt", n.normalisiere("Raumfahrt: Deutsche Rakete gestartet ins All"))
def test_teaser_behaelt_seine_doppelpunkte(self):
# Im Teaser steht der Doppelpunkt mitten im Satz; die Basis
# titel+teaser trennt darum nichts ab.
i = _item(1, "Ein Titel ueber irgendetwas Wichtiges",
"Der Minister sagte: das sei nicht hinnehmbar und muesse aufhoeren",
basis="titel+teaser")
self.assertIn("minister", i.text)
class TitelBasis(unittest.TestCase):
def test_uebernahme_mit_verschiedenen_kuerzeln_findet_zusammen(self):
items = [_item(1, "Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
"Ein Teaser des einen Hauses ueber den Start.",
quelle="heise", basis="titel"),
_item(2, "Deutsche Rakete erfolgreich ins All gestartet",
"Ein voellig anders formulierter Teaser des anderen Hauses.",
quelle="tagesschau", basis="titel")]
uf, _ = d.gruppiere(items)
self.assertEqual(uf.finde(1), uf.finde(2))
def test_dieselben_items_ueber_titel_und_teaser_finden_nicht_zusammen(self):
# Der Gegenbeweis zur Messung: mit Teaser im Sack faellt das Paar
# unter die Schwelle.
items = [_item(1, "Raumfahrt: Deutsche Rakete erfolgreich ins All gestartet",
"Ein Teaser des einen Hauses ueber den Start.",
quelle="heise", basis="titel+teaser"),
_item(2, "Deutsche Rakete erfolgreich ins All gestartet",
"Ein voellig anders formulierter Teaser des anderen Hauses.",
quelle="tagesschau", basis="titel+teaser")]
uf, _ = d.gruppiere(items)
self.assertNotEqual(uf.finde(1), uf.finde(2))
def test_verschiedene_meldungen_bleiben_getrennt(self):
items = [_item(1, "Bundestag beschliesst Reform der Netzentgelte am Freitag",
quelle="faz", basis="titel"),
_item(2, "Sturmtief Xaver erreicht am Abend die Nordseekueste",
quelle="ntv", basis="titel")]
uf, _ = d.gruppiere(items)
self.assertNotEqual(uf.finde(1), uf.finde(2))
def test_kurzer_titel_wird_nicht_verglichen(self):
i = _item(1, "Kanzler tritt zurueck", basis="titel")
self.assertFalse(i.vergleichbar)
class Agentur(unittest.TestCase):
def test_klammer(self):
self.assertEqual(d.agentur("Berlin (dpa) - etwas geschah"), "dpa")
def test_reuters(self):
self.assertEqual(d.agentur("Meldung", "Wie Reuters berichtet"), "reuters")
def test_ohne_beleg_keine_vermutung(self):
self.assertIsNone(d.agentur("Der Bundestag hat entschieden"))
def _item(id, titel, teaser=None, quelle="x", tag=1, gesehen_h=0, basis="titel+teaser"):
return d.Item(id, quelle, titel, teaser,
dt.datetime(2026, 9, tag, gesehen_h, tzinfo=dt.timezone.utc),
dt.date(2026, 9, tag), f"hash{id}", basis=basis)
AGENTURMELDUNG = ("Bundestag beschliesst Reform der Netzentgelte",
"Der Bundestag hat am Freitag die Reform der Netzentgelte beschlossen. "
"Die Kosten sollen kuenftig gleichmaessiger verteilt werden.")
class Gruppierung(unittest.TestCase):
def test_gleiche_meldung_zwei_haeuser(self):
items = [_item(1, *AGENTURMELDUNG, quelle="faz"),
_item(2, *AGENTURMELDUNG, quelle="welt")]
uf, erg = d.gruppiere(items)
self.assertEqual(uf.finde(1), uf.finde(2))
def test_verschiedene_meldungen_bleiben_getrennt(self):
items = [
_item(1, *AGENTURMELDUNG),
_item(2, "Sturmtief Xaver erreicht die Nordseekueste",
"Der Deutsche Wetterdienst warnt vor Orkanboeen an der Kueste "
"und rechnet mit Sturmfluten am Abend.", quelle="ntv"),
]
uf, erg = d.gruppiere(items)
self.assertNotEqual(uf.finde(1), uf.finde(2))
def test_datumsfenster_trennt(self):
items = [_item(1, *AGENTURMELDUNG, tag=1), _item(2, *AGENTURMELDUNG, tag=5)]
uf, erg = d.gruppiere(items)
self.assertNotEqual(uf.finde(1), uf.finde(2))
def test_zu_kurze_items_werden_nicht_gepaart(self):
items = [_item(1, "Kanzler tritt zurueck"), _item(2, "Kanzler tritt zurueck")]
self.assertFalse(items[0].vergleichbar)
uf, erg = d.gruppiere(items)
self.assertNotEqual(uf.finde(1), uf.finde(2))
def test_leitartikel_ist_das_aelteste(self):
items = [_item(9, *AGENTURMELDUNG, gesehen_h=8),
_item(4, *AGENTURMELDUNG, gesehen_h=3, quelle="b"),
_item(7, *AGENTURMELDUNG, gesehen_h=3, quelle="c")]
uf, erg = d.gruppiere(items)
gruppe = erg[uf.finde(9)]
self.assertEqual(gruppe["leitartikel"].id, 4) # frueheste Zeit, dann kleinste id
def test_gruppen_id_haengt_nur_am_leitartikel(self):
# Das ist die Zusage aus gruppen_id(): ein spaeter hinzukommendes
# Mitglied darf die Kennung der Gruppe nicht veraendern.
zwei = [_item(1, *AGENTURMELDUNG, gesehen_h=1),
_item(2, *AGENTURMELDUNG, gesehen_h=2, quelle="b")]
drei = zwei + [_item(3, *AGENTURMELDUNG, gesehen_h=9, quelle="c")]
uf2, e2 = d.gruppiere(zwei)
uf3, e3 = d.gruppiere(drei)
self.assertEqual(d.nutzlast(zwei[0], e2[uf2.finde(1)])["gruppe"],
d.nutzlast(drei[0], e3[uf3.finde(1)])["gruppe"])
def test_kette_ueber_tage_wird_getrennt(self):
"""Taegliche Wiederholung darf nicht zu einer Gruppe verschmelzen.
Drei gleichlautende Sendungen an drei aufeinanderfolgenden Tagen
haengen paarweise im Fenster zusammen. Transitiv waeren sie eine
Gruppe ueber drei Tage - das ist keine Uebernahme.
"""
items = [_item(1, *AGENTURMELDUNG, tag=1),
_item(2, *AGENTURMELDUNG, tag=2),
_item(3, *AGENTURMELDUNG, tag=3),
_item(4, *AGENTURMELDUNG, tag=4)]
uf, erg = d.gruppiere(items)
gruppen = {uf.finde(i.id) for i in items}
self.assertGreater(len(gruppen), 1)
for g in erg.values():
tage = [i.datum for i in g["mitglieder"]]
self.assertLessEqual((max(tage) - min(tage)).days, 1)
def test_gleiches_haus_an_anderen_tagen_nicht_gepaart(self):
"""Wiederkehrendes Format ist keine Uebernahme.
Diese Regel sitzt auf Paarebene, damit sie fensterunabhaengig ist -
sie ist der Grund, dass ein Slot-Lauf dieselben Gruppen liefert wie
ein Backfill.
"""
selbe = [_item(1, *AGENTURMELDUNG, quelle="tagesschau", tag=1),
_item(2, *AGENTURMELDUNG, quelle="tagesschau", tag=2)]
uf, _ = d.gruppiere(selbe)
self.assertNotEqual(uf.finde(1), uf.finde(2))
# am selben Tag dagegen schon
selber_tag = [_item(1, *AGENTURMELDUNG, quelle="tagesschau", tag=1),
_item(2, *AGENTURMELDUNG, quelle="tagesschau", tag=1)]
uf, _ = d.gruppiere(selber_tag)
self.assertEqual(uf.finde(1), uf.finde(2))
# verschiedene Haeuser an benachbarten Tagen bleiben eine Uebernahme
haeuser = [_item(1, *AGENTURMELDUNG, quelle="faz", tag=1),
_item(2, *AGENTURMELDUNG, quelle="welt", tag=2)]
uf, _ = d.gruppiere(haeuser)
self.assertEqual(uf.finde(1), uf.finde(2))
def test_haeuserzahl_in_der_nutzlast(self):
# Eine Gruppe aus einem Haus ist Wiederholung, keine Uebernahme.
items = [_item(1, *AGENTURMELDUNG, quelle="faz"),
_item(2, *AGENTURMELDUNG, quelle="welt")]
uf, erg = d.gruppiere(items)
self.assertEqual(d.nutzlast(items[0], erg[uf.finde(1)])["haeuser"], 2)
eigen = [_item(1, *AGENTURMELDUNG, quelle="faz"),
_item(2, *AGENTURMELDUNG, quelle="faz")]
uf, erg = d.gruppiere(eigen)
self.assertEqual(d.nutzlast(eigen[0], erg[uf.finde(1)])["haeuser"], 1)
def test_einzelstueck_bekommt_eigene_gruppe(self):
items = [_item(1, *AGENTURMELDUNG)]
uf, erg = d.gruppiere(items)
last = d.nutzlast(items[0], erg[uf.finde(1)])
self.assertEqual(last["gruppengroesse"], 1)
self.assertEqual(last["leitartikel"], 1)
self.assertIsNone(last["jaccard_min"])
def test_nutzlast_haelt_den_textstand_fest(self):
items = [_item(1, *AGENTURMELDUNG)]
uf, erg = d.gruppiere(items)
last = d.nutzlast(items[0], erg[uf.finde(1)])
self.assertEqual(last["basis_hash"], "hash1")
self.assertEqual(last["basis"], "titel+teaser")
# und bei der anderen Basis steht auch die andere drin
nur_titel = [_item(1, *AGENTURMELDUNG, basis="titel")]
uf2, erg2 = d.gruppiere(nur_titel)
self.assertEqual(d.nutzlast(nur_titel[0], erg2[uf2.finde(1)])["basis"], "titel")
def test_nutzlast_ohne_langen_text(self):
# Abnahmekriterium: kein Text > 100 Zeichen in codings.nutzlast.
# Die Datenbank erzwingt es; hier faellt es frueher auf.
items = [_item(1, *AGENTURMELDUNG)]
uf, erg = d.gruppiere(items)
for wert in d.nutzlast(items[0], erg[uf.finde(1)]).values():
if isinstance(wert, str):
self.assertLessEqual(len(wert), 100)
# Festwert. Faellt dieser Test, hat sich das Verfahren geaendert und die
# coder_version muss steigen.
ERWARTETER_SIMHASH = "91c3912900958489"
if __name__ == "__main__":
unittest.main()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Testfaelle fuer das Embeddingmodul, soweit sie ohne Modell auskommen.
python3 -m unittest test_embeddings -v
Was das Modell selbst tut, laesst sich hier nicht pruefen - dafuer ist
`embeddings.py --nachweis` da, das im Container gegen die echten Gewichte
laeuft. Geprueft wird hier alles davor und danach: welcher Text hineingeht und
welche Bytes herauskommen.
"""
import struct
import unittest
import embeddings as e
import normalisierung as n
class Bereinigung(unittest.TestCase):
"""bereinige() raeumt auf, ohne den Text zu zerlegen."""
def test_grossschreibung_bleibt(self):
self.assertEqual(n.bereinige("Habeck fordert Wende"), "Habeck fordert Wende")
def test_satzzeichen_bleiben(self):
self.assertIn("?", n.bereinige("Kommt die Wende?"))
def test_tags_und_entities_weg(self):
self.assertEqual(n.bereinige("<b>Bund</b> &amp; Länder"), "Bund & Länder")
def test_anfuehrungszeichen_vereinheitlicht(self):
self.assertEqual(n.bereinige("„Wende“"), '"Wende"')
def test_pluskasten_bleibt_stehen(self):
# Anders als normalisiere(): fuer einen Encoder ist der Ticker-Rahmen
# Text, kein Beiwerk.
self.assertIn("Liveticker", n.bereinige("++ Liveticker zur Wahl ++"))
def test_leerraum_zusammengezogen(self):
self.assertEqual(n.bereinige("a \n b"), "a b")
def test_leere_teile_uebersprungen(self):
self.assertEqual(n.bereinige(None, "Titel", ""), "Titel")
class Eingabetext(unittest.TestCase):
def test_praefix_vorne(self):
self.assertTrue(e.text("Titel", "Teaser").startswith(e.PRAEFIX))
def test_titel_und_teaser_verbunden(self):
t = e.text("Wahl in Sachsen-Anhalt", "Die AfD liegt vorn.")
self.assertIn("Wahl in Sachsen-Anhalt Die AfD liegt vorn.", t)
def test_fehlender_teaser(self):
self.assertEqual(e.text("Nur ein Titel", None), e.PRAEFIX + "Nur ein Titel")
class Vektorliteral(unittest.TestCase):
def test_form(self):
self.assertEqual(e.als_vector([1.0, -0.5]), "[1.0,-0.5]")
def test_rueckweg_ist_verlustfrei(self):
werte = [0.1234567, -0.9876543, 1e-8]
zurueck = [float(x) for x in e.als_vector(werte).strip("[]").split(",")]
self.assertEqual(werte, zurueck)
class Vektorhash(unittest.TestCase):
def test_gleiche_werte_gleicher_hash(self):
self.assertEqual(e.vektor_hash([0.5, 0.25]), e.vektor_hash([0.5, 0.25]))
def test_kleinste_aenderung_anderer_hash(self):
eins = struct.unpack("<f", struct.pack("<f", 0.5))[0]
knapp = struct.unpack("<f", struct.pack("<I",
struct.unpack("<I", struct.pack("<f", 0.5))[0] + 1))[0]
self.assertNotEqual(eins, knapp)
self.assertNotEqual(e.vektor_hash([eins]), e.vektor_hash([knapp]))
def test_laenge_geht_ein(self):
self.assertNotEqual(e.vektor_hash([0.0]), e.vektor_hash([0.0, 0.0]))
class Einstellungen(unittest.TestCase):
def test_revision_und_torch_gehen_ein(self):
a = e.einstellungen("abc", "2.14.0+cpu")
b = e.einstellungen("def", "2.14.0+cpu")
self.assertNotEqual(a, b)
def test_threads_stehen_drin(self):
# Die Threadzahl aendert die Reihenfolge der Summen und muss deshalb
# im Versions-Hash landen.
self.assertIn("threads", e.einstellungen())
def test_dimension_passt_zur_migration(self):
self.assertEqual(e.DIMENSIONEN, 1024)
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Testfaelle fuer die Revisionserkennung.
python3 -m unittest test_revisionen -v
Ohne Datenbank. Die Beispiele stammen saemtlich aus dem Bestand vom
4.-7.9.2026; erfundene Faelle pruefen hier nichts, weil das Modul eine
Behauptung ueber echte Redaktionssprache aufstellt.
"""
import datetime as dt
import unittest
import normalisierung as n
import revisionen as r
def fassung(item, minute, titel, teaser="", url="u"):
return r.Fassung(item, "zeit",
dt.datetime(2026, 9, 7, 8, 0) + dt.timedelta(minutes=minute),
dt.datetime(2026, 9, 7, 8, 0), titel, teaser, url)
class Pluskasten(unittest.TestCase):
"""Fuer Revisionen muss der +++-Kasten stehen bleiben."""
TICKER = "++ Liveticker zur Sachsen-Anhalt-Wahl: Wahllokale haben geoeffnet ++"
def test_dublettenweg_leert_den_titel(self):
self.assertEqual(n.normalisiere(self.TICKER), "")
def test_revisionsweg_behaelt_ihn(self):
self.assertIn("wahllokale", n.normalisiere(self.TICKER, pluskasten=False))
class Ressortteilung(unittest.TestCase):
def test_kopf_und_rest(self):
kopf, rest = n.ressort_teilen(
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
self.assertEqual(kopf, "Landtagswahl")
self.assertEqual(rest, "AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
def test_kein_kopf_wenn_der_rest_zu_kurz_ist(self):
# Die Vorsicht aus dubletten.py: bliebe hinter dem Doppelpunkt kein
# Satz stehen, war es keine Ressortmarke.
self.assertEqual(n.ressort_teilen("Landtagswahl: AfD-Triumph")[0], None)
def test_kein_kopf_bei_zitat(self):
# "Habeck: Wir haben uns geirrt" - der Teil vor dem Doppelpunkt
# traegt die Aussage. ressort_teilen darf ihn nicht abschneiden.
self.assertEqual(n.ressort_teilen("Habeck: Wir haben uns geirrt")[0], None)
def test_abtrennen_nutzt_teilung(self):
t = "Fussball: FIFA-Praesident sagt Besuch in Berlin ab"
self.assertEqual(n._ressort_abtrennen(t), n.ressort_teilen(t)[1])
class Klassifikation(unittest.TestCase):
def urteil(self, alt, neu):
return r.klassifiziere(alt, neu)
def test_unveraendert(self):
self.assertEqual(self.urteil("Gleicher Titel", "Gleicher Titel"), "unveraendert")
def test_formal_gedankenstrich(self):
self.assertEqual(self.urteil(
"Berliner Senat zahlt nicht - sensible Daten jetzt im Darknet",
"Berliner Senat zahlt nicht – sensible Daten jetzt im Darknet"), "formal")
def test_formal_apostroph(self):
self.assertEqual(self.urteil("Zeigen, wie’s geht in der Bergdiele",
"Zeigen, wie's geht in der Bergdiele"), "formal")
def test_tippfehler(self):
self.assertEqual(self.urteil(
"Waldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt",
"Wahldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt"),
"tippfehler")
def test_tippfehler_nicht_bei_wortkuerzung(self):
# "ARD-Sondersendung" -> "ARD-Sendung" ist eine Kuerzung, kein
# Tippfehler. Die Laengengrenze muss das trennen.
self.assertNotEqual(self.urteil(
"Livestream: ARD-Sondersendung zur Wahl in Sachsen-Anhalt",
"Livestream: ARD-Sendung zur Wahl in Sachsen-Anhalt"), "tippfehler")
def test_kopfwechsel(self):
self.assertEqual(self.urteil(
"Sabotage an Umspannwerken: Polizei fahndet nach mutmasslichem Taeter",
"Stromnetz-Sabotage: Polizei fahndet nach mutmasslichem Taeter"),
"kopfwechsel")
def test_kopf_entfernt_ist_kopfwechsel(self):
self.assertEqual(self.urteil(
"Fussball-Bundesliga: Bayer Leverkusen zerlegt zahnloses Union Berlin",
"Bayer Leverkusen zerlegt zahnloses Union Berlin"), "kopfwechsel")
def test_erweiterung(self):
self.assertEqual(self.urteil(
"Natalie Portman ist zum dritten Mal Mutter geworden",
"Natalie Portman ist mit 45 zum dritten Mal Mutter geworden"),
"erweiterung")
def test_kuerzung(self):
self.assertEqual(self.urteil(
"Top 10: Maehroboter ohne Begrenzungskabel im Test - Dreame vor Mammotion",
"Top 10: Maehroboter ohne Begrenzungskabel"), "kuerzung")
def test_umformulierung(self):
self.assertEqual(self.urteil(
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Schwierige Regierungsbildung",
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Regierungsbildung schwierig"),
"umformulierung")
def test_neufassung(self):
self.assertEqual(self.urteil(
"Explosionen nahe iranischer Oelinsel Kharg gemeldet",
"Iranische Revolutionsgarden haben drei Oeltanker in der Strasse"
" von Hormus angegriffen"), "neufassung")
def test_leere_fassung_bleibt_unbestimmt(self):
self.assertEqual(self.urteil("...", "Ein richtiger Titel mit Woertern"),
"unbestimmt")
def test_richtung_zaehlt(self):
a = "Polizei fahndet nach Taeter"
b = "Polizei fahndet mit Fotos nach Taeter"
self.assertEqual(self.urteil(a, b), "erweiterung")
self.assertEqual(self.urteil(b, a), "kuerzung")
class Wortdiff(unittest.TestCase):
def test_hinzu_und_entfernt(self):
hinzu, entfernt = r.wortdiff("Polizei fahndet nach Taeter",
"Polizei sucht mit Fotos nach Taeter")
self.assertEqual(hinzu, ["sucht", "mit", "fotos"])
self.assertEqual(entfernt, ["fahndet"])
def test_aehnlichkeit_gleich_ist_eins(self):
self.assertEqual(r.aehnlichkeit("Ein Titel", "Ein Titel"), 1.0)
def test_aehnlichkeit_faellt_bei_austausch(self):
self.assertLess(
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
"Revolutionsgarden greifen Oeltanker in Hormus an"),
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
"Explosionen nahe iranischer Oelinsel bestaetigt"))
class Tickermarken(unittest.TestCase):
def test_erkannte_marken(self):
for t in ("++ Liveticker zur Wahl: Stand ++",
"Newsblog zum Ukraine-Krieg: Angriffe dauern an",
"Liveblog zur Wahl in Sachsen-Anhalt: Klingbeil aeussert sich",
"Livestream: ARD-Sondersendung zur Wahl",
"Live: Heute im Livestream: Sachsen-Anhalt hat gewaehlt"):
self.assertTrue(r.ticker_markiert(t), t)
def test_liverpool_ist_kein_ticker(self):
self.assertFalse(r.ticker_markiert(
"Liverpool gelingt bei Ipswich der erste Saisonsieg"))
def test_ressortkasten_ist_kein_ticker(self):
# handelsblatt setzt "+++ USA +++:" als Ressortmarke.
self.assertFalse(r.ticker_markiert(
"+++ USA +++: Briefwahl in den USA beginnt"))
class Rubrik(unittest.TestCase):
"""kopf() sieht nur nach, ob die Rubrik dieselbe blieb - ohne die
Vorsicht von ressort_teilen(), die kurze Tickerstaende verschluckt."""
def test_kurzer_stand_behaelt_seinen_kopf(self):
self.assertEqual(r.kopf("Liveblog zur Wahl: Wahllokale sind geoeffnet"),
r.kopf("Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"))
def test_ohne_doppelpunkt_kein_kopf(self):
self.assertIsNone(r.kopf("Hertha und Magdeburg liefern sich ein wildes Spiel"))
def test_kopf_ohne_wortgrenze(self):
# ressort_teilen() liesse den fuenfwortigen Kopf fallen, teilen() nicht.
a = "CDU-Desaster in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
b = "CDU-Desaster bei Wahl in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
self.assertEqual(r.klassifiziere(a, b), "kopfwechsel")
def test_rest_wird_mitgeteilt(self):
self.assertEqual(r.teilen("Verkehr: Unfall auf der A3"),
("verkehr", "unfall auf der a3"))
def test_gewechselte_rubrik(self):
self.assertNotEqual(r.kopf("Sabotage an Umspannwerken: Polizei fahndet"),
r.kopf("Stromnetz-Sabotage: Polizei fahndet"))
class Zahlen(unittest.TestCase):
def test_geaenderte_zahl(self):
self.assertNotEqual(r.zahlen("54,4 Prozent am Nachmittag"),
r.zahlen("65,3 Prozent am Nachmittag"))
def test_umgestellte_zahl_bleibt_gleich(self):
self.assertEqual(r.zahlen("2 zu 1 fuer Hertha"), r.zahlen("1 zu 2 fuer Hertha"))
class Nutzlast(unittest.TestCase):
def test_unveraendertes_item(self):
l = r.nutzlast([fassung(1, 0, "Ein Titel, der so bleibt")])
self.assertEqual(l["fassungen"], 1)
self.assertEqual(l["titel_aenderungen"], 0)
self.assertEqual(l["art"], "unveraendert")
self.assertEqual(l["hinzu"], [])
self.assertEqual(l["aehnlichkeit"], 1.0)
def test_erste_gegen_letzte(self):
l = r.nutzlast([
fassung(2, 0, "Polizei fahndet nach Taeter"),
fassung(2, 15, "Polizei fahndet mit Fotos nach Taeter"),
fassung(2, 45, "Polizei fahndet mit Fotos und Video nach Taeter"),
])
self.assertEqual(l["fassungen"], 3)
self.assertEqual(l["titel_aenderungen"], 2)
self.assertEqual(l["arten"], ["erweiterung", "erweiterung"])
self.assertEqual(l["art"], "erweiterung")
self.assertEqual(l["spanne_min"], 45)
def test_zurueckgenommen(self):
# Der faz-Fall aus dem Bestand: "dessen" eingefuegt und wieder
# gestrichen. Erste und letzte Fassung sind gleich, geaendert wurde
# trotzdem.
l = r.nutzlast([
fassung(3, 0, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
fassung(3, 90, "Siegmund dankt Elon Musk fuer dessen Unterstuetzung"),
fassung(3, 120, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
])
self.assertEqual(l["art"], "zurueckgenommen")
self.assertEqual(l["titel_aenderungen"], 2)
def test_kopf_stabil(self):
stabil = r.nutzlast([
fassung(4, 0, "Liveblog zur Wahl: Wahllokale sind geoeffnet"),
fassung(4, 60, "Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"),
])
self.assertTrue(stabil["kopf_stabil"])
self.assertTrue(stabil["ticker_markiert"])
gewechselt = r.nutzlast([
fassung(5, 0, "Sabotage an Umspannwerken: Polizei fahndet nach Taeter"),
fassung(5, 60, "Stromnetz-Sabotage: Polizei fahndet nach Taeter"),
])
self.assertFalse(gewechselt["kopf_stabil"])
def test_teaser_getrennt_gezaehlt(self):
l = r.nutzlast([
fassung(6, 0, "Gleicher Titel bleibt stehen", "Alter Teaser mit Text"),
fassung(6, 30, "Gleicher Titel bleibt stehen", "Neuer Teaser mit Text"),
])
self.assertEqual(l["titel_aenderungen"], 0)
self.assertEqual(l["teaser_aenderungen"], 1)
self.assertIsNotNone(l["teaser_art"])
self.assertEqual(l["art"], "unveraendert")
def test_wortlisten_gekappt(self):
lang = " ".join(f"wort{i}" for i in range(40))
l = r.nutzlast([fassung(7, 0, "Ein kurzer Titel steht hier"),
fassung(7, 10, lang)])
self.assertLessEqual(len(l["hinzu"]), r.WOERTER_MAX)
self.assertTrue(l["gekappt"])
def test_keine_zeichenkette_ueber_hundert(self):
# Spiegelt den Constraint codings_kein_volltext.
lang = "Ein sehr langer Titel " * 12
l = r.nutzlast([fassung(8, 0, lang), fassung(8, 10, lang + " Nachtrag")])
for wert in _zeichenketten(l):
self.assertLessEqual(len(wert), 100, wert)
def _zeichenketten(wert):
if isinstance(wert, str):
yield wert
elif isinstance(wert, dict):
for v in wert.values():
yield from _zeichenketten(v)
elif isinstance(wert, list):
for v in wert:
yield from _zeichenketten(v)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""coder_version: was hinter dem Hash steht.
Format aus der Spezifikation: p2-<ISO-Datum>-<hash8>.
Der Hash bildet alle Bestandteile, die das Ergebnis beeinflussen. Aendert
sich einer, aendert sich die Version, und alte Codings bleiben unangetastet
liegen. Das Datum ist das der letzten Aenderung an den Komponenten, nicht das
des Laufs - sonst waere die Version taeglich neu.
Anmerkung zur Spezifikation: sie sieht *eine* Version ueber alle fuenf Module
vor. Ein neues Sentimentlexikon entwertet damit auch die Embeddings, obwohl
es sie nicht beruehrt. Das ist teuer, aber es ist die festgelegte Semantik;
wer das aendern will, aendert die Spezifikation, nicht diese Datei.
"""
import hashlib
import json
import normalisierung
# Stand der Komponenten. Module, die es noch nicht gibt, stehen auf null -
# sie tragen dann nichts zum Hash bei, ausser dass sie erwaehnt sind.
KOMPONENTEN = {
"stand": "2026-09-07",
"normalisierung": normalisierung.REGELWERK_VERSION,
# Wird vom jeweiligen Modul beim Lauf eingesetzt (siehe komponenten()).
# Die Einstellungen stehen dort, wo sie wirken, nicht hier - sonst
# gerieten die beiden auseinander, und der Hash behauptete eine
# Gleichheit, die es nicht gibt.
"dubletten": None,
# Ausserhalb der Spezifikation, siehe migrations/p2-004-revisionen.sql.
"revision": None,
"akteure": None,
"geo": None,
"tonalitaet": None,
"embedding": None,
}
def komponenten(**module):
"""Kopie der Komponenten mit eingesetzten Modulangaben.
Ein Modul kennt seine Einstellungen selbst und reicht sie hier herein.
Aendert es eine Schwelle, aendert sich der Hash - genau so ist es
gemeint.
"""
k = dict(KOMPONENTEN)
k.update(module)
return k
def coder_version(komponenten=None):
k = KOMPONENTEN if komponenten is None else komponenten
# sort_keys: die Reihenfolge im Quelltext darf den Hash nicht bewegen.
roh = json.dumps(k, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
hash8 = hashlib.blake2b(roh.encode("utf-8"), digest_size=4).hexdigest()
return f"p2-{k['stand']}-{hash8}"
def eintragen(conn, komponenten=None):
"""Version in coder_versionen festhalten. Ohne das sagt der Hash nichts."""
from psycopg.types.json import Jsonb
k = KOMPONENTEN if komponenten is None else komponenten
v = coder_version(k)
conn.execute(
"insert into coder_versionen (version, komponenten) values (%s, %s)"
" on conflict (version) do nothing",
(v, Jsonb(k)),
)
return v