Files
irrlichtandClaude Opus 5 3a9c000a0e Landtage und Regierungen im Lexikon, Rang als Gleichstandsregel
Drei neue Spiegelabfragen schliessen die gemessene Luecke im
Akteurslexikon: landtage (1539), regierung (2003), sondergesandte (5).
Lexikon 5540 -> 8856 Akteure, Aufloesung 21,1 % -> 23,2 % ueber 5018
Items. Der Gewinn steckt in wenigen Personen - Siegmund 123, Witkoff
102, Kushner 93, Lawrow 21.

Drei Abfragen, weil Wikidata drei Wege verlangt:

  landtage        P31/P279*, nicht P279* - "Mitglied des Landtag
                  Sachsen-Anhalt" ist eine Instanz von Q1939559, keine
                  Unterklasse. Dazu ein schaerferer Datumsfilter: von
                  11464 je gewaehlten Personen tragen 9700 keinerlei
                  Zeitangabe, ein blosses "kein Ende gesetzt" liesse die
                  Weimarer Republik herein.
  regierung       P1308, Amt -> derzeitiger Inhaber. Ueber die Amtszeit
                  der Person faellt Lawrow (amtiert seit 2004) aus jedem
                  Datumsfenster, ohne Fenster kommen Tausende Minister
                  herein, deren Amtszeit nie zu Ende gepflegt wurde.
  sondergesandte  Der andere Weg, fuer Witkoff - sein Amt traegt kein
                  P1308. Beide Wege in einer Abfrage laufen in die
                  Zeitgrenze der WDQS.

Kushner steht in hand.json: sein Amt endete 2021, seine Rolle nicht.

Die Erweiterung hat zuerst Schaden angerichtet. Mit den Landtagen kamen
Janine und Heiner Merz ins Lexikon, "Merz" wurde mehrdeutig, und 125
Nennungen des Bundeskanzlers fielen aus. Aus jedem "Hamburg" wurde eine
Kultusministerin (22-mal), aus "KI" ein Nachname (16-mal). Daher sieben
neue Sperreintraege und eine dritte Regel in waehle(): traegt genau ein
Kandidat den niedrigsten Rang, gewinnt er. Der Rang kommt aus der
Spiegelabfrage, Staatsfuehrung vor Bundestag vor Landtag - keine
Wichtigkeitsordnung, sondern eine Erwartung darueber, wen eine deutsche
Schlagzeile mit blossem Nachnamen meint. Abschlag 0,15, Grund steht in
der Nutzlast. Die Amtstabelle bekommt den schlechtesten Rang: sie reicht
bis 1794 zurueck, mit Rang 0 wurde aus "Teufel" ein
Ministerpraesident a. D.

Dabei fiel ein Loch im Fingerabdruck auf: NACHNAMEN_SPERRE stand nicht
in EINSTELLUNGEN_FEST. Ein Eintrag mehr aenderte das Ergebnis von 4868
auf 4820 aufgeloeste Nennungen unter derselben coder_version - genau
der Fehler, den die Version verhindern soll. Sperrliste und Rangordnung
gehen jetzt als Hash ein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:11:56 +02:00

520 lines
19 KiB
Python

"""Modul 4: Akteursaufloesung (ebene = 'akteure').
Drei Schritte, streng getrennt:
NER liefert Kandidatenspannen - nur wo, nicht was
Lexikon entscheidet, was eine Spanne ist
Amtstabelle loest Aemter gegen das Datum des Items auf
Die Trennung ist gemessen begruendet: de_core_news_lg haelt "Merz" fuer
einen Ort, "Landtagswahl" fuer einen Ort und "Ukraine-Krieg" fuer eine
Person (siehe NOTES.md). Der Typ einer Nennung kommt deshalb aus dem
Lexikon, nie aus dem Modell. Der NER-Typ wird trotzdem mitgeschrieben -
die Geokodierung braucht ihn als Vorfilter, und er dokumentiert, woher
eine Spanne stammt.
Nicht aufloesbare Nennungen bleiben mit id null stehen. Sie sind kein
Ausschuss, sondern das Rohmaterial der Lexikonpflege; die Sicht
akteure_unaufgeloest zaehlt sie aus.
"""
import argparse
import collections
import datetime
import hashlib
import re
import sys
import time
import unicodedata
import db
import normalisierung
import version
EBENE = "akteure"
NER_MODELL = "de_core_news_lg"
STAPEL = 64
# Obergrenze fuer eine Oberflaechenform in der Nutzlast.
FORM_MAX = 100
# Nur diese NER-Typen werden als Kandidaten betrachtet. MISC bleibt drin,
# weil beide Modelle Parteien regelmaessig dort einsortieren - AfD steht
# 226-mal als MISC im sm-Lauf.
TYPEN = ("PER", "ORG", "LOC", "MISC")
# Fuzzy-Match: erlaubte Editierdistanz nach Laenge der Oberflaechenform.
# Kurze Formen vertragen keinen Fehler - aus "Wolf" wuerde sonst "Wolff",
# "Rolf" und "Golf".
def fuzzy_grenze(laenge):
if laenge < 6:
return 0
if laenge < 12:
return 1
return 2
# Woerter, die als Nachname im Lexikon stehen, in Schlagzeilen aber fast
# immer etwas anderes meinen. Handpflege, mit Begruendung je Eintrag.
NACHNAMEN_SPERRE = {
"wolf", # Katja Wolf, aber auch das Tier - in dieser Saison haeufig
"merkel", # nicht im Lexikon, aber als Nachname mehrdeutig belegt
"bund",
"land",
"recht",
"kraft",
"post",
"stark",
"reich",
"gross",
"klein",
"lang",
"kurz",
"jung",
"alt",
"neu",
"weiss",
"schwarz",
"braun",
"gruen",
"hacker", # Thomas Hacker, MdB - in Schlagzeilen fast nie gemeint
"un", # Nachname von Kim Jong-un, in Schlagzeilen die UN
"partei",
"post",
"bahn",
"welt",
"zeit",
"spiegel",
"focus",
# Staedtenamen, die zugleich Nachnamen von Abgeordneten sind. Erst mit
# den Landtagen kamen sie ins Lexikon, und sofort wurde aus jedem
# "Hamburg" eine niedersaechsische Kultusministerin - 22-mal im Korpus
# vom 08.09.2026.
"hamburg", # Julia Willie Hamburg, MdL Niedersachsen
"rostock", # Clemens Rostock, MdL Brandenburg
"oldenburg", # Simone Oldenburg, MdL Mecklenburg-Vorpommern
"ki", # Ki-hong Nam - in deutschen Schlagzeilen die KI
"nothing", # Volker Nothing, MdL - sonst das englische Wort
"andrew", # Stuart Andrew, MP - sonst ein Vorname
"champagne", # Francois-Philippe Champagne - sonst das Getraenk
}
# Aliase, die Wikidata fuehrt, die in Schlagzeilen aber regelmaessig etwas
# anderes meinen. Wirkt auf den Alias-Match, nicht nur auf Nachnamen.
FORMEN_SPERRE = {
"europa", # Wikidata fuehrt es als Alias der EU. Meist ist der
# Kontinent gemeint, und der ist Sache der Geokodierung.
"europas",
"union", # CDU/CSU oder Europaeische Union - ohne Kontext nicht zu
# entscheiden, und beide Lesarten sind haeufig.
}
# Rangordnung der Spiegelabfragen fuer den Gleichstandsfall. Sie ist eine
# Aussage darueber, wen eine deutsche Schlagzeile mit blossem Nachnamen
# meint, nicht darueber, wer wichtiger ist. Der Anlass ist messbar: mit den
# Landtagen kamen Janine und Heiner Merz ins Lexikon, und "Merz" war
# schlagartig mehrdeutig - 125 Nennungen des Bundeskanzlers fielen aus.
#
# Ein Rang entscheidet nur, wenn genau ein Kandidat den niedrigsten traegt.
# Bei Gleichstand innerhalb eines Rangs bleibt es beim Muenzwurfverbot.
RANG = {
"hand": 0,
"staatsfuehrung": 1,
"regierung": 1,
"sondergesandte": 1,
"bundestag": 2,
"landtage": 3,
# Die Amtstabelle reicht bis 1794 zurueck und besteht ueberwiegend aus
# Verstorbenen. Sie darf einen Gleichstand nicht gewinnen: sonst wird
# aus "Teufel" ein Ministerpraesident a.D. und aus "Otto" ein Bremer
# Buergermeister des 18. Jahrhunderts. Wer heute im Amt ist, steht
# ohnehin in einer der Personenabfragen.
"aemter": 5,
}
RANG_SONST = 4
def rang(quelle):
"""'wikidata:landtage@2026-09-08' -> 3."""
name = (quelle or "").split(":")[-1].split("@")[0]
return RANG.get(name, RANG_SONST)
EINSTELLUNGEN_FEST = {
"ner_modell": NER_MODELL,
"basis": "titel+teaser",
"text": "bereinigt",
"ner_typen": list(TYPEN),
"typ_aus": "lexikon",
"fuzzy": "editierdistanz_nach_laenge",
"nachnamen": "eindeutig_kontext_rang",
"rang": "|".join(f"{k}={v}" for k, v in sorted(RANG.items())),
# Die Sperrliste gehoert in die Version, nicht nur in den Quelltext.
# Sie aendert das Ergebnis - ein Eintrag mehr, und aus 22 Nennungen
# der Hansestadt wird keine Ministerin mehr. Ohne sie im Fingerabdruck
# traegt dieselbe coder_version zwei verschiedene Auszaehlungen, und
# genau das soll die Version verhindern.
"nachnamen_sperre": hashlib.blake2b(
"|".join(sorted(NACHNAMEN_SPERRE)).encode(), digest_size=8
).hexdigest(),
}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
_UNRAT = re.compile(r"[^a-z0-9 ]+")
_MEHRFACH = re.compile(r"\s+")
# Titel und Anreden, die vor einem Namen stehen und nicht zu ihm gehoeren.
_ANREDE = re.compile(
r"^(?:der|die|das|dem|den|des|ex|alt)?[- ]?"
r"(?:bundes|landes|us|eu|nato|un|uno)?[- ]?"
r"(?:kanzler(?:in)?|praesident(?:in)?|minister(?:in)?|ministerpraesident(?:in)?|"
r"aussenminister(?:in)?|innenminister(?:in)?|chef(?:in)?|sprecher(?:in)?|"
r"vorsitzende[rn]?|abgeordnete[rn]?|spitzenkandidat(?:in)?|kandidat(?:in)?|"
r"parteichef(?:in)?|fraktionschef(?:in)?|gesandte[rn]?|premier(?:minister)?)"
r"[- ]",
)
def vergleichsform(text):
"""Form, in der Oberflaeche und Lexikon miteinander verglichen werden."""
text = unicodedata.normalize("NFKC", text).casefold().translate(_UMLAUTE)
text = unicodedata.normalize("NFKD", text)
text = "".join(z for z in text if not unicodedata.combining(z))
return _MEHRFACH.sub(" ", _UNRAT.sub(" ", text)).strip()
def ohne_anrede(form):
"""'us-praesident trump' -> 'trump'. Mehrfach, fuer Ketten."""
vorher = None
while vorher != form:
vorher = form
form = _ANREDE.sub("", form).strip()
return form or vorher
def distanz(a, b, grenze):
"""Levenshtein, abgebrochen sobald die Grenze ueberschritten ist."""
if abs(len(a) - len(b)) > grenze:
return grenze + 1
vorige = list(range(len(b) + 1))
for i, za in enumerate(a, 1):
zeile = [i]
for j, zb in enumerate(b, 1):
zeile.append(min(vorige[j] + 1, zeile[j - 1] + 1,
vorige[j - 1] + (za != zb)))
if min(zeile) > grenze:
return grenze + 1
vorige = zeile
return vorige[-1]
class Lexikon:
"""Nachschlagewerk im Speicher. Wird einmal je Lauf gebaut."""
def __init__(self, akteure, amtszeiten):
self.akteure = {a["id"]: a for a in akteure}
self.formen = collections.defaultdict(set)
self.nachnamen = collections.defaultdict(set)
self.amtszeiten = collections.defaultdict(list)
for a in akteure:
for form in [a["name"], *a["aliase"]]:
v = vergleichsform(form)
if v:
self.formen[v].add(a["id"])
if a["typ"] == "person":
teile = vergleichsform(a["name"]).split()
if len(teile) > 1 and teile[-1] not in NACHNAMEN_SPERRE:
self.nachnamen[teile[-1]].add(a["id"])
for z in amtszeiten:
self.amtszeiten[z["amt_id"]].append(z)
for eintraege in self.amtszeiten.values():
eintraege.sort(key=lambda z: z["von"])
# Fuzzy-Suche nur innerhalb eines Laengenfensters, sonst waere sie
# ein Kreuzprodukt aus 14000 Spannen und 12000 Formen.
self.nach_laenge = collections.defaultdict(list)
for v in self.formen:
self.nach_laenge[len(v)].append(v)
def inhaber(self, amt_id, datum):
"""Wer hatte dieses Amt an diesem Tag."""
treffer = []
for z in self.amtszeiten.get(amt_id, ()):
# Halboffen, wie in der Sicht amtsinhaber_ueberschneidungen:
# am Tag der Uebergabe zaehlt der Nachfolger.
if z["von"] <= datum and (z["bis"] is None or datum < z["bis"]):
treffer.append(z["person_id"])
return treffer
def schlage_nach(self, form):
"""(ids, methode) fuer eine Vergleichsform."""
if form in FORMEN_SPERRE:
return set(), None
if form in self.formen:
return self.formen[form], "alias"
kurz = ohne_anrede(form)
if kurz in FORMEN_SPERRE:
return set(), None
if kurz != form and kurz in self.formen:
return self.formen[kurz], "alias_ohne_anrede"
if kurz in self.nachnamen:
return self.nachnamen[kurz], "nachname"
grenze = fuzzy_grenze(len(kurz))
if grenze:
beste, bester_abstand = set(), grenze + 1
for laenge in range(len(kurz) - grenze, len(kurz) + grenze + 1):
for kandidat in self.nach_laenge.get(laenge, ()):
if kandidat[0] != kurz[0]:
continue
d = distanz(kurz, kandidat, grenze)
if d > grenze:
# distanz() liefert bei Abbruch grenze+1. Ohne diese
# Zeile zaehlt jeder Abbruch als Gleichstand mit
# jedem anderen Abbruch, und die Kandidatenliste
# fuellt sich mit allem, was zufaellig gleich lang
# ist und mit demselben Buchstaben beginnt.
continue
if d < bester_abstand:
beste, bester_abstand = set(self.formen[kandidat]), d
elif d == bester_abstand:
beste |= self.formen[kandidat]
if beste:
return beste, "fuzzy"
return set(), None
def lade_lexikon(conn):
with conn.cursor() as k:
k.execute("select id, typ, name, aliase, land, rolle, quelle from akteure")
akteure = [
{"id": r[0], "typ": r[1], "name": r[2], "aliase": r[3],
"land": r[4], "rolle": r[5], "rang": rang(r[6])}
for r in k.fetchall()
]
k.execute("select amt_id, person_id, von, bis from amtsinhaber")
amtszeiten = [
{"amt_id": r[0], "person_id": r[1], "von": r[2], "bis": r[3]}
for r in k.fetchall()
]
return Lexikon(akteure, amtszeiten)
def waehle(lexikon, ids, kontext):
"""Aus mehreren Kandidaten einen machen - oder keinen.
Regelbasiert und in dieser Reihenfolge, wie die Spezifikation es
verlangt:
1. Ist genau einer gemeint, ist die Sache erledigt.
2. Wird die Partei eines Kandidaten im selben Item genannt, gewinnt er.
3. Traegt genau einer den niedrigsten Rang, gewinnt er - siehe RANG.
4. Sonst bleibt die Nennung unaufgeloest. Ein Muenzwurf waere hier der
schlimmere Fehler, weil er in der Zeitreihe nicht auffaellt.
"""
if len(ids) == 1:
return next(iter(ids)), "eindeutig"
mit_partei = [i for i in ids
if (lexikon.akteure[i].get("notiz") or "") and
vergleichsform(lexikon.akteure[i]["notiz"]) in kontext]
if len(mit_partei) == 1:
return mit_partei[0], "kontext_partei"
beste = min(lexikon.akteure[i].get("rang", RANG_SONST) for i in ids)
vorn = [i for i in ids
if lexikon.akteure[i].get("rang", RANG_SONST) == beste]
if len(vorn) == 1:
return vorn[0], "rang"
return None, "mehrdeutig"
def erkennungen(nlp, titel, teaser):
"""Kandidatenspannen je Feld, mit Offsets im bereinigten Text."""
felder = [("titel", normalisierung.bereinige(titel)),
("teaser", normalisierung.bereinige(teaser))]
ergebnis = []
for feld, text in felder:
if not text:
continue
for ent in nlp(text).ents:
if ent.label_ in TYPEN:
ergebnis.append({"feld": feld,
# Die Nutzlast darf keinen Volltext tragen
# (Constraint p2_nutzlast_ohne_text). Eine
# Nennung ist nie so lang; start/ende machen
# den Urtext ohnehin auffindbar.
"form": ent.text[:FORM_MAX],
"start": ent.start_char, "ende": ent.end_char,
"ner_typ": ent.label_})
return ergebnis
def aufloesen(lexikon, spannen, datum):
"""Spannen gegen das Lexikon aufloesen. Reine Funktion, testbar."""
kontext = " ".join(vergleichsform(s["form"]) for s in spannen)
erkannt = []
for s in spannen:
form = vergleichsform(s["form"])
ids, methode = lexikon.schlage_nach(form)
eintrag = dict(s)
if not ids:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": "ner_unaufgeloest", "konfidenz": 0.4})
erkannt.append(eintrag)
continue
gewaehlt, grund = waehle(lexikon, ids, kontext)
if gewaehlt is None:
eintrag.update({"id": None, "typ": None, "rolle": None,
"methode": f"{methode}_{grund}", "konfidenz": 0.3,
"kandidaten": sorted(ids)[:5]})
erkannt.append(eintrag)
continue
a = lexikon.akteure[gewaehlt]
konfidenz = {"alias": 0.95, "alias_ohne_anrede": 0.9,
"nachname": 0.8, "fuzzy": 0.6}[methode]
# Abschlaege fuer die Gleichstandsregeln. Der Rang ist die
# schwaechere der beiden: die Partei steht im Text, der Rang ist
# nur eine Erwartung darueber, wer gemeint sein duerfte.
if grund == "kontext_partei":
konfidenz -= 0.1
elif grund == "rang":
konfidenz -= 0.15
eintrag.update({"id": gewaehlt, "typ": a["typ"], "rolle": a["rolle"],
"methode": methode, "konfidenz": round(konfidenz, 2)})
if grund != "eindeutig":
eintrag["grund"] = grund
# Aemter gegen das Datum des Items aufloesen. Das Amt bleibt stehen,
# die Person kommt daneben - beides ist eine Aussage ueber den Text.
if a["typ"] == "amt" and datum:
inhaber = lexikon.inhaber(gewaehlt, datum)
if len(inhaber) == 1:
eintrag["inhaber"] = inhaber[0]
eintrag["methode"] = "amt"
elif inhaber:
eintrag["inhaber_mehrdeutig"] = sorted(inhaber)
erkannt.append(eintrag)
return erkannt
def nutzlast(erkannt, lexikon_stand):
aufgeloest = [e for e in erkannt if e["id"]]
return {
"erkannt": erkannt,
"ner_modell": NER_MODELL,
"lexikon_version": lexikon_stand,
"nennungen": len(erkannt),
"aufgeloest": len(aufgeloest),
"typen": dict(collections.Counter(e["typ"] for e in aufgeloest)),
}
_SPALTEN = "select id, titel, teaser, pubdate from raw_items"
def lade(conn, slot=None):
with conn.cursor() as k:
if slot:
k.execute(_SPALTEN + " where slot = %s order by id", (slot,))
else:
k.execute(_SPALTEN + " order by id")
return k.fetchall()
def lexikon_stand(conn):
"""Fingerabdruck des Lexikons.
Geht in die coder_version ein, und zwar inhaltlich: ein einziger neuer
Alias aendert den Hash und damit die Version. Eine blosse Zeilenzahl
taete das nicht - dann waere eine Korrektur am Lexikon von aussen
unsichtbar, und alte Codings behaupteten eine Grundlage, die es nicht
mehr gibt.
"""
with conn.cursor() as k:
k.execute("""select id, typ, name, aliase, land, rolle
from akteure order by id""")
akteure_zeilen = k.fetchall()
k.execute("""select amt_id, person_id, von, bis
from amtsinhaber order by amt_id, person_id, von""")
amtszeilen = k.fetchall()
roh = repr(akteure_zeilen) + repr(amtszeilen)
kurz = hashlib.blake2b(roh.encode("utf-8"), digest_size=6).hexdigest()
return f"{len(akteure_zeilen)}+{len(amtszeilen)}/{kurz}"
def einstellungen(stand):
return dict(EINSTELLUNGEN_FEST, lexikon=stand)
def verarbeite(conn, slot=None, trocken=False):
import spacy
beginn = time.time()
lexikon = lade_lexikon(conn)
stand = lexikon_stand(conn)
v = version.coder_version(version.komponenten(akteure=einstellungen(stand)))
if not trocken:
version.eintragen(conn, version.komponenten(akteure=einstellungen(stand)))
nlp = spacy.load(NER_MODELL, exclude=["lemmatizer"])
zeilen = lade(conn, slot)
print(f"{EBENE}: {len(zeilen)} Items, Version {v}, Lexikon {stand}")
saetze, zahlen = [], collections.Counter()
for item_id, titel, teaser, pubdate in zeilen:
datum = pubdate.date() if isinstance(pubdate, datetime.datetime) else pubdate
spannen = erkennungen(nlp, titel, teaser)
erkannt = aufloesen(lexikon, spannen, datum)
zahlen["nennungen"] += len(erkannt)
zahlen["aufgeloest"] += sum(1 for e in erkannt if e["id"])
saetze.append((item_id, v, nutzlast(erkannt, stand)))
if trocken:
print(f" trocken, nichts geschrieben: {dict(zahlen)}")
return zahlen
from psycopg.types.json import Jsonb
with conn.cursor() as k:
k.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene) do update
set nutzlast = excluded.nutzlast, kodiert_am = now()""",
[(i, ver, EBENE, Jsonb(n)) for i, ver, n in saetze],
)
k.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler)
values (%s, %s, %s, %s, %s, %s, 0)""",
(slot, EBENE, v, int((time.time() - beginn) * 1000),
len(zeilen), len(saetze)),
)
conn.commit()
quote = zahlen["aufgeloest"] / zahlen["nennungen"] if zahlen["nennungen"] else 0
print(f" {len(saetze)} Codings, {zahlen['nennungen']} Nennungen, "
f"{zahlen['aufgeloest']} aufgeloest ({quote:.1%}), "
f"{time.time() - beginn:.1f}s")
return zahlen
def main(argumente=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--backfill", action="store_true")
g.add_argument("--slot")
p.add_argument("--trocken", action="store_true")
a = p.parse_args(argumente)
with db.verbindung() as conn:
verarbeite(conn, slot=a.slot, trocken=a.trocken)
return 0
if __name__ == "__main__":
sys.exit(main())