"""`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//.*).""" 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()