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
@@ -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()
|
||||
Reference in New Issue
Block a user