Initial import of Politrack, a self-hosted service that tracks Austrian politicians from federal to municipal level - roles, votes, money flows, statements, meetings, social media and legal proceedings - with every claim linked to its sources. - backend: FastAPI + Postgres/pgvector; adapter framework (fetch -> parse -> load -> enrich) with robots.txt/TDM-aware fetching, a raw-response archive for reparsing, and a claim/evidence model with corroboration - sources: Parliament, Wikidata, EU Parliament/Commission, Council of Ministers, Rechnungshof party reports and donations, procurement, RTR media transparency, political ad libraries, WKStA, news feeds, Wikipedia edits, Bluesky, Mastodon, public Telegram channels, YouTube and X (official API) - web: Next.js frontend in German and English with typed API client - mcp: MCP server for AI agents and RAG - docs: architecture, data model, sources, legal notes, Unraid deployment and contributor guides; Docker Compose setup for Unraid Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
5.4 KiB
Python
99 lines
5.4 KiB
Python
from politrack.enrich.events import detect
|
||
from politrack.enrich.legal import classify
|
||
from politrack.enrich.linker import OFFICE_TITLE
|
||
from politrack.sources.parlament.common import parse_immunity_report, parse_roll_call
|
||
|
||
ROLL_CALL_HTML = """
|
||
<p class="randnummer-placeholder-value randnummer" id="121.4" title="Abstimmung">RN/121.4</p>
|
||
<p><i>Mit „Ja“ stimmten die Abgeordneten:</i></p>
|
||
<p><i>Auer, Auinger-Oberzaucher;</i></p>
|
||
<p><i>Eder, </i><a target="_blank" href="https://www.parlament.gv.at/person/20056"><i><span class="generated">Egger Kurt</span></i></a><i>, Elian;</i></p>
|
||
<p><i>von Künsberg Sarre;</i></p>
|
||
<p><i>Mit „Nein“ stimmten die Abgeordneten:</i></p>
|
||
<p><i>Belakowitsch, </i><a href="https://www.parlament.gv.at/person/2996"><i>Berger Ricarda</i></a><i>, Kickl;</i></p>
|
||
<p><i>Zanger.</i></p><hr/>
|
||
<p class="randnummer-placeholder-value randnummer" id="121.5" title="Abstimmung">RN/121.5</p>
|
||
<p>Mit „Ja“ stimmten die Abgeordneten: Falsch</p>
|
||
"""
|
||
|
||
|
||
def test_roll_call_lists():
|
||
result = parse_roll_call(ROLL_CALL_HTML, "121.4")
|
||
assert [label for label, _ in result["for"]] == [
|
||
"Auer", "Auinger-Oberzaucher", "Eder", "@@20056@@", "Elian", "von Künsberg Sarre"]
|
||
assert (("@@20056@@", 20056) in result["for"])
|
||
assert [label for label, _ in result["against"]] == ["Belakowitsch", "@@2996@@", "Kickl", "Zanger"]
|
||
|
||
|
||
def test_roll_call_result_in_following_paragraph():
|
||
html = ('<p id="226" class="randnummer" title="Ergebnis">RN/226</p><p>Ich gebe das Abstimmungsergebnis bekannt.</p>'
|
||
'<p class="randnummer" id="226.1">RN/226.1</p><p>Mit „Ja“ stimmten die Abgeordneten: Aldali, Bures.</p>'
|
||
'<p>Mit „Nein“ stimmten die Abgeordneten: Kickl.</p><hr/>')
|
||
assert parse_roll_call(html, "226") == {"for": [("Aldali", None), ("Bures", None)], "against": [("Kickl", None)]}
|
||
other = ('<p id="300" class="randnummer">RN/300</p><p>Wir gelangen nun zur Abstimmung über den Antrag.</p>'
|
||
'<p class="randnummer" id="300.1">RN/300.1</p><p>Mit „Ja“ stimmten die Abgeordneten: X.</p>')
|
||
assert parse_roll_call(other, "300") == {}
|
||
|
||
|
||
def test_roll_call_does_not_read_into_next_vote():
|
||
assert parse_roll_call(ROLL_CALL_HTML, "121.5") == {"for": [("Falsch", None)], "against": []}
|
||
assert parse_roll_call(ROLL_CALL_HTML, "999") == {}
|
||
|
||
|
||
IMMUNITY_REPORT = (
|
||
"Bericht des Immunitätsausschusses über das Ersuchen der Staatsanwaltschaft Wien, do. GZ. 506 St 62/26t - 1.3, "
|
||
"um Zustimmung zur behördlichen Verfolgung des Abgeordneten zum Nationalrat Klubobmann Herbert Kickl gemäß "
|
||
"Art. 57 Abs. 3 B-VG Die Staatsanwaltschaft Wien ersucht ... wegen des Verdachtes einer strafbaren Handlung nach "
|
||
"§ 283 Abs. 1 Z 1 und Z 2, Abs. 2 StGB. Der Immunitätsausschuss ... Als Ergebnis seiner Beratungen stellt der "
|
||
"Immunitätsausschuss somit den Antrag, der Nationalrat wolle beschließen: ... wird im Sinne des Art. 57 Abs. 3 B‑VG "
|
||
"festgestellt, dass ein Zusammenhang zwischen der inkriminierten Handlung und der politischen Tätigkeit ... besteht; "
|
||
"einer behördlichen Verfolgung des Abgeordneten zum Nationalrat Klubobmann Herbert Kickl wird nicht zugestimmt ."
|
||
)
|
||
|
||
|
||
def test_immunity_report():
|
||
r = parse_immunity_report(IMMUNITY_REPORT)
|
||
assert r["authority"] == "Staatsanwaltschaft Wien"
|
||
assert r["case_ref"] == "506 St 62/26t - 1.3"
|
||
assert r["offense"].startswith("§ 283") and "Verhetzung" in r["offense"]
|
||
assert r["recommendation"] == "refuse"
|
||
assert r["political_connection"] is True
|
||
|
||
|
||
def test_immunity_consent():
|
||
r = parse_immunity_report("Ersuchen des Landesgerichts für Strafsachen Graz, GZ 1 Hv 2/26, ... wegen § 111 StGB. "
|
||
"... kein Zusammenhang ... einer behördlichen Verfolgung wird zugestimmt .")
|
||
assert r["recommendation"] == "consent"
|
||
assert r["political_connection"] is False
|
||
assert "Üble Nachrede" in r["offense"]
|
||
|
||
|
||
def test_detect_trip_and_meeting():
|
||
trip = detect("Bundeskanzler Stocker beim Berlin-Prozess-Gipfel in Montenegro")
|
||
assert trip == [{"kind": "foreign_trip", "claim_type": "travel", "place": "Montenegro", "country": "ME"}]
|
||
meeting = detect("Nationalratspräsident Rosenkranz empfängt tschechischen Außenminister Petr Macinka im Parlament")
|
||
assert meeting[0]["kind"] == "meeting"
|
||
assert "Außenminister Petr Macinka" in meeting[0]["counterpart"]
|
||
|
||
|
||
def test_detect_ignores_non_events():
|
||
assert detect("Regierung traf eine Entscheidung zum Budget") == []
|
||
assert detect("Nationalrat: Hitzige Diskussion um Selbstbehalte bei Arztbesuchen") == []
|
||
assert detect("Landeshauptmann besucht Spital in Graz") == [] # domestic, not a trip abroad
|
||
|
||
|
||
def test_legal_classification():
|
||
assert classify("WKStA ermittelt gegen Ex-Minister") == "investigation"
|
||
assert classify("Ex-Politiker nicht rechtskräftig verurteilt") == "convicted_not_final"
|
||
assert classify("Freispruch für Bürgermeister") == "acquitted"
|
||
assert classify("Anklage gegen Landesrat eingebracht") == "indicted"
|
||
assert classify("Budget: Regierung einigt sich") is None
|
||
|
||
|
||
def test_office_title_surname():
|
||
names = [m.group("surname") for m in OFFICE_TITLE.finditer(
|
||
"Bundeskanzler Stocker und Europaministerin Bauer trafen Landeshauptfrau Mikl-Leitner")]
|
||
assert names == ["Stocker", "Bauer", "Mikl-Leitner"]
|
||
# A first name after the title must not be taken for a surname.
|
||
assert [m.group("surname") for m in OFFICE_TITLE.finditer("Nationalratspräsident Walter Rosenkranz")] == []
|