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
+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()