Revisionsmodul, Embeddings und Container
Drei Schritte, die zusammen Phase 2 lauffaehig machen.
Revisionen (migrations/p2-004, revisionen.py)
Ingest schreibt jede geaenderte Fassung eines Items nach
raw_item_versionen fort. Daraus faellt ein Befund ab, den die
Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.
Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.
Embeddings (embeddings.py)
multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
gegen den 3407er-Backfill.
Darum transformers statt sentence-transformers: letzteres kapselt genau
diese Stelle weg.
Container (Containerfile, phase2.py, deploy/)
Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
gemessen, nicht behauptet.
version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
This commit is contained in:
co-authored by
Claude Opus 5
parent
50d497925f
commit
cf89e012b2
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
__pycache__
|
||||
stichprobe-*.txt
|
||||
*.md
|
||||
!README.md
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+373
@@ -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())
|
||||
@@ -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;
|
||||
+45
-6
@@ -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,6 +96,7 @@ def normalisiere(*teile, ressort_abtrennen=False):
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = text.translate(_ZEICHEN)
|
||||
text = text.casefold()
|
||||
if pluskasten:
|
||||
text = _PLUSKASTEN.sub(" ", text)
|
||||
if ressort_abtrennen:
|
||||
text = _ressort_abtrennen(text)
|
||||
@@ -87,6 +104,28 @@ def normalisiere(*teile, ressort_abtrennen=False):
|
||||
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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,8 @@
|
||||
# Nur fuer embeddings.py.
|
||||
#
|
||||
# transformers und nicht sentence-transformers: letzteres zieht scipy und
|
||||
# scikit-learn nach (rund 60 MB), die hier nichts tun, und kapselt genau die
|
||||
# Stelle weg, auf die es ankommt - das Auffuellen der Stapel. Mittelwert und
|
||||
# L2-Normierung sind zwei Zeilen; die Kontrolle ueber die feste Tokenlaenge
|
||||
# ist die Reproduzierbarkeit wert (siehe embeddings.MAX_TOKEN).
|
||||
transformers
|
||||
+514
@@ -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())
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Testfaelle fuer das Embeddingmodul, soweit sie ohne Modell auskommen.
|
||||
|
||||
python3 -m unittest test_embeddings -v
|
||||
|
||||
Was das Modell selbst tut, laesst sich hier nicht pruefen - dafuer ist
|
||||
`embeddings.py --nachweis` da, das im Container gegen die echten Gewichte
|
||||
laeuft. Geprueft wird hier alles davor und danach: welcher Text hineingeht und
|
||||
welche Bytes herauskommen.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import embeddings as e
|
||||
import normalisierung as n
|
||||
|
||||
|
||||
class Bereinigung(unittest.TestCase):
|
||||
"""bereinige() raeumt auf, ohne den Text zu zerlegen."""
|
||||
|
||||
def test_grossschreibung_bleibt(self):
|
||||
self.assertEqual(n.bereinige("Habeck fordert Wende"), "Habeck fordert Wende")
|
||||
|
||||
def test_satzzeichen_bleiben(self):
|
||||
self.assertIn("?", n.bereinige("Kommt die Wende?"))
|
||||
|
||||
def test_tags_und_entities_weg(self):
|
||||
self.assertEqual(n.bereinige("<b>Bund</b> & Länder"), "Bund & Länder")
|
||||
|
||||
def test_anfuehrungszeichen_vereinheitlicht(self):
|
||||
self.assertEqual(n.bereinige("„Wende“"), '"Wende"')
|
||||
|
||||
def test_pluskasten_bleibt_stehen(self):
|
||||
# Anders als normalisiere(): fuer einen Encoder ist der Ticker-Rahmen
|
||||
# Text, kein Beiwerk.
|
||||
self.assertIn("Liveticker", n.bereinige("++ Liveticker zur Wahl ++"))
|
||||
|
||||
def test_leerraum_zusammengezogen(self):
|
||||
self.assertEqual(n.bereinige("a \n b"), "a b")
|
||||
|
||||
def test_leere_teile_uebersprungen(self):
|
||||
self.assertEqual(n.bereinige(None, "Titel", ""), "Titel")
|
||||
|
||||
|
||||
class Eingabetext(unittest.TestCase):
|
||||
def test_praefix_vorne(self):
|
||||
self.assertTrue(e.text("Titel", "Teaser").startswith(e.PRAEFIX))
|
||||
|
||||
def test_titel_und_teaser_verbunden(self):
|
||||
t = e.text("Wahl in Sachsen-Anhalt", "Die AfD liegt vorn.")
|
||||
self.assertIn("Wahl in Sachsen-Anhalt Die AfD liegt vorn.", t)
|
||||
|
||||
def test_fehlender_teaser(self):
|
||||
self.assertEqual(e.text("Nur ein Titel", None), e.PRAEFIX + "Nur ein Titel")
|
||||
|
||||
|
||||
class Vektorliteral(unittest.TestCase):
|
||||
def test_form(self):
|
||||
self.assertEqual(e.als_vector([1.0, -0.5]), "[1.0,-0.5]")
|
||||
|
||||
def test_rueckweg_ist_verlustfrei(self):
|
||||
werte = [0.1234567, -0.9876543, 1e-8]
|
||||
zurueck = [float(x) for x in e.als_vector(werte).strip("[]").split(",")]
|
||||
self.assertEqual(werte, zurueck)
|
||||
|
||||
|
||||
class Vektorhash(unittest.TestCase):
|
||||
def test_gleiche_werte_gleicher_hash(self):
|
||||
self.assertEqual(e.vektor_hash([0.5, 0.25]), e.vektor_hash([0.5, 0.25]))
|
||||
|
||||
def test_kleinste_aenderung_anderer_hash(self):
|
||||
eins = struct.unpack("<f", struct.pack("<f", 0.5))[0]
|
||||
knapp = struct.unpack("<f", struct.pack("<I",
|
||||
struct.unpack("<I", struct.pack("<f", 0.5))[0] + 1))[0]
|
||||
self.assertNotEqual(eins, knapp)
|
||||
self.assertNotEqual(e.vektor_hash([eins]), e.vektor_hash([knapp]))
|
||||
|
||||
def test_laenge_geht_ein(self):
|
||||
self.assertNotEqual(e.vektor_hash([0.0]), e.vektor_hash([0.0, 0.0]))
|
||||
|
||||
|
||||
class Einstellungen(unittest.TestCase):
|
||||
def test_revision_und_torch_gehen_ein(self):
|
||||
a = e.einstellungen("abc", "2.14.0+cpu")
|
||||
b = e.einstellungen("def", "2.14.0+cpu")
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_threads_stehen_drin(self):
|
||||
# Die Threadzahl aendert die Reihenfolge der Summen und muss deshalb
|
||||
# im Versions-Hash landen.
|
||||
self.assertIn("threads", e.einstellungen())
|
||||
|
||||
def test_dimension_passt_zur_migration(self):
|
||||
self.assertEqual(e.DIMENSIONEN, 1024)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Testfaelle fuer die Revisionserkennung.
|
||||
|
||||
python3 -m unittest test_revisionen -v
|
||||
|
||||
Ohne Datenbank. Die Beispiele stammen saemtlich aus dem Bestand vom
|
||||
4.-7.9.2026; erfundene Faelle pruefen hier nichts, weil das Modul eine
|
||||
Behauptung ueber echte Redaktionssprache aufstellt.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import unittest
|
||||
|
||||
import normalisierung as n
|
||||
import revisionen as r
|
||||
|
||||
|
||||
def fassung(item, minute, titel, teaser="", url="u"):
|
||||
return r.Fassung(item, "zeit",
|
||||
dt.datetime(2026, 9, 7, 8, 0) + dt.timedelta(minutes=minute),
|
||||
dt.datetime(2026, 9, 7, 8, 0), titel, teaser, url)
|
||||
|
||||
|
||||
class Pluskasten(unittest.TestCase):
|
||||
"""Fuer Revisionen muss der +++-Kasten stehen bleiben."""
|
||||
|
||||
TICKER = "++ Liveticker zur Sachsen-Anhalt-Wahl: Wahllokale haben geoeffnet ++"
|
||||
|
||||
def test_dublettenweg_leert_den_titel(self):
|
||||
self.assertEqual(n.normalisiere(self.TICKER), "")
|
||||
|
||||
def test_revisionsweg_behaelt_ihn(self):
|
||||
self.assertIn("wahllokale", n.normalisiere(self.TICKER, pluskasten=False))
|
||||
|
||||
|
||||
class Ressortteilung(unittest.TestCase):
|
||||
def test_kopf_und_rest(self):
|
||||
kopf, rest = n.ressort_teilen(
|
||||
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
|
||||
self.assertEqual(kopf, "Landtagswahl")
|
||||
self.assertEqual(rest, "AfD-Triumph in Sachsen-Anhalt erschwert die Regierungsbildung")
|
||||
|
||||
def test_kein_kopf_wenn_der_rest_zu_kurz_ist(self):
|
||||
# Die Vorsicht aus dubletten.py: bliebe hinter dem Doppelpunkt kein
|
||||
# Satz stehen, war es keine Ressortmarke.
|
||||
self.assertEqual(n.ressort_teilen("Landtagswahl: AfD-Triumph")[0], None)
|
||||
|
||||
def test_kein_kopf_bei_zitat(self):
|
||||
# "Habeck: Wir haben uns geirrt" - der Teil vor dem Doppelpunkt
|
||||
# traegt die Aussage. ressort_teilen darf ihn nicht abschneiden.
|
||||
self.assertEqual(n.ressort_teilen("Habeck: Wir haben uns geirrt")[0], None)
|
||||
|
||||
def test_abtrennen_nutzt_teilung(self):
|
||||
t = "Fussball: FIFA-Praesident sagt Besuch in Berlin ab"
|
||||
self.assertEqual(n._ressort_abtrennen(t), n.ressort_teilen(t)[1])
|
||||
|
||||
|
||||
class Klassifikation(unittest.TestCase):
|
||||
def urteil(self, alt, neu):
|
||||
return r.klassifiziere(alt, neu)
|
||||
|
||||
def test_unveraendert(self):
|
||||
self.assertEqual(self.urteil("Gleicher Titel", "Gleicher Titel"), "unveraendert")
|
||||
|
||||
def test_formal_gedankenstrich(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Berliner Senat zahlt nicht - sensible Daten jetzt im Darknet",
|
||||
"Berliner Senat zahlt nicht – sensible Daten jetzt im Darknet"), "formal")
|
||||
|
||||
def test_formal_apostroph(self):
|
||||
self.assertEqual(self.urteil("Zeigen, wie’s geht in der Bergdiele",
|
||||
"Zeigen, wie's geht in der Bergdiele"), "formal")
|
||||
|
||||
def test_tippfehler(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Waldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt",
|
||||
"Wahldesaster in Sachsen-Anhalt: Warken sieht Reformkurs bestaetigt"),
|
||||
"tippfehler")
|
||||
|
||||
def test_tippfehler_nicht_bei_wortkuerzung(self):
|
||||
# "ARD-Sondersendung" -> "ARD-Sendung" ist eine Kuerzung, kein
|
||||
# Tippfehler. Die Laengengrenze muss das trennen.
|
||||
self.assertNotEqual(self.urteil(
|
||||
"Livestream: ARD-Sondersendung zur Wahl in Sachsen-Anhalt",
|
||||
"Livestream: ARD-Sendung zur Wahl in Sachsen-Anhalt"), "tippfehler")
|
||||
|
||||
def test_kopfwechsel(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Sabotage an Umspannwerken: Polizei fahndet nach mutmasslichem Taeter",
|
||||
"Stromnetz-Sabotage: Polizei fahndet nach mutmasslichem Taeter"),
|
||||
"kopfwechsel")
|
||||
|
||||
def test_kopf_entfernt_ist_kopfwechsel(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Fussball-Bundesliga: Bayer Leverkusen zerlegt zahnloses Union Berlin",
|
||||
"Bayer Leverkusen zerlegt zahnloses Union Berlin"), "kopfwechsel")
|
||||
|
||||
def test_erweiterung(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Natalie Portman ist zum dritten Mal Mutter geworden",
|
||||
"Natalie Portman ist mit 45 zum dritten Mal Mutter geworden"),
|
||||
"erweiterung")
|
||||
|
||||
def test_kuerzung(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Top 10: Maehroboter ohne Begrenzungskabel im Test - Dreame vor Mammotion",
|
||||
"Top 10: Maehroboter ohne Begrenzungskabel"), "kuerzung")
|
||||
|
||||
def test_umformulierung(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Schwierige Regierungsbildung",
|
||||
"Landtagswahl: AfD-Triumph in Sachsen-Anhalt - Regierungsbildung schwierig"),
|
||||
"umformulierung")
|
||||
|
||||
def test_neufassung(self):
|
||||
self.assertEqual(self.urteil(
|
||||
"Explosionen nahe iranischer Oelinsel Kharg gemeldet",
|
||||
"Iranische Revolutionsgarden haben drei Oeltanker in der Strasse"
|
||||
" von Hormus angegriffen"), "neufassung")
|
||||
|
||||
def test_leere_fassung_bleibt_unbestimmt(self):
|
||||
self.assertEqual(self.urteil("...", "Ein richtiger Titel mit Woertern"),
|
||||
"unbestimmt")
|
||||
|
||||
def test_richtung_zaehlt(self):
|
||||
a = "Polizei fahndet nach Taeter"
|
||||
b = "Polizei fahndet mit Fotos nach Taeter"
|
||||
self.assertEqual(self.urteil(a, b), "erweiterung")
|
||||
self.assertEqual(self.urteil(b, a), "kuerzung")
|
||||
|
||||
|
||||
class Wortdiff(unittest.TestCase):
|
||||
def test_hinzu_und_entfernt(self):
|
||||
hinzu, entfernt = r.wortdiff("Polizei fahndet nach Taeter",
|
||||
"Polizei sucht mit Fotos nach Taeter")
|
||||
self.assertEqual(hinzu, ["sucht", "mit", "fotos"])
|
||||
self.assertEqual(entfernt, ["fahndet"])
|
||||
|
||||
def test_aehnlichkeit_gleich_ist_eins(self):
|
||||
self.assertEqual(r.aehnlichkeit("Ein Titel", "Ein Titel"), 1.0)
|
||||
|
||||
def test_aehnlichkeit_faellt_bei_austausch(self):
|
||||
self.assertLess(
|
||||
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
|
||||
"Revolutionsgarden greifen Oeltanker in Hormus an"),
|
||||
r.aehnlichkeit("Explosionen nahe iranischer Oelinsel gemeldet",
|
||||
"Explosionen nahe iranischer Oelinsel bestaetigt"))
|
||||
|
||||
|
||||
class Tickermarken(unittest.TestCase):
|
||||
def test_erkannte_marken(self):
|
||||
for t in ("++ Liveticker zur Wahl: Stand ++",
|
||||
"Newsblog zum Ukraine-Krieg: Angriffe dauern an",
|
||||
"Liveblog zur Wahl in Sachsen-Anhalt: Klingbeil aeussert sich",
|
||||
"Livestream: ARD-Sondersendung zur Wahl",
|
||||
"Live: Heute im Livestream: Sachsen-Anhalt hat gewaehlt"):
|
||||
self.assertTrue(r.ticker_markiert(t), t)
|
||||
|
||||
def test_liverpool_ist_kein_ticker(self):
|
||||
self.assertFalse(r.ticker_markiert(
|
||||
"Liverpool gelingt bei Ipswich der erste Saisonsieg"))
|
||||
|
||||
def test_ressortkasten_ist_kein_ticker(self):
|
||||
# handelsblatt setzt "+++ USA +++:" als Ressortmarke.
|
||||
self.assertFalse(r.ticker_markiert(
|
||||
"+++ USA +++: Briefwahl in den USA beginnt"))
|
||||
|
||||
|
||||
class Rubrik(unittest.TestCase):
|
||||
"""kopf() sieht nur nach, ob die Rubrik dieselbe blieb - ohne die
|
||||
Vorsicht von ressort_teilen(), die kurze Tickerstaende verschluckt."""
|
||||
|
||||
def test_kurzer_stand_behaelt_seinen_kopf(self):
|
||||
self.assertEqual(r.kopf("Liveblog zur Wahl: Wahllokale sind geoeffnet"),
|
||||
r.kopf("Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"))
|
||||
|
||||
def test_ohne_doppelpunkt_kein_kopf(self):
|
||||
self.assertIsNone(r.kopf("Hertha und Magdeburg liefern sich ein wildes Spiel"))
|
||||
|
||||
def test_kopf_ohne_wortgrenze(self):
|
||||
# ressort_teilen() liesse den fuenfwortigen Kopf fallen, teilen() nicht.
|
||||
a = "CDU-Desaster in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
|
||||
b = "CDU-Desaster bei Wahl in Sachsen-Anhalt: Jetzt wird es richtig chaotisch"
|
||||
self.assertEqual(r.klassifiziere(a, b), "kopfwechsel")
|
||||
|
||||
def test_rest_wird_mitgeteilt(self):
|
||||
self.assertEqual(r.teilen("Verkehr: Unfall auf der A3"),
|
||||
("verkehr", "unfall auf der a3"))
|
||||
|
||||
def test_gewechselte_rubrik(self):
|
||||
self.assertNotEqual(r.kopf("Sabotage an Umspannwerken: Polizei fahndet"),
|
||||
r.kopf("Stromnetz-Sabotage: Polizei fahndet"))
|
||||
|
||||
|
||||
class Zahlen(unittest.TestCase):
|
||||
def test_geaenderte_zahl(self):
|
||||
self.assertNotEqual(r.zahlen("54,4 Prozent am Nachmittag"),
|
||||
r.zahlen("65,3 Prozent am Nachmittag"))
|
||||
|
||||
def test_umgestellte_zahl_bleibt_gleich(self):
|
||||
self.assertEqual(r.zahlen("2 zu 1 fuer Hertha"), r.zahlen("1 zu 2 fuer Hertha"))
|
||||
|
||||
|
||||
class Nutzlast(unittest.TestCase):
|
||||
def test_unveraendertes_item(self):
|
||||
l = r.nutzlast([fassung(1, 0, "Ein Titel, der so bleibt")])
|
||||
self.assertEqual(l["fassungen"], 1)
|
||||
self.assertEqual(l["titel_aenderungen"], 0)
|
||||
self.assertEqual(l["art"], "unveraendert")
|
||||
self.assertEqual(l["hinzu"], [])
|
||||
self.assertEqual(l["aehnlichkeit"], 1.0)
|
||||
|
||||
def test_erste_gegen_letzte(self):
|
||||
l = r.nutzlast([
|
||||
fassung(2, 0, "Polizei fahndet nach Taeter"),
|
||||
fassung(2, 15, "Polizei fahndet mit Fotos nach Taeter"),
|
||||
fassung(2, 45, "Polizei fahndet mit Fotos und Video nach Taeter"),
|
||||
])
|
||||
self.assertEqual(l["fassungen"], 3)
|
||||
self.assertEqual(l["titel_aenderungen"], 2)
|
||||
self.assertEqual(l["arten"], ["erweiterung", "erweiterung"])
|
||||
self.assertEqual(l["art"], "erweiterung")
|
||||
self.assertEqual(l["spanne_min"], 45)
|
||||
|
||||
def test_zurueckgenommen(self):
|
||||
# Der faz-Fall aus dem Bestand: "dessen" eingefuegt und wieder
|
||||
# gestrichen. Erste und letzte Fassung sind gleich, geaendert wurde
|
||||
# trotzdem.
|
||||
l = r.nutzlast([
|
||||
fassung(3, 0, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
|
||||
fassung(3, 90, "Siegmund dankt Elon Musk fuer dessen Unterstuetzung"),
|
||||
fassung(3, 120, "Siegmund dankt Elon Musk fuer Unterstuetzung"),
|
||||
])
|
||||
self.assertEqual(l["art"], "zurueckgenommen")
|
||||
self.assertEqual(l["titel_aenderungen"], 2)
|
||||
|
||||
def test_kopf_stabil(self):
|
||||
stabil = r.nutzlast([
|
||||
fassung(4, 0, "Liveblog zur Wahl: Wahllokale sind geoeffnet"),
|
||||
fassung(4, 60, "Liveblog zur Wahl: Erste Hochrechnung sieht AfD vorn"),
|
||||
])
|
||||
self.assertTrue(stabil["kopf_stabil"])
|
||||
self.assertTrue(stabil["ticker_markiert"])
|
||||
|
||||
gewechselt = r.nutzlast([
|
||||
fassung(5, 0, "Sabotage an Umspannwerken: Polizei fahndet nach Taeter"),
|
||||
fassung(5, 60, "Stromnetz-Sabotage: Polizei fahndet nach Taeter"),
|
||||
])
|
||||
self.assertFalse(gewechselt["kopf_stabil"])
|
||||
|
||||
def test_teaser_getrennt_gezaehlt(self):
|
||||
l = r.nutzlast([
|
||||
fassung(6, 0, "Gleicher Titel bleibt stehen", "Alter Teaser mit Text"),
|
||||
fassung(6, 30, "Gleicher Titel bleibt stehen", "Neuer Teaser mit Text"),
|
||||
])
|
||||
self.assertEqual(l["titel_aenderungen"], 0)
|
||||
self.assertEqual(l["teaser_aenderungen"], 1)
|
||||
self.assertIsNotNone(l["teaser_art"])
|
||||
self.assertEqual(l["art"], "unveraendert")
|
||||
|
||||
def test_wortlisten_gekappt(self):
|
||||
lang = " ".join(f"wort{i}" for i in range(40))
|
||||
l = r.nutzlast([fassung(7, 0, "Ein kurzer Titel steht hier"),
|
||||
fassung(7, 10, lang)])
|
||||
self.assertLessEqual(len(l["hinzu"]), r.WOERTER_MAX)
|
||||
self.assertTrue(l["gekappt"])
|
||||
|
||||
def test_keine_zeichenkette_ueber_hundert(self):
|
||||
# Spiegelt den Constraint codings_kein_volltext.
|
||||
lang = "Ein sehr langer Titel " * 12
|
||||
l = r.nutzlast([fassung(8, 0, lang), fassung(8, 10, lang + " Nachtrag")])
|
||||
for wert in _zeichenketten(l):
|
||||
self.assertLessEqual(len(wert), 100, wert)
|
||||
|
||||
|
||||
def _zeichenketten(wert):
|
||||
if isinstance(wert, str):
|
||||
yield wert
|
||||
elif isinstance(wert, dict):
|
||||
for v in wert.values():
|
||||
yield from _zeichenketten(v)
|
||||
elif isinstance(wert, list):
|
||||
for v in wert:
|
||||
yield from _zeichenketten(v)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user