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:
irrlicht
2026-09-08 08:27:05 +02:00
co-authored by Claude Opus 5
parent cf89e012b2
commit 7e4c31912c
21 changed files with 34531 additions and 2 deletions
+2
View File
@@ -5,3 +5,5 @@ __pycache__
stichprobe-*.txt
*.md
!README.md
bebop_database_*
lexikon/MANIFEST.json.bak
+5
View File
@@ -2,3 +2,8 @@
__pycache__/
.env
stichprobe-*.txt
# Datenbankspiegel von Prod. Enthaelt Rollen samt Passworthashes.
bebop_database_*
*.dump
*.sql.gz
+12 -1
View File
@@ -32,13 +32,24 @@ 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 ./
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.
+118
View File
@@ -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
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.
+81
View File
@@ -24,6 +24,11 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
| `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 |
@@ -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
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.
+455
View File
@@ -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
View File
@@ -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()
+44
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+10075
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -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
+1172
View File
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
+309
View File
@@ -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()
+117
View File
@@ -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;
+4 -1
View File
@@ -18,7 +18,10 @@ laesst die anderen unberuehrt - dafuer ist die Schichtung in codings da.
import argparse
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):
+11
View File
@@ -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
View File
@@ -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()