Akteursmodul mit Wikidata-Spiegel
Modul 4 der Spezifikation, dazu das Lexikon, auf das es sich stuetzt. Drei Schritte, streng getrennt: die NER liefert Kandidatenspannen, das Lexikon entscheidet den Typ, die Amtstabelle loest Aemter gegen das Datum des Items auf. Die Trennung ist gemessen begruendet und nicht uebernommen: de_core_news_lg haelt "Merz" fuer einen Ort, "Landtagswahl" fuer einen Ort und "Ukraine-Krieg" fuer eine Person. Der NER-Typ steht als ner_typ in der Nutzlast, entscheidet aber nichts. Modellwahl gemessen: sm und lg finden fast gleich viele Entitaeten (14249 gegen 14261), aber nicht dieselben - nur 10,7 Prozent der Items bekommen von beiden dasselbe Ergebnis, der Jaccard ueber alle Spannen liegt bei 0,590. lg findet 170-mal zusaetzlich "AfD" sowie Merz, Putin, Trump, Kushner, Witkoff. Zeitlich kostet das nichts (7,3 gegen 8,0 Sekunden fuer den Gesamtbestand), an Plattenplatz 568 MB. Der Spiegel ist eine Abschrift, keine Auslegung: lexikon/*.json enthaelt die Wikidata-Antworten unveraendert, mit MANIFEST.json als Beleg. Jede Ableitung geschieht in lexikon_laden.py. Nur lexikon.py --spiegeln fasst das Netz an, und es laeuft von Hand - die WDQS ist ein spendenfinanzierter Dienst, kein Endpunkt fuer eine Pipeline im Viertelstundentakt. p2-005 legt akteure und amtsinhaber an, dazu zwei Betriebssichten. Keine Ausschlussbeschraenkung gegen ueberlappende Amtszeiten: Doppelspitzen sind im deutschen Parteiensystem der Normalfall, und Bremen fuehrt "Buergermeister" als kollegiales Amt. Die Sicht amtsinhaber_ueberschneidungen macht Ueberlappungen sichtbar, statt sie zu verbieten. Amtszeiten sind halboffen - der Tag der Uebergabe gehoert dem Nachfolger, sonst waere jeder Amtswechsel eine Ueberschneidung. Messung ueber den Gesamtbestand: 3407 Items in 21,1 Sekunden, 14652 Nennungen, 2889 aufgeloest. Ein Slot-Lauf erzeugt bitgleich dieselbe Nutzlast wie der Gesamtlauf, 0 Abweichungen. spaCy ist stapelunabhaengig (Stapelgroesse 64, 7, 1 und rueckwaerts: identische Spannen), der Kunstgriff mit der festen Tokenlaenge aus dem Embeddingmodul ist hier nicht noetig. Zwei Fallen sind in NOTES.md festgehalten: eine geratene QID haengt einen Eintrag an ein fremdes Objekt (Q157617 ist die Commerzbank, nicht der Bundesrat), und FILTER (lang(?name) = "de") verliert lautlos alles ohne deutsches Label - Apple, Microsoft und Meta. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
This commit is contained in:
co-authored by
Claude Opus 5
parent
cf89e012b2
commit
7e4c31912c
@@ -5,3 +5,5 @@ __pycache__
|
|||||||
stichprobe-*.txt
|
stichprobe-*.txt
|
||||||
*.md
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
|
bebop_database_*
|
||||||
|
lexikon/MANIFEST.json.bak
|
||||||
|
|||||||
@@ -2,3 +2,8 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
.env
|
.env
|
||||||
stichprobe-*.txt
|
stichprobe-*.txt
|
||||||
|
|
||||||
|
# Datenbankspiegel von Prod. Enthaelt Rollen samt Passworthashes.
|
||||||
|
bebop_database_*
|
||||||
|
*.dump
|
||||||
|
*.sql.gz
|
||||||
|
|||||||
+12
-1
@@ -32,13 +32,24 @@ RUN python3 -m pip install --no-cache-dir --upgrade pip \
|
|||||||
&& python3 -m pip install --no-cache-dir \
|
&& python3 -m pip install --no-cache-dir \
|
||||||
--index-url https://download.pytorch.org/whl/cpu torch==2.14.0+cpu
|
--index-url https://download.pytorch.org/whl/cpu torch==2.14.0+cpu
|
||||||
|
|
||||||
COPY requirements.txt requirements-embedding.txt ./
|
COPY requirements.txt requirements-embedding.txt requirements-akteure.txt ./
|
||||||
RUN python3 -m pip install --no-cache-dir -r requirements.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-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
|
&& python3 -m pip freeze > /app/requirements-lock.txt
|
||||||
|
|
||||||
COPY *.py ./
|
COPY *.py ./
|
||||||
COPY migrations/ ./migrations/
|
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
|
# Eigener Benutzer, kein root. /cache traegt die Modellgewichte und muss ein
|
||||||
# Volume sein - 2,2 GB im Container-Layer waeren bei jedem Neubau weg.
|
# Volume sein - 2,2 GB im Container-Layer waeren bei jedem Neubau weg.
|
||||||
|
|||||||
@@ -294,3 +294,121 @@ 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
|
angelegtes Podman-Volume gehört root im User-Namespace. Ohne `:U` am
|
||||||
Volume-Argument scheitert der erste Schreibzugriff auf den Modell-Cache.
|
Volume-Argument scheitert der erste Schreibzugriff auf den Modell-Cache.
|
||||||
Dieselbe Klasse Problem wie bei pgAdmin, nur mit anderem Symptom.
|
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.
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
|
|||||||
| `revisionen.py` | Modul: nachträglich geänderte Schlagzeilen |
|
| `revisionen.py` | Modul: nachträglich geänderte Schlagzeilen |
|
||||||
| `test_revisionen.py` | Testfälle dazu, ohne Datenbank |
|
| `test_revisionen.py` | Testfälle dazu, ohne Datenbank |
|
||||||
| `embeddings.py` | Modul 5: Satzvektoren |
|
| `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 |
|
| `phase2.py` | Läufer: alle Module in fester Reihenfolge |
|
||||||
| `Containerfile` | Laufzeitumgebung, identisch auf Dev und Prod |
|
| `Containerfile` | Laufzeitumgebung, identisch auf Dev und Prod |
|
||||||
| `deploy/` | systemd-Units und Umgebungsbeispiel für Prod |
|
| `deploy/` | systemd-Units und Umgebungsbeispiel für Prod |
|
||||||
@@ -430,3 +435,79 @@ 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
|
`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
|
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.
|
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.
|
||||||
|
|||||||
+455
@@ -0,0 +1,455 @@
|
|||||||
|
"""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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
}
|
||||||
|
|
||||||
|
EINSTELLUNGEN_FEST = {
|
||||||
|
"ner_modell": NER_MODELL,
|
||||||
|
"basis": "titel+teaser",
|
||||||
|
"text": "bereinigt",
|
||||||
|
"ner_typen": list(TYPEN),
|
||||||
|
"typ_aus": "lexikon",
|
||||||
|
"fuzzy": "editierdistanz_nach_laenge",
|
||||||
|
"nachnamen": "eindeutig_oder_kontext",
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 from akteure")
|
||||||
|
akteure = [
|
||||||
|
{"id": r[0], "typ": r[1], "name": r[2], "aliase": r[3],
|
||||||
|
"land": r[4], "rolle": r[5]}
|
||||||
|
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. 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"
|
||||||
|
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]
|
||||||
|
if grund == "kontext_partei":
|
||||||
|
konfidenz -= 0.1
|
||||||
|
eintrag.update({"id": gewaehlt, "typ": a["typ"], "rolle": a["rolle"],
|
||||||
|
"methode": methode, "konfidenz": round(konfidenz, 2)})
|
||||||
|
|
||||||
|
# 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())
|
||||||
+293
@@ -0,0 +1,293 @@
|
|||||||
|
"""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.
|
||||||
|
# Eine Kontaktangabe gehoert hier hinein, sobald eine vereinbart ist -
|
||||||
|
# bis dahin steht nur der Name des Projekts da, keine fremde Adresse.
|
||||||
|
KENNUNG = os.environ.get(
|
||||||
|
"WDQS_USER_AGENT", "wurzelwerk-p2/0.1 (Datenjournalismus-Pipeline Wurzelwerkt)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
""",
|
||||||
|
# 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, "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,44 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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,133 @@
|
|||||||
|
{
|
||||||
|
"_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."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
|||||||
|
"""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"),
|
||||||
|
"staatsfuehrung": ("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,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;
|
||||||
@@ -18,7 +18,10 @@ laesst die anderen unberuehrt - dafuer ist die Schichtung in codings da.
|
|||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
MODULE = ["dubletten", "revisionen", "embeddings"]
|
# 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):
|
def slots_ohne_kodierung(conn):
|
||||||
|
|||||||
@@ -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
|
||||||
+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()
|
||||||
Reference in New Issue
Block a user