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>
536 lines
12 KiB
Python
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
|