Files
politics-tracking/backend/politrack/enrich/curated.py
T
simonandClaude Opus 5.5 583ccba31c 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 20:02:01 +01:00

75 lines
3.0 KiB
Python

"""Curated lists maintained by the operator in config/ (accounts that no official register lists).
* config.social_accounts - config/social_accounts.yaml -> social_account rows for persons/organisations,
e.g. party Telegram channels or Bluesky accounts linked from party websites. The social adapters then
collect their public posts.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import yaml
from politrack.config import get_settings
from politrack.core import Enricher, RunContext, SourceSpec, register
from politrack.core.records import OrgRef, PersonRef, SocialAccount
CURATED = SourceSpec(
slug="curated",
name="Kuratierte Listen (config/)",
kind="derived",
homepage="",
license="operator-maintained",
access="manual",
trust=60,
tos_notes="Entries maintained by the operator; each should name the public page that links the account.",
)
def person_ref(ctx: RunContext, entry: dict[str, Any]) -> PersonRef | None:
"""`person: <politrack slug>`, `person: {wikidata: Q..}` / `{parlament: ..}` or `name: <full name>`."""
person = entry.get("person")
if isinstance(person, dict):
return PersonRef(ids={k: str(v) for k, v in person.items()})
if isinstance(person, str):
row = ctx.conn.execute("SELECT id FROM person WHERE slug = %s", (person,)).fetchone()
return PersonRef(ids={"politrack": str(row["id"])}) if row else None
if entry.get("name"):
return PersonRef(name=entry["name"])
return None
def org_ref(entry: dict[str, Any]) -> OrgRef | None:
"""`org: <slug>` for existing organisations, or `org: {slug, name, kind}` to create one."""
org = entry.get("org")
if isinstance(org, str):
return OrgRef(slug=org, create=False)
if isinstance(org, dict):
return OrgRef(slug=org.get("slug"), name=org.get("name"), short_name=org.get("short_name"),
kind=org.get("kind", "other"), create=bool(org.get("name")))
return None
@register
class CuratedSocialAccounts(Enricher):
spec = CURATED
name = "config.social_accounts"
description = "Import social accounts from config/social_accounts.yaml"
schedule = "5 3 * * *"
def run(self, ctx: RunContext) -> Iterator[SocialAccount]:
path = get_settings().config_dir / "social_accounts.yaml"
if not path.exists():
return
for entry in (yaml.safe_load(path.read_text(encoding="utf-8")) or {}).get("accounts") or []:
person = person_ref(ctx, entry)
org = None if person else org_ref(entry)
if person is None and org is None:
ctx.stats["entries_without_owner"] += 1
ctx.log.warning("social_accounts.yaml: no person/org for %s", entry.get("url"))
continue
yield SocialAccount(platform=entry["platform"], url=entry["url"], handle=entry.get("handle"),
person=person, org=org, verified=bool(entry.get("verified")))