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
+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;