Files
politics-tracking/backend/politrack/enrich/maintenance.py
T
Simon DiesenreiterandClaude Opus 5.5 9da5a1dc48 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 19:47:14 +01:00

82 lines
4.1 KiB
Python

"""Housekeeping enrichers that work directly on canonical rows (no new facts, only derived structure).
* enrich.roles - give open-ended offices the party of the holder's party membership at that time
(Wikidata offices carry no party), close open roles at the holder's date of death
* enrich.rollcall - derive each faction's majority position from individual roll-call votes, so party
pages show roll calls too ("ÖVP mostly for: ... 70 yes, 2 no")
"""
from __future__ import annotations
from collections import Counter, defaultdict
from collections.abc import Iterator
from politrack.core import Enricher, RunContext, register
from politrack.core.records import Claim, Evidence, OrgRef, VotePosition
from politrack.core.spec import DERIVED
@register
class RoleMaintenance(Enricher):
spec = DERIVED
name = "enrich.roles"
description = "Assign parties to offices from party memberships; close roles at death"
schedule = "50 5 * * *"
def run(self, ctx: RunContext) -> None:
conn = ctx.conn
ctx.stats["roles_with_party"] = conn.execute(
"""UPDATE role r SET party_id = pm.party_id
FROM role pm JOIN claim c ON c.id = pm.claim_id
WHERE r.party_id IS NULL AND r.level <> 'party' AND pm.role = 'party_member' AND c.merged_into IS NULL
AND pm.person_id = r.person_id
AND (pm.start_date IS NULL OR r.end_date IS NULL OR pm.start_date <= r.end_date)
AND (pm.end_date IS NULL OR r.start_date IS NULL OR pm.end_date >= r.start_date)"""
).rowcount
ctx.stats["roles_closed_at_death"] = conn.execute(
"""UPDATE role r SET end_date = p.death_date FROM person p
WHERE p.id = r.person_id AND r.end_date IS NULL AND p.death_date IS NOT NULL"""
).rowcount
return None
@register
class RollCallFactions(Enricher):
spec = DERIVED
name = "enrich.rollcall"
description = "Faction majority positions derived from individual roll-call votes"
schedule = "55 4 * * *"
def run(self, ctx: RunContext) -> Iterator[Claim]:
rows = ctx.conn.execute(
"""SELECT ve.event_key, ve.item_title, ve.voted_on, ve.url, ch.slug AS chamber, vp.party_id, vp.position,
o.short_name, o.name
FROM vote_event ve JOIN vote_position vp ON vp.vote_event_id = ve.id
JOIN organization ch ON ch.id = ve.chamber_id
JOIN organization o ON o.id = vp.party_id
JOIN claim c ON c.id = vp.claim_id AND c.merged_into IS NULL
WHERE ve.roll_call AND vp.person_id IS NOT NULL"""
).fetchall()
tally: dict[tuple, Counter] = defaultdict(Counter)
info: dict[tuple, dict] = {}
for r in rows:
key = (r["event_key"], r["party_id"])
tally[key][r["position"]] += 1
info[key] = r
for key, counts in tally.items():
r = info[key]
position, _ = counts.most_common(1)[0]
party = r["short_name"] or r["name"]
yield Claim(
claim_type="vote", org=OrgRef(ids={"politrack_org": str(r["party_id"])}, create=False),
counterpart_org=OrgRef(slug=r["chamber"]), occurred_at=r["voted_on"],
summary_de=f"{party} stimmte mehrheitlich {'für' if position == 'for' else 'gegen'}: {r['item_title']} "
f"(namentliche Abstimmung, {counts['for']} Ja, {counts['against']} Nein)",
summary_en=f"{party} mostly voted {'for' if position == 'for' else 'against'}: {r['item_title']} "
f"(roll call, {counts['for']} yes / {counts['against']} no)",
data={"position": position, "roll_call": True, "yes": counts["for"], "no": counts["against"]},
evidence=[Evidence(url=r["url"], source="parlament", doc_type="api_record", title=r["item_title"],
published_at=r["voted_on"], locator=f"rollcall:{r['voted_on']}")],
facet=VotePosition(event_key=r["event_key"], position=position),
)