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