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

233 lines
10 KiB
Python

"""Runs adapters and enrichers.
fetch mode: start() -> fetch (policy, archive) -> parse() -> Loader, one transaction per response
reparse mode: replay the latest archived response of every request the adapter ever made
test mode: parse a given response without DB (used by golden tests)
"""
from __future__ import annotations
import logging
import traceback
from collections import deque
from collections.abc import Iterable
from datetime import datetime, timezone
from typing import Any
from psycopg import Connection
from psycopg.types.json import Jsonb
from politrack.core import registry
from politrack.core.adapter import Adapter, Enricher, Request, Response, RunContext, StateStore
from politrack.core.archive import Archive
from politrack.core.fetch import Disallowed, Fetcher
from politrack.core.loader import Loader, source_row
from politrack.db import connection
log = logging.getLogger(__name__)
ENRICH_COMMIT_EVERY = 200
def run(name: str, *, mode: str = "fetch", since: datetime | None = None, params: dict[str, Any] | None = None,
max_requests: int | None = None) -> dict:
cls = registry.get(name)
with connection() as conn:
run_id = conn.execute("INSERT INTO run (adapter, mode) VALUES (%s, %s) RETURNING id", (name, mode)).fetchone()["id"]
conn.commit()
ctx = RunContext(adapter=name, spec=cls.spec, state=StateStore(conn, name), since=since, params=params or {},
mode=mode, conn=conn, creates_persons=cls.creates_persons,
log=logging.getLogger(f"politrack.{name}"))
status, error = "ok", None
try:
if issubclass(cls, Enricher):
_run_enricher(cls(), ctx, conn)
elif mode == "reparse":
_reparse(cls(), ctx, conn)
else:
_fetch_run(cls(), ctx, conn, max_requests)
if ctx.stats.get("errors"):
status = "partial"
except Exception:
conn.rollback()
status, error = "error", traceback.format_exc()
ctx.log.exception("%s failed", name)
conn.execute("UPDATE run SET finished_at = now(), status = %s, stats = %s, error = %s WHERE id = %s",
(status, Jsonb(dict(ctx.stats)), error, run_id))
conn.commit()
ctx.log.info("%s %s: %s %s", name, mode, status, dict(ctx.stats))
if status == "error":
raise RuntimeError(f"{name} failed, see run {run_id}")
return {"status": status, **dict(ctx.stats)}
def _fetch_run(adapter: Adapter, ctx: RunContext, conn: Connection, max_requests: int | None) -> None:
fetcher = Fetcher()
loader = Loader(conn, ctx)
queue: deque[Request] = deque(adapter.start(ctx))
seen: set[str] = set()
try:
while queue:
req = queue.popleft()
if req.fingerprint in seen:
continue
seen.add(req.fingerprint)
if max_requests is not None and ctx.stats["requests"] >= max_requests:
ctx.stats["stopped_at_max_requests"] = 1
break
ctx.stats["requests"] += 1
# Multi-publisher adapters (news feeds, GDELT) name the publisher per request.
spec = registry.SPECS.get(req.meta.get("source") or adapter.spec.slug, adapter.spec)
try:
resp = fetcher.fetch(conn, req, adapter=adapter.name, spec=spec,
source_id=source_row(conn, spec.slug)["id"])
except Disallowed:
ctx.stats["disallowed_by_robots"] += 1
ctx.log.warning("robots.txt disallows %s", req.url)
continue
except Exception as exc:
conn.rollback()
ctx.stats["fetch_errors"] += 1
ctx.stats["errors"] += 1
ctx.log.warning("fetch failed %s: %s", req.url, exc)
continue
if req.skip_unchanged and _unchanged(conn, resp, adapter.parser_version):
# Same body, same parser: its records are already loaded. Still collect follow-up requests,
# so children an interrupted run never fetched are picked up (they have their own checks).
ctx.stats["unchanged"] += 1
queue.extend(_follow_ups(adapter, ctx, resp))
continue
queue.extend(_parse_and_load(adapter, ctx, conn, loader, resp))
finally:
fetcher.close()
def _unchanged(conn: Connection, resp: Response, parser_version: int) -> bool:
if resp.fetch_id is None:
return False
row = conn.execute(
"""SELECT 1 FROM fetch_log WHERE request_fp = %s AND parsed_sha256 = %s AND parser_version = %s LIMIT 1""",
(resp.request.fingerprint, resp.sha256, parser_version),
).fetchone()
return row is not None
def _follow_ups(adapter: Adapter, ctx: RunContext, resp: Response) -> list[Request]:
ctx.response = resp
try:
return [item for item in adapter.parse(ctx, resp) if isinstance(item, Request)]
except Exception:
ctx.stats["parse_errors"] += 1
ctx.log.exception("parse failed for unchanged %s", resp.url)
return []
finally:
ctx.response = None
def _parse_and_load(adapter: Adapter, ctx: RunContext, conn: Connection, loader: Loader,
resp: Response) -> list[Request]:
follow: list[Request] = []
ctx.response = resp
try:
for item in adapter.parse(ctx, resp):
if isinstance(item, Request):
follow.append(item)
else:
loader.load(item)
if resp.fetch_id is not None:
Archive().mark_parsed(conn, resp.fetch_id, resp.sha256, adapter.parser_version)
conn.commit()
except Exception as exc:
conn.rollback()
ctx.stats["errors"] += 1
ctx.stats["parse_errors"] += 1
ctx.log.exception("parse/load failed for %s", resp.url)
if resp.fetch_id is not None:
Archive().mark_error(conn, resp.fetch_id, f"{type(exc).__name__}: {exc}")
conn.commit()
loader.resolver = type(loader.resolver)(conn) # drop caches that may reference rolled-back rows
finally:
ctx.response = None
return follow
def _reparse(adapter: Adapter, ctx: RunContext, conn: Connection) -> None:
archive = Archive()
loader = Loader(conn, ctx)
started = conn.execute("SELECT now() AS t").fetchone()["t"]
rows = archive.latest_stored(conn, adapter.name)
ctx.log.info("reparsing %s archived responses", len(rows))
# Parse list/index responses before detail responses: records referencing others (vote positions ->
# vote events) are emitted by the parent's parse() in order, so the original order is preserved
# by sorting on fetch time.
for row in sorted(rows, key=lambda r: r["fetched_at"]):
body = archive.read_blob(row["sha256"])
if body is None:
ctx.stats["missing_blobs"] += 1
continue
req_info = row["request"] or {}
req = Request(url=row["url"], kind=req_info.get("kind", "default"), method=row["method"],
params=req_info.get("params"), json=req_info.get("json"), meta=req_info.get("meta") or {})
resp = Response(request=req, status=row["status"], url=row["url"],
headers={"content-type": row["content_type"] or ""}, body=body,
fetched_at=row["fetched_at"], sha256=row["sha256"], fetch_id=row["id"],
tdm_reserved=row["tdm_reserved"], from_archive=True)
ctx.stats["responses"] += 1
_parse_and_load(adapter, ctx, conn, loader, resp) # follow-ups are archived separately
if not ctx.stats.get("errors") and not ctx.stats.get("missing_blobs"):
_sweep_stale_claims(adapter, ctx, conn, started)
def _sweep_stale_claims(adapter: Adapter, ctx: RunContext, conn: Connection, started: datetime) -> None:
"""After a complete reparse, delete this adapter's claims that the current parser no longer produces
(e.g. a fixed parser emits a corrected fact with a new identity) - unless another source backs them.
Claims are reproducible from the archive, so nothing is lost; facets and evidence links cascade."""
deleted = conn.execute(
"""DELETE FROM claim c
WHERE c.created_by = %(adapter)s AND c.updated_at < %(started)s
AND NOT EXISTS (SELECT 1 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 s.slug <> %(source)s)""",
{"adapter": adapter.name, "started": started, "source": adapter.spec.slug},
).rowcount
conn.commit()
ctx.stats["stale_claims_deleted"] = deleted
def _run_enricher(enricher: Enricher, ctx: RunContext, conn: Connection) -> None:
loader = Loader(conn, ctx)
result = enricher.run(ctx)
pending = 0
for record in result or []:
try:
loader.load(record)
except Exception:
conn.rollback()
ctx.stats["errors"] += 1
ctx.log.exception("enricher %s failed to load a record", enricher.name)
loader = Loader(conn, ctx)
continue
pending += 1
if pending >= ENRICH_COMMIT_EVERY:
conn.commit()
pending = 0
conn.commit()
def parse_offline(adapter: Adapter, response: Response, params: dict[str, Any] | None = None) -> Iterable[Any]:
"""Run parse() without a database (golden tests, debugging)."""
ctx = RunContext(adapter=adapter.name, spec=adapter.spec, state=StateStore(None, adapter.name), mode="test",
params=params or {})
ctx.response = response
return list(adapter.parse(ctx, response))
def make_response(url: str, body: bytes, *, kind: str = "default", meta: dict | None = None,
content_type: str = "", method: str = "GET", json_body: Any = None,
params: dict | None = None) -> Response:
"""Build a Response for tests/fixtures."""
import hashlib
req = Request(url=url, kind=kind, meta=meta or {}, method=method, json=json_body, params=params)
return Response(request=req, status=200, url=url, headers={"content-type": content_type}, body=body,
fetched_at=datetime(2026, 1, 1, tzinfo=timezone.utc), sha256=hashlib.sha256(body).hexdigest())