"""`catlas` intelligence commands: process-changes, enrich, metrics, daily, signals, trends, alerts-eval, llm-test, reprocess-events, events, digest, retention.""" from __future__ import annotations import asyncio import json import time from datetime import UTC, date, datetime, timedelta from typing import Annotated import typer from rich.table import Table from companyatlas.cli import console, out, run_async def register(app: typer.Typer) -> None: @app.command("process-changes") def process_changes(limit: int = 200, loop: Annotated[bool, typer.Option("--loop", help="keep polling every 20 s")] = False) -> None: """Turn pending changes into deterministic events (clusters, sources, review queue, LLM jobs).""" from companyatlas.services.events import process_pending_changes async def go() -> None: while True: stats = await process_pending_changes(limit=limit) out.print(stats) if not loop: return await asyncio.sleep(20) run_async(go()) @app.command() def enrich(limit: int = 10, once: Annotated[bool, typer.Option("--once", help="one batch only (default: drain until idle)")] = False) -> None: """Run the LLM enrichment worker over pending llm_jobs (sticky by kind, budgeted).""" from companyatlas.services.llm.enrich import run_llm_jobs async def go() -> None: while True: stats = await run_llm_jobs(limit=limit) out.print(stats) if once or not stats["claimed"] or stats.get("skipped_budget"): return run_async(go()) @app.command() def metrics(all: Annotated[bool, typer.Option("--all", help="every company (default: active in the last 90 days)")] = False, company: Annotated[str | None, typer.Option("--company", help="one company slug or id")] = None) -> None: """Compute company metrics (activity, hiring momentum, AI adoption, velocity, CCI…) into metrics_current + metric_series.""" from companyatlas.db import fetch_val, transaction from companyatlas.services.metrics import compute_company_metrics async def go() -> None: ids = None if company: async with transaction() as conn: cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company) if not cid: console.print(f"[red]company not found: {company}[/]") raise typer.Exit(1) ids = [cid] out.print(await compute_company_metrics(ids, all_companies=all)) if company: async with transaction() as conn: from companyatlas.db import fetch_all rows = await fetch_all(conn, "select metric, value, confidence, formula_version, computed_at from metrics_current where company_id = :c order by metric", c=ids[0]) t = Table(title=f"metrics · {company}") for col in ("metric", "value", "confidence", "formula", "computed_at"): t.add_column(col) for r in rows: t.add_row(r["metric"], f"{r['value']:.2f}", f"{r['confidence']:.2f}", r["formula_version"], str(r["computed_at"])[:19]) out.print(t) run_async(go()) @app.command() def daily(day: Annotated[str | None, typer.Option("--day", help="YYYY-MM-DD (UTC) or 'today'")] = None, catch_up: Annotated[bool, typer.Option("--catch-up", help="fill every missing day up to yesterday")] = False, include_today: Annotated[bool, typer.Option("--include-today")] = False) -> None: """Daily aggregates: company_daily, global_daily (activity index, baseline 100), baselines.""" from companyatlas.services.metrics import compute_daily, compute_daily_catch_up async def go() -> None: if catch_up: results = await compute_daily_catch_up(include_today=include_today) out.print({"days": len(results), "last": results[-1] if results else None}) return d = datetime.now(UTC).date() if day in (None, "today") else date.fromisoformat(day) out.print(await compute_daily(d)) run_async(go()) @app.command() def signals(company: Annotated[str | None, typer.Option("--company")] = None) -> None: """Detect company / industry / country signals (hiring surge, launch build-up, expansion, AI acceleration…).""" from companyatlas.db import fetch_all, fetch_val, transaction from companyatlas.services.signals import compute_signals async def go() -> None: ids = None if company: async with transaction() as conn: cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company) ids = [cid] if cid else [] out.print(await compute_signals(ids)) async with transaction() as conn: rows = await fetch_all(conn, """select coalesce(co.slug, s.scope || ':' || s.scope_key) as who, s.kind, s.strength, s.confidence, s.title from signals s left join companies co on co.id = s.company_id where s.status = 'active' order by s.strength desc limit 30""") t = Table(title="active signals") for col in ("scope", "kind", "strength", "confidence", "title"): t.add_column(col) for r in rows: t.add_row(r["who"], r["kind"], f"{r['strength']:.2f}", f"{r['confidence']:.2f}", r["title"]) out.print(t) run_async(go()) @app.command() def trends(days: int = 1, window: int = 7) -> None: """Extract trending terms from event/news titles for the last N days and print momentum.""" from companyatlas.services.trends import compute_trends_range, store_momentum_snapshots, trend_momentum async def go() -> None: for r in await compute_trends_range(days): out.print(r) await store_momentum_snapshots() items = await trend_momentum(window) t = Table(title=f"trend momentum · {window}d") for col in ("term", "mentions", "companies", "momentum"): t.add_column(col) for it in items[:25]: t.add_row(it["term"], str(it["mentions"]), str(it["companies"]), f"{it['momentum']:+.2f}") out.print(t) run_async(go()) @app.command("alerts-eval") def alerts_eval(event: Annotated[list[str] | None, typer.Option("--event", help="event id(s); default = sweep")] = None) -> None: """Evaluate alerts for given events, or run the catch-up sweep (+ metric alerts).""" from companyatlas.services.alerts import evaluate_alerts, sweep run_async(_print(evaluate_alerts(event) if event else sweep())) @app.command("llm-test") def llm_test(prompt: str = "Reply with a JSON object {\"ok\": true, \"model\": \"\"}", tier: str = "small", json_mode: bool = True) -> None: """Live round-trip against the configured LLM endpoint: prints health, model, latency and the answer.""" from pydantic import BaseModel from companyatlas.config import settings from companyatlas.services.llm.gateway import LLMError, get_provider class Probe(BaseModel): ok: bool = True model: str | None = None answer: str | None = None async def go() -> None: if not settings.llm_configured: console.print("[red]LLM not configured (CA_LLM_BASE_URL / CA_LLM_ENABLED)[/]") raise typer.Exit(1) p = get_provider() h = await p.health() out.print({"health": h.ok, "base_url": h.base_url, "latency_ms": h.latency_ms, "models": h.models[:20], "error": h.error}) t0 = time.monotonic() try: if json_mode: res = await p.complete_json(tier, "You are a health probe. Answer briefly.", prompt, Probe, max_tokens=120) answer = res.data.model_dump() else: res = await p.complete_text(tier, "You are a health probe. Answer briefly.", prompt, max_tokens=120) answer = res.data except LLMError as exc: console.print(f"[red]LLM error: {exc}[/]") raise typer.Exit(1) from exc out.print({"model": res.model, "latency_ms": res.latency_ms, "wall_ms": int((time.monotonic() - t0) * 1000), "request_tokens": res.request_tokens, "response_tokens": res.response_tokens, "attempts": res.attempts, "repaired": res.repaired, "answer": answer}) await p.close() run_async(go()) @app.command("reprocess-events") def reprocess(since: Annotated[str, typer.Option("--since", help="ISO date/datetime or e.g. 7d")] = "7d", company: Annotated[str | None, typer.Option("--company")] = None, limit: int = 5000) -> None: """Re-run deterministic rules on processed changes (no refetch; dedupe keys keep it idempotent).""" from companyatlas.db import fetch_val, transaction from companyatlas.services.events import reprocess_events async def go() -> None: cid = None if company: async with transaction() as conn: cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company) out.print(await reprocess_events(_parse_since(since), limit=limit, company_id=cid)) run_async(go()) @app.command() def events(company: Annotated[str | None, typer.Option("--company")] = None, type: Annotated[str | None, typer.Option("--type")] = None, limit: int = 50) -> None: """List recent events.""" from companyatlas.services.events import list_events async def go() -> None: rows = await list_events(company=company, event_type=type, limit=limit) t = Table(title="events") for col in ("detected", "company", "subtype", "imp", "conf", "origin", "status", "title"): t.add_column(col) for r in rows: t.add_row(str(r["detected_at"])[:16], r["slug"], r["event_subtype"], f"{r['importance']:.2f}", r["confidence_label"], r["origin"], r["status"], r["title"][:90]) out.print(t) run_async(go()) @app.command() def digest(company: Annotated[str | None, typer.Option("--company")] = None, scope: Annotated[str | None, typer.Option("--scope", help="industry|country")] = None, key: Annotated[str | None, typer.Option("--key")] = None, days: int = 7) -> None: """Print digest JSON for a company or an industry/country scope.""" from companyatlas.services.digest import company_digest, scope_digest async def go() -> None: if company: data = await company_digest(company, days=days) elif scope and key: data = await scope_digest(scope, key, days=days) else: console.print("[red]--company or --scope + --key required[/]") raise typer.Exit(1) out.print_json(json.dumps(data, default=str)) run_async(go()) @app.command("retention-intel") def retention(dry_run: Annotated[bool, typer.Option("--dry-run")] = True) -> None: """Prune unchanged observations > 90 d, crawl_runs > 30 d, archive finished llm_jobs > 60 d (dry-run by default).""" from companyatlas.services.retention import run_retention run_async(_print(run_retention(dry_run=dry_run))) async def _print(coro) -> None: # type: ignore[no-untyped-def] out.print(await coro) def _parse_since(value: str) -> datetime: v = value.strip().lower() if v.endswith("d") and v[:-1].isdigit(): return datetime.now(UTC) - timedelta(days=int(v[:-1])) if v.endswith("h") and v[:-1].isdigit(): return datetime.now(UTC) - timedelta(hours=int(v[:-1])) dt = datetime.fromisoformat(value) return dt if dt.tzinfo else dt.replace(tzinfo=UTC)