SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
12.2 KB · 253 lines python
Raw Blame History
1"""`catlas` intelligence commands: process-changes, enrich, metrics, daily, signals, trends, alerts-eval, llm-test, reprocess-events, events, digest, retention."""2from __future__ import annotations34import asyncio5import json6import time7from datetime import UTC, date, datetime, timedelta8from typing import Annotated910import typer11from rich.table import Table1213from companyatlas.cli import console, out, run_async141516def register(app: typer.Typer) -> None:17    @app.command("process-changes")18    def process_changes(limit: int = 200, loop: Annotated[bool, typer.Option("--loop", help="keep polling every 20 s")] = False) -> None:19        """Turn pending changes into deterministic events (clusters, sources, review queue, LLM jobs)."""20        from companyatlas.services.events import process_pending_changes2122        async def go() -> None:23            while True:24                stats = await process_pending_changes(limit=limit)25                out.print(stats)26                if not loop:27                    return28                await asyncio.sleep(20)2930        run_async(go())3132    @app.command()33    def enrich(limit: int = 10, once: Annotated[bool, typer.Option("--once", help="one batch only (default: drain until idle)")] = False) -> None:34        """Run the LLM enrichment worker over pending llm_jobs (sticky by kind, budgeted)."""35        from companyatlas.services.llm.enrich import run_llm_jobs3637        async def go() -> None:38            while True:39                stats = await run_llm_jobs(limit=limit)40                out.print(stats)41                if once or not stats["claimed"] or stats.get("skipped_budget"):42                    return4344        run_async(go())4546    @app.command()47    def metrics(all: Annotated[bool, typer.Option("--all", help="every company (default: active in the last 90 days)")] = False,48                company: Annotated[str | None, typer.Option("--company", help="one company slug or id")] = None) -> None:49        """Compute company metrics (activity, hiring momentum, AI adoption, velocity, CCI…) into metrics_current + metric_series."""50        from companyatlas.db import fetch_val, transaction51        from companyatlas.services.metrics import compute_company_metrics5253        async def go() -> None:54            ids = None55            if company:56                async with transaction() as conn:57                    cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company)58                if not cid:59                    console.print(f"[red]company not found: {company}[/]")60                    raise typer.Exit(1)61                ids = [cid]62            out.print(await compute_company_metrics(ids, all_companies=all))63            if company:64                async with transaction() as conn:65                    from companyatlas.db import fetch_all6667                    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])68                t = Table(title=f"metrics · {company}")69                for col in ("metric", "value", "confidence", "formula", "computed_at"):70                    t.add_column(col)71                for r in rows:72                    t.add_row(r["metric"], f"{r['value']:.2f}", f"{r['confidence']:.2f}", r["formula_version"], str(r["computed_at"])[:19])73                out.print(t)7475        run_async(go())7677    @app.command()78    def daily(day: Annotated[str | None, typer.Option("--day", help="YYYY-MM-DD (UTC) or 'today'")] = None,79              catch_up: Annotated[bool, typer.Option("--catch-up", help="fill every missing day up to yesterday")] = False,80              include_today: Annotated[bool, typer.Option("--include-today")] = False) -> None:81        """Daily aggregates: company_daily, global_daily (activity index, baseline 100), baselines."""82        from companyatlas.services.metrics import compute_daily, compute_daily_catch_up8384        async def go() -> None:85            if catch_up:86                results = await compute_daily_catch_up(include_today=include_today)87                out.print({"days": len(results), "last": results[-1] if results else None})88                return89            d = datetime.now(UTC).date() if day in (None, "today") else date.fromisoformat(day)90            out.print(await compute_daily(d))9192        run_async(go())9394    @app.command()95    def signals(company: Annotated[str | None, typer.Option("--company")] = None) -> None:96        """Detect company / industry / country signals (hiring surge, launch build-up, expansion, AI acceleration…)."""97        from companyatlas.db import fetch_all, fetch_val, transaction98        from companyatlas.services.signals import compute_signals99100        async def go() -> None:101            ids = None102            if company:103                async with transaction() as conn:104                    cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company)105                ids = [cid] if cid else []106            out.print(await compute_signals(ids))107            async with transaction() as conn:108                rows = await fetch_all(conn, """select coalesce(co.slug, s.scope || ':' || s.scope_key) as who, s.kind, s.strength, s.confidence, s.title109                                                from signals s left join companies co on co.id = s.company_id where s.status = 'active' order by s.strength desc limit 30""")110            t = Table(title="active signals")111            for col in ("scope", "kind", "strength", "confidence", "title"):112                t.add_column(col)113            for r in rows:114                t.add_row(r["who"], r["kind"], f"{r['strength']:.2f}", f"{r['confidence']:.2f}", r["title"])115            out.print(t)116117        run_async(go())118119    @app.command()120    def trends(days: int = 1, window: int = 7) -> None:121        """Extract trending terms from event/news titles for the last N days and print momentum."""122        from companyatlas.services.trends import compute_trends_range, store_momentum_snapshots, trend_momentum123124        async def go() -> None:125            for r in await compute_trends_range(days):126                out.print(r)127            await store_momentum_snapshots()128            items = await trend_momentum(window)129            t = Table(title=f"trend momentum · {window}d")130            for col in ("term", "mentions", "companies", "momentum"):131                t.add_column(col)132            for it in items[:25]:133                t.add_row(it["term"], str(it["mentions"]), str(it["companies"]), f"{it['momentum']:+.2f}")134            out.print(t)135136        run_async(go())137138    @app.command("alerts-eval")139    def alerts_eval(event: Annotated[list[str] | None, typer.Option("--event", help="event id(s); default = sweep")] = None) -> None:140        """Evaluate alerts for given events, or run the catch-up sweep (+ metric alerts)."""141        from companyatlas.services.alerts import evaluate_alerts, sweep142143        run_async(_print(evaluate_alerts(event) if event else sweep()))144145    @app.command("llm-test")146    def llm_test(prompt: str = "Reply with a JSON object {\"ok\": true, \"model\": \"<your model name>\"}", tier: str = "small", json_mode: bool = True) -> None:147        """Live round-trip against the configured LLM endpoint: prints health, model, latency and the answer."""148        from pydantic import BaseModel149150        from companyatlas.config import settings151        from companyatlas.services.llm.gateway import LLMError, get_provider152153        class Probe(BaseModel):154            ok: bool = True155            model: str | None = None156            answer: str | None = None157158        async def go() -> None:159            if not settings.llm_configured:160                console.print("[red]LLM not configured (CA_LLM_BASE_URL / CA_LLM_ENABLED)[/]")161                raise typer.Exit(1)162            p = get_provider()163            h = await p.health()164            out.print({"health": h.ok, "base_url": h.base_url, "latency_ms": h.latency_ms, "models": h.models[:20], "error": h.error})165            t0 = time.monotonic()166            try:167                if json_mode:168                    res = await p.complete_json(tier, "You are a health probe. Answer briefly.", prompt, Probe, max_tokens=120)169                    answer = res.data.model_dump()170                else:171                    res = await p.complete_text(tier, "You are a health probe. Answer briefly.", prompt, max_tokens=120)172                    answer = res.data173            except LLMError as exc:174                console.print(f"[red]LLM error: {exc}[/]")175                raise typer.Exit(1) from exc176            out.print({"model": res.model, "latency_ms": res.latency_ms, "wall_ms": int((time.monotonic() - t0) * 1000), "request_tokens": res.request_tokens,177                       "response_tokens": res.response_tokens, "attempts": res.attempts, "repaired": res.repaired, "answer": answer})178            await p.close()179180        run_async(go())181182    @app.command("reprocess-events")183    def reprocess(since: Annotated[str, typer.Option("--since", help="ISO date/datetime or e.g. 7d")] = "7d",184                  company: Annotated[str | None, typer.Option("--company")] = None, limit: int = 5000) -> None:185        """Re-run deterministic rules on processed changes (no refetch; dedupe keys keep it idempotent)."""186        from companyatlas.db import fetch_val, transaction187        from companyatlas.services.events import reprocess_events188189        async def go() -> None:190            cid = None191            if company:192                async with transaction() as conn:193                    cid = await fetch_val(conn, "select id from companies where slug = :s or id = :s", s=company)194            out.print(await reprocess_events(_parse_since(since), limit=limit, company_id=cid))195196        run_async(go())197198    @app.command()199    def events(company: Annotated[str | None, typer.Option("--company")] = None, type: Annotated[str | None, typer.Option("--type")] = None,200               limit: int = 50) -> None:201        """List recent events."""202        from companyatlas.services.events import list_events203204        async def go() -> None:205            rows = await list_events(company=company, event_type=type, limit=limit)206            t = Table(title="events")207            for col in ("detected", "company", "subtype", "imp", "conf", "origin", "status", "title"):208                t.add_column(col)209            for r in rows:210                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])211            out.print(t)212213        run_async(go())214215    @app.command()216    def digest(company: Annotated[str | None, typer.Option("--company")] = None, scope: Annotated[str | None, typer.Option("--scope", help="industry|country")] = None,217               key: Annotated[str | None, typer.Option("--key")] = None, days: int = 7) -> None:218        """Print digest JSON for a company or an industry/country scope."""219        from companyatlas.services.digest import company_digest, scope_digest220221        async def go() -> None:222            if company:223                data = await company_digest(company, days=days)224            elif scope and key:225                data = await scope_digest(scope, key, days=days)226            else:227                console.print("[red]--company or --scope + --key required[/]")228                raise typer.Exit(1)229            out.print_json(json.dumps(data, default=str))230231        run_async(go())232233    @app.command("retention-intel")234    def retention(dry_run: Annotated[bool, typer.Option("--dry-run")] = True) -> None:235        """Prune unchanged observations > 90 d, crawl_runs > 30 d, archive finished llm_jobs > 60 d (dry-run by default)."""236        from companyatlas.services.retention import run_retention237238        run_async(_print(run_retention(dry_run=dry_run)))239240241async def _print(coro) -> None:  # type: ignore[no-untyped-def]242    out.print(await coro)243244245def _parse_since(value: str) -> datetime:246    v = value.strip().lower()247    if v.endswith("d") and v[:-1].isdigit():248        return datetime.now(UTC) - timedelta(days=int(v[:-1]))249    if v.endswith("h") and v[:-1].isdigit():250        return datetime.now(UTC) - timedelta(hours=int(v[:-1]))251    dt = datetime.fromisoformat(value)252    return dt if dt.tzinfo else dt.replace(tzinfo=UTC)253