intelligence layer (events, clustering, LLM gateway + prompts, metrics, signals, trends, alerts, retention) and API v1 (74 endpoints, SSE, rate limits, exports, admin)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
64 changed files +10,114 −6
modified
docs/API.md
+69 −1
@@ -83,7 +83,7 @@ type Signal = { id: string; company_id: string | null; scope: string; scope_key: | |||||
| 83 | ### Companies | 83 | ### Companies | ||
| 84 | | Method | Path | Notes | | 84 | | Method | Path | Notes | | ||
| 85 | |---|---|---| | 85 | |---|---|---| | ||
| 86 | −| GET | `/companies` | filters: `q, country, industry, tier, public (bool), status, has_events (bool), sort=activity\|events\|hiring\|name\|importance\|recent, sparkline=1` → page of `CompanyCard` | | 86 | +| GET | `/companies` | filters: `q, country, industry, tier, public (bool), status, has_events (bool), sort=activity\|events\|hiring\|name\|importance\|recent\|relevance` (relevance is automatic with `q`), `sparkline=1` → page of `CompanyCard` | | ||
| 87 | | GET | `/companies/{slug_or_id}` | `CompanyCard & { aliases: string[], domains: {domain, kind}[], relationships: {kind, company: {slug, display_name} \| null, to_name, valid_from, valid_to, confidence}[], metrics_detail: {metric, value, confidence, computed_at, inputs}[], sensors_by_surface: Record<string, number>, coverage: {historical_coverage, first_observed_at, days_observed, sensor_uptime}, signals: Signal[], sparklines: {activity_30d: number[], hiring_90d: number[]} }` | | 87 | | GET | `/companies/{slug_or_id}` | `CompanyCard & { aliases: string[], domains: {domain, kind}[], relationships: {kind, company: {slug, display_name} \| null, to_name, valid_from, valid_to, confidence}[], metrics_detail: {metric, value, confidence, computed_at, inputs}[], sensors_by_surface: Record<string, number>, coverage: {historical_coverage, first_observed_at, days_observed, sensor_uptime}, signals: Signal[], sparklines: {activity_30d: number[], hiring_90d: number[]} }` | | ||
| 88 | | GET | `/companies/{slug}/events` | filters `event_type, event_subtype, since, until, min_importance, surface`; `sort=recent\|importance` → page of `Event` | | 88 | | GET | `/companies/{slug}/events` | filters `event_type, event_subtype, since, until, min_importance, surface`; `sort=recent\|importance` → page of `Event` | | ||
| 89 | | GET | `/companies/{slug}/timeline?filter=all\|products\|jobs\|pricing\|leadership\|locations\|legal\|news\|developer&limit=200` | `{ items: (Event & {day: string})[], days: {day, count}[] }` grouped for the timeline UI | | 89 | | GET | `/companies/{slug}/timeline?filter=all\|products\|jobs\|pricing\|leadership\|locations\|legal\|news\|developer&limit=200` | `{ items: (Event & {day: string})[], days: {day, count}[] }` grouped for the timeline UI | | ||
@@ -168,6 +168,74 @@ type Signal = { id: string; company_id: string | null; scope: string; scope_key: | |||||
| 168 | | GET | `/admin/costs?days=30` | `{ items: {day, dimension, key, units, cost_estimate}[], per_1000_companies, per_million_observations, per_meaningful_event }` | | 168 | | GET | `/admin/costs?days=30` | `{ items: {day, dimension, key, units, cost_estimate}[], per_1000_companies, per_million_observations, per_meaningful_event }` | | ||
| 169 | | POST | `/admin/cache/clear` | | | 169 | | POST | `/admin/cache/clear` | | | ||
| 170 | 170 | ||||
| 171 | +## Rate limits & auth | ||||
| 172 | + | ||||
| 173 | +| Tier | How | Limit (per client, per minute) | | ||||
| 174 | +|---|---|---| | ||||
| 175 | +| anonymous | no header — keyed by client IP (first hop of `X-Forwarded-For`) | 120 | | ||||
| 176 | +| authenticated | `X-CA-API-Key: <key>` (create with `catlas api-key create <name>`) | 600 | | ||||
| 177 | +| paid | `X-CA-API-Key` with `tier = paid` (`catlas api-key create <name> --tier paid`) | 3 000 | | ||||
| 178 | +| internal | `X-CA-API-Key` with `tier = internal` | unlimited | | ||||
| 179 | +| admin | valid `X-CA-Admin-Token` | bypass (also required for `/admin/*`) | | ||||
| 180 | + | ||||
| 181 | +Token bucket per process (refills continuously at `limit / 60` tokens per second). Every limited response carries `X-RateLimit-Limit`, | ||||
| 182 | +`X-RateLimit-Remaining` and `X-RateLimit-Tier`; when the bucket is empty the API answers `429 { "detail": "rate limit exceeded" }` with a | ||||
| 183 | +`Retry-After` (seconds). `/health`, `/ready`, `/api/v1/docs` and `/api/v1/openapi.json` are never limited. Keys are stored as sha256 hashes | ||||
| 184 | +(`api_keys.key_hash`); the raw key is printed once by the CLI. `last_used_at` / `request_count` are updated lazily (≈ every 30 s). Owner endpoints | ||||
| 185 | +(`/watchlist`, `/alerts*`) need `X-CA-Owner-Token` (client-generated, 24–200 chars); the owner row is created on first use and the token is only | ||||
| 186 | +ever stored hashed. Admin endpoints answer `401 { "detail": "admin token required" }` without a valid `X-CA-Admin-Token`. | ||||
| 187 | + | ||||
| 188 | +## Implementation notes (API v1.0 — additive details) | ||||
| 189 | + | ||||
| 190 | +- **Caching / ETags.** Cached public aggregates (`/pulse` 60 s, `/stats` 60 s, `/system` 30 s, `/rankings` 120 s, `/industries*`, `/countries*`, | ||||
| 191 | + `/index`, `/map`, `/trends`, `/events/types`, `/events/summary`, `/stats/history` 300 s, `/sitemap` 600 s, `/methodology` 3600 s) return | ||||
| 192 | + `Cache-Control: public, max-age=N, stale-while-revalidate=2N` plus a weak `ETag`; a matching `If-None-Match` yields `304`. Other public GETs | ||||
| 193 | + carry `public, max-age=30…600` (per resource volatility); `/live*`, owner and admin routes are `no-store`. `POST /admin/cache/clear?prefix=` | ||||
| 194 | + clears one key prefix (e.g. `pulse`, `industries:`) or everything. | ||||
| 195 | +- **Pagination totals.** `total` is exact up to **10 000** (bounded count) so deep feeds stay cheap; `pages` derives from it. | ||||
| 196 | +- **`/live`** also accepts `country` and `industry`; the payload is `{ items, count, cursor, server_time }` — pass `cursor` back as `since`. | ||||
| 197 | +- **`/live/stream`** accepts `?since=&event_type=&min_importance=&max_s=` (`max_s` ≤ 3600 bounds the connection for proxies with idle timeouts; | ||||
| 198 | + the stream ends with `event: end` carrying the last cursor). The first message is always an `event: heartbeat` with `{at, cursor}`; | ||||
| 199 | + `event: event` messages carry `id: <event id>`. Response headers include `X-Accel-Buffering: no`. | ||||
| 200 | +- **`/companies`** gains `sort=relevance` (automatic when `q` is present); `q` uses FTS (`companies.search`) for ≥ 3 characters with a | ||||
| 201 | + trigram fallback and a prefix match for shorter strings. `sparkline=1` adds 30 daily `activity_score` points. | ||||
| 202 | +- **`/companies/{slug}`** additionally returns `company_type, employees, wikidata_id, indexed, discovered_at, first_observed_at, last_change_at, | ||||
| 203 | + recent_events: Event[10]` and `sparkline`. | ||||
| 204 | +- **`/companies/{slug}/timeline`** accepts `filter=corporate` (FINANCING, M&A, PARTNERSHIP, STRATEGY) and `before=<iso>`; items carry `day`. | ||||
| 205 | +- **`/companies/{slug}/jobs`** accepts `department`, `remote`, `sort=recent|title|posted`; `q` is a trigram `ILIKE` on `title`. | ||||
| 206 | +- **`/companies/{slug}/history`** accepts `versions=1…20`; `/companies/{slug}/sensors?include_retired=1`; `/companies/{slug}/news?category=`; | ||||
| 207 | + `/companies/{slug}/locations?status=all`; `/companies/{slug}/pricing?history_limit=`. | ||||
| 208 | +- **`/companies/compare`** accepts `days=` (7–365) for the activity series; unknown slugs → `404 company not found: <slug>`; fewer than 2 → 422. | ||||
| 209 | +- **`/events`** accepts `status=active|retracted|duplicate|review|all` (default `active`). Retracted events expose `retracted_reason`. | ||||
| 210 | + `/events/{id}` embeds the full `CompanyCard` under `company` (a superset of `CompanyRef`), `sources` (falls back to the event's own | ||||
| 211 | + `source_url` when `event_sources` is empty), `change` and `cluster`. | ||||
| 212 | +- **`/events/types`** lists every taxonomy type/subtype (count `0` when unseen) so filter UIs can render on an empty database. | ||||
| 213 | +- **`/events/summary`** items also carry `previous` (count of the preceding window). | ||||
| 214 | +- **`/rankings`** items are `CompanyCard & { rank, value, delta }`; the payload also lists `kinds`. `hiring_growth`/`hiring_decline` read | ||||
| 215 | + `hiring_momentum_7d|30d|90d` according to the window; `pricing_changes` counts PRICING events in the window; `delta` is the difference with | ||||
| 216 | + the `metric_series` value at the start of the window (`null` when no history yet). | ||||
| 217 | +- **`/countries/{code}`** accepts an ISO-2 code or a name slug (`/countries/canada`); `CountryRow` includes `slug` and `subregion`. | ||||
| 218 | + `/industries/{slug}` adds `children`, `signals`, `companies_total`; `trending` is `[]` until per-industry trend data exists (never faked). | ||||
| 219 | +- **`/signals`** accepts `scope_key`, `company`, `min_strength`; items include a `company` ref when scoped to a company. | ||||
| 220 | +- **`/search`** people/products use trigram similarity (`migrations/pending/api.sql` adds the two supporting GIN indexes). `/search/suggest` | ||||
| 221 | + items add `slug` / `code` / `event_type` next to `href`. | ||||
| 222 | +- **`/ask`** returns `engine` (`deterministic` | `llm`) and `events_total`; the parser is `services/llm/ask.py` when present (deterministic | ||||
| 223 | + parse + optional LLM refinement), else `api/ask_fallback.py`. Answers only phrase measured counts. | ||||
| 224 | +- **`/snapshots/{id}`** accepts `include=text,blocks,extracted` and returns `text_truncated` (text capped at 200 kB), `sensor`, `object_keys`. | ||||
| 225 | + `/snapshots/{a}/diff/{b}` orders the pair by `fetched_at`, returns `source: "change"` (stored diff, with `change_id`) or `"computed"` | ||||
| 226 | + (`sdk.diff.compare`); `501` when the diff engine is unavailable, `404` when block objects are missing. | ||||
| 227 | +- **Exports.** `/export/events.{fmt}` also accepts `until`, `company`, `min_importance`, `limit` (≤ 10 000); `/export/companies.{fmt}` accepts | ||||
| 228 | + `status`, `tier`, `limit` (≤ 20 000); `/export/jobs.{json|ndjson|csv}` accepts `status=open|removed|all`, `country`, `ai`, `limit` (≤ 10 000). | ||||
| 229 | + CSV flattens the company ref (`company_slug`, `company_name`, `company_domain`, `country`). | ||||
| 230 | +- **`/sitemap`** pages hold 5 000 entries; `kind=companies` returns `total`; industries/countries only list entries with ≥ 1 active company. | ||||
| 231 | +- **Admin additions.** `GET /admin/sensors` accepts `surface`, `sort=recent|next_run|failures|quality|changes|created`; sensor actions return | ||||
| 232 | + `{ ok, action, sensor, queued? , interval_s? }` (`set_interval` is clamped to `CA_MIN_INTERVAL_S…CA_MAX_INTERVAL_S` and recomputes the tier). | ||||
| 233 | + `POST /admin/companies` → `201 { ok, company, queued }` (409 when the registrable domain already exists; 422 for non-http(s) URLs, unknown | ||||
| 234 | + country codes or industry slugs). `GET /admin/failures` returns `by_class` and `classes`; `GET /admin/queue` returns `counts` + `items`; | ||||
| 235 | + `POST /admin/queue/requeue-dead { kind? }`; `GET /admin/llm` includes `stats`; `POST /admin/reviews/{id}` accepts `label` (correct | | ||||
| 236 | + duplicate | noise | misclassified) feeding `/admin/quality.calibration`; retract/restore append an audit trail under `payload._audit` and | ||||
| 237 | + never delete; `GET /admin/storage` reports the object store. `/admin/overview` also returns `companies_by_onboarding`, `scheduler_heartbeat`. | ||||
| 238 | + | ||||
| 171 | ## Conventions for implementers | 239 | ## Conventions for implementers | ||
| 172 | - Every list endpoint is bounded (`per_page ≤ 200`, `limit ≤ 500`), uses indexed predicates, and returns `Cache-Control: public, max-age=60` for public aggregates (`/pulse`, `/stats`, `/rankings`, `/industries`, `/countries`) and `no-store` for `/live*`, owner and admin routes. | 240 | - Every list endpoint is bounded (`per_page ≤ 200`, `limit ≤ 500`), uses indexed predicates, and returns `Cache-Control: public, max-age=60` for public aggregates (`/pulse`, `/stats`, `/rankings`, `/industries`, `/countries`) and `no-store` for `/live*`, owner and admin routes. | ||
| 173 | - Company lookups accept slug **or** id. Unknown → 404 `{detail: "company not found"}`. | 241 | - Company lookups accept slug **or** id. Unknown → 404 `{detail: "company not found"}`. | ||
added
docs/EVENT-TAXONOMY.md
+87 −0
@@ -0,0 +1,87 @@ | |||||
| 1 | +# Event taxonomy — deterministic rules, wording policy, importance & confidence | ||||
| 2 | + | ||||
| 3 | +Owner: intelligence layer (`services/events.py`, `services/clustering.py`, rules version `rules-v1`, schema `event-v1`). | ||||
| 4 | +Vocabulary: `taxonomy.EVENT_SUBTYPES` (subtype → type + default importance). The LLM classifier may only pick from that list. | ||||
| 5 | + | ||||
| 6 | +## Pipeline | ||||
| 7 | + | ||||
| 8 | +``` | ||||
| 9 | +changes (status='pending', kind ≥ meaningful) ─▶ derive_events(change, company, sensor, baseline) pure rules, no I/O | ||||
| 10 | + ─▶ persist_change_events() events + event_sources + clusters + review_queue + llm_jobs | ||||
| 11 | + ─▶ changes.status = 'processed' · sensors.event_count · companies.last_event_at | ||||
| 12 | + ─▶ alerts.evaluate_alerts(new_event_ids) | ||||
| 13 | +``` | ||||
| 14 | + | ||||
| 15 | +- `catlas process-changes [--limit] [--loop]` · periodic `process-changes` every 20 s (SKIP LOCKED, batch 200). | ||||
| 16 | +- `catlas reprocess-events --since 7d [--company]` re-runs the rules on processed changes **without refetching**; dedupe keys make it idempotent, | ||||
| 17 | + new rules simply add the events they now produce. | ||||
| 18 | +- **Noise / minor changes never produce events** (they are archived if still pending). Meaningful (≥ 0.40), major (≥ 0.65), critical (≥ 0.85) do. | ||||
| 19 | + | ||||
| 20 | +## Rules → subtypes | ||||
| 21 | + | ||||
| 22 | +| Family | Input (`structured_delta`) | Subtype(s) | Title pattern | | ||||
| 23 | +|---|---|---|---| | ||||
| 24 | +| HIRING | `jobs.added/removed/open_before/open_after` | `JOB_COUNT_INCREASE` when net > 0 (entities.jobs ≤ 50) | *12 new positions detected on careers page* | | ||||
| 25 | +| | | `JOB_COUNT_DECREASE` when net < 0 | *7 monitored job listings no longer visible on careers page* | | ||||
| 26 | +| | AI keyword in added titles (`taxonomy.AI_KEYWORDS`) or `is_ai` | `AI_HIRING` | *2 AI-related positions detected on careers page* | | ||||
| 27 | +| | ≤ 5 jobs added | `NEW_JOB` per job | *New position listed: Senior ML Engineer (Toronto, CA)* | | ||||
| 28 | +| | added ≥ mean + 2σ of `baselines.jobs_new_weekly` (≥ 4 samples) — fallback ≥ 10 and ≥ 50 % of open_before | `HIRING_SURGE` | *Hiring surge signal: 25 new positions detected in one observation (baseline ≈ 3.0 new/week)* | | ||||
| 29 | +| | removed ≥ 10, ≥ 50 % of open_before and open_after ≤ 50 % | `HIRING_FREEZE_SIGNAL` (+ review `unexpected_activity`) | *Hiring slowdown signal: 30 of 40 monitored listings no longer visible* | | ||||
| 30 | +| PRICING | `plans.price_changed` | `PRICE_INCREASE` / `PRICE_DECREASE` (old/new values, `payload.pct`) | *Pro plan price observed at $59 (was $49)* | | ||||
| 31 | +| | `plans.added` | `NEW_PRICING_TIER` (tag `enterprise` when contact-sales) | *New pricing tier listed: Enterprise (contact sales)* | | ||||
| 32 | +| | `plans.removed` | `PRICING_TIER_REMOVED` | *Pricing tier no longer listed: Starter* | | ||||
| 33 | +| | pricing surface, no typed delta | `PRICING_CHANGE` (text-diff) | *Pricing page materially updated (3 blocks changed)* | | ||||
| 34 | +| LEADERSHIP | `people.added` (executive) | `NEW_EXECUTIVE` | *Jane Doe listed as Chief Financial Officer on leadership page* | | ||||
| 35 | +| | `people.removed` (executive) | `EXECUTIVE_NO_LONGER_LISTED` | *Jane Doe no longer listed on leadership page* | | ||||
| 36 | +| | `people.title_changed` | `EXECUTIVE_TITLE_CHANGE` | *Ann Lee now listed as COO (was VP Operations)* | | ||||
| 37 | +| | ≥ 3 people changes, or non-executive changes only | `LEADERSHIP_CHANGE` aggregate | *Leadership page updated: 2 added, 1 no longer listed* | | ||||
| 38 | +| PRODUCT | `products.added` / `removed` | `NEW_PRODUCT` / `PRODUCT_REMOVED` | *New product listed: Atlas Pro* · *Product no longer listed: Atlas Lite* | | ||||
| 39 | +| LOCATION | `locations.added` | `NEW_LOCATION` (kind-aware label) | *New office listed: Toronto, CA* | | ||||
| 40 | +| | `locations.new_countries` | `COUNTRY_EXPANSION` | *New country presence listed: Japan (Tokyo)* | | ||||
| 41 | +| | `locations.removed` | `OFFICE_REMOVED` | *Office no longer listed: Berlin, DE* | | ||||
| 42 | +| COMMUNICATION / IR / DEVELOPER | `news.added` (≤ 20 per change) | `NEWS_RELEASE` · `BLOG_POST` · `CHANGELOG_ENTRY` · `INVESTOR_UPDATE` · `EARNINGS_RELEASE` (title heuristics: earnings/quarter/fiscal → earnings; investor/annual report/dividend → IR; category/surface otherwise) | *News release: <title>* | | ||||
| 43 | +| DEVELOPER (text-diff) | docs/developer · api · changelog | `DOC_CHANGE` · `API_CHANGE` · `CHANGELOG_ENTRY` | *Documentation updated (5 sections changed)* | | ||||
| 44 | +| LEGAL (text-diff) | legal_terms · legal_privacy · security | `TERMS_CHANGE` · `PRIVACY_POLICY_CHANGE` · `SECURITY_UPDATE` (+ review `legal_sensitive`) | *Terms of service page materially updated (3 sections changed)* — sections from block `path` | | ||||
| 45 | +| WEBSITE | homepage meaningful · major+ · `meta.title_changed` | `WEBSITE_CHANGE` · `HOMEPAGE_REDESIGN` · `MESSAGING_CHANGE` | *Homepage materially redesigned (9 blocks changed, 62% of text)* | | ||||
| 46 | +| other surfaces (text-diff) | products/services/solutions · investor_relations · sustainability · status · careers · locations · about | `PRODUCT_UPDATE` · `INVESTOR_UPDATE` · `SUSTAINABILITY_UPDATE` · `OPERATIONS_UPDATE` · `WEBSITE_CHANGE` | *<Surface> updated (n sections changed)* | | ||||
| 47 | + | ||||
| 48 | +Surfaces with no rule (`other`, `partners`, `customers`, `support`, …) and meaningful+ significance produce no deterministic event; the change is | ||||
| 49 | +queued for the LLM classifier instead (`llm_jobs.kind = classify_change`), together with ambiguous surfaces (homepage/about/products text-only). | ||||
| 50 | + | ||||
| 51 | +## Wording policy (spec §167–168) | ||||
| 52 | + | ||||
| 53 | +- Verbs: **detected, observed, listed, now listed, no longer listed, no longer visible, appears, signal**. | ||||
| 54 | +- Never: *fired, laid off, layoffs, shut down, bankrupt, collapsed* (`taxonomy.FORBIDDEN_WORDING`). `safe_wording()` rewrites them defensively; | ||||
| 55 | + the LLM schemas reject them at validation time. | ||||
| 56 | +- Disappearance ≠ departure: `EXECUTIVE_NO_LONGER_LISTED` summaries say so explicitly; `HIRING_FREEZE_SIGNAL` / `hiring_freeze` are labelled *signal*. | ||||
| 57 | +- Titles ≤ 200 chars, summaries ≤ 600; entity lists bounded to 50; per-entity events capped (5 jobs, 10 people, 20 news items). | ||||
| 58 | + | ||||
| 59 | +## Importance & confidence | ||||
| 60 | + | ||||
| 61 | +- `importance = default(subtype) × (0.7 + 0.6·significance) × (1 + 0.3·magnitude)` clamped to [0.05, 1]. `magnitude` is rule-specific and 0–1: | ||||
| 62 | + relative job delta (`n / max(open_before, 5)`), |price pct| / 50, headquarters vs office, country expansion 0.6, launch-tagged news 0.5, text | ||||
| 63 | + ratio × 2 for page changes. | ||||
| 64 | +- `confidence` = evidence quality (`taxonomy.EVIDENCE_CONFIDENCE`): structured ATS JSON **0.95** (jobs_board surface, Greenhouse/Lever/Ashby/ | ||||
| 65 | + SmartRecruiters/Workday/JSON connectors), JSON-LD / feed **0.9**, HTML extraction **0.8**, text-diff-only **0.7**. `confidence_label` via | ||||
| 66 | + `taxonomy.confidence_label` (VERIFIED ≥ 0.95, HIGH_CONFIDENCE ≥ 0.85, LIKELY ≥ 0.7, INFERRED ≥ 0.5, else LOW_CONFIDENCE). | ||||
| 67 | +- Corroboration: a second surface in the same cluster adds **+0.03 per extra surface**, capped at 0.99 (clustering below). | ||||
| 68 | +- Review queue: `major_event` for critical changes, `legal_sensitive` for legal inferences, `low_confidence` (< 0.5), `unexpected_activity` for freeze signals. | ||||
| 69 | + | ||||
| 70 | +## Idempotency & clustering | ||||
| 71 | + | ||||
| 72 | +- `events.dedupe_key = sha(company, subtype, normalised entity key, sensor, detection day)` → inserting the same change twice is a no-op | ||||
| 73 | + (`on conflict do nothing`). Entity keys: job label, plan name, person name, product name, place, news title, `jobs:<before>><after>` for aggregates, | ||||
| 74 | + `page:<sensor>` for page-level events. | ||||
| 75 | +- `event_clusters.cluster_key = sha(company, subtype, normalised entity key, 7-day bucket)` — surface-independent, so the same press release seen | ||||
| 76 | + on the newsroom **and** the feed, or the same executive on leadership **and** about, fold into one cluster. The first event is canonical; later | ||||
| 77 | + ones are stored with `status='duplicate'` + `cluster_id`, the canonical event gains an `event_sources` row (`kind='corroboration'`), | ||||
| 78 | + `payload.sources[]`, `payload.corroborations` and the confidence bump. Clusters track `source_count`, `surfaces[]`, `confidence`. | ||||
| 79 | + | ||||
| 80 | +## LLM enrichment hooks | ||||
| 81 | + | ||||
| 82 | +- `classify_change` (small model) when no deterministic event or ambiguous surface, significance ≥ `CA_LLM_MIN_SIGNIFICANCE`, within | ||||
| 83 | + `CA_LLM_DAILY_BUDGET`, only when `settings.llm_configured`. Material classifications create `origin='llm'` events (dedupe | ||||
| 84 | + `sha(company, 'llm', change, subtype)`); when a deterministic event of the same subtype already exists for the change, the classification enriches | ||||
| 85 | + it instead (`origin='hybrid'`, `payload.llm_classification`). | ||||
| 86 | +- `summarize_event` (medium model) for legal, homepage/messaging and news/IR events that carry diff content → `events.summary`, `origin='hybrid'`, | ||||
| 87 | + `payload.llm` (key points or legal sections + materiality), model/prompt/schema versions recorded. See docs/LLM.md. | ||||
added
docs/LLM.md
+91 −0
@@ -0,0 +1,91 @@ | |||||
| 1 | +# LLM enrichment — gateway, prompts, worker, budgets | ||||
| 2 | + | ||||
| 3 | +LLMs are enrichment, never the crawler (spec §2.4, §24–25). Everything deterministic happens first (`docs/EVENT-TAXONOMY.md`); the model only | ||||
| 4 | +sees bounded, already-diffed content, answers in strict JSON, and every output is validated, versioned and attributed. | ||||
| 5 | + | ||||
| 6 | +## Configuration (`config.Settings`) | ||||
| 7 | + | ||||
| 8 | +| Setting | Default | Meaning | | ||||
| 9 | +|---|---|---| | ||||
| 10 | +| `CA_LLM_BASE_URL` / `CA_LLM_API_KEY` | `https://www.llm-api.io/v1` (`.env`) / key in `deploy/.llm-key` (git-ignored) | OpenAI-compatible endpoint; `settings.llm_configured` = enabled **and** URL set | | ||||
| 11 | +| `CA_LLM_ENABLED` | true | master switch — when off, jobs stay pending and the worker logs once | | ||||
| 12 | +| `CA_LLM_SMALL_MODEL` / `MEDIUM` / `LARGE` / `EMBEDDING` | `qwen3-4b-instruct-2507-4bit` / `qwen3.6-35b-a3b-4bit` / `qwen3.8-27b-4bit` / `qwen3-embedding-0.6b-8bit` | tiers used by tasks | | ||||
| 13 | +| `CA_LLM_DAILY_BUDGET` | 1500 | max jobs per UTC day (enqueue side counts created jobs, worker side counts finished jobs) | | ||||
| 14 | +| `CA_LLM_MIN_SIGNIFICANCE` | 0.40 | changes below this never reach a model | | ||||
| 15 | +| `CA_LLM_TIMEOUT` | 300 s | per request (the server loads models on demand: first call 30–60 s) | | ||||
| 16 | +| `CA_LLM_MAX_TRIES` / `BACKOFF_INITIAL_S` / `BACKOFF_MAX_S` | 6 / 15 / 120 | 429/503/5xx/timeouts: 15 → 30 → 60 → 120 → 120 s (Retry-After honoured) | | ||||
| 17 | +| `CA_LLM_JOB_MAX_ATTEMPTS` | 3 | retryable failures re-queue the job until this | | ||||
| 18 | +| `CA_LLM_CONTEXT_BLOCK_BYTES` | 3072 | before/after text per block sent to the model (≤ 12 blocks) | | ||||
| 19 | +| `CA_PROMPTS_DIR` | `<repo>/prompts` | prompt files location | | ||||
| 20 | + | ||||
| 21 | +## Gateway (`services/llm/gateway.py`) | ||||
| 22 | + | ||||
| 23 | +`LLMProvider` (abstract) → `OpenAICompatibleProvider` (httpx, `/chat/completions`, `/models`, `/embeddings`): | ||||
| 24 | + | ||||
| 25 | +- `complete_json(tier, system, user, schema, max_tokens)` — `response_format={"type": "json_object"}`, the compact JSON schema of the pydantic model | ||||
| 26 | + appended to the system prompt, `<think>` blocks / code fences / prose tolerated when extracting the object, **strict pydantic validation**, and | ||||
| 27 | + **one repair round-trip** (the model sees its own answer plus the validation error). Still invalid → `LLMValidationError` (job failed, nothing stored). | ||||
| 28 | +- `complete_text(...)`, `embed(texts)` (embedding tier; reserved for later search/clustering work), `health()` (model list + latency). | ||||
| 29 | +- Returns `LLMResult(data, model, request_tokens, response_tokens, latency_ms, attempts, repaired)`; tokens go to `llm_jobs` and `cost_ledger` | ||||
| 30 | + (`dimension='llm'`, key = model, units = tokens). | ||||
| 31 | +- `get_provider()` / `set_provider()` for a process-wide instance or test doubles. The gateway is the only module allowed to call the endpoint. | ||||
| 32 | + | ||||
| 33 | +Sticky model per stream: the worker drains one job kind at a time (classification → small model, summaries → medium model) so the on-demand | ||||
| 34 | +server does not evict models between calls. Use `catlas llm-test ["prompt"] [--tier medium]` for a live health + round-trip check. | ||||
| 35 | + | ||||
| 36 | +## Prompts (`prompts/<task>/v<N>.md`) | ||||
| 37 | + | ||||
| 38 | +Loaded by `services/llm/prompts.load_prompt(task, version=None)` (latest `vN` by default); the leading `<!-- … -->` comment is metadata, the rest is | ||||
| 39 | +the **system** prompt. The user message is always a compact JSON context — untrusted page text is never interpolated into instructions. | ||||
| 40 | +`events.prompt_version` stores `task/vN`; bump the file (v2.md) when wording changes materially and keep v1 for reproducibility. | ||||
| 41 | + | ||||
| 42 | +| Prompt | Tier | Schema (`services/llm/schemas.py`, version) | Used by | | ||||
| 43 | +|---|---|---|---| | ||||
| 44 | +| `change-classifier/v1` | small | `ChangeClassification` (`classify-v1`): subtype ∈ `EVENT_SUBTYPES` else OTHER, importance, confidence, title ≤ 120, summary ≤ 400, old/new value, entities, tags, language, `is_material` | `classify_change` jobs | | ||||
| 45 | +| `event-summarizer/v1` | medium | `EventSummary` (`summary-v1`): summary ≤ 400, key_points ≤ 5, confidence, language | `summarize_event` (non-legal) | | ||||
| 46 | +| `legal-diff/v1` | medium | `LegalDiffSummary` (`legal-v1`): sections_changed[{section, change}], materiality ∈ editorial/minor/material/unclear, summary, user_impact | `summarize_event` for LEGAL events | | ||||
| 47 | +| `industry-tagger/v1` | small | `IndustryTags` (`industry-v1`): slugs from the allowed list only, primary, keywords | `classify_industry` jobs → `companies.source_meta.llm_industries` (suggestion only) | | ||||
| 48 | +| `ask-router/v1` | small | `AskRoute` (`ask-v1`): intent, countries, industries, event types/subtypes, companies, window, keywords, answer_style | `services/llm/ask.route_question` (optional refinement of the deterministic parser) | | ||||
| 49 | + | ||||
| 50 | +All schemas reject the forbidden wording (`taxonomy.FORBIDDEN_WORDING`: fired, laid off, shut down…) at validation time; prompts state the ban and | ||||
| 51 | +the observational language ("detected", "no longer listed"). | ||||
| 52 | + | ||||
| 53 | +## Worker (`services/llm/enrich.py`) | ||||
| 54 | + | ||||
| 55 | +`run_llm_jobs(limit)` — periodic `llm-enrich` every 15 s, `catlas enrich [--limit] [--once]`: | ||||
| 56 | + | ||||
| 57 | +1. Budget check (finished jobs today < `CA_LLM_DAILY_BUDGET`), pick the sticky kind, claim jobs with `FOR UPDATE SKIP LOCKED` (`attempts += 1`). | ||||
| 58 | +2. Build the bounded context: company meta (name, domain, country, industries, description ≤ 400), surface, source URL, significance/kind, block | ||||
| 59 | + counts, ≤ 12 diff blocks (before/after ≤ 3 kB each), structured deltas with lists trimmed to 10 items. | ||||
| 60 | +3. Call the gateway, validate, write: | ||||
| 61 | + - `classify_change` → material & non-OTHER & importance ≥ 0.25 → new event `origin='llm'` (confidence capped at 0.9, `status='review'` + | ||||
| 62 | + review_queue when < 0.5, dedupe `sha(company, 'llm', change, subtype)`, event_sources, clustering, alerts). If a deterministic event with the | ||||
| 63 | + same subtype already exists for that change, it is enriched instead (`origin='hybrid'`, `payload.llm_classification`). `changes.status='enriched'`. | ||||
| 64 | + - `summarize_event` → `events.summary` (previous kept in `payload.summary_prev`), `origin='hybrid'`, `model_provider/model_name/prompt_version/ | ||||
| 65 | + schema_version`, `payload.llm` (key points, or legal sections + materiality + tag `materiality:<x>`). | ||||
| 66 | + - `classify_industry` → `companies.source_meta.llm_industries` (never overwrites registry industries). | ||||
| 67 | +4. `llm_jobs` gets status, model, prompt_version, tokens, latency, `result` JSON or `error`; retryable failures go back to `pending` until | ||||
| 68 | + `CA_LLM_JOB_MAX_ATTEMPTS`, a retryable failure also stops the current batch (the server is unhealthy — the periodic task returns). | ||||
| 69 | + | ||||
| 70 | +Enqueue side (`services/events.py`): `classify_change` for meaningful+ changes with no deterministic event or an ambiguous surface; | ||||
| 71 | +`summarize_event` for legal / homepage / messaging / news / IR events that have diff content. Both only when `settings.llm_configured`, above | ||||
| 72 | +`CA_LLM_MIN_SIGNIFICANCE`, within budget, and never twice for the same ref. | ||||
| 73 | + | ||||
| 74 | +## `/ask` helper (`services/llm/ask.py`) | ||||
| 75 | + | ||||
| 76 | +`parse_question(q, industries=, countries=)` is deterministic: countries (names/demonyms + registry table), industries (registry slugs/names), | ||||
| 77 | +intents → event types/subtypes/tags (hiring, pricing ± increase/decrease, AI, launch, leadership, expansion, legal, developer, financing, M&A, | ||||
| 78 | +communication), windows ("last 3 months", "this week", "since 2024", today/yesterday), quoted company names, answer style (list / count / compare / | ||||
| 79 | +timeline / trend), `min_importance` for "major/important". `route_question()` adds the LLM refinement only when configured and only to tighten | ||||
| 80 | +filters; on any failure the deterministic interpretation is returned. `build_answer()` phrases counts the API measured — it never invents results. | ||||
| 81 | + | ||||
| 82 | +## Live check (2026-09-12) | ||||
| 83 | + | ||||
| 84 | +`catlas llm-test` against `https://www.llm-api.io/v1`: health OK (15 models listed), small model round-trip **4.5 s**, 96 prompt / 19 completion | ||||
| 85 | +tokens, no repair needed. Live enrichment of factory changes: `classify_change` (small) 3.9 s; three `summarize_event` (medium, incl. legal-diff) | ||||
| 86 | +3.1–8.2 s, 0 failures. | ||||
| 87 | + | ||||
| 88 | +## Retention & cost | ||||
| 89 | + | ||||
| 90 | +`llm_jobs` finished > `CA_RETENTION_LLM_JOBS_DAYS` (60) are summarised into `cost_ledger` (`archived:<model>` tokens, `archived-jobs:<model>` count) | ||||
| 91 | +and deleted (`services/retention.py`); events keep their model/prompt attribution forever. | ||||
added
docs/OPERATIONS.md
+72 −0
@@ -0,0 +1,72 @@ | |||||
| 1 | +# Company Atlas — operations | ||||
| 2 | + | ||||
| 3 | +## API | ||||
| 4 | + | ||||
| 5 | +The API is one FastAPI process (`catlas api`, uvicorn) bound to loopback; Next.js proxies `/api/v1/*` to it. It is stateless: every cache is | ||||
| 6 | +in-process (`api/common.TTLCache`, cleared with `POST /api/v1/admin/cache/clear` or on restart), so several workers may run behind PM2 and | ||||
| 7 | +the rate limiter buckets are per process (effective limits scale with the number of workers — keep `--workers 1` unless traffic requires | ||||
| 8 | +more, or terminate rate limiting at the tunnel). | ||||
| 9 | + | ||||
| 10 | +### Environment variables (read by `config.Settings`, prefix `CA_`) | ||||
| 11 | + | ||||
| 12 | +| Variable | Default | Used by the API for | | ||||
| 13 | +|---|---|---| | ||||
| 14 | +| `DATABASE_URL` | `postgresql+asyncpg://companyatlas:companyatlas@127.0.0.1:5432/companyatlas` | every query (pool 8 + 8 overflow per process) | | ||||
| 15 | +| `CA_API_HOST` / `CA_API_PORT` | `127.0.0.1` / `8371` (prod `8361`) | bind address of `catlas api` | | ||||
| 16 | +| `CA_ADMIN_TOKEN` | *(empty → every `/admin/*` call is 401)* | `X-CA-Admin-Token` (also bypasses rate limits) | | ||||
| 17 | +| `CA_SITE_URL` | `https://www.company-atlas.co` | CORS allow-list (plus the localhost dev/prod ports) | | ||||
| 18 | +| `CA_DATA_DIR` | `./data` | object store read by `/snapshots/{id}` (text/blocks) and `/stats.archive` | | ||||
| 19 | +| `CA_SEO_MIN_EVENTS` / `CA_SEO_MIN_SENSORS` | `1` / `3` | `/sitemap` inclusion rule (`companies.indexed` or ≥ events **and** ≥ active sensors) | | ||||
| 20 | +| `CA_MIN_INTERVAL_S` / `CA_MAX_INTERVAL_S` | `900` / `604800` | clamp for `POST /admin/sensors/{id}/set_interval` | | ||||
| 21 | +| `CA_LLM_DAILY_BUDGET`, `CA_LLM_BASE_URL`, `CA_LLM_ENABLED` | `1500`, *(empty)*, `true` | `/admin/overview.llm.budget_left`, `/ask` LLM refinement availability | | ||||
| 22 | +| `CA_NOISE_THRESHOLD` … `CA_CRITICAL_THRESHOLD` | `0.20 / 0.40 / 0.65 / 0.85` | `/methodology.significance_bands`, `/admin/costs` meaningful-event denominator | | ||||
| 23 | +| `CA_LOG_JSON` | `true` | JSON logs (`service=ca-api`) | | ||||
| 24 | + | ||||
| 25 | +Run: `.venv/bin/catlas api` (dev, port 8371) · `.venv/bin/catlas api --port 8361` (prod under PM2 `company-atlas-api`). | ||||
| 26 | +Health: `GET /health` → `{status, db, version, api_version, uptime_s}`; `GET /ready` → `{ready}` (DB reachable). Docs: `/api/v1/docs`, | ||||
| 27 | +schema `/api/v1/openapi.json`. The scheduler's heartbeat (`settings_kv['scheduler:heartbeat']`) surfaces as `/system.scheduler_last_tick_at`. | ||||
| 28 | + | ||||
| 29 | +### API keys and rate-limit tiers | ||||
| 30 | + | ||||
| 31 | +Anonymous traffic is limited to 120 requests/min per client IP (first hop of `X-Forwarded-For` — Caddy on the MacLustr Tunnel sets it). | ||||
| 32 | +Keys raise the tier (`authenticated` 600/min, `paid` 3 000/min, `internal` unlimited). Keys are stored as sha256 hashes in `api_keys`; | ||||
| 33 | +the raw key is printed **once**: | ||||
| 34 | + | ||||
| 35 | +```bash | ||||
| 36 | +.venv/bin/catlas api-key create "acme research" --tier paid # prints ca_paid_… once; store it in the customer's vault | ||||
| 37 | +.venv/bin/catlas api-key list [--include-revoked] # id, prefix, tier, last used, request count | ||||
| 38 | +.venv/bin/catlas api-key revoke key_01… # soft revoke (row kept, `revoked_at` set); cached for ≤ 60 s in running API processes | ||||
| 39 | +``` | ||||
| 40 | + | ||||
| 41 | +Clients send `X-CA-API-Key: <key>`; responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Tier`; a `429` carries | ||||
| 42 | +`Retry-After`. Usage counters (`last_used_at`, `request_count`) are flushed every ~30 s per process. There is no self-service key issuance — | ||||
| 43 | +create keys from the M2U64 shell (or any host with `DATABASE_URL`). | ||||
| 44 | + | ||||
| 45 | +### Caches and freshness | ||||
| 46 | + | ||||
| 47 | +| Endpoint | TTL | Notes | | ||||
| 48 | +|---|---|---| | ||||
| 49 | +| `/pulse`, `/stats` | 60 s | homepage / counters; `archive` stats refresh every 10 min (`settings_kv['archive:stats']` if the ops job writes it, else a directory walk in a thread) | | ||||
| 50 | +| `/system` | 30 s | public health | | ||||
| 51 | +| `/rankings` | 120 s | per (kind, window, country, industry, limit) | | ||||
| 52 | +| `/industries*`, `/countries*`, `/index`, `/map`, `/trends`, `/events/types`, `/events/summary`, `/stats/history` | 300 s | aggregate producers in `api/aggregates.py` | | ||||
| 53 | +| `/sitemap` | 600 s | 5 000 entries per page | | ||||
| 54 | +| `/live*`, owner, admin | none (`no-store`) | | | ||||
| 55 | + | ||||
| 56 | +After bulk backfills or manual corrections: `curl -X POST -H "X-CA-Admin-Token: $CA_ADMIN_TOKEN" localhost:8371/api/v1/admin/cache/clear` | ||||
| 57 | +(optionally `?prefix=pulse`). Retract/restore of events clears the caches automatically. | ||||
| 58 | + | ||||
| 59 | +### Pending SQL | ||||
| 60 | + | ||||
| 61 | +`migrations/pending/api.sql` adds two trigram GIN indexes (`people.name`, `products.name`) used by `/search?types=people,products`. Apply | ||||
| 62 | +with `psql -f` (uses `CREATE INDEX CONCURRENTLY`, so outside a transaction) or fold into the next numbered Alembic migration. The endpoints | ||||
| 63 | +work without them (sequential scans on small tables). | ||||
| 64 | + | ||||
| 65 | +### Troubleshooting | ||||
| 66 | + | ||||
| 67 | +- `503`/`degraded` on `/health` → Postgres unreachable; the API keeps serving cached aggregates until they expire. | ||||
| 68 | +- `429` for the web app itself → the Next.js server is one IP; give it an `internal` key (`API_KEY` env in `apps/web`) or terminate limits upstream. | ||||
| 69 | +- `/snapshots/{id}` returns `text: null` → the object key is missing from `CA_DATA_DIR/objects` (different data dir or pruned by retention); | ||||
| 70 | + metadata still renders and `/snapshots/{a}/diff/{b}` falls back to the stored `changes.diff` when present. | ||||
| 71 | +- `/ask` engine is always `deterministic` → `CA_LLM_BASE_URL`/`CA_LLM_API_KEY` unset or the small model unreachable (the deterministic parser is | ||||
| 72 | + the designed fallback; nothing is fabricated either way). | ||||
added
docs/SCORING.md
+78 −0
@@ -0,0 +1,78 @@ | |||||
| 1 | +# Scoring — metrics, indices, baselines, signals, trends | ||||
| 2 | + | ||||
| 3 | +Owner: `services/metrics.py` (`metrics-v1`, CCI `cci-v1`), `services/signals.py` (`signals-v1`), `services/trends.py` (`trends-v1`). | ||||
| 4 | +Parameters live in `taxonomy.METRIC_PARAMS` / `taxonomy.CCI_WEIGHTS` / `config.Settings`; **bump the formula version whenever they change** | ||||
| 5 | +(spec §177). Every `metrics_current` row stores `value`, `confidence`, `inputs` (the numbers the formula saw), `formula_version`, `computed_at`; | ||||
| 6 | +`metric_series` keeps one point per company/metric/day. **No inputs → no row** (never 0-as-measured). | ||||
| 7 | + | ||||
| 8 | +Schedules: `metrics-hourly` (`CA_METRICS_CRON`, default `7 * * * *`) for companies with activity in the last `CA_METRICS_ACTIVE_WINDOW_DAYS` (90); | ||||
| 9 | +`daily-aggregates` (`CA_DAILY_CRON`, `20 0 * * *`) = catch-up of missed days + all-company recompute; `signals` hourly; `trends` at :25. | ||||
| 10 | +CLI: `catlas metrics [--all] [--company slug]`, `catlas daily [--day|--catch-up]`, `catlas signals`, `catlas trends`. | ||||
| 11 | + | ||||
| 12 | +Notation: `sat(x, k) = 1 − e^(−x/k)` (0–1, x=k → 0.63); `decay(age) = e^(−age/τ)`; ages in days. | ||||
| 13 | + | ||||
| 14 | +## Company metrics (`metrics_current`) | ||||
| 15 | + | ||||
| 16 | +| Metric | Formula | Inputs stored | Emitted when | | ||||
| 17 | +|---|---|---|---| | ||||
| 18 | +| `activity_score` (0–100) | `raw = Σ_changes w(kind)·decay(age, τ=10) + Σ_events importance·decay(age, τ)` over 30 d, `w` = meaningful 1 / major 2 / critical 3; `density = raw / active_sensors^0.5` (coverage normalisation); `score = 100·log1p(density)/log1p(6)` capped at 100 | changes_30d, events_30d, raw_changes, raw_events, active_sensors, density, τ, exponent, D_max | ≥ 1 active sensor or any activity | | ||||
| 19 | +| `open_jobs` | count of `jobs.status='open'` | jobs_new_30d, jobs_removed_30d, remote_ratio, by_country, by_department, ai_open | company has a careers/jobs_board sensor or any job rows | | ||||
| 20 | +| `hiring_momentum_{7,30,90}d` (%) | `(open_now − open_then)/open_then × 100`, `open_then` reconstructed from `first_seen_at ≤ t−N and (removed_at is null or > t−N)` | open_now, open_then, window (+ 30 d extras) | `open_then ≥ 3` (hiring_min_listings) — no % from 0 or 2 listings | | ||||
| 21 | +| `ai_adoption` (0–100) | components: jobs `min(1, ai_open/open × 2)` (w 0.5), events `sat(ai_events_90d, 3)` (w 0.25: AI_HIRING/AI_LAUNCH/tag `ai`), keywords `sat(hits, 5)` (w 0.25: `AI_KEYWORDS` in product names, 90 d news titles, latest snapshot titles); weights renormalised over available components | ai_open, open_jobs, ai_events_90d, keyword_hits, components, weights_used | any component has inputs | | ||||
| 22 | +| `product_velocity` | `100·sat(Σ importance·decay(age, 30), 6)` over 90 d PRODUCT + changelog/docs/API/SDK subtypes | events_90d, surfaces | products/changelog/docs/api/developer/services sensor or a PRODUCT event | | ||||
| 23 | +| `developer_momentum` | `100·sat(Σ DEVELOPER importance·decay + 0.5·meaningful dev-surface changes 30 d, 6)` | events_90d, meaningful_changes_30d, surfaces | docs/api/developer/changelog sensor or DEVELOPER event | | ||||
| 24 | +| `communication_activity` | `100·sat(max(news_30d, comm_events_30d) + 0.5·min(·), 8)` | news_items_30d, events_30d | newsroom/blog/feed/IR/research sensor or news rows | | ||||
| 25 | +| `pricing_activity` | `100·sat(Σ PRICING importance·decay + 0.25·pricing changes 30 d, 3)` | events_90d, meaningful_changes_30d | pricing sensor or PRICING event | | ||||
| 26 | +| `leadership_activity` | `100·sat(Σ LEADERSHIP importance·decay, 3)` | events_90d | leadership sensor or LEADERSHIP event | | ||||
| 27 | +| `geo_expansion` | `100·sat(new_locations_90d + 3·|new countries| + Σ COUNTRY_EXPANSION importance, 4)`; new countries = countries whose first location **or** first job appeared within 90 d | new_locations_90d, new_countries_90d, countries_listed, location_events_90d | locations/contact/about/careers sensor, location rows or jobs | | ||||
| 28 | +| `corporate_change_index` (`cci-v1`) | `Σ w_i·c_i / Σ w_i` over **available** components with `CCI_WEIGHTS` (hiring 0.25 · product 0.20 · geo 0.15 · leadership 0.15 · developer 0.10 · communication 0.10 · pricing 0.05); hiring momentum (%) mapped to `50 + clamp(m, −100, 100)/2`; confidence `0.4 + 0.6·weight_coverage` | components, weights, weight_coverage | ≥ 1 component | | ||||
| 29 | +| `anomaly_score` (z) | `(this_week_meaningful − mean) / max(stddev, 0.5)` against `baselines.meaningful_changes_weekly` | this_week, mean, stddev, samples | baseline with ≥ 4 samples | | ||||
| 30 | +| `historical_coverage` (0–100) | `100·(0.5·min(1, observed_ok/expected) + 0.3·continuity + 0.2·min(1, surfaces/8))`; `expected = Σ_sensors age_s / current_interval_s`; `continuity = days_with_observations / days_since_first_observed` | observed, expected, days_with_obs, days_since_first, surfaces | `first_observed_at` set and ≥ 1 sensor | | ||||
| 31 | + | ||||
| 32 | +Confidence conventions: activity `min(0.95, 0.5 + 0.05·sensors)`; hiring 0.9 with a structured jobs_board sensor else 0.75; AI adoption 0.6 | ||||
| 33 | +(inferred from public signals only); saturating scores 0.7; coverage 0.8. | ||||
| 34 | + | ||||
| 35 | +## Daily aggregates | ||||
| 36 | + | ||||
| 37 | +`compute_daily(day)` (UTC day, idempotent upserts): | ||||
| 38 | + | ||||
| 39 | +- `company_daily` — observations, changes, meaningful_changes, events (+ `events_by_type`), jobs_open / jobs_new / jobs_removed / jobs_ai_open at end of | ||||
| 40 | + day (reconstructed from `first_seen_at` / `removed_at`), news_items, sensors_active. Only companies with activity that day get a row. | ||||
| 41 | +- `global_daily` — sums + `companies_active` / `sensors_active` (distinct in observations that day; falls back to active sensors when no observation | ||||
| 42 | + rows exist), `events_by_type`, `jobs_open`, `by_country` / `by_industry` (`{companies, meaningful_changes, events}` per key) and the | ||||
| 43 | + **Global Corporate Activity Index**: `ratio = meaningful_changes / sensors_active`; `baseline = mean(ratio)` over the trailing 28 days with | ||||
| 44 | + sensors; `activity_index = ratio / baseline × 100` (baseline = 100). Null when no baseline exists yet (never fabricated). | ||||
| 45 | +- `baselines` — per company, weekly buckets of `company_daily` over `CA_BASELINE_WINDOW_DAYS` (56): `meaningful_changes_weekly`, `jobs_new_weekly`, | ||||
| 46 | + `news_weekly` → population mean / stddev / samples. Requires ≥ 2 observed weeks. Consumed by `anomaly_score`, `HIRING_SURGE` and signals. | ||||
| 47 | +- `compute_daily_catch_up()` fills every day between the last computed day (or the first observation/change/event) and yesterday. | ||||
| 48 | + | ||||
| 49 | +Coverage normalisation (spec §146): activity uses per-sensor density, the global index divides by active sensors and compares to a trailing | ||||
| 50 | +baseline, trends divide mentions by active companies, and the CCI renormalises over available components — growth of the sensor network alone | ||||
| 51 | +must not move any index. | ||||
| 52 | + | ||||
| 53 | +## Signals (`signals`, `signals-v1`) | ||||
| 54 | + | ||||
| 55 | +Labelled as signals, with `strength` 0–1, `confidence`, `explanation`, `evidence` (event ids + metric values), `window_days`, `expires_at` | ||||
| 56 | +(`CA_SIGNALS_TTL_DAYS` = 14). One active row per (company, kind): re-detection updates it, disappearance sets `status='expired'`. | ||||
| 57 | + | ||||
| 58 | +| kind | trigger | | ||||
| 59 | +|---|---| | ||||
| 60 | +| `hiring_surge` | `hiring_momentum_30d ≥ +30 %` with ≥ 10 open, or a `HIRING_SURGE` event in the window | | ||||
| 61 | +| `hiring_freeze` | `hiring_momentum_30d ≤ −30 %` with ≤ 1 new listing, or a `HIRING_FREEZE_SIGNAL` event — worded "listings no longer visible" | | ||||
| 62 | +| `launch_buildup` | within 14 d ≥ 3 of {product, docs/API, changelog, careers, messaging} categories including product, or ≥ 4 categories — "Possible launch preparation signal" | | ||||
| 63 | +| `expansion` | `COUNTRY_EXPANSION` event, or new countries (locations or job countries, 90 d) with location events / new jobs | | ||||
| 64 | +| `pricing_migration` | ≥ 2 PRICING events in 30 d, or a tier added **and** a tier removed | | ||||
| 65 | +| `developer_push` | ≥ 3 DEVELOPER events in 30 d or `developer_momentum ≥ 60` | | ||||
| 66 | +| `enterprise_repositioning` | new contact-sales tier(s) in 30 d, or ≥ 2 events mentioning enterprise / SSO / audit logs / contact sales | | ||||
| 67 | +| `ai_acceleration` | ≥ 2 AI-tagged events in 30 d, or ≥ 25 % of ≥ 4 new listings are AI-related | | ||||
| 68 | +| `abnormal_activity` | `anomaly_score ≥ CA_ANOMALY_Z` (2.5) | | ||||
| 69 | + | ||||
| 70 | +Scope aggregates (`scope = industry | country`): ≥ 3 companies with the same active kind → one aggregate signal per (scope, key, kind) with the | ||||
| 71 | +company slugs as evidence; aggregates expire when the support drops below the threshold. | ||||
| 72 | + | ||||
| 73 | +## Trends (`trends`, `trends-v1`) | ||||
| 74 | + | ||||
| 75 | +Terms = lowercase 1–3-grams from event titles (prefix like "News release:" removed) and news titles of the day, company names removed, | ||||
| 76 | +stopwords excluded at n-gram edges, tokens ≥ 3 chars (unigrams ≥ 4). A term is kept for a day only when ≥ `CA_TRENDS_MIN_COMPANIES` (3) distinct | ||||
| 77 | +companies used it (`mentions`, `companies`). Momentum for a window W ∈ {7, 30, 90}: `rate = mentions / mean(companies_active)` over the window, | ||||
| 78 | +`momentum = (rate_now − rate_prev) / max(rate_prev, ε)`; snapshots are stored in `settings_kv['trends:momentum:<W>d']` for the API. | ||||
added
migrations/pending/api.sql
+4 −0
@@ -0,0 +1,4 @@ | |||||
| 1 | +-- API (search): trigram indexes for people / product name search (`/search?types=people,products`). | ||||
| 2 | +-- Apply once, outside a transaction (CONCURRENTLY), or fold into the next numbered migration. | ||||
| 3 | +create index concurrently if not exists people_name_trgm_idx on people using gin (name gin_trgm_ops); | ||||
| 4 | +create index concurrently if not exists products_name_trgm_idx on products using gin (name gin_trgm_ops); | ||||
added
prompts/ask-router/v1.md
+20 −0
@@ -0,0 +1,20 @@ | |||||
| 1 | +<!-- ask-router v1 · model tier: small · schema: AskRoute (ask-v1) --> | ||||
| 2 | +You translate a natural-language question about companies into structured search filters for the Company Atlas API. You NEVER answer the question yourself and you NEVER invent companies, events or numbers — the platform runs the filters against its own database and links every result to its source. | ||||
| 3 | + | ||||
| 4 | +You receive a JSON context: the user's question, the filters a deterministic parser already extracted (`parsed`), the allowed event types and subtypes, the known industry slugs (`industries`) and country codes with names (`countries`). | ||||
| 5 | + | ||||
| 6 | +Produce an `AskRoute` object: | ||||
| 7 | +- `intent`: one of search, hiring, pricing, ai, launch, leadership, expansion, legal, developer, compare, trend, count. | ||||
| 8 | +- `countries`: ISO-3166 alpha-2 codes mentioned or clearly implied ("Canadian companies" → ["CA"], "Europe" → leave empty and add keyword "europe"). | ||||
| 9 | +- `industries`: slugs from the provided list only. | ||||
| 10 | +- `event_types` / `event_subtypes`: from the allowed lists only. | ||||
| 11 | +- `companies`: company names literally mentioned (as written), max 8. | ||||
| 12 | +- `window_days`: the time window when the question states one ("last month" → 30, "this week" → 7, "this year" → 365, "since 2024" → days from 2024-01-01 to today); null otherwise. | ||||
| 13 | +- `keywords`: up to 8 lowercase free-text terms that should be matched against titles (technologies, product names, roles). | ||||
| 14 | +- `answer_style`: list (default), count ("how many"), compare ("X vs Y"), timeline ("when did", "history of"). | ||||
| 15 | +- `confidence` (0–1). | ||||
| 16 | + | ||||
| 17 | +Rules: | ||||
| 18 | +1. Start from `parsed` and only add or tighten filters that the question clearly supports. When in doubt, leave a filter empty rather than guessing. | ||||
| 19 | +2. Do not translate vague adjectives into filters ("big companies", "interesting") — put them in keywords or ignore them. | ||||
| 20 | +3. Output one JSON object only. | ||||
added
prompts/change-classifier/v1.md
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +<!-- change-classifier v1 · model tier: small · schema: ChangeClassification (classify-v1) --> | ||||
| 2 | +You are the change classifier of Company Atlas, a platform that observes public company web pages and records how companies change over time. | ||||
| 3 | + | ||||
| 4 | +You receive a JSON context describing ONE detected change on ONE public page: company metadata, the page surface (careers, pricing, homepage, legal_terms…), the blocks of text that were added, removed or modified, and any structured deltas already extracted deterministically. | ||||
| 5 | + | ||||
| 6 | +Your task: decide whether this change corresponds to a corporate event, and describe it as a structured JSON object. | ||||
| 7 | + | ||||
| 8 | +Rules — follow all of them: | ||||
| 9 | +1. Choose `event_subtype` ONLY from this list (or `OTHER`): PRODUCT_LAUNCH, NEW_PRODUCT, PRODUCT_REMOVED, PRODUCT_RENAME, PRODUCT_UPDATE, FEATURE_LAUNCH, PRICE_INCREASE, PRICE_DECREASE, NEW_PRICING_TIER, PRICING_TIER_REMOVED, PRICING_CHANGE, NEW_JOB, JOB_REMOVED, JOB_COUNT_INCREASE, JOB_COUNT_DECREASE, AI_HIRING, NEW_EXECUTIVE, EXECUTIVE_NO_LONGER_LISTED, EXECUTIVE_TITLE_CHANGE, LEADERSHIP_CHANGE, NEW_OFFICE, NEW_LOCATION, OFFICE_REMOVED, COUNTRY_EXPANSION, FUNDING_ROUND, IPO, ACQUISITION, DIVESTITURE, MERGER, NEW_PARTNERSHIP, PARTNERSHIP_ENDED, BRAND_REPOSITIONING, STRATEGY_UPDATE, ENTERPRISE_REPOSITIONING, TECHNOLOGY_ADOPTION, AI_LAUNCH, TERMS_CHANGE, PRIVACY_POLICY_CHANGE, LEGAL_UPDATE, REGULATORY, CAMPAIGN_LAUNCH, MESSAGING_CHANGE, API_LAUNCH, API_CHANGE, SDK_RELEASE, DOCUMENTATION_CHANGE, CHANGELOG_ENTRY, SECURITY_INCIDENT, SECURITY_UPDATE, OUTAGE, OPERATIONS_UPDATE, SUSTAINABILITY_UPDATE, EARNINGS_RELEASE, INVESTOR_UPDATE, NEWS_RELEASE, BLOG_POST, WEBSITE_CHANGE, HOMEPAGE_REDESIGN, DOC_CHANGE. | ||||
| 10 | +2. Only describe what the changed text itself shows. Never infer internal decisions, motives, headcount, revenue or outcomes. If the change is cosmetic (layout, dates, cookie banners, navigation, footers, typos) set `is_material` to false, `event_subtype` to `OTHER` and `importance` ≤ 0.2. | ||||
| 11 | +3. Wording must be careful and observational: "detected", "observed", "now listed", "no longer listed", "appears". NEVER write "fired", "laid off", "layoffs", "shut down", "bankrupt" or any statement about people losing jobs. | ||||
| 12 | +4. `title` ≤ 120 characters, factual, starts with the subject (e.g. "Enterprise plan now lists SSO and audit logs"). `summary` ≤ 400 characters, 1–3 sentences, no marketing tone, no speculation. | ||||
| 13 | +5. `old_value` / `new_value` are short literal values when the change is a replacement (price, title, name); otherwise null. | ||||
| 14 | +6. `importance` (0–1): how much a company analyst would care. Pricing, leadership, launches, expansion, legal terms are usually 0.5–0.9; routine blog posts 0.2–0.4; cosmetic 0–0.2. | ||||
| 15 | +7. `confidence` (0–1): how sure you are the subtype is right given the evidence. Below 0.5 when the text is ambiguous or partial. | ||||
| 16 | +8. `entities`: an object with optional arrays `products`, `people` (name, title), `locations` (city, country), `plans` (plan_name, price), `amounts`, `organizations`. Only entities literally present in the text. | ||||
| 17 | +9. `tags`: up to 8 lowercase keywords (e.g. "ai", "enterprise", "api", "europe"). `language`: ISO-639-1 code of the page text. | ||||
| 18 | +10. Output one JSON object and nothing else. | ||||
added
prompts/event-summarizer/v1.md
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +<!-- event-summarizer v1 · model tier: medium · schema: EventSummary (summary-v1) --> | ||||
| 2 | +You write the short neutral summary shown under a Company Atlas event card. Company Atlas records how public company web pages change; every event links back to its source page, so the summary must only restate what the source text shows. | ||||
| 3 | + | ||||
| 4 | +You receive a JSON context: company metadata, the event already detected (type, subtype, title), the page surface, the text blocks that were added / removed / modified, and any structured deltas (jobs, plans, people, locations, news). | ||||
| 5 | + | ||||
| 6 | +Write: | ||||
| 7 | +- `summary`: 1–3 sentences, ≤ 400 characters, plain and factual, in English even if the source is in another language (mention the source language if it is not English). Say what is now on the page versus before. Do not repeat the title verbatim; add the concrete details (names, plan names, prices, cities, product names, section names). | ||||
| 8 | +- `key_points`: up to 5 short bullets (≤ 160 characters each) with concrete observed details. Omit if nothing beyond the summary. | ||||
| 9 | +- `confidence` (0–1): how well the provided text supports the summary. | ||||
| 10 | +- `language`: ISO-639-1 code of the source text. | ||||
| 11 | + | ||||
| 12 | +Strict rules: | ||||
| 13 | +1. No speculation about causes, strategy, intent, financial impact or people's employment status. Use "detected", "observed", "now lists", "no longer lists", "appears". | ||||
| 14 | +2. NEVER use "fired", "laid off", "layoffs", "shut down", "bankrupt", "collapse". A person or listing disappearing from a page is described as "no longer listed". | ||||
| 15 | +3. Do not invent numbers, dates, names or products that are not literally in the context. If the context is too thin, say so in the summary ("The page changed materially; the visible text does not state the reason."). | ||||
| 16 | +4. No marketing tone, no adjectives like "exciting", "innovative", "leading". | ||||
| 17 | +5. Output a single JSON object only. | ||||
added
prompts/industry-tagger/v1.md
+16 −0
@@ -0,0 +1,16 @@ | |||||
| 1 | +<!-- industry-tagger v1 · model tier: small · schema: IndustryTags (industry-v1) --> | ||||
| 2 | +You assign industry tags to a company for Company Atlas using only public, observable evidence. | ||||
| 3 | + | ||||
| 4 | +You receive a JSON context: company name, canonical domain, the public description (if any), the homepage title and meta description, the first product names observed, up to 10 recent job titles, and the list of allowed industry slugs with their names (`allowed_industries`). | ||||
| 5 | + | ||||
| 6 | +Produce: | ||||
| 7 | +- `industries`: 1–5 slugs chosen ONLY from `allowed_industries`, most specific first. Never invent a slug. | ||||
| 8 | +- `primary`: the single best slug from `industries`. | ||||
| 9 | +- `keywords`: up to 10 lowercase keywords that describe what the company visibly does (products, markets, technologies) — derived from the context, not general knowledge. | ||||
| 10 | +- `confidence` (0–1): 0.9 when the description and products clearly match; ≤ 0.5 when you rely mostly on the name or domain. | ||||
| 11 | + | ||||
| 12 | +Rules: | ||||
| 13 | +1. Prefer what the company says about itself on its own pages over what you believe you know about it. If the context contradicts your prior knowledge, follow the context and lower confidence. | ||||
| 14 | +2. Holding companies and conglomerates: tag the visible operating activities, then `conglomerate` if it is in the allowed list. | ||||
| 15 | +3. If the context is empty or unrelated (parked domain, error page), return `industries: []`, `primary: null`, `confidence: 0.1`. | ||||
| 16 | +4. Output one JSON object only. | ||||
added
prompts/legal-diff/v1.md
+18 −0
@@ -0,0 +1,18 @@ | |||||
| 1 | +<!-- legal-diff v1 · model tier: medium · schema: LegalDiffSummary (legal-v1) --> | ||||
| 2 | +You summarise changes between two versions of a public legal page (terms of service, privacy policy, acceptable-use, security or data-processing terms) for Company Atlas. Readers are analysts; the summary is shown next to a link to both versions, so it must be precise and restrained. | ||||
| 3 | + | ||||
| 4 | +You receive a JSON context: company metadata, the surface (legal_terms / legal_privacy / security), and the blocks that were added, removed or modified, each with its heading path (e.g. "Terms > 7. Termination"). | ||||
| 5 | + | ||||
| 6 | +Produce: | ||||
| 7 | +- `sections_changed`: up to 12 objects `{section, change}` — `section` is the heading path or the section number/name visible in the text, `change` is one sentence (≤ 240 characters) describing what the text now says compared to before ("now requires 30 days' notice, previously 14"). | ||||
| 8 | +- `materiality`: one of `editorial` (wording, formatting, dates, typos), `minor` (clarifications with no change in rights or obligations), `material` (changes to rights, obligations, fees, data use, liability, jurisdiction, termination, arbitration), `unclear` (not enough text). | ||||
| 9 | +- `summary`: ≤ 400 characters, neutral, starting with the page and the number of sections changed ("Privacy policy: 3 sections changed. …"). | ||||
| 10 | +- `user_impact`: ≤ 240 characters, only if the text explicitly changes what users/customers can or must do; else null. | ||||
| 11 | +- `confidence` (0–1). | ||||
| 12 | + | ||||
| 13 | +Rules: | ||||
| 14 | +1. This is NOT legal advice. Never state whether the change is lawful, fair or compliant; never guess the reason for the change. | ||||
| 15 | +2. Only describe text present in the context. Missing "before" text means the section is new; missing "after" text means it is no longer present — say "no longer includes", never "removed rights" or similar conclusions. | ||||
| 16 | +3. Quote key phrases briefly when they matter (fees, notice periods, jurisdictions, data categories). | ||||
| 17 | +4. Never use "fired", "laid off", "shut down", "bankrupt". | ||||
| 18 | +5. Output one JSON object only. | ||||
added
src/companyatlas/api/aggregates.py
+417 −0
@@ -0,0 +1,417 @@ | |||||
| 1 | +"""Aggregate producers shared by several routers (`/pulse` composes them): industry & country rows, rankings, trends, map buckets, | ||||
| 2 | +global stats and the activity index. Every producer degrades to empty lists / `null` values on an empty database — nothing is invented.""" | ||||
| 3 | +from __future__ import annotations | ||||
| 4 | + | ||||
| 5 | +import asyncio | ||||
| 6 | +from collections import defaultdict | ||||
| 7 | +from datetime import UTC, datetime, timedelta | ||||
| 8 | +from typing import Any | ||||
| 9 | + | ||||
| 10 | +from sqlalchemy.ext.asyncio import AsyncConnection | ||||
| 11 | + | ||||
| 12 | +from companyatlas import archive | ||||
| 13 | +from companyatlas.api import queries as q | ||||
| 14 | +from companyatlas.api import serializers as ser | ||||
| 15 | +from companyatlas.api.common import cache, cached | ||||
| 16 | +from companyatlas.db import connection, fetch_all, fetch_one | ||||
| 17 | +from companyatlas.ids import slugify | ||||
| 18 | +from companyatlas.taxonomy import METRICS_FORMULA_VERSION, Metric | ||||
| 19 | + | ||||
| 20 | +# ------------------------------------------------------------------------------------------------ industries / countries | ||||
| 21 | + | ||||
| 22 | +_ROW_METRICS = (Metric.ACTIVITY_SCORE.value, Metric.HIRING_MOMENTUM_30D.value, Metric.AI_ADOPTION.value) | ||||
| 23 | + | ||||
| 24 | + | ||||
| 25 | +def _avg_map(rows: list[dict[str, Any]], key: str) -> dict[str, dict[str, float]]: | ||||
| 26 | + out: dict[str, dict[str, float]] = defaultdict(dict) | ||||
| 27 | + for r in rows: | ||||
| 28 | + if r[key] is not None and r["v"] is not None: | ||||
| 29 | + out[r[key]][r["metric"]] = float(r["v"]) | ||||
| 30 | + return out | ||||
| 31 | + | ||||
| 32 | + | ||||
| 33 | +async def industry_rows(conn: AsyncConnection, *, country: str | None = None) -> list[dict[str, Any]]: | ||||
| 34 | + scope = " and c.country = cast(:country as char(2))" if country else "" | ||||
| 35 | + params: dict[str, Any] = {"country": country} if country else {} | ||||
| 36 | + d7, d30 = q.days_ago(7), q.days_ago(30) | ||||
| 37 | + taxonomy = await fetch_all(conn, "select slug, name, parent_slug, description, sort_order from industries order by sort_order, name") | ||||
| 38 | + companies = await fetch_all(conn, f"select ind, count(*) as n from companies c, unnest(c.industries) ind where c.status = 'ACTIVE'{scope} " | ||||
| 39 | + "group by ind", **params) | ||||
| 40 | + events = await fetch_all(conn, "select ind, e.event_type, count(*) filter (where e.detected_at >= :d7) as n7, count(*) as n30 " | ||||
| 41 | + "from events e join companies c on c.id = e.company_id, unnest(c.industries) ind " | ||||
| 42 | + f"where e.status = 'active' and e.detected_at >= :d30{scope} group by ind, e.event_type", d7=d7, d30=d30, **params) | ||||
| 43 | + metrics = await fetch_all(conn, "select ind, m.metric, avg(m.value) as v from metrics_current m join companies c on c.id = m.company_id, " | ||||
| 44 | + f"unnest(c.industries) ind where m.metric = any(cast(:ms as text[])){scope} group by ind, m.metric", | ||||
| 45 | + ms=list(_ROW_METRICS), **params) | ||||
| 46 | + n_companies = {r["ind"]: int(r["n"]) for r in companies} | ||||
| 47 | + ev7: dict[str, int] = defaultdict(int) | ||||
| 48 | + ev30: dict[str, int] = defaultdict(int) | ||||
| 49 | + types: dict[str, dict[str, int]] = defaultdict(dict) | ||||
| 50 | + for r in events: | ||||
| 51 | + ev7[r["ind"]] += int(r["n7"]) | ||||
| 52 | + ev30[r["ind"]] += int(r["n30"]) | ||||
| 53 | + types[r["ind"]][r["event_type"]] = int(r["n30"]) | ||||
| 54 | + avg = _avg_map(metrics, "ind") | ||||
| 55 | + names = {t["slug"]: t for t in taxonomy} | ||||
| 56 | + slugs = list(names) + [s for s in n_companies if s not in names] | ||||
| 57 | + rows = [] | ||||
| 58 | + for slug in slugs: | ||||
| 59 | + t = names.get(slug, {}) | ||||
| 60 | + m = avg.get(slug, {}) | ||||
| 61 | + rows.append({"slug": slug, "name": t.get("name") or slug.replace("-", " ").title(), "parent_slug": t.get("parent_slug"), | ||||
| 62 | + "description": t.get("description"), "companies": n_companies.get(slug, 0), "events_7d": ev7.get(slug, 0), | ||||
| 63 | + "events_30d": ev30.get(slug, 0), "hiring_momentum_30d": ser.metric_value("hiring_momentum_30d", m.get("hiring_momentum_30d")), | ||||
| 64 | + "activity_score": ser.metric_value("activity_score", m.get("activity_score")), | ||||
| 65 | + "ai_adoption": ser.metric_value("ai_adoption", m.get("ai_adoption")), | ||||
| 66 | + "top_event_types": [k for k, _ in sorted(types.get(slug, {}).items(), key=lambda kv: (-kv[1], kv[0]))[:3]]}) | ||||
| 67 | + rows.sort(key=lambda r: (-r["companies"], -(r["activity_score"] or 0), r["name"])) | ||||
| 68 | + return rows | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +async def country_rows(conn: AsyncConnection, *, industry: str | None = None) -> list[dict[str, Any]]: | ||||
| 72 | + scope = " and cast(:industry as text) = any(c.industries)" if industry else "" | ||||
| 73 | + params: dict[str, Any] = {"industry": industry} if industry else {} | ||||
| 74 | + d7, d30 = q.days_ago(7), q.days_ago(30) | ||||
| 75 | + ref = await fetch_all(conn, "select code, name, region, subregion, lat, lon from countries order by name") | ||||
| 76 | + companies = await fetch_all(conn, f"select c.country as code, count(*) as n from companies c where c.status = 'ACTIVE' and c.country is not null{scope} " | ||||
| 77 | + "group by c.country", **params) | ||||
| 78 | + events = await fetch_all(conn, "select c.country as code, count(*) filter (where e.detected_at >= :d7) as n7, count(*) as n30 " | ||||
| 79 | + "from events e join companies c on c.id = e.company_id " | ||||
| 80 | + f"where e.status = 'active' and e.detected_at >= :d30 and c.country is not null{scope} group by c.country", | ||||
| 81 | + d7=d7, d30=d30, **params) | ||||
| 82 | + metrics = await fetch_all(conn, "select c.country as code, m.metric, avg(m.value) as v from metrics_current m join companies c on c.id = m.company_id " | ||||
| 83 | + f"where m.metric = any(cast(:ms as text[])) and c.country is not null{scope} group by c.country, m.metric", | ||||
| 84 | + ms=list(_ROW_METRICS), **params) | ||||
| 85 | + mix = await fetch_all(conn, "select c.country as code, ind, count(*) as n from companies c, unnest(c.industries) ind " | ||||
| 86 | + f"where c.status = 'ACTIVE' and c.country is not null{scope} group by c.country, ind", **params) | ||||
| 87 | + n_companies = {r["code"]: int(r["n"]) for r in companies} | ||||
| 88 | + ev = {r["code"]: (int(r["n7"]), int(r["n30"])) for r in events} | ||||
| 89 | + avg = _avg_map(metrics, "code") | ||||
| 90 | + mixes: dict[str, list[tuple[str, int]]] = defaultdict(list) | ||||
| 91 | + for r in mix: | ||||
| 92 | + mixes[r["code"]].append((r["ind"], int(r["n"]))) | ||||
| 93 | + names = {r["code"]: r for r in ref} | ||||
| 94 | + codes = list(names) + [c for c in n_companies if c not in names] | ||||
| 95 | + rows = [] | ||||
| 96 | + for code in codes: | ||||
| 97 | + c = names.get(code, {}) | ||||
| 98 | + m = avg.get(code, {}) | ||||
| 99 | + name = c.get("name") or code | ||||
| 100 | + rows.append({"code": code, "slug": slugify(name), "name": name, "region": c.get("region"), "subregion": c.get("subregion"), | ||||
| 101 | + "companies": n_companies.get(code, 0), "events_7d": ev.get(code, (0, 0))[0], "events_30d": ev.get(code, (0, 0))[1], | ||||
| 102 | + "hiring_momentum_30d": ser.metric_value("hiring_momentum_30d", m.get("hiring_momentum_30d")), | ||||
| 103 | + "activity_score": ser.metric_value("activity_score", m.get("activity_score")), | ||||
| 104 | + "ai_adoption": ser.metric_value("ai_adoption", m.get("ai_adoption")), | ||||
| 105 | + "industry_mix": [{"industry": i, "companies": n} for i, n in sorted(mixes.get(code, []), key=lambda x: -x[1])[:6]], | ||||
| 106 | + "lat": c.get("lat"), "lon": c.get("lon")}) | ||||
| 107 | + rows.sort(key=lambda r: (-r["companies"], -(r["activity_score"] or 0), r["name"])) | ||||
| 108 | + return rows | ||||
| 109 | + | ||||
| 110 | + | ||||
| 111 | +async def cached_industry_rows(country: str | None = None) -> list[dict[str, Any]]: | ||||
| 112 | + async def produce() -> list[dict[str, Any]]: | ||||
| 113 | + async with connection() as conn: | ||||
| 114 | + return await industry_rows(conn, country=country) | ||||
| 115 | + return await cached(f"industries:{country or ''}", 300, produce) | ||||
| 116 | + | ||||
| 117 | + | ||||
| 118 | +async def cached_country_rows(industry: str | None = None) -> list[dict[str, Any]]: | ||||
| 119 | + async def produce() -> list[dict[str, Any]]: | ||||
| 120 | + async with connection() as conn: | ||||
| 121 | + return await country_rows(conn, industry=industry) | ||||
| 122 | + return await cached(f"countries:{industry or ''}", 300, produce) | ||||
| 123 | + | ||||
| 124 | + | ||||
| 125 | +async def resolve_country(conn: AsyncConnection, key: str) -> dict[str, Any] | None: | ||||
| 126 | + """Accept an ISO-2 code (`CA`) or a name slug (`canada`).""" | ||||
| 127 | + key = (key or "").strip() | ||||
| 128 | + if not key: | ||||
| 129 | + return None | ||||
| 130 | + if len(key) == 2: | ||||
| 131 | + row = await fetch_one(conn, "select code, name, region, subregion, lat, lon from countries where code = cast(:c as char(2))", c=key.upper()) | ||||
| 132 | + if row: | ||||
| 133 | + return row | ||||
| 134 | + rows = await fetch_all(conn, "select code, name, region, subregion, lat, lon from countries") | ||||
| 135 | + k = slugify(key) | ||||
| 136 | + for r in rows: | ||||
| 137 | + if slugify(r["name"]) == k or r["code"].lower() == key.lower(): | ||||
| 138 | + return r | ||||
| 139 | + return None | ||||
| 140 | + | ||||
| 141 | + | ||||
| 142 | +# ------------------------------------------------------------------------------------------------ rankings | ||||
| 143 | + | ||||
| 144 | +RANKING_KINDS: dict[str, dict[str, Any]] = { | ||||
| 145 | + "most_active": {"metric": Metric.ACTIVITY_SCORE.value}, | ||||
| 146 | + "hiring_growth": {"metric": "hiring_momentum_{w}", "min": 0.0}, | ||||
| 147 | + "hiring_decline": {"metric": "hiring_momentum_{w}", "asc": True, "max": 0.0}, | ||||
| 148 | + "product_velocity": {"metric": Metric.PRODUCT_VELOCITY.value}, | ||||
| 149 | + "ai_active": {"metric": Metric.AI_ADOPTION.value}, | ||||
| 150 | + "geo_expansion": {"metric": Metric.GEO_EXPANSION.value}, | ||||
| 151 | + "developer_momentum": {"metric": Metric.DEVELOPER_MOMENTUM.value}, | ||||
| 152 | + "pricing_changes": {"events": "PRICING"}, | ||||
| 153 | + "unusual_activity": {"metric": Metric.ANOMALY_SCORE.value}, | ||||
| 154 | +} | ||||
| 155 | +_HIRING_WINDOW = {"24h": "7d", "7d": "7d", "30d": "30d", "90d": "90d", "1y": "90d"} | ||||
| 156 | + | ||||
| 157 | + | ||||
| 158 | +async def ranking(conn: AsyncConnection, kind: str, window: str, *, country: str | None = None, industry: str | None = None, | ||||
| 159 | + limit: int = 50) -> list[dict[str, Any]]: | ||||
| 160 | + """[{company_id, value, delta}] for a ranking kind. `delta` = value − series value at the start of the window (null if unknown).""" | ||||
| 161 | + spec = RANKING_KINDS[kind] | ||||
| 162 | + scope, params = [], {} | ||||
| 163 | + if country: | ||||
| 164 | + scope.append("c.country = cast(:country as char(2))") | ||||
| 165 | + params["country"] = country.upper()[:2] | ||||
| 166 | + if industry: | ||||
| 167 | + scope.append("cast(:industry as text) = any(c.industries)") | ||||
| 168 | + params["industry"] = industry[:80] | ||||
| 169 | + scope_sql = (" and " + " and ".join(scope)) if scope else "" | ||||
| 170 | + since = q.window_start(window) | ||||
| 171 | + if "events" in spec: | ||||
| 172 | + rows = await fetch_all(conn, "select e.company_id, count(*) as value from events e join companies c on c.id = e.company_id " | ||||
| 173 | + f"where e.status = 'active' and e.event_type = :et and e.detected_at >= :since and c.status = 'ACTIVE'{scope_sql} " | ||||
| 174 | + "group by e.company_id order by value desc, e.company_id limit :limit", et=spec["events"], since=since, | ||||
| 175 | + limit=limit, **params) | ||||
| 176 | + return [{"company_id": r["company_id"], "value": float(r["value"]), "delta": None} for r in rows] | ||||
| 177 | + metric = spec["metric"].format(w=_HIRING_WINDOW.get(window, "30d")) | ||||
| 178 | + bounds = "" | ||||
| 179 | + if "min" in spec: | ||||
| 180 | + bounds += " and m.value > :vmin" | ||||
| 181 | + params["vmin"] = spec["min"] | ||||
| 182 | + if "max" in spec: | ||||
| 183 | + bounds += " and m.value < :vmax" | ||||
| 184 | + params["vmax"] = spec["max"] | ||||
| 185 | + order = "m.value asc" if spec.get("asc") else "m.value desc" | ||||
| 186 | + rows = await fetch_all(conn, "select m.company_id, m.value from metrics_current m join companies c on c.id = m.company_id " | ||||
| 187 | + f"where m.metric = :metric and c.status = 'ACTIVE'{scope_sql}{bounds} order by {order}, m.company_id limit :limit", | ||||
| 188 | + metric=metric, limit=limit, **params) | ||||
| 189 | + ids = [r["company_id"] for r in rows] | ||||
| 190 | + past = await q.metric_values_at(conn, ids, metric, since.date()) | ||||
| 191 | + out = [] | ||||
| 192 | + for r in rows: | ||||
| 193 | + v = float(r["value"]) | ||||
| 194 | + p = past.get(r["company_id"]) | ||||
| 195 | + out.append({"company_id": r["company_id"], "value": v, "delta": round(v - p, 2) if p is not None else None}) | ||||
| 196 | + return out | ||||
| 197 | + | ||||
| 198 | + | ||||
| 199 | +async def ranking_cards(conn: AsyncConnection, kind: str, window: str, *, country: str | None = None, industry: str | None = None, | ||||
| 200 | + limit: int = 50, sparkline: bool = False) -> list[dict[str, Any]]: | ||||
| 201 | + items = await ranking(conn, kind, window, country=country, industry=industry, limit=limit) | ||||
| 202 | + cards = await q.fetch_cards_by_ids(conn, [i["company_id"] for i in items], sparkline=sparkline) | ||||
| 203 | + by_id = {c["id"]: c for c in cards} | ||||
| 204 | + out = [] | ||||
| 205 | + for rank, it in enumerate(items, start=1): | ||||
| 206 | + row = by_id.get(it["company_id"]) | ||||
| 207 | + if row is None: | ||||
| 208 | + continue | ||||
| 209 | + card = ser.company_card(row) | ||||
| 210 | + metric = RANKING_KINDS[kind].get("metric", "").format(w=_HIRING_WINDOW.get(window, "30d")) | ||||
| 211 | + card.update({"rank": rank, "value": ser.metric_value(metric, it["value"]) if metric else int(it["value"]), "delta": it["delta"]}) | ||||
| 212 | + out.append(card) | ||||
| 213 | + return out | ||||
| 214 | + | ||||
| 215 | + | ||||
| 216 | +# ------------------------------------------------------------------------------------------------ trends / signals | ||||
| 217 | + | ||||
| 218 | + | ||||
| 219 | +async def trend_rows(conn: AsyncConnection, window_days: int, limit: int) -> list[dict[str, Any]]: | ||||
| 220 | + d0 = q.days_ago(window_days).date() | ||||
| 221 | + top = await fetch_all(conn, "select term, sum(mentions) as mentions, max(companies) as companies from trends where day >= :d group by term " | ||||
| 222 | + "order by mentions desc, term limit :limit", d=d0, limit=limit) | ||||
| 223 | + if not top: | ||||
| 224 | + return [] | ||||
| 225 | + terms = [t["term"] for t in top] | ||||
| 226 | + series = await fetch_all(conn, "select term, day, mentions from trends where day >= :d and term = any(cast(:terms as text[])) order by term, day", | ||||
| 227 | + d=d0, terms=terms) | ||||
| 228 | + by_term: dict[str, list[tuple[Any, int]]] = defaultdict(list) | ||||
| 229 | + for r in series: | ||||
| 230 | + by_term[r["term"]].append((r["day"], int(r["mentions"]))) | ||||
| 231 | + mid = q.days_ago(window_days // 2 or 1).date() | ||||
| 232 | + out = [] | ||||
| 233 | + for t in top: | ||||
| 234 | + pts = by_term.get(t["term"], []) | ||||
| 235 | + first = sum(m for d, m in pts if d < mid) | ||||
| 236 | + second = sum(m for d, m in pts if d >= mid) | ||||
| 237 | + momentum = round((second - first) / first * 100, 1) if first > 0 else None | ||||
| 238 | + out.append({"term": t["term"], "mentions": int(t["mentions"]), "companies": int(t["companies"] or 0), "momentum": momentum, | ||||
| 239 | + "series": [m for _, m in pts]}) | ||||
| 240 | + return out | ||||
| 241 | + | ||||
| 242 | + | ||||
| 243 | +# ------------------------------------------------------------------------------------------------ map | ||||
| 244 | + | ||||
| 245 | +MAP_MAX_BUCKETS = 600 | ||||
| 246 | + | ||||
| 247 | + | ||||
| 248 | +async def map_buckets(conn: AsyncConnection, metric: str = "events_30d") -> list[dict[str, Any]]: | ||||
| 249 | + d30 = q.days_ago(30) | ||||
| 250 | + ev_by_company = {r["company_id"]: int(r["n"]) for r in await fetch_all( | ||||
| 251 | + conn, "select company_id, count(*) as n from events where status = 'active' and detected_at >= :d group by company_id", d=d30)} | ||||
| 252 | + jobs_by_company = {r["company_id"]: int(r["n"]) for r in await fetch_all( | ||||
| 253 | + conn, "select company_id, count(*) as n from jobs where status = 'open' group by company_id")} | ||||
| 254 | + countries = await fetch_all(conn, "select k.code, k.name, k.lat, k.lon, c.id, c.slug, c.display_name, c.importance from countries k " | ||||
| 255 | + "join companies c on c.country = k.code and c.status = 'ACTIVE' where k.lat is not null order by c.importance desc") | ||||
| 256 | + cities = await fetch_all(conn, "select l.country, l.city, avg(l.lat) as lat, avg(l.lon) as lon, " | ||||
| 257 | + "array_agg(distinct l.company_id) as company_ids from locations l join companies c on c.id = l.company_id " | ||||
| 258 | + "where l.status = 'listed' and l.lat is not null and l.lon is not null and l.city is not null " | ||||
| 259 | + "group by l.country, l.city order by count(distinct l.company_id) desc limit :lim", lim=MAP_MAX_BUCKETS) | ||||
| 260 | + by_country: dict[str, dict[str, Any]] = {} | ||||
| 261 | + for r in countries: | ||||
| 262 | + b = by_country.setdefault(r["code"], {"lat": r["lat"], "lon": r["lon"], "country": r["code"], "city": None, "companies": 0, "events_30d": 0, | ||||
| 263 | + "jobs_open": 0, "top": [], "_ids": []}) | ||||
| 264 | + b["companies"] += 1 | ||||
| 265 | + b["events_30d"] += ev_by_company.get(r["id"], 0) | ||||
| 266 | + b["jobs_open"] += jobs_by_company.get(r["id"], 0) | ||||
| 267 | + if len(b["top"]) < 3: | ||||
| 268 | + b["top"].append({"slug": r["slug"], "display_name": r["display_name"]}) | ||||
| 269 | + names: dict[str, tuple[str, str]] = {} | ||||
| 270 | + if cities: | ||||
| 271 | + ids = sorted({cid for r in cities for cid in (r["company_ids"] or [])}) | ||||
| 272 | + names = {r["id"]: (r["slug"], r["display_name"]) for r in await fetch_all( | ||||
| 273 | + conn, "select id, slug, display_name from companies where id = any(cast(:ids as text[])) order by importance desc", ids=ids[:5000])} | ||||
| 274 | + buckets = list(by_country.values()) | ||||
| 275 | + for r in cities: | ||||
| 276 | + ids = [cid for cid in (r["company_ids"] or []) if cid in names] | ||||
| 277 | + buckets.append({"lat": round(float(r["lat"]), 4), "lon": round(float(r["lon"]), 4), "country": r["country"], "city": r["city"], "companies": len(ids), | ||||
| 278 | + "events_30d": sum(ev_by_company.get(i, 0) for i in ids), "jobs_open": sum(jobs_by_company.get(i, 0) for i in ids), | ||||
| 279 | + "top": [{"slug": names[i][0], "display_name": names[i][1]} for i in ids[:3]]}) | ||||
| 280 | + key = {"companies": "companies", "hiring": "jobs_open"}.get(metric, "events_30d") | ||||
| 281 | + buckets.sort(key=lambda b: (-b[key], -b["companies"])) | ||||
| 282 | + for b in buckets: | ||||
| 283 | + b.pop("_ids", None) | ||||
| 284 | + return buckets[:MAP_MAX_BUCKETS] | ||||
| 285 | + | ||||
| 286 | + | ||||
| 287 | +# ------------------------------------------------------------------------------------------------ global stats / index | ||||
| 288 | + | ||||
| 289 | + | ||||
| 290 | +async def archive_stats() -> dict[str, int]: | ||||
| 291 | + async def produce() -> dict[str, int]: | ||||
| 292 | + async with connection() as conn: | ||||
| 293 | + kv = await q.settings_value(conn, "archive:stats") | ||||
| 294 | + if isinstance(kv, dict) and "objects" in kv: | ||||
| 295 | + return {"objects": int(kv.get("objects") or 0), "bytes": int(kv.get("bytes") or 0)} | ||||
| 296 | + return await asyncio.to_thread(archive.store_stats) | ||||
| 297 | + return await cached("archive:stats", 600, produce) | ||||
| 298 | + | ||||
| 299 | + | ||||
| 300 | +async def global_stats(conn: AsyncConnection) -> dict[str, Any]: | ||||
| 301 | + today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) | ||||
| 302 | + row = await fetch_one(conn, """ | ||||
| 303 | + select (select count(*) from companies) as companies, | ||||
| 304 | + (select count(*) from companies where status = 'ACTIVE' and onboarding_status = 'active') as companies_active, | ||||
| 305 | + (select count(*) from sensors where status <> 'retired') as sensors, | ||||
| 306 | + (select count(*) from sensors where status = 'active') as sensors_active, | ||||
| 307 | + (select coalesce(sum(observation_count), 0) from sensors) as observations, | ||||
| 308 | + (select coalesce(sum(snapshot_count), 0) from sensors) as snapshots, | ||||
| 309 | + (select count(*) from changes) as changes, | ||||
| 310 | + (select count(*) from changes where kind in ('meaningful', 'major', 'critical')) as meaningful_changes, | ||||
| 311 | + (select count(*) from events where status = 'active') as events, | ||||
| 312 | + (select count(*) from jobs where status = 'open') as jobs_open, | ||||
| 313 | + (select count(distinct country) from companies where country is not null and status = 'ACTIVE') as countries, | ||||
| 314 | + (select count(distinct ind) from companies c, unnest(c.industries) ind where c.status = 'ACTIVE') as industries, | ||||
| 315 | + (select count(*) from observations where fetched_at >= :today) as observations_today, | ||||
| 316 | + (select count(*) from changes where detected_at >= :today) as changes_today, | ||||
| 317 | + (select count(*) from events where status = 'active' and detected_at >= :today) as events_today, | ||||
| 318 | + (select min(first_observed_at) from companies) as oldest_observation_at, | ||||
| 319 | + (select max(fetched_at) from observations) as last_observation_at | ||||
| 320 | + """, today=today) | ||||
| 321 | + row = row or {} | ||||
| 322 | + started = await q.settings_value(conn, "dataset_started_at") | ||||
| 323 | + started_dt = q.parse_iso(started) if isinstance(started, str) else None | ||||
| 324 | + now = datetime.now(UTC) | ||||
| 325 | + oldest = row.get("oldest_observation_at") | ||||
| 326 | + return {"companies": int(row.get("companies") or 0), "companies_active": int(row.get("companies_active") or 0), | ||||
| 327 | + "sensors": int(row.get("sensors") or 0), "sensors_active": int(row.get("sensors_active") or 0), | ||||
| 328 | + "observations": int(row.get("observations") or 0), "snapshots": int(row.get("snapshots") or 0), "changes": int(row.get("changes") or 0), | ||||
| 329 | + "meaningful_changes": int(row.get("meaningful_changes") or 0), "events": int(row.get("events") or 0), | ||||
| 330 | + "jobs_open": int(row.get("jobs_open") or 0), "countries": int(row.get("countries") or 0), "industries": int(row.get("industries") or 0), | ||||
| 331 | + "observations_today": int(row.get("observations_today") or 0), "changes_today": int(row.get("changes_today") or 0), | ||||
| 332 | + "events_today": int(row.get("events_today") or 0), "dataset_started_at": started_dt, | ||||
| 333 | + "dataset_age_days": (now - started_dt).days if started_dt else None, | ||||
| 334 | + "oldest_history_days": (now - oldest).days if oldest else None, "last_observation_at": row.get("last_observation_at"), | ||||
| 335 | + "archive": await archive_stats()} | ||||
| 336 | + | ||||
| 337 | + | ||||
| 338 | +async def cached_global_stats() -> dict[str, Any]: | ||||
| 339 | + async def produce() -> dict[str, Any]: | ||||
| 340 | + async with connection() as conn: | ||||
| 341 | + return await global_stats(conn) | ||||
| 342 | + return await cached("stats", 60, produce) | ||||
| 343 | + | ||||
| 344 | + | ||||
| 345 | +async def global_daily_rows(conn: AsyncConnection, days: int) -> list[dict[str, Any]]: | ||||
| 346 | + rows = await fetch_all(conn, "select * from global_daily where day >= :d order by day asc limit :lim", d=q.days_ago(days).date(), lim=days + 1) | ||||
| 347 | + return [{"day": r["day"], "companies_active": r["companies_active"], "sensors_active": r["sensors_active"], "observations": r["observations"], | ||||
| 348 | + "changes": r["changes"], "meaningful_changes": r["meaningful_changes"], "events": r["events"], "events_by_type": ser._dict(r["events_by_type"]), | ||||
| 349 | + "jobs_open": r["jobs_open"], "jobs_new": r["jobs_new"], "jobs_removed": r["jobs_removed"], | ||||
| 350 | + "activity_index": ser._float(r["activity_index"], 2), "by_country": ser._dict(r["by_country"]), "by_industry": ser._dict(r["by_industry"])} | ||||
| 351 | + for r in rows] | ||||
| 352 | + | ||||
| 353 | + | ||||
| 354 | +def _index_at(rows: list[dict[str, Any]], days_back: int) -> float | None: | ||||
| 355 | + if not rows: | ||||
| 356 | + return None | ||||
| 357 | + target = rows[-1]["day"] - timedelta(days=days_back) | ||||
| 358 | + candidates = [r for r in rows if r["day"] <= target and r["activity_index"] is not None] | ||||
| 359 | + return candidates[-1]["activity_index"] if candidates else None | ||||
| 360 | + | ||||
| 361 | + | ||||
| 362 | +async def activity_index(conn: AsyncConnection, days: int = 365) -> dict[str, Any]: | ||||
| 363 | + rows = await global_daily_rows(conn, days) | ||||
| 364 | + with_value = [r for r in rows if r["activity_index"] is not None] | ||||
| 365 | + latest = with_value[-1] if with_value else None | ||||
| 366 | + value = latest["activity_index"] if latest else None | ||||
| 367 | + v7, v30 = _index_at(with_value, 7), _index_at(with_value, 30) | ||||
| 368 | + formula = await q.settings_value(conn, "index:formula_version") | ||||
| 369 | + return {"value": value, "baseline": 100, "delta_7d": round(value - v7, 2) if value is not None and v7 is not None else None, | ||||
| 370 | + "delta_30d": round(value - v30, 2) if value is not None and v30 is not None else None, | ||||
| 371 | + "series": [{"day": r["day"], "value": r["activity_index"], "confidence": None} for r in with_value], | ||||
| 372 | + "by_type": (latest or {}).get("events_by_type", {}), | ||||
| 373 | + "by_country": [{"key": k, "value": v} for k, v in sorted((latest or {}).get("by_country", {}).items(), key=lambda kv: -float(kv[1] or 0))[:50]], | ||||
| 374 | + "by_industry": [{"key": k, "value": v} for k, v in sorted((latest or {}).get("by_industry", {}).items(), key=lambda kv: -float(kv[1] or 0))[:50]], | ||||
| 375 | + "formula_version": formula if isinstance(formula, str) else METRICS_FORMULA_VERSION, "computed_at": (latest or {}).get("day")} | ||||
| 376 | + | ||||
| 377 | + | ||||
| 378 | +async def system_health(conn: AsyncConnection) -> dict[str, Any]: | ||||
| 379 | + today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) | ||||
| 380 | + row = await fetch_one(conn, """ | ||||
| 381 | + select (select count(*) from sensors where status = 'active') as sensors_online, | ||||
| 382 | + (select count(*) from sensors where status in ('failing', 'stale', 'blocked')) as sensors_failing, | ||||
| 383 | + (select count(*) from observations where fetched_at >= :today) as observations_today, | ||||
| 384 | + (select count(*) from events where status = 'active' and detected_at >= :today) as events_today, | ||||
| 385 | + (select count(distinct country) from companies where country is not null and status = 'ACTIVE') as countries_covered, | ||||
| 386 | + (select extract(epoch from (now() - min(run_at))) from queue_jobs where status = 'pending' and run_at <= now()) as queue_lag_s, | ||||
| 387 | + (select count(*) from observations where fetched_at >= now() - interval '10 minutes') as obs_10m, | ||||
| 388 | + (select count(*) from observations where fetched_at >= now() - interval '24 hours') as obs_24h, | ||||
| 389 | + (select count(*) from observations where fetched_at >= now() - interval '24 hours' and failure_class is null) as ok_24h | ||||
| 390 | + """, today=today) or {} | ||||
| 391 | + hb = await q.settings_value(conn, "scheduler:heartbeat") | ||||
| 392 | + tick = None | ||||
| 393 | + if isinstance(hb, dict): | ||||
| 394 | + tick = hb.get("at") or hb.get("ts") or hb.get("time") or hb.get("last_tick_at") | ||||
| 395 | + elif isinstance(hb, str): | ||||
| 396 | + tick = hb | ||||
| 397 | + obs_24h = int(row.get("obs_24h") or 0) | ||||
| 398 | + return {"sensors_online": int(row.get("sensors_online") or 0), "sensors_failing": int(row.get("sensors_failing") or 0), | ||||
| 399 | + "observations_today": int(row.get("observations_today") or 0), "events_today": int(row.get("events_today") or 0), | ||||
| 400 | + "countries_covered": int(row.get("countries_covered") or 0), | ||||
| 401 | + "queue_lag_s": round(float(row["queue_lag_s"]), 1) if row.get("queue_lag_s") is not None else 0.0, | ||||
| 402 | + "scheduler_last_tick_at": tick, "fetch_per_min": round(int(row.get("obs_10m") or 0) / 10.0, 2), | ||||
| 403 | + "success_rate_24h": round(int(row.get("ok_24h") or 0) / obs_24h, 4) if obs_24h else None} | ||||
| 404 | + | ||||
| 405 | + | ||||
| 406 | +async def live_events(conn: AsyncConnection, limit: int, **filters: Any) -> list[dict[str, Any]]: | ||||
| 407 | + where, params = q.event_filters(**filters) | ||||
| 408 | + return [ser.event(r) for r in await q.fetch_events(conn, where, params, sort="recent", limit=limit)] | ||||
| 409 | + | ||||
| 410 | + | ||||
| 411 | +def clear_aggregate_cache() -> None: | ||||
| 412 | + cache.clear() | ||||
| 413 | + | ||||
| 414 | + | ||||
| 415 | +__all__ = ["MAP_MAX_BUCKETS", "RANKING_KINDS", "activity_index", "archive_stats", "cached_country_rows", "cached_global_stats", "cached_industry_rows", | ||||
| 416 | + "clear_aggregate_cache", "country_rows", "global_daily_rows", "global_stats", "industry_rows", "live_events", "map_buckets", "ranking", | ||||
| 417 | + "ranking_cards", "resolve_country", "system_health", "trend_rows"] | ||||
added
src/companyatlas/api/ask_fallback.py
+180 −0
@@ -0,0 +1,180 @@ | |||||
| 1 | +"""Deterministic natural-language parser for `/ask` (used when `companyatlas.services.llm.ask` is not available). | ||||
| 2 | + | ||||
| 3 | +"companies hiring AI engineers in Canada" → {event_types: [HIRING], ai: true, country: "CA", window: "30d"}. Only vocabulary from the | ||||
| 4 | +taxonomy and the reference tables is recognised; anything else becomes free-text `terms` used for company search. Nothing is guessed: | ||||
| 5 | +when no filter matches, the interpretation says so and the answer falls back to "no monitored evidence". | ||||
| 6 | +""" | ||||
| 7 | +from __future__ import annotations | ||||
| 8 | + | ||||
| 9 | +import re | ||||
| 10 | +from dataclasses import asdict, dataclass, field | ||||
| 11 | +from typing import Any | ||||
| 12 | + | ||||
| 13 | +from companyatlas.ids import normalize_alias | ||||
| 14 | + | ||||
| 15 | +WINDOW_PATTERNS: list[tuple[re.Pattern[str], str]] = [ | ||||
| 16 | + (re.compile(r"\b(today|last 24 ?h(ours)?|past 24 ?h(ours)?)\b", re.IGNORECASE), "24h"), | ||||
| 17 | + (re.compile(r"\b(yesterday)\b", re.IGNORECASE), "24h"), | ||||
| 18 | + (re.compile(r"\b(this week|last (7|seven) days|past (7|seven) days|last week|weekly)\b", re.IGNORECASE), "7d"), | ||||
| 19 | + (re.compile(r"\b(this month|last (30|thirty) days|past (30|thirty) days|last month|monthly|recently|recent)\b", re.IGNORECASE), "30d"), | ||||
| 20 | + (re.compile(r"\b(this quarter|last (90|ninety) days|past (90|ninety) days|last quarter|quarterly|last (3|three) months)\b", re.IGNORECASE), "90d"), | ||||
| 21 | + (re.compile(r"\b(this year|last (12|twelve) months|past year|last year|yearly|annual)\b", re.IGNORECASE), "1y"), | ||||
| 22 | +] | ||||
| 23 | + | ||||
| 24 | +EVENT_KEYWORDS: dict[str, tuple[str, ...]] = { | ||||
| 25 | + "HIRING": ("hiring", "hire", "hires", "jobs", "job", "recruit", "recruiting", "openings", "positions", "careers", "vacanc", "headcount", "layoff", "layoffs"), | ||||
| 26 | + "PRICING": ("pricing", "price", "prices", "plan", "plans", "tier", "tiers", "subscription", "cheaper", "expensive"), | ||||
| 27 | + "PRODUCT": ("launch", "launched", "launches", "product", "products", "release", "released", "feature", "features", "shipped", "ship"), | ||||
| 28 | + "LEADERSHIP": ("ceo", "cfo", "cto", "coo", "executive", "executives", "leadership", "appointed", "appoint", "board", "founder", "president", "chief"), | ||||
| 29 | + "LOCATION": ("office", "offices", "location", "locations", "expansion", "expanding", "expand", "opened", "opening", "headquarters", "hq", "store", "stores", "factory"), | ||||
| 30 | + "FINANCING": ("funding", "raised", "raise", "round", "series a", "series b", "investment", "investors", "ipo", "valuation"), | ||||
| 31 | + "M&A": ("acquisition", "acquired", "acquire", "acquires", "merger", "merged", "buyout", "divest", "divestiture"), | ||||
| 32 | + "PARTNERSHIP": ("partnership", "partner", "partners", "partnered", "alliance", "integration"), | ||||
| 33 | + "DEVELOPER": ("api", "apis", "sdk", "developer", "developers", "docs", "documentation", "changelog"), | ||||
| 34 | + "LEGAL": ("legal", "terms", "privacy", "policy", "compliance", "regulatory", "regulation", "gdpr"), | ||||
| 35 | + "SECURITY": ("security", "breach", "incident", "vulnerability", "cve"), | ||||
| 36 | + "TECHNOLOGY": ("technology", "tech stack", "adopted", "adoption", "platform"), | ||||
| 37 | + "SUSTAINABILITY": ("sustainability", "esg", "climate", "carbon", "net zero"), | ||||
| 38 | + "INVESTOR_RELATIONS": ("earnings", "investor", "quarterly results", "annual report", "guidance"), | ||||
| 39 | + "COMMUNICATION": ("news", "press", "announcement", "announced", "blog", "published"), | ||||
| 40 | +} | ||||
| 41 | +AI_TERMS = ("ai", "a.i.", "artificial intelligence", "machine learning", "ml", "llm", "llms", "generative", "genai", "copilot", "agentic", "deep learning") | ||||
| 42 | +RANKING_INTENT: list[tuple[re.Pattern[str], str]] = [ | ||||
| 43 | + (re.compile(r"\b(most active|fastest moving|moving fastest|busiest)\b", re.IGNORECASE), "most_active"), | ||||
| 44 | + (re.compile(r"\b(hiring (the )?(most|fastest)|fastest hiring|hiring growth|growing headcount)\b", re.IGNORECASE), "hiring_growth"), | ||||
| 45 | + (re.compile(r"\b(hiring (decline|freeze|slowdown)|cutting|fewer jobs|shrinking)\b", re.IGNORECASE), "hiring_decline"), | ||||
| 46 | + (re.compile(r"\b(product velocity|shipping (the )?most|most launches)\b", re.IGNORECASE), "product_velocity"), | ||||
| 47 | + (re.compile(r"\b(most ai|ai[- ]active|ai adoption|ai leaders)\b", re.IGNORECASE), "ai_active"), | ||||
| 48 | + (re.compile(r"\b(expanding (abroad|internationally|geographically)|geographic expansion|new countries)\b", re.IGNORECASE), "geo_expansion"), | ||||
| 49 | + (re.compile(r"\b(developer momentum|developer[- ]focused)\b", re.IGNORECASE), "developer_momentum"), | ||||
| 50 | + (re.compile(r"\b(pricing changes|changed (their )?pricing|price (increase|hike)s?)\b", re.IGNORECASE), "pricing_changes"), | ||||
| 51 | + (re.compile(r"\b(unusual|anomal|abnormal|behaving (unusually|strangely))", re.IGNORECASE), "unusual_activity"), | ||||
| 52 | +] | ||||
| 53 | +DEMONYMS: dict[str, str] = { | ||||
| 54 | + "canadian": "CA", "american": "US", "us": "US", "usa": "US", "u.s.": "US", "british": "GB", "uk": "GB", "u.k.": "GB", "english": "GB", "french": "FR", | ||||
| 55 | + "german": "DE", "japanese": "JP", "korean": "KR", "indian": "IN", "australian": "AU", "chinese": "CN", "brazilian": "BR", "mexican": "MX", | ||||
| 56 | + "dutch": "NL", "swedish": "SE", "swiss": "CH", "spanish": "ES", "italian": "IT", "israeli": "IL", "singaporean": "SG", "irish": "IE", | ||||
| 57 | + "european": None, # region, not a country | ||||
| 58 | +} | ||||
| 59 | +STOPWORDS = {"the", "a", "an", "of", "in", "on", "at", "for", "to", "and", "or", "with", "which", "what", "who", "are", "is", "was", "were", "has", | ||||
| 60 | + "have", "had", "do", "does", "did", "show", "me", "list", "find", "companies", "company", "that", "this", "these", "those", "any", | ||||
| 61 | + "all", "from", "by", "about", "their", "its", "new", "recently", "recent", "engineers", "engineer", "people", "roles", "many", "how", | ||||
| 62 | + "much", "top", "best", "biggest", "largest", "most", "events", "event", "changes", "change", "signal", "signals", "atlas"} | ||||
| 63 | + | ||||
| 64 | + | ||||
| 65 | +@dataclass | ||||
| 66 | +class Interpretation: | ||||
| 67 | + query: str | ||||
| 68 | + window: str = "30d" | ||||
| 69 | + event_types: list[str] = field(default_factory=list) | ||||
| 70 | + country: str | None = None | ||||
| 71 | + country_name: str | None = None | ||||
| 72 | + industry: str | None = None | ||||
| 73 | + industry_name: str | None = None | ||||
| 74 | + ai: bool = False | ||||
| 75 | + ranking_kind: str | None = None | ||||
| 76 | + company_terms: list[str] = field(default_factory=list) | ||||
| 77 | + terms: list[str] = field(default_factory=list) | ||||
| 78 | + matched: list[str] = field(default_factory=list) | ||||
| 79 | + | ||||
| 80 | + def to_json(self) -> dict[str, Any]: | ||||
| 81 | + d = asdict(self) | ||||
| 82 | + d["filters"] = {k: v for k, v in {"event_types": self.event_types or None, "country": self.country, "industry": self.industry, | ||||
| 83 | + "ai": self.ai or None, "window": self.window}.items() if v} | ||||
| 84 | + return d | ||||
| 85 | + | ||||
| 86 | + | ||||
| 87 | +def _contains(text: str, phrase: str) -> bool: | ||||
| 88 | + return re.search(rf"(?<![a-z0-9]){re.escape(phrase)}(?![a-z0-9])", text) is not None | ||||
| 89 | + | ||||
| 90 | + | ||||
| 91 | +def interpret(query: str, *, countries: list[dict[str, Any]], industries: list[dict[str, Any]]) -> Interpretation: | ||||
| 92 | + """`countries`: [{code, name}], `industries`: [{slug, name, keywords}]. Deterministic, order-independent.""" | ||||
| 93 | + text = " " + re.sub(r"\s+", " ", (query or "").strip().lower()) + " " | ||||
| 94 | + it = Interpretation(query=query.strip()) | ||||
| 95 | + consumed: list[str] = [] | ||||
| 96 | + for pat, w in WINDOW_PATTERNS: | ||||
| 97 | + m = pat.search(text) | ||||
| 98 | + if m: | ||||
| 99 | + it.window = w | ||||
| 100 | + consumed.append(m.group(0)) | ||||
| 101 | + it.matched.append(f"window:{w}") | ||||
| 102 | + break | ||||
| 103 | + for pat, kind in RANKING_INTENT: | ||||
| 104 | + m = pat.search(text) | ||||
| 105 | + if m: | ||||
| 106 | + it.ranking_kind = kind | ||||
| 107 | + consumed.append(m.group(0)) | ||||
| 108 | + it.matched.append(f"ranking:{kind}") | ||||
| 109 | + break | ||||
| 110 | + if any(_contains(text, t) for t in AI_TERMS): | ||||
| 111 | + it.ai = True | ||||
| 112 | + it.matched.append("ai") | ||||
| 113 | + consumed.extend(t for t in AI_TERMS if _contains(text, t)) | ||||
| 114 | + for etype, words in EVENT_KEYWORDS.items(): | ||||
| 115 | + hits = [w for w in words if _contains(text, w)] | ||||
| 116 | + if hits: | ||||
| 117 | + it.event_types.append(etype) | ||||
| 118 | + consumed.extend(hits) | ||||
| 119 | + it.matched.append(f"event_type:{etype}") | ||||
| 120 | + by_len = sorted(countries, key=lambda c: -len(c.get("name") or "")) | ||||
| 121 | + for c in by_len: | ||||
| 122 | + name = (c.get("name") or "").lower() | ||||
| 123 | + if name and _contains(text, name): | ||||
| 124 | + it.country, it.country_name = c["code"], c["name"] | ||||
| 125 | + consumed.append(name) | ||||
| 126 | + it.matched.append(f"country:{c['code']}") | ||||
| 127 | + break | ||||
| 128 | + if it.country is None: | ||||
| 129 | + codes = {c["code"].upper(): c for c in countries} | ||||
| 130 | + for dem, code in DEMONYMS.items(): | ||||
| 131 | + if code and _contains(text, dem) and code in codes: | ||||
| 132 | + it.country, it.country_name = code, codes[code]["name"] | ||||
| 133 | + consumed.append(dem) | ||||
| 134 | + it.matched.append(f"country:{code}") | ||||
| 135 | + break | ||||
| 136 | + for ind in sorted(industries, key=lambda i: -len(i.get("name") or "")): | ||||
| 137 | + candidates = [ind.get("name") or "", ind.get("slug", "").replace("-", " ")] + list(ind.get("keywords") or []) | ||||
| 138 | + hit = next((c for c in candidates if c and len(c) >= 3 and _contains(text, c.lower())), None) | ||||
| 139 | + if hit: | ||||
| 140 | + it.industry, it.industry_name = ind["slug"], ind.get("name") or ind["slug"] | ||||
| 141 | + consumed.append(hit.lower()) | ||||
| 142 | + it.matched.append(f"industry:{ind['slug']}") | ||||
| 143 | + break | ||||
| 144 | + residual = text | ||||
| 145 | + for phrase in sorted(set(consumed), key=len, reverse=True): | ||||
| 146 | + residual = re.sub(rf"(?<![a-z0-9]){re.escape(phrase)}(?![a-z0-9])", " ", residual) | ||||
| 147 | + tokens = [t for t in re.findall(r"[a-z0-9][a-z0-9.&'-]*", residual) if t not in STOPWORDS and len(t) > 1] | ||||
| 148 | + it.terms = tokens[:8] | ||||
| 149 | + it.company_terms = [t for t in tokens if len(t) >= 3][:4] | ||||
| 150 | + return it | ||||
| 151 | + | ||||
| 152 | + | ||||
| 153 | +def compose_answer(it: Interpretation, *, events_total: int, companies_count: int, sample_titles: list[str]) -> str: | ||||
| 154 | + """Careful, non-fabricating summary sentence (spec §162–168).""" | ||||
| 155 | + scope = [] | ||||
| 156 | + if it.event_types: | ||||
| 157 | + scope.append(" / ".join(it.event_types).lower() + " events") | ||||
| 158 | + else: | ||||
| 159 | + scope.append("events") | ||||
| 160 | + if it.ai: | ||||
| 161 | + scope.append("with observable AI signals") | ||||
| 162 | + if it.industry_name: | ||||
| 163 | + scope.append(f"in {it.industry_name}") | ||||
| 164 | + if it.country_name: | ||||
| 165 | + scope.append(f"in {it.country_name}") | ||||
| 166 | + window = {"24h": "the last 24 hours", "7d": "the last 7 days", "30d": "the last 30 days", "90d": "the last 90 days", "1y": "the last 12 months"}[it.window] | ||||
| 167 | + if events_total == 0 and companies_count == 0: | ||||
| 168 | + return f"No monitored evidence matches this question yet ({' '.join(scope)}, {window}). Coverage grows as sensors observe more pages." | ||||
| 169 | + parts = [f"Detected {events_total:,} {' '.join(scope)} across {companies_count:,} monitored compan{'y' if companies_count == 1 else 'ies'} in {window}."] | ||||
| 170 | + if sample_titles: | ||||
| 171 | + parts.append("Most recent: " + "; ".join(t[:90] for t in sample_titles[:3]) + ".") | ||||
| 172 | + parts.append("Each event links to its public source; interpretations are signals, not verified facts.") | ||||
| 173 | + return " ".join(parts) | ||||
| 174 | + | ||||
| 175 | + | ||||
| 176 | +def alias_key(term: str) -> str: | ||||
| 177 | + return normalize_alias(term) | ||||
| 178 | + | ||||
| 179 | + | ||||
| 180 | +__all__ = ["EVENT_KEYWORDS", "Interpretation", "alias_key", "compose_answer", "interpret"] | ||||
modified
src/companyatlas/api/common.py
+32 −3
@@ -5,10 +5,10 @@ import hashlib | |||||
| 5 | import hmac | 5 | import hmac | ||
| 6 | import time | 6 | import time | ||
| 7 | from collections import OrderedDict | 7 | from collections import OrderedDict | ||
| 8 | −from typing import Any | 8 | +from typing import Annotated, Any | ||
| 9 | 9 | ||||
| 10 | import orjson | 10 | import orjson | ||
| 11 | −from fastapi import Header, HTTPException, Query, Request | 11 | +from fastapi import Depends, Header, HTTPException, Query, Request, Response | ||
| 12 | from fastapi.responses import JSONResponse | 12 | from fastapi.responses import JSONResponse | ||
| 13 | 13 | ||||
| 14 | from companyatlas.config import settings | 14 | from companyatlas.config import settings | ||
@@ -44,6 +44,9 @@ class PageParams: | |||||
| 44 | return (self.page - 1) * self.per_page | 44 | return (self.page - 1) * self.per_page | ||
| 45 | 45 | ||||
| 46 | 46 | ||||
| 47 | +PageDep = Annotated[PageParams, Depends()] # `p: PageDep` in route signatures (avoids call-in-default lint) | ||||
| 48 | + | ||||
| 49 | + | ||||
| 47 | def page_payload(items: list[Any], total: int, p: PageParams) -> dict[str, Any]: | 50 | def page_payload(items: list[Any], total: int, p: PageParams) -> dict[str, Any]: | ||
| 48 | return {"items": items, "page": p.page, "per_page": p.per_page, "total": total, "pages": max(1, -(-total // p.per_page))} | 51 | return {"items": items, "page": p.page, "per_page": p.per_page, "total": total, "pages": max(1, -(-total // p.per_page))} | ||
| 49 | 52 | ||||
@@ -115,4 +118,30 @@ async def cached(key: str, ttl_s: float, producer): # type: ignore[no-untyped-d | |||||
| 115 | return value | 118 | return value | ||
| 116 | 119 | ||||
| 117 | 120 | ||||
| 118 | −__all__ = ["AtlasJSONResponse", "PageParams", "TTLCache", "cache", "cached", "client_ip", "owner_hash", "page_payload", "require_admin"] | 121 | +# ------------------------------------------------------------------------------------------------ cache headers / conditional GET | ||
| 122 | + | ||||
| 123 | +NO_STORE = "no-store" | ||||
| 124 | + | ||||
| 125 | + | ||||
| 126 | +def public_cache_value(max_age_s: int) -> str: | ||||
| 127 | + return f"public, max-age={max_age_s}, stale-while-revalidate={max_age_s * 2}" | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +def cached_response(request: Request, payload: Any, max_age_s: int) -> Any: | ||||
| 131 | + """JSON response for a cached public aggregate: `Cache-Control: public, max-age=…` + weak ETag, `304` when `If-None-Match` matches.""" | ||||
| 132 | + body = AtlasJSONResponse(payload).body | ||||
| 133 | + etag = 'W/"' + hashlib.sha1(body).hexdigest()[:20] + '"' | ||||
| 134 | + headers = {"cache-control": public_cache_value(max_age_s), "etag": etag, "vary": "Accept-Encoding"} | ||||
| 135 | + inm = request.headers.get("if-none-match") | ||||
| 136 | + if inm and etag in [x.strip() for x in inm.split(",")]: | ||||
| 137 | + return Response(status_code=304, headers=headers) | ||||
| 138 | + return Response(content=body, media_type=AtlasJSONResponse.media_type, headers=headers) | ||||
| 139 | + | ||||
| 140 | + | ||||
| 141 | +def is_admin_request(request: Request) -> bool: | ||||
| 142 | + tok = request.headers.get("x-ca-admin-token") | ||||
| 143 | + return bool(settings.admin_token and tok and hmac.compare_digest(tok, settings.admin_token)) | ||||
| 144 | + | ||||
| 145 | + | ||||
| 146 | +__all__ = ["NO_STORE", "AtlasJSONResponse", "PageDep", "PageParams", "TTLCache", "cache", "cached", "cached_response", "client_ip", "is_admin_request", | ||||
| 147 | + "owner_hash", "page_payload", "public_cache_value", "require_admin"] | ||||
modified
src/companyatlas/api/main.py
+4 −0
@@ -128,4 +128,8 @@ def _include_routers() -> None: | |||||
| 128 | 128 | ||||
| 129 | _include_routers() | 129 | _include_routers() | ||
| 130 | 130 | ||||
| 131 | +from companyatlas.api.ratelimit import rate_limit_middleware # registered last on purpose: outermost app-level hook | ||||
| 132 | + | ||||
| 133 | +app.middleware("http")(rate_limit_middleware) | ||||
| 134 | + | ||||
| 131 | __all__ = ["API_VERSION", "app"] | 135 | __all__ = ["API_VERSION", "app"] | ||
added
src/companyatlas/api/queries.py
+309 −0
@@ -0,0 +1,309 @@ | |||||
| 1 | +"""Reusable, parameterised SQL for the public/admin API. | ||||
| 2 | + | ||||
| 3 | +Rules: user input is always bound (`:param`), never interpolated; sort keys go through whitelists; every list is bounded; the | ||||
| 4 | +company card is built in two phases (page of ids with an indexed ORDER BY, then one card query for those ids) so the per-company | ||||
| 5 | +lateral aggregates only run for the rows that are returned. | ||||
| 6 | +""" | ||||
| 7 | +from __future__ import annotations | ||||
| 8 | + | ||||
| 9 | +from datetime import UTC, date, datetime, timedelta | ||||
| 10 | +from typing import Any | ||||
| 11 | + | ||||
| 12 | +from fastapi import HTTPException | ||||
| 13 | +from sqlalchemy.ext.asyncio import AsyncConnection | ||||
| 14 | + | ||||
| 15 | +from companyatlas.db import fetch_all, fetch_one, fetch_val | ||||
| 16 | +from companyatlas.taxonomy import EventType | ||||
| 17 | + | ||||
| 18 | +# ------------------------------------------------------------------------------------------------ windows / params | ||||
| 19 | + | ||||
| 20 | +WINDOWS: dict[str, timedelta] = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30), "90d": timedelta(days=90), | ||||
| 21 | + "1y": timedelta(days=365)} | ||||
| 22 | +COUNT_CAP = 10_000 # `total` is exact up to this bound (keeps deep pagination cheap on the big tables) | ||||
| 23 | +MAX_LIMIT = 500 | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +def now_utc() -> datetime: | ||||
| 27 | + return datetime.now(UTC) | ||||
| 28 | + | ||||
| 29 | + | ||||
| 30 | +def window_start(window: str, default: str = "7d") -> datetime: | ||||
| 31 | + return now_utc() - WINDOWS.get(window, WINDOWS[default]) | ||||
| 32 | + | ||||
| 33 | + | ||||
| 34 | +def days_ago(days: int) -> datetime: | ||||
| 35 | + return now_utc() - timedelta(days=days) | ||||
| 36 | + | ||||
| 37 | + | ||||
| 38 | +def parse_iso(value: str | None, name: str = "since") -> datetime | None: | ||||
| 39 | + if not value: | ||||
| 40 | + return None | ||||
| 41 | + v = value.strip() | ||||
| 42 | + if v.endswith(("Z", "z")): | ||||
| 43 | + v = v[:-1] + "+00:00" | ||||
| 44 | + try: | ||||
| 45 | + dt = datetime.fromisoformat(v) | ||||
| 46 | + except ValueError: | ||||
| 47 | + try: | ||||
| 48 | + dt = datetime.combine(date.fromisoformat(v), datetime.min.time()) | ||||
| 49 | + except ValueError as exc: | ||||
| 50 | + raise HTTPException(status_code=422, detail=f"{name}: invalid ISO-8601 timestamp") from exc | ||||
| 51 | + if dt.tzinfo is None: | ||||
| 52 | + dt = dt.replace(tzinfo=UTC) | ||||
| 53 | + return dt.astimezone(UTC) | ||||
| 54 | + | ||||
| 55 | + | ||||
| 56 | +def parse_bool(value: str | bool | None) -> bool | None: | ||||
| 57 | + if value is None or value == "": | ||||
| 58 | + return None | ||||
| 59 | + if isinstance(value, bool): | ||||
| 60 | + return value | ||||
| 61 | + return value.strip().lower() in ("1", "true", "yes", "on") | ||||
| 62 | + | ||||
| 63 | + | ||||
| 64 | +def clamp(value: int, lo: int, hi: int) -> int: | ||||
| 65 | + return max(lo, min(hi, value)) | ||||
| 66 | + | ||||
| 67 | + | ||||
| 68 | +def csv_list(value: str | None, *, maxlen: int = 20) -> list[str]: | ||||
| 69 | + if not value: | ||||
| 70 | + return [] | ||||
| 71 | + return [x.strip() for x in value.split(",") if x.strip()][:maxlen] | ||||
| 72 | + | ||||
| 73 | + | ||||
| 74 | +# ------------------------------------------------------------------------------------------------ companies | ||||
| 75 | + | ||||
| 76 | + | ||||
| 77 | +async def get_company(conn: AsyncConnection, key: str) -> dict[str, Any] | None: | ||||
| 78 | + key = (key or "").strip() | ||||
| 79 | + if not key or len(key) > 200: | ||||
| 80 | + return None | ||||
| 81 | + return await fetch_one(conn, "select * from companies where slug = :k or id = :k limit 1", k=key) | ||||
| 82 | + | ||||
| 83 | + | ||||
| 84 | +async def require_company(conn: AsyncConnection, key: str) -> dict[str, Any]: | ||||
| 85 | + row = await get_company(conn, key) | ||||
| 86 | + if row is None: | ||||
| 87 | + raise HTTPException(status_code=404, detail="company not found") | ||||
| 88 | + return row | ||||
| 89 | + | ||||
| 90 | + | ||||
| 91 | +COMPANY_CARD_SQL = """ | ||||
| 92 | +select c.id, c.slug, c.display_name, c.legal_name, c.canonical_domain, c.website, c.description, c.industries, c.industry_primary, | ||||
| 93 | + c.country, c.hq_city, c.hq_region, c.public_company, c.ticker, c.exchange, c.founded_year, c.employees_band, c.logo_url, | ||||
| 94 | + c.status, c.onboarding_status, c.onboarding_error, c.importance, c.tier, c.indexed, c.stats, c.last_event_at, c.last_observed_at, | ||||
| 95 | + c.first_observed_at, c.discovered_at, c.created_at, c.updated_at, | ||||
| 96 | + mx.metrics, sc.sensors, sc.observations, sc.changes, sc.events, jc.jobs_open{sparkline_col} | ||||
| 97 | +from companies c | ||||
| 98 | +left join lateral (select jsonb_object_agg(m.metric, m.value) as metrics from metrics_current m where m.company_id = c.id) mx on true | ||||
| 99 | +left join lateral (select count(*) filter (where s.status <> 'retired') as sensors, coalesce(sum(s.observation_count), 0) as observations, | ||||
| 100 | + coalesce(sum(s.change_count), 0) as changes, coalesce(sum(s.event_count), 0) as events | ||||
| 101 | + from sensors s where s.company_id = c.id) sc on true | ||||
| 102 | +left join lateral (select count(*) as jobs_open from jobs j where j.company_id = c.id and j.status = 'open') jc on true | ||||
| 103 | +{sparkline_join} | ||||
| 104 | +where c.id = any(cast(:ids as text[])) | ||||
| 105 | +""" | ||||
| 106 | +SPARKLINE_COL = ", sp.sparkline" | ||||
| 107 | +SPARKLINE_JOIN = ("left join lateral (select array_agg(x.value order by x.day) as sparkline from (select day, value from metric_series ms " | ||||
| 108 | + "where ms.company_id = c.id and ms.metric = 'activity_score' and ms.day >= :spark_from order by day desc limit 30) x) sp on true") | ||||
| 109 | + | ||||
| 110 | +COMPANY_SORTS: dict[str, str] = { | ||||
| 111 | + "activity": "ma.value desc nulls last, c.importance desc, c.id", | ||||
| 112 | + "events": "c.last_event_at desc nulls last, c.importance desc, c.id", | ||||
| 113 | + "hiring": "mh.value desc nulls last, c.importance desc, c.id", | ||||
| 114 | + "name": "c.display_name asc, c.id", | ||||
| 115 | + "importance": "c.importance desc, c.display_name asc, c.id", | ||||
| 116 | + "recent": "c.discovered_at desc, c.id", | ||||
| 117 | + # only valid together with a `q` filter (binds :q); list endpoints switch to it automatically when q is present | ||||
| 118 | + "relevance": ("greatest(coalesce(ts_rank(c.search, websearch_to_tsquery('simple', :q)), 0), similarity(c.display_name, :q), " | ||||
| 119 | + "similarity(c.canonical_domain, :q)) desc, c.importance desc, c.id"), | ||||
| 120 | +} | ||||
| 121 | +COMPANY_SORT_JOINS: dict[str, str] = { | ||||
| 122 | + "activity": "left join metrics_current ma on ma.company_id = c.id and ma.metric = 'activity_score'", | ||||
| 123 | + "hiring": "left join metrics_current mh on mh.company_id = c.id and mh.metric = 'hiring_momentum_30d'", | ||||
| 124 | +} | ||||
| 125 | + | ||||
| 126 | + | ||||
| 127 | +async def fetch_cards_by_ids(conn: AsyncConnection, ids: list[str], *, sparkline: bool = False) -> list[dict[str, Any]]: | ||||
| 128 | + """Card rows for the given ids, in the given order.""" | ||||
| 129 | + if not ids: | ||||
| 130 | + return [] | ||||
| 131 | + sql = COMPANY_CARD_SQL.format(sparkline_col=SPARKLINE_COL if sparkline else "", sparkline_join=SPARKLINE_JOIN if sparkline else "") | ||||
| 132 | + params: dict[str, Any] = {"ids": ids} | ||||
| 133 | + if sparkline: | ||||
| 134 | + params["spark_from"] = (now_utc() - timedelta(days=30)).date() | ||||
| 135 | + rows = await fetch_all(conn, sql, **params) | ||||
| 136 | + by_id = {r["id"]: r for r in rows} | ||||
| 137 | + return [by_id[i] for i in ids if i in by_id] | ||||
| 138 | + | ||||
| 139 | + | ||||
| 140 | +async def company_page_ids(conn: AsyncConnection, where: list[str], params: dict[str, Any], *, sort: str = "activity", limit: int = 25, | ||||
| 141 | + offset: int = 0, extra_join: str = "") -> tuple[list[str], int]: | ||||
| 142 | + """Phase 1 of a company list: ids for the page + bounded total.""" | ||||
| 143 | + sort = sort if sort in COMPANY_SORTS else "activity" | ||||
| 144 | + if sort == "relevance" and "q" not in params: | ||||
| 145 | + sort = "activity" | ||||
| 146 | + elif sort == "activity" and params.get("q"): | ||||
| 147 | + sort = "relevance" | ||||
| 148 | + join = " ".join(x for x in (COMPANY_SORT_JOINS.get(sort, ""), extra_join) if x) | ||||
| 149 | + where_sql = (" where " + " and ".join(where)) if where else "" | ||||
| 150 | + rows = await fetch_all(conn, f"select c.id from companies c {join}{where_sql} order by {COMPANY_SORTS[sort]} limit :limit offset :offset", | ||||
| 151 | + **params, limit=limit, offset=offset) | ||||
| 152 | + total = await bounded_count(conn, f"from companies c {extra_join}{where_sql}", params) | ||||
| 153 | + return [r["id"] for r in rows], total | ||||
| 154 | + | ||||
| 155 | + | ||||
| 156 | +async def bounded_count(conn: AsyncConnection, from_where_sql: str, params: dict[str, Any], cap: int = COUNT_CAP) -> int: | ||||
| 157 | + val = await fetch_val(conn, f"select count(*) from (select 1 {from_where_sql} limit :cap) t", **params, cap=cap) | ||||
| 158 | + return int(val or 0) | ||||
| 159 | + | ||||
| 160 | + | ||||
| 161 | +def company_filters(*, q: str | None = None, country: str | None = None, industry: str | None = None, tier: int | None = None, | ||||
| 162 | + public: bool | None = None, status: str | None = None, has_events: bool | None = None, | ||||
| 163 | + onboarding_status: str | None = None) -> tuple[list[str], dict[str, Any]]: | ||||
| 164 | + where: list[str] = [] | ||||
| 165 | + params: dict[str, Any] = {} | ||||
| 166 | + if q: | ||||
| 167 | + q = q.strip()[:200] | ||||
| 168 | + params["q"] = q | ||||
| 169 | + if len(q) >= 3: | ||||
| 170 | + where.append("(c.search @@ websearch_to_tsquery('simple', :q) or c.display_name % :q or c.canonical_domain % :q)") | ||||
| 171 | + else: | ||||
| 172 | + where.append("(c.display_name ilike :q_prefix or c.canonical_domain ilike :q_prefix)") | ||||
| 173 | + params["q_prefix"] = q.replace("%", "").replace("_", "") + "%" | ||||
| 174 | + if country: | ||||
| 175 | + where.append("c.country = cast(:country as char(2))") | ||||
| 176 | + params["country"] = country.strip().upper()[:2] | ||||
| 177 | + if industry: | ||||
| 178 | + where.append("cast(:industry as text) = any(c.industries)") | ||||
| 179 | + params["industry"] = industry.strip()[:80] | ||||
| 180 | + if tier is not None: | ||||
| 181 | + where.append("c.tier = :tier") | ||||
| 182 | + params["tier"] = tier | ||||
| 183 | + if public is not None: | ||||
| 184 | + where.append("c.public_company = :public") | ||||
| 185 | + params["public"] = public | ||||
| 186 | + if status: | ||||
| 187 | + where.append("c.status = cast(:status as text)") | ||||
| 188 | + params["status"] = status.strip().upper()[:40] | ||||
| 189 | + if has_events is True: | ||||
| 190 | + where.append("c.last_event_at is not null") | ||||
| 191 | + elif has_events is False: | ||||
| 192 | + where.append("c.last_event_at is null") | ||||
| 193 | + if onboarding_status: | ||||
| 194 | + where.append("c.onboarding_status = cast(:onboarding_status as text)") | ||||
| 195 | + params["onboarding_status"] = onboarding_status.strip().lower()[:40] | ||||
| 196 | + return where, params | ||||
| 197 | + | ||||
| 198 | + | ||||
| 199 | +# ------------------------------------------------------------------------------------------------ events | ||||
| 200 | + | ||||
| 201 | +EVENT_SELECT = """ | ||||
| 202 | +select e.id, e.company_id, e.sensor_id, e.change_id, e.cluster_id, e.surface, e.event_type, e.event_subtype, e.importance, e.confidence, | ||||
| 203 | + e.confidence_label, e.title, e.summary, e.old_value, e.new_value, e.payload, e.entities, e.tags, e.detected_at, e.effective_at, | ||||
| 204 | + e.published_at, e.source_url, e.snapshot_before, e.snapshot_after, e.language, e.origin, e.model_name, e.prompt_version, e.status, | ||||
| 205 | + e.retracted_reason, | ||||
| 206 | + c.slug as company_slug, c.display_name as company_display_name, c.canonical_domain as company_domain, c.country as company_country, | ||||
| 207 | + c.logo_url as company_logo_url | ||||
| 208 | +from events e join companies c on c.id = e.company_id | ||||
| 209 | +""" | ||||
| 210 | +EVENT_SORTS: dict[str, str] = {"recent": "e.detected_at desc, e.id desc", "importance": "e.importance desc, e.detected_at desc, e.id desc"} | ||||
| 211 | +_EVENT_TYPES = {t.value for t in EventType} | ||||
| 212 | + | ||||
| 213 | + | ||||
| 214 | +def event_filters(*, company_id: str | None = None, event_type: str | None = None, event_subtype: str | None = None, country: str | None = None, | ||||
| 215 | + industry: str | None = None, since: datetime | None = None, until: datetime | None = None, min_importance: float | None = None, | ||||
| 216 | + min_confidence: float | None = None, q: str | None = None, surface: str | None = None, origin: str | None = None, | ||||
| 217 | + status: str | None = "active", event_types: list[str] | None = None, | ||||
| 218 | + event_subtypes: list[str] | None = None) -> tuple[list[str], dict[str, Any]]: | ||||
| 219 | + where: list[str] = [] | ||||
| 220 | + params: dict[str, Any] = {} | ||||
| 221 | + if status: | ||||
| 222 | + where.append("e.status = cast(:e_status as text)") | ||||
| 223 | + params["e_status"] = status | ||||
| 224 | + if company_id: | ||||
| 225 | + where.append("e.company_id = :e_company_id") | ||||
| 226 | + params["e_company_id"] = company_id | ||||
| 227 | + if event_type: | ||||
| 228 | + where.append("e.event_type = cast(:e_type as text)") | ||||
| 229 | + params["e_type"] = event_type.strip().upper()[:40] | ||||
| 230 | + if event_types: | ||||
| 231 | + where.append("e.event_type = any(cast(:e_types as text[]))") | ||||
| 232 | + params["e_types"] = [t.upper()[:40] for t in event_types][:30] | ||||
| 233 | + if event_subtype: | ||||
| 234 | + where.append("e.event_subtype = cast(:e_subtype as text)") | ||||
| 235 | + params["e_subtype"] = event_subtype.strip().upper()[:60] | ||||
| 236 | + if event_subtypes: | ||||
| 237 | + where.append("e.event_subtype = any(cast(:e_subtypes as text[]))") | ||||
| 238 | + params["e_subtypes"] = [t.upper()[:60] for t in event_subtypes][:60] | ||||
| 239 | + if country: | ||||
| 240 | + where.append("c.country = cast(:e_country as char(2))") | ||||
| 241 | + params["e_country"] = country.strip().upper()[:2] | ||||
| 242 | + if industry: | ||||
| 243 | + where.append("cast(:e_industry as text) = any(c.industries)") | ||||
| 244 | + params["e_industry"] = industry.strip()[:80] | ||||
| 245 | + if since is not None: | ||||
| 246 | + where.append("e.detected_at >= :e_since") | ||||
| 247 | + params["e_since"] = since | ||||
| 248 | + if until is not None: | ||||
| 249 | + where.append("e.detected_at < :e_until") | ||||
| 250 | + params["e_until"] = until | ||||
| 251 | + if min_importance is not None: | ||||
| 252 | + where.append("e.importance >= :e_min_imp") | ||||
| 253 | + params["e_min_imp"] = float(min_importance) | ||||
| 254 | + if min_confidence is not None: | ||||
| 255 | + where.append("e.confidence >= :e_min_conf") | ||||
| 256 | + params["e_min_conf"] = float(min_confidence) | ||||
| 257 | + if q: | ||||
| 258 | + where.append("e.search @@ websearch_to_tsquery('english', :e_q)") | ||||
| 259 | + params["e_q"] = q.strip()[:200] | ||||
| 260 | + if surface: | ||||
| 261 | + where.append("e.surface = cast(:e_surface as text)") | ||||
| 262 | + params["e_surface"] = surface.strip().lower()[:40] | ||||
| 263 | + if origin: | ||||
| 264 | + where.append("e.origin = cast(:e_origin as text)") | ||||
| 265 | + params["e_origin"] = origin.strip().lower()[:20] | ||||
| 266 | + return where, params | ||||
| 267 | + | ||||
| 268 | + | ||||
| 269 | +async def fetch_events(conn: AsyncConnection, where: list[str], params: dict[str, Any], *, sort: str = "recent", limit: int = 50, | ||||
| 270 | + offset: int = 0) -> list[dict[str, Any]]: | ||||
| 271 | + order = EVENT_SORTS.get(sort, EVENT_SORTS["recent"]) | ||||
| 272 | + where_sql = (" where " + " and ".join(where)) if where else "" | ||||
| 273 | + return await fetch_all(conn, f"{EVENT_SELECT}{where_sql} order by {order} limit :limit offset :offset", **params, | ||||
| 274 | + limit=clamp(limit, 1, MAX_LIMIT), offset=max(0, offset)) | ||||
| 275 | + | ||||
| 276 | + | ||||
| 277 | +async def count_events(conn: AsyncConnection, where: list[str], params: dict[str, Any]) -> int: | ||||
| 278 | + where_sql = (" where " + " and ".join(where)) if where else "" | ||||
| 279 | + return await bounded_count(conn, f"from events e join companies c on c.id = e.company_id{where_sql}", params) | ||||
| 280 | + | ||||
| 281 | + | ||||
| 282 | +async def fetch_event(conn: AsyncConnection, event_id: str) -> dict[str, Any] | None: | ||||
| 283 | + return await fetch_one(conn, f"{EVENT_SELECT} where e.id = :id", id=event_id) | ||||
| 284 | + | ||||
| 285 | + | ||||
| 286 | +# ------------------------------------------------------------------------------------------------ metrics helpers | ||||
| 287 | + | ||||
| 288 | + | ||||
| 289 | +async def metric_series(conn: AsyncConnection, company_id: str, metric: str, days: int) -> list[dict[str, Any]]: | ||||
| 290 | + return await fetch_all(conn, "select day, value, confidence from metric_series where company_id = :cid and metric = :m and day >= :d " | ||||
| 291 | + "order by day asc limit :lim", cid=company_id, m=metric, d=days_ago(days).date(), lim=clamp(days, 1, 2000)) | ||||
| 292 | + | ||||
| 293 | + | ||||
| 294 | +async def metric_values_at(conn: AsyncConnection, ids: list[str], metric: str, on_or_before: date) -> dict[str, float]: | ||||
| 295 | + """Latest series value on/before a day per company (used for window deltas).""" | ||||
| 296 | + if not ids: | ||||
| 297 | + return {} | ||||
| 298 | + rows = await fetch_all(conn, "select distinct on (company_id) company_id, value from metric_series where metric = :m and " | ||||
| 299 | + "company_id = any(cast(:ids as text[])) and day <= :d order by company_id, day desc", m=metric, ids=ids, d=on_or_before) | ||||
| 300 | + return {r["company_id"]: float(r["value"]) for r in rows} | ||||
| 301 | + | ||||
| 302 | + | ||||
| 303 | +async def settings_value(conn: AsyncConnection, key: str) -> Any: | ||||
| 304 | + return await fetch_val(conn, "select value from settings_kv where key = :k", k=key) | ||||
| 305 | + | ||||
| 306 | + | ||||
| 307 | +__all__ = ["COMPANY_SORTS", "COUNT_CAP", "EVENT_SORTS", "MAX_LIMIT", "WINDOWS", "bounded_count", "clamp", "company_filters", "company_page_ids", | ||||
| 308 | + "count_events", "csv_list", "days_ago", "event_filters", "fetch_cards_by_ids", "fetch_event", "fetch_events", "get_company", | ||||
| 309 | + "metric_series", "metric_values_at", "now_utc", "parse_bool", "parse_iso", "require_company", "settings_value", "window_start"] | ||||
added
src/companyatlas/api/ratelimit.py
+173 −0
@@ -0,0 +1,173 @@ | |||||
| 1 | +"""In-process token-bucket rate limiting (spec §136: tiers anonymous / authenticated / paid / internal). | ||||
| 2 | + | ||||
| 3 | +Key = client IP (first hop of `X-Forwarded-For`) for anonymous traffic, or the API key id when a valid `X-CA-API-Key` is presented. | ||||
| 4 | +API keys are looked up by sha256 hash in `api_keys` (60 s in-process cache, negative results too); `last_used_at`/`request_count` | ||||
| 5 | +are flushed lazily every `FLUSH_EVERY_S`. Admin requests (valid `X-CA-Admin-Token`) and health/docs paths bypass the limiter. | ||||
| 6 | +`429` carries `Retry-After`; every limited response carries `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Tier`. | ||||
| 7 | +""" | ||||
| 8 | +from __future__ import annotations | ||||
| 9 | + | ||||
| 10 | +import asyncio | ||||
| 11 | +import hashlib | ||||
| 12 | +import logging | ||||
| 13 | +import time | ||||
| 14 | +from dataclasses import dataclass, field | ||||
| 15 | +from datetime import UTC, datetime | ||||
| 16 | +from typing import Any | ||||
| 17 | + | ||||
| 18 | +from fastapi import Request | ||||
| 19 | + | ||||
| 20 | +from companyatlas.api.common import AtlasJSONResponse, TTLCache, client_ip, is_admin_request | ||||
| 21 | +from companyatlas.db import connection, execute, fetch_one, transaction | ||||
| 22 | + | ||||
| 23 | +log = logging.getLogger("companyatlas.api.ratelimit") | ||||
| 24 | + | ||||
| 25 | +TIER_LIMITS_PER_MIN: dict[str, int | None] = {"anonymous": 120, "authenticated": 600, "paid": 3000, "internal": None} | ||||
| 26 | +KEY_CACHE_TTL_S = 60.0 | ||||
| 27 | +FLUSH_EVERY_S = 30.0 | ||||
| 28 | +BYPASS_PREFIXES = ("/health", "/ready", "/api/v1/health", "/api/v1/docs", "/api/v1/openapi.json") | ||||
| 29 | +MAX_BUCKETS = 50_000 | ||||
| 30 | + | ||||
| 31 | + | ||||
| 32 | +@dataclass(slots=True) | ||||
| 33 | +class Bucket: | ||||
| 34 | + tokens: float | ||||
| 35 | + updated: float | ||||
| 36 | + | ||||
| 37 | + | ||||
| 38 | +@dataclass | ||||
| 39 | +class RateLimiter: | ||||
| 40 | + limits: dict[str, int | None] = field(default_factory=lambda: dict(TIER_LIMITS_PER_MIN)) | ||||
| 41 | + buckets: dict[str, Bucket] = field(default_factory=dict) | ||||
| 42 | + | ||||
| 43 | + def take(self, key: str, tier: str, now: float | None = None) -> tuple[bool, int, int, float]: | ||||
| 44 | + """Consume one token. Returns (allowed, limit, remaining, retry_after_s).""" | ||||
| 45 | + limit = self.limits.get(tier, self.limits["anonymous"]) | ||||
| 46 | + if limit is None: | ||||
| 47 | + return True, 0, 0, 0.0 | ||||
| 48 | + now = time.monotonic() if now is None else now | ||||
| 49 | + rate = limit / 60.0 | ||||
| 50 | + b = self.buckets.get(key) | ||||
| 51 | + if b is None: | ||||
| 52 | + if len(self.buckets) >= MAX_BUCKETS: | ||||
| 53 | + self._evict(now) | ||||
| 54 | + b = self.buckets[key] = Bucket(tokens=float(limit), updated=now) | ||||
| 55 | + else: | ||||
| 56 | + b.tokens = min(float(limit), b.tokens + (now - b.updated) * rate) | ||||
| 57 | + b.updated = now | ||||
| 58 | + if b.tokens >= 1.0: | ||||
| 59 | + b.tokens -= 1.0 | ||||
| 60 | + return True, limit, int(b.tokens), 0.0 | ||||
| 61 | + retry = (1.0 - b.tokens) / rate | ||||
| 62 | + return False, limit, 0, retry | ||||
| 63 | + | ||||
| 64 | + def _evict(self, now: float) -> None: | ||||
| 65 | + stale = [k for k, b in self.buckets.items() if now - b.updated > 120] | ||||
| 66 | + for k in stale: | ||||
| 67 | + self.buckets.pop(k, None) | ||||
| 68 | + if len(self.buckets) >= MAX_BUCKETS: # still full: drop the oldest half | ||||
| 69 | + for k in sorted(self.buckets, key=lambda k: self.buckets[k].updated)[: MAX_BUCKETS // 2]: | ||||
| 70 | + self.buckets.pop(k, None) | ||||
| 71 | + | ||||
| 72 | + | ||||
| 73 | +limiter = RateLimiter() | ||||
| 74 | +_key_cache = TTLCache(max_items=10_000) | ||||
| 75 | +_usage: dict[str, int] = {} | ||||
| 76 | +_usage_lock = asyncio.Lock() | ||||
| 77 | +_last_flush = time.monotonic() | ||||
| 78 | + | ||||
| 79 | + | ||||
| 80 | +def hash_key(raw: str) -> str: | ||||
| 81 | + return hashlib.sha256(raw.encode("utf-8")).hexdigest() | ||||
| 82 | + | ||||
| 83 | + | ||||
| 84 | +async def resolve_api_key(raw: str | None) -> dict[str, Any] | None: | ||||
| 85 | + """`{id, tier, name}` for a valid, non-revoked key; None otherwise (cached 60 s either way).""" | ||||
| 86 | + if not raw or len(raw) < 16 or len(raw) > 200: | ||||
| 87 | + return None | ||||
| 88 | + h = hash_key(raw) | ||||
| 89 | + hit = _key_cache.get(h) | ||||
| 90 | + if hit is not None: | ||||
| 91 | + return hit or None | ||||
| 92 | + try: | ||||
| 93 | + async with connection() as conn: | ||||
| 94 | + row = await fetch_one(conn, "select id, tier, name from api_keys where key_hash = :h and revoked_at is null", h=h) | ||||
| 95 | + except Exception: | ||||
| 96 | + log.warning("api key lookup failed", exc_info=True) | ||||
| 97 | + return None | ||||
| 98 | + info = {"id": row["id"], "tier": row["tier"], "name": row["name"]} if row else {} | ||||
| 99 | + _key_cache.set(h, info, KEY_CACHE_TTL_S) | ||||
| 100 | + return info or None | ||||
| 101 | + | ||||
| 102 | + | ||||
| 103 | +async def _write_usage(batch: dict[str, int]) -> None: | ||||
| 104 | + if not batch: | ||||
| 105 | + return | ||||
| 106 | + try: | ||||
| 107 | + async with transaction() as conn: | ||||
| 108 | + for kid, n in batch.items(): | ||||
| 109 | + await execute(conn, "update api_keys set last_used_at = :t, request_count = request_count + :n where id = :id", | ||||
| 110 | + t=datetime.now(UTC), n=n, id=kid) | ||||
| 111 | + except Exception: | ||||
| 112 | + log.warning("api key usage flush failed", exc_info=True) | ||||
| 113 | + | ||||
| 114 | + | ||||
| 115 | +async def _note_usage(key_id: str) -> None: | ||||
| 116 | + global _last_flush | ||||
| 117 | + async with _usage_lock: | ||||
| 118 | + _usage[key_id] = _usage.get(key_id, 0) + 1 | ||||
| 119 | + if time.monotonic() - _last_flush < FLUSH_EVERY_S: | ||||
| 120 | + return | ||||
| 121 | + batch, _last_flush = dict(_usage), time.monotonic() | ||||
| 122 | + _usage.clear() | ||||
| 123 | + await _write_usage(batch) | ||||
| 124 | + | ||||
| 125 | + | ||||
| 126 | +async def flush_usage() -> None: | ||||
| 127 | + """Force a usage flush (tests / shutdown).""" | ||||
| 128 | + global _last_flush | ||||
| 129 | + async with _usage_lock: | ||||
| 130 | + batch, _last_flush = dict(_usage), time.monotonic() | ||||
| 131 | + _usage.clear() | ||||
| 132 | + await _write_usage(batch) | ||||
| 133 | + | ||||
| 134 | + | ||||
| 135 | +def _is_local_server_side(request: Request) -> bool: | ||||
| 136 | + if request.headers.get("x-forwarded-for") or request.headers.get("x-real-ip"): | ||||
| 137 | + return False | ||||
| 138 | + host = request.client.host if request.client else "" | ||||
| 139 | + return host in ("127.0.0.1", "::1") | ||||
| 140 | + | ||||
| 141 | + | ||||
| 142 | +async def rate_limit_middleware(request: Request, call_next): # type: ignore[no-untyped-def] | ||||
| 143 | + path = request.url.path | ||||
| 144 | + if request.method == "OPTIONS" or path.startswith(BYPASS_PREFIXES): | ||||
| 145 | + return await call_next(request) | ||||
| 146 | + if is_admin_request(request): | ||||
| 147 | + response = await call_next(request) | ||||
| 148 | + response.headers["x-ratelimit-tier"] = "admin" | ||||
| 149 | + return response | ||||
| 150 | + key_info = await resolve_api_key(request.headers.get("x-ca-api-key")) | ||||
| 151 | + if key_info: | ||||
| 152 | + tier, bucket_key = key_info["tier"], f"key:{key_info['id']}" | ||||
| 153 | + asyncio.get_running_loop().create_task(_note_usage(key_info["id"])) | ||||
| 154 | + elif _is_local_server_side(request): | ||||
| 155 | + # Next.js server components fetch the loopback API without X-Forwarded-For: that is our own SSR, not a public client. | ||||
| 156 | + # Browser traffic proxied through the Next rewrite (and through Caddy) always carries X-Forwarded-For and stays anonymous. | ||||
| 157 | + tier, bucket_key = "internal", "internal:ssr" | ||||
| 158 | + else: | ||||
| 159 | + tier, bucket_key = "anonymous", f"ip:{client_ip(request)}" | ||||
| 160 | + allowed, limit, remaining, retry = limiter.take(bucket_key, tier) | ||||
| 161 | + if not allowed: | ||||
| 162 | + return AtlasJSONResponse({"detail": "rate limit exceeded"}, status_code=429, | ||||
| 163 | + headers={"retry-after": str(max(1, int(retry + 0.999))), "x-ratelimit-limit": str(limit), | ||||
| 164 | + "x-ratelimit-remaining": "0", "x-ratelimit-tier": tier, "cache-control": "no-store"}) | ||||
| 165 | + response = await call_next(request) | ||||
| 166 | + response.headers["x-ratelimit-tier"] = tier | ||||
| 167 | + if limit: | ||||
| 168 | + response.headers["x-ratelimit-limit"] = str(limit) | ||||
| 169 | + response.headers["x-ratelimit-remaining"] = str(remaining) | ||||
| 170 | + return response | ||||
| 171 | + | ||||
| 172 | + | ||||
| 173 | +__all__ = ["TIER_LIMITS_PER_MIN", "RateLimiter", "flush_usage", "hash_key", "limiter", "rate_limit_middleware", "resolve_api_key"] | ||||
added
src/companyatlas/api/routers/admin.py
+561 −0
Large diff (561 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/companies.py
+354 −0
Large diff (354 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/countries.py
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/events.py
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/exports.py
+183 −0
Large diff (183 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/industries.py
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/live.py
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/owner.py
+163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/provenance.py
+201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/rankings.py
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/search.py
+254 −0
Large diff (254 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/signals.py
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/sitemap.py
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/routers/stats.py
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/serializers.py
+290 −0
Large diff (290 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/api/sse.py
+85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/commands/api_keys.py
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/commands/intel.py
+252 −0
Large diff (252 lines) is not loaded by default to keep the page fast.Load diff
modified
src/companyatlas/config.py
+16 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/alerts.py
+237 −0
Large diff (237 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/clustering.py
+113 −0
Large diff (113 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/digest.py
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/events.py
+904 −0
Large diff (904 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/__init__.py
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/ask.py
+275 −0
Large diff (275 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/enrich.py
+357 −0
Large diff (357 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/gateway.py
+301 −0
Large diff (301 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/prompts.py
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/llm/schemas.py
+155 −0
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/metrics.py
+591 −0
Large diff (591 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/retention.py
+91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/signals.py
+273 −0
Large diff (273 lines) is not loaded by default to keep the page fast.Load diff
added
src/companyatlas/services/trends.py
+136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
modified
src/companyatlas/taxonomy.py
+25 −2
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
tests/factories.py
+168 −0
Large diff (168 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_admin.py
+139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_companies.py
+156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_limits_exports.py
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_owner.py
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_provenance.py
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_public.py
+198 −0
Large diff (198 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_api_support.py
+220 −0
Large diff (220 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_ask_parser.py
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_events_rules.py
+332 −0
Large diff (332 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_llm_gateway.py
+159 −0
Large diff (159 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_metrics.py
+184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_signals.py
+107 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff