Files
simonandClaude Opus 5.5 583ccba31c Politrack: track Austrian politicians from public sources
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>
2026-10-28 20:02:01 +01:00

99 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")] == []