Design concept and design system in docs/design/: principles (evidence
first, neutral, fair to the people shown, plain language, time as a
dimension, accessible and private), information architecture, core
patterns and a concept for every page; tokens with checked contrast,
component catalogue, content and accessibility rules.
web:
- neutral ink-indigo accent instead of flag red; party colours only as
small markers next to the name; light/dark theme with a switcher
- stylesheets split into tokens, base, layout, components, print
- new header (search, filter count, language, theme, mobile menu) and
footer; breadcrumbs, page headers, tabs with counts, filter bars that
collapse on phones, pagination, notices and empty states
- claim cards with source meter, legal stage, innocence notice inside
the card and a permalink; claim pages with citation (/angaben/{id})
- profiles with role timeline and key figures; party and organisation
dashboards; vote list with position chips; calendar agenda on phones;
tables that stack into cards; loading and error pages; favicon
backend:
- GET /api/v1/claims/{id} and MCP tool get_claim for permalinks
- parties list: evaluate person_status once instead of per party
(30 s -> 1 s on the sample database, identical result)
- parliament calendar: HTML-decode titles ("84. Sitzung")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
6.7 KiB
Python
161 lines
6.7 KiB
Python
from datetime import date
|
||
from decimal import Decimal
|
||
|
||
from politrack.enrich.embed import split_text
|
||
from politrack.enrich.linker import detect_context, tokens
|
||
from politrack.enrich.topics import keyword_pattern
|
||
from politrack.sources.parlament.common import parse_date, parse_vote_text
|
||
from politrack.sources.rechnungshof import (
|
||
clean_name,
|
||
decode,
|
||
find_columns,
|
||
parse_amount,
|
||
parse_de_date,
|
||
read_rows,
|
||
)
|
||
from politrack.sources.wikidata import classify_level
|
||
from politrack.text import fingerprint, normalize, strip_titles
|
||
|
||
|
||
def test_vote_text_simple():
|
||
v = parse_vote_text("137. Sitzung: Gesetzesvorschlag in dritter Lesung angenommen Dafür: ÖVP, SPÖ, GRÜNE, "
|
||
"dagegen: FPÖ, NEOS")
|
||
assert v["result"] == "accepted"
|
||
assert v["for"] == ["ÖVP", "SPÖ", "GRÜNE"]
|
||
assert v["against"] == ["FPÖ", "NEOS"]
|
||
assert v["stage"] == "137. Sitzung: Gesetzesvorschlag in dritter Lesung"
|
||
|
||
|
||
def test_vote_text_rejected_motion():
|
||
v = parse_vote_text("50. Sitzung: Entschließungsantrag abgelehnt Dafür: FPÖ, dagegen: ÖVP, SPÖ, NEOS, GRÜNE")
|
||
assert v["result"] == "rejected"
|
||
assert v["for"] == ["FPÖ"]
|
||
|
||
|
||
def test_vote_text_split_vote_is_not_attributed():
|
||
v = parse_vote_text("12. Sitzung: angenommen Dafür: V, S, N (teilweise G), dagegen: F")
|
||
assert v["split"] is True
|
||
assert v["for"] == [] and v["against"] == []
|
||
|
||
|
||
def test_vote_text_empty_against():
|
||
v = parse_vote_text("997. Sitzung: Antrag, keinen Einspruch zu erheben, angenommen Dafür: ÖVP, SPÖ, dagegen: -")
|
||
assert v["against"] == []
|
||
assert v["stage"].endswith("zu erheben")
|
||
|
||
|
||
def test_vote_text_without_result():
|
||
assert parse_vote_text("Zuweisung an den Ausschuss") is None
|
||
|
||
|
||
def test_parse_date_formats():
|
||
assert parse_date("16.12.2021") == date(2021, 12, 16)
|
||
assert parse_date("2026-10-14T12:00:00") == date(2026, 10, 14)
|
||
assert parse_date("") is None
|
||
|
||
|
||
def test_rechnungshof_header_after_title_row():
|
||
rows = [["Zu veröffentlichen", "", ""],
|
||
["Partei", "Meldedatum_an_RH", "Spendeneingangsdatum", "Name_der_Spenderin_des_Spenders",
|
||
"PLZ_der_Spenderin_des_Spenders", "Betrag", "Waehrung", "Empfaengerin_Empfaenger_der_Spende", "Anmerkungen"]]
|
||
start, idx = find_columns(rows)
|
||
assert start == 2
|
||
assert idx["date"] == 2 # Spendeneingangsdatum wins over Meldedatum
|
||
assert idx["donor"] == 3
|
||
assert idx["recipient"] == 7
|
||
assert 4 not in idx.values() # postcode is never mapped
|
||
|
||
|
||
def test_rechnungshof_headerless_file_skips_address():
|
||
text = "08.07.19;300.000,00;Dr. Hans Peter Haselsteiner;1220 Wien Donau-City-Straße 9;NEOS\n"
|
||
idx, rows = read_rows(text)
|
||
assert idx["date"] == 0 and idx["amount"] == 1 and idx["donor"] == 2 and idx["party"] == 4
|
||
assert 3 not in idx.values()
|
||
assert rows[0][2] == "Dr. Hans Peter Haselsteiner"
|
||
|
||
|
||
def test_rechnungshof_clean_name_drops_addresses():
|
||
assert clean_name("2522 Oberwaltersdorf Hofeldstraße 4;Team Stronach für Österreich") == "Team Stronach für Österreich"
|
||
assert clean_name("1220 Wien Donau-City-Straße 9") == ""
|
||
assert clean_name("NEOS – Das Neue Österreich") == "NEOS – Das Neue Österreich"
|
||
|
||
|
||
def test_rechnungshof_decoding():
|
||
assert decode("Österreich".encode("cp1252")) == "Österreich"
|
||
assert decode("Österreich".encode("mac_roman")) == "Österreich"
|
||
assert decode("Österreich".encode("utf-8")) == "Österreich"
|
||
|
||
|
||
def test_amount_and_date():
|
||
assert parse_amount("1.971.000,00") == Decimal("1971000.00")
|
||
assert parse_amount("790") == Decimal("790")
|
||
assert parse_amount("") is None
|
||
assert parse_de_date("26.08.24") == date(2024, 8, 26)
|
||
|
||
|
||
def test_text_helpers():
|
||
assert normalize(" Österreichische Volkspartei ") == "osterreichische volkspartei"
|
||
assert strip_titles("Mag. Dr. Selma Yildirim, BA") == "Selma Yildirim"
|
||
assert fingerprint("a", 1) == fingerprint("a", 1) != fingerprint("a", 2)
|
||
|
||
|
||
def test_linker_helpers():
|
||
assert tokens("Karl Nehammer (ÖVP) im ZIB 2-Interview") == ["karl", "nehammer", "ovp", "im", "zib", "2-interview"]
|
||
assert detect_context("Babler in der ZIB 2") == "tv"
|
||
assert detect_context("Gastkommentar: Warum wir mehr brauchen") == "op_ed"
|
||
assert detect_context("Budget beschlossen") is None
|
||
|
||
|
||
def test_keyword_pattern_short_words_are_whole_words():
|
||
pattern = keyword_pattern(["EU", "Klima"])
|
||
assert pattern.search("Die EU beschließt")
|
||
assert not pattern.search("Euro-Rettung")
|
||
assert pattern.search("Klimaschutzgesetz")
|
||
|
||
|
||
def test_classify_level():
|
||
assert classify_level("Mitglied des Tiroler Landtages") == "state"
|
||
assert classify_level("Bürgermeister von Graz") == "municipal"
|
||
assert classify_level("Abgeordneter zum Nationalrat") == "federal"
|
||
assert classify_level("Mitglied des Europäischen Parlaments") == "eu"
|
||
|
||
|
||
def test_split_text():
|
||
chunks = split_text("\n".join(["x" * 500] * 6), size=1200)
|
||
assert all(len(c) <= 1800 for c in chunks)
|
||
assert sum(len(c.replace("\n", "")) for c in chunks) == 3000
|
||
|
||
|
||
def test_employer_without_address():
|
||
from politrack.sources.parlament.persons import employer
|
||
|
||
assert employer("Hypo Tirol Bank AG, Meraner Straße 8, 6020 Innsbruck") == ("Hypo Tirol Bank AG", None)
|
||
# parliament's JSON glues name, address and register number together
|
||
assert employer("25 PersonaldienstleistungsgesmbHFranz Josef Kai 95020 Salzburg FN306527a") == (
|
||
"25 PersonaldienstleistungsgesmbH", "306527a")
|
||
assert employer("Salzburger Parkgaragen Gesellschaft m.b.H") == ("Salzburger Parkgaragen Gesellschaft m.b.H", None)
|
||
assert employer("Verein Musterhilfe Hauptstraße 12 1010 Wien")[0] == "Verein Musterhilfe"
|
||
|
||
|
||
def test_firmenbuch_number():
|
||
from politrack.text import firmenbuch_number
|
||
|
||
assert firmenbuch_number("FN 306527 a") == firmenbuch_number("FN306527A") == firmenbuch_number("306527a") == "306527a"
|
||
assert firmenbuch_number("ATU12345678") is None
|
||
|
||
|
||
def test_parlament_calendar_titles_are_html_decoded():
|
||
import json
|
||
|
||
from politrack.core.runner import make_response, parse_offline
|
||
from politrack.sources.parlament.news import ParlamentCalendar
|
||
|
||
body = {"header": [{"label": "Datum"}, {"label": "Terminart"}, {"label": "Bezeichnung"}, {"label": "Gremium"},
|
||
{"label": "Link"}, {"label": "WENTRY_ID"}],
|
||
"rows": [["2026-06-10T09:00:00", "Plenarsitzung<BR>NR", "84. Sitzung des Nationalrates", "Nationalrat",
|
||
"/termin/1", "1"]]}
|
||
events = parse_offline(ParlamentCalendar(), make_response("https://www.parlament.gv.at/x", json.dumps(body).encode(), kind="list"),
|
||
params={"since_year": 2026})
|
||
assert [e.title for e in events] == ["84. Sitzung des Nationalrates"]
|
||
assert events[0].description == "Plenarsitzung NR"
|