API polish: provenance source names, methodology labels, status vocabulary, zero-filled stats
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 | ||||