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