Skip to content

Commit cfb73c4

Browse files
feat(api): add GET /specs/map endpoint
New endpoint returns one row per spec — id, title, best-impl preview URLs (light/dark), best-impl quality_score, spec-level tags and impl-level tags — for the upcoming /map page that clusters specs by tag similarity. Best-impl selection: highest quality_score with lexicographic library_id tiebreak for determinism. Specs without implementations are skipped, mirroring _build_specs_list. Route is registered before /specs/{spec_id} so the path-parameter route doesn't capture "map". Cache key specs_map is invalidated alongside specs_list whenever a spec changes. Refs #5646 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bf88d2a commit cfb73c4

4 files changed

Lines changed: 155 additions & 1 deletion

File tree

api/cache.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def clear_spec_cache(spec_id: str) -> int:
178178
count += clear_cache_by_pattern(f"spec:{spec_id}")
179179
count += clear_cache_by_pattern(f"spec_images:{spec_id}")
180180
count += clear_cache_by_pattern("specs_list") # List might have changed
181+
count += clear_cache_by_pattern("specs_map") # Map page payload might have changed
181182
count += clear_cache_by_pattern("filter:") # Filters might be affected
182183
count += clear_cache_by_pattern("stats") # Stats might have changed
183184
count += clear_cache_by_pattern("sitemap") # Sitemap includes spec URLs

api/routers/specs.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from api.cache import cache_key, get_or_set_cache
77
from api.dependencies import require_db
88
from api.exceptions import raise_not_found
9-
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem
9+
from api.schemas import ImplementationResponse, SpecDetailResponse, SpecListItem, SpecMapItem
1010
from core.config import settings
1111
from core.database import ImplRepository, SpecRepository
1212
from core.database.connection import get_db_context
@@ -28,6 +28,33 @@ async def _build_specs_list(db: AsyncSession) -> list[SpecListItem]:
2828
]
2929

3030

31+
async def _build_specs_map(db: AsyncSession) -> list[SpecMapItem]:
32+
"""One row per spec with its best-rated impl image + spec/impl tag bag for the /map page.
33+
34+
Best-impl tiebreak: highest quality_score, then lexicographic library_id for determinism.
35+
Specs without any implementations are skipped (mirrors _build_specs_list).
36+
"""
37+
repo = SpecRepository(db)
38+
specs = await repo.get_all()
39+
items: list[SpecMapItem] = []
40+
for spec in specs:
41+
if not spec.impls:
42+
continue
43+
best = max(spec.impls, key=lambda i: ((i.quality_score or 0.0), i.library_id))
44+
items.append(
45+
SpecMapItem(
46+
id=spec.id,
47+
title=spec.title,
48+
preview_url_light=best.preview_url_light,
49+
preview_url_dark=best.preview_url_dark,
50+
quality_score=best.quality_score,
51+
tags=spec.tags,
52+
impl_tags=best.impl_tags,
53+
)
54+
)
55+
return items
56+
57+
3158
async def _build_spec_detail(db: AsyncSession, spec_id: str) -> SpecDetailResponse:
3259
repo = SpecRepository(db)
3360
spec = await repo.get_by_id(spec_id)
@@ -125,6 +152,25 @@ async def _refresh() -> list[SpecListItem]:
125152
)
126153

127154

155+
@router.get("/specs/map", response_model=list[SpecMapItem])
156+
async def get_specs_map(db: AsyncSession = Depends(require_db)):
157+
"""Get one row per spec (best-impl image + tag bag) for the /map clustering page.
158+
159+
NOTE: must stay declared before /specs/{spec_id} so the path-parameter route doesn't capture "map".
160+
"""
161+
162+
async def _fetch() -> list[SpecMapItem]:
163+
return await _build_specs_map(db)
164+
165+
async def _refresh() -> list[SpecMapItem]:
166+
async with get_db_context() as fresh_db:
167+
return await _build_specs_map(fresh_db)
168+
169+
return await get_or_set_cache(
170+
cache_key("specs_map"), _fetch, refresh_after=settings.cache_refresh_after, refresh_factory=_refresh
171+
)
172+
173+
128174
@router.get("/specs/{spec_id}", response_model=SpecDetailResponse)
129175
async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
130176
"""Get detailed spec information including all implementations."""

api/schemas.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ class SpecListItem(BaseModel):
6969
library_count: int = 0
7070

7171

72+
class SpecMapItem(BaseModel):
73+
"""One row per spec for the /map page: best-impl preview + full tag bag for client-side similarity clustering."""
74+
75+
id: str
76+
title: str
77+
preview_url_light: str | None = None
78+
preview_url_dark: str | None = None
79+
quality_score: float | None = None
80+
tags: dict[str, Any] | None = None
81+
impl_tags: dict[str, Any] | None = None
82+
83+
7284
class ImageResponse(BaseModel):
7385
"""Image/plot response for grid display."""
7486

tests/unit/api/test_routers.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,101 @@ def test_spec_detail_not_found(self, client: TestClient) -> None:
347347
response = client.get("/specs/nonexistent")
348348
assert response.status_code == 404
349349

350+
def test_specs_map_without_db(self, client: TestClient) -> None:
351+
"""Specs map should return 503 when DB not configured."""
352+
with patch(DB_CONFIG_PATCH, return_value=False):
353+
response = client.get("/specs/map")
354+
assert response.status_code == 503
355+
356+
def test_specs_map_returns_list(self, client: TestClient, mock_spec) -> None:
357+
"""Specs map returns one row per spec with best-impl preview + tag bag."""
358+
mock_spec_repo = MagicMock()
359+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
360+
361+
with (
362+
patch(DB_CONFIG_PATCH, return_value=True),
363+
patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache),
364+
patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo),
365+
):
366+
response = client.get("/specs/map")
367+
assert response.status_code == 200
368+
data = response.json()
369+
assert isinstance(data, list)
370+
assert len(data) == 1
371+
row = data[0]
372+
assert row["id"] == "scatter-basic"
373+
assert row["title"] == "Basic Scatter Plot"
374+
assert row["preview_url_light"] == TEST_IMAGE_URL
375+
assert row["quality_score"] == 92.5
376+
assert row["tags"] == {
377+
"plot_type": ["scatter"],
378+
"domain": ["statistics"],
379+
"data_type": ["numeric"],
380+
"features": ["basic"],
381+
}
382+
assert row["impl_tags"] == {"patterns": ["data-generation"], "styling": ["alpha-blending"]}
383+
384+
def test_specs_map_picks_best_impl(self, client: TestClient, mock_spec) -> None:
385+
"""Specs map picks the impl with the highest quality_score per spec."""
386+
# Append a second, lower-rated impl with a distinct preview URL
387+
worse_impl = MagicMock()
388+
worse_impl.library_id = "seaborn"
389+
worse_impl.preview_url_light = "https://example.com/worse-light.png"
390+
worse_impl.preview_url_dark = None
391+
worse_impl.quality_score = 60.0
392+
worse_impl.impl_tags = {"patterns": ["should-not-appear"]}
393+
mock_spec.impls = [worse_impl, mock_spec.impls[0]] # quality 60 then quality 92.5
394+
395+
mock_spec_repo = MagicMock()
396+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec])
397+
398+
with (
399+
patch(DB_CONFIG_PATCH, return_value=True),
400+
patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache),
401+
patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo),
402+
):
403+
response = client.get("/specs/map")
404+
assert response.status_code == 200
405+
data = response.json()
406+
assert len(data) == 1
407+
assert data[0]["preview_url_light"] == TEST_IMAGE_URL # higher-rated matplotlib impl
408+
assert data[0]["quality_score"] == 92.5
409+
assert data[0]["impl_tags"] == {"patterns": ["data-generation"], "styling": ["alpha-blending"]}
410+
411+
def test_specs_map_skips_specs_without_impls(self, client: TestClient, mock_spec) -> None:
412+
"""Specs map omits specs with zero implementations (matches /specs behavior)."""
413+
empty_spec = MagicMock()
414+
empty_spec.id = "no-impls"
415+
empty_spec.title = "No Implementations Yet"
416+
empty_spec.impls = []
417+
418+
mock_spec_repo = MagicMock()
419+
mock_spec_repo.get_all = AsyncMock(return_value=[mock_spec, empty_spec])
420+
421+
with (
422+
patch(DB_CONFIG_PATCH, return_value=True),
423+
patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache),
424+
patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo),
425+
):
426+
response = client.get("/specs/map")
427+
assert response.status_code == 200
428+
data = response.json()
429+
assert [row["id"] for row in data] == ["scatter-basic"]
430+
431+
def test_specs_map_empty_db(self, client: TestClient) -> None:
432+
"""Specs map returns [] (not 404) when there are no specs."""
433+
mock_spec_repo = MagicMock()
434+
mock_spec_repo.get_all = AsyncMock(return_value=[])
435+
436+
with (
437+
patch(DB_CONFIG_PATCH, return_value=True),
438+
patch("api.routers.specs.get_or_set_cache", side_effect=_passthrough_cache),
439+
patch("api.routers.specs.SpecRepository", return_value=mock_spec_repo),
440+
):
441+
response = client.get("/specs/map")
442+
assert response.status_code == 200
443+
assert response.json() == []
444+
350445

351446
class TestDownloadRouter:
352447
"""Tests for download router."""

0 commit comments

Comments
 (0)