SPB Git

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>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 043c04e Browse files

64 changed files +10,114 −6

64 changed files
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
Menu

Theme