Files
politics-tracking/backend/politrack/api/schemas.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

536 lines
12 KiB
Python

"""Response models: the API contract. `politrack openapi --ts ../web/src/lib/api-types.ts` turns them into
TypeScript types for the web app, so frontend and backend cannot silently drift apart."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
ClaimType = Literal["role", "vote", "donation", "money_flow", "statement", "appearance", "meeting", "travel",
"side_job", "legal_proceeding", "lobbying", "media_mention", "career", "parliamentary_question",
"government_decision", "wiki_edit", "declaration", "ownership", "affiliation", "party_finance",
"other"]
PersonStatus = Literal["active", "former", "party_member", "deceased"]
class Model(BaseModel):
# Fields with defaults are always present in responses, so mark them required in the (serialization)
# schema - the generated TypeScript types then have no needless optional fields.
model_config = ConfigDict(from_attributes=True, json_schema_serialization_defaults_required=True)
class Stats(Model):
persons: int
active_persons: int
parties: int
claims: int
corroborated: int
pending_review: int
documents: int
vote_events: int
roll_call_votes: int
events: int
money_flows: int
last_update: datetime | None = None
class Source(Model):
slug: str
name: str
kind: str
homepage: str | None = None
license: str | None = None
access_method: str
default_trust: int
tos_notes: str | None = None
documents: int
class Topic(Model):
slug: str
name_de: str
name_en: str
class Evidence(Model):
url: str
title: str | None = None
source: str
source_name: str | None = None
source_kind: str | None = None
published_at: datetime | None = None
quote: str | None = None
stance: Literal["supports", "contradicts", "mentions"] = "supports"
archive_url: str | None = None
class MoneyFacet(Model):
kind: str
amount: float | None = None
amount_category: str | None = None
currency: str | None = None
paid_on: date | None = None
period: str | None = None
reference: str | None = None
category: str | None = None
payer_name: str | None = None
payee_name: str | None = None
class Claim(Model):
id: int
claim_type: ClaimType
summary_de: str
summary_en: str | None = None
occurred_at: datetime | None = None
valid_from: date | None = None
valid_to: date | None = None
status: Literal["reported", "corroborated", "disputed", "retracted"]
sensitive: bool
review_status: Literal["pending", "published", "rejected"]
data: dict[str, Any] = {}
person_slug: str | None = None
person_name: str | None = None
organization_slug: str | None = None
organization_short: str | None = None
organization_name: str | None = None
counterpart_slug: str | None = None
counterpart_name: str | None = None
counterpart_kind: str | None = None
counterpart_person_slug: str | None = None
counterpart_person_name: str | None = None
legal_stage: str | None = None
legal_offense: str | None = None
legal_authority: str | None = None
money: MoneyFacet | None = None
platform: str | None = None
topics: list[Topic] | None = None
evidence: list[Evidence]
source_count: int
class PartyRef(Model):
slug: str
short_name: str | None = None
color: str | None = None
class PersonListItem(Model):
slug: str
full_name: str
photo_url: str | None = None
status: PersonStatus
status_verified: bool
last_office_end: date | None = None
current_roles: list[str] | None = None
parties: list[PartyRef] | None = None
class RoleEvidence(Model):
url: str
source: str
class Role(Model):
role: str
level: str
region: str | None = None
start_date: date | None = None
end_date: date | None = None
body: str | None = None
body_slug: str | None = None
party_slug: str | None = None
party_short: str | None = None
party_color: str | None = None
claim_status: str
evidence: list[RoleEvidence] | None = None
class SocialAccountOut(Model):
platform: str
handle: str | None = None
url: str
verified: bool
class PersonDetail(Model):
slug: str
full_name: str
given_name: str | None = None
family_name: str | None = None
birth_date: date | None = None
death_date: date | None = None
photo_url: str | None = None
wikidata_qid: str | None = None
parlament_pad: str | None = None
ep_id: str | None = None
status: PersonStatus
status_verified: bool
last_office_end: date | None = None
roles: list[Role]
social_accounts: list[SocialAccountOut]
claim_counts: dict[str, int]
class FormerPerson(Model):
slug: str
full_name: str
photo_url: str | None = None
status: PersonStatus
last_office_end: date | None = None
last_role: str | None = None
latest_news: str | None = None
latest_news_at: datetime | None = None
latest_news_url: str | None = None
latest_career: str | None = None
class NetworkOrg(Model):
id: int
slug: str
name: str
kind: str
via: list[str]
label: str | None = None
money: dict[str, Any] | None = None
class Network(Model):
organizations: list[NetworkOrg]
class Party(Model):
slug: str
name: str
short_name: str | None = None
color: str | None = None
logo_url: str | None = None
active_officials: int = 0
total_people: int = 0
class PartyMember(Model):
slug: str
full_name: str
photo_url: str | None = None
role: str
level: str
region: str | None = None
body: str | None = None
class FormerMember(Model):
slug: str
full_name: str
last_office_end: date | None = None
last_mention: datetime | None = None
class MoneyByYear(Model):
year: int
kind: str
total: float | None = None
count: int
class Finance(Model):
data: dict[str, Any]
valid_from: date | None = None
class CountByPosition(Model):
position: str
count: int
class Dissenter(Model):
slug: str
full_name: str
dissenting_votes: int
class RelatedOrg(Model):
claim_type: str
slug: str
name: str
kind: str
class PartyDetail(Model):
slug: str
name: str
short_name: str | None = None
color: str | None = None
logo_url: str | None = None
kind: str
members: list[PartyMember]
former_members: list[FormerMember]
money_by_year: list[MoneyByYear]
finance: list[Finance]
vote_stats: list[CountByPosition]
dissent: list[Dissenter]
related: list[RelatedOrg]
class OrganizationListItem(Model):
slug: str
name: str
short_name: str | None = None
kind: str
paid: float | None = None
received: float | None = None
class OrgMoney(Model):
kind: str
direction: Literal["paid", "received"]
total: float | None = None
count: int
first: str | None = None
last: str | None = None
class Counterparty(Model):
slug: str
name: str
kind: str
money_kind: str
direction: Literal["paid_to", "received_from"]
total: float | None = None
count: int
class OrgPerson(Model):
slug: str
full_name: str
via: str
summary_de: str
class OrganizationDetail(Model):
slug: str
name: str
short_name: str | None = None
kind: str
parent_slug: str | None = None
parent_name: str | None = None
identifiers: dict[str, str] | None = None
money: list[OrgMoney]
counterparties: list[Counterparty]
people: list[OrgPerson]
class MoneyFlow(Model):
id: int
summary_de: str
summary_en: str | None = None
occurred_at: datetime | None = None
status: str
data: dict[str, Any] = {}
kind: str
amount: float | None = None
amount_category: str | None = None
currency: str | None = None
paid_on: date | None = None
period: str | None = None
reference: str | None = None
category: str | None = None
payer_name: str | None = None
payee_name: str | None = None
payer_slug: str | None = None
payer_kind: str | None = None
payee_slug: str | None = None
payee_kind: str | None = None
evidence: list[Evidence]
source_count: int
class MoneySummaryRow(Model):
kind: str
slug: str
name: str
org_kind: str
side: Literal["payer", "payee"]
total: float | None = None
count: int
class SocialPost(Model):
id: int
platform: str | None = None
said_at: datetime | None = None
excerpt: str | None = None
url: str
title: str | None = None
data: dict[str, Any] = {}
person_slug: str | None = None
person_name: str | None = None
org_slug: str | None = None
org_name: str | None = None
evidence: list[Evidence]
class WikiEdit(Model):
id: int
occurred_at: datetime | None = None
summary_de: str
summary_en: str | None = None
data: dict[str, Any] = {}
org_slug: str
org_name: str
person_slug: str | None = None
person_name: str | None = None
url: str | None = None
class TimelineBucket(Model):
month: str
claim_type: str
count: int
class Participant(Model):
slug: str
name: str
class CalendarEvent(Model):
id: int
kind: str
title: str
starts_at: datetime | None = None
ends_at: datetime | None = None
location_name: str | None = None
country: str | None = None
url: str | None = None
description: str | None = None
body: str | None = None
participants: list[Participant]
class DayCount(Model):
day: date
claim_type: str
count: int
class CalendarData(Model):
events: list[CalendarEvent]
days: list[DayCount]
class PartyPosition(Model):
party: str | None = None
slug: str
color: str | None = None
position: str
class Speech(Model):
url: str
title: str | None = None
class VoteItem(Model):
id: int
item_title: str
item_ref: str | None = None
stage: str | None = None
voted_on: date | None = None
result: str | None = None
url: str | None = None
protocol_url: str | None = None
roll_call: bool
raw_text: str | None = None
chamber: str
positions: list[PartyPosition] = []
individual_votes: int = 0
class PersonVote(Model):
id: int
item_title: str
item_ref: str | None = None
stage: str | None = None
voted_on: date | None = None
result: str | None = None
url: str | None = None
protocol_url: str | None = None
roll_call: bool
position: str
faction: str | None = None
chamber: str
individual: bool
dissent: bool
spoke: Speech | None = None
topics: list[str] | None = None
class IndividualVote(Model):
slug: str
full_name: str
position: str
faction: str | None = None
dissent: bool
class VoteDetail(Model):
id: int
item_title: str
item_ref: str | None = None
stage: str | None = None
voted_on: date | None = None
result: str | None = None
url: str | None = None
protocol_url: str | None = None
roll_call: bool
raw_text: str | None = None
chamber: str
positions: list[PartyPosition]
individual: list[IndividualVote]
class SearchEvidence(Model):
url: str
title: str | None = None
source: str
class SearchHit(Model):
id: int
text: str
claim_id: int | None = None
document_id: int | None = None
summary_de: str | None = None
claim_type: str | None = None
occurred_at: datetime | None = None
person_slug: str | None = None
person_name: str | None = None
organization_slug: str | None = None
document_url: str | None = None
document_title: str | None = None
document_date: datetime | None = None
evidence: list[SearchEvidence] | None = None
score: float
class Run(Model):
adapter: str
mode: str
status: str
started_at: datetime
finished_at: datetime | None = None
stats: dict[str, Any] = {}
error: str | None = None
class PendingClaim(Model):
id: int
claim_type: str
summary_de: str
occurred_at: datetime | None = None
full_name: str | None = None
evidence: list[dict[str, Any]] | None = None