diff --git a/.containerignore b/.containerignore
new file mode 100644
index 0000000..59d2ea5
--- /dev/null
+++ b/.containerignore
@@ -0,0 +1,7 @@
+.git
+.venv
+.env
+__pycache__
+stichprobe-*.txt
+*.md
+!README.md
diff --git a/Containerfile b/Containerfile
new file mode 100644
index 0000000..0badfed
--- /dev/null
+++ b/Containerfile
@@ -0,0 +1,51 @@
+# 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 ./
+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 freeze > /app/requirements-lock.txt
+
+COPY *.py ./
+COPY migrations/ ./migrations/
+
+# 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"]
diff --git a/NOTES.md b/NOTES.md
index 6f2a76e..ea7b006 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -154,3 +154,143 @@ 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.
diff --git a/README.md b/README.md
index daabfaa..b9d5772 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,12 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
| `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 |
+| `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
@@ -175,3 +181,252 @@ 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.service` und `.timer` als Vorlage. Kein
+Quadlet: Phase 2 ist kein Dienst, der läuft, sondern ein Auftrag, der fällig
+wird. 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/p2.env` (chmod 600), nicht im
+Repo und nicht in der Unit. Vorlage: `deploy/p2.env.beispiel`.
+
+## 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.
diff --git a/deploy/p2.env.beispiel b/deploy/p2.env.beispiel
new file mode 100644
index 0000000..04eb75c
--- /dev/null
+++ b/deploy/p2.env.beispiel
@@ -0,0 +1,14 @@
+# Nach ~/.config/wurzelwerk/p2.env kopieren und ausfuellen. chmod 600.
+# Enthaelt Zugangsdaten und gehoert nicht ins Repo.
+
+# host.containers.internal zeigt aus dem Container auf den Host, wo Postgres
+# seinen Port veroeffentlicht.
+DATABASE_URL=postgresql://wurzelwerk:PASSWORT@host.containers.internal: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
diff --git a/deploy/wurzelwerk-p2.service b/deploy/wurzelwerk-p2.service
new file mode 100644
index 0000000..85874ce
--- /dev/null
+++ b/deploy/wurzelwerk-p2.service
@@ -0,0 +1,31 @@
+# Phase 2 fuer den juengsten Slot. Nach ~/.config/systemd/user/ legen.
+#
+# systemctl --user daemon-reload
+# systemctl --user enable --now wurzelwerk-p2.timer
+#
+# Kein Quadlet: das hier ist kein Dienst, der laeuft, sondern ein Auftrag, der
+# faellig wird. Quadlet-Units sind fuer Container gedacht, die stehen bleiben.
+
+[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
+
+[Service]
+Type=oneshot
+EnvironmentFile=%h/.config/wurzelwerk/p2.env
+TimeoutStartSec=1800
+ExecStart=/usr/bin/podman run --rm \
+ --name wurzelwerk-p2-lauf \
+ --network=pasta \
+ --memory=8g --shm-size=1g \
+ --volume wurzelwerk-modelle:/cache:U \
+ --env DATABASE_URL \
+ --env OMP_NUM_THREADS \
+ --env HF_HUB_OFFLINE \
+ localhost/wurzelwerk-p2 phase2.py --aufholen
+
+[Install]
+WantedBy=default.target
diff --git a/deploy/wurzelwerk-p2.timer b/deploy/wurzelwerk-p2.timer
new file mode 100644
index 0000000..6749e71
--- /dev/null
+++ b/deploy/wurzelwerk-p2.timer
@@ -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
diff --git a/embeddings.py b/embeddings.py
new file mode 100644
index 0000000..3c7ad77
--- /dev/null
+++ b/embeddings.py
@@ -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())
diff --git a/migrations/p2-004-revisionen.sql b/migrations/p2-004-revisionen.sql
new file mode 100644
index 0000000..5c732d0
--- /dev/null
+++ b/migrations/p2-004-revisionen.sql
@@ -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;
diff --git a/normalisierung.py b/normalisierung.py
index fe9a9bc..7698ca0 100644
--- a/normalisierung.py
+++ b/normalisierung.py
@@ -49,19 +49,29 @@ _RESSORT_MAX_WOERTER = 4
_REST_MIN_WOERTER = 5
-def _ressort_abtrennen(text):
+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 text
+ return None, text
kopf, rest = treffer.group("kopf"), treffer.group("rest")
if len(kopf.split()) > _RESSORT_MAX_WOERTER:
- return text
+ return None, text
if len(rest.split()) < _REST_MIN_WOERTER:
- return text
- return rest
+ return None, text
+ return kopf, rest
-def normalisiere(*teile, ressort_abtrennen=False):
+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
@@ -71,6 +81,12 @@ def normalisiere(*teile, ressort_abtrennen=False):
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:
@@ -80,13 +96,36 @@ def normalisiere(*teile, ressort_abtrennen=False):
text = unicodedata.normalize("NFKC", text)
text = text.translate(_ZEICHEN)
text = text.casefold()
- text = _PLUSKASTEN.sub(" ", text)
+ 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()
diff --git a/phase2.py b/phase2.py
new file mode 100644
index 0000000..ee2c7e5
--- /dev/null
+++ b/phase2.py
@@ -0,0 +1,97 @@
+#!/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
+
+MODULE = ["dubletten", "revisionen", "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())
diff --git a/requirements-embedding.txt b/requirements-embedding.txt
new file mode 100644
index 0000000..9570029
--- /dev/null
+++ b/requirements-embedding.txt
@@ -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
diff --git a/revisionen.py b/revisionen.py
new file mode 100644
index 0000000..869713f
--- /dev/null
+++ b/revisionen.py
@@ -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())
diff --git a/test_embeddings.py b/test_embeddings.py
new file mode 100644
index 0000000..c091230
--- /dev/null
+++ b/test_embeddings.py
@@ -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("Bund & 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(" "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()
diff --git a/version.py b/version.py
index 5b02b19..1c3d621 100644
--- a/version.py
+++ b/version.py
@@ -28,6 +28,8 @@ KOMPONENTEN = {
# 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,