SPB Git

spb/ai-atlas

Public

API 1.1 (Stream C): canonical model universe, comparability-grouped leaderboards, deployments & cost, intelligence surfaces, admin workbenches

Existing routes (additive, v1 shapes kept): /models canonical universe + include=artifacts, family_id join, canonical licence
matching, families/licenses/trust facets, /models/{slug} accepts artifacts and reports redirected_from; EntityDetail adds family,
artifacts, deployments, identity, licence, openness, version_history, grouped benchmarks and runs in <= 4 connection groups; /stats
adds organizations_total, artifacts, model_families, change_events_live_24h and counter definitions; /changes, /changes/daily
("Today in AI 2.0" with group_key folding), /timeline, entity timelines and /diff default to is_backfill=false on occurred_at
(include_backfill, date_field restore v1); /benchmarks exposes family/variant/metric/direction/groups/trust_mix and resolves aliases;
/compare groups benchmark dimensions by (benchmark, metric, config_key) with comparability, diff_only and mode; /prices adds
family/org/modality filters and sort=cheapest_frontier; /prices/index becomes the AI Price Index; /providers adds distributions,
30-day churn, organizations_covered, features_supported; /search compiler v2 (compiled/sort/residual/unrecognised, org verified
against aliases, guarded casts, FTS fallback when pgvector is absent); /methodology, /trending?kind=.

New routes: /benchmarks/{slug}/leaderboard, /benchmarks/{slug}/frontier, /benchmarks/matrix, /models/{a}/diff/{b}, /deployments,
/cost, /cost/context, /frontier, /pareto, /pulse, /open, /find-a-model, /run-locally, /hardware/{slug}/fit, /families,
/families/{slug}, /graph/explore, /time-machine, /claims/{id}, /entities/{slug}/claims, /entities/{slug}/provenance/{property},
/licenses, /licenses/{key}; admin: /admin/quality, /admin/entity-resolution (GET + POST decisions via merge_entities mode= when
available), /admin/anomalies, /admin/extractions/{snapshot_id}, /admin/quarantine (501 until services.canonical ships),
/admin/audit, /admin/runs/{run_id}/rollback, /admin/cache/flush?after_run=1.

Services: frontier.py (comparability groups, one-row-per-model leaderboards, frontier model set, leader_at), finder.py, pareto.py,
cost.py, hardware_fit.py (architecture-aware KV cache, observed artifact sizes, multi-GPU), search.py v2, stats.py definitions.

Security/perf: rate_limit("admin") before require_admin, failed-auth limiter 10/min/IP, x-forwarded-for only with AIA_TRUST_PROXY,
set_limit GUC leak removed, weak ETag + Cache-Control + 304 on public GETs, admin audit log on every admin call. EXPLAIN ANALYZE on the
production copy: every read path < 20 ms, no index migration needed.

Docs: docs/API.md rewritten as the 1.1 contract (changelog, 1.1 additions, semantics changes, deprecations: none).
Tests: tests/test_api_v11.py (contract) and tests/test_api_services.py (pareto, cost, compiler, hardware fit); 44 API tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 198176a Browse files

34 changed files +4,815 −476

modified docs/API.md +175 −105
@@ -1,10 +1,24 @@
1 −# AI Atlas API — contract (v1)
1 +# AI Atlas API — contract (API 1.1, mounted at `/api/v1`)
2 2
3 3 Base: `/api/v1` (FastAPI, `src/aiatlas/api`). JSON, UTF-8, ISO-8601 UTC timestamps, numbers as numbers (Postgres aggregates may arrive as
4 −strings — the web layer normalises). Public routes are cached in Redis (`aia:api:*`, 60–600 s) and rate-limited per IP on search.
5 −Admin routes require header `x-aia-admin-token: <AIA_ADMIN_TOKEN>`. OpenAPI at `/api/v1/docs`. Errors: `{"detail": "..."}` with 400/404/429/503.
4 +strings — the web layer normalises). Public routes are cached in Redis (`aia:api:*`, 60–600 s), carry a weak `ETag` + `Cache-Control: public,
5 +max-age=60, stale-while-revalidate=300` and answer `304` to a matching `If-None-Match`; search is rate-limited per IP. Every response carries
6 +`x-api-version: 1.1`. Admin routes require header `x-aia-admin-token: <AIA_ADMIN_TOKEN>` and are rate-limited **before** the token check
7 +(240/min per IP; 10 failed authentications per minute per IP → 429). OpenAPI at `/api/v1/docs`. Errors: `{"detail": "..."}` with 400/404/409/422/429/501/503.
6 8 Never expose credentials, internal hostnames, raw archive content or `raw_path`s.
7 9
10 +**1.1 is additive over v1.** No v1 route, field or type was removed or retyped (see "Deprecations": none). New fields and routes are listed
11 +below; three defaults changed semantics (see "Semantics changes") and each has a parameter that restores the v1 behaviour. Every number
12 +that is derived or estimated comes with a `methodology`, `note`, `definition` or `estimated: true` string next to it; when data is missing the
13 +value is `null`/empty with a note — nothing is fabricated.
14 +
15 +## Changelog
16 +
17 +| version | date | changes |
18 +|---|---|---|
19 +| **1.1** | 2026-09-12 | Canonical model universe (`/models` excludes artifacts and folded variants; `include=artifacts`), model detail blocks (family, artifacts, deployments, identity, licence, openness, version_history, grouped benchmarks, `redirected_from`), backfill-aware feeds keyed on `occurred_at`, "Today in AI 2.0", comparability groups + one-row-per-model leaderboards, `/benchmarks/matrix`, `/benchmarks/{slug}/frontier`, grouped `/compare` + `diff_only` + `/models/{a}/diff/{b}`, `Deployment` shape + `/deployments` + `/cost`, AI Price Index, provider aggregates, `/frontier`, `/pareto`, `/pulse`, `/open`, `/find-a-model`, `/run-locally`, `/hardware/{slug}/fit`, `/families`, `/graph/explore`, `/time-machine`, `/diff` sections, search compiler v2 (`compiled`), `/claims/{id}`, `/entities/{slug}/claims`, `/entities/{slug}/provenance/{property}`, `/licenses`, richer `/methodology`, `/trending?kind=`, `/stats` counter definitions, admin workbenches (quality, entity-resolution, anomalies, extractions, quarantine, audit, rollback), ETag/304, admin rate limit before auth, failed-auth limiter, trust-proxy, `set_limit` GUC leak fixed, detail fan-out ≤ 4 connections. |
20 +| 1.0 | 2026-09-11 | Initial contract (table "v1 routes" below). |
21 +
8 22 ## Shared shapes
9 23
10 24 ```ts
@@ -19,132 +33,188 @@ type Provenance = Record<string, { source_id: string | null; source_name?: strin
19 33 confidence: string; extractor: string; unit?: string }>
20 34 type ChangeEvent = { id: string; event_type: string; category: string; property: string | null; old_value: unknown; new_value: unknown;
21 35 summary: string; importance: 0 | 1 | 2 | 3; observed_at: string; effective_at: string | null; source_url: string | null; connector_name: string | null;
22 − entity: EntitySummary | null; meta: Record<string, unknown> }
36 + entity: EntitySummary | null; meta: Record<string, unknown>;
37 + occurred_at?: string; is_backfill?: boolean; group_key?: string | null } // 1.1 (feeds)
23 38 type Price = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; input_per_mtok: Num; output_per_mtok: Num;
24 39 cached_input_per_mtok: Num; cache_write_per_mtok: Num; batch_input_per_mtok: Num; batch_output_per_mtok: Num; per_image: Num; currency: string;
25 40 context_length: Num; max_output_tokens: Num; features: Record<string, unknown>; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number }
26 41 type BenchmarkResult = { id: string; model: EntitySummary; benchmark: EntitySummary; score: number; metric: string | null; unit: string | null;
27 − higher_is_better: boolean; config: Record<string, unknown>; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; confidence: string }
42 + higher_is_better: boolean; config: Record<string, unknown>; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; confidence: string;
43 + valid_to: string | null; config_key?: string | null; trust_level?: string | null } // 1.1 adds config_key/trust_level where returned
28 44 type Claim = { id: string; property: string; value: unknown; unit: string | null; tier: number; confidence: string; status: string; extractor: string;
29 45 observed_at: string; effective_at: string | null; valid_from: string; valid_to: string | null; source_url: string | null; source_name: string | null }
30 46 type SourceRef = { source_id: string | null; source_name: string | null; domain: string | null; tier: number | null; url: string; doc_type: string; last_observed_at: string | null; snapshots: number }
47 +
48 +// ---- 1.1 shapes
49 +type Deployment = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; context_length: Num; max_output_tokens: Num;
50 + prices: { input: Num; cached_input: Num; cache_write: Num; output: Num; batch_input: Num; batch_output: Num; per_image: Num; per_request: Num; currency: string;
51 + unit: "USD per 1M tokens"; native_units: Record<string, unknown> }; // provider-specific priced features kept verbatim (flex_*, priority_*, *_per_1k_requests…)
52 + features: Record<string, unknown>; status: "active" | "delisted"; observed_at: string; valid_from: string; valid_to: string | null; source_url: string | null; tier: number }
53 +type Group = { metric: string; config_key: string; label: string; n: number; model_count: number; config: Record<string, unknown>; higher_is_better: boolean; trust_mix: Record<string, number> }
54 +type LeaderboardRow = { rank: number; model: ModelRef; score: number; metric: string; unit: string | null; higher_is_better: boolean; delta_rank: number | null; previous_rank: number | null;
55 + trust_level: string; trust_label: string; config: Record<string, unknown>; config_key: string; comparability: "comparable" | "partially-comparable" | "not-comparable";
56 + comparability_reasons: string[]; evaluated_at: string | null; observed_at: string; source_url: string | null; tier: number; result_id: string; n_rows: number }
57 +type ModelRef = { id: string; slug: string; name: string; entity_type: string; organization: Org; attributes: Record<string, unknown> } // openness/params/context/release/modalities/license/family
58 +type LicenseInfo = { key: string; label: string; category: string; spdx: string | null; url: string | null; commercial_use: boolean | null; redistribution: boolean | null;
59 + derivatives: boolean | null; hosting_restrictions: boolean | null; attribution: boolean | null; acceptable_use: boolean | null; osi_approved: boolean; weights_downloadable: boolean }
60 +type Fit = { quantization: string; estimated: true; fits: boolean; estimated_memory_gb: number; headroom_gb: number; parameter_count?: number;
61 + breakdown: { weights_gb: number; weights_source: "observed" | "estimated"; overhead_gb: number; kv_cache_gb: number; kv_cache_method: "architecture" | "heuristic"; reserved_gb: number; context: number; batch: number };
62 + device: { memory_gb: number; gpu_count: number; total_memory_gb: number }; note: string; multi_gpu_note?: string }
31 63 ```
32 64
33 −## Public routes
65 +## v1 routes (unchanged unless noted "1.1:")
34 66
35 67 | route | returns |
36 68 |---|---|
37 −| `GET /health` | `{ status: "ok"|"degraded", version, db: bool, redis: bool, llm: { available, reachable? }, time }` |
38 −| `GET /stats` | `{ entities: Record<type, number>, entities_total, sources, connectors, connectors_enabled, documents, snapshots, claims, claims_current, relations, change_events, change_events_24h, change_events_7d, benchmark_results, prices_current, prices_total, review_pending, llm_jobs, llm_tokens, last_snapshot_at, last_event_at, first_entity_at, archive: { raw_bytes, raw_files, text_bytes, text_files }, computed_at }` — **always live from the DB** |
69 +| `GET /health` | `{ status: "ok"|"degraded", version, api_version: "1.1", db: bool, redis: bool, llm: { available, reachable? }, time }` |
70 +| `GET /stats` | `{ entities: Record<type, number>, entities_total, sources, connectors, connectors_enabled, documents, snapshots, claims, claims_current, relations, change_events, change_events_24h, change_events_7d, benchmark_results, prices_current, prices_total, review_pending, llm_jobs, llm_tokens, last_snapshot_at, last_event_at, first_entity_at, archive: {…}, computed_at }` — **1.1:** `entities.model` = canonical models; `entities.artifact`, `entities.model_family` (zero-filled); adds `organizations_total`, `artifacts`, `model_families`, `change_events_live_24h`, `change_events_live_7d`, `definitions: Record<key, string>` ("How counted"). Cached 60 s. |
39 71 | `GET /stats/history?days=90` | `{ items: { day: string; counts: object }[] }` |
40 −| `GET /search?q=&type=&limit=30&offset=0` | `{ query: CompiledQuery, items: (EntitySummary & { rank: number })[], total: number }` — uses `services.search.compile_query` + `search_entities` (+ embedding when the gateway is up) |
72 +| `GET /search?q=&type=&limit=30&offset=0` | `{ query: CompiledQuery, items: (EntitySummary & { rank: number })[], total: number }` — **1.1:** compiler v2 (below) |
41 73 | `GET /search/suggest?q=` | `{ items: { id, entity_type, slug, name, organization_name }[] }` (≤ 8, prefix, fast) |
42 −| `GET /entities/{slug_or_id}` | `EntityDetail` (below). Also mounted as `/models/{slug}`, `/companies/{slug}`, `/papers/{slug}`, `/providers/{slug}`, `/benchmarks/{slug}`, `/hardware/{slug}`, `/frameworks/{slug}`, `/datasets/{slug}`, `/tools/{slug}` (404 if the type doesn't match) |
43 −| `GET /entities/{slug}/timeline?limit=50&before=` | `{ items: ChangeEvent[] }` (entity's own events + events of entities it develops for companies) |
74 +| `GET /entities/{slug_or_id}` | `EntityDetail` (below). Also mounted as `/models/{slug}` (**1.1:** accepts `model` AND `artifact`), `/companies/{slug}`, `/papers/{slug}`, `/providers/{slug}`, `/benchmarks/{slug}` (**1.1:** slug, alias or id), `/hardware/{slug}`, `/frameworks/{slug}`, `/datasets/{slug}`, `/tools/{slug}` (404 if the type doesn't match) |
75 +| `GET /entities/{slug}/timeline?limit=50&before=&include_backfill=0&date_field=occurred` | `{ items: ChangeEvent[], next_before, date_field, include_backfill }` — **1.1:** defaults `is_backfill=false`, ordered by `occurred_at`; cursor = `occurred_at` (or `observed_at` with `date_field=observed`) |
44 76 | `GET /entities/{slug}/history?property=` | `{ items: Claim[] }` — full claim history (all statuses), newest first |
45 −| `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes: Record<string, unknown>, claims: Claim[] }` — state as known at that date |
46 −| `GET /entities/{slug}/graph?depth=1&limit=80` | `{ nodes: { id, slug, name, entity_type, organization_name? }[], edges: { source, target, predicate }[] }` (depth ≤ 2, cap nodes) |
77 +| `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes, claims: Claim[] }` |
78 +| `GET /entities/{slug}/graph?depth=1&limit=80` | `{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] }` |
47 79 | `GET /entities/{slug}/sources` | `{ items: SourceRef[] }` |
48 −| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family / shared relations) |
49 −| `GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=(updated|name|params|context|release|quality|downloads)&order=&limit=&offset=` | `Page<EntitySummary>` + `facets` when `facets=1`: `{ organizations: {slug,name,count}[], openness: {value,count}[], modalities, families, years, licenses, status }` |
50 −| `GET /companies?q=&country=&kind=&sort=(models|name|updated|quality)&limit=&offset=` | `Page<EntitySummary & { model_count: number; paper_count: number }>` (+`facets`: countries, kinds) |
51 −| `GET /papers?q=&category=&org=&since=&until=&sort=(published|updated)&limit=&offset=` | `Page<EntitySummary>` |
52 −| `GET /providers` | `{ items: (EntitySummary & { model_count: number; price_count: number; min_input_per_mtok: Num; min_output_per_mtok: Num })[] }` |
53 −| `GET /prices?model=&provider=&sort=(input|output|model|provider|observed)&limit=&offset=&current=1` | `Page<Price>` |
54 −| `GET /prices/history?model=&provider=` | `{ items: Price[] }` (all rows incl. closed, oldest first) |
55 −| `GET /prices/index?days=180` | `{ series: { day: string; median_input: Num; median_output: Num; min_input: Num; models: number }[]; movers: ChangeEvent[] }` computed from `prices` history |
56 −| `GET /benchmarks` | `{ items: (EntitySummary & { result_count: number; model_count: number; top: { model: EntitySummary; score: number } | null })[] }` |
57 −| `GET /benchmarks/{slug}/results?limit=&offset=&config=` | `Page<BenchmarkResult>` sorted by score (respecting `higher_is_better`), current rows only unless `history=1` |
58 −| `GET /benchmarks/{slug}/history?model=` | `{ items: BenchmarkResult[] }` over time |
59 −| `GET /hardware?kind=&manufacturer=&min_memory=&sort=` | `Page<EntitySummary>` |
60 −| `GET /hardware/fit?memory_gb=&quant=(4bit|8bit|fp16)&context=8192&limit=` | `{ inputs, assumptions: string[], items: { model: EntitySummary; parameter_count: number; estimated_memory_gb: number; fits: boolean; headroom_gb: number; quantization: string; note: string }[] }` — **ESTIMATED**: bytes/param (4bit 0.5+overhead 1.15, 8bit 1.0, fp16 2.0) + KV cache estimate; label as estimated |
61 −| `GET /explore/types` | `{ items: { entity_type, count, label }[] }` |
62 −| `GET /explore/{type}?q=&org=&sort=&limit=&offset=` | `Page<EntitySummary>` generic listing for any type (datasets, frameworks, tools, repositories, regulation…) |
63 −| `GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&limit=50&before=<observed_at cursor>` | `Page<ChangeEvent>` newest first; `type` = comma list of event types; excludes `DOCUMENT_CHANGED` unless `include_documents=1` |
64 −| `GET /changes/daily?date=YYYY-MM-DD` | `{ date, counts: Record<category, number>, sections: { category, label, items: ChangeEvent[] }[], new_models: EntitySummary[] }` ("What changed in AI today", DB-generated only) |
65 −| `GET /changes/categories?days=7` | `{ items: { category, event_type, count }[] }` |
66 −| `GET /timeline?entity=&year=&category=&limit=` | `{ items: { month: string; events: ChangeEvent[] }[] }` grouped by month; global when no entity |
67 −| `GET /compare?ids=slug,slug,…` (2–6) | `{ entity_type, dimensions: { key, label, unit?, kind: "number"|"text"|"list"|"bool"|"date" }[], items: { entity: EntitySummary; values: Record<key, unknown>; provenance: Provenance; prices?: Price[]; results?: BenchmarkResult[] }[] }` — dimensions per type: model (params, active params, context, max output, openness, license, modalities, release date, knowledge cutoff, best price in/out, benchmark scores shared by all), provider (models, min prices, features), hardware (memory, bandwidth, tdp, runtimes), framework (version, license, stars), company (country, founded, models, papers) |
68 −| `GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=(all|models|org:<slug>|family:<name>)` | `{ a, b, new_entities: EntitySummary[], gone_entities: EntitySummary[], property_changes: ChangeEvent[], price_changes: ChangeEvent[], benchmark_changes: ChangeEvent[], counts }` |
69 −| `GET /sources` | `{ items: { key, name, domain, tier, kind, category, organization: Org, enabled, documents, last_crawled_at, connectors: { name, label, health, last_success_at, interval_seconds }[] }[] }` (public transparency page) |
70 −| `GET /methodology` | `{ metrics: metric_definitions[], confidence_levels, tiers, event_types, extractors }` |
71 −| `GET /trending?days=7&limit=12` | `{ items: (EntitySummary & { views: number })[] }` from `page_views` |
72 −| `POST /views` `{ path }` | `{ ok: true }` (beacon; 1 req/s/IP) |
73 −| `GET /sitemap?type=&limit=5000&offset=0` | `{ items: { slug, entity_type, updated_at }[], total }` |
74 −| `GET /api-keys/me` (header `x-api-key`) | `{ label, plan, rate_per_min, usage_count }` — developer keys (later); public routes work without a key |
80 +| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family (`family_id` or label) / shared relations) |
81 +| `GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=&order=&limit=&offset=&facets=1` | `Page<EntitySummary & { identity_confidence, family?: {id,slug,name}, canonical?: EntitySummary, artifact_kind? }> & { universe }` — **1.1:** default universe = `entity_type='model' and merged_into is null`; `include=artifacts`; `family=` matches `model_family` slug/id/name via `entities.family_id` (falls back to `attributes.family`); `license=` matches the canonical key (`attributes.license_key`) or any raw label the ontology maps to it; `reasoning=0|1`, `trust=high,medium,low` (identity_confidence), `sort=cheapest`. Facets add `families` (`{value,label,canonical,count}`), canonical `licenses` (`{value,label,category,count,raw_labels,raw?}`), `trust`, `definitions`. |
82 +| `GET /companies?q=&country=&kind=&sort=&limit=&offset=&facets=1` | `Page<EntitySummary & { model_count, paper_count }>` — covers `company|organization|lab|university`; `total` = `/stats.organizations_total` |
83 +| `GET /papers?…` | `Page<EntitySummary>` |
84 +| `GET /providers` | `{ items: (EntitySummary & { model_count, price_count, min_input_per_mtok, min_output_per_mtok, input_price_distribution: {min,p25,median,p75,max,n}|null, output_price_distribution, models_added_30d, models_removed_30d, price_changes_30d, organizations_covered, features_supported: string[], feature_keys: string[] })[], note }` — **1.1** aggregates |
85 +| `GET /prices?model=&provider=&sort=&limit=&offset=&current=1&family=&org=&modality=` | `Page<Price>` — **1.1:** `family`, `org`, `modality` filters; `sort=cheapest_frontier` (frontier models only, cheapest output first, + `methodology`) |
86 +| `GET /prices/history?model=&provider=` | `{ items: Price[] }` |
87 +| `GET /prices/index?days=180` | **AI Price Index (1.1)** `{ days, series: { day, median_input, median_output, median_frontier_output, median_frontier_input, median_open_output, median_embedding_input, min_input, max_input, min_frontier_output, models, offers, sample: { models, offers, frontier_models, frontier_offers, open_models, embedding_models } }[], movers: ChangeEvent[], cheapest_frontier: { model, provider, output, input, context_length, price_id }|null, cheapest_frontier_1m_context, distribution: { metric, unit, buckets: { from, to, label, offers }[], offers }, new_listings_30d, delistings_30d, price_changes_30d, frontier: { composition, methodology }, methodology, note }` — v1 keys kept |
88 +| `GET /benchmarks?category=` | `{ items: { id, entity_type, slug, name, category, family, variant, metric, unit, direction, attributes, result_count, model_count, leader: LeaderboardRow|null, second, top: {model, score}|null, primary_group: Group|null, groups: Group[], trust_mix, trust_labels }[], total, note }` — **1.1:** `result_count` = current rows, `model_count` = distinct canonical models, `leader` from the primary metric group |
89 +| `GET /benchmarks/{slug}` | `EntityDetail` + `family, variant, metric, direction, category, groups, primary_group, result_count, model_count, leaderboard (top 25), trust_mix` |
90 +| `GET /benchmarks/{slug}/results?limit=&offset=&config=&history=&metric=&config_key=` | `Page<BenchmarkResult>` — v1 behaviour (one row per result) + `metric` / `config_key` filters |
91 +| `GET /benchmarks/{slug}/history?model=` | `{ benchmark, items: BenchmarkResult[] }` |
92 +| `GET /hardware?…` | `Page<EntitySummary>` + facets |
93 +| `GET /hardware/fit?memory_gb=&quant=&context=&limit=&openness=` | v1 estimate (unchanged) |
94 +| `GET /explore/types` · `GET /explore/{type}` | unchanged (`artifact`, `model_family` appear as types once present) |
95 +| `GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&entity=&limit=50&before=&offset=&include_documents=0&include_backfill=0&date_field=occurred` | `Page<ChangeEvent> & { next_before, date_field, include_backfill }` — **1.1:** defaults `is_backfill=false`, `occurred_at`; cursor/filters follow `date_field` |
96 +| `GET /changes/daily?date=&per_section=30&include_backfill=0&date_field=occurred` | `{ date, counts, total, sections (v1 by category), today: { key, label, items: (ChangeEvent & { sources: n, documents: string[], grouped_events, event_ids? })[], total }[], new_models, labels, backfill_excluded, previous_day, next_day, note }` — **Today in AI 2.0** sections: `MAJOR_RELEASES, OPEN_WEIGHT_RELEASES, PRICE_MOVES, BENCHMARK_MOVES, MODEL_CHANGES, RESEARCH, DEPRECATIONS, PROVIDER_CHANGES, HARDWARE`; events sharing a `group_key` are folded into one item |
97 +| `GET /changes/categories?days=7&include_backfill=0` | `{ items: { category, event_type, count }[] }` |
98 +| `GET /timeline?entity=&year=&category=&importance_min=&limit=&include_backfill=0&date_field=occurred` | `{ items: { month, count, events }[], total, date_field, include_backfill }` |
99 +| `GET /compare?ids=a,b,…&diff_only=0&mode=` | `{ entity_type, dimensions, items, comparability: Record<dimKey, { level, reasons, trust }>, diff_only, note }` — **1.1:** benchmark dimensions keyed `bench:<benchmark>:<metric>:<config_key>` (only groups where EVERY model has a current result; dimension carries `comparability`, `trust_levels`), model dims add `reasoning`, `tool_calling`; `diff_only=1` keeps differing dimensions; `mode=models|providers|hardware|companies|frameworks|benchmarks` asserts the type (400 otherwise) |
100 +| `GET /diff?a=&b=&scope=&limit=&include=&include_backfill=0` | `{ a, b, scope, new_entities, gone_entities, property_changes, price_changes, benchmark_changes, new_benchmark_leaders: { benchmark, at_a, at_b }[], provider_changes, hardware_changes, context_changes, retired_models, counts, include_artifacts, include_backfill, note }` — **1.1:** events on `occurred_at`, not back-filled; `new_entities` excludes artifacts unless `include=artifacts` |
101 +| `GET /sources` | unchanged |
102 +| `GET /methodology` | v1 keys + **1.1:** `openness: { categories, labels, definitions, dimensions }`, `trust_levels`, `comparability` (rules, task/condition/ignored keys), `counters` (how counted), `anomaly_checks`, `event_semantics`, `hardware_fit`, `frontier`, `find_a_model`, `licence_categories` |
103 +| `GET /trending?days=7&limit=12&type=&kind=views` | `{ days, kind, items, definition }` — **1.1:** `kind=views` (v1, `views`) · `most_changed` · `new_listings` · `new_results` (each `events`, `last_event_at`); separate lists, never merged |
104 +| `POST /views` · `GET /sitemap` · `GET /api-keys/me` | unchanged |
75 105
76 106 ### `EntityDetail`
77 107
78 108 ```ts
79 109 EntitySummary & {
80 − attributes: Record<string, unknown> // full
81 − provenance: Provenance
82 − aliases: string[]; identifiers: { scheme: string; value: string }[]
83 − relations: { predicate: string; direction: "out" | "in"; items: EntitySummary[]; total: number }[] // grouped, ≤ 24 per group
84 − sources: SourceRef[] // documents describing this entity (deduped by URL)
85 − timeline: ChangeEvent[] // latest 30
86 − quality: {...}; counts: {...}
87 − // type-specific blocks (present only when relevant)
88 − prices?: Price[] // current rows across providers (model) or for this provider (provider)
89 − price_history?: Price[] // closed + current, oldest first (model)
90 − results?: BenchmarkResult[] // model: its results · benchmark: leaderboard top 100
91 − lineage?: { ancestors: EntitySummary[]; descendants: EntitySummary[]; quantizations: EntitySummary[] } // model
92 − providers?: EntitySummary[] // model
93 − hardware_fit?: { hardware: EntitySummary; quantization: string; estimated_memory_gb: number; fits: boolean }[] // model, ESTIMATED
94 − models?: Page<EntitySummary> // company / provider / hardware(runnable)
95 − papers?: EntitySummary[] // company / model
96 − repositories?: EntitySummary[] // company / model / framework
110 + attributes: Record<string, unknown>; provenance: Provenance; aliases: string[]; identifiers: { scheme, value }[]
111 + relations: { predicate, direction: "out"|"in", items: EntitySummary[], total }[]; sources: SourceRef[]; timeline: ChangeEvent[] // timeline: occurred_at order, not back-filled
112 + redirected_from?: { slug, id, entity_type } // 1.1: the caller asked for a merged/folded row — web should 301 to this detail's slug
113 + // model
114 + prices?: Price[]; price_history?: Price[]; results?: BenchmarkResult[]; lineage?: { ancestors, descendants, quantizations }; providers?: EntitySummary[]
115 + hardware_fit?: {…}[]; hardware_fit_assumptions?: string[]; papers?: EntitySummary[]; repositories?: EntitySummary[]
116 + family?: EntitySummary | { id: null, name, canonical: false, note } | null // 1.1
117 + artifacts?: { items: { kind, items: (EntitySummary & { artifact_kind })[], count }[], total } // 1.1, grouped by artifact_kind
118 + deployments?: Deployment[] // 1.1
119 + identity?: { canonical_model: true, official_checkpoints: string[], official_artifacts, third_party_artifacts, provider_deployments, folded_variants, api_aliases: string[], note }
120 + licence?: LicenseInfo & { raw, url_observed } | { key: null, raw, note } // 1.1
121 + openness?: { category, raw, label, definition, dimensions: Record<dim, boolean|null>, note } // 1.1
122 + version_history?: { property, transitions: { from, to, valid_from, valid_to, effective_at, source_url, tier, claim_id, status }[], current }[] // context_length, max_output_tokens, status, knowledge_cutoff, license, openness, parameter_count
123 + benchmarks?: { items: { id, slug, name, category, metrics: { metric, groups: { config_key, comparability_group, n_rows, higher_is_better, best: {…trust_level…}, trust_levels }[] }[] }[], total_rows, note }
124 + family_id?: string | null; identity_confidence?: "high"|"medium"|"low"
125 + // artifact (1.1)
126 + canonical?: EntitySummary | null; artifact_kind?: "checkpoint"|"quantization"|"conversion"|"packaging"|null
127 + // company / provider / hardware / framework / model_family
128 + models?: Page<EntitySummary>; papers?; repositories?
97 129 }
98 130 ```
99 131
100 −## Admin routes (`x-aia-admin-token`)
132 +## 1.1 additions
133 +
134 +| route | returns |
135 +|---|---|
136 +| `GET /models/{a}/diff/{b}` | `{ a, b, dimensions: (Dimension & { a, b, delta: { absolute, percent } | { added, removed } | null })[], comparability, note }` — differing dimensions only |
137 +| `GET /entities/{slug}/claims?property=&status=current|all|superseded|conflicting|retracted&limit=&offset=` | `{ entity, items: (Claim & { snapshot_id, run_id, value_raw })[], total, limit, offset, status }` |
138 +| `GET /entities/{slug}/provenance/{property}` | evidence drawer: `{ entity, property, value, value_raw, unit, source: { id, name, domain, url }, tier, confidence, extractor, extractor_version, observed_at, effective_at, valid_since, claim_id, run_id, snapshot_id, snapshot: { id, observed_at, document_url, title, archived }, conflicts: Claim[], history_count, note }` (404 when no claim and no attribute) |
139 +| `GET /claims/{id}` | `{ claim, entity, property, chain: { previous: Claim[], superseding: Claim[], conflicting: Claim[], history_count }, source: { id, name, domain, tier, url, snapshot_id, observed_at }, extractor: { name, version, confidence }, run_id, evidence: { snapshot_id, document_url, archived, snapshot_observed_at, document_title, doc_type }, note }` |
140 +| `GET /benchmarks/{slug}/leaderboard?metric=&config_key=&trust=&org=&since=&until=&comparable_only=0&limit=100&offset=0` | `{ benchmark, group: Group, groups: Group[], items: LeaderboardRow[], total, limit, offset, comparable_only, filters, history_available, methodology }` — ONE row per canonical model (best current row in the group; default group = primary metric, most-populated config); `delta_rank` vs the ranking of the closed rows of the same group when history exists |
141 +| `GET /benchmarks/{slug}/frontier?metric=&config_key=` | `{ benchmark, series: { group: Group, primary: bool, points: { date, model: ModelRef, score, trust_level, config, result_id }[], current_leader }[], generated_at, methodology }` — leader history per group (a point each time a new best appears, ordered by `coalesce(evaluated_at, observed_at)`, closed rows included) |
142 +| `GET /benchmarks/matrix?benchmarks=a,b,c&models=…&org=&family=&limit=60&comparable_only=0&min_cells=3` | `{ columns: { id, slug, name, category, metric, config_key, group_label, higher_is_better, n_models }[], rows: { model: {…}, cells: Record<benchmarkId, { score, rank, trust_level, config_key, comparability, result_id } | null>, n_cells, mean_rank }[], total_rows, comparable_only, min_cells, methodology }` — default columns = 12 benchmarks with most current results, default rows = models with ≥ `min_cells` cells (`mean_rank` is a sort key, not a score) |
143 +| `GET /deployments?model=&provider=&org=&current=1&sort=valid_from|output|input|model|provider&limit=50&offset=&before=<valid_from cursor>` | `Page<Deployment> & { next_before, current }` |
144 +| `GET /cost?model=&provider=&input_tokens=1000&output_tokens=500&requests_per_day=1000&cached_share=0..1&batch=0|1` | `{ model, inputs, items: { deployment: Deployment, cost: { per_request, daily, monthly, annual, effective_input_per_mtok, effective_output_per_mtok, per_request_fee, inputs, notes: string[] } }[], total, currency, methodology, note }` — cached/batch prices used only when published (else a `notes` entry) |
145 +| `GET /cost/context?tokens=1000000&limit=50&model=&org=` | `{ tokens, items: { deployment, context_length, context_source: "offer"|"model attribute", cost_usd }[], total, currency, methodology }` — offers whose context ≥ tokens, cheapest first |
146 +| `GET /frontier?limit=12` | `{ latest_major_models: ChangeEvent[], benchmark_frontier: { benchmark, group, leader, second, gap }[], price_frontier: { cheapest_output: Deployment, cheapest_output_1m_context, frontier_models, composition }, context_frontier: { model, context_length }[], open_weight_frontier: { items: { model, best_rank, best_rank_on, parameter_count, context_length, ranks }[], dimensions, note }, efficiency_frontier: { quality: { benchmark, group }, x, points: { id, model, x, y, rank, trust_level, pareto }[], frontier: string[] }, agentic_frontier: { benchmark, group, leaders: LeaderboardRow[] }[], multimodal_frontier: { model, modalities, top10_on }[], recent_frontier_movements: (ChangeEvent & { percent_change? })[], generated_at, methodology }` |
147 +| `GET /pareto?benchmark=&x=output_price|input_price|parameter_count|context_length|memory_estimate&y=score&metric=&config_key=&org=&family=&openness=` | `{ benchmark, group, groups, x: { key, label }, y, points: { id, model, x, y, rank, trust_level, config, provider?, estimated?, pareto }[], frontier: string[], methodology }` — `x=latency` → 400 (not stored, never estimated) |
148 +| `GET /pulse?days=7` | `{ days, since, until, counters: Record<key, { value, definition, median_percent?, items? }>, note }` — keys: `new_models, new_open_weight_models, new_artifacts, new_papers, provider_listings, provider_delistings, price_changes, new_models_1m_context, new_benchmark_leaders, documents_changed, sources_observed, events_total`; all `is_backfill=false`, `occurred_at` in window |
149 +| `GET /open?sort=release|params|context|rank|name|downloads&license=&min_params=&max_params=&min_context=&modality=&days=&openness=&limit=&offset=` | `Page<{ model, licence: LicenseInfo & { raw } | { key: null, raw, note }, dimensions, best_results: { benchmark, rank }[], best_rank, hardware_fit: { "4bit_64gb": Fit, "8bit_128gb": Fit, estimated: true }, providers, cheapest_output_per_mtok }> & { summary: { by_category, by_license_top, new_30d }, note }` — universe = downloadable weights (open-weights, open-source, restricted-weights) |
150 +| `GET /find-a-model?use_case=coding|reasoning|agentic|long_context|vision|low_cost|local|embeddings|chat&deployment=local|api|any&memory_gb=&quant=&context_min=&license=commercial|any&openness=&max_input_price=&max_output_price=&modalities=&limit=30` | `{ matches: { model, why: string[], observed: {…}, estimated_fit?: Fit, deployments?: Deployment[] }[], total, filters_applied, rules, note }` — deterministic rules (see `/methodology.find_a_model`), sorted by satisfied criteria → best benchmark rank → release date; **no composite score** |
151 +| `GET /run-locally?memory_gb=&gpu_count=1|2|4|8&quant=&context=8192&batch=1&platform=apple|nvidia|amd|any&use_case=&openness=&limit=` | `{ inputs, estimated: true, assumptions, counts, items: { model, fit: Fit, artifacts: { artifact, quant_format, file_size_gb, weights_source: "observed"|"estimated", fit: Fit }[], artifact_count }[], note }` — artifacts with an observed `file_size_gb` use it as the weights size |
152 +| `GET /hardware/{slug}/fit?quant=&context=&memory_gb=&gpu_count=&limit=&openness=` | `{ hardware, memory_options_gb, inputs, estimated: true, assumptions, runtimes, counts, items: ({ model } & Fit)[] }` |
153 +| `GET /families?q=&org=&sort=models|name|last_release&limit=&offset=` | `Page<{ id, slug, name, canonical: bool, entity_type: "model_family", organization, model_count, first_release, last_release, param_range, modalities, licenses, benchmark_best }> & { note }` — `model_family` entities + legacy `attributes.family` labels (`canonical: false`) |
154 +| `GET /families/{slug}?limit=` | `{ id, slug, name, canonical, summary, …aggregates, members: { model, key_facts, benchmark_ranks }[], artifacts_count, providers, lineage: { source, target, predicate }[], timeline, note }` — accepts a family label pre-canonicalisation |
155 +| `GET /graph/explore?node=&mode=lineage|research|company|benchmark|dataset|provider|hardware&depth=1|2&limit=150` | `{ root, mode, depth, predicates, nodes: { id, slug, name, entity_type, org, org_slug, level, artifact_kind, attributes }[], edges: { source, target, predicate, attributes, tier }[], truncated, counts }` — never more than `limit` nodes |
156 +| `GET /time-machine?date=YYYY-MM-DD&scope=models|prices|benchmarks|hardware|all&limit=50` | `{ date, scope, first_entity_at, reconstructed, note, models?: { items: { model, attributes_as_of, observed_then, reconstructed }[], total }, prices?: { items: Deployment[], total }, benchmarks?: { leaders: { benchmark, leader }[] }, hardware?: { items } }` — before `first_entity_at` the state is reconstructed from claims/release dates (`reconstructed: true`) |
157 +| `GET /licenses` · `GET /licenses/{key}?limit=` | `{ items: (LicenseInfo & { aliases, models })[], total, categories, unclassified: { raw, models }[], note }` · `LicenseInfo & { aliases, models: Page<EntitySummary> }` |
158 +
159 +### Search compiler v2 (`/search`)
160 +
161 +`query` = `CompiledQuery & { compiled: { filter, label, value, source_span }[], sort, residual, unrecognised: string[], semantic, version: 2, note? }`.
162 +Recognised: `open|open-weights|open source`, `proprietary|closed`, `reasoning|thinking models`, `vision|multimodal|image|audio|video|embedding(s)`,
163 +`under/below/cheaper than $X/M [input|output]`, `fit(s) in|runs on N GB`, `on my mac`, `context ≥ 1M|1M context|long-context`, `since|after 2025`,
164 +`in 2026`, `released this year|last year`, `last N days|weeks|months`, `by <Org>|from <Org>|<Org> models…` (verified against organization names
165 +and aliases — an unknown name is demoted to free text and listed in `unrecognised`), `over|≥|more than 100B`, `under 30B`, `between 7B and 70B`,
166 +`apache|mit|bsd|cc-by|gpl|llama|gemma license`, `commercial use`, `papers …`, `benchmark <name>`, `provider <name>`, sort hints `cheapest|newest|largest|smallest|best`.
167 +`memory_gb` becomes an ESTIMATED parameter bound (4-bit, 8K context) — noted in `query.note`. All numeric casts in SQL are regex-guarded.
168 +
169 +## Semantics changes (1.1)
170 +
171 +1. **Models universe.** `/models`, `/hardware/fit`, `/open`, `/run-locally`, leaderboards, matrix, price index and `/stats.entities.model` count
172 + **canonical model releases**: `entity_type = 'model' and merged_into is null`. Artifacts (`entity_type = 'artifact'`: checkpoints, quantisations,
173 + conversions, packagings) and folded evaluation variants (rows with `merged_into`) are excluded. `include=artifacts` on `/models` restores the
174 + pre-1.1 universe. Every old slug keeps resolving: `/models/<artifact slug>` returns the artifact detail (`canonical`, `artifact_kind`);
175 + `/models/<folded variant slug>` returns the canonical model with `redirected_from`.
176 +2. **Event date fields.** `/changes`, `/changes/daily`, `/changes/categories`, `/timeline`, `/entities/{slug}/timeline`, `/diff`, `/pulse`,
177 + `/trending?kind≠views` use `occurred_at = coalesce(effective_at, observed_at)` and exclude `is_backfill = true` rows. `include_backfill=1` and
178 + `date_field=observed` restore the v1 behaviour; cursors follow the chosen field. `/stats.change_events_24h` keeps the v1 meaning
179 + (observed) and `change_events_live_24h` is the new "what happened today" counter.
180 +3. **Leaderboard grouping.** Results are organised in comparability groups (canonical metric × `config_key`, the hash of task-defining
181 + configuration keys). `/benchmarks` `leader`/`top`, `/benchmarks/{slug}/leaderboard`, `/benchmarks/matrix`, `/compare` benchmark dimensions,
182 + `/pareto`, `/frontier` and `/find-a-model` ranks are computed per group with one row per canonical model. `/benchmarks/{slug}/results` keeps the
183 + v1 one-row-per-result listing. Rows written before migration 0003 (NULL `config_key`/`trust_level`) are recomputed on read from `config` and
184 + the source key, so the API behaves identically before and after canonicalisation.
185 +
186 +Also: `/companies.total == /stats.organizations_total`; `/benchmarks/{slug}` resolves aliases (`GPQA Diamond`); `/entities/{slug}/related`
187 +uses `family_id` when present; `x-forwarded-for` is honoured only when `settings.trust_proxy` / `AIA_TRUST_PROXY=1`.
188 +
189 +## Deprecations
190 +
191 +None. No v1 route, parameter, field or type was removed or retyped in 1.1.
192 +
193 +## Admin routes (`x-aia-admin-token`; rate limit 240/min per IP evaluated BEFORE the token; 10 failed auths/min per IP → 429; every call except the polling GETs `overview`, `infrastructure`, `llm/health`, `audit` writes an `admin_audit_log` row `{ actor: 'admin', action: '<METHOD> <path>', target, payload: { method, path, query?, body? }, ip }`)
101 194
102 195 | route | returns / action |
103 196 |---|---|
104 −| `GET /admin/overview` | `{ stats, queue: Record<kind, Record<status, n>>, heartbeats, connectors: { ok, degraded, failing, disabled }, review_pending, recent_errors: n, llm: { jobs_24h, tokens_24h, by_stage } , archive }` |
105 −| `GET /admin/connectors` | full `connectors` rows + last run + docs/snapshots counts + source tier |
106 −| `POST /admin/connectors/{name}/run` `{ force?: bool }` | enqueue an immediate run (Redis flag `aia:run-now:<name>` consumed by the scheduler tick) → `{ queued: true }` |
107 −| `PATCH /admin/connectors/{name}` `{ enabled?, interval_seconds?, priority? }` | update |
108 −| `GET /admin/runs?connector=&limit=` · `GET /admin/errors?connector=&limit=` | rows |
109 −| `GET /admin/documents?connector=&status=&q=&limit=&offset=` · `GET /admin/documents/{id}` | documents (+ snapshots list); `GET /admin/snapshots/{id}` → metadata + `structured` + `diff` + first 20 kB of cleaned text (never raw HTML) |
110 −| `GET /admin/jobs?status=&kind=` · `POST /admin/jobs/{id}/retry` · `POST /admin/jobs/requeue-dead` | queue |
111 −| `GET /admin/llm-jobs?limit=` · `GET /admin/llm/health` | LLM factory accounting |
112 −| `GET /admin/review?status=pending&kind=` · `POST /admin/review/{id}` `{ action: "approve"|"reject"|"edit", resolution?: object }` | review queue; approving a `merge_candidate` calls `merge_entities(source, target)` (aliases/identifiers/claims/relations/events moved, `merged_into` set) |
113 −| `GET /admin/entities/duplicates?type=&limit=` | candidate pairs by normalized name similarity (`pg_trgm`) within a type |
114 −| `POST /admin/entities/merge` `{ source_id, target_id }` · `POST /admin/entities/{id}/claims/{claim_id}/retract` | curation |
115 −| `POST /admin/reprocess` `{ connector, url? }` | enqueue `reprocess_snapshot` |
116 −| `POST /admin/llm/enqueue` `{ limit?, task? }` | queue `llm_extract` for pending snapshots |
117 −| `GET /admin/infrastructure` | heartbeats, archive size, DB size (`pg_database_size`), table sizes, node hostname, uptime |
118 −| `POST /admin/cache/flush` | `{ flushed: n }` |
119 −| `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | maintenance |
120 −
121 −## Implementation notes (2026-09-11, `src/aiatlas/api`)
122 −
123 −Deviations and additions relative to the table above — everything else is implemented as written.
124 −
125 −- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds (`limit=999`, non-integer `depth`…) return **422** with
126 − `{ detail: "<field>: <message>", errors: [...] }`; semantic errors raised by the routes (bad `sort`, bad date, wrong `scope`, `ids` count) return **400**.
127 −- **`/companies`** (listing, alias, `/compare` type `company`) covers the organization-like types `company | organization | lab | university`.
128 − `/frameworks/{slug}` also accepts `library | runtime`; `/tools/{slug}` accepts `tool | agent | application | product | mcp_server`.
129 −- **`/explore/{type}`** accepts plural forms (`/explore/models`, `/explore/companies`) and adds `entity_type` + `label` to the page.
130 −- **`EntitySummary.description`** is truncated at 280 characters in summaries (full text in `EntityDetail`).
131 −- **Cursor feeds** (`/changes`, `/entities/{slug}/timeline`) add `next_before` (the `observed_at` of the last item, or `null` at the end).
132 − `/changes` also honours `offset` and `entity=` for convenience; `total` is capped at 10 000.
133 −- **`/changes/daily`** adds `total`, `labels`, `previous_day`, `next_day`; sections are capped at `per_section` (default 30) items each.
134 −- **`/timeline`** month groups carry `count`; the response includes `total` (events returned).
135 −- **`/search`**: `query` carries `semantic: bool` (embedding used or not). When the embedding endpoint times out (2 s) the API backs off to
136 − FTS-only for 5 minutes (`aia:api:search:embed-degraded`) so the LLM never adds latency to consecutive queries.
137 −- **`hardware_fit`** (model detail) is *omitted* when `parameter_count` is unknown (nothing is estimated from thin air); when present the
138 − detail also carries `hardware_fit_assumptions: string[]`. `/hardware/fit` adds `estimated: true`, `counts`, optional `openness=` filter.
139 −- **`/prices/index`** series rows add `max_input` and `offers`; response adds `days` and `note`. `/prices/history` requires `model` or `provider`.
140 −- **`/benchmarks/{slug}/results`** and `/history` include the `benchmark` summary; result rows include `valid_to`.
141 −- **`/diff`** adds `scope` (resolved description) and extra `counts` (`events`, `price_rows_opened/closed`, `claims_superseded`, `entities_at_a/b`).
142 −- **`/sources`** items add `snapshots`, `claims`, `base_url`, `robots_policy`, `priority`, `notes`; response adds `tiers` legend.
143 −- **`/methodology`** adds `quality_version`, `expected_fields`, `principles`; `event_types` are live counts from `change_events`.
144 −- **`/trending`** accepts `type=`; `/sitemap` `limit` ≤ 5000 (the one listing not capped at 200).
145 −- **Rate limits** are in-process sliding windows (search 60/min, views 1/s, admin 240/min per IP) — fine for the single uvicorn worker behind Next.js.
146 −- **Admin**: `GET /admin/review?status=all` lists every status; approving a `conflict` with `resolution.keep_claim_id` promotes that claim;
147 − `PATCH /admin/connectors/{name}` also resets `health`/`circuit_open_until` when toggling; `/admin/connectors` flags `run_now_pending` and
148 − connectors present in code but not in the table; `/admin/snapshots/{id}` never returns `raw_path`/`text_path` (`has_raw`/`has_text` booleans instead).
149 −- **Migration `0002_api_indexes`** adds partial indexes for the read paths (type + release_date/family/openness/updated/first_seen, importance-ordered
150 − events, current leaderboards/prices, page views, review kinds).
197 +| `GET /admin/overview` · `GET /admin/connectors` · `POST /admin/connectors/{name}/run` · `PATCH /admin/connectors/{name}` · `GET /admin/runs` · `GET /admin/errors` | v1 (unchanged) |
198 +| `GET /admin/documents…` · `GET /admin/documents/{id}` · `GET /admin/snapshots/{id}` · `GET /admin/jobs` · `POST /admin/jobs/{id}/retry` · `POST /admin/jobs/requeue-dead` · `GET /admin/llm-jobs` · `GET /admin/llm/health` · `POST /admin/llm/enqueue` · `POST /admin/reprocess` | v1 (unchanged) |
199 +| `GET /admin/review` · `POST /admin/review/{id}` · `GET /admin/entities/duplicates?type=&limit=&threshold=` · `POST /admin/entities/merge` · `POST /admin/entities/{id}/claims/{claim_id}/retract` | v1 — **1.1:** `duplicates` no longer calls `set_limit()` (GUC leak): the `%` operator uses the index at the default 0.3 threshold and `similarity() > :threshold` applies the exact bound |
200 +| `GET /admin/infrastructure` · `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | v1 (unchanged) |
201 +| `POST /admin/cache/flush?prefix=&after_run=0` | `{ flushed, prefix }`; `after_run=1` → `{ flushed, by_prefix }` over the read paths a connector run invalidates (`/api/v1/stats`, `/changes*`, `/benchmarks*`, `/prices*`, `/models*`, `/deployments`, `/frontier`, `/pulse`, `/open`, `/families`, `/timeline`, `/diff`, `facets:`, `frontier:`). The scheduler calls `services.cache.cache_invalidate(prefix)` with the same prefixes after each run (Stream A). |
202 +| **`GET /admin/quality`** | data-health dashboard: `duplicate_candidates { pending_decisions, review_merge_candidates }`, `taxonomy_violations { unmapped_taxonomy_rows, openness_unknown_vocab, status_unknown_vocab, license_unclassified }`, `impossible_values { count, by_check, sample }`, `conflicting_t1_claims`, `models_without_organization`, `models_without_release_source`, `models_without_parameters`, `orphan_benchmark_results` (model merged or artifact), `benchmarks_without_results`, `unresolved_provider_deployments` (provider_model_id matching no identifier), `quantisations_typed_as_models` (ontology name analysis says artifact), `stale_sources` (last success > 3× interval), `empty_public_categories`, `quarantined_runs_pending` — each `{ count, sample }` — plus `review_queue_priority: { kind, id, reasons: string[], … }[]` (frontier models, benchmark leaders, largest params/context claims, price anomalies, major orgs, duplicates of frontier models first) |
203 +| **`GET /admin/entity-resolution?type=model&status=pending|decided|all&limit=&threshold=0.8`** | `{ items: { a: Side, b: Side, signals: { kind: review_merge_candidate|trigram_similarity|same_variant_key, … }[], similarity, same_variant_key, same_organization, decision, hint }[], total, decisions }` — `Side` = names, org, family, params, release date, architecture, hf_repo, identifiers, aliases, relations/sources/claims/prices/results counts, `variant_key`, `name_analysis` |
204 +| **`POST /admin/entity-resolution/{a}/{b}`** `{ decision: merge|alias|variant_of|family_member|keep_separate|defer, note? }` | persists `resolution_decisions` (upsert on a, b, decision) and applies: `merge_entities(conn, a, b, mode=<decision>)` when the service signature accepts `mode` (checked with `inspect.signature`), otherwise plain merge for `merge`, `canonical_id` + merge for `variant_of`, alias row for `alias`, `family_id` for `family_member` (b must be a `model_family`); matching pending `merge_candidate` review items are approved → `{ ok, a, b, decision, applied, effect }` |
205 +| **`GET /admin/anomalies?status=open|all&severity=&check=&limit=&offset=`** · **`POST /admin/anomalies/{id}`** `{ status: resolved|ignored|open, note? }` | anomaly flags (never deleted) |
206 +| **`GET /admin/extractions/{snapshot_id}?text_limit=`** | extraction debugger: snapshot metadata (no paths), `text` (≤ 20 kB cleaned text), `structured`, `diff`, `previous_snapshot`, `claims`, `relations`, `results`, `prices`, `events`, `llm_jobs`, `entity_candidates`, `spans: { claim_id, property, value, found, offset?, match?, context?, tried? }[]`, `spans_found` — value location is best effort, not-found is reported |
207 +| **`GET /admin/quarantine?status=pending|all`** · **`POST /admin/quarantine/{id}`** `{ action: release|discard, note? }` | held facts; the POST calls `services.canonical.release_quarantine/discard_quarantine` (imported lazily) → 501 with a note while Stream A has not shipped them |
208 +| **`GET /admin/audit?limit=&offset=&action=`** | `{ items: { id, actor, action, target, payload, ip, created_at }[], total }` |
209 +| **`POST /admin/runs/{run_id}/rollback`** | retracts claims (`status = retracted`), closes relations / prices / results (`valid_to`, `is_current = false`), flags events `is_backfill = true` + `meta.rolled_back`, re-materialises attributes from the best remaining claim — only rows with that `run_id`; deletes nothing → `{ ok, run_id, connector, counts, note }` |
210 +
211 +## Implementation notes (`src/aiatlas/api`)
212 +
213 +- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds return **422** with `{ detail, errors }`; semantic errors raised by the routes return **400**.
214 +- **Caching**: public GETs are cached in Redis (`aia:api:<path>?<sorted query>`, 60–600 s) and served with a weak ETag (`W/"sha1(body)"`) computed on the uncompressed body inside GZip; `/stats` stays at 60 s. Cache invalidation: `services.cache.cache_invalidate(prefix)`; `POST /admin/cache/flush?after_run=1`.
215 +- **Connections**: `EntityDetail` runs its blocks in ≤ 4 concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per block.
216 +- **Performance (2026-09-12, prod copy, EXPLAIN ANALYZE)**: leaderboard load 8 ms · matrix / all groups 8 ms · models listing 0.5 ms (+ count 1.2 ms, facets ≤ 4 ms) · changes feed 0.8 ms (count 3 ms) · daily sections 0.4 ms · price index 180 d 9 ms · frontier composition 1.2 ms · stats 7 ms · deployments cursor 0.4 ms · providers 2.5 ms · free-text search 16 ms. No query exceeded 200 ms, so **no migration 0004 was needed** (the 0003 partial indexes on `(is_backfill, occurred_at)`, `(benchmark_id, config_key, is_current)`, `family_id`, `canonical_id` are used). The admin trigram self-join (`/admin/entities/duplicates`, `/admin/entity-resolution`) takes ~120 ms in SQL + per-pair side-by-side lookups; it is admin-only and not cached.
217 +- **Search**: `query.semantic` says whether an embedding was used; on embedding timeout (2 s) the API backs off to FTS-only for 5 minutes.
218 +- **`hardware_fit`** (model detail) is omitted when `parameter_count` is unknown; every fit payload carries `estimated: true`.
219 +- **Rate limits** are in-process sliding windows (search 60/min, views 1/s, admin 240/min, admin-auth-failed 10/min per IP) — fine for the single uvicorn worker behind Next.js.
220 +- **Migrations**: `0002_api_indexes` (read paths), `0003_canonical_ontology` (hierarchy, event semantics, comparability, admin tables) — both required by 1.1.
modified src/aiatlas/api/common.py +151 −32
@@ -5,6 +5,8 @@ from __future__ import annotations
5 5
6 6 import hmac
7 7 import inspect
8 +import logging
9 +import os
8 10 import time
9 11 from collections import defaultdict, deque
10 12 from collections.abc import Awaitable, Callable
@@ -20,9 +22,11 @@ from fastapi.responses import JSONResponse
20 22 from sqlalchemy.ext.asyncio import AsyncConnection
21 23
22 24 from aiatlas.config import settings
23 −from aiatlas.db import fetch_one
25 +from aiatlas.db import execute, fetch_one, jsonb, transaction
24 26 from aiatlas.services import cache
25 27
28 +log = logging.getLogger("aiatlas.api")
29 +
26 30 # ------------------------------------------------------------------------------------------------------------------ errors & JSON
27 31
28 32
@@ -167,48 +171,84 @@ def cached(ttl_s: int) -> Callable[[Callable[..., Awaitable[Any]]], Callable[...
167 171 return deco
168 172
169 173
170 −# ------------------------------------------------------------------------------------------------------------------ admin auth
171 −
172 −
173 −def require_admin(request: Request) -> None:
174 − expected = settings.admin_token
175 − if not expected:
176 − raise ApiError(503, "admin API disabled: AIA_ADMIN_TOKEN is not configured")
177 − token = request.headers.get("x-aia-admin-token") or ""
178 − if not hmac.compare_digest(token, expected):
179 − raise ApiError(401, "invalid or missing x-aia-admin-token")
180 −
181 −
182 174 # ------------------------------------------------------------------------------------------------------------------ rate limiting
183 175
184 −_LIMITS: dict[str, tuple[int, float]] = {"search": (60, 60.0), "views": (1, 1.0), "admin": (240, 60.0), "default": (600, 60.0)}
176 +_LIMITS: dict[str, tuple[int, float]] = {"search": (60, 60.0), "views": (1, 1.0), "admin": (240, 60.0), "admin-auth-failed": (10, 60.0), "default": (600, 60.0)}
185 177 _hits: dict[tuple[str, str], deque[float]] = defaultdict(deque)
186 178
187 179
180 +def trust_proxy() -> bool:
181 + """`x-forwarded-for` is only honoured behind a trusted reverse proxy (`settings.trust_proxy` or env `AIA_TRUST_PROXY=1`)."""
182 + v = getattr(settings, "trust_proxy", None)
183 + if v is None:
184 + v = os.environ.get("AIA_TRUST_PROXY", "")
185 + return str(v).strip().lower() in ("1", "true", "yes", "on")
186 +
187 +
188 188 def client_ip(request: Request) -> str:
189 − fwd = request.headers.get("x-forwarded-for")
190 − if fwd:
191 − return fwd.split(",")[0].strip()
189 + if trust_proxy():
190 + fwd = request.headers.get("x-forwarded-for")
191 + if fwd:
192 + return fwd.split(",")[0].strip()
192 193 return request.client.host if request.client else "unknown"
193 194
194 195
196 +def _bucket_hit(ip: str, bucket: str, *, record: bool = True) -> bool:
197 + """Sliding-window counter. Returns True when the call is allowed (and records it), False when the bucket is full."""
198 + n, window = _LIMITS.get(bucket, _LIMITS["default"])
199 + now = time.monotonic()
200 + q = _hits[(ip, bucket)]
201 + while q and q[0] <= now - window:
202 + q.popleft()
203 + if len(q) >= n:
204 + return False
205 + if record:
206 + q.append(now)
207 + if len(_hits) > 20000:
208 + for k in [k for k, v in _hits.items() if not v or v[-1] < now - 120]:
209 + _hits.pop(k, None)
210 + return True
211 +
212 +
195 213 def rate_limit(bucket: str) -> Callable[[Request], None]:
196 214 def dep(request: Request) -> None:
197 215 n, window = _LIMITS.get(bucket, _LIMITS["default"])
198 − now = time.monotonic()
199 − q = _hits[(client_ip(request), bucket)]
200 − while q and q[0] <= now - window:
201 − q.popleft()
202 − if len(q) >= n:
216 + if not _bucket_hit(client_ip(request), bucket):
203 217 raise ApiError(429, f"rate limit exceeded for {bucket}: {n} per {int(window)} s")
204 − q.append(now)
205 − if len(_hits) > 20000:
206 − for k in [k for k, v in _hits.items() if not v or v[-1] < now - 120]:
207 − _hits.pop(k, None)
208 218
209 219 return dep
210 220
211 221
222 +# ------------------------------------------------------------------------------------------------------------------ admin auth
223 +
224 +
225 +def require_admin(request: Request) -> None:
226 + """Token check. Failed attempts are counted per IP (10/min → 429) so the token cannot be brute-forced; must run AFTER `rate_limit('admin')`."""
227 + ip = client_ip(request)
228 + if not _bucket_hit(ip, "admin-auth-failed", record=False):
229 + raise ApiError(429, "too many failed admin authentications; retry in a minute")
230 + expected = settings.admin_token
231 + if not expected:
232 + raise ApiError(503, "admin API disabled: AIA_ADMIN_TOKEN is not configured")
233 + token = request.headers.get("x-aia-admin-token") or ""
234 + if not hmac.compare_digest(token, expected):
235 + _bucket_hit(ip, "admin-auth-failed")
236 + raise ApiError(401, "invalid or missing x-aia-admin-token")
237 +
238 +
239 +ADMIN_DEPENDENCIES = [Depends(rate_limit("admin")), Depends(require_admin)] # order matters: limit first, then auth
240 +
241 +
242 +async def audit(action: str, target: str | None = None, payload: dict[str, Any] | None = None, ip: str | None = None, *, actor: str = "admin") -> None:
243 + """One row in `admin_audit_log` per admin action (best effort — never breaks the request)."""
244 + try:
245 + async with transaction() as conn:
246 + await execute(conn, "insert into admin_audit_log (actor, action, target, payload, ip) values (:a, :ac, :t, cast(:p as jsonb), :ip)",
247 + a=actor, ac=action, t=target, p=jsonb(payload or {}), ip=ip)
248 + except Exception: # noqa: BLE001
249 + log.warning("audit log write failed", extra={"action": action, "target": target})
250 +
251 +
212 252 # ------------------------------------------------------------------------------------------------------------------ entity serialisers
213 253
214 254 ENTITY_FIELDS = ("id", "entity_type", "canonical_name", "slug", "description", "status", "organization_id", "attributes", "quality", "counts",
@@ -249,7 +289,36 @@ COMPANY_TYPES = ("company", "organization", "lab", "university")
249 289 TYPE_LABELS = {"model": "Models", "company": "Companies", "organization": "Organizations", "lab": "Labs", "university": "Universities",
250 290 "provider": "Providers", "paper": "Papers", "benchmark": "Benchmarks", "hardware": "Hardware", "framework": "Frameworks",
251 291 "dataset": "Datasets", "tool": "Tools", "repository": "Repositories", "regulation": "Regulation", "incident": "Incidents",
252 − "researcher": "Researchers", "agent": "Agents", "product": "Products", "runtime": "Runtimes", "conference": "Conferences"}
292 + "researcher": "Researchers", "agent": "Agents", "product": "Products", "runtime": "Runtimes", "conference": "Conferences",
293 + "artifact": "Artifacts", "model_family": "Model families", "license": "Licenses"}
294 +
295 +# Canonical model universe (API 1.1): model releases only — artifacts (checkpoints, quantisations, conversions) are `entity_type = 'artifact'`,
296 +# folded evaluation variants carry `merged_into`. `include=artifacts` on /models restores the pre-1.1 universe.
297 +MODEL_UNIVERSE = "e.entity_type = 'model' and e.merged_into is null"
298 +MODEL_OR_ARTIFACT_UNIVERSE = "e.entity_type in ('model', 'artifact') and e.merged_into is null"
299 +OPEN_CATEGORIES = ("open-weights", "open-source") # "open-*"
300 +DOWNLOADABLE_CATEGORIES = ("open-weights", "open-source", "restricted-weights", "restricted") # weights can be downloaded (terms may restrict)
301 +ARTIFACT_KINDS = ("checkpoint", "quantization", "conversion", "packaging")
302 +
303 +
304 +def openness_values(raw: str | None) -> list[str]:
305 + """Expand the `openness=` filter vocabulary: `open` → open-weights + open-source (+ legacy `open`); `restricted` ↔ `restricted-weights`."""
306 + vals = [v.strip() for v in (raw or "").split(",") if v.strip()]
307 + out: list[str] = []
308 + for v in vals:
309 + if v == "open":
310 + out += [*OPEN_CATEGORIES, "open"]
311 + elif v in ("restricted", "restricted-weights"):
312 + out += ["restricted", "restricted-weights"]
313 + elif v == "downloadable":
314 + out += list(DOWNLOADABLE_CATEGORIES)
315 + else:
316 + out.append(v)
317 + return list(dict.fromkeys(out))
318 +
319 +
320 +def is_open(openness: Any) -> bool:
321 + return str(openness or "") in OPEN_CATEGORIES
253 322
254 323
255 324 def summary_attributes(entity_type: str, attrs: dict[str, Any] | None) -> dict[str, Any]:
@@ -371,6 +440,24 @@ PRICE_COLS = ("p.id, p.provider_model_id, p.input_per_mtok, p.output_per_mtok, p
371 440 "p.valid_to, p.source_url, p.tier, " + entity_cols("m", "m_") + ", " + entity_cols("pv", "p_"))
372 441 PRICE_FROM = "prices p " + entity_join("m", "p.model_id") + " " + entity_join("pv", "p.provider_id")
373 442
443 +_NATIVE_UNIT_SUFFIXES = ("_per_mtok", "_per_1k_requests", "_per_mtok_hour", "_per_image", "_per_request", "_per_second", "_per_minute", "_per_hour", "_per_char")
444 +
445 +
446 +def deployment_row(row: dict[str, Any]) -> dict[str, Any]:
447 + """`Deployment` (API 1.1): one model × provider × provider_model_id offer. Prices in USD per 1M tokens unless the key says otherwise;
448 + provider-specific priced features (`flex_input_per_mtok`, `search_grounding_per_1k_requests`…) are kept verbatim under `native_units`."""
449 + feats = dict(row.get("features") or {})
450 + native = {k: v for k, v in feats.items() if any(k.endswith(s) for s in _NATIVE_UNIT_SUFFIXES) or k == "per_request"}
451 + other = {k: v for k, v in feats.items() if k not in native}
452 + return {"id": row["id"], "model": entity_summary(row, "m_"), "provider": entity_summary(row, "p_"), "provider_model_id": row.get("provider_model_id"),
453 + "context_length": row.get("context_length"), "max_output_tokens": row.get("max_output_tokens"),
454 + "prices": {"input": row.get("input_per_mtok"), "cached_input": row.get("cached_input_per_mtok"), "cache_write": row.get("cache_write_per_mtok"),
455 + "output": row.get("output_per_mtok"), "batch_input": row.get("batch_input_per_mtok"), "batch_output": row.get("batch_output_per_mtok"),
456 + "per_image": row.get("per_image"), "per_request": row.get("per_request"), "currency": row.get("currency") or "USD", "unit": "USD per 1M tokens",
457 + "native_units": native},
458 + "features": other, "status": "active" if row.get("valid_to") is None else "delisted", "observed_at": row.get("observed_at"),
459 + "valid_from": row.get("valid_from"), "valid_to": row.get("valid_to"), "source_url": row.get("source_url"), "tier": row.get("tier")}
460 +
374 461
375 462 def result_row(row: dict[str, Any]) -> dict[str, Any]:
376 463 return {"id": row["id"], "model": entity_summary(row, "m_"), "benchmark": entity_summary(row, "b_"), "score": row.get("score"), "metric": row.get("metric"),
@@ -400,24 +487,44 @@ CLAIM_FROM = "claims c left join sources s on s.id = c.source_id"
400 487 # ------------------------------------------------------------------------------------------------------------------ entity resolution
401 488
402 489
403 −async def resolve_entity(conn: AsyncConnection, slug_or_id: str, types: tuple[str, ...] | None = None) -> dict[str, Any]:
404 − """Slug first, then id; follows `merged_into`; 404 when absent or when the type does not match the mounted alias."""
490 +ENTITY_EXTRA_COLS = "e.provenance, e.merged_into, e.family_id, e.canonical_id, e.artifact_kind, e.identity_confidence"
491 +
492 +
493 +async def resolve_entity(conn: AsyncConnection, slug_or_id: str, types: tuple[str, ...] | None = None, *, aliases: bool = False) -> dict[str, Any]:
494 + """Slug first, then id (then alias when `aliases=True`); follows `merged_into` and records the hop in `redirected_from`
495 + (`{slug, id}` of the row the caller asked for) so the web layer can 301; 404 when absent or when the type does not match the mounted alias."""
405 496 key = slug_or_id.strip()
406 497 if not key or len(key) > 200:
407 498 raise ApiError(404, "entity not found")
408 − row = await fetch_one(conn, f"select {ENTITY_COLS}, e.provenance, e.merged_into from {ENTITY_FROM} where e.slug = :k or e.id = :k "
409 − f"order by case when e.slug = :k then 0 else 1 end limit 1", k=key)
499 + sel = f"select {ENTITY_COLS}, {ENTITY_EXTRA_COLS} from {ENTITY_FROM}"
500 + row = await fetch_one(conn, f"{sel} where e.slug = :k or e.id = :k order by case when e.slug = :k then 0 else 1 end limit 1", k=key)
501 + if not row and aliases:
502 + row = await fetch_one(conn, f"{sel} where e.id = (select a.entity_id from entity_aliases a join entities x on x.id = a.entity_id "
503 + f"where (a.alias_norm = :n or lower(a.alias) = lower(:k)) {'and x.entity_type = any(cast(:types as text[]))' if types else ''} "
504 + f"order by x.merged_into is not null, x.updated_at desc limit 1)", k=key, n=_alias_norm(key), types=list(types or ()))
505 + asked = row
410 506 hops = 0
411 507 while row and row.get("merged_into") and hops < 5:
412 − row = await fetch_one(conn, f"select {ENTITY_COLS}, e.provenance, e.merged_into from {ENTITY_FROM} where e.id = :k", k=row["merged_into"])
508 + row = await fetch_one(conn, f"{sel} where e.id = :k", k=row["merged_into"])
413 509 hops += 1
414 510 if not row:
415 511 raise ApiError(404, "entity not found")
416 512 if types and row["entity_type"] not in types:
417 513 raise ApiError(404, f"entity {key!r} is a {row['entity_type']}, not one of {', '.join(types)}")
514 + if asked is not None and asked["id"] != row["id"]:
515 + row["redirected_from"] = {"slug": asked["slug"], "id": asked["id"], "entity_type": asked["entity_type"]}
418 516 return row
419 517
420 518
519 +def _alias_norm(s: str) -> str:
520 + try:
521 + from aiatlas.ids import normalize_alias
522 +
523 + return normalize_alias(s)
524 + except Exception: # noqa: BLE001
525 + return "".join(ch for ch in s.lower() if ch.isalnum())
526 +
527 +
421 528 async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple[str, ...] | None = None) -> str | None:
422 529 if not slug_or_id:
423 530 return None
@@ -425,15 +532,22 @@ async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple
425 532
426 533
427 534 __all__ = [
535 + "ADMIN_DEPENDENCIES",
536 + "ARTIFACT_KINDS",
428 537 "CLAIM_COLS",
429 538 "CLAIM_FROM",
430 539 "COMPANY_TYPES",
540 + "DOWNLOADABLE_CATEGORIES",
431 541 "ENTITY_COLS",
542 + "ENTITY_EXTRA_COLS",
432 543 "ENTITY_FROM",
433 544 "EVENT_COLS",
434 545 "EVENT_FROM",
435 546 "EVENT_TYPE_LABELS",
436 547 "MAIN_ENTITY_TYPES",
548 + "MODEL_OR_ARTIFACT_UNIVERSE",
549 + "MODEL_UNIVERSE",
550 + "OPEN_CATEGORIES",
437 551 "PAGINATION",
438 552 "PRICE_COLS",
439 553 "PRICE_FROM",
@@ -446,6 +560,7 @@ __all__ = [
446 560 "AtlasJSONResponse",
447 561 "Pagination",
448 562 "attr_num",
563 + "audit",
449 564 "cache_key",
450 565 "cached",
451 566 "change_event",
@@ -453,6 +568,7 @@ __all__ = [
453 568 "client_ip",
454 569 "csv",
455 570 "day_bounds",
571 + "deployment_row",
456 572 "dumps",
457 573 "enrich_provenance",
458 574 "entity_cols",
@@ -461,9 +577,11 @@ __all__ = [
461 577 "event_type_importance",
462 578 "event_type_label",
463 579 "flip_order",
580 + "is_open",
464 581 "normalize",
465 582 "normalize_status",
466 583 "num_expr",
584 + "openness_values",
467 585 "org_of",
468 586 "page",
469 587 "parse_date",
@@ -474,4 +592,5 @@ __all__ = [
474 592 "resolve_entity",
475 593 "resolve_id",
476 594 "result_row",
595 + "trust_proxy",
477 596 ]
modified src/aiatlas/api/detail.py +274 −40
@@ -1,12 +1,20 @@
1 −"""`EntityDetail` builder (docs/API.md): shared by `/entities/{slug}` and the type-scoped aliases."""
1 +"""`EntityDetail` builder (docs/API.md): shared by `/entities/{slug}` and the type-scoped aliases.
2 +
3 +API 1.1: blocks run in at most FOUR concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per
4 +block; model details gain `family`, `artifacts`, `deployments`, `identity`, `licence`, `openness`, `version_history` and grouped `benchmarks`;
5 +artifact details carry `canonical` + `artifact_kind`; a resolved `merged_into` hop is reported as `redirected_from`."""
2 6 from __future__ import annotations
3 7
4 8 import asyncio
9 +from collections import defaultdict
5 10 from typing import Any
6 11
7 12 from sqlalchemy.ext.asyncio import AsyncConnection
8 13
9 14 from aiatlas.api.common import (
15 + ARTIFACT_KINDS,
16 + CLAIM_COLS,
17 + CLAIM_FROM,
10 18 COMPANY_TYPES,
11 19 ENTITY_COLS,
12 20 ENTITY_FROM,
@@ -18,16 +26,23 @@ from aiatlas.api.common import (
18 26 RESULT_FROM,
19 27 RESULT_ORDER,
20 28 change_event,
29 + deployment_row,
21 30 enrich_provenance,
22 31 entity_summary,
23 32 price_row,
24 33 result_row,
25 34 )
26 −from aiatlas.db import connection, fetch_all
35 +from aiatlas.db import connection, fetch_all, fetch_one
36 +from aiatlas.ontology.benchmarks import TRUST_LABELS
37 +from aiatlas.ontology.licenses import LICENSES, normalize_license
38 +from aiatlas.ontology.openness import OPENNESS_DEFINITIONS, OPENNESS_LABELS, normalize_openness, openness_dimensions
27 39 from aiatlas.services import hardware_fit as hf
40 +from aiatlas.services.frontier import config_summary, enrich, group_label
28 41
29 42 LINEAGE_PREDICATES = ("derived_from", "fine_tuned_from", "distilled_from", "merged_from", "quantized_from")
30 43 RELATION_GROUP_LIMIT = 24
44 +VERSIONED_PROPERTIES = ("context_length", "max_output_tokens", "status", "knowledge_cutoff", "license", "openness", "parameter_count")
45 +MAX_CONCURRENT_GROUPS = 4
31 46
32 47
33 48 async def relations_grouped(conn: AsyncConnection, entity_id: str) -> list[dict[str, Any]]:
@@ -68,17 +83,30 @@ def _scope_sql(is_org: bool) -> str:
68 83 return "ev.entity_id = :id"
69 84
70 85
86 +def event_date_col(date_field: str) -> str:
87 + return "ev.observed_at" if date_field == "observed" else "ev.occurred_at"
88 +
89 +
71 90 async def timeline_of(conn: AsyncConnection, entity_id: str, entity_type: str, *, limit: int = 30, before: Any = None,
72 − include_documents: bool = False) -> list[dict[str, Any]]:
91 + include_documents: bool = False, include_backfill: bool = False, date_field: str = "occurred") -> list[dict[str, Any]]:
92 + col = event_date_col(date_field)
73 93 where = [_scope_sql(entity_type in COMPANY_TYPES)]
74 94 params: dict[str, Any] = {"id": entity_id, "lim": limit}
75 95 if before is not None:
76 − where.append("ev.observed_at < :before")
96 + where.append(f"{col} < :before")
77 97 params["before"] = before
78 98 if not include_documents:
79 99 where.append("ev.event_type <> 'DOCUMENT_CHANGED'")
80 − rows = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where {' and '.join(where)} order by ev.observed_at desc, ev.id desc limit :lim", **params)
81 − return [change_event(r) for r in rows]
100 + if not include_backfill:
101 + where.append("ev.is_backfill = false")
102 + rows = await fetch_all(conn, f"select {EVENT_COLS}, ev.occurred_at, ev.is_backfill, ev.group_key from {EVENT_FROM} where {' and '.join(where)} "
103 + f"order by {col} desc, ev.id desc limit :lim", **params)
104 + out = []
105 + for r in rows:
106 + ev = change_event(r)
107 + ev["occurred_at"], ev["is_backfill"], ev["group_key"] = r.get("occurred_at"), r.get("is_backfill"), r.get("group_key")
108 + out.append(ev)
109 + return out
82 110
83 111
84 112 async def prices_of_model(conn: AsyncConnection, model_id: str, *, current_only: bool) -> list[dict[str, Any]]:
@@ -88,6 +116,11 @@ async def prices_of_model(conn: AsyncConnection, model_id: str, *, current_only:
88 116 return [price_row(r) for r in rows]
89 117
90 118
119 +async def deployments_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]:
120 + rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.model_id = :id and p.valid_to is null order by p.output_per_mtok nulls last, pv.canonical_name limit 200", id=model_id)
121 + return [deployment_row(r) for r in rows]
122 +
123 +
91 124 async def prices_of_provider(conn: AsyncConnection, provider_id: str) -> list[dict[str, Any]]:
92 125 rows = await fetch_all(conn, f"select {PRICE_COLS} from {PRICE_FROM} where p.provider_id = :id and p.valid_to is null order by m.canonical_name limit 500", id=provider_id)
93 126 return [price_row(r) for r in rows]
@@ -98,7 +131,43 @@ async def results_of_model(conn: AsyncConnection, model_id: str) -> list[dict[st
98 131 return [result_row(r) for r in rows]
99 132
100 133
101 −async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = 100, offset: int = 0, config: str | None = None, history: bool = False) -> list[dict[str, Any]]:
134 +async def benchmarks_of_model(conn: AsyncConnection, model_id: str) -> dict[str, Any]:
135 + """Current results grouped by benchmark → metric → (config group): best row, n_rows, trust level, comparability group label."""
136 + rows = await fetch_all(conn, """
137 + select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.unit, r.higher_is_better, r.config, r.evaluated_at, r.observed_at, r.source_url, r.tier, r.confidence,
138 + r.config_key, r.trust_level, r.extractor, s.key as source_key, b.slug as benchmark_slug, b.canonical_name as benchmark_name, b.attributes->>'category' as category
139 + from benchmark_results r join entities b on b.id = r.benchmark_id left join sources s on s.id = r.source_id
140 + where r.model_id = :id and r.valid_to is null and r.is_current order by b.canonical_name, r.observed_at desc limit 500""", id=model_id)
141 + for r in rows:
142 + r["model_name"] = None
143 + enrich(r)
144 + by_bench: dict[str, dict[str, Any]] = {}
145 + for r in rows:
146 + b = by_bench.setdefault(r["benchmark_id"], {"benchmark": {"id": r["benchmark_id"], "slug": r["benchmark_slug"], "name": r["benchmark_name"], "category": r["category"]}, "metrics": {}})
147 + m = b["metrics"].setdefault(r["metric_canonical"], {"metric": r["metric_canonical"], "groups": {}})
148 + g = m["groups"].setdefault(r["config_key"], {"config_key": r["config_key"], "label": group_label(r["metric_canonical"], r.get("config")), "rows": []})
149 + g["rows"].append(r)
150 + items = []
151 + for b in by_bench.values():
152 + metrics = []
153 + for m in b["metrics"].values():
154 + groups = []
155 + for g in m["groups"].values():
156 + hib = all(x.get("higher_is_better", True) for x in g["rows"])
157 + best = max(g["rows"], key=lambda x: x["score"]) if hib else min(g["rows"], key=lambda x: x["score"])
158 + groups.append({"config_key": g["config_key"], "comparability_group": g["label"], "n_rows": len(g["rows"]), "higher_is_better": hib,
159 + "best": {"score": best["score"], "unit": best.get("unit"), "trust_level": best["trust_level"], "trust_label": TRUST_LABELS.get(best["trust_level"], best["trust_level"]),
160 + "config": config_summary(best.get("config")), "evaluated_at": best.get("evaluated_at"), "observed_at": best["observed_at"],
161 + "source_url": best.get("source_url"), "tier": best.get("tier"), "result_id": best["id"]},
162 + "trust_levels": sorted({x["trust_level"] for x in g["rows"]})})
163 + metrics.append({"metric": m["metric"], "groups": groups})
164 + items.append({**b["benchmark"], "metrics": metrics})
165 + return {"items": items, "total_rows": len(rows), "note": "Current rows only, grouped by benchmark → canonical metric → comparability group (task configuration). "
166 + "Effort variants folded into this model appear as rows of the same group."}
167 +
168 +
169 +async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = 100, offset: int = 0, config: str | None = None, history: bool = False,
170 + metric: str | None = None, config_key: str | None = None) -> list[dict[str, Any]]:
102 171 where = ["r.benchmark_id = :id"]
103 172 params: dict[str, Any] = {"id": benchmark_id, "lim": limit, "off": offset}
104 173 if not history:
@@ -106,8 +175,20 @@ async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int =
106 175 if config:
107 176 where.append("r.config::text ilike :cfg")
108 177 params["cfg"] = f"%{config}%"
109 − rows = await fetch_all(conn, f"select {RESULT_COLS} from {RESULT_FROM} where {' and '.join(where)} order by {RESULT_ORDER} limit :lim offset :off", **params)
110 − return [result_row(r) for r in rows]
178 + if metric:
179 + where.append("lower(r.metric) = lower(:metric)")
180 + params["metric"] = metric
181 + if config_key:
182 + where.append("r.config_key = :ck")
183 + params["ck"] = config_key
184 + rows = await fetch_all(conn, f"select {RESULT_COLS}, r.config_key, r.trust_level from {RESULT_FROM} where {' and '.join(where)} order by {RESULT_ORDER} limit :lim offset :off", **params)
185 + out = []
186 + for r in rows:
187 + item = result_row(r)
188 + item["config_key"] = r.get("config_key")
189 + item["trust_level"] = r.get("trust_level")
190 + out.append(item)
191 + return out
111 192
112 193
113 194 async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dict[str, Any]]]:
@@ -126,7 +207,7 @@ async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dic
126 207 union
127 208 select r.subject_id, down.depth + 1 from down join relations r on r.object_id = down.id and r.valid_to is null and r.predicate = any(cast(:preds as text[])) where down.depth < 3)
128 209 select distinct on (e.id) down.depth, {ENTITY_COLS} from down join entities e on e.id = down.id left join entities eo on eo.id = e.organization_id
129 − where e.id <> :id and e.merged_into is null order by e.id, down.depth""", id=model_id, preds=desc_preds)
210 + where e.id <> :id and e.merged_into is null and e.entity_type <> 'artifact' order by e.id, down.depth""", id=model_id, preds=desc_preds)
130 211 quants = await fetch_all(conn, f"""select {ENTITY_COLS} from relations r join entities e on e.id = r.subject_id left join entities eo on eo.id = e.organization_id
131 212 where r.object_id = :id and r.predicate = 'quantized_from' and r.valid_to is null and e.merged_into is null
132 213 order by e.canonical_name limit 100""", id=model_id)
@@ -135,6 +216,111 @@ async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dic
135 216 "quantizations": [entity_summary(r) for r in quants]}
136 217
137 218
219 +async def artifacts_of_model(conn: AsyncConnection, model_id: str) -> dict[str, Any]:
220 + """Artifacts (entity_type 'artifact' with canonical_id = model, or `artifact_of` relation) grouped by kind."""
221 + rows = await fetch_all(conn, f"""
222 + with ids as (select e.id, e.artifact_kind from entities e where e.canonical_id = :id and e.entity_type = 'artifact' and e.merged_into is null
223 + union select r.subject_id, null from relations r join entities x on x.id = r.subject_id where r.object_id = :id and r.predicate in ('artifact_of','quantized_from')
224 + and r.valid_to is null and x.entity_type = 'artifact' and x.merged_into is null)
225 + select distinct on (e.id) coalesce(e.artifact_kind, ids.artifact_kind) as kind, {ENTITY_COLS} from ids join entities e on e.id = ids.id left join entities eo on eo.id = e.organization_id
226 + order by e.id limit 300""", id=model_id)
227 + groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
228 + for r in rows:
229 + kind = r["kind"] if r["kind"] in ARTIFACT_KINDS else "other"
230 + s = entity_summary(r) or {}
231 + s["artifact_kind"] = r["kind"]
232 + groups[kind].append(s)
233 + ordered = [k for k in (*ARTIFACT_KINDS, "other") if k in groups]
234 + return {"items": [{"kind": k, "items": sorted(groups[k], key=lambda x: x["name"] or ""), "count": len(groups[k])} for k in ordered], "total": len(rows)}
235 +
236 +
237 +async def family_of_model(conn: AsyncConnection, row: dict[str, Any]) -> dict[str, Any] | None:
238 + fid = row.get("family_id")
239 + if fid:
240 + fam = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = :id", id=fid)
241 + if fam:
242 + return entity_summary(fam)
243 + label = (row.get("attributes") or {}).get("family")
244 + if label:
245 + fam = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model_family' and e.merged_into is null and e.canonical_name ilike :n limit 1", n=label)
246 + return entity_summary(fam) if fam else {"id": None, "entity_type": "model_family", "slug": None, "name": label, "canonical": False,
247 + "note": "family label from attributes; no model_family entity yet"}
248 + return None
249 +
250 +
251 +async def canonical_of_artifact(conn: AsyncConnection, row: dict[str, Any]) -> dict[str, Any] | None:
252 + cid = row.get("canonical_id")
253 + if not cid:
254 + rel = await fetch_one(conn, "select object_id from relations where subject_id = :id and predicate in ('artifact_of','quantized_from') and valid_to is null order by predicate limit 1", id=row["id"])
255 + cid = rel["object_id"] if rel else None
256 + if not cid:
257 + return None
258 + can = await fetch_one(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.id = :id", id=cid)
259 + return entity_summary(can) if can else None
260 +
261 +
262 +async def identity_of_model(conn: AsyncConnection, model_id: str, identifiers: list[dict[str, str]]) -> dict[str, Any]:
263 + counts = await fetch_one(conn, """
264 + select (select count(*) from entities a where a.canonical_id = :id and a.entity_type = 'artifact' and a.merged_into is null and coalesce(a.artifact_kind, '') <> 'checkpoint') as third_party,
265 + (select count(*) from entities a where a.canonical_id = :id and a.entity_type = 'artifact' and a.merged_into is null and a.artifact_kind = 'checkpoint') as official_artifacts,
266 + (select count(distinct p.provider_id) from prices p where p.model_id = :id and p.valid_to is null) as providers,
267 + (select count(*) from entities v where v.merged_into = :id) as folded_variants""", id=model_id)
268 + hf_repos = [i["value"] for i in identifiers if i["scheme"] == "hf_repo"]
269 + api_ids = sorted({i["value"] for i in identifiers if i["scheme"].endswith("_model_id") or i["scheme"] in ("openrouter", "api_model_id", "artificial_analysis")})
270 + c = counts or {}
271 + return {"canonical_model": True, "official_checkpoints": hf_repos, "official_artifacts": int(c.get("official_artifacts") or 0), "third_party_artifacts": int(c.get("third_party") or 0),
272 + "provider_deployments": int(c.get("providers") or 0), "folded_variants": int(c.get("folded_variants") or 0), "api_aliases": api_ids,
273 + "note": "official_checkpoints = hf_repo identifiers carried by the model itself; artifacts are separate entities pointing here through canonical_id."}
274 +
275 +
276 +def licence_block(attrs: dict[str, Any]) -> dict[str, Any] | None:
277 + key = attrs.get("license_key") or normalize_license(attrs.get("license"))
278 + info = LICENSES.get(key) if key else None
279 + if not info:
280 + return {"key": None, "raw": attrs.get("license") or attrs.get("license_raw"), "note": "licence label not classified in the ontology"} if attrs.get("license") else None
281 + return {**info.as_dict(), "raw": attrs.get("license_raw") or attrs.get("license"), "url_observed": attrs.get("license_url")}
282 +
283 +
284 +def openness_block(attrs: dict[str, Any]) -> dict[str, Any] | None:
285 + raw = attrs.get("openness")
286 + cat = normalize_openness(raw) if raw else None
287 + dims = attrs.get("openness_dimensions")
288 + if not isinstance(dims, dict):
289 + key = attrs.get("license_key") or normalize_license(attrs.get("license"))
290 + weights = True if cat in ("open-weights", "open-source", "restricted-weights") else False if cat == "proprietary" else None
291 + dims = openness_dimensions(weights_available=weights, license_key=key, license_raw=attrs.get("license"))
292 + if not cat and not raw:
293 + return None
294 + return {"category": cat or "unknown", "raw": raw, "label": OPENNESS_LABELS.get(cat or "unknown"), "definition": OPENNESS_DEFINITIONS.get(cat or "unknown"), "dimensions": dims,
295 + "note": "dimensions marked null are unknown, not false"}
296 +
297 +
298 +async def version_history_of(conn: AsyncConnection, entity_id: str) -> list[dict[str, Any]]:
299 + rows = await fetch_all(conn, f"select {CLAIM_COLS} from {CLAIM_FROM} where c.entity_id = :id and c.property = any(cast(:props as text[])) and c.status <> 'retracted' "
300 + f"order by c.property, c.valid_from asc, c.observed_at asc limit 2000", id=entity_id, props=list(VERSIONED_PROPERTIES))
301 + by_prop: dict[str, list[dict[str, Any]]] = defaultdict(list)
302 + for r in rows:
303 + by_prop[r["property"]].append(r)
304 + out = []
305 + for prop in VERSIONED_PROPERTIES:
306 + claims = by_prop.get(prop)
307 + if not claims:
308 + continue
309 + transitions = []
310 + prev: Any = None
311 + for c in claims:
312 + if c["status"] == "conflicting":
313 + continue
314 + if c["value"] == prev and transitions:
315 + transitions[-1]["valid_to"] = c["valid_to"] or transitions[-1]["valid_to"]
316 + continue
317 + transitions.append({"from": prev, "to": c["value"], "valid_from": c["valid_from"], "valid_to": c["valid_to"], "effective_at": c["effective_at"],
318 + "source_url": c["source_url"], "tier": c["tier"], "claim_id": c["id"], "status": c["status"]})
319 + prev = c["value"]
320 + out.append({"property": prop, "transitions": transitions, "current": transitions[-1]["to"] if transitions else None})
321 + return out
322 +
323 +
138 324 async def providers_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]:
139 325 rows = await fetch_all(conn, f"""
140 326 with ids as (select r.object_id as id from relations r where r.subject_id = :id and r.predicate = 'available_through' and r.valid_to is null
@@ -173,15 +359,20 @@ async def related_by_type(conn: AsyncConnection, entity_id: str, etype: str, *,
173 359 return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0
174 360
175 361
176 −async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
177 − """Full detail. Blocks run on separate pooled connections concurrently (read-only, so no transaction needed)."""
178 − eid, etype = row["id"], row["entity_type"]
362 +async def _run_group(tasks: list[tuple[str, Any, tuple[Any, ...], dict[str, Any]]]) -> dict[str, Any]:
363 + """Run the blocks of one group sequentially on a single pooled connection."""
364 + out: dict[str, Any] = {}
365 + async with connection() as conn:
366 + for name, fn, args, kw in tasks:
367 + out[name] = await fn(conn, *args, **kw)
368 + return out
179 369
180 − async def one(fn: Any, *args: Any, **kw: Any) -> Any:
181 − async with connection() as conn:
182 − return await fn(conn, *args, **kw)
183 370
371 +async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
372 + """Full detail. Blocks are spread over ≤ 4 concurrent groups (one connection each)."""
373 + eid, etype = row["id"], row["entity_type"]
184 374 provenance = dict(row.get("provenance") or {})
375 + attrs = row.get("attributes") or {}
185 376
186 377 async def base(conn: AsyncConnection) -> dict[str, Any]:
187 378 aliases = await fetch_all(conn, "select alias from entity_aliases where entity_id = :id order by kind, alias limit 200", id=eid)
@@ -189,36 +380,42 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
189 380 await enrich_provenance(conn, provenance)
190 381 return {"aliases": [a["alias"] for a in aliases], "identifiers": [{"scheme": i["scheme"], "value": i["value"]} for i in idents]}
191 382
192 − tasks: dict[str, Any] = {"base": one(base), "relations": one(relations_grouped, eid), "sources": one(sources_of, eid), "timeline": one(timeline_of, eid, etype)}
383 + T = lambda name, fn, *args, **kw: (name, fn, args, kw)
384 + groups: list[list[tuple[str, Any, tuple[Any, ...], dict[str, Any]]]] = [
385 + [T("base", base), T("relations", relations_grouped, eid)],
386 + [T("sources", sources_of, eid), T("timeline", timeline_of, eid, etype)],
387 + ]
193 388 if etype == "model":
194 − tasks["prices"] = one(prices_of_model, eid, current_only=True)
195 − tasks["price_history"] = one(prices_of_model, eid, current_only=False)
196 − tasks["results"] = one(results_of_model, eid)
197 − tasks["lineage"] = one(lineage_of, eid)
198 − tasks["providers"] = one(providers_of_model, eid)
199 − tasks["hardware_fit"] = one(hardware_fit_of_model, row["attributes"] or {})
200 − tasks["papers"] = one(related_by_type, eid, "paper", limit=24)
201 − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24)
389 + groups[0] += [T("prices", prices_of_model, eid, current_only=True), T("deployments", deployments_of_model, eid), T("providers", providers_of_model, eid)]
390 + groups[1] += [T("price_history", prices_of_model, eid, current_only=False), T("family", family_of_model, row), T("artifacts", artifacts_of_model, eid)]
391 + groups.append([T("results", results_of_model, eid), T("benchmarks", benchmarks_of_model, eid), T("version_history", version_history_of, eid)])
392 + groups.append([T("lineage", lineage_of, eid), T("hardware_fit", hardware_fit_of_model, attrs), T("papers", related_by_type, eid, "paper", limit=24),
393 + T("repositories", related_by_type, eid, "repository", limit=24)])
394 + elif etype == "artifact":
395 + groups[0] += [T("canonical", canonical_of_artifact, row), T("prices", prices_of_model, eid, current_only=True)]
396 + groups[1] += [T("lineage", lineage_of, eid), T("hardware_fit", hardware_fit_of_model, attrs)]
202 397 elif etype in COMPANY_TYPES:
203 − tasks["models"] = one(related_by_type, eid, "model", limit=50, include_org_children=True)
204 − tasks["papers"] = one(related_by_type, eid, "paper", limit=24, include_org_children=True)
205 − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24, include_org_children=True)
398 + groups[0] += [T("models", related_by_type, eid, "model", limit=50, include_org_children=True)]
399 + groups[1] += [T("papers", related_by_type, eid, "paper", limit=24, include_org_children=True), T("repositories", related_by_type, eid, "repository", limit=24, include_org_children=True)]
206 400 elif etype == "provider":
207 − tasks["prices"] = one(prices_of_provider, eid)
208 − tasks["models"] = one(_provider_models, eid)
401 + groups[0] += [T("prices", prices_of_provider, eid)]
402 + groups[1] += [T("models", _provider_models, eid)]
209 403 elif etype == "benchmark":
210 − tasks["results"] = one(leaderboard, eid, limit=100)
404 + groups[0] += [T("results", leaderboard, eid, limit=100)]
211 405 elif etype == "hardware":
212 − tasks["models"] = one(related_by_type, eid, "model", limit=50)
213 − elif etype == "framework":
214 − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24)
406 + groups[0] += [T("models", related_by_type, eid, "model", limit=50)]
407 + elif etype in ("framework", "library", "runtime"):
408 + groups[0] += [T("repositories", related_by_type, eid, "repository", limit=24)]
409 + elif etype == "model_family":
410 + groups[0] += [T("models", _family_models, eid)]
215 411
216 − keys = list(tasks)
217 − values = await asyncio.gather(*(tasks[k] for k in keys))
218 − blocks = dict(zip(keys, values, strict=True))
412 + results = await asyncio.gather(*(_run_group(g) for g in groups[:MAX_CONCURRENT_GROUPS]))
413 + blocks: dict[str, Any] = {}
414 + for r in results:
415 + blocks.update(r)
219 416
220 417 detail = entity_summary(row) or {}
221 − detail["attributes"] = row.get("attributes") or {}
418 + detail["attributes"] = attrs
222 419 detail["provenance"] = provenance
223 420 detail.update(blocks.pop("base"))
224 421 detail["relations"] = blocks.pop("relations")
@@ -232,11 +429,33 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
232 429 detail[k] = v[0]
233 430 elif v is not None:
234 431 detail[k] = v
235 − if etype == "model" and "hardware_fit" in detail:
236 − detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS
432 + if etype == "model":
433 + if "hardware_fit" in detail:
434 + detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS
435 + detail["identity"] = await _with_conn(identity_of_model, eid, detail.get("identifiers") or [])
436 + lic = licence_block(attrs)
437 + if lic is not None:
438 + detail["licence"] = lic
439 + opn = openness_block(attrs)
440 + if opn is not None:
441 + detail["openness"] = opn
442 + detail["family_id"] = row.get("family_id")
443 + detail["identity_confidence"] = row.get("identity_confidence")
444 + if etype == "artifact":
445 + detail["artifact_kind"] = row.get("artifact_kind")
446 + detail.setdefault("canonical", None)
447 + if "hardware_fit" in detail:
448 + detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS
449 + if row.get("redirected_from"):
450 + detail["redirected_from"] = row["redirected_from"]
237 451 return detail
238 452
239 453
454 +async def _with_conn(fn: Any, *args: Any) -> Any:
455 + async with connection() as conn:
456 + return await fn(conn, *args)
457 +
458 +
240 459 async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[list[dict[str, Any]], int]:
241 460 rows = await fetch_all(conn, f"""
242 461 with ids as (select p.model_id as id from prices p where p.provider_id = :id and p.valid_to is null
@@ -246,11 +465,25 @@ async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[lis
246 465 return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0
247 466
248 467
468 +async def _family_models(conn: AsyncConnection, family_id: str) -> tuple[list[dict[str, Any]], int]:
469 + rows = await fetch_all(conn, f"""select count(*) over () as total, {ENTITY_COLS} from {ENTITY_FROM} where e.family_id = :id and e.entity_type = 'model' and e.merged_into is null
470 + order by e.attributes->>'release_date' desc nulls last, e.canonical_name limit 50""", id=family_id)
471 + return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0
472 +
473 +
249 474 __all__ = [
475 + "VERSIONED_PROPERTIES",
476 + "artifacts_of_model",
477 + "benchmarks_of_model",
478 + "deployments_of_model",
250 479 "entity_detail",
480 + "event_date_col",
481 + "family_of_model",
251 482 "hardware_fit_of_model",
252 483 "leaderboard",
484 + "licence_block",
253 485 "lineage_of",
486 + "openness_block",
254 487 "prices_of_model",
255 488 "prices_of_provider",
256 489 "providers_of_model",
@@ -259,4 +492,5 @@ __all__ = [
259 492 "results_of_model",
260 493 "sources_of",
261 494 "timeline_of",
495 + "version_history_of",
262 496 ]
added src/aiatlas/api/etag.py +103 −0
@@ -0,0 +1,103 @@
1 +"""Weak ETag + Cache-Control on public GET responses (API 1.1).
2 +
3 +Pure ASGI middleware so it can sit *inside* GZip (the hash is computed on the uncompressed JSON body — gzip output embeds a
4 +timestamp and would never be stable). `If-None-Match` matching the body hash → `304 Not Modified` with an empty body.
5 +Admin, docs and non-200 responses are left untouched."""
6 +from __future__ import annotations
7 +
8 +import hashlib
9 +from typing import Any
10 +
11 +from starlette.types import ASGIApp, Message, Receive, Scope, Send
12 +
13 +PUBLIC_PREFIX = "/api/v1/"
14 +SKIP_PREFIXES = ("/api/v1/admin", "/api/v1/docs", "/api/v1/openapi.json", "/api/v1/api-keys")
15 +CACHE_CONTROL = "public, max-age=60, stale-while-revalidate=300"
16 +MAX_BUFFER = 8 * 1024 * 1024 # bodies above this are passed through unhashed
17 +
18 +
19 +def weak_etag(body: bytes) -> str:
20 + return 'W/"' + hashlib.sha1(body).hexdigest() + '"'
21 +
22 +
23 +def _etags_match(header: str | None, etag: str) -> bool:
24 + if not header:
25 + return False
26 + if header.strip() == "*":
27 + return True
28 + wanted = {t.strip() for t in header.split(",")}
29 + strong = etag.removeprefix("W/")
30 + return etag in wanted or strong in wanted or any((t.removeprefix("W/")) == strong for t in wanted)
31 +
32 +
33 +class ETagMiddleware:
34 + def __init__(self, app: ASGIApp) -> None:
35 + self.app = app
36 +
37 + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
38 + if scope["type"] != "http" or scope.get("method") != "GET":
39 + await self.app(scope, receive, send)
40 + return
41 + path: str = scope.get("path", "")
42 + if not path.startswith(PUBLIC_PREFIX) or path.startswith(SKIP_PREFIXES):
43 + await self.app(scope, receive, send)
44 + return
45 + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])}
46 + inm = headers.get("if-none-match")
47 +
48 + start: Message | None = None
49 + chunks: list[bytes] = []
50 + passthrough = False
51 + size = 0
52 +
53 + async def send_wrapper(message: Message) -> None:
54 + nonlocal start, passthrough, size
55 + if passthrough:
56 + await send(message)
57 + return
58 + if message["type"] == "http.response.start":
59 + start = message
60 + if message.get("status") != 200:
61 + passthrough = True
62 + await send(message)
63 + return
64 + if message["type"] == "http.response.body":
65 + body = message.get("body", b"")
66 + size += len(body)
67 + if size > MAX_BUFFER:
68 + passthrough = True
69 + assert start is not None
70 + await send(start)
71 + for c in chunks:
72 + await send({"type": "http.response.body", "body": c, "more_body": True})
73 + await send(message)
74 + return
75 + chunks.append(body)
76 + if message.get("more_body"):
77 + return
78 + assert start is not None
79 + full = b"".join(chunks)
80 + etag = weak_etag(full)
81 + raw_headers: list[tuple[bytes, bytes]] = [(k, v) for k, v in start.get("headers", []) if k.lower() not in (b"etag", b"cache-control")]
82 + raw_headers.append((b"etag", etag.encode("latin-1")))
83 + raw_headers.append((b"cache-control", CACHE_CONTROL.encode("latin-1")))
84 + if _etags_match(inm, etag):
85 + keep = {b"etag", b"cache-control", b"vary", b"x-content-type-options", b"referrer-policy", b"access-control-allow-origin"}
86 + hdrs = [(k, v) for k, v in raw_headers if k.lower() in keep]
87 + await send({"type": "http.response.start", "status": 304, "headers": hdrs})
88 + await send({"type": "http.response.body", "body": b"", "more_body": False})
89 + return
90 + await send({"type": "http.response.start", "status": 200, "headers": raw_headers})
91 + await send({"type": "http.response.body", "body": full, "more_body": False})
92 + return
93 + await send(message)
94 +
95 + await self.app(scope, receive, send_wrapper)
96 +
97 +
98 +def etag_of(value: Any) -> str:
99 + """Helper for tests and callers holding an already-rendered body."""
100 + return weak_etag(value if isinstance(value, bytes) else str(value).encode())
101 +
102 +
103 +__all__ = ["CACHE_CONTROL", "ETagMiddleware", "etag_of", "weak_etag"]
modified src/aiatlas/api/main.py +20 −7
@@ -1,4 +1,4 @@
1 −"""FastAPI application — `/api/v1`. Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`."""
1 +"""FastAPI application — `/api/v1` (API 1.1). Loopback-only in production (Next.js proxies `/api/v1/*`); docs at `/api/v1/docs`."""
2 2 from __future__ import annotations
3 3
4 4 import asyncio
@@ -15,16 +15,23 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
15 15
16 16 import aiatlas
17 17 from aiatlas.api.common import AtlasJSONResponse
18 +from aiatlas.api.etag import ETagMiddleware
18 19 from aiatlas.api.routers import (
19 20 admin,
21 + admin_quality,
20 22 benchmarks,
21 23 changes,
24 + claims,
22 25 companies,
23 26 compare,
27 + deployments,
24 28 diff,
25 29 entities,
26 30 explore,
31 + families,
32 + graph,
27 33 hardware,
34 + intelligence,
28 35 misc,
29 36 models,
30 37 papers,
@@ -42,13 +49,14 @@ from aiatlas.services import cache
42 49 from aiatlas.services.llm import gateway
43 50
44 51 log = logging.getLogger("aiatlas.api")
52 +API_VERSION = "1.1"
45 53
46 54
47 55 @asynccontextmanager
48 56 async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
49 57 setup_logging(service="aia-api")
50 58 settings.ensure_dirs()
51 − log.info("api started", extra={"version": aiatlas.__version__, "env": settings.app_env, "port": settings.api_port})
59 + log.info("api started", extra={"version": aiatlas.__version__, "api": API_VERSION, "env": settings.app_env, "port": settings.api_port})
52 60 yield
53 61 await cache.close()
54 62 await dispose()
@@ -57,12 +65,14 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
57 65 app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse,
58 66 docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json",
59 67 description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — "
60 − "with provenance and history on every fact. Contract: docs/API.md.")
68 + "with provenance and history on every fact. Contract: docs/API.md (API 1.1, additive over v1).")
61 69
70 +# Middleware order: the LAST added is the OUTERMOST. ETag must be inside GZip (hash of the uncompressed body), so it is added first.
71 +app.add_middleware(ETagMiddleware)
62 72 app.add_middleware(GZipMiddleware, minimum_size=1024)
63 73 _origins = {settings.site_url, "https://www.ai-atlas.co", "https://ai-atlas.co", "http://localhost:8320", "http://127.0.0.1:8320", "http://localhost:8330", "http://127.0.0.1:8330"}
64 74 app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"],
65 − allow_headers=["*"], max_age=600)
75 + allow_headers=["*"], expose_headers=["etag", "cache-control", "x-api-version"], max_age=600)
66 76
67 77
68 78 @app.middleware("http")
@@ -74,6 +84,7 @@ async def security_headers(request: Request, call_next): # type: ignore[no-unty
74 84 return AtlasJSONResponse({"detail": "internal server error"}, status_code=500)
75 85 response.headers["x-content-type-options"] = "nosniff"
76 86 response.headers["referrer-policy"] = "strict-origin-when-cross-origin"
87 + response.headers["x-api-version"] = API_VERSION
77 88 return response
78 89
79 90
@@ -117,7 +128,7 @@ async def _health() -> dict[str, Any]:
117 128 cached = {"reachable": False}
118 129 await cache.cache_set("health:llm", cached, 300)
119 130 llm.update(cached)
120 − return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)}
131 + return {"status": "ok" if db_ok else "degraded", "version": aiatlas.__version__, "api_version": API_VERSION, "db": db_ok, "redis": redis_ok, "llm": llm, "time": datetime.now(UTC)}
121 132
122 133
123 134 @app.get("/health", tags=["health"])
@@ -131,7 +142,9 @@ async def health_v1() -> dict[str, Any]:
131 142
132 143
133 144 # Order matters only where a literal path and a `{param}` path share a prefix — literal routes live in the same router and are declared first.
134 −for r in (stats, search, models, companies, papers, providers, prices, benchmarks, hardware, explore, changes, timeline, compare, diff, sources, misc, entities, admin):
145 +# `intelligence`, `families`, `graph`, `claims`, `deployments` and `misc` mount literal paths under /api/v1 and must precede `entities` (/{slug_or_id}).
146 +for r in (stats, search, models, companies, papers, providers, prices, deployments, benchmarks, hardware, explore, changes, timeline, compare, diff, sources,
147 + intelligence, families, graph, claims, misc, entities, admin, admin_quality):
135 148 app.include_router(r.router)
136 149
137 −__all__ = ["app"]
150 +__all__ = ["API_VERSION", "app"]
modified src/aiatlas/api/routers/admin.py +41 −6
@@ -8,10 +8,10 @@ import time
8 8 from datetime import UTC, datetime
9 9 from typing import Any
10 10
11 −from fastapi import APIRouter, Depends, Query
11 +from fastapi import APIRouter, Depends, Query, Request
12 12 from pydantic import BaseModel, Field
13 13
14 −from aiatlas.api.common import PAGINATION, ApiError, Pagination, page, rate_limit, require_admin
14 +from aiatlas.api.common import ADMIN_DEPENDENCIES, PAGINATION, ApiError, Pagination, audit, client_ip, page
15 15 from aiatlas.config import settings
16 16 from aiatlas.connectors import registry as connector_registry
17 17 from aiatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction
@@ -23,7 +23,32 @@ from aiatlas.services.llm import gateway
23 23 from aiatlas.services.merge import merge_entities
24 24 from aiatlas.services.stats import live_counts
25 25
26 −router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin), Depends(rate_limit("admin"))])
26 +SKIP_AUDIT_GET = ("/api/v1/admin/overview", "/api/v1/admin/infrastructure", "/api/v1/admin/llm/health", "/api/v1/admin/audit")
27 +
28 +
29 +async def audit_request(request: Request) -> None:
30 + """Every admin call leaves a row in `admin_audit_log` (except the dashboard polling GETs). Runs AFTER the rate limit and the token check."""
31 + if request.method == "GET" and request.url.path in SKIP_AUDIT_GET:
32 + return
33 + payload: dict[str, Any] = {"method": request.method, "path": request.url.path}
34 + if request.query_params:
35 + payload["query"] = dict(request.query_params)
36 + if request.method in ("POST", "PATCH", "PUT", "DELETE"):
37 + try:
38 + body = await request.body()
39 + if body:
40 + import orjson
41 +
42 + payload["body"] = orjson.loads(body) if len(body) < 64 * 1024 else {"truncated": True, "bytes": len(body)}
43 + except Exception: # noqa: BLE001
44 + payload["body"] = {"unparsed": True}
45 + target = request.path_params.get("name") or request.path_params.get("review_id") or request.path_params.get("job_id") or request.path_params.get("snap_id") \
46 + or request.path_params.get("doc_id") or request.path_params.get("entity_id") or request.path_params.get("run_id") or request.path_params.get("anomaly_id") \
47 + or request.path_params.get("quarantine_id") or request.path_params.get("a")
48 + await audit(f"{request.method} {request.url.path}", str(target) if target else None, payload, client_ip(request))
49 +
50 +
51 +router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[*ADMIN_DEPENDENCIES, Depends(audit_request)])
27 52 _STARTED = time.time()
28 53 SNAPSHOT_TEXT_LIMIT = 20 * 1024
29 54 HIDDEN = ("raw_path", "text_path")
@@ -422,7 +447,8 @@ async def _accept_claim(conn: Any, claim_id: str) -> dict[str, Any]:
422 447 async def duplicates(type: str | None = Query(None, alias="type"), limit: int = Query(100, ge=1, le=500), threshold: float = Query(0.8, ge=0.3, le=1.0)) -> dict[str, Any]:
423 448 where = "a.entity_type = :t" if type else "true"
424 449 async with connection() as conn:
425 − await execute(conn, "select set_limit(:th)", th=threshold)
450 + # `%` uses the pg_trgm GIN index at the session default threshold (0.3, a superset of any threshold ≥ 0.3); the exact bound is applied
451 + # with similarity() > :th — no set_limit(), which used to leak a GUC change into the pooled connection.
426 452 rows = await fetch_all(conn, f"""
427 453 select a.id as a_id, a.slug as a_slug, a.canonical_name as a_name, a.entity_type, a.first_seen_at as a_first_seen_at, oa.canonical_name as a_org,
428 454 b.id as b_id, b.slug as b_slug, b.canonical_name as b_name, b.first_seen_at as b_first_seen_at, ob.canonical_name as b_org,
@@ -518,9 +544,18 @@ async def infrastructure() -> dict[str, Any]:
518 544 "llm": {"available": gateway.available, "engine": gateway.engine.name}, "scheduler_tick_s": settings.scheduler_tick_s}
519 545
520 546
547 +CACHE_PREFIXES_AFTER_RUN = ("/api/v1/stats", "/api/v1/changes", "/api/v1/benchmarks", "/api/v1/prices", "/api/v1/models", "/api/v1/deployments", "/api/v1/frontier",
548 + "/api/v1/pulse", "/api/v1/open", "/api/v1/families", "/api/v1/timeline", "/api/v1/diff", "facets:", "frontier:")
549 +
550 +
521 551 @router.post("/cache/flush")
522 −async def cache_flush(prefix: str = "") -> dict[str, Any]:
523 − return {"flushed": await cache.cache_invalidate(prefix)}
552 +async def cache_flush(prefix: str = "", after_run: int = Query(0, ge=0, le=1)) -> dict[str, Any]:
553 + """Flush `aia:api:<prefix>*`. `after_run=1` flushes the read paths a connector run invalidates (the scheduler calls `services.cache.cache_invalidate`
554 + with these prefixes after every run — see docs/API.md)."""
555 + if after_run:
556 + flushed = {p: await cache.cache_invalidate(p) for p in CACHE_PREFIXES_AFTER_RUN}
557 + return {"flushed": sum(flushed.values()), "by_prefix": flushed}
558 + return {"flushed": await cache.cache_invalidate(prefix), "prefix": prefix or "*"}
524 559
525 560
526 561 @router.post("/stats/recompute")
added src/aiatlas/api/routers/admin_quality.py +474 −0
@@ -0,0 +1,474 @@
1 +"""Admin workbenches (API 1.1, `x-aia-admin-token`): data-health dashboard, entity resolution, anomalies, extraction debugger, quarantine,
2 +audit log, run rollback. Every call is audited (router dependency shared with `admin.py`). Nothing here deletes data."""
3 +from __future__ import annotations
4 +
5 +import inspect
6 +import re
7 +from collections import defaultdict
8 +from typing import Any
9 +
10 +from fastapi import APIRouter, Depends, Query, Request
11 +from pydantic import BaseModel, Field
12 +
13 +from aiatlas.api.common import ADMIN_DEPENDENCIES, MAIN_ENTITY_TYPES, STATUS_VOCAB, ApiError, audit, client_ip
14 +from aiatlas.api.routers.admin import SNAPSHOT_TEXT_LIMIT, audit_request
15 +from aiatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction
16 +from aiatlas.ids import new_id
17 +from aiatlas.ontology.licenses import normalize_license
18 +from aiatlas.ontology.models import analyze_model_name, variant_key
19 +from aiatlas.ontology.openness import OPENNESS_CATEGORIES
20 +from aiatlas.sdk import archive
21 +from aiatlas.services import cache
22 +from aiatlas.services import merge as merge_service
23 +from aiatlas.services.frontier import all_primary_groups, frontier_model_ids, rank_rows
24 +
25 +router = APIRouter(prefix="/api/v1/admin", tags=["admin-quality"], dependencies=[*ADMIN_DEPENDENCIES, Depends(audit_request)])
26 +SAMPLE = 8
27 +DECISIONS = ("merge", "alias", "variant_of", "family_member", "keep_separate", "defer")
28 +
29 +
30 +async def _count_sample(conn: Any, sql_from_where: str, select: str, order: str = "1", **params: Any) -> dict[str, Any]:
31 + count = await fetch_val(conn, f"select count(*) {sql_from_where}", **params)
32 + sample = await fetch_all(conn, f"select {select} {sql_from_where} order by {order} limit {SAMPLE}", **params)
33 + return {"count": int(count or 0), "sample": sample}
34 +
35 +
36 +# ------------------------------------------------------------------------------------------------------------------ /admin/quality
37 +
38 +
39 +@router.get("/quality")
40 +async def quality_dashboard() -> dict[str, Any]:
41 + out: dict[str, Any] = {}
42 + ent = "e.id, e.slug, e.canonical_name as name, e.entity_type"
43 + async with connection() as conn:
44 + out["duplicate_candidates"] = {
45 + "pending_decisions": await _count_sample(conn, "from resolution_decisions d where d.decision = 'defer' and not d.applied", "d.id, d.a_id, d.b_id, d.note, d.created_at", "d.created_at desc"),
46 + "review_merge_candidates": await _count_sample(conn, "from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending'", "r.id, r.entity_ids, r.reason, r.created_at", "r.created_at desc"),
47 + }
48 + tax_unmapped = await _count_sample(conn, "from taxonomy_mappings t where t.canonical is null", "t.domain, t.raw, t.count, t.last_seen_at", "t.count desc")
49 + bad_open = await _count_sample(conn, "from entities e where e.entity_type = 'model' and e.merged_into is null and coalesce(e.attributes->>'openness', '') <> '' and e.attributes->>'openness' <> all(cast(:cats as text[]))",
50 + f"{ent}, e.attributes->>'openness' as value", "e.updated_at desc", cats=list(OPENNESS_CATEGORIES))
51 + bad_status = await _count_sample(conn, "from entities e where e.entity_type = 'model' and e.merged_into is null and e.status <> all(cast(:st as text[]))", f"{ent}, e.status as value", "e.updated_at desc",
52 + st=[*STATUS_VOCAB, "merged", "archived"])
53 + lic_rows = await fetch_all(conn, "select e.attributes->>'license' as raw, count(*) as n from entities e where e.entity_type = 'model' and e.merged_into is null and e.attributes ? 'license' and not e.attributes ? 'license_key' group by 1")
54 + bad_lic = [{"raw": r["raw"], "models": int(r["n"])} for r in lic_rows if r["raw"] and normalize_license(r["raw"]) is None]
55 + out["taxonomy_violations"] = {"unmapped_taxonomy_rows": tax_unmapped, "openness_unknown_vocab": bad_open, "status_unknown_vocab": bad_status,
56 + "license_unclassified": {"count": sum(x["models"] for x in bad_lic), "sample": sorted(bad_lic, key=lambda x: -x["models"])[:SAMPLE]}}
57 + by_check = await fetch_all(conn, "select check_name, severity, count(*) as n from anomalies where status = 'open' group by 1, 2 order by 3 desc")
58 + out["impossible_values"] = {"count": sum(int(r["n"]) for r in by_check), "by_check": [{**r, "n": int(r["n"])} for r in by_check],
59 + "sample": await fetch_all(conn, "select a.id, a.check_name, a.severity, a.message, a.entity_id, e.slug, a.value, a.last_seen_at from anomalies a left join entities e on e.id = a.entity_id where a.status = 'open' order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit 12")}
60 + out["conflicting_t1_claims"] = await _count_sample(conn, "from claims c join entities e on e.id = c.entity_id where c.status = 'conflicting' and c.tier = 1",
61 + f"c.id as claim_id, c.property, c.value, c.source_url, {ent}", "c.observed_at desc")
62 + base_models = "from entities e where e.entity_type = 'model' and e.merged_into is null"
63 + out["models_without_organization"] = await _count_sample(conn, f"{base_models} and e.organization_id is null", ent, "e.updated_at desc")
64 + out["models_without_release_source"] = await _count_sample(conn, f"{base_models} and not exists (select 1 from claims c where c.entity_id = e.id and c.property = 'release_date' and c.status = 'current')", ent, "e.updated_at desc")
65 + out["models_without_parameters"] = await _count_sample(conn, f"{base_models} and not e.attributes ? 'parameter_count'", ent, "e.updated_at desc")
66 + out["orphan_benchmark_results"] = await _count_sample(conn, "from benchmark_results r join entities m on m.id = r.model_id where r.valid_to is null and (m.merged_into is not null or m.entity_type <> 'model')",
67 + "r.id as result_id, r.benchmark_id, m.id, m.slug, m.canonical_name as name, m.entity_type, m.merged_into", "r.observed_at desc")
68 + out["benchmarks_without_results"] = await _count_sample(conn, "from entities e where e.entity_type = 'benchmark' and e.merged_into is null and not exists (select 1 from benchmark_results r where r.benchmark_id = e.id and r.valid_to is null)",
69 + ent, "e.canonical_name")
70 + out["unresolved_provider_deployments"] = await _count_sample(conn, """from prices p join entities m on m.id = p.model_id join entities pv on pv.id = p.provider_id where p.valid_to is null and p.provider_model_id is not null
71 + and not exists (select 1 from entity_identifiers i where i.entity_id = p.model_id and i.value = p.provider_model_id)""",
72 + "p.id as price_id, p.provider_model_id, m.slug as model, pv.slug as provider", "p.observed_at desc")
73 + names = await fetch_all(conn, "select e.id, e.slug, e.canonical_name, e.attributes->>'hf_repo' as hf_repo, e.attributes->>'is_quantized' as is_q, e.attributes->>'quant_format' as fmt from entities e where e.entity_type = 'model' and e.merged_into is null")
74 + quants = []
75 + for r in names:
76 + a = analyze_model_name(r["hf_repo"] or r["canonical_name"])
77 + if a.is_artifact or r["is_q"] == "true":
78 + quants.append({"id": r["id"], "slug": r["slug"], "name": r["canonical_name"], "hf_repo": r["hf_repo"], "quant_formats": a.quant_formats or ([r["fmt"]] if r["fmt"] else []),
79 + "kind": "quantization" if (a.is_quantized or r["is_q"] == "true") else "conversion"})
80 + out["quantisations_typed_as_models"] = {"count": len(quants), "sample": quants[:SAMPLE]}
81 + out["stale_sources"] = await _count_sample(conn, "from connectors c where c.enabled and c.last_success_at is not null and c.last_success_at < now() - make_interval(secs => 3 * c.interval_seconds)",
82 + "c.name, c.health, c.last_success_at, c.interval_seconds, c.consecutive_failures", "c.last_success_at")
83 + counts = {r["entity_type"]: int(r["n"]) for r in await fetch_all(conn, "select entity_type, count(*) as n from entities where merged_into is null group by 1")}
84 + out["empty_public_categories"] = {"count": sum(1 for t in MAIN_ENTITY_TYPES if not counts.get(t)), "sample": [t for t in MAIN_ENTITY_TYPES if not counts.get(t)]}
85 + out["quarantined_runs_pending"] = await _count_sample(conn, "from quarantined_runs q where q.status = 'pending'", "q.id, q.run_id, q.connector_name, q.reason, q.stats, q.created_at", "q.created_at desc")
86 + out["review_queue_priority"] = await _review_priority(conn)
87 + out["note"] = "Counts are live. Nothing is deleted by these checks; they point at review actions (/admin/entity-resolution, /admin/anomalies, /admin/quarantine)."
88 + return out
89 +
90 +
91 +async def _review_priority(conn: Any) -> list[dict[str, Any]]:
92 + """Homepage-visible items first: frontier models, largest params/context claims, price anomalies, benchmark leaders, major orgs, duplicate canonical models."""
93 + frontier, _ = await frontier_model_ids(conn)
94 + groups = await all_primary_groups(conn)
95 + leaders = {rank_rows(g["rows"], g["higher_is_better"])[0]["model_id"] for g in groups.values() if g["rows"]}
96 + big = {r["id"] for r in await fetch_all(conn, """select id from entities where entity_type = 'model' and merged_into is null and attributes->>'parameter_count' ~ '^[0-9.]+$'
97 + order by (attributes->>'parameter_count')::double precision desc limit 20""")}
98 + big |= {r["id"] for r in await fetch_all(conn, """select id from entities where entity_type = 'model' and merged_into is null and attributes->>'context_length' ~ '^[0-9]+$'
99 + order by (attributes->>'context_length')::bigint desc limit 20""")}
100 + major_orgs = {r["organization_id"] for r in await fetch_all(conn, "select organization_id from entities where entity_type = 'model' and merged_into is null and organization_id is not null group by 1 having count(*) >= 10")}
101 + anomalies = await fetch_all(conn, """select a.id, a.check_name, a.severity, a.message, a.entity_id, e.slug, e.organization_id, e.entity_type from anomalies a left join entities e on e.id = a.entity_id
102 + where a.status = 'open' order by a.last_seen_at desc limit 2000""")
103 + merges = await fetch_all(conn, "select r.id, r.entity_ids, r.reason from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending' limit 500")
104 + items = []
105 + for a in anomalies:
106 + reasons = []
107 + eid = a["entity_id"]
108 + if eid in frontier:
109 + reasons.append("frontier model")
110 + if eid in leaders:
111 + reasons.append("benchmark leader")
112 + if eid in big:
113 + reasons.append("among the largest params/context claims")
114 + if a["check_name"] in ("negative_price", "price_too_high", "zero_output_price", "price_jump_100x", "cached_gt_input_price"):
115 + reasons.append("price anomaly")
116 + if a["organization_id"] in major_orgs:
117 + reasons.append("major organization")
118 + if reasons or a["severity"] == "critical":
119 + items.append({"kind": "anomaly", "id": a["id"], "check": a["check_name"], "severity": a["severity"], "message": a["message"], "entity_id": eid, "slug": a["slug"], "reasons": reasons or ["critical severity"],
120 + "_p": (0 if reasons else 1, {"critical": 0, "warning": 1}.get(a["severity"], 2))})
121 + for m in merges:
122 + ids = list(m["entity_ids"] or [])
123 + hits = [i for i in ids if i in frontier or i in leaders]
124 + if hits:
125 + items.append({"kind": "merge_candidate", "id": m["id"], "entity_ids": ids, "reason": m["reason"], "reasons": ["duplicate involving a frontier model / benchmark leader"], "_p": (0, 0)})
126 + items.sort(key=lambda x: x["_p"])
127 + for x in items:
128 + x.pop("_p")
129 + return items[:50]
130 +
131 +
132 +# ------------------------------------------------------------------------------------------------------------------ /admin/entity-resolution
133 +
134 +
135 +async def _side(conn: Any, eid: str) -> dict[str, Any] | None:
136 + row = await fetch_one(conn, """select e.id, e.slug, e.canonical_name, e.entity_type, e.attributes, e.first_seen_at, e.merged_into, e.family_id, e.canonical_id, e.identity_confidence,
137 + o.slug as org_slug, o.canonical_name as org_name, f.canonical_name as family_name,
138 + (select count(*) from relations r where (r.subject_id = e.id or r.object_id = e.id) and r.valid_to is null) as relations,
139 + (select count(distinct c.source_id) from claims c where c.entity_id = e.id and c.status = 'current') as sources,
140 + (select count(*) from claims c where c.entity_id = e.id and c.status = 'current') as claims,
141 + (select count(*) from prices p where p.model_id = e.id and p.valid_to is null) as prices,
142 + (select count(*) from benchmark_results r where r.model_id = e.id and r.valid_to is null) as results,
143 + (select jsonb_agg(jsonb_build_object('scheme', i.scheme, 'value', i.value) order by i.scheme) from entity_identifiers i where i.entity_id = e.id) as identifiers,
144 + (select jsonb_agg(a.alias order by a.alias) from entity_aliases a where a.entity_id = e.id) as aliases
145 + from entities e left join entities o on o.id = e.organization_id left join entities f on f.id = e.family_id where e.id = :id or e.slug = :id limit 1""", id=eid)
146 + if not row:
147 + return None
148 + a = row["attributes"] or {}
149 + return {"id": row["id"], "slug": row["slug"], "name": row["canonical_name"], "entity_type": row["entity_type"], "organization": row["org_name"], "organization_slug": row["org_slug"],
150 + "family": row["family_name"] or a.get("family"), "parameter_count": a.get("parameter_count"), "active_parameter_count": a.get("active_parameter_count"), "release_date": a.get("release_date"),
151 + "architecture": a.get("architecture"), "model_type": a.get("model_type"), "hf_repo": a.get("hf_repo"), "openness": a.get("openness"), "context_length": a.get("context_length"),
152 + "identifiers": row["identifiers"] or [], "aliases": row["aliases"] or [], "relations": int(row["relations"] or 0), "sources": int(row["sources"] or 0), "claims": int(row["claims"] or 0),
153 + "prices": int(row["prices"] or 0), "results": int(row["results"] or 0), "first_seen_at": row["first_seen_at"], "merged_into": row["merged_into"], "canonical_id": row["canonical_id"],
154 + "identity_confidence": row["identity_confidence"], "variant_key": variant_key(a.get("hf_repo") or row["canonical_name"]), "name_analysis": _analysis(a.get("hf_repo") or row["canonical_name"])}
155 +
156 +
157 +def _analysis(name: str) -> dict[str, Any]:
158 + a = analyze_model_name(name)
159 + return {"base_key": a.base_key, "quant_formats": a.quant_formats, "precision": a.precision, "is_artifact": a.is_artifact, "effort": a.effort, "family_hint": a.family_hint,
160 + "parameter_count": a.parameter_count, "snapshot_date": a.snapshot_date}
161 +
162 +
163 +@router.get("/entity-resolution")
164 +async def entity_resolution(type: str | None = Query("model", alias="type"), status: str = Query("pending"), limit: int = Query(50, ge=1, le=200), threshold: float = Query(0.8, ge=0.3, le=1.0)) -> dict[str, Any]:
165 + """Candidate pairs: review merge_candidates + trigram similarity + same `variant_key` (ontology.models) — side by side."""
166 + pairs: dict[tuple[str, str], dict[str, Any]] = {}
167 + async with connection() as conn:
168 + decided = {(d["a_id"], d["b_id"]): d for d in await fetch_all(conn, "select a_id, b_id, decision, applied, created_at, note from resolution_decisions")}
169 + if status in ("pending", "all"):
170 + for r in await fetch_all(conn, "select r.id, r.entity_ids, r.reason, r.payload from review_queue r where r.kind = 'merge_candidate' and r.status = 'pending' order by r.created_at desc limit :lim", lim=limit * 2):
171 + ids = list(r["entity_ids"] or [])
172 + if len(ids) >= 2:
173 + pairs.setdefault(tuple(sorted(ids[:2])), {"sources": [], "similarity": None})["sources"].append({"kind": "review_merge_candidate", "review_id": r["id"], "reason": r["reason"]})
174 + where = "a.entity_type = :t" if type else "a.entity_type in ('model','company','organization','lab','provider','benchmark','researcher')"
175 + for r in await fetch_all(conn, f"""select a.id as a_id, b.id as b_id, similarity(a.canonical_name, b.canonical_name) as sim from entities a join entities b
176 + on b.entity_type = a.entity_type and b.id > a.id and a.canonical_name % b.canonical_name
177 + where {where} and a.merged_into is null and b.merged_into is null and similarity(a.canonical_name, b.canonical_name) > :th order by sim desc limit :lim""",
178 + t=type, th=threshold, lim=limit * 2):
179 + p = pairs.setdefault(tuple(sorted((r["a_id"], r["b_id"]))), {"sources": [], "similarity": None})
180 + p["similarity"] = round(float(r["sim"]), 3)
181 + p["sources"].append({"kind": "trigram_similarity", "similarity": round(float(r["sim"]), 3)})
182 + if type in (None, "model"):
183 + names = await fetch_all(conn, "select id, canonical_name, attributes->>'hf_repo' as hf from entities where entity_type = 'model' and merged_into is null")
184 + by_key: dict[str, list[str]] = defaultdict(list)
185 + for n in names:
186 + k = variant_key(n["hf"] or n["canonical_name"])
187 + if k:
188 + by_key[k].append(n["id"])
189 + for k, ids in by_key.items():
190 + if 2 <= len(ids) <= 6:
191 + for i in range(len(ids)):
192 + for j in range(i + 1, len(ids)):
193 + pairs.setdefault(tuple(sorted((ids[i], ids[j]))), {"sources": [], "similarity": None})["sources"].append({"kind": "same_variant_key", "variant_key": k})
194 + items = []
195 + for (a, b), p in pairs.items():
196 + d = decided.get((a, b)) or decided.get((b, a))
197 + if status == "pending" and d and d["decision"] != "defer":
198 + continue
199 + if status == "decided" and not d:
200 + continue
201 + sa, sb = await _side(conn, a), await _side(conn, b)
202 + if not sa or not sb:
203 + continue
204 + items.append({"a": sa, "b": sb, "signals": p["sources"], "similarity": p["similarity"], "same_variant_key": sa["variant_key"] == sb["variant_key"] and bool(sa["variant_key"]),
205 + "same_organization": sa["organization_slug"] == sb["organization_slug"] and sa["organization_slug"] is not None, "decision": d,
206 + "hint": ("artifact of the other" if sa["name_analysis"]["is_artifact"] != sb["name_analysis"]["is_artifact"] else
207 + "effort variant" if (sa["name_analysis"]["effort"] or sb["name_analysis"]["effort"]) else "possible duplicate")})
208 + if len(items) >= limit:
209 + break
210 + items.sort(key=lambda x: (-len(x["signals"]), -(x["similarity"] or 0)))
211 + return {"items": items, "total": len(items), "threshold": threshold, "status": status, "decisions": list(DECISIONS),
212 + "note": "Signals are independent (review queue, trigram similarity, shared variant_key); nothing is merged until POST /admin/entity-resolution/{a}/{b}."}
213 +
214 +
215 +class ResolutionBody(BaseModel):
216 + decision: str = Field(..., pattern="^(merge|alias|variant_of|family_member|keep_separate|defer)$")
217 + note: str | None = Field(None, max_length=2000)
218 +
219 +
220 +@router.post("/entity-resolution/{a}/{b}")
221 +async def resolve_pair(a: str, b: str, body: ResolutionBody, request: Request) -> dict[str, Any]:
222 + """Persist the decision, then apply it (merge_entities with `mode=` when the service supports it). `a` is folded INTO `b`."""
223 + applied = False
224 + effect: dict[str, Any] | None = None
225 + async with transaction() as conn:
226 + ea = await fetch_one(conn, "select id, entity_type, canonical_name, merged_into from entities where id = :k or slug = :k limit 1", k=a)
227 + eb = await fetch_one(conn, "select id, entity_type, canonical_name, merged_into from entities where id = :k or slug = :k limit 1", k=b)
228 + if not ea or not eb:
229 + raise ApiError(404, "entity a or b not found")
230 + if ea["id"] == eb["id"]:
231 + raise ApiError(400, "a and b are the same entity")
232 + did = new_id("review")
233 + sig = inspect.signature(merge_service.merge_entities)
234 + supports_mode = "mode" in sig.parameters
235 + try:
236 + if body.decision == "merge":
237 + effect = await (merge_service.merge_entities(conn, ea["id"], eb["id"], mode="merge") if supports_mode else merge_service.merge_entities(conn, ea["id"], eb["id"]))
238 + applied = True
239 + elif body.decision == "variant_of":
240 + if supports_mode:
241 + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="variant_of")
242 + else:
243 + await execute(conn, "update entities set canonical_id = :b where id = :a", a=ea["id"], b=eb["id"])
244 + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"])
245 + effect["fallback"] = "canonical_id set then folded with merge_entities (service has no mode= yet)"
246 + applied = True
247 + elif body.decision == "alias":
248 + if supports_mode:
249 + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="alias")
250 + else:
251 + from aiatlas.ids import normalize_alias
252 +
253 + await execute(conn, "insert into entity_aliases (entity_id, alias, alias_norm, kind) values (:t, :al, :n, 'alias') on conflict (entity_id, alias_norm) do nothing",
254 + t=eb["id"], al=ea["canonical_name"], n=normalize_alias(ea["canonical_name"]))
255 + effect = {"alias_added": ea["canonical_name"], "to": eb["id"], "fallback": "alias row only (service has no mode= yet); entities kept separate"}
256 + applied = True
257 + elif body.decision == "family_member":
258 + if supports_mode:
259 + effect = await merge_service.merge_entities(conn, ea["id"], eb["id"], mode="family_member")
260 + applied = True
261 + elif eb["entity_type"] == "model_family":
262 + await execute(conn, "update entities set family_id = :b, updated_at = now() where id = :a", a=ea["id"], b=eb["id"])
263 + effect = {"family_id": eb["id"]}
264 + applied = True
265 + else:
266 + raise ApiError(400, "family_member requires b to be a model_family entity")
267 + except (ValueError, LookupError) as exc:
268 + raise ApiError(400, str(exc)) from exc
269 + await execute(conn, """insert into resolution_decisions (id, a_id, b_id, decision, actor, note, payload, applied) values (:id, :a, :b, :d, 'admin', :n, cast(:p as jsonb), :ap)
270 + on conflict (a_id, b_id, decision) do update set note = excluded.note, payload = excluded.payload, applied = excluded.applied, created_at = now()""",
271 + id=did, a=ea["id"], b=eb["id"], d=body.decision, n=body.note, p=jsonb({"effect": effect, "mode_supported": supports_mode}), ap=applied)
272 + if applied:
273 + await execute(conn, "update review_queue set status = 'approved', resolved_at = now(), resolution = cast(:r as jsonb) where kind = 'merge_candidate' and status = 'pending' and entity_ids @> cast(:ids as text[])",
274 + r=jsonb({"via": "entity-resolution", "decision": body.decision}), ids=[ea["id"], eb["id"]])
275 + if applied:
276 + await cache.cache_invalidate()
277 + await audit("entity-resolution", f"{ea['id']}→{eb['id']}", {"decision": body.decision, "applied": applied, "effect": effect}, client_ip(request))
278 + return {"ok": True, "a": ea["id"], "b": eb["id"], "decision": body.decision, "applied": applied, "effect": effect}
279 +
280 +
281 +# ------------------------------------------------------------------------------------------------------------------ /admin/anomalies
282 +
283 +
284 +@router.get("/anomalies")
285 +async def anomalies(status: str = Query("open"), severity: str | None = None, check: str | None = None, limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0)) -> dict[str, Any]:
286 + where = ["true"] if status == "all" else ["a.status = :st"]
287 + params: dict[str, Any] = {"st": status, "lim": limit, "off": offset}
288 + if severity:
289 + where.append("a.severity = :sev")
290 + params["sev"] = severity
291 + if check:
292 + where.append("a.check_name = :chk")
293 + params["chk"] = check
294 + w = " and ".join(where)
295 + async with connection() as conn:
296 + rows = await fetch_all(conn, f"""select a.*, e.slug, e.canonical_name as entity_name, e.entity_type from anomalies a left join entities e on e.id = a.entity_id where {w}
297 + order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit :lim offset :off""", **params)
298 + total = await fetch_val(conn, f"select count(*) from anomalies a where {w}", **{k: v for k, v in params.items() if k not in ("lim", "off")})
299 + by = await fetch_all(conn, "select check_name, severity, status, count(*) as n from anomalies group by 1, 2, 3 order by 4 desc")
300 + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset, "by_check": [{**r, "n": int(r["n"])} for r in by]}
301 +
302 +
303 +class AnomalyBody(BaseModel):
304 + status: str = Field(..., pattern="^(resolved|ignored|open)$")
305 + note: str | None = Field(None, max_length=2000)
306 +
307 +
308 +@router.post("/anomalies/{anomaly_id}")
309 +async def anomaly_action(anomaly_id: str, body: AnomalyBody) -> dict[str, Any]:
310 + async with transaction() as conn:
311 + row = await fetch_one(conn, "update anomalies set status = :st, resolution = :n, resolved_at = case when :st = 'open' then null else now() end where id = :id returning id, check_name, status",
312 + st=body.status, n=body.note, id=anomaly_id)
313 + if not row:
314 + raise ApiError(404, "anomaly not found")
315 + return {"ok": True, **row}
316 +
317 +
318 +# ------------------------------------------------------------------------------------------------------------------ /admin/extractions/{snapshot_id}
319 +
320 +_NUM_FORMATS = (lambda v: str(v), lambda v: f"{int(v):,}" if float(v).is_integer() else f"{v:,}", lambda v: f"{int(v)}" if float(v).is_integer() else f"{v}",
321 + lambda v: f"{v / 1e9:g}B" if abs(v) >= 1e9 else f"{v / 1e6:g}M" if abs(v) >= 1e6 else f"{v / 1e3:g}K" if abs(v) >= 1e3 else str(v),
322 + lambda v: f"{v / 1e9:.1f}B" if abs(v) >= 1e9 else f"{v / 1e6:.1f}M" if abs(v) >= 1e6 else f"{v / 1e3:.0f}K" if abs(v) >= 1e3 else str(v),
323 + lambda v: f"{v / 1e3:g}k" if abs(v) >= 1e3 else str(v), lambda v: f"${v:g}", lambda v: f"${v:.2f}")
324 +
325 +
326 +def _locate(text: str, value: Any) -> dict[str, Any]:
327 + cands: list[str] = []
328 + if isinstance(value, bool):
329 + cands = [str(value).lower()]
330 + elif isinstance(value, (int, float)):
331 + for f in _NUM_FORMATS:
332 + try:
333 + cands.append(f(float(value)))
334 + except Exception: # noqa: BLE001
335 + pass
336 + elif isinstance(value, str) and value.strip():
337 + cands = [value.strip()]
338 + for c in dict.fromkeys(cands):
339 + m = re.search(re.escape(c), text, flags=re.IGNORECASE)
340 + if m:
341 + s, e = max(0, m.start() - 80), min(len(text), m.end() + 80)
342 + return {"found": True, "offset": m.start(), "match": text[m.start():m.end()], "context": text[s:e]}
343 + return {"found": False, "tried": cands[:6]}
344 +
345 +
346 +@router.get("/extractions/{snap_id}")
347 +async def extraction_debugger(snap_id: str, text_limit: int = Query(SNAPSHOT_TEXT_LIMIT, ge=0, le=SNAPSHOT_TEXT_LIMIT)) -> dict[str, Any]:
348 + async with connection() as conn:
349 + snap = await fetch_one(conn, """select s.id, s.document_id, s.run_id, s.url, s.final_url, s.observed_at, s.http_status, s.content_type, s.content_hash, s.byte_size, s.text_hash, s.structured, s.diff,
350 + s.parser_version, s.connector_version, s.transport, s.changed, s.processing_status, s.raw_path is not null as has_raw, s.text_path, d.url as document_url,
351 + d.doc_type, d.connector_name, d.entity_id, d.title, e.slug as entity_slug, e.canonical_name as entity_name
352 + from snapshots s join documents d on d.id = s.document_id left join entities e on e.id = d.entity_id where s.id = :id""", id=snap_id)
353 + if not snap:
354 + raise ApiError(404, "snapshot not found")
355 + prev = await fetch_one(conn, "select id, observed_at, content_hash from snapshots where document_id = :d and observed_at < :t and changed order by observed_at desc limit 1", d=snap["document_id"], t=snap["observed_at"])
356 + claims = await fetch_all(conn, "select c.id, c.entity_id, e.slug as entity_slug, c.property, c.value, c.value_raw, c.unit, c.status, c.confidence, c.extractor, c.tier, c.observed_at from claims c left join entities e on e.id = c.entity_id where c.snapshot_id = :id order by e.slug, c.property limit 1000", id=snap_id)
357 + relations = await fetch_all(conn, "select r.id, r.subject_id, r.predicate, r.object_id, r.attributes, r.valid_to from relations r where r.snapshot_id = :id limit 500", id=snap_id)
358 + results = await fetch_all(conn, "select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.config, r.config_key, r.trust_level, r.valid_to from benchmark_results r where r.snapshot_id = :id limit 500", id=snap_id)
359 + prices = await fetch_all(conn, "select p.id, p.model_id, p.provider_id, p.provider_model_id, p.input_per_mtok, p.output_per_mtok, p.valid_from, p.valid_to from prices p where p.snapshot_id = :id limit 500", id=snap_id)
360 + events = await fetch_all(conn, "select id, entity_id, event_type, category, property, summary, importance, occurred_at, is_backfill, group_key from change_events where snapshot_id = :id order by occurred_at desc limit 300", id=snap_id)
361 + llm = await fetch_all(conn, "select id, task_type, stage, model, status, input_tokens, output_tokens, duration_ms, error, created_at from llm_jobs where snapshot_id = :id order by created_at desc limit 50", id=snap_id)
362 + touched = {c["entity_id"] for c in claims} | {r["subject_id"] for r in relations} | {r["object_id"] for r in relations} | {r["model_id"] for r in results} | {p["model_id"] for p in prices}
363 + touched.discard(None)
364 + cands = await fetch_all(conn, "select id, slug, canonical_name, entity_type, merged_into, identity_confidence from entities where id = any(cast(:ids as text[])) order by canonical_name", ids=list(touched)) if touched else []
365 + text, text_error, text_len = None, None, 0
366 + if snap.get("text_path"):
367 + try:
368 + full = archive.load_text(snap["text_path"])
369 + text_len = len(full)
370 + text = full[:text_limit]
371 + except OSError as exc:
372 + text_error = f"cleaned text unavailable ({exc.__class__.__name__})"
373 + full = ""
374 + else:
375 + full = ""
376 + spans = []
377 + for c in claims:
378 + v = c["value"]
379 + if isinstance(v, (int, float, str, bool)) and not (isinstance(v, str) and len(v) > 200):
380 + spans.append({"claim_id": c["id"], "property": c["property"], "value": v, **(_locate(full, v) if full else {"found": False, "reason": "no cleaned text"})})
381 + out = {k: v for k, v in snap.items() if k != "text_path"}
382 + out.update({"has_text": bool(snap.get("text_path")), "text": text, "text_chars": text_len, "text_truncated": text_len > text_limit, "text_error": text_error,
383 + "previous_snapshot": prev, "diff": snap.get("diff"), "claims": claims, "relations": relations, "results": results, "prices": prices, "events": events, "llm_jobs": llm,
384 + "entity_candidates": cands, "spans": spans, "spans_found": sum(1 for s in spans if s.get("found")),
385 + "note": "spans are a best-effort textual search of each claim value in the cleaned text (several number formats tried); not-found is reported honestly, never inferred."})
386 + return out
387 +
388 +
389 +# ------------------------------------------------------------------------------------------------------------------ /admin/quarantine
390 +
391 +
392 +@router.get("/quarantine")
393 +async def quarantine(status: str = Query("pending"), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0)) -> dict[str, Any]:
394 + where = "true" if status == "all" else "q.status = :st"
395 + async with connection() as conn:
396 + rows = await fetch_all(conn, f"select q.id, q.run_id, q.connector_name, q.reason, q.stats, q.status, q.created_at, q.resolved_at, q.resolved_by, jsonb_array_length(q.facts) as facts from quarantined_runs q where {where} order by q.created_at desc limit :lim offset :off",
397 + st=status, lim=limit, off=offset)
398 + total = await fetch_val(conn, f"select count(*) from quarantined_runs q where {where}", st=status)
399 + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset}
400 +
401 +
402 +class QuarantineBody(BaseModel):
403 + action: str = Field(..., pattern="^(release|discard)$")
404 + note: str | None = Field(None, max_length=2000)
405 +
406 +
407 +@router.post("/quarantine/{quarantine_id}")
408 +async def quarantine_action(quarantine_id: str, body: QuarantineBody) -> dict[str, Any]:
409 + try:
410 + from aiatlas.services import canonical
411 + except ImportError:
412 + canonical = None # type: ignore[assignment]
413 + fn = getattr(canonical, f"{body.action}_quarantine", None) if canonical else None
414 + if fn is None:
415 + raise ApiError(501, f"quarantine {body.action} is not available yet: services.canonical.{body.action}_quarantine is missing (Stream A)")
416 + async with transaction() as conn:
417 + row = await fetch_one(conn, "select * from quarantined_runs where id = :id for update", id=quarantine_id)
418 + if not row:
419 + raise ApiError(404, "quarantined run not found")
420 + if row["status"] != "pending":
421 + raise ApiError(409, f"quarantined run already {row['status']}")
422 + result = await fn(conn, quarantine_id) if inspect.iscoroutinefunction(fn) else fn(conn, quarantine_id)
423 + await execute(conn, "update quarantined_runs set status = :st, resolved_at = now(), resolved_by = 'admin' where id = :id and status = 'pending'",
424 + st="released" if body.action == "release" else "discarded", id=quarantine_id)
425 + await cache.cache_invalidate()
426 + return {"ok": True, "id": quarantine_id, "action": body.action, "result": result}
427 +
428 +
429 +# ------------------------------------------------------------------------------------------------------------------ /admin/audit · rollback
430 +
431 +
432 +@router.get("/audit")
433 +async def audit_log(limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0), action: str | None = None) -> dict[str, Any]:
434 + where = "action ilike :a" if action else "true"
435 + async with connection() as conn:
436 + rows = await fetch_all(conn, f"select id, actor, action, target, payload, ip, created_at from admin_audit_log where {where} order by id desc limit :lim offset :off", a=f"%{action}%" if action else None, lim=limit, off=offset)
437 + total = await fetch_val(conn, f"select count(*) from admin_audit_log where {where}", a=f"%{action}%" if action else None)
438 + return {"items": rows, "total": int(total or 0), "limit": limit, "offset": offset}
439 +
440 +
441 +@router.post("/runs/{run_id}/rollback")
442 +async def rollback_run(run_id: str, request: Request) -> dict[str, Any]:
443 + """Undo one connector run WITHOUT deleting: retract its claims, close its relations/prices/results, flag its events as back-fill (meta.rolled_back)."""
444 + async with transaction() as conn:
445 + run = await fetch_one(conn, "select id, connector_name, status from connector_runs where id = :id", id=run_id)
446 + n: dict[str, int] = {}
447 + n["claims_retracted"] = int(await fetch_val(conn, "with u as (update claims set status = 'retracted', valid_to = coalesce(valid_to, now()) where run_id = :r and status <> 'retracted' returning 1) select count(*) from u", r=run_id) or 0)
448 + n["relations_closed"] = int(await fetch_val(conn, "with u as (update relations set valid_to = now() where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0)
449 + n["prices_closed"] = int(await fetch_val(conn, "with u as (update prices set valid_to = now() where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0)
450 + n["results_closed"] = int(await fetch_val(conn, "with u as (update benchmark_results set valid_to = now(), is_current = false where run_id = :r and valid_to is null returning 1) select count(*) from u", r=run_id) or 0)
451 + n["events_flagged"] = int(await fetch_val(conn, """with u as (update change_events set is_backfill = true, meta = meta || '{"rolled_back": true}'::jsonb where run_id = :r
452 + and not coalesce((meta->>'rolled_back')::boolean, false) returning 1) select count(*) from u""", r=run_id) or 0)
453 + # re-materialise attributes whose current claim was retracted: fall back to the best remaining claim
454 + affected = await fetch_all(conn, "select distinct entity_id, property from claims where run_id = :r and status = 'retracted'", r=run_id)
455 + restored = 0
456 + for a in affected:
457 + prev = await fetch_one(conn, "select id, value from claims where entity_id = :e and property = :p and status in ('superseded','current') order by tier, valid_from desc limit 1", e=a["entity_id"], p=a["property"])
458 + if prev:
459 + await execute(conn, "update claims set status = 'current', valid_to = null where id = :id", id=prev["id"])
460 + await execute(conn, "update entities set attributes = attributes || jsonb_build_object(:p, cast(:v as jsonb)), updated_at = now() where id = :e", p=a["property"], v=jsonb(prev["value"]), e=a["entity_id"])
461 + restored += 1
462 + else:
463 + await execute(conn, "update entities set attributes = attributes - :p, provenance = provenance - :p, updated_at = now() where id = :e", p=a["property"], e=a["entity_id"])
464 + n["attributes_restored"] = restored
465 + if run:
466 + await execute(conn, "update connector_runs set meta = meta || cast(:m as jsonb) where id = :id", m=jsonb({"rolled_back": True, "rollback_counts": n}), id=run_id)
467 + if not run and not any(n.values()):
468 + raise ApiError(404, f"no run or facts found for run_id {run_id!r}")
469 + await cache.cache_invalidate()
470 + await audit("rollback", run_id, {"counts": n, "connector": run["connector_name"] if run else None}, client_ip(request))
471 + return {"ok": True, "run_id": run_id, "connector": run["connector_name"] if run else None, "counts": n, "note": "nothing deleted: claims retracted, live rows closed with valid_to, events flagged is_backfill + meta.rolled_back"}
472 +
473 +
474 +__all__ = ["DECISIONS", "router"]
modified src/aiatlas/api/routers/benchmarks.py +223 −30
Large diff (306 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/changes.py +99 −24
Large diff (192 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/api/routers/claims.py +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/compare.py +74 −26
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/api/routers/deployments.py +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/diff.py +55 −16
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/entities.py +66 −6
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/api/routers/families.py +145 −0
Large diff (145 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/api/routers/graph.py +78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/hardware.py +30 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/api/routers/intelligence.py +565 −0
Large diff (565 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/misc.py +127 −9
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/models.py +130 −23
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/prices.py +100 −17
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/providers.py +45 −4
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/search.py +53 −46
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/stats.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/api/routers/timeline.py +15 −7
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/services/cost.py +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/services/finder.py +255 −0
Large diff (255 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/services/frontier.py +303 −0
Large diff (303 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/services/hardware_fit.py +107 −12
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added src/aiatlas/services/pareto.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/services/search.py +369 −61
Large diff (504 lines) is not loaded by default to keep the page fast.Load diff
modified src/aiatlas/services/stats.py +36 −4
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_api_services.py +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_api_v11.py +284 −0
Large diff (285 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme