SPB Git

spb/ai-atlas

Public

API polish: provenance source names, methodology labels, status vocabulary, zero-filled stats

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

7 changed files +106 −7

modified src/aiatlas/api/common.py +67 −2
@@ -65,7 +65,6 @@ class AtlasJSONResponse(JSONResponse):
65 # ------------------------------------------------------------------------------------------------------------------ pagination & parsing 65 # ------------------------------------------------------------------------------------------------------------------ pagination & parsing
66 66
67 67
68−
69 class Pagination: 68 class Pagination:
70 def __init__(self, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)): 69 def __init__(self, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)):
71 self.limit = limit 70 self.limit = limit
@@ -266,6 +265,62 @@ def summary_attributes(entity_type: str, attrs: dict[str, Any] | None) -> dict[s
266 return out 265 return out
267 266
268 267
268+STATUS_VOCAB = ("active", "preview", "deprecated", "retired", "announced", "limited-availability", "unknown")
269+STATUS_MAP = {"available": "active", "ga": "active", "general-availability": "active", "released": "active", "live": "active", "beta": "preview",
270+ "alpha": "preview", "experimental": "preview", "coming-soon": "announced", "upcoming": "announced", "sunset": "retired", "discontinued": "retired",
271+ "archived": "retired", "legacy": "deprecated", "limited": "limited-availability", "merged": "retired"}
272+
273+
274+def normalize_status(value: Any) -> str:
275+ s = str(value or "").strip().lower().replace("_", "-").replace(" ", "-")
276+ s = STATUS_MAP.get(s, s)
277+ return s if s in STATUS_VOCAB else "unknown"
278+
279+
280+MAIN_ENTITY_TYPES = ("model", "company", "paper", "provider", "benchmark", "hardware", "framework", "dataset", "tool", "repository")
281+
282+EVENT_TYPE_LABELS: dict[str, tuple[str, int]] = { # event_type -> (human label, default importance 0–3)
283+ "NEW_MODEL": ("New model", 3), "MODEL_UPDATED": ("Model updated", 1), "PRICE_CHANGED": ("Price change", 2), "CONTEXT_CHANGED": ("Context window change", 2),
284+ "MAX_OUTPUT_CHANGED": ("Max output change", 1), "PARAMETERS_CHANGED": ("Parameter count change", 2), "KNOWLEDGE_CUTOFF_CHANGED": ("Knowledge cutoff change", 1),
285+ "RELEASE_DATE_CHANGED": ("Release date change", 1), "STATUS_CHANGED": ("Status change", 2), "LICENSE_CHANGED": ("License change", 2),
286+ "OPENNESS_CHANGED": ("Openness change", 2), "CAPABILITIES_CHANGED": ("Capabilities change", 1), "PROPERTY_CHANGED": ("Property change", 1),
287+ "DEPRECATION_ANNOUNCED": ("Deprecation announced", 2), "RETIREMENT_ANNOUNCED": ("Retirement announced", 2), "BENCHMARK_RESULT": ("Benchmark result", 1),
288+ "BENCHMARK_UPDATED": ("Benchmark updated", 1), "NEW_PAPER": ("New paper", 2), "NEW_COMPANY": ("New company", 2), "NEW_ORGANIZATION": ("New organization", 2),
289+ "NEW_LAB": ("New lab", 2), "NEW_UNIVERSITY": ("New university", 1), "NEW_PROVIDER": ("New provider", 2), "NEW_HARDWARE": ("New hardware", 2),
290+ "NEW_FRAMEWORK": ("New framework", 2), "NEW_LIBRARY": ("New library", 1), "NEW_BENCHMARK": ("New benchmark", 2), "NEW_DATASET": ("New dataset", 2),
291+ "NEW_REPOSITORY": ("New repository", 1), "NEW_TOOL": ("New tool", 1), "PROVIDER_LISTED": ("Listed by provider", 2), "PROVIDER_DELISTED": ("Delisted by provider", 2),
292+ "ANNOUNCEMENT": ("Announcement", 2), "RELEASE": ("Release", 2), "VERSION_RELEASED": ("Version released", 1), "DOCUMENT_CHANGED": ("Source document changed", 0),
293+ "ENTITY_MERGED": ("Duplicate merged", 1), "CLAIM_RETRACTED": ("Claim retracted", 1),
294+}
295+
296+
297+def event_type_label(event_type: str) -> str:
298+ known = EVENT_TYPE_LABELS.get(event_type)
299+ return known[0] if known else event_type.replace("_", " ").capitalize()
300+
301+
302+def event_type_importance(event_type: str) -> int:
303+ known = EVENT_TYPE_LABELS.get(event_type)
304+ return known[1] if known else 1
305+
306+
307+async def enrich_provenance(conn: AsyncConnection, *provenances: dict[str, Any] | None) -> None:
308+ """Add `source_name` (from `sources.name`) to every provenance entry that carries a `source_id` — in place."""
309+ ids = {v.get("source_id") for p in provenances if p for v in p.values() if isinstance(v, dict) and v.get("source_id")}
310+ if not ids:
311+ return
312+ from aiatlas.db import fetch_all
313+
314+ rows = await fetch_all(conn, "select id, name, domain, tier from sources where id = any(cast(:ids as text[]))", ids=sorted(ids))
315+ names = {r["id"]: r for r in rows}
316+ for p in provenances:
317+ for v in (p or {}).values():
318+ if isinstance(v, dict) and v.get("source_id") in names:
319+ src = names[v["source_id"]]
320+ v.setdefault("source_name", src["name"])
321+ v.setdefault("source_domain", src["domain"])
322+
323+
269 def org_of(row: dict[str, Any], prefix: str = "") -> dict[str, Any] | None: 324 def org_of(row: dict[str, Any], prefix: str = "") -> dict[str, Any] | None:
270 oid = row.get(f"{prefix}organization_id") 325 oid = row.get(f"{prefix}organization_id")
271 if not oid: 326 if not oid:
@@ -279,8 +334,11 @@ def entity_summary(row: dict[str, Any], prefix: str = "") -> dict[str, Any] | No
279 return None 334 return None
280 etype = g(f"{prefix}entity_type") or "" 335 etype = g(f"{prefix}entity_type") or ""
281 desc = g(f"{prefix}description") 336 desc = g(f"{prefix}description")
337+ raw_status = g(f"{prefix}status")
338+ status = normalize_status(raw_status)
282 return {"id": g(f"{prefix}id"), "entity_type": etype, "slug": g(f"{prefix}slug"), "name": g(f"{prefix}canonical_name"), 339 return {"id": g(f"{prefix}id"), "entity_type": etype, "slug": g(f"{prefix}slug"), "name": g(f"{prefix}canonical_name"),
283− "description": (desc[:280] if isinstance(desc, str) and len(desc) > 280 else desc), "status": g(f"{prefix}status"), 340+ "description": (desc[:280] if isinstance(desc, str) and len(desc) > 280 else desc), "status": status,
341+ **({"status_raw": raw_status} if raw_status and raw_status != status else {}),
284 "organization": org_of(row, prefix), "attributes": summary_attributes(etype, g(f"{prefix}attributes")), 342 "organization": org_of(row, prefix), "attributes": summary_attributes(etype, g(f"{prefix}attributes")),
285 "quality": g(f"{prefix}quality") or {}, "counts": g(f"{prefix}counts") or {}, 343 "quality": g(f"{prefix}quality") or {}, "counts": g(f"{prefix}counts") or {},
286 "first_seen_at": g(f"{prefix}first_seen_at"), "last_seen_at": g(f"{prefix}last_seen_at"), "updated_at": g(f"{prefix}updated_at")} 344 "first_seen_at": g(f"{prefix}first_seen_at"), "last_seen_at": g(f"{prefix}last_seen_at"), "updated_at": g(f"{prefix}updated_at")}
@@ -374,12 +432,15 @@ __all__ = [
374 "ENTITY_FROM", 432 "ENTITY_FROM",
375 "EVENT_COLS", 433 "EVENT_COLS",
376 "EVENT_FROM", 434 "EVENT_FROM",
435+ "EVENT_TYPE_LABELS",
436+ "MAIN_ENTITY_TYPES",
377 "PAGINATION", 437 "PAGINATION",
378 "PRICE_COLS", 438 "PRICE_COLS",
379 "PRICE_FROM", 439 "PRICE_FROM",
380 "RESULT_COLS", 440 "RESULT_COLS",
381 "RESULT_FROM", 441 "RESULT_FROM",
382 "RESULT_ORDER", 442 "RESULT_ORDER",
443+ "STATUS_VOCAB",
383 "TYPE_LABELS", 444 "TYPE_LABELS",
384 "ApiError", 445 "ApiError",
385 "AtlasJSONResponse", 446 "AtlasJSONResponse",
@@ -393,11 +454,15 @@ __all__ = [
393 "csv", 454 "csv",
394 "day_bounds", 455 "day_bounds",
395 "dumps", 456 "dumps",
457+ "enrich_provenance",
396 "entity_cols", 458 "entity_cols",
397 "entity_join", 459 "entity_join",
398 "entity_summary", 460 "entity_summary",
461+ "event_type_importance",
462+ "event_type_label",
399 "flip_order", 463 "flip_order",
400 "normalize", 464 "normalize",
465+ "normalize_status",
401 "num_expr", 466 "num_expr",
402 "org_of", 467 "org_of",
403 "page", 468 "page",
modified src/aiatlas/api/detail.py +5 −1
@@ -18,6 +18,7 @@ from aiatlas.api.common import (
18 RESULT_FROM, 18 RESULT_FROM,
19 RESULT_ORDER, 19 RESULT_ORDER,
20 change_event, 20 change_event,
21+ enrich_provenance,
21 entity_summary, 22 entity_summary,
22 price_row, 23 price_row,
23 result_row, 24 result_row,
@@ -180,9 +181,12 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
180 async with connection() as conn: 181 async with connection() as conn:
181 return await fn(conn, *args, **kw) 182 return await fn(conn, *args, **kw)
182 183
184+ provenance = dict(row.get("provenance") or {})
185+
183 async def base(conn: AsyncConnection) -> dict[str, Any]: 186 async def base(conn: AsyncConnection) -> dict[str, Any]:
184 aliases = await fetch_all(conn, "select alias from entity_aliases where entity_id = :id order by kind, alias limit 200", id=eid) 187 aliases = await fetch_all(conn, "select alias from entity_aliases where entity_id = :id order by kind, alias limit 200", id=eid)
185 idents = await fetch_all(conn, "select scheme, value from entity_identifiers where entity_id = :id order by scheme, value limit 200", id=eid) 188 idents = await fetch_all(conn, "select scheme, value from entity_identifiers where entity_id = :id order by scheme, value limit 200", id=eid)
189+ await enrich_provenance(conn, provenance)
186 return {"aliases": [a["alias"] for a in aliases], "identifiers": [{"scheme": i["scheme"], "value": i["value"]} for i in idents]} 190 return {"aliases": [a["alias"] for a in aliases], "identifiers": [{"scheme": i["scheme"], "value": i["value"]} for i in idents]}
187 191
188 tasks: dict[str, Any] = {"base": one(base), "relations": one(relations_grouped, eid), "sources": one(sources_of, eid), "timeline": one(timeline_of, eid, etype)} 192 tasks: dict[str, Any] = {"base": one(base), "relations": one(relations_grouped, eid), "sources": one(sources_of, eid), "timeline": one(timeline_of, eid, etype)}
@@ -215,7 +219,7 @@ async def entity_detail(row: dict[str, Any]) -> dict[str, Any]:
215 219
216 detail = entity_summary(row) or {} 220 detail = entity_summary(row) or {}
217 detail["attributes"] = row.get("attributes") or {} 221 detail["attributes"] = row.get("attributes") or {}
218− detail["provenance"] = row.get("provenance") or {} 222+ detail["provenance"] = provenance
219 detail.update(blocks.pop("base")) 223 detail.update(blocks.pop("base"))
220 detail["relations"] = blocks.pop("relations") 224 detail["relations"] = blocks.pop("relations")
221 detail["sources"] = blocks.pop("sources") 225 detail["sources"] = blocks.pop("sources")
modified src/aiatlas/api/routers/changes.py +3 −1
@@ -17,6 +17,7 @@ from aiatlas.api.common import (
17 csv, 17 csv,
18 day_bounds, 18 day_bounds,
19 entity_summary, 19 entity_summary,
20+ event_type_label,
20 parse_date, 21 parse_date,
21 parse_ts, 22 parse_ts,
22 resolve_id, 23 resolve_id,
@@ -119,7 +120,8 @@ async def changes_categories(request: Request, days: int = Query(7, ge=1, le=365
119 async with connection() as conn: 120 async with connection() as conn:
120 rows = await fetch_all(conn, """select category, event_type, count(*) as count from change_events where observed_at > now() - make_interval(days => :d) 121 rows = await fetch_all(conn, """select category, event_type, count(*) as count from change_events where observed_at > now() - make_interval(days => :d)
121 and event_type <> 'DOCUMENT_CHANGED' group by 1, 2 order by 3 desc, 1, 2""", d=days) 122 and event_type <> 'DOCUMENT_CHANGED' group by 1, 2 order by 3 desc, 1, 2""", d=days)
122− return {"days": days, "items": [{"category": r["category"], "label": CATEGORY_LABELS.get(r["category"], r["category"].title()), "event_type": r["event_type"], "count": int(r["count"])} for r in rows]} 123+ return {"days": days, "items": [{"category": r["category"], "label": CATEGORY_LABELS.get(r["category"], r["category"].title()), "event_type": r["event_type"],
124+ "event_label": event_type_label(r["event_type"]), "count": int(r["count"])} for r in rows]}
123 125
124 126
125 __all__ = ["CATEGORY_LABELS", "ApiError", "router"] 127 __all__ = ["CATEGORY_LABELS", "ApiError", "router"]
modified src/aiatlas/api/routers/compare.py +2 −0
@@ -14,6 +14,7 @@ from aiatlas.api.common import (
14 ApiError, 14 ApiError,
15 cached, 15 cached,
16 csv, 16 csv,
17+ enrich_provenance,
17 entity_summary, 18 entity_summary,
18 price_row, 19 price_row,
19 resolve_entity, 20 resolve_entity,
@@ -104,6 +105,7 @@ async def compare(request: Request, ids: str = Query(..., description="2–6 slu
104 agg = await fetch_all(conn, "select benchmark_id, count(*) as n from benchmark_results where benchmark_id = any(cast(:ids as text[])) and valid_to is null group by 1", ids=eids) 105 agg = await fetch_all(conn, "select benchmark_id, count(*) as n from benchmark_results where benchmark_id = any(cast(:ids as text[])) and valid_to is null group by 1", ids=eids)
105 for a in agg: 106 for a in agg:
106 extra[a["benchmark_id"]] = {"result_count": int(a["n"])} 107 extra[a["benchmark_id"]] = {"result_count": int(a["n"])}
108+ await enrich_provenance(conn, *(r.get("provenance") for r in rows))
107 for r in rows: 109 for r in rows:
108 attrs = r.get("attributes") or {} 110 attrs = r.get("attributes") or {}
109 prov = r.get("provenance") or {} 111 prov = r.get("provenance") or {}
modified src/aiatlas/api/routers/misc.py +16 −2
@@ -8,7 +8,17 @@ from typing import Any
8 from fastapi import APIRouter, Depends, Query, Request 8 from fastapi import APIRouter, Depends, Query, Request
9 from pydantic import BaseModel, Field 9 from pydantic import BaseModel, Field
10 10
11−from aiatlas.api.common import ENTITY_COLS, ApiError, cached, entity_summary, rate_limit 11+from aiatlas.api.common import (
12+ ENTITY_COLS,
13+ EVENT_TYPE_LABELS,
14+ STATUS_VOCAB,
15+ ApiError,
16+ cached,
17+ entity_summary,
18+ event_type_importance,
19+ event_type_label,
20+ rate_limit,
21+)
12 from aiatlas.db import connection, execute, fetch_all, fetch_one, transaction 22 from aiatlas.db import connection, execute, fetch_all, fetch_one, transaction
13 from aiatlas.ids import ENTITY_TYPES 23 from aiatlas.ids import ENTITY_TYPES
14 from aiatlas.services.quality import EXPECTED_FIELDS, QUALITY_VERSION 24 from aiatlas.services.quality import EXPECTED_FIELDS, QUALITY_VERSION
@@ -41,8 +51,12 @@ async def methodology(request: Request) -> dict[str, Any]:
41 async with connection() as conn: 51 async with connection() as conn:
42 metrics = await fetch_all(conn, "select key, label, version, description, formula from metric_definitions order by key") 52 metrics = await fetch_all(conn, "select key, label, version, description, formula from metric_definitions order by key")
43 event_types = await fetch_all(conn, "select event_type, category, count(*) as count, max(observed_at) as last_seen_at from change_events group by 1, 2 order by 3 desc") 53 event_types = await fetch_all(conn, "select event_type, category, count(*) as count, max(observed_at) as last_seen_at from change_events group by 1, 2 order by 3 desc")
54+ seen = {r["event_type"] for r in event_types}
55+ types = [{**r, "count": int(r["count"]), "label": event_type_label(r["event_type"]), "importance": event_type_importance(r["event_type"])} for r in event_types]
56+ types += [{"event_type": t, "category": None, "count": 0, "last_seen_at": None, "label": lbl, "importance": imp}
57+ for t, (lbl, imp) in EVENT_TYPE_LABELS.items() if t not in seen]
44 return {"metrics": metrics, "quality_version": QUALITY_VERSION, "expected_fields": EXPECTED_FIELDS, "confidence_levels": CONFIDENCE_LEVELS, "tiers": TIERS, 58 return {"metrics": metrics, "quality_version": QUALITY_VERSION, "expected_fields": EXPECTED_FIELDS, "confidence_levels": CONFIDENCE_LEVELS, "tiers": TIERS,
45− "event_types": [{**r, "count": int(r["count"])} for r in event_types], "extractors": EXTRACTORS, 59+ "event_types": types, "status_vocabulary": list(STATUS_VOCAB), "extractors": EXTRACTORS,
46 "principles": ["Never fabricate: missing data is reported as unavailable.", "Every fact carries provenance (source, snapshot, URL, tier, confidence, extractor).", 60 "principles": ["Never fabricate: missing data is reported as unavailable.", "Every fact carries provenance (source, snapshot, URL, tier, confidence, extractor).",
47 "History is append-only: claims, prices and benchmark results are never overwritten.", 61 "History is append-only: claims, prices and benchmark results are never overwritten.",
48 "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database."]} 62 "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database."]}
modified src/aiatlas/api/routers/stats.py +2 −1
@@ -7,7 +7,7 @@ from typing import Any
7 7
8 from fastapi import APIRouter, Query, Request 8 from fastapi import APIRouter, Query, Request
9 9
10−from aiatlas.api.common import cached 10+from aiatlas.api.common import MAIN_ENTITY_TYPES, cached
11 from aiatlas.db import connection 11 from aiatlas.db import connection
12 from aiatlas.sdk.archive import archive_size 12 from aiatlas.sdk.archive import archive_size
13 from aiatlas.services.stats import history, live_counts 13 from aiatlas.services.stats import history, live_counts
@@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
20 async def stats(request: Request) -> dict[str, Any]: 20 async def stats(request: Request) -> dict[str, Any]:
21 async with connection() as conn: 21 async with connection() as conn:
22 counts = await live_counts(conn) 22 counts = await live_counts(conn)
23+ counts["entities"] = {**{t: 0 for t in MAIN_ENTITY_TYPES}, **counts["entities"]} # zero-filled: the homepage never renders a missing key
23 counts["archive"] = await asyncio.to_thread(archive_size) 24 counts["archive"] = await asyncio.to_thread(archive_size)
24 counts["computed_at"] = datetime.now(UTC) 25 counts["computed_at"] = datetime.now(UTC)
25 return counts 26 return counts
modified tests/test_api.py +11 −0
@@ -45,6 +45,8 @@ async def test_stats_keys_and_live(client: AsyncClient) -> None:
45 assert key in s, key 45 assert key in s, key
46 assert s["entities_total"] == sum(s["entities"].values()) > 0 46 assert s["entities_total"] == sum(s["entities"].values()) > 0
47 assert s["entities"].get("model", 0) > 0 47 assert s["entities"].get("model", 0) > 0
48+ for t in ("model", "company", "paper", "provider", "benchmark", "hardware", "framework", "dataset", "tool", "repository"):
49+ assert t in s["entities"], t # zero-filled for the homepage
48 assert {"raw_bytes", "raw_files", "text_bytes", "text_files"} <= set(s["archive"]) 50 assert {"raw_bytes", "raw_files", "text_bytes", "text_files"} <= set(s["archive"])
49 hist = await client.get("/api/v1/stats/history?days=30") 51 hist = await client.get("/api/v1/stats/history?days=30")
50 assert hist.status_code == 200 and "items" in hist.json() 52 assert hist.status_code == 200 and "items" in hist.json()
@@ -92,6 +94,8 @@ async def test_entity_detail_blocks(client: AsyncClient) -> None:
92 "providers", "quality", "counts"): 94 "providers", "quality", "counts"):
93 assert block in d, block 95 assert block in d, block
94 assert d["provenance"] and all({"tier", "confidence", "extractor", "observed_at"} <= set(v) for v in d["provenance"].values()) 96 assert d["provenance"] and all({"tier", "confidence", "extractor", "observed_at"} <= set(v) for v in d["provenance"].values())
97+ assert any(v.get("source_name") for v in d["provenance"].values() if v.get("source_id"))
98+ assert d["status"] in ("active", "preview", "deprecated", "retired", "announced", "limited-availability", "unknown")
95 assert d["prices"] and d["prices"][0]["provider"]["slug"] and d["prices"][0]["input_per_mtok"] is not None 99 assert d["prices"] and d["prices"][0]["provider"]["slug"] and d["prices"][0]["input_per_mtok"] is not None
96 assert d["timeline"] and any(e["event_type"] == "NEW_MODEL" for e in d["timeline"]) 100 assert d["timeline"] and any(e["event_type"] == "NEW_MODEL" for e in d["timeline"])
97 assert d["sources"] and {"url", "tier", "doc_type", "snapshots"} <= set(d["sources"][0]) 101 assert d["sources"] and {"url", "tier", "doc_type", "snapshots"} <= set(d["sources"][0])
@@ -182,6 +186,13 @@ async def test_listings_and_misc(client: AsyncClient) -> None:
182 assert r.status_code == 200, (path, r.text[:200]) 186 assert r.status_code == 200, (path, r.text[:200])
183 prices = (await client.get("/api/v1/prices", params={"model": MODEL})).json() 187 prices = (await client.get("/api/v1/prices", params={"model": MODEL})).json()
184 assert prices["items"] and prices["items"][0]["model"]["slug"] == MODEL 188 assert prices["items"] and prices["items"][0]["model"]["slug"] == MODEL
189+ meth = (await client.get("/api/v1/methodology")).json()
190+ assert meth["metrics"] and {"key", "label", "version", "description"} <= set(meth["metrics"][0])
191+ assert all({"event_type", "label", "importance", "count"} <= set(t) for t in meth["event_types"])
192+ assert next(t for t in meth["event_types"] if t["event_type"] == "NEW_MODEL")["label"] == "New model"
193+ srcs = (await client.get("/api/v1/sources")).json()["items"]
194+ linked = [c for s in srcs for c in s["connectors"]]
195+ assert linked and {"name", "label", "health", "last_success_at", "interval_seconds"} <= set(linked[0])
185 fit = (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24})).json() 196 fit = (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24})).json()
186 assert fit["estimated"] is True and fit["assumptions"] and "items" in fit 197 assert fit["estimated"] is True and fit["assumptions"] and "items" in fit
187 assert (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24, "quant": "2bit"})).status_code == 400 198 assert (await client.get("/api/v1/hardware/fit", params={"memory_gb": 24, "quant": "2bit"})).status_code == 400
188 199
Menu

Theme