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>
158 lines
8.2 KiB
Python
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}
|