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

192 lines
7.4 KiB
Python

"""`politrack` command line. Run `politrack --help` for all commands."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
import typer
app = typer.Typer(help="Politrack command line", no_args_is_help=True)
sources_app = typer.Typer(help="Source specs and adapters")
app.add_typer(sources_app, name="sources")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def _params(values: list[str]) -> dict:
"""--param key=value (value parsed as JSON when possible)."""
out = {}
for item in values:
key, _, raw = item.partition("=")
try:
out[key] = json.loads(raw)
except json.JSONDecodeError:
out[key] = raw
return out
@app.command()
def migrate() -> None:
"""Apply SQL migrations, sync source specs and install the job-queue schema."""
from politrack.core.registry import sync_sources
from politrack.db import connection
from politrack.migrate import migrate as run_migrations
run_migrations()
with connection() as conn:
typer.echo(f"synced {sync_sources(conn)} sources")
installed = conn.execute("SELECT to_regclass('procrastinate_jobs') AS t").fetchone()["t"]
if not installed:
from politrack.worker import app as jobs
with jobs.open():
jobs.schema_manager.apply_schema()
@sources_app.command("list")
def sources_list() -> None:
"""List adapters, enrichers and their schedules."""
from politrack.core import registry
registry.load_builtin()
for name, cls in sorted({**registry.ADAPTERS, **registry.ENRICHERS}.items()):
typer.echo(f"{name:34} {cls.queue:7} {cls.schedule or 'manual':16} {cls.spec.slug:22} {cls.description}")
@sources_app.command("sync")
def sources_sync() -> None:
"""Upsert SourceSpecs into the database."""
from politrack.core.registry import sync_sources
from politrack.db import connection
with connection() as conn:
typer.echo(f"synced {sync_sources(conn)} sources")
@app.command("run")
def run_cmd(
name: str,
param: list[str] = typer.Option([], "--param", "-p", help="key=value, e.g. -p periods='[\"XXVIII\"]'"),
since: str = typer.Option(None, help="ISO date for incremental adapters"),
max_requests: int = typer.Option(None, help="Stop after N requests (testing)"),
) -> None:
"""Run one adapter or enricher now, e.g. `politrack run parlament.persons`."""
from politrack.core.runner import run
result = run(name, params=_params(param), since=datetime.fromisoformat(since) if since else None,
max_requests=max_requests)
typer.echo(json.dumps(result, indent=2, default=str))
@app.command()
def reparse(name: str, param: list[str] = typer.Option([], "--param", "-p")) -> None:
"""Re-run parse() on the archived responses of an adapter (after a parser fix, no network)."""
from politrack.core.runner import run
typer.echo(json.dumps(run(name, mode="reparse", params=_params(param)), indent=2, default=str))
@app.command()
def enrich() -> None:
"""Run all enrichers once (linking, events, legal, topics, roll calls, corroboration, embeddings)."""
from politrack.core import registry
from politrack.core.runner import run
registry.load_builtin()
for name in ("enrich.linker", "enrich.events", "enrich.legal", "enrich.topics", "enrich.roles",
"enrich.rollcall", "enrich.corroborate", "enrich.embed"):
typer.echo(f"{name}: {run(name)}")
@app.command()
def status(limit: int = 30) -> None:
"""Show recent runs."""
from politrack.db import connection
with connection() as conn:
for r in conn.execute("SELECT adapter, mode, status, started_at, finished_at, stats FROM run "
"ORDER BY id DESC LIMIT %s", (limit,)):
took = (r["finished_at"] - r["started_at"]).seconds if r["finished_at"] else "…"
typer.echo(f"{r['started_at']:%Y-%m-%d %H:%M} {r['adapter']:32} {r['status']:8} {took}s "
f"{json.dumps(r['stats'])[:120]}")
@app.command()
def review(
approve: list[int] = typer.Option([], help="Claim ids to publish"),
reject: list[int] = typer.Option([], help="Claim ids to reject"),
reviewer: str = typer.Option("cli", help="Your name, stored with the decision"),
) -> None:
"""List claims waiting for review (legal matters detected in headlines) or approve/reject them."""
from politrack import queries
from politrack.db import connection
with connection() as conn:
for claim_id in approve:
queries.set_review(conn, claim_id, "published", reviewer)
for claim_id in reject:
queries.set_review(conn, claim_id, "rejected", reviewer)
if approve or reject:
typer.echo(f"published {len(approve)}, rejected {len(reject)}")
return
for c in queries.pending_claims(conn, limit=100):
typer.echo(f"[{c['id']}] {c['occurred_at']:%Y-%m-%d} {c['full_name']}: {c['summary_de']}")
for ev in c["evidence"] or []:
typer.echo(f" {ev['title']} {ev['url']}")
@app.command()
def fixture(name: str, url: str, case: str, kind: str = "default", meta: str = "{}", method: str = "GET",
body: str = typer.Option(None, help="JSON request body for POST")) -> None:
"""Capture a live response as a golden-test fixture (tests/fixtures/<adapter>/<case>.*)."""
import httpx
from politrack.config import get_settings
out = Path(__file__).resolve().parent.parent / "tests" / "fixtures" / name
out.mkdir(parents=True, exist_ok=True)
resp = httpx.request(method, url, json=json.loads(body) if body else None, timeout=120, follow_redirects=True,
headers={"User-Agent": get_settings().user_agent})
resp.raise_for_status()
suffix = {"application/json": "json", "text/html": "html", "text/xml": "xml", "application/xml": "xml"}.get(
resp.headers.get("content-type", "").split(";")[0], "bin")
(out / f"{case}.{suffix}").write_bytes(resp.content)
(out / f"{case}.request.json").write_text(json.dumps(
{"url": url, "kind": kind, "meta": json.loads(meta), "method": method, "json": json.loads(body) if body else None,
"content_type": resp.headers.get("content-type", ""), "body_file": f"{case}.{suffix}"}, indent=2), encoding="utf-8")
typer.echo(f"saved {out / case}.* - now run: UPDATE_GOLDEN=1 pytest tests/test_golden.py -k {case}")
@app.command()
def openapi(json_out: Path = Path("openapi.json"), ts_out: Path = typer.Option(None, "--ts")) -> None:
"""Export the OpenAPI schema and (optionally) TypeScript types for the web app."""
from politrack.api.main import app as api_app
from politrack.openapi_ts import to_typescript
schema = api_app.openapi()
json_out.write_text(json.dumps(schema, indent=2, ensure_ascii=False), encoding="utf-8")
if ts_out:
ts_out.write_text(to_typescript(schema), encoding="utf-8")
typer.echo(f"wrote {json_out}" + (f" and {ts_out}" if ts_out else ""))
@app.command()
def api(host: str = "0.0.0.0", port: int = 8000) -> None:
import uvicorn
uvicorn.run("politrack.api.main:app", host=host, port=port, proxy_headers=True)
@app.command()
def mcp(host: str = "0.0.0.0", port: int = 8001) -> None:
"""Serve the MCP endpoint for AI agents (streamable HTTP at /mcp)."""
from politrack.mcp_server import serve
serve(host, port)
if __name__ == "__main__":
app()