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,