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

147 lines
7.7 KiB
Python

"""Persons: lists, profiles, former politicians, connections (network)."""
from __future__ import annotations
from psycopg import Connection
def list_persons(conn: Connection, *, q: str | None = None, party: str | None = None, level: str | None = None,
status: str | None = "active", limit: int = 50, offset: int = 0) -> list[dict]:
"""status: active | former | party_member | deceased | None (all)."""
return conn.execute(
"""
SELECT p.slug, p.full_name, p.photo_url, ps.status, ps.verified AS status_verified, ps.last_office_end,
ps.current_roles,
(SELECT jsonb_agg(DISTINCT jsonb_build_object('slug', o.slug, 'short_name', o.short_name, 'color', o.color))
FROM organization o WHERE o.id = ANY(ps.current_party_ids)) AS parties
FROM person p
JOIN person_status ps ON ps.person_id = p.id
WHERE (%(status)s::text IS NULL OR ps.status = %(status)s)
AND (%(q)s::text IS NULL OR p.full_name ILIKE '%%' || %(q)s || '%%'
OR EXISTS (SELECT 1 FROM alias a WHERE a.entity_type = 'person' AND a.entity_id = p.id
AND a.alias_norm LIKE '%%' || lower(unaccent(%(q)s)) || '%%'))
AND (%(party)s::text IS NULL OR EXISTS (
SELECT 1 FROM role r JOIN organization o ON o.id = r.party_id
WHERE r.person_id = p.id AND o.slug = %(party)s
AND (ps.status <> 'active' OR o.id = ANY(ps.current_party_ids))))
AND (%(level)s::text IS NULL OR EXISTS (
SELECT 1 FROM role r WHERE r.person_id = p.id AND r.level = %(level)s
AND (ps.status <> 'active' OR r.role = ANY(ps.current_roles))))
AND EXISTS (SELECT 1 FROM role r WHERE r.person_id = p.id)
ORDER BY p.family_name NULLS LAST, p.full_name
LIMIT %(limit)s OFFSET %(offset)s
""",
{"q": q, "party": party, "level": level, "status": status, "limit": limit, "offset": offset},
).fetchall()
def get_person(conn: Connection, slug: str) -> dict | None:
person = conn.execute(
"""
SELECT p.id, p.slug, p.full_name, p.given_name, p.family_name, p.honorific_prefix, p.birth_date, p.death_date,
p.photo_url, pi.wikidata_qid, pi.parlament_pad, pi.ep_id, p.summary_de, p.summary_en, ps.status,
ps.verified AS status_verified, ps.last_office_end
FROM person p JOIN person_status ps ON ps.person_id = p.id JOIN person_identifier pi ON pi.person_id = p.id
WHERE p.slug = %s
""",
(slug,),
).fetchone()
if not person:
return None
person["roles"] = conn.execute(
"""
SELECT r.role, r.level, r.region, r.start_date, r.end_date, o.name AS body, o.slug AS body_slug,
pa.slug AS party_slug, pa.short_name AS party_short, pa.color AS party_color, c.status AS claim_status,
(SELECT jsonb_agg(DISTINCT jsonb_build_object('url', d.url, 'source', s.slug))
FROM claim_evidence e JOIN document d ON d.id = e.document_id JOIN source s ON s.id = d.source_id
WHERE e.claim_id = r.claim_id) AS evidence
FROM role r
JOIN claim c ON c.id = r.claim_id AND c.merged_into IS NULL
LEFT JOIN organization o ON o.id = r.organization_id
LEFT JOIN organization pa ON pa.id = r.party_id
WHERE r.person_id = %s
ORDER BY r.end_date DESC NULLS FIRST, r.start_date DESC NULLS LAST
""",
(person["id"],),
).fetchall()
person["social_accounts"] = conn.execute(
"SELECT platform, handle, url, verified FROM social_account WHERE person_id = %s ORDER BY verified DESC, platform",
(person["id"],),
).fetchall()
person["claim_counts"] = {
r["claim_type"]: r["count"]
for r in conn.execute(
"SELECT claim_type, count(*) AS count FROM claim WHERE person_id = %s AND merged_into IS NULL "
"AND review_status = 'published' GROUP BY claim_type",
(person["id"],),
)
}
return person
def former_overview(conn: Connection, *, party: str | None = None, limit: int = 50, offset: int = 0) -> list[dict]:
"""Former politicians, most recently in the news first ("Was macht eigentlich ...?")."""
return conn.execute(
"""
SELECT p.slug, p.full_name, p.photo_url, ps.status, ps.last_office_end,
(SELECT r.role FROM role r JOIN claim c ON c.id = r.claim_id AND c.merged_into IS NULL
WHERE r.person_id = p.id AND r.level <> 'party' ORDER BY r.end_date DESC NULLS LAST LIMIT 1) AS last_role,
latest.summary_de AS latest_news, latest.occurred_at AS latest_news_at, latest.url AS latest_news_url,
career.summary_de AS latest_career
FROM person p
JOIN person_status ps ON ps.person_id = p.id
LEFT JOIN LATERAL (
SELECT c.summary_de, c.occurred_at, d.url FROM claim c
JOIN claim_evidence e ON e.claim_id = c.id JOIN document d ON d.id = e.document_id
WHERE c.person_id = p.id AND c.claim_type IN ('media_mention', 'statement') AND c.merged_into IS NULL
ORDER BY c.occurred_at DESC NULLS LAST LIMIT 1) latest ON true
LEFT JOIN LATERAL (
SELECT c.summary_de FROM claim c WHERE c.person_id = p.id AND c.claim_type = 'career'
AND c.merged_into IS NULL ORDER BY c.valid_from DESC NULLS LAST LIMIT 1) career ON true
WHERE ps.status IN ('former', 'party_member')
AND (%(party)s::text IS NULL OR EXISTS (
SELECT 1 FROM role r JOIN organization o ON o.id = r.party_id
WHERE r.person_id = p.id AND o.slug = %(party)s))
AND (latest.occurred_at IS NOT NULL OR career.summary_de IS NOT NULL)
ORDER BY latest.occurred_at DESC NULLS LAST, ps.last_office_end DESC NULLS LAST
LIMIT %(limit)s OFFSET %(offset)s
""",
{"party": party, "limit": limit, "offset": offset},
).fetchall()
def person_network(conn: Connection, slug: str) -> dict:
"""Organisations connected to a person and what flows through them.
Connections: employers/boards from side-job disclosures and careers, parties, orgs met (meetings).
For each organisation: donations it made, contracts it won, ads/funding it received or paid.
"""
person = conn.execute("SELECT id FROM person WHERE slug = %s", (slug,)).fetchone()
if not person:
return {"organizations": []}
orgs = conn.execute(
"""
WITH links AS (
SELECT c.counterpart_org_id AS org_id, c.claim_type AS via, max(c.summary_de) AS label
FROM claim c
WHERE c.person_id = %(pid)s AND c.counterpart_org_id IS NOT NULL AND c.merged_into IS NULL
AND c.review_status = 'published'
AND c.claim_type IN ('side_job', 'career', 'meeting', 'role', 'lobbying')
GROUP BY 1, 2
UNION
SELECT r.party_id, 'party', NULL FROM role r WHERE r.person_id = %(pid)s AND r.party_id IS NOT NULL
)
SELECT o.id, o.slug, o.name, o.kind, array_agg(DISTINCT l.via) AS via, max(l.label) AS label,
(SELECT jsonb_object_agg(k, v) FROM (
SELECT mf.kind || CASE WHEN mf.payer_org_id = o.id THEN '_paid' ELSE '_received' END AS k,
jsonb_build_object('total', sum(mf.amount), 'count', count(*)) AS v
FROM money_flow mf JOIN claim mc ON mc.id = mf.claim_id AND mc.merged_into IS NULL
WHERE mf.payer_org_id = o.id OR mf.payee_org_id = o.id GROUP BY 1) x) AS money
FROM links l JOIN organization o ON o.id = l.org_id
GROUP BY o.id
ORDER BY (o.kind = 'party') DESC, o.name
""",
{"pid": person["id"]},
).fetchall()
return {"organizations": orgs}