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

158 lines
8.2 KiB
Python

"""Claims (the generic fact list), timeline histogram and calendar."""
from __future__ import annotations
from datetime import date
from psycopg import Connection
from politrack.queries.common import VISIBLE, SourceFilter
def list_claims(
conn: Connection,
*,
sources: SourceFilter,
person: str | None = None,
party: str | None = None,
organization: str | None = None,
claim_types: list[str] | None = None,
topics: list[str] | None = None,
since: date | None = None,
until: date | None = None,
q: str | None = None,
include_pending: bool = False,
limit: int = 50,
offset: int = 0,
) -> list[dict]:
source_sql, params = sources.sql("s")
params.update({
"person": person, "party": party, "organization": organization, "claim_types": claim_types, "topics": topics, "since": since,
"until": until, "q": q, "limit": limit, "offset": offset, "include_pending": include_pending,
})
return conn.execute(
f"""
SELECT c.id, c.claim_type, c.summary_de, c.summary_en, c.occurred_at, c.valid_from, c.valid_to, c.status,
c.sensitive, c.review_status, c.data,
p.slug AS person_slug, p.full_name AS person_name,
o.slug AS organization_slug, o.short_name AS organization_short, o.name AS organization_name,
lp.stage AS legal_stage, lp.offense AS legal_offense, lp.authority AS legal_authority,
cp.slug AS counterpart_slug, cp.name AS counterpart_name, cp.kind AS counterpart_kind,
cpp.slug AS counterpart_person_slug, cpp.full_name AS counterpart_person_name,
to_jsonb(mf) - 'claim_id' AS money, st.platform AS platform,
(SELECT jsonb_agg(jsonb_build_object('slug', t.slug, 'name_de', t.name_de, 'name_en', t.name_en))
FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id WHERE ta.claim_id = c.id) AS topics,
ev.evidence, ev.source_count
FROM claim c
LEFT JOIN person p ON p.id = c.person_id
LEFT JOIN organization o ON o.id = c.organization_id
LEFT JOIN legal_proceeding lp ON lp.claim_id = c.id
LEFT JOIN organization cp ON cp.id = c.counterpart_org_id
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
LEFT JOIN money_flow mf ON mf.claim_id = c.id
LEFT JOIN statement st ON st.claim_id = c.id
CROSS JOIN LATERAL (
SELECT jsonb_agg(jsonb_build_object(
'url', d.url, 'title', d.title, 'source', s.slug, 'source_name', s.name, 'source_kind', s.kind,
'published_at', d.published_at, 'quote', e.quote, 'stance', e.stance, 'archive_url', d.archive_url)
ORDER BY s.default_trust DESC, d.published_at) AS evidence,
count(DISTINCT s.id) AS source_count
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 = c.id AND {source_sql}
) ev
WHERE ev.evidence IS NOT NULL AND {VISIBLE}
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
AND (%(party)s::text IS NULL OR o.slug = %(party)s
OR c.counterpart_org_id = (SELECT id FROM organization WHERE slug = %(party)s))
AND (%(organization)s::text IS NULL OR o.slug = %(organization)s
OR c.counterpart_org_id = (SELECT id FROM organization WHERE slug = %(organization)s))
AND (%(claim_types)s::text[] IS NULL OR c.claim_type = ANY(%(claim_types)s))
AND (%(topics)s::text[] IS NULL OR c.claim_type IN ('role', 'career') OR EXISTS (
SELECT 1 FROM topic_assignment ta JOIN topic t ON t.id = ta.topic_id
WHERE ta.claim_id = c.id AND t.slug = ANY(%(topics)s)))
AND (%(since)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_to, c.valid_from, current_date) >= %(since)s)
AND (%(until)s::date IS NULL OR COALESCE(c.occurred_at::date, c.valid_from) <= %(until)s)
AND (%(q)s::text IS NULL OR c.summary_de ILIKE '%%' || %(q)s || '%%')
ORDER BY COALESCE(c.occurred_at, c.valid_from::timestamptz, c.created_at) DESC, c.id DESC
LIMIT %(limit)s OFFSET %(offset)s
""",
params,
).fetchall()
def timeline_histogram(conn: Connection, *, sources: SourceFilter, person: str | None = None,
party: str | None = None) -> list[dict]:
"""Claim counts per month and type - drives the timeline overview."""
source_sql, params = sources.sql("s")
params.update({"person": person, "party": party, "include_pending": False})
return conn.execute(
f"""
SELECT to_char(date_trunc('month', COALESCE(c.occurred_at, c.valid_from::timestamptz)), 'YYYY-MM') AS month,
c.claim_type, count(DISTINCT c.id) AS count
FROM claim c
LEFT JOIN person p ON p.id = c.person_id
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
LEFT JOIN organization o ON o.id = c.organization_id
JOIN claim_evidence e ON e.claim_id = c.id JOIN document d ON d.id = e.document_id
JOIN source s ON s.id = d.source_id
WHERE {VISIBLE} AND {source_sql} AND COALESCE(c.occurred_at, c.valid_from::timestamptz) IS NOT NULL
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
AND (%(party)s::text IS NULL OR o.slug = %(party)s)
AND c.claim_type <> 'role'
GROUP BY 1, 2 ORDER BY 1
""",
params,
).fetchall()
def calendar(conn: Connection, *, sources: SourceFilter, start: date, end: date, person: str | None = None,
party: str | None = None, kinds: list[str] | None = None) -> dict:
"""Events (sessions, trips, meetings, appearances) plus per-day claim counts in [start, end]."""
source_sql, params = sources.sql("s")
params.update({"start": start, "end": end, "person": person, "party": party, "kinds": kinds,
"include_pending": False})
events = conn.execute(
"""
SELECT ev.id, ev.kind, ev.title, ev.starts_at, ev.ends_at, ev.location_name, ev.country, ev.url,
ev.description, o.name AS body,
COALESCE(jsonb_agg(DISTINCT jsonb_build_object('slug', p.slug, 'name', p.full_name))
FILTER (WHERE p.id IS NOT NULL), '[]'::jsonb) AS participants
FROM event ev
LEFT JOIN organization o ON o.id = ev.organization_id
LEFT JOIN event_participant ep ON ep.event_id = ev.id
LEFT JOIN claim c ON c.id = ep.claim_id
LEFT JOIN person p ON p.id = ep.person_id AND c.merged_into IS NULL
WHERE ev.starts_at::date BETWEEN %(start)s AND %(end)s
AND (%(kinds)s::text[] IS NULL OR ev.kind = ANY(%(kinds)s))
AND (%(person)s::text IS NULL OR EXISTS (
SELECT 1 FROM event_participant ep2 JOIN person p2 ON p2.id = ep2.person_id
JOIN claim c2 ON c2.id = ep2.claim_id AND c2.merged_into IS NULL
WHERE ep2.event_id = ev.id AND p2.slug = %(person)s))
AND (%(party)s::text IS NULL OR EXISTS (
SELECT 1 FROM event_participant ep3 JOIN person_status ps3 ON ps3.person_id = ep3.person_id
JOIN organization o3 ON o3.id = ANY(ps3.current_party_ids)
WHERE ep3.event_id = ev.id AND o3.slug = %(party)s))
GROUP BY ev.id, o.name
ORDER BY ev.starts_at
LIMIT 2000
""",
params,
).fetchall()
days = conn.execute(
f"""
SELECT c.occurred_at::date AS day, c.claim_type, count(DISTINCT c.id) AS count
FROM claim c
LEFT JOIN person p ON p.id = c.person_id
LEFT JOIN person cpp ON cpp.id = c.counterpart_person_id
LEFT JOIN organization o ON o.id = c.organization_id
JOIN claim_evidence e ON e.claim_id = c.id JOIN document d ON d.id = e.document_id
JOIN source s ON s.id = d.source_id
WHERE {VISIBLE} AND {source_sql} AND c.occurred_at::date BETWEEN %(start)s AND %(end)s
AND (%(person)s::text IS NULL OR p.slug = %(person)s OR cpp.slug = %(person)s)
AND (%(party)s::text IS NULL OR o.slug = %(party)s)
GROUP BY 1, 2 ORDER BY 1
""",
params,
).fetchall()
return {"events": events, "days": days}