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>
192 lines
7.4 KiB
Python
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()
|