Files
politics-tracking/backend/politrack/queries/votes.py
T
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

128 lines
7.0 KiB
Python

"""Votes: per person (individual roll calls + faction votes during mandates, with "say vs. vote"), lists, details."""
from __future__ import annotations
from datetime import date
from psycopg import Connection
# Did the person speak on this item on the day of the vote? Speech titles are the item titles of the
# parliament's speech list; vote_event titles come from the item page - similar, not identical.
SPOKE = """
(SELECT jsonb_build_object('url', st.url, 'title', st.title) FROM statement st
JOIN claim sc ON sc.id = st.claim_id AND sc.merged_into IS NULL
WHERE st.person_id = me.id AND st.context = 'parliament_speech' AND st.said_at::date = ve.voted_on
AND similarity(st.title, ve.item_title) > 0.35
ORDER BY similarity(st.title, ve.item_title) DESC LIMIT 1)
"""
def person_votes(conn: Connection, slug: str, *, topics: list[str] | None = None, limit: int = 100,
offset: int = 0) -> list[dict]:
"""Votes concerning a person.
* individual=true: roll-call votes with the person's own vote; dissent=true when it differs from the
majority of their faction (vote_position.party_id = their faction that day).
* individual=false: faction votes cast while the person held a mandate for that faction.
* spoke: the person's speech on that item on the same day ("say vs. vote"), if any.
"""
return conn.execute(
f"""
WITH me AS (SELECT id FROM person WHERE slug = %(slug)s)
SELECT ve.id, ve.item_title, ve.item_ref, ve.stage, ve.voted_on, ve.result, ve.url, ve.protocol_url,
ve.roll_call, vp.position, fo.short_name AS faction, ch.name AS chamber, true AS individual,
(fp.position IS NOT NULL AND fp.position <> vp.position) AS dissent, {SPOKE} AS spoke,
(SELECT jsonb_agg(DISTINCT t.slug) FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
WHERE ta.claim_id = vp.claim_id) AS topics
FROM vote_position vp
JOIN me ON me.id = vp.person_id
JOIN claim c ON c.id = vp.claim_id AND c.merged_into IS NULL
JOIN vote_event ve ON ve.id = vp.vote_event_id
JOIN organization ch ON ch.id = ve.chamber_id
LEFT JOIN organization fo ON fo.id = vp.party_id
LEFT JOIN vote_position fp ON fp.vote_event_id = ve.id AND fp.organization_id = vp.party_id
WHERE (%(topics)s::text[] IS NULL OR EXISTS (
SELECT 1 FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
WHERE ta.claim_id = vp.claim_id AND t.slug = ANY(%(topics)s)))
UNION ALL
SELECT ve.id, ve.item_title, ve.item_ref, ve.stage, ve.voted_on, ve.result, ve.url, ve.protocol_url,
ve.roll_call, vp.position, o.short_name, ch.name, false, false, {SPOKE},
(SELECT jsonb_agg(DISTINCT t.slug) FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
WHERE ta.claim_id = vp.claim_id)
FROM me
JOIN role r ON r.person_id = me.id AND r.role = 'mp'
JOIN claim rc ON rc.id = r.claim_id AND rc.merged_into IS NULL
JOIN vote_event ve ON ve.chamber_id = r.organization_id AND NOT ve.roll_call
AND ve.voted_on BETWEEN r.start_date AND COALESCE(r.end_date, 'infinity'::date)
JOIN vote_position vp ON vp.vote_event_id = ve.id AND vp.organization_id = r.party_id
JOIN organization o ON o.id = vp.organization_id
JOIN organization ch ON ch.id = ve.chamber_id
WHERE (%(topics)s::text[] IS NULL OR EXISTS (
SELECT 1 FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
WHERE ta.claim_id = vp.claim_id AND t.slug = ANY(%(topics)s)))
ORDER BY voted_on DESC NULLS LAST
LIMIT %(limit)s OFFSET %(offset)s
""",
{"slug": slug, "topics": topics, "limit": limit, "offset": offset},
).fetchall()
def list_votes(conn: Connection, *, party: str | None = None, topics: list[str] | None = None,
roll_call: bool | None = None, since: date | None = None, until: date | None = None,
limit: int = 50, offset: int = 0) -> list[dict]:
return conn.execute(
"""
SELECT ve.id, ve.item_title, ve.item_ref, ve.stage, ve.voted_on, ve.result, ve.url, ve.protocol_url,
ve.raw_text, ve.roll_call, ch.name AS chamber,
COALESCE(jsonb_agg(jsonb_build_object('party', o.short_name, 'slug', o.slug, 'color', o.color,
'position', vp.position) ORDER BY o.short_name)
FILTER (WHERE o.id IS NOT NULL), '[]'::jsonb) AS positions,
(SELECT count(*) FROM vote_position x WHERE x.vote_event_id = ve.id AND x.person_id IS NOT NULL)
AS individual_votes
FROM vote_event ve
JOIN organization ch ON ch.id = ve.chamber_id
LEFT JOIN vote_position vp ON vp.vote_event_id = ve.id AND vp.organization_id IS NOT NULL
LEFT JOIN organization o ON o.id = vp.organization_id
WHERE (%(party)s::text IS NULL OR EXISTS (
SELECT 1 FROM vote_position v2 JOIN organization o2 ON o2.id = v2.organization_id
WHERE v2.vote_event_id = ve.id AND o2.slug = %(party)s))
AND (%(topics)s::text[] IS NULL OR EXISTS (
SELECT 1 FROM vote_position v3 JOIN topic_assignment ta ON ta.claim_id = v3.claim_id
JOIN topic t ON t.id = ta.topic_id WHERE v3.vote_event_id = ve.id AND t.slug = ANY(%(topics)s)))
AND (%(roll_call)s::boolean IS NULL OR ve.roll_call = %(roll_call)s)
AND (%(since)s::date IS NULL OR ve.voted_on >= %(since)s)
AND (%(until)s::date IS NULL OR ve.voted_on <= %(until)s)
GROUP BY ve.id, ch.name
ORDER BY ve.voted_on DESC NULLS LAST, ve.id DESC
LIMIT %(limit)s OFFSET %(offset)s
""",
{"party": party, "topics": topics, "roll_call": roll_call, "since": since, "until": until,
"limit": limit, "offset": offset},
).fetchall()
def vote_detail(conn: Connection, vote_id: int) -> dict | None:
vote = conn.execute(
"SELECT ve.*, ch.name AS chamber FROM vote_event ve JOIN organization ch ON ch.id = ve.chamber_id WHERE ve.id = %s",
(vote_id,),
).fetchone()
if not vote:
return None
vote["positions"] = conn.execute(
"""SELECT o.short_name AS party, o.slug, o.color, vp.position FROM vote_position vp
JOIN organization o ON o.id = vp.organization_id WHERE vp.vote_event_id = %s ORDER BY o.short_name""",
(vote_id,),
).fetchall()
vote["individual"] = conn.execute(
"""
SELECT p.slug, p.full_name, vp.position, o.short_name AS faction,
(fp.position IS NOT NULL AND fp.position <> vp.position) AS dissent
FROM vote_position vp JOIN person p ON p.id = vp.person_id
LEFT JOIN organization o ON o.id = vp.party_id
LEFT JOIN vote_position fp ON fp.vote_event_id = vp.vote_event_id AND fp.organization_id = vp.party_id
WHERE vp.vote_event_id = %s ORDER BY o.short_name, p.family_name
""",
(vote_id,),
).fetchall()
return vote