Files
politics-tracking/backend/politrack/core/loader.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

449 lines
24 KiB
Python

"""The Loader: the only code that writes canonical rows.
Invariants enforced here (not in adapters):
* no claim without evidence; claims whose person cannot be resolved are skipped (and counted),
* full text is kept only for sources with store_fulltext and responses without TDM reservation,
* claim identity is computed from *resolved* entity ids + type-specific fields, so the same fact
from different sources becomes one claim with several evidence rows ("corroborated"),
* claims marked `review=True` start as review_status='pending' (hidden until a human approves),
* persons are only created by adapters with creates_persons=True.
"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from psycopg import Connection
from psycopg.types.json import Jsonb
from politrack.core.adapter import RunContext
from politrack.core.records import (
Claim,
Document,
Event,
Evidence,
Legal,
Money,
OrgRecord,
OrgRef,
Participation,
PersonRecord,
PersonRef,
Role,
SocialAccount,
Statement,
VoteEvent,
VotePosition,
)
from politrack.core.resolve import Resolver
from politrack.text import excerpt, fingerprint, make_slug, normalize, normalize_org_name, strip_titles
# Source policy is read from the DB once per process (synced from SourceSpecs).
_SOURCES: dict[str, dict[str, Any]] = {}
def source_row(conn: Connection, slug: str) -> dict[str, Any]:
if slug not in _SOURCES:
row = conn.execute("SELECT id, store_fulltext, kind FROM source WHERE slug = %s", (slug,)).fetchone()
if row is None:
raise KeyError(f"unknown source {slug!r}: run `politrack sources sync`")
_SOURCES[slug] = row
return _SOURCES[slug]
class Loader:
def __init__(self, conn: Connection, ctx: RunContext) -> None:
self.conn = conn
self.ctx = ctx
self.resolver = Resolver(conn)
# -- dispatch ---------------------------------------------------------------------
def load(self, record: Any) -> int | None:
if isinstance(record, Claim):
return self.claim(record)
if isinstance(record, Document):
return self.document(record.evidence)
if isinstance(record, PersonRecord):
return self.person(record)
if isinstance(record, OrgRecord):
return self.organization(record)
if isinstance(record, SocialAccount):
return self.social_account(record)
if isinstance(record, VoteEvent):
return self.vote_event(record)
if isinstance(record, Event):
return self.event(record)
raise TypeError(f"unknown record type {type(record).__name__}")
# -- documents --------------------------------------------------------------------
def document(self, ev: Evidence) -> int:
slug = ev.source or self.ctx.spec.slug
src = source_row(self.conn, slug)
response = self.ctx.response
tdm = bool(response and response.tdm_reserved)
full_text = ev.full_text if (src["store_fulltext"] and not tdm) else None
short = None if tdm else (excerpt(ev.excerpt, 400) if ev.excerpt else None)
# Link the document to the raw response only if it *is* that response (not e.g. a protocol it cites).
fetch_id = response.fetch_id if response and ev.url in (response.url, response.request.url) else None
row = self.conn.execute(
"""
INSERT INTO document (source_id, url, title, doc_type, published_at, excerpt, full_text, fetch_id, meta)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (url) DO UPDATE SET
title = COALESCE(EXCLUDED.title, document.title),
published_at = COALESCE(EXCLUDED.published_at, document.published_at),
excerpt = COALESCE(EXCLUDED.excerpt, document.excerpt),
full_text = COALESCE(EXCLUDED.full_text, document.full_text),
fetch_id = COALESCE(EXCLUDED.fetch_id, document.fetch_id),
meta = document.meta || EXCLUDED.meta,
retrieved_at = now()
RETURNING id
""",
(src["id"], ev.url, ev.title, ev.doc_type, ev.published_at, short, full_text, fetch_id,
Jsonb({**ev.meta, **({"tdm_reserved": True} if tdm else {})})),
).fetchone()
self.ctx.stats["documents"] += 1
return row["id"]
# -- entities ---------------------------------------------------------------------
def person(self, rec: PersonRecord) -> int | None:
person_id = self.resolver.person_by_ids(rec.ref.ids)
if person_id is None and rec.match_by_name:
person_id = self.resolver.person_by_name(rec.full_name)
if person_id is None:
if not self.ctx.creates_persons:
self.ctx.stats["persons_not_created"] += 1
return None
base = make_slug(rec.given_name or "", rec.family_name or strip_titles(rec.full_name)) or "person"
slug, n = base, 1
while self.conn.execute("SELECT 1 FROM person WHERE slug = %s", (slug,)).fetchone():
n += 1
slug = f"{base}-{n}"
person_id = self.conn.execute(
"""INSERT INTO person (slug, full_name, given_name, family_name, honorific_prefix, honorific_suffix,
birth_date, death_date, gender, photo_url)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(slug, rec.full_name, rec.given_name, rec.family_name, rec.honorific_prefix, rec.honorific_suffix,
rec.birth_date, rec.death_date, rec.gender, rec.photo_url),
).fetchone()["id"]
self.ctx.stats["persons_created"] += 1
else:
self.conn.execute(
"""UPDATE person SET full_name = %s,
given_name = COALESCE(%s, given_name), family_name = COALESCE(%s, family_name),
honorific_prefix = COALESCE(%s, honorific_prefix), honorific_suffix = COALESCE(%s, honorific_suffix),
birth_date = COALESCE(%s, birth_date), death_date = COALESCE(%s, death_date),
gender = COALESCE(%s, gender), photo_url = COALESCE(%s, photo_url), updated_at = now()
WHERE id = %s""",
(rec.full_name, rec.given_name, rec.family_name, rec.honorific_prefix, rec.honorific_suffix,
rec.birth_date, rec.death_date, rec.gender, rec.photo_url, person_id),
)
for scheme, value in rec.ref.ids.items():
if value:
self.conn.execute(
"""INSERT INTO identifier (entity_type, entity_id, scheme, value, source_slug)
VALUES ('person', %s, %s, %s, %s) ON CONFLICT DO NOTHING""",
(person_id, scheme, str(value), self.ctx.spec.slug),
)
names = {rec.full_name, strip_titles(rec.full_name), *rec.aliases}
if rec.given_name and rec.family_name:
names.add(f"{rec.given_name} {rec.family_name}")
for name in names:
if name:
self.conn.execute(
"""INSERT INTO alias (entity_type, entity_id, alias, alias_norm) VALUES ('person', %s, %s, %s)
ON CONFLICT DO NOTHING""",
(person_id, name, normalize(strip_titles(name))),
)
self.ctx.stats["persons"] += 1
return person_id
def organization(self, rec: OrgRecord) -> int | None:
org_id = self.resolver.org(rec.ref)
if org_id is None:
return None
if rec.color:
self.conn.execute("UPDATE organization SET color = COALESCE(color, %s) WHERE id = %s", (rec.color, org_id))
if rec.parent:
parent = self.resolver.org(rec.parent)
if parent and parent != org_id:
self.conn.execute("UPDATE organization SET parent_id = %s WHERE id = %s", (parent, org_id))
self.resolver.add_org_aliases(org_id, list(rec.aliases))
return org_id
def social_account(self, rec: SocialAccount) -> int | None:
person_id = self.resolver.person(rec.person)
org_id = self.resolver.org(rec.org) if rec.org else None # OrgRef.create decides
if person_id is None and org_id is None:
self.ctx.stats["social_unresolved"] += 1
return None
row = self.conn.execute(
"""INSERT INTO social_account (person_id, organization_id, platform, handle, url, verified, source_slug)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (platform, url) DO UPDATE SET
handle = COALESCE(EXCLUDED.handle, social_account.handle),
verified = social_account.verified OR EXCLUDED.verified,
person_id = COALESCE(social_account.person_id, EXCLUDED.person_id),
organization_id = COALESCE(social_account.organization_id, EXCLUDED.organization_id)
RETURNING id""",
(person_id, org_id, rec.platform.lower(), rec.handle, rec.url, rec.verified, self.ctx.spec.slug),
).fetchone()
self.ctx.stats["social_accounts"] += 1
return row["id"]
# -- standalone facets ------------------------------------------------------------
def vote_event(self, rec: VoteEvent) -> int:
chamber = self.resolver.org(rec.chamber, create=False) if rec.chamber else None
row = self.conn.execute(
"""INSERT INTO vote_event (event_key, chamber_id, item_ref, item_title, stage, voted_on, result, roll_call,
url, protocol_url, raw_text)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (event_key) DO UPDATE SET result = EXCLUDED.result, raw_text = EXCLUDED.raw_text,
roll_call = EXCLUDED.roll_call, item_title = EXCLUDED.item_title,
protocol_url = COALESCE(EXCLUDED.protocol_url, vote_event.protocol_url)
RETURNING id""",
(rec.key, chamber, rec.item_ref, rec.title, rec.stage, rec.voted_on, rec.result, rec.roll_call, rec.url,
rec.protocol_url, rec.raw_text),
).fetchone()
self.ctx.stats["vote_events"] += 1
return row["id"]
def event(self, rec: Event) -> int:
body = self.resolver.org(rec.body, create=False) if rec.body else None
source_id = source_row(self.conn, self.ctx.spec.slug)["id"]
row = self.conn.execute(
"""INSERT INTO event (event_key, kind, title, starts_at, ends_at, location_name, country, url, description,
organization_id, source_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (event_key) DO UPDATE SET title = EXCLUDED.title,
starts_at = COALESCE(EXCLUDED.starts_at, event.starts_at), ends_at = EXCLUDED.ends_at,
location_name = COALESCE(EXCLUDED.location_name, event.location_name),
description = COALESCE(EXCLUDED.description, event.description)
RETURNING id""",
(rec.key, rec.kind, rec.title, rec.starts_at, rec.ends_at, rec.location, rec.country, rec.url,
rec.description, body, source_id),
).fetchone()
self.ctx.stats["events"] += 1
return row["id"]
# -- claims -----------------------------------------------------------------------
def claim(self, c: Claim) -> int | None:
if not c.evidence:
raise ValueError("a claim needs at least one piece of evidence")
person_id = self.resolver.person(c.person)
if c.person is not None and person_id is None:
self.ctx.stats["unresolved_person"] += 1
self.ctx.log.debug("unresolved person %s for %s", c.person, c.summary_de[:80])
return None
org_id = self.resolver.org(c.org) if c.org else None
cp_person = self.resolver.person(c.counterpart_person)
cp_org = self.resolver.org(c.counterpart_org) if c.counterpart_org else None
if person_id is None and org_id is None and cp_org is None:
self.ctx.stats["claims_without_subject"] += 1
return None
fp = fingerprint(*self.identity(c, person_id, org_id, cp_org))
row = self.conn.execute(
"""
INSERT INTO claim (claim_type, fingerprint, person_id, organization_id, counterpart_person_id,
counterpart_org_id, occurred_at, valid_from, valid_to, summary_de, summary_en,
data, sensitive, review_status, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (fingerprint) DO UPDATE SET
summary_de = EXCLUDED.summary_de,
summary_en = COALESCE(EXCLUDED.summary_en, claim.summary_en),
valid_to = COALESCE(EXCLUDED.valid_to, claim.valid_to),
occurred_at = COALESCE(claim.occurred_at, EXCLUDED.occurred_at),
data = claim.data || EXCLUDED.data,
sensitive = claim.sensitive OR EXCLUDED.sensitive,
updated_at = now()
RETURNING id, (xmax = 0) AS inserted
""",
(c.claim_type, fp, person_id, org_id, cp_person, cp_org, c.occurred_at, c.valid_from, c.valid_to,
c.summary_de, c.summary_en, Jsonb(c.data), c.sensitive or c.claim_type == "legal_proceeding",
"pending" if c.review else "published", self.ctx.adapter),
).fetchone()
claim_id = row["id"]
self.ctx.stats["claims_new" if row["inserted"] else "claims_updated"] += 1
for ev in c.evidence:
doc_id = self.document(ev)
self.conn.execute(
"""INSERT INTO claim_evidence (claim_id, document_id, stance, quote, locator, extracted_by, confidence)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (claim_id, document_id, locator) DO UPDATE SET
quote = COALESCE(EXCLUDED.quote, claim_evidence.quote),
confidence = GREATEST(claim_evidence.confidence, EXCLUDED.confidence)""",
(claim_id, doc_id, ev.stance, ev.quote and excerpt(ev.quote, 600), ev.locator, self.ctx.adapter,
ev.confidence),
)
for topic in c.topics:
self.assign_topic(claim_id, topic)
if c.facet is not None:
self.facet(claim_id, c, person_id, org_id)
self.refresh_status(claim_id)
return claim_id
def identity(self, c: Claim, person_id: int | None, org_id: int | None, cp_org: int | None) -> tuple:
"""Semantic identity of a claim (see docs/architecture/01-design-from-scratch.md §3.3)."""
if c.key is not None:
return (c.claim_type, "key", *c.key)
f = c.facet
if isinstance(f, Role):
body = self.resolver.org(f.body) if f.body else cp_org
return ("role", person_id, body, normalize(f.role), f.start)
if isinstance(f, VotePosition):
return ("vote", f.event_key, person_id, org_id)
if isinstance(f, Money):
def side(ref: Any, name: str | None) -> Any:
if isinstance(ref, PersonRef):
return ("p", self.resolver.person(ref))
if isinstance(ref, OrgRef):
return ("o", self.resolver.org(ref))
return ("n", normalize_org_name(name or ""))
return ("money", f.kind, side(f.payer, f.payer_name), side(f.payee, f.payee_name), _amount(f.amount),
f.paid_on, f.period, f.reference)
if isinstance(f, Statement):
return ("statement", person_id, org_id, f.url)
if isinstance(f, Participation):
return ("participation", f.event_key, person_id, org_id)
if c.claim_type == "media_mention":
return ("mention", c.evidence[0].url, person_id, org_id)
raise ValueError(f"claim of type {c.claim_type} needs an explicit key")
def facet(self, claim_id: int, c: Claim, person_id: int | None, org_id: int | None) -> None:
f = c.facet
if isinstance(f, Role):
body = self.resolver.org(f.body) if f.body else None
party = self.resolver.org(f.party) if f.party else None
self.conn.execute(
"""INSERT INTO role (claim_id, person_id, organization_id, party_id, role, level, region, start_date, end_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (claim_id) DO UPDATE SET end_date = COALESCE(EXCLUDED.end_date, role.end_date),
party_id = COALESCE(EXCLUDED.party_id, role.party_id), region = COALESCE(EXCLUDED.region, role.region),
organization_id = COALESCE(EXCLUDED.organization_id, role.organization_id)""",
(claim_id, person_id, body, party, f.role, f.level, f.region, f.start, f.end),
)
elif isinstance(f, VotePosition):
event = self.conn.execute(
"""SELECT ve.id, ve.voted_on, o.slug AS chamber FROM vote_event ve
LEFT JOIN organization o ON o.id = ve.chamber_id WHERE ve.event_key = %s""",
(f.event_key,),
).fetchone()
if event is None:
raise ValueError(f"vote event {f.event_key} must be loaded before its positions")
party = self.resolver.org(f.party) if f.party else None
if person_id is not None and party is None and event["chamber"]:
# individual (roll-call) vote: remember the voter's faction on that day (dissent detection)
party = self.resolver.faction_of(person_id, event["chamber"], event["voted_on"])
self.conn.execute(
"""INSERT INTO vote_position (claim_id, vote_event_id, organization_id, person_id, party_id, position)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (claim_id) DO UPDATE SET position = EXCLUDED.position,
party_id = COALESCE(EXCLUDED.party_id, vote_position.party_id)""",
(claim_id, event["id"], org_id if person_id is None else None, person_id, party, f.position),
)
elif isinstance(f, Money):
payer_org = self.resolver.org(f.payer) if isinstance(f.payer, OrgRef) else None
payee_org = self.resolver.org(f.payee) if isinstance(f.payee, OrgRef) else None
payer_person = self.resolver.person(f.payer) if isinstance(f.payer, PersonRef) else None
payee_person = self.resolver.person(f.payee) if isinstance(f.payee, PersonRef) else None
self.conn.execute(
"""INSERT INTO money_flow (claim_id, kind, payer_name, payer_org_id, payer_person_id, payee_name,
payee_org_id, payee_person_id, amount, amount_category, currency, paid_on,
period, reference, category)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (claim_id) DO UPDATE SET amount = EXCLUDED.amount,
payer_org_id = COALESCE(EXCLUDED.payer_org_id, money_flow.payer_org_id),
payee_org_id = COALESCE(EXCLUDED.payee_org_id, money_flow.payee_org_id)""",
(claim_id, f.kind, f.payer_name, payer_org, payer_person, f.payee_name, payee_org, payee_person,
_amount(f.amount), f.amount_category, f.currency, f.paid_on, f.period, f.reference, f.category),
)
elif isinstance(f, Participation):
event = self.conn.execute("SELECT id FROM event WHERE event_key = %s", (f.event_key,)).fetchone()
if event is None:
raise ValueError(f"event {f.event_key} must be loaded before participations")
self.conn.execute(
"""INSERT INTO event_participant (claim_id, event_id, person_id, organization_id, participation)
VALUES (%s, %s, %s, %s, %s) ON CONFLICT (claim_id) DO NOTHING""",
(claim_id, event["id"], person_id, org_id, f.participation),
)
elif isinstance(f, Statement):
self.conn.execute(
"""INSERT INTO statement (claim_id, person_id, context, platform, medium, title, said_at, excerpt, url)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (claim_id) DO UPDATE SET
excerpt = COALESCE(EXCLUDED.excerpt, statement.excerpt)""",
(claim_id, person_id, f.context, f.platform, f.medium, f.title, f.said_at,
f.excerpt and excerpt(f.excerpt, 600), f.url),
)
elif isinstance(f, Legal):
self.conn.execute(
"""INSERT INTO legal_proceeding (claim_id, person_id, organization_id, stage, offense, authority, case_ref,
stage_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (claim_id) DO UPDATE SET stage = EXCLUDED.stage,
offense = COALESCE(EXCLUDED.offense, legal_proceeding.offense),
authority = COALESCE(EXCLUDED.authority, legal_proceeding.authority),
case_ref = COALESCE(EXCLUDED.case_ref, legal_proceeding.case_ref)""",
(claim_id, person_id, org_id, f.stage, f.offense, f.authority, f.case_ref, f.stage_date),
)
# -- helpers ----------------------------------------------------------------------
def assign_topic(self, claim_id: int, topic_name: str, method: str | None = None, confidence: float = 1.0) -> None:
norm = normalize(topic_name)
row = self.conn.execute(
"SELECT id FROM topic WHERE lower(unaccent(name_de)) = %s OR slug = %s", (norm, topic_name)
).fetchone()
if row is None:
row = self.conn.execute(
"INSERT INTO topic (slug, name_de, name_en) VALUES (%s, %s, %s) ON CONFLICT (slug) DO UPDATE "
"SET name_de = topic.name_de RETURNING id",
(make_slug(topic_name), topic_name, topic_name),
).fetchone()
self.conn.execute(
"INSERT INTO topic_assignment (topic_id, claim_id, confidence, method) VALUES (%s, %s, %s, %s) "
"ON CONFLICT DO NOTHING",
(row["id"], claim_id, confidence, method or self.ctx.adapter),
)
def refresh_status(self, claim_id: int) -> None:
"""corroborated = backed by >= 2 different sources; disputed = any contradicting evidence."""
self.conn.execute(
"""
UPDATE claim SET status = CASE
WHEN status = 'retracted' THEN status
WHEN EXISTS (SELECT 1 FROM claim_evidence e WHERE e.claim_id = claim.id AND e.stance = 'contradicts')
THEN 'disputed'
WHEN (SELECT count(DISTINCT d.source_id) FROM claim_evidence e JOIN document d ON d.id = e.document_id
WHERE e.claim_id = claim.id AND e.stance IN ('supports', 'mentions')) >= 2 THEN 'corroborated'
ELSE 'reported' END
WHERE id = %s
""",
(claim_id,),
)
def _amount(value: Any) -> Decimal | None:
if value is None or value == "":
return None
try:
return Decimal(str(value)).quantize(Decimal("0.01"))
except InvalidOperation:
return None
def as_date(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return None