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>
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 | Base: `/api/v1` (FastAPI, `src/aiatlas/api`). JSON, UTF-8, ISO-8601 UTC timestamps, numbers as numbers (Postgres aggregates may arrive as | 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. | 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 | −Admin routes require header `x-aia-admin-token: <AIA_ADMIN_TOKEN>`. OpenAPI at `/api/v1/docs`. Errors: `{"detail": "..."}` with 400/404/429/503. | 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 | Never expose credentials, internal hostnames, raw archive content or `raw_path`s. | 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 | ## Shared shapes | 22 | ## Shared shapes | ||
| 9 | 23 | ||||
| 10 | ```ts | 24 | ```ts | ||
@@ -19,132 +33,188 @@ type Provenance = Record<string, { source_id: string | null; source_name?: strin | |||||
| 19 | confidence: string; extractor: string; unit?: string }> | 33 | confidence: string; extractor: string; unit?: string }> | ||
| 20 | type ChangeEvent = { id: string; event_type: string; category: string; property: string | null; old_value: unknown; new_value: unknown; | 34 | type ChangeEvent = { id: string; event_type: string; category: string; property: string | null; old_value: unknown; new_value: unknown; | ||
| 21 | summary: string; importance: 0 | 1 | 2 | 3; observed_at: string; effective_at: string | null; source_url: string | null; connector_name: string | null; | 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 | type Price = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; input_per_mtok: Num; output_per_mtok: Num; | 38 | type Price = { id: string; model: EntitySummary; provider: EntitySummary; provider_model_id: string | null; input_per_mtok: Num; output_per_mtok: Num; | ||
| 24 | 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; | 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 | 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 } | 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 | type BenchmarkResult = { id: string; model: EntitySummary; benchmark: EntitySummary; score: number; metric: string | null; unit: string | null; | 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 | type Claim = { id: string; property: string; value: unknown; unit: string | null; tier: number; confidence: string; status: string; extractor: string; | 44 | type Claim = { id: string; property: string; value: unknown; unit: string | null; tier: number; confidence: string; status: string; extractor: string; | ||
| 29 | observed_at: string; effective_at: string | null; valid_from: string; valid_to: string | null; source_url: string | null; source_name: string | null } | 45 | observed_at: string; effective_at: string | null; valid_from: string; valid_to: string | null; source_url: string | null; source_name: string | null } | ||
| 30 | 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 } | 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 | | route | returns | | 67 | | route | returns | | ||
| 36 | |---|---| | 68 | |---|---| | ||
| 37 | −| `GET /health` | `{ status: "ok"|"degraded", version, db: bool, redis: bool, llm: { available, reachable? }, time }` | | 69 | +| `GET /health` | `{ status: "ok"|"degraded", version, api_version: "1.1", 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** | | 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 | | `GET /stats/history?days=90` | `{ items: { day: string; counts: object }[] }` | | 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 | | `GET /search/suggest?q=` | `{ items: { id, entity_type, slug, name, organization_name }[] }` (≤ 8, prefix, fast) | | 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) | | 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) | | ||
| 43 | −| `GET /entities/{slug}/timeline?limit=50&before=` | `{ items: ChangeEvent[] }` (entity's own events + events of entities it develops for companies) | | 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 | | `GET /entities/{slug}/history?property=` | `{ items: Claim[] }` — full claim history (all statuses), newest first | | 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 | | 77 | +| `GET /entities/{slug}/asof?date=YYYY-MM-DD` | `{ existed: bool, first_seen_at, date, attributes, claims: Claim[] }` | | ||
| 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) | | 78 | +| `GET /entities/{slug}/graph?depth=1&limit=80` | `{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] }` | | ||
| 47 | | `GET /entities/{slug}/sources` | `{ items: SourceRef[] }` | | 79 | | `GET /entities/{slug}/sources` | `{ items: SourceRef[] }` | | ||
| 48 | −| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family / shared relations) | | 80 | +| `GET /entities/{slug}/related?limit=` | `{ items: EntitySummary[] }` (same org / same family (`family_id` or label) / 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 }` | | 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`. | | ||
| 50 | −| `GET /companies?q=&country=&kind=&sort=(models|name|updated|quality)&limit=&offset=` | `Page<EntitySummary & { model_count: number; paper_count: number }>` (+`facets`: countries, kinds) | | 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` | | ||
| 51 | −| `GET /papers?q=&category=&org=&since=&until=&sort=(published|updated)&limit=&offset=` | `Page<EntitySummary>` | | 83 | +| `GET /papers?…` | `Page<EntitySummary>` | | ||
| 52 | −| `GET /providers` | `{ items: (EntitySummary & { model_count: number; price_count: number; min_input_per_mtok: Num; min_output_per_mtok: Num })[] }` | | 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 | | ||
| 53 | −| `GET /prices?model=&provider=&sort=(input|output|model|provider|observed)&limit=&offset=¤t=1` | `Page<Price>` | | 85 | +| `GET /prices?model=&provider=&sort=&limit=&offset=¤t=1&family=&org=&modality=` | `Page<Price>` — **1.1:** `family`, `org`, `modality` filters; `sort=cheapest_frontier` (frontier models only, cheapest output first, + `methodology`) | | ||
| 54 | −| `GET /prices/history?model=&provider=` | `{ items: Price[] }` (all rows incl. closed, oldest first) | | 86 | +| `GET /prices/history?model=&provider=` | `{ items: Price[] }` | | ||
| 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 | | 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 | | ||
| 56 | −| `GET /benchmarks` | `{ items: (EntitySummary & { result_count: number; model_count: number; top: { model: EntitySummary; score: number } | null })[] }` | | 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 | | ||
| 57 | −| `GET /benchmarks/{slug}/results?limit=&offset=&config=` | `Page<BenchmarkResult>` sorted by score (respecting `higher_is_better`), current rows only unless `history=1` | | 89 | +| `GET /benchmarks/{slug}` | `EntityDetail` + `family, variant, metric, direction, category, groups, primary_group, result_count, model_count, leaderboard (top 25), trust_mix` | | ||
| 58 | −| `GET /benchmarks/{slug}/history?model=` | `{ items: BenchmarkResult[] }` over time | | 90 | +| `GET /benchmarks/{slug}/results?limit=&offset=&config=&history=&metric=&config_key=` | `Page<BenchmarkResult>` — v1 behaviour (one row per result) + `metric` / `config_key` filters | | ||
| 59 | −| `GET /hardware?kind=&manufacturer=&min_memory=&sort=` | `Page<EntitySummary>` | | 91 | +| `GET /benchmarks/{slug}/history?model=` | `{ benchmark, items: BenchmarkResult[] }` | | ||
| 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 | | 92 | +| `GET /hardware?…` | `Page<EntitySummary>` + facets | | ||
| 61 | −| `GET /explore/types` | `{ items: { entity_type, count, label }[] }` | | 93 | +| `GET /hardware/fit?memory_gb=&quant=&context=&limit=&openness=` | v1 estimate (unchanged) | | ||
| 62 | −| `GET /explore/{type}?q=&org=&sort=&limit=&offset=` | `Page<EntitySummary>` generic listing for any type (datasets, frameworks, tools, repositories, regulation…) | | 94 | +| `GET /explore/types` · `GET /explore/{type}` | unchanged (`artifact`, `model_family` appear as types once present) | | ||
| 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` | | 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` | | ||
| 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) | | 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 | | ||
| 65 | −| `GET /changes/categories?days=7` | `{ items: { category, event_type, count }[] }` | | 97 | +| `GET /changes/categories?days=7&include_backfill=0` | `{ items: { category, event_type, count }[] }` | | ||
| 66 | −| `GET /timeline?entity=&year=&category=&limit=` | `{ items: { month: string; events: ChangeEvent[] }[] }` grouped by month; global when no entity | | 98 | +| `GET /timeline?entity=&year=&category=&importance_min=&limit=&include_backfill=0&date_field=occurred` | `{ items: { month, count, events }[], total, date_field, include_backfill }` | | ||
| 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) | | 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) | | ||
| 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 }` | | 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` | | ||
| 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) | | 101 | +| `GET /sources` | unchanged | | ||
| 70 | −| `GET /methodology` | `{ metrics: metric_definitions[], confidence_levels, tiers, event_types, extractors }` | | 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` | | ||
| 71 | −| `GET /trending?days=7&limit=12` | `{ items: (EntitySummary & { views: number })[] }` from `page_views` | | 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 | | ||
| 72 | −| `POST /views` `{ path }` | `{ ok: true }` (beacon; 1 req/s/IP) | | 104 | +| `POST /views` · `GET /sitemap` · `GET /api-keys/me` | unchanged | | ||
| 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 | | ||||
| 75 | 105 | ||||
| 76 | ### `EntityDetail` | 106 | ### `EntityDetail` | ||
| 77 | 107 | ||||
| 78 | ```ts | 108 | ```ts | ||
| 79 | EntitySummary & { | 109 | EntitySummary & { | ||
| 80 | − attributes: Record<string, unknown> // full | 110 | + attributes: Record<string, unknown>; provenance: Provenance; aliases: string[]; identifiers: { scheme, value }[] | ||
| 81 | − provenance: Provenance | 111 | + relations: { predicate, direction: "out"|"in", items: EntitySummary[], total }[]; sources: SourceRef[]; timeline: ChangeEvent[] // timeline: occurred_at order, not back-filled | ||
| 82 | − aliases: string[]; identifiers: { scheme: string; value: string }[] | 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 | ||
| 83 | − relations: { predicate: string; direction: "out" | "in"; items: EntitySummary[]; total: number }[] // grouped, ≤ 24 per group | 113 | + // model | ||
| 84 | − sources: SourceRef[] // documents describing this entity (deduped by URL) | 114 | + prices?: Price[]; price_history?: Price[]; results?: BenchmarkResult[]; lineage?: { ancestors, descendants, quantizations }; providers?: EntitySummary[] | ||
| 85 | − timeline: ChangeEvent[] // latest 30 | 115 | + hardware_fit?: {…}[]; hardware_fit_assumptions?: string[]; papers?: EntitySummary[]; repositories?: EntitySummary[] | ||
| 86 | − quality: {...}; counts: {...} | 116 | + family?: EntitySummary | { id: null, name, canonical: false, note } | null // 1.1 | ||
| 87 | − // type-specific blocks (present only when relevant) | 117 | + artifacts?: { items: { kind, items: (EntitySummary & { artifact_kind })[], count }[], total } // 1.1, grouped by artifact_kind | ||
| 88 | − prices?: Price[] // current rows across providers (model) or for this provider (provider) | 118 | + deployments?: Deployment[] // 1.1 | ||
| 89 | − price_history?: Price[] // closed + current, oldest first (model) | 119 | + identity?: { canonical_model: true, official_checkpoints: string[], official_artifacts, third_party_artifacts, provider_deployments, folded_variants, api_aliases: string[], note } | ||
| 90 | − results?: BenchmarkResult[] // model: its results · benchmark: leaderboard top 100 | 120 | + licence?: LicenseInfo & { raw, url_observed } | { key: null, raw, note } // 1.1 | ||
| 91 | − lineage?: { ancestors: EntitySummary[]; descendants: EntitySummary[]; quantizations: EntitySummary[] } // model | 121 | + openness?: { category, raw, label, definition, dimensions: Record<dim, boolean|null>, note } // 1.1 | ||
| 92 | − providers?: EntitySummary[] // model | 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 | ||
| 93 | − hardware_fit?: { hardware: EntitySummary; quantization: string; estimated_memory_gb: number; fits: boolean }[] // model, ESTIMATED | 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 } | ||
| 94 | − models?: Page<EntitySummary> // company / provider / hardware(runnable) | 124 | + family_id?: string | null; identity_confidence?: "high"|"medium"|"low" | ||
| 95 | − papers?: EntitySummary[] // company / model | 125 | + // artifact (1.1) | ||
| 96 | − repositories?: EntitySummary[] // company / model / framework | 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=¤t=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 | | route | returns / action | | 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 }` | | 197 | +| `GET /admin/overview` · `GET /admin/connectors` · `POST /admin/connectors/{name}/run` · `PATCH /admin/connectors/{name}` · `GET /admin/runs` · `GET /admin/errors` | v1 (unchanged) | | ||
| 105 | −| `GET /admin/connectors` | full `connectors` rows + last run + docs/snapshots counts + source tier | | 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) | | ||
| 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 }` | | 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 | | ||
| 107 | −| `PATCH /admin/connectors/{name}` `{ enabled?, interval_seconds?, priority? }` | update | | 200 | +| `GET /admin/infrastructure` · `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | v1 (unchanged) | | ||
| 108 | −| `GET /admin/runs?connector=&limit=` · `GET /admin/errors?connector=&limit=` | rows | | 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). | | ||
| 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) | | 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) | | ||
| 110 | −| `GET /admin/jobs?status=&kind=` · `POST /admin/jobs/{id}/retry` · `POST /admin/jobs/requeue-dead` | queue | | 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` | | ||
| 111 | −| `GET /admin/llm-jobs?limit=` · `GET /admin/llm/health` | LLM factory accounting | | 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 }` | | ||
| 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) | | 205 | +| **`GET /admin/anomalies?status=open|all&severity=&check=&limit=&offset=`** · **`POST /admin/anomalies/{id}`** `{ status: resolved|ignored|open, note? }` | anomaly flags (never deleted) | | ||
| 113 | −| `GET /admin/entities/duplicates?type=&limit=` | candidate pairs by normalized name similarity (`pg_trgm`) within a type | | 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 | | ||
| 114 | −| `POST /admin/entities/merge` `{ source_id, target_id }` · `POST /admin/entities/{id}/claims/{claim_id}/retract` | curation | | 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 | | ||
| 115 | −| `POST /admin/reprocess` `{ connector, url? }` | enqueue `reprocess_snapshot` | | 208 | +| **`GET /admin/audit?limit=&offset=&action=`** | `{ items: { id, actor, action, target, payload, ip, created_at }[], total }` | | ||
| 116 | −| `POST /admin/llm/enqueue` `{ limit?, task? }` | queue `llm_extract` for pending snapshots | | 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 }` | | ||
| 117 | −| `GET /admin/infrastructure` | heartbeats, archive size, DB size (`pg_database_size`), table sizes, node hostname, uptime | | 210 | + | ||
| 118 | −| `POST /admin/cache/flush` | `{ flushed: n }` | | 211 | +## Implementation notes (`src/aiatlas/api`) | ||
| 119 | −| `POST /admin/stats/recompute` · `POST /admin/quality/recompute` | maintenance | | 212 | + | ||
| 120 | − | 213 | +- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds return **422** with `{ detail, errors }`; semantic errors raised by the routes return **400**. | ||
| 121 | −## Implementation notes (2026-09-11, `src/aiatlas/api`) | 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`. | ||
| 122 | − | 215 | +- **Connections**: `EntityDetail` runs its blocks in ≤ 4 concurrent groups (one pooled connection each, sequential inside a group) instead of one connection per block. | ||
| 123 | −Deviations and additions relative to the table above — everything else is implemented as written. | 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. | ||
| 124 | − | 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. | ||
| 125 | −- **Validation errors**: parameters rejected by FastAPI/pydantic typing or bounds (`limit=999`, non-integer `depth`…) return **422** with | 218 | +- **`hardware_fit`** (model detail) is omitted when `parameter_count` is unknown; every fit payload carries `estimated: true`. | ||
| 126 | − `{ detail: "<field>: <message>", errors: [...] }`; semantic errors raised by the routes (bad `sort`, bad date, wrong `scope`, `ids` count) return **400**. | 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. | ||
| 127 | −- **`/companies`** (listing, alias, `/compare` type `company`) covers the organization-like types `company | organization | lab | university`. | 220 | +- **Migrations**: `0002_api_indexes` (read paths), `0003_canonical_ontology` (hierarchy, event semantics, comparability, admin tables) — both required by 1.1. | ||
| 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). | ||||
modified
src/aiatlas/api/common.py
+151 −32
@@ -5,6 +5,8 @@ from __future__ import annotations | |||||
| 5 | 5 | ||||
| 6 | import hmac | 6 | import hmac | ||
| 7 | import inspect | 7 | import inspect | ||
| 8 | +import logging | ||||
| 9 | +import os | ||||
| 8 | import time | 10 | import time | ||
| 9 | from collections import defaultdict, deque | 11 | from collections import defaultdict, deque | ||
| 10 | from collections.abc import Awaitable, Callable | 12 | from collections.abc import Awaitable, Callable | ||
@@ -20,9 +22,11 @@ from fastapi.responses import JSONResponse | |||||
| 20 | from sqlalchemy.ext.asyncio import AsyncConnection | 22 | from sqlalchemy.ext.asyncio import AsyncConnection | ||
| 21 | 23 | ||||
| 22 | from aiatlas.config import settings | 24 | from aiatlas.config import settings | ||
| 23 | −from aiatlas.db import fetch_one | 25 | +from aiatlas.db import execute, fetch_one, jsonb, transaction | ||
| 24 | from aiatlas.services import cache | 26 | from aiatlas.services import cache | ||
| 25 | 27 | ||||
| 28 | +log = logging.getLogger("aiatlas.api") | ||||
| 29 | + | ||||
| 26 | # ------------------------------------------------------------------------------------------------------------------ errors & JSON | 30 | # ------------------------------------------------------------------------------------------------------------------ errors & JSON | ||
| 27 | 31 | ||||
| 28 | 32 | ||||
@@ -167,48 +171,84 @@ def cached(ttl_s: int) -> Callable[[Callable[..., Awaitable[Any]]], Callable[... | |||||
| 167 | return deco | 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 | # ------------------------------------------------------------------------------------------------------------------ rate limiting | 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 | _hits: dict[tuple[str, str], deque[float]] = defaultdict(deque) | 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 | def client_ip(request: Request) -> str: | 188 | def client_ip(request: Request) -> str: | ||
| 189 | − fwd = request.headers.get("x-forwarded-for") | 189 | + if trust_proxy(): | ||
| 190 | − if fwd: | 190 | + fwd = request.headers.get("x-forwarded-for") | ||
| 191 | − return fwd.split(",")[0].strip() | 191 | + if fwd: | ||
| 192 | + return fwd.split(",")[0].strip() | ||||
| 192 | return request.client.host if request.client else "unknown" | 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 | def rate_limit(bucket: str) -> Callable[[Request], None]: | 213 | def rate_limit(bucket: str) -> Callable[[Request], None]: | ||
| 196 | def dep(request: Request) -> None: | 214 | def dep(request: Request) -> None: | ||
| 197 | n, window = _LIMITS.get(bucket, _LIMITS["default"]) | 215 | n, window = _LIMITS.get(bucket, _LIMITS["default"]) | ||
| 198 | − now = time.monotonic() | 216 | + if not _bucket_hit(client_ip(request), bucket): | ||
| 199 | − q = _hits[(client_ip(request), bucket)] | ||||
| 200 | − while q and q[0] <= now - window: | ||||
| 201 | − q.popleft() | ||||
| 202 | − if len(q) >= n: | ||||
| 203 | raise ApiError(429, f"rate limit exceeded for {bucket}: {n} per {int(window)} s") | 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 | return dep | 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 | # ------------------------------------------------------------------------------------------------------------------ entity serialisers | 252 | # ------------------------------------------------------------------------------------------------------------------ entity serialisers | ||
| 213 | 253 | ||||
| 214 | ENTITY_FIELDS = ("id", "entity_type", "canonical_name", "slug", "description", "status", "organization_id", "attributes", "quality", "counts", | 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 | TYPE_LABELS = {"model": "Models", "company": "Companies", "organization": "Organizations", "lab": "Labs", "university": "Universities", | 289 | TYPE_LABELS = {"model": "Models", "company": "Companies", "organization": "Organizations", "lab": "Labs", "university": "Universities", | ||
| 250 | "provider": "Providers", "paper": "Papers", "benchmark": "Benchmarks", "hardware": "Hardware", "framework": "Frameworks", | 290 | "provider": "Providers", "paper": "Papers", "benchmark": "Benchmarks", "hardware": "Hardware", "framework": "Frameworks", | ||
| 251 | "dataset": "Datasets", "tool": "Tools", "repository": "Repositories", "regulation": "Regulation", "incident": "Incidents", | 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 | def summary_attributes(entity_type: str, attrs: dict[str, Any] | None) -> dict[str, Any]: | 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 | "p.valid_to, p.source_url, p.tier, " + entity_cols("m", "m_") + ", " + entity_cols("pv", "p_")) | 440 | "p.valid_to, p.source_url, p.tier, " + entity_cols("m", "m_") + ", " + entity_cols("pv", "p_")) | ||
| 372 | PRICE_FROM = "prices p " + entity_join("m", "p.model_id") + " " + entity_join("pv", "p.provider_id") | 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 | def result_row(row: dict[str, Any]) -> dict[str, Any]: | 462 | def result_row(row: dict[str, Any]) -> dict[str, Any]: | ||
| 376 | return {"id": row["id"], "model": entity_summary(row, "m_"), "benchmark": entity_summary(row, "b_"), "score": row.get("score"), "metric": row.get("metric"), | 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 | # ------------------------------------------------------------------------------------------------------------------ entity resolution | 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]: | 490 | +ENTITY_EXTRA_COLS = "e.provenance, e.merged_into, e.family_id, e.canonical_id, e.artifact_kind, e.identity_confidence" | ||
| 404 | − """Slug first, then id; follows `merged_into`; 404 when absent or when the type does not match the mounted alias.""" | 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 | key = slug_or_id.strip() | 496 | key = slug_or_id.strip() | ||
| 406 | if not key or len(key) > 200: | 497 | if not key or len(key) > 200: | ||
| 407 | raise ApiError(404, "entity not found") | 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 " | 499 | + sel = f"select {ENTITY_COLS}, {ENTITY_EXTRA_COLS} from {ENTITY_FROM}" | ||
| 409 | − f"order by case when e.slug = :k then 0 else 1 end limit 1", k=key) | 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 | hops = 0 | 506 | hops = 0 | ||
| 411 | while row and row.get("merged_into") and hops < 5: | 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 | hops += 1 | 509 | hops += 1 | ||
| 414 | if not row: | 510 | if not row: | ||
| 415 | raise ApiError(404, "entity not found") | 511 | raise ApiError(404, "entity not found") | ||
| 416 | if types and row["entity_type"] not in types: | 512 | if types and row["entity_type"] not in types: | ||
| 417 | raise ApiError(404, f"entity {key!r} is a {row['entity_type']}, not one of {', '.join(types)}") | 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 | return row | 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 | async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple[str, ...] | None = None) -> str | None: | 528 | async def resolve_id(conn: AsyncConnection, slug_or_id: str | None, types: tuple[str, ...] | None = None) -> str | None: | ||
| 422 | if not slug_or_id: | 529 | if not slug_or_id: | ||
| 423 | return None | 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 | __all__ = [ | 534 | __all__ = [ | ||
| 535 | + "ADMIN_DEPENDENCIES", | ||||
| 536 | + "ARTIFACT_KINDS", | ||||
| 428 | "CLAIM_COLS", | 537 | "CLAIM_COLS", | ||
| 429 | "CLAIM_FROM", | 538 | "CLAIM_FROM", | ||
| 430 | "COMPANY_TYPES", | 539 | "COMPANY_TYPES", | ||
| 540 | + "DOWNLOADABLE_CATEGORIES", | ||||
| 431 | "ENTITY_COLS", | 541 | "ENTITY_COLS", | ||
| 542 | + "ENTITY_EXTRA_COLS", | ||||
| 432 | "ENTITY_FROM", | 543 | "ENTITY_FROM", | ||
| 433 | "EVENT_COLS", | 544 | "EVENT_COLS", | ||
| 434 | "EVENT_FROM", | 545 | "EVENT_FROM", | ||
| 435 | "EVENT_TYPE_LABELS", | 546 | "EVENT_TYPE_LABELS", | ||
| 436 | "MAIN_ENTITY_TYPES", | 547 | "MAIN_ENTITY_TYPES", | ||
| 548 | + "MODEL_OR_ARTIFACT_UNIVERSE", | ||||
| 549 | + "MODEL_UNIVERSE", | ||||
| 550 | + "OPEN_CATEGORIES", | ||||
| 437 | "PAGINATION", | 551 | "PAGINATION", | ||
| 438 | "PRICE_COLS", | 552 | "PRICE_COLS", | ||
| 439 | "PRICE_FROM", | 553 | "PRICE_FROM", | ||
@@ -446,6 +560,7 @@ __all__ = [ | |||||
| 446 | "AtlasJSONResponse", | 560 | "AtlasJSONResponse", | ||
| 447 | "Pagination", | 561 | "Pagination", | ||
| 448 | "attr_num", | 562 | "attr_num", | ||
| 563 | + "audit", | ||||
| 449 | "cache_key", | 564 | "cache_key", | ||
| 450 | "cached", | 565 | "cached", | ||
| 451 | "change_event", | 566 | "change_event", | ||
@@ -453,6 +568,7 @@ __all__ = [ | |||||
| 453 | "client_ip", | 568 | "client_ip", | ||
| 454 | "csv", | 569 | "csv", | ||
| 455 | "day_bounds", | 570 | "day_bounds", | ||
| 571 | + "deployment_row", | ||||
| 456 | "dumps", | 572 | "dumps", | ||
| 457 | "enrich_provenance", | 573 | "enrich_provenance", | ||
| 458 | "entity_cols", | 574 | "entity_cols", | ||
@@ -461,9 +577,11 @@ __all__ = [ | |||||
| 461 | "event_type_importance", | 577 | "event_type_importance", | ||
| 462 | "event_type_label", | 578 | "event_type_label", | ||
| 463 | "flip_order", | 579 | "flip_order", | ||
| 580 | + "is_open", | ||||
| 464 | "normalize", | 581 | "normalize", | ||
| 465 | "normalize_status", | 582 | "normalize_status", | ||
| 466 | "num_expr", | 583 | "num_expr", | ||
| 584 | + "openness_values", | ||||
| 467 | "org_of", | 585 | "org_of", | ||
| 468 | "page", | 586 | "page", | ||
| 469 | "parse_date", | 587 | "parse_date", | ||
@@ -474,4 +592,5 @@ __all__ = [ | |||||
| 474 | "resolve_entity", | 592 | "resolve_entity", | ||
| 475 | "resolve_id", | 593 | "resolve_id", | ||
| 476 | "result_row", | 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 | from __future__ import annotations | 6 | from __future__ import annotations | ||
| 3 | 7 | ||||
| 4 | import asyncio | 8 | import asyncio | ||
| 9 | +from collections import defaultdict | ||||
| 5 | from typing import Any | 10 | from typing import Any | ||
| 6 | 11 | ||||
| 7 | from sqlalchemy.ext.asyncio import AsyncConnection | 12 | from sqlalchemy.ext.asyncio import AsyncConnection | ||
| 8 | 13 | ||||
| 9 | from aiatlas.api.common import ( | 14 | from aiatlas.api.common import ( | ||
| 15 | + ARTIFACT_KINDS, | ||||
| 16 | + CLAIM_COLS, | ||||
| 17 | + CLAIM_FROM, | ||||
| 10 | COMPANY_TYPES, | 18 | COMPANY_TYPES, | ||
| 11 | ENTITY_COLS, | 19 | ENTITY_COLS, | ||
| 12 | ENTITY_FROM, | 20 | ENTITY_FROM, | ||
@@ -18,16 +26,23 @@ from aiatlas.api.common import ( | |||||
| 18 | RESULT_FROM, | 26 | RESULT_FROM, | ||
| 19 | RESULT_ORDER, | 27 | RESULT_ORDER, | ||
| 20 | change_event, | 28 | change_event, | ||
| 29 | + deployment_row, | ||||
| 21 | enrich_provenance, | 30 | enrich_provenance, | ||
| 22 | entity_summary, | 31 | entity_summary, | ||
| 23 | price_row, | 32 | price_row, | ||
| 24 | result_row, | 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 | from aiatlas.services import hardware_fit as hf | 39 | from aiatlas.services import hardware_fit as hf | ||
| 40 | +from aiatlas.services.frontier import config_summary, enrich, group_label | ||||
| 28 | 41 | ||||
| 29 | LINEAGE_PREDICATES = ("derived_from", "fine_tuned_from", "distilled_from", "merged_from", "quantized_from") | 42 | LINEAGE_PREDICATES = ("derived_from", "fine_tuned_from", "distilled_from", "merged_from", "quantized_from") | ||
| 30 | RELATION_GROUP_LIMIT = 24 | 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 | async def relations_grouped(conn: AsyncConnection, entity_id: str) -> list[dict[str, Any]]: | 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 | return "ev.entity_id = :id" | 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 | async def timeline_of(conn: AsyncConnection, entity_id: str, entity_type: str, *, limit: int = 30, before: Any = None, | 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 | where = [_scope_sql(entity_type in COMPANY_TYPES)] | 93 | where = [_scope_sql(entity_type in COMPANY_TYPES)] | ||
| 74 | params: dict[str, Any] = {"id": entity_id, "lim": limit} | 94 | params: dict[str, Any] = {"id": entity_id, "lim": limit} | ||
| 75 | if before is not None: | 95 | if before is not None: | ||
| 76 | − where.append("ev.observed_at < :before") | 96 | + where.append(f"{col} < :before") | ||
| 77 | params["before"] = before | 97 | params["before"] = before | ||
| 78 | if not include_documents: | 98 | if not include_documents: | ||
| 79 | where.append("ev.event_type <> 'DOCUMENT_CHANGED'") | 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) | 100 | + if not include_backfill: | ||
| 81 | − return [change_event(r) for r in rows] | 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 | async def prices_of_model(conn: AsyncConnection, model_id: str, *, current_only: bool) -> list[dict[str, Any]]: | 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 | return [price_row(r) for r in rows] | 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 | async def prices_of_provider(conn: AsyncConnection, provider_id: str) -> list[dict[str, Any]]: | 124 | async def prices_of_provider(conn: AsyncConnection, provider_id: str) -> list[dict[str, Any]]: | ||
| 92 | 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) | 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 | return [price_row(r) for r in rows] | 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 | return [result_row(r) for r in rows] | 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 | where = ["r.benchmark_id = :id"] | 171 | where = ["r.benchmark_id = :id"] | ||
| 103 | params: dict[str, Any] = {"id": benchmark_id, "lim": limit, "off": offset} | 172 | params: dict[str, Any] = {"id": benchmark_id, "lim": limit, "off": offset} | ||
| 104 | if not history: | 173 | if not history: | ||
@@ -106,8 +175,20 @@ async def leaderboard(conn: AsyncConnection, benchmark_id: str, *, limit: int = | |||||
| 106 | if config: | 175 | if config: | ||
| 107 | where.append("r.config::text ilike :cfg") | 176 | where.append("r.config::text ilike :cfg") | ||
| 108 | params["cfg"] = f"%{config}%" | 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) | 178 | + if metric: | ||
| 110 | − return [result_row(r) for r in rows] | 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 | async def lineage_of(conn: AsyncConnection, model_id: str) -> dict[str, list[dict[str, Any]]]: | 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 | union | 207 | union | ||
| 127 | 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) | 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 | 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 | 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 | 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 | 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 | where r.object_id = :id and r.predicate = 'quantized_from' and r.valid_to is null and e.merged_into is null | 212 | where r.object_id = :id and r.predicate = 'quantized_from' and r.valid_to is null and e.merged_into is null | ||
| 132 | order by e.canonical_name limit 100""", id=model_id) | 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 | "quantizations": [entity_summary(r) for r in quants]} | 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 | async def providers_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]: | 324 | async def providers_of_model(conn: AsyncConnection, model_id: str) -> list[dict[str, Any]]: | ||
| 139 | rows = await fetch_all(conn, f""" | 325 | rows = await fetch_all(conn, f""" | ||
| 140 | 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 | 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 | return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0 | 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]: | 362 | +async def _run_group(tasks: list[tuple[str, Any, tuple[Any, ...], dict[str, Any]]]) -> dict[str, Any]: | ||
| 177 | − """Full detail. Blocks run on separate pooled connections concurrently (read-only, so no transaction needed).""" | 363 | + """Run the blocks of one group sequentially on a single pooled connection.""" | ||
| 178 | − eid, etype = row["id"], row["entity_type"] | 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 | provenance = dict(row.get("provenance") or {}) | 374 | provenance = dict(row.get("provenance") or {}) | ||
| 375 | + attrs = row.get("attributes") or {} | ||||
| 185 | 376 | ||||
| 186 | async def base(conn: AsyncConnection) -> dict[str, Any]: | 377 | async def base(conn: AsyncConnection) -> dict[str, Any]: | ||
| 187 | aliases = await fetch_all(conn, "select alias from entity_aliases where entity_id = :id order by kind, alias limit 200", id=eid) | 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 | await enrich_provenance(conn, provenance) | 380 | await enrich_provenance(conn, provenance) | ||
| 190 | return {"aliases": [a["alias"] for a in aliases], "identifiers": [{"scheme": i["scheme"], "value": i["value"]} for i in idents]} | 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 | if etype == "model": | 388 | if etype == "model": | ||
| 194 | − tasks["prices"] = one(prices_of_model, eid, current_only=True) | 389 | + groups[0] += [T("prices", prices_of_model, eid, current_only=True), T("deployments", deployments_of_model, eid), T("providers", providers_of_model, eid)] | ||
| 195 | − tasks["price_history"] = one(prices_of_model, eid, current_only=False) | 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)] | ||
| 196 | − tasks["results"] = one(results_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)]) | ||
| 197 | − tasks["lineage"] = one(lineage_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), | ||
| 198 | − tasks["providers"] = one(providers_of_model, eid) | 393 | + T("repositories", related_by_type, eid, "repository", limit=24)]) | ||
| 199 | − tasks["hardware_fit"] = one(hardware_fit_of_model, row["attributes"] or {}) | 394 | + elif etype == "artifact": | ||
| 200 | − tasks["papers"] = one(related_by_type, eid, "paper", limit=24) | 395 | + groups[0] += [T("canonical", canonical_of_artifact, row), T("prices", prices_of_model, eid, current_only=True)] | ||
| 201 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24) | 396 | + groups[1] += [T("lineage", lineage_of, eid), T("hardware_fit", hardware_fit_of_model, attrs)] | ||
| 202 | elif etype in COMPANY_TYPES: | 397 | elif etype in COMPANY_TYPES: | ||
| 203 | − tasks["models"] = one(related_by_type, eid, "model", limit=50, include_org_children=True) | 398 | + groups[0] += [T("models", 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) | 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)] | ||
| 205 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24, include_org_children=True) | ||||
| 206 | elif etype == "provider": | 400 | elif etype == "provider": | ||
| 207 | − tasks["prices"] = one(prices_of_provider, eid) | 401 | + groups[0] += [T("prices", prices_of_provider, eid)] | ||
| 208 | − tasks["models"] = one(_provider_models, eid) | 402 | + groups[1] += [T("models", _provider_models, eid)] | ||
| 209 | elif etype == "benchmark": | 403 | elif etype == "benchmark": | ||
| 210 | − tasks["results"] = one(leaderboard, eid, limit=100) | 404 | + groups[0] += [T("results", leaderboard, eid, limit=100)] | ||
| 211 | elif etype == "hardware": | 405 | elif etype == "hardware": | ||
| 212 | − tasks["models"] = one(related_by_type, eid, "model", limit=50) | 406 | + groups[0] += [T("models", related_by_type, eid, "model", limit=50)] | ||
| 213 | − elif etype == "framework": | 407 | + elif etype in ("framework", "library", "runtime"): | ||
| 214 | − tasks["repositories"] = one(related_by_type, eid, "repository", limit=24) | 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) | 412 | + results = await asyncio.gather(*(_run_group(g) for g in groups[:MAX_CONCURRENT_GROUPS])) | ||
| 217 | − values = await asyncio.gather(*(tasks[k] for k in keys)) | 413 | + blocks: dict[str, Any] = {} | ||
| 218 | − blocks = dict(zip(keys, values, strict=True)) | 414 | + for r in results: | ||
| 415 | + blocks.update(r) | ||||
| 219 | 416 | ||||
| 220 | detail = entity_summary(row) or {} | 417 | detail = entity_summary(row) or {} | ||
| 221 | − detail["attributes"] = row.get("attributes") or {} | 418 | + detail["attributes"] = attrs | ||
| 222 | detail["provenance"] = provenance | 419 | detail["provenance"] = provenance | ||
| 223 | detail.update(blocks.pop("base")) | 420 | detail.update(blocks.pop("base")) | ||
| 224 | detail["relations"] = blocks.pop("relations") | 421 | detail["relations"] = blocks.pop("relations") | ||
@@ -232,11 +429,33 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]: | |||||
| 232 | detail[k] = v[0] | 429 | detail[k] = v[0] | ||
| 233 | elif v is not None: | 430 | elif v is not None: | ||
| 234 | detail[k] = v | 431 | detail[k] = v | ||
| 235 | − if etype == "model" and "hardware_fit" in detail: | 432 | + if etype == "model": | ||
| 236 | − detail["hardware_fit_assumptions"] = hf.ASSUMPTIONS | 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 | return detail | 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 | async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[list[dict[str, Any]], int]: | 459 | async def _provider_models(conn: AsyncConnection, provider_id: str) -> tuple[list[dict[str, Any]], int]: | ||
| 241 | rows = await fetch_all(conn, f""" | 460 | rows = await fetch_all(conn, f""" | ||
| 242 | with ids as (select p.model_id as id from prices p where p.provider_id = :id and p.valid_to is null | 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 | return [entity_summary(r) for r in rows], int(rows[0]["total"]) if rows else 0 | 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 | __all__ = [ | 474 | __all__ = [ | ||
| 475 | + "VERSIONED_PROPERTIES", | ||||
| 476 | + "artifacts_of_model", | ||||
| 477 | + "benchmarks_of_model", | ||||
| 478 | + "deployments_of_model", | ||||
| 250 | "entity_detail", | 479 | "entity_detail", | ||
| 480 | + "event_date_col", | ||||
| 481 | + "family_of_model", | ||||
| 251 | "hardware_fit_of_model", | 482 | "hardware_fit_of_model", | ||
| 252 | "leaderboard", | 483 | "leaderboard", | ||
| 484 | + "licence_block", | ||||
| 253 | "lineage_of", | 485 | "lineage_of", | ||
| 486 | + "openness_block", | ||||
| 254 | "prices_of_model", | 487 | "prices_of_model", | ||
| 255 | "prices_of_provider", | 488 | "prices_of_provider", | ||
| 256 | "providers_of_model", | 489 | "providers_of_model", | ||
@@ -259,4 +492,5 @@ __all__ = [ | |||||
| 259 | "results_of_model", | 492 | "results_of_model", | ||
| 260 | "sources_of", | 493 | "sources_of", | ||
| 261 | "timeline_of", | 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 | from __future__ import annotations | 2 | from __future__ import annotations | ||
| 3 | 3 | ||||
| 4 | import asyncio | 4 | import asyncio | ||
@@ -15,16 +15,23 @@ from starlette.exceptions import HTTPException as StarletteHTTPException | |||||
| 15 | 15 | ||||
| 16 | import aiatlas | 16 | import aiatlas | ||
| 17 | from aiatlas.api.common import AtlasJSONResponse | 17 | from aiatlas.api.common import AtlasJSONResponse | ||
| 18 | +from aiatlas.api.etag import ETagMiddleware | ||||
| 18 | from aiatlas.api.routers import ( | 19 | from aiatlas.api.routers import ( | ||
| 19 | admin, | 20 | admin, | ||
| 21 | + admin_quality, | ||||
| 20 | benchmarks, | 22 | benchmarks, | ||
| 21 | changes, | 23 | changes, | ||
| 24 | + claims, | ||||
| 22 | companies, | 25 | companies, | ||
| 23 | compare, | 26 | compare, | ||
| 27 | + deployments, | ||||
| 24 | diff, | 28 | diff, | ||
| 25 | entities, | 29 | entities, | ||
| 26 | explore, | 30 | explore, | ||
| 31 | + families, | ||||
| 32 | + graph, | ||||
| 27 | hardware, | 33 | hardware, | ||
| 34 | + intelligence, | ||||
| 28 | misc, | 35 | misc, | ||
| 29 | models, | 36 | models, | ||
| 30 | papers, | 37 | papers, | ||
@@ -42,13 +49,14 @@ from aiatlas.services import cache | |||||
| 42 | from aiatlas.services.llm import gateway | 49 | from aiatlas.services.llm import gateway | ||
| 43 | 50 | ||||
| 44 | log = logging.getLogger("aiatlas.api") | 51 | log = logging.getLogger("aiatlas.api") | ||
| 52 | +API_VERSION = "1.1" | ||||
| 45 | 53 | ||||
| 46 | 54 | ||||
| 47 | @asynccontextmanager | 55 | @asynccontextmanager | ||
| 48 | async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] | 56 | async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] | ||
| 49 | setup_logging(service="aia-api") | 57 | setup_logging(service="aia-api") | ||
| 50 | settings.ensure_dirs() | 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 | yield | 60 | yield | ||
| 53 | await cache.close() | 61 | await cache.close() | ||
| 54 | await dispose() | 62 | await dispose() | ||
@@ -57,12 +65,14 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def] | |||||
| 57 | app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse, | 65 | app = FastAPI(title="AI Atlas API", version=aiatlas.__version__, lifespan=lifespan, default_response_class=AtlasJSONResponse, | ||
| 58 | docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json", | 66 | docs_url="/api/v1/docs", redoc_url=None, openapi_url="/api/v1/openapi.json", | ||
| 59 | description="The global intelligence layer for artificial intelligence: models, companies, papers, providers, prices, benchmarks, hardware — " | 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 | app.add_middleware(GZipMiddleware, minimum_size=1024) | 72 | app.add_middleware(GZipMiddleware, minimum_size=1024) | ||
| 63 | _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"} | 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 | app.add_middleware(CORSMiddleware, allow_origins=sorted(o for o in _origins if o), allow_methods=["GET", "POST", "PATCH", "OPTIONS"], | 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 | @app.middleware("http") | 78 | @app.middleware("http") | ||
@@ -74,6 +84,7 @@ async def security_headers(request: Request, call_next): # type: ignore[no-unty | |||||
| 74 | return AtlasJSONResponse({"detail": "internal server error"}, status_code=500) | 84 | return AtlasJSONResponse({"detail": "internal server error"}, status_code=500) | ||
| 75 | response.headers["x-content-type-options"] = "nosniff" | 85 | response.headers["x-content-type-options"] = "nosniff" | ||
| 76 | response.headers["referrer-policy"] = "strict-origin-when-cross-origin" | 86 | response.headers["referrer-policy"] = "strict-origin-when-cross-origin" | ||
| 87 | + response.headers["x-api-version"] = API_VERSION | ||||
| 77 | return response | 88 | return response | ||
| 78 | 89 | ||||
| 79 | 90 | ||||
@@ -117,7 +128,7 @@ async def _health() -> dict[str, Any]: | |||||
| 117 | cached = {"reachable": False} | 128 | cached = {"reachable": False} | ||
| 118 | await cache.cache_set("health:llm", cached, 300) | 129 | await cache.cache_set("health:llm", cached, 300) | ||
| 119 | llm.update(cached) | 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 | @app.get("/health", tags=["health"]) | 134 | @app.get("/health", tags=["health"]) | ||
@@ -131,7 +142,9 @@ async def health_v1() -> dict[str, Any]: | |||||
| 131 | 142 | ||||
| 132 | 143 | ||||
| 133 | # 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. | 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 | app.include_router(r.router) | 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 | from datetime import UTC, datetime | 8 | from datetime import UTC, datetime | ||
| 9 | from typing import Any | 9 | from typing import Any | ||
| 10 | 10 | ||||
| 11 | −from fastapi import APIRouter, Depends, Query | 11 | +from fastapi import APIRouter, Depends, Query, Request | ||
| 12 | from pydantic import BaseModel, Field | 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 | from aiatlas.config import settings | 15 | from aiatlas.config import settings | ||
| 16 | from aiatlas.connectors import registry as connector_registry | 16 | from aiatlas.connectors import registry as connector_registry | ||
| 17 | from aiatlas.db import connection, execute, fetch_all, fetch_one, fetch_val, jsonb, transaction | 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 | from aiatlas.services.merge import merge_entities | 23 | from aiatlas.services.merge import merge_entities | ||
| 24 | from aiatlas.services.stats import live_counts | 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 | _STARTED = time.time() | 52 | _STARTED = time.time() | ||
| 28 | SNAPSHOT_TEXT_LIMIT = 20 * 1024 | 53 | SNAPSHOT_TEXT_LIMIT = 20 * 1024 | ||
| 29 | HIDDEN = ("raw_path", "text_path") | 54 | HIDDEN = ("raw_path", "text_path") | ||
@@ -422,7 +447,8 @@ async def _accept_claim(conn: Any, claim_id: str) -> dict[str, Any]: | |||||
| 422 | 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]: | 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 | where = "a.entity_type = :t" if type else "true" | 448 | where = "a.entity_type = :t" if type else "true" | ||
| 424 | async with connection() as conn: | 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 | rows = await fetch_all(conn, f""" | 452 | rows = await fetch_all(conn, f""" | ||
| 427 | 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, | 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 | 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, | 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 | "llm": {"available": gateway.available, "engine": gateway.engine.name}, "scheduler_tick_s": settings.scheduler_tick_s} | 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 | @router.post("/cache/flush") | 551 | @router.post("/cache/flush") | ||
| 522 | −async def cache_flush(prefix: str = "") -> dict[str, Any]: | 552 | +async def cache_flush(prefix: str = "", after_run: int = Query(0, ge=0, le=1)) -> dict[str, Any]: | ||
| 523 | − return {"flushed": await cache.cache_invalidate(prefix)} | 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 | @router.post("/stats/recompute") | 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