From c035ebd737f174c6deab12f7a282d339c5157fda Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 13:41:01 +0200 Subject: [PATCH 01/16] feat(models): add cluster detail, sub-cluster, and annotation models --- embedding_cluster/server/models.py | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/embedding_cluster/server/models.py b/embedding_cluster/server/models.py index 8de9ca2..b53843e 100644 --- a/embedding_cluster/server/models.py +++ b/embedding_cluster/server/models.py @@ -156,3 +156,70 @@ class SearchRequest(BaseModel): class SearchResponse(BaseModel): results: list[SearchResult] + + +class ClusterItemResponse(BaseModel): + id: str + metadata: dict[str, object] + distance_to_centroid: float + + +class ClusterDetailResponse(BaseModel): + cluster_index: int + cluster_name: str + total_items: int + page: int + page_size: int + items: list[ClusterItemResponse] + + +class SubClusterRequest(BaseModel): + num_sub_clusters: int = 3 + + @field_validator("num_sub_clusters") + @classmethod + def validate_num_sub_clusters(cls, v: int) -> int: + if v < 2: + msg = "num_sub_clusters must be at least 2" + raise ValueError(msg) + return v + + +class SubClusterPoint(BaseModel): + id: str + x: float + y: float + z: float + sub_cluster: int + metadata: dict[str, object] + + +class SubClusterInfo(BaseModel): + index: int + count: int + color: str + + +class SubClusterResponse(BaseModel): + parent_cluster_index: int + points: list[SubClusterPoint] + sub_clusters: list[SubClusterInfo] + total_points: int + + +class AnnotationUpdate(BaseModel): + name: str | None = None + notes: str | None = None + tags: list[str] | None = None + + +class ClusterAnnotation(BaseModel): + name: str | None = None + notes: str | None = None + tags: list[str] | None = None + updated_at: str | None = None + + +class AnnotationsResponse(BaseModel): + job_id: str + clusters: dict[str, ClusterAnnotation] From 308230ede911298ef9afc79514c90e0e798d14d5 Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 13:44:06 +0200 Subject: [PATCH 02/16] feat(annotations): add AnnotationManager with file-based JSON storage --- embedding_cluster/annotations.py | 69 ++++++++++++++++++++++++ tests/test_annotations.py | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 embedding_cluster/annotations.py create mode 100644 tests/test_annotations.py diff --git a/embedding_cluster/annotations.py b/embedding_cluster/annotations.py new file mode 100644 index 0000000..5e4aabc --- /dev/null +++ b/embedding_cluster/annotations.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_DEFAULT_BASE_DIR = Path("./annotations") + + +class AnnotationManager: + def __init__(self, base_dir: Path | None = None) -> None: + self._base_dir = base_dir or _DEFAULT_BASE_DIR + self._base_dir.mkdir(parents=True, exist_ok=True) + + def _file_path(self, job_id: str) -> Path: + return self._base_dir / f"{job_id}.json" + + def _read(self, job_id: str) -> dict[str, Any]: + path = self._file_path(job_id) + if not path.exists(): + return {"job_id": job_id, "clusters": {}} + data: dict[str, Any] = json.loads(path.read_text()) + return data + + def _write(self, job_id: str, data: dict[str, Any]) -> None: + path = self._file_path(job_id) + path.write_text(json.dumps(data, indent=2)) + + def get_annotations(self, job_id: str) -> dict[str, Any]: + return self._read(job_id) + + def update_annotation( + self, + job_id: str, + cluster_index: int, + name: str | None = None, + notes: str | None = None, + tags: list[str] | None = None, + ) -> dict[str, Any]: + data = self._read(job_id) + key = str(cluster_index) + if key not in data["clusters"]: + data["clusters"][key] = { + "name": None, + "notes": None, + "tags": None, + "updated_at": None, + } + cluster = data["clusters"][key] + if name is not None: + cluster["name"] = name + if notes is not None: + cluster["notes"] = notes + if tags is not None: + cluster["tags"] = tags + cluster["updated_at"] = datetime.now( + tz=timezone.utc # noqa: UP017 + ).isoformat() + self._write(job_id, data) + return data + + def delete_annotations(self, job_id: str) -> None: + path = self._file_path(job_id) + if path.exists(): + path.unlink() diff --git a/tests/test_annotations.py b/tests/test_annotations.py new file mode 100644 index 0000000..714eb93 --- /dev/null +++ b/tests/test_annotations.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from embedding_cluster.annotations import AnnotationManager + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def annotations_dir(tmp_path: Path) -> Path: + return tmp_path / "annotations" + + +@pytest.fixture +def manager(annotations_dir: Path) -> AnnotationManager: + return AnnotationManager(base_dir=annotations_dir) + + +class TestAnnotationManager: + def test_get_empty_annotations(self, manager: AnnotationManager) -> None: + result = manager.get_annotations("job1") + assert result == {"job_id": "job1", "clusters": {}} + + def test_update_cluster_annotation(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, name="Shoes", notes="Athletic shoes") + result = manager.get_annotations("job1") + assert "0" in result["clusters"] + assert result["clusters"]["0"]["name"] == "Shoes" + assert result["clusters"]["0"]["notes"] == "Athletic shoes" + assert result["clusters"]["0"]["updated_at"] is not None + + def test_update_partial_fields(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, name="Shoes") + manager.update_annotation("job1", 0, notes="Running shoes") + result = manager.get_annotations("job1") + assert result["clusters"]["0"]["name"] == "Shoes" + assert result["clusters"]["0"]["notes"] == "Running shoes" + + def test_update_tags(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, tags=["footwear", "sport"]) + result = manager.get_annotations("job1") + assert result["clusters"]["0"]["tags"] == [ + "footwear", + "sport", + ] + + def test_multiple_clusters(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, name="Shoes") + manager.update_annotation("job1", 1, name="Hats") + result = manager.get_annotations("job1") + assert len(result["clusters"]) == 2 + assert result["clusters"]["0"]["name"] == "Shoes" + assert result["clusters"]["1"]["name"] == "Hats" + + def test_persistence(self, annotations_dir: Path) -> None: + manager1 = AnnotationManager(base_dir=annotations_dir) + manager1.update_annotation("job1", 0, name="Shoes") + manager2 = AnnotationManager(base_dir=annotations_dir) + result = manager2.get_annotations("job1") + assert result["clusters"]["0"]["name"] == "Shoes" + + def test_delete_annotations(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, name="Shoes") + manager.delete_annotations("job1") + result = manager.get_annotations("job1") + assert result == {"job_id": "job1", "clusters": {}} + + def test_file_created_in_correct_dir( + self, + manager: AnnotationManager, + annotations_dir: Path, + ) -> None: + manager.update_annotation("job1", 0, name="Shoes") + file_path = annotations_dir / "job1.json" + assert file_path.exists() + data = json.loads(file_path.read_text()) + assert data["job_id"] == "job1" + + def test_multiple_jobs(self, manager: AnnotationManager) -> None: + manager.update_annotation("job1", 0, name="Shoes") + manager.update_annotation("job2", 0, name="Hats") + r1 = manager.get_annotations("job1") + r2 = manager.get_annotations("job2") + assert r1["clusters"]["0"]["name"] == "Shoes" + assert r2["clusters"]["0"]["name"] == "Hats" From 1533a881c66a245f960979a6680dcd831c6a308f Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 13:49:23 +0200 Subject: [PATCH 03/16] feat(plot): store standardized embeddings and labels in task result --- embedding_cluster/scatter_plot.py | 3 + embedding_cluster/server/routes/plot.py | 5 +- tests/test_scatter_plot.py | 78 +++++++++++++++++++++++++ tests/test_server_plot.py | 46 +++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) diff --git a/embedding_cluster/scatter_plot.py b/embedding_cluster/scatter_plot.py index 6400305..769422a 100644 --- a/embedding_cluster/scatter_plot.py +++ b/embedding_cluster/scatter_plot.py @@ -340,6 +340,9 @@ def compute_plot_data(settings: Settings) -> dict[str, Any]: "points": points, "clusters": clusters, "total_points": len(collection_content["ids"]), + "embeddings_standardized": embeddings_standardized.tolist(), + "cluster_labels": np.asarray(pred_arr).tolist(), + "point_ids": [p["id"] for p in points], } diff --git a/embedding_cluster/server/routes/plot.py b/embedding_cluster/server/routes/plot.py index cba9303..a0ea21e 100644 --- a/embedding_cluster/server/routes/plot.py +++ b/embedding_cluster/server/routes/plot.py @@ -71,10 +71,13 @@ async def get_plot_data(job_id: str) -> dict[str, object]: return {"status": "failed", "error": task.error, "ready": False} # COMPLETED result = cast("dict[str, object]", task.result) + # Strip internal fields not meant for the frontend + internal_keys = ("embeddings_standardized", "cluster_labels", "point_ids") + frontend_result = {k: v for k, v in result.items() if k not in internal_keys} return { "status": "completed", "ready": True, - **result, + **frontend_result, } diff --git a/tests/test_scatter_plot.py b/tests/test_scatter_plot.py index 80333ab..e98e58f 100644 --- a/tests/test_scatter_plot.py +++ b/tests/test_scatter_plot.py @@ -603,3 +603,81 @@ def test_defaults_to_all_metadata_when_no_fields_selected(self) -> None: "imageUrl": "url1", "category": "a", } + + +class TestComputePlotDataInternalFields: + """Tests for internal fields added by compute_plot_data.""" + + def _make_settings(self) -> Settings: + return Settings( + running_mode="PLOT", + chromadb_collection_name="test_collection", + num_clusters=2, + reduction_algorithm="pca", + text_display_fields=["name"], + image_field="imageUrl", + ) + + def _run_compute(self, settings: Settings, n_points: int = 4) -> dict[str, object]: + from embedding_cluster.scatter_plot import compute_plot_data + + rng = np.random.default_rng(42) + embeddings = rng.random((n_points, 5)).tolist() + ids = [str(i) for i in range(n_points)] + metadatas = [{"name": f"item{i}", "imageUrl": f"url{i}"} for i in range(n_points)] + collection_content = { + "ids": ids, + "embeddings": embeddings, + "metadatas": metadatas, + } + labels = np.array([i % 2 for i in range(n_points)]) + + with ( + patch( + "embedding_cluster.scatter_plot.load_chromadb_collection", + return_value=collection_content, + ), + patch( + "embedding_cluster.scatter_plot.reduce_dimensions", + return_value=np.zeros((n_points, 3)), + ), + patch( + "embedding_cluster.scatter_plot.KMeans", + ) as mock_kmeans_cls, + ): + mock_kmeans = MagicMock() + mock_kmeans.fit_predict.return_value = labels + mock_kmeans.cluster_centers_ = rng.random((2, 5)) + mock_kmeans_cls.return_value = mock_kmeans + return compute_plot_data(settings) + + def test_embeddings_standardized_present(self) -> None: + result = self._run_compute(self._make_settings()) + assert "embeddings_standardized" in result + + def test_embeddings_standardized_is_list_of_lists(self) -> None: + result = self._run_compute(self._make_settings(), n_points=4) + emb = result["embeddings_standardized"] + assert isinstance(emb, list) + assert len(emb) == 4 + assert isinstance(emb[0], list) + + def test_cluster_labels_present(self) -> None: + result = self._run_compute(self._make_settings()) + assert "cluster_labels" in result + + def test_cluster_labels_length_matches_points(self) -> None: + result = self._run_compute(self._make_settings(), n_points=4) + labels = result["cluster_labels"] + assert isinstance(labels, list) + assert len(labels) == 4 + + def test_point_ids_present(self) -> None: + result = self._run_compute(self._make_settings()) + assert "point_ids" in result + + def test_point_ids_match_collection_ids(self) -> None: + result = self._run_compute(self._make_settings(), n_points=4) + point_ids = result["point_ids"] + assert isinstance(point_ids, list) + assert sorted(point_ids) == ["0", "1", "2", "3"] diff --git a/tests/test_server_plot.py b/tests/test_server_plot.py index 3471109..4aa1d76 100644 --- a/tests/test_server_plot.py +++ b/tests/test_server_plot.py @@ -415,3 +415,49 @@ async def test_suggest_clusters_status_completed( result = cast("dict[str, object]", data["result"]) assert "k_values" in result assert "suggested_k" in result + + +@pytest.mark.asyncio +async def test_get_data_strips_internal_fields(app: FastAPI) -> None: + """Internal fields should not leak to the frontend response.""" + + def fake_compute_with_internals(_settings: object) -> dict[str, object]: + return { + "points": [ + {"x": 1.0, "y": 2.0, "z": 3.0, "cluster": 0, "metadata": {}, "id": "1"}, + ], + "clusters": [{"index": 0, "name": "A", "color": "red", "count": 1}], + "total_points": 1, + "embeddings_standardized": [[0.1, 0.2]], + "cluster_labels": [0], + "point_ids": ["1"], + } + + with patch( + "embedding_cluster.server.routes.plot.compute_plot_data", + side_effect=fake_compute_with_internals, + ): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start_resp = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test_collection"}, + ) + job_id = cast("str", start_resp.json()["job_id"]) + + await asyncio.sleep(0.2) + + response = await client.get(f"/api/plot/data/{job_id}") + + assert response.status_code == status.HTTP_200_OK + data = cast("dict[str, object]", response.json()) + assert cast("bool", data["ready"]) is True + # Public fields present + assert "points" in data + assert "clusters" in data + assert "total_points" in data + # Internal fields stripped + assert "embeddings_standardized" not in data + assert "cluster_labels" not in data + assert "point_ids" not in data From d37b7c67c357672cbd404038bdde67441cc28523 Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 13:51:51 +0200 Subject: [PATCH 04/16] feat(api): add cluster detail and sub-cluster endpoints --- embedding_cluster/server/routes/plot.py | 182 ++++++++++++++++++++- tests/test_cluster_detail.py | 205 ++++++++++++++++++++++++ tests/test_sub_cluster.py | 186 +++++++++++++++++++++ 3 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 tests/test_cluster_detail.py create mode 100644 tests/test_sub_cluster.py diff --git a/embedding_cluster/server/routes/plot.py b/embedding_cluster/server/routes/plot.py index a0ea21e..26658f7 100644 --- a/embedding_cluster/server/routes/plot.py +++ b/embedding_cluster/server/routes/plot.py @@ -2,18 +2,25 @@ import asyncio import logging -from typing import cast +from typing import Any, cast from fastapi import APIRouter, HTTPException from embedding_cluster.scatter_plot import ( compute_plot_data, load_chromadb_embeddings, + reduce_dimensions, suggest_optimal_clusters, ) from embedding_cluster.server.models import ( + ClusterDetailResponse, + ClusterItemResponse, IndexStartResponse, PlotRequest, + SubClusterInfo, + SubClusterPoint, + SubClusterRequest, + SubClusterResponse, SuggestClustersRequest, ) from embedding_cluster.server.tasks import TaskState, TaskStatus, task_registry @@ -148,3 +155,176 @@ async def get_suggest_clusters_status(job_id: str) -> dict[str, object]: "ready": True, "result": result, } + + +@router.get( + "/{job_id}/cluster/{cluster_index}", + response_model=ClusterDetailResponse, +) +async def get_cluster_detail( + job_id: str, + cluster_index: int, + page: int = 1, + page_size: int = 50, +) -> ClusterDetailResponse: + task = task_registry.get(job_id) + if task is None: + raise HTTPException(status_code=404, detail="Job not found") + if task.status != TaskStatus.COMPLETED: + raise HTTPException(status_code=409, detail="Job not completed") + + result = cast("dict[str, object]", task.result) + clusters = cast("list[dict[str, object]]", result["clusters"]) + cluster_labels = cast("list[int]", result["cluster_labels"]) + embeddings = cast("list[list[float]]", result["embeddings_standardized"]) + points = cast("list[dict[str, object]]", result["points"]) + + # Validate cluster index + cluster_info: dict[str, Any] | None = None + for c in clusters: + if cast("int", c["index"]) == cluster_index: + cluster_info = cast("dict[str, Any]", c) + break + if cluster_info is None: + raise HTTPException(status_code=404, detail="Cluster not found") + + # Get indices belonging to this cluster + cluster_point_indices = [ + i for i, label in enumerate(cluster_labels) if label == cluster_index + ] + + # Compute centroid + import numpy as np + + cluster_embeddings = np.array([embeddings[i] for i in cluster_point_indices]) + centroid = cluster_embeddings.mean(axis=0) + + # Compute distances and build items + items_with_distance: list[tuple[float, dict[str, object]]] = [] + for idx in cluster_point_indices: + point_embedding = np.array(embeddings[idx]) + distance = float(np.linalg.norm(point_embedding - centroid)) + point = points[idx] + items_with_distance.append((distance, point)) + + # Sort by distance + items_with_distance.sort(key=lambda x: x[0]) + + # Paginate + total_items = len(items_with_distance) + start = (page - 1) * page_size + end = start + page_size + page_items = items_with_distance[start:end] + + return ClusterDetailResponse( + cluster_index=cluster_index, + cluster_name=cast("str", cluster_info["name"]), + total_items=total_items, + page=page, + page_size=page_size, + items=[ + ClusterItemResponse( + id=cast("str", point["id"]), + metadata=cast("dict[str, object]", point["metadata"]), + distance_to_centroid=dist, + ) + for dist, point in page_items + ], + ) + + +@router.post( + "/{job_id}/cluster/{cluster_index}/sub-cluster", + response_model=SubClusterResponse, +) +async def sub_cluster( + job_id: str, + cluster_index: int, + request: SubClusterRequest, +) -> SubClusterResponse: + task = task_registry.get(job_id) + if task is None: + raise HTTPException(status_code=404, detail="Job not found") + if task.status != TaskStatus.COMPLETED: + raise HTTPException(status_code=409, detail="Job not completed") + + result = cast("dict[str, object]", task.result) + clusters = cast("list[dict[str, object]]", result["clusters"]) + cluster_labels = cast("list[int]", result["cluster_labels"]) + embeddings = cast("list[list[float]]", result["embeddings_standardized"]) + points = cast("list[dict[str, object]]", result["points"]) + + # Validate cluster exists + cluster_exists = any(cast("int", c["index"]) == cluster_index for c in clusters) + if not cluster_exists: + raise HTTPException(status_code=404, detail="Cluster not found") + + # Get indices for this cluster + cluster_point_indices = [ + i for i, label in enumerate(cluster_labels) if label == cluster_index + ] + + num_sub = request.num_sub_clusters + if num_sub > len(cluster_point_indices): + raise HTTPException( + status_code=400, + detail=( + f"num_sub_clusters ({num_sub}) exceeds " + f"items in cluster ({len(cluster_point_indices)})" + ), + ) + + # Run k-means on cluster subset + import numpy as np + + cluster_embeddings = np.array([embeddings[i] for i in cluster_point_indices]) + + def _compute() -> SubClusterResponse: + from sklearn.cluster import KMeans + + kmeans = KMeans( + n_clusters=num_sub, + n_init="auto", + random_state=171, + max_iter=1000, + ) + sub_labels = kmeans.fit_predict(cluster_embeddings) + + # Reduce dimensions for visualization + reduced = reduce_dimensions( + cluster_embeddings, + algorithm="pca", + n_components=3, + ) + + sub_points: list[SubClusterPoint] = [] + for j, idx in enumerate(cluster_point_indices): + point = points[idx] + sub_points.append( + SubClusterPoint( + id=cast("str", point["id"]), + x=float(reduced[j, 0]), + y=float(reduced[j, 1]), + z=float(reduced[j, 2]), + sub_cluster=int(sub_labels[j]), + metadata=cast( + "dict[str, object]", + point["metadata"], + ), + ) + ) + + sub_cluster_infos: list[SubClusterInfo] = [] + for si in range(num_sub): + count = int(np.sum(sub_labels == si)) + color = f"hsl({si * 360 // num_sub}, 70%, 50%)" + sub_cluster_infos.append(SubClusterInfo(index=si, count=count, color=color)) + + return SubClusterResponse( + parent_cluster_index=cluster_index, + points=sub_points, + sub_clusters=sub_cluster_infos, + total_points=len(cluster_point_indices), + ) + + return await asyncio.to_thread(_compute) diff --git a/tests/test_cluster_detail.py b/tests/test_cluster_detail.py new file mode 100644 index 0000000..37ce460 --- /dev/null +++ b/tests/test_cluster_detail.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast +from unittest.mock import patch + +import numpy as np +import pytest +from fastapi import status +from httpx import ASGITransport, AsyncClient + +if TYPE_CHECKING: + from collections.abc import Iterator + + from fastapi import FastAPI + +from embedding_cluster.server.app import create_app + + +@pytest.fixture +def app() -> FastAPI: + return create_app() + + +def _fake_compute_with_internals( + _settings: object, +) -> dict[str, object]: + """Fake compute that includes internal fields.""" + # 4 points, 2 clusters + embeddings = np.array( + [ + [1.0, 0.0], + [1.1, 0.1], + [5.0, 5.0], + [5.1, 5.1], + ] + ) + return { + "points": [ + { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "cluster": 0, + "metadata": {"name": "item1"}, + "id": "1", + }, + { + "x": 0.1, + "y": 0.1, + "z": 0.1, + "cluster": 0, + "metadata": {"name": "item2"}, + "id": "2", + }, + { + "x": 1.0, + "y": 1.0, + "z": 1.0, + "cluster": 1, + "metadata": {"name": "item3"}, + "id": "3", + }, + { + "x": 1.1, + "y": 1.1, + "z": 1.1, + "cluster": 1, + "metadata": {"name": "item4"}, + "id": "4", + }, + ], + "clusters": [ + { + "index": 0, + "name": "Group 1", + "color": "hsl(0, 70%, 50%)", + "count": 2, + }, + { + "index": 1, + "name": "Group 2", + "color": "hsl(180, 70%, 50%)", + "count": 2, + }, + ], + "total_points": 4, + "embeddings_standardized": embeddings.tolist(), + "cluster_labels": [0, 0, 1, 1], + "point_ids": ["1", "2", "3", "4"], + } + + +@pytest.fixture +def mock_compute_internals() -> Iterator[None]: + with patch( + "embedding_cluster.server.routes.plot.compute_plot_data", + side_effect=_fake_compute_with_internals, + ): + yield + + +@pytest.mark.asyncio +async def test_cluster_detail_success(app: FastAPI, mock_compute_internals: None) -> None: + _ = mock_compute_internals + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.get(f"/api/plot/{job_id}/cluster/0") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["cluster_index"] == 0 + assert data["cluster_name"] == "Group 1" + assert data["total_items"] == 2 + assert len(data["items"]) == 2 + # Items should be sorted by distance to centroid + distances = [item["distance_to_centroid"] for item in data["items"]] + assert distances == sorted(distances) + + +@pytest.mark.asyncio +async def test_cluster_detail_pagination( + app: FastAPI, mock_compute_internals: None +) -> None: + _ = mock_compute_internals + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.get(f"/api/plot/{job_id}/cluster/0?page=1&page_size=1") + + data = response.json() + assert data["page"] == 1 + assert data["page_size"] == 1 + assert len(data["items"]) == 1 + assert data["total_items"] == 2 + + +@pytest.mark.asyncio +async def test_cluster_detail_invalid_cluster( + app: FastAPI, mock_compute_internals: None +) -> None: + _ = mock_compute_internals + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.get(f"/api/plot/{job_id}/cluster/99") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_cluster_detail_invalid_job( + app: FastAPI, +) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/plot/nonexistent/cluster/0") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_cluster_detail_job_not_ready( + app: FastAPI, mock_compute_internals: None +) -> None: + _ = mock_compute_internals + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + # Don't wait - query immediately + response = await client.get(f"/api/plot/{job_id}/cluster/0") + + # Should get 409 or 200 depending on timing + assert response.status_code in ( + status.HTTP_409_CONFLICT, + status.HTTP_200_OK, + ) diff --git a/tests/test_sub_cluster.py b/tests/test_sub_cluster.py new file mode 100644 index 0000000..2f29131 --- /dev/null +++ b/tests/test_sub_cluster.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast +from unittest.mock import patch + +import numpy as np +import pytest +from fastapi import status +from httpx import ASGITransport, AsyncClient + +if TYPE_CHECKING: + from collections.abc import Iterator + + from fastapi import FastAPI + +from embedding_cluster.server.app import create_app + + +@pytest.fixture +def app() -> FastAPI: + return create_app() + + +def _fake_compute_for_subcluster( + _settings: object, +) -> dict[str, object]: + """Fake compute with enough points per cluster.""" + rng = np.random.default_rng(42) + n_per_cluster = 20 + emb_dim = 10 + cluster0_emb = rng.normal(0, 1, (n_per_cluster, emb_dim)) + cluster1_emb = rng.normal(5, 1, (n_per_cluster, emb_dim)) + all_emb = np.vstack([cluster0_emb, cluster1_emb]) + + points = [] + labels = [] + ids = [] + for i in range(n_per_cluster * 2): + cluster = 0 if i < n_per_cluster else 1 + points.append( + { + "x": float(i), + "y": float(i), + "z": float(i), + "cluster": cluster, + "metadata": {"name": f"item{i}"}, + "id": str(i), + } + ) + labels.append(cluster) + ids.append(str(i)) + + return { + "points": points, + "clusters": [ + { + "index": 0, + "name": "Group 1", + "color": "hsl(0, 70%, 50%)", + "count": n_per_cluster, + }, + { + "index": 1, + "name": "Group 2", + "color": "hsl(180, 70%, 50%)", + "count": n_per_cluster, + }, + ], + "total_points": n_per_cluster * 2, + "embeddings_standardized": all_emb.tolist(), + "cluster_labels": labels, + "point_ids": ids, + } + + +@pytest.fixture +def mock_compute_subcluster() -> Iterator[None]: + with patch( + "embedding_cluster.server.routes.plot.compute_plot_data", + side_effect=_fake_compute_for_subcluster, + ): + yield + + +@pytest.mark.asyncio +async def test_sub_cluster_success(app: FastAPI, mock_compute_subcluster: None) -> None: + _ = mock_compute_subcluster + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.post( + f"/api/plot/{job_id}/cluster/0/sub-cluster", + json={"num_sub_clusters": 3}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["parent_cluster_index"] == 0 + assert data["total_points"] == 20 + assert len(data["sub_clusters"]) == 3 + assert len(data["points"]) == 20 + # Each point has sub_cluster assignment + for point in data["points"]: + assert "sub_cluster" in point + assert 0 <= point["sub_cluster"] < 3 + + +@pytest.mark.asyncio +async def test_sub_cluster_invalid_cluster( + app: FastAPI, mock_compute_subcluster: None +) -> None: + _ = mock_compute_subcluster + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.post( + f"/api/plot/{job_id}/cluster/99/sub-cluster", + json={"num_sub_clusters": 3}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_sub_cluster_too_few_points( + app: FastAPI, mock_compute_subcluster: None +) -> None: + """When num_sub_clusters > num_items, return 400.""" + _ = mock_compute_subcluster + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + start = await client.post( + "/api/plot/compute", + json={"chromadb_collection_name": "test"}, + ) + job_id = cast("str", start.json()["job_id"]) + await asyncio.sleep(0.2) + + response = await client.post( + f"/api/plot/{job_id}/cluster/0/sub-cluster", + json={"num_sub_clusters": 100}, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.asyncio +async def test_sub_cluster_invalid_job(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/plot/nonexistent/cluster/0/sub-cluster", + json={"num_sub_clusters": 3}, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.asyncio +async def test_sub_cluster_validation_min(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/plot/somejob/cluster/0/sub-cluster", + json={"num_sub_clusters": 1}, + ) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY From 14bc14a2bac5e3409a0ce904ae832762222db920 Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 13:55:53 +0200 Subject: [PATCH 05/16] feat(api): add annotation CRUD endpoints and gitignore annotations dir --- .gitignore | 1 + embedding_cluster/server/app.py | 4 + .../server/routes/annotations.py | 57 +++++++++ tests/test_server_annotations.py | 111 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 embedding_cluster/server/routes/annotations.py create mode 100644 tests/test_server_annotations.py diff --git a/.gitignore b/.gitignore index 86be558..96b7c89 100644 --- a/.gitignore +++ b/.gitignore @@ -161,5 +161,6 @@ cython_debug/ #.idea/ chromadb +annotations/ .worktrees/ diff --git a/embedding_cluster/server/app.py b/embedding_cluster/server/app.py index 937c0c4..7a352da 100644 --- a/embedding_cluster/server/app.py +++ b/embedding_cluster/server/app.py @@ -8,6 +8,9 @@ from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from embedding_cluster.server.routes.annotations import ( + router as annotations_router, +) from embedding_cluster.server.routes.collections import ( router as collections_router, ) @@ -44,6 +47,7 @@ async def health_check() -> dict[str, str]: app.include_router(csv_router) app.include_router(index_router) app.include_router(plot_router) + app.include_router(annotations_router) app.include_router(search_router) if FRONTEND_DIR.is_dir(): diff --git a/embedding_cluster/server/routes/annotations.py b/embedding_cluster/server/routes/annotations.py new file mode 100644 index 0000000..c6e17ad --- /dev/null +++ b/embedding_cluster/server/routes/annotations.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from fastapi import APIRouter + +from embedding_cluster.annotations import AnnotationManager +from embedding_cluster.server.models import ( + AnnotationsResponse, + AnnotationUpdate, + MessageResponse, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_ANNOTATIONS_DIR = Path("./annotations") + +router = APIRouter(prefix="/api/annotations", tags=["annotations"]) + + +def _get_manager() -> AnnotationManager: + return AnnotationManager(base_dir=_DEFAULT_ANNOTATIONS_DIR) + + +@router.get("/{job_id}", response_model=AnnotationsResponse) +async def get_annotations(job_id: str) -> AnnotationsResponse: + manager = _get_manager() + data = manager.get_annotations(job_id) + return AnnotationsResponse(**data) + + +@router.put( + "/{job_id}/cluster/{cluster_index}", + response_model=AnnotationsResponse, +) +async def update_annotation( + job_id: str, + cluster_index: int, + body: AnnotationUpdate, +) -> AnnotationsResponse: + manager = _get_manager() + data = manager.update_annotation( + job_id, + cluster_index, + name=body.name, + notes=body.notes, + tags=body.tags, + ) + return AnnotationsResponse(**data) + + +@router.delete("/{job_id}", response_model=MessageResponse) +async def delete_annotations(job_id: str) -> MessageResponse: + manager = _get_manager() + manager.delete_annotations(job_id) + return MessageResponse(message="Annotations deleted") diff --git a/tests/test_server_annotations.py b/tests/test_server_annotations.py new file mode 100644 index 0000000..43e79f0 --- /dev/null +++ b/tests/test_server_annotations.py @@ -0,0 +1,111 @@ +# tests/test_server_annotations.py +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +from fastapi import status +from httpx import ASGITransport, AsyncClient + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + from fastapi import FastAPI + +from embedding_cluster.server.app import create_app + + +@pytest.fixture +def app(tmp_path: Path) -> Iterator[FastAPI]: + with patch( + "embedding_cluster.server.routes.annotations._DEFAULT_ANNOTATIONS_DIR", + tmp_path / "annotations", + ): + yield create_app() + + +@pytest.mark.asyncio +async def test_get_annotations_empty(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/annotations/somejob") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["job_id"] == "somejob" + assert data["clusters"] == {} + + +@pytest.mark.asyncio +async def test_update_annotation(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.put( + "/api/annotations/somejob/cluster/0", + json={"name": "Shoes", "notes": "Athletic shoes"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["clusters"]["0"]["name"] == "Shoes" + assert data["clusters"]["0"]["notes"] == "Athletic shoes" + + +@pytest.mark.asyncio +async def test_update_partial_annotation(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.put( + "/api/annotations/somejob/cluster/0", + json={"name": "Shoes"}, + ) + response = await client.put( + "/api/annotations/somejob/cluster/0", + json={"notes": "Running shoes"}, + ) + + data = response.json() + assert data["clusters"]["0"]["name"] == "Shoes" + assert data["clusters"]["0"]["notes"] == "Running shoes" + + +@pytest.mark.asyncio +async def test_delete_annotations(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.put( + "/api/annotations/somejob/cluster/0", + json={"name": "Shoes"}, + ) + response = await client.delete("/api/annotations/somejob") + + assert response.status_code == status.HTTP_200_OK + + # Verify empty after delete + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.get("/api/annotations/somejob") + + data = response.json() + assert data["clusters"] == {} + + +@pytest.mark.asyncio +async def test_annotation_with_tags(app: FastAPI) -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.put( + "/api/annotations/somejob/cluster/0", + json={"tags": ["footwear", "sport"]}, + ) + + data = response.json() + assert data["clusters"]["0"]["tags"] == ["footwear", "sport"] From 96f2a2d1856eae40018331d9fc3caa0464ea5dc8 Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 14:28:14 +0200 Subject: [PATCH 06/16] feat(frontend): add cluster drill-down types and API functions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- frontend/src/api/plot.ts | 67 ++++++++++++++++++++++++++++++++++++- frontend/src/types/index.ts | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/plot.ts b/frontend/src/api/plot.ts index da9de6e..d24a44a 100644 --- a/frontend/src/api/plot.ts +++ b/frontend/src/api/plot.ts @@ -1,4 +1,18 @@ -import type { IndexStartResponse, PlotRequest, PlotResponse, SearchRequest, SearchResponse, SuggestClustersRequest, SuggestClustersStatusResponse } from "../types"; +import type { + AnnotationUpdate, + AnnotationsResponse, + ClusterDetailResponse, + IndexStartResponse, + MessageResponse, + PlotRequest, + PlotResponse, + SearchRequest, + SearchResponse, + SubClusterRequest, + SubClusterResponse, + SuggestClustersRequest, + SuggestClustersStatusResponse, +} from "../types"; import { apiFetch, apiPost } from "./client"; export async function startPlotCompute( @@ -34,3 +48,54 @@ export async function getSuggestClustersStatus( `/plot/suggest-clusters/${jobId}`, ); } + +export async function getClusterDetail( + jobId: string, + clusterIndex: number, + page = 1, + pageSize = 50, +): Promise { + return apiFetch( + `/plot/${jobId}/cluster/${clusterIndex}?page=${page}&page_size=${pageSize}`, + ); +} + +export async function subCluster( + jobId: string, + clusterIndex: number, + request: SubClusterRequest, +): Promise { + return apiPost( + `/plot/${jobId}/cluster/${clusterIndex}/sub-cluster`, + request, + ); +} + +export async function getAnnotations( + jobId: string, +): Promise { + return apiFetch(`/annotations/${jobId}`); +} + +export async function updateAnnotation( + jobId: string, + clusterIndex: number, + body: AnnotationUpdate, +): Promise { + return apiFetch( + `/annotations/${jobId}/cluster/${clusterIndex}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +export async function deleteAnnotations( + jobId: string, +): Promise { + return apiFetch(`/annotations/${jobId}`, { + method: "DELETE", + }); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a1839ab..2aee66c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -147,3 +147,65 @@ export interface SearchRequest { export interface SearchResponse { results: SearchResult[]; } + +// Cluster Detail +export interface ClusterItemResponse { + id: string; + metadata: Record; + distance_to_centroid: number; +} + +export interface ClusterDetailResponse { + cluster_index: number; + cluster_name: string; + total_items: number; + page: number; + page_size: number; + items: ClusterItemResponse[]; +} + +// Sub-Cluster +export interface SubClusterRequest { + num_sub_clusters: number; +} + +export interface SubClusterPoint { + id: string; + x: number; + y: number; + z: number; + sub_cluster: number; + metadata: Record; +} + +export interface SubClusterInfo { + index: number; + count: number; + color: string; +} + +export interface SubClusterResponse { + parent_cluster_index: number; + points: SubClusterPoint[]; + sub_clusters: SubClusterInfo[]; + total_points: number; +} + +// Annotations +export interface AnnotationUpdate { + name?: string; + notes?: string; + tags?: string[]; +} + +export interface ClusterAnnotation { + name?: string; + notes?: string; + tags?: string[]; + updated_at?: string; +} + +export interface AnnotationsResponse { + job_id: string; + clusters: Record; +} From a4251936e104c6fda37bb220b302e8874d24e7b8 Mon Sep 17 00:00:00 2001 From: aGallea Date: Wed, 4 Mar 2026 14:28:26 +0200 Subject: [PATCH 07/16] feat(frontend): add cluster detail drawer and sub-cluster view Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../components/plot/ClusterDetailDrawer.tsx | 280 ++++++++++++++++++ .../src/components/plot/SubClusterView.tsx | 110 +++++++ 2 files changed, 390 insertions(+) create mode 100644 frontend/src/components/plot/ClusterDetailDrawer.tsx create mode 100644 frontend/src/components/plot/SubClusterView.tsx diff --git a/frontend/src/components/plot/ClusterDetailDrawer.tsx b/frontend/src/components/plot/ClusterDetailDrawer.tsx new file mode 100644 index 0000000..4453d18 --- /dev/null +++ b/frontend/src/components/plot/ClusterDetailDrawer.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { usePlotStore, CLUSTER_COLORS } from '../../stores/plotStore' +import { getClusterDetail, updateAnnotation, getAnnotations } from '../../api/plot' +import type { ClusterDetailResponse } from '../../types' +import SubClusterView from './SubClusterView' + +interface ClusterDetailDrawerProps { + jobId: string + imageField?: string +} + +export default function ClusterDetailDrawer({ jobId, imageField }: ClusterDetailDrawerProps) { + const selectedCluster = usePlotStore((s) => s.selectedCluster) + const clusterDetail = usePlotStore((s) => s.clusterDetail) + const annotations = usePlotStore((s) => s.annotations) + const isLoadingClusterDetail = usePlotStore((s) => s.isLoadingClusterDetail) + const plotData = usePlotStore((s) => s.plotData) + const setClusterDetail = usePlotStore((s) => s.setClusterDetail) + const setAnnotations = usePlotStore((s) => s.setAnnotations) + const setIsLoadingClusterDetail = usePlotStore((s) => s.setIsLoadingClusterDetail) + const setHighlightedIds = usePlotStore((s) => s.setHighlightedIds) + const clearClusterDrillDown = usePlotStore((s) => s.clearClusterDrillDown) + + const [page, setPage] = useState(1) + const [isEditingName, setIsEditingName] = useState(false) + const [editName, setEditName] = useState('') + const [notes, setNotes] = useState('') + const [tagsInput, setTagsInput] = useState('') + const [showSubCluster, setShowSubCluster] = useState(false) + const notesTimeoutRef = useRef>(undefined) + const tagsTimeoutRef = useRef>(undefined) + + const clusterIndex = selectedCluster + const cluster = plotData?.clusters.find((c) => c.index === clusterIndex) + const color = clusterIndex != null ? CLUSTER_COLORS[clusterIndex % CLUSTER_COLORS.length] : '#999' + const annotation = clusterIndex != null ? annotations?.clusters[String(clusterIndex)] : undefined + + // Load cluster detail when selected cluster changes + useEffect(() => { + if (clusterIndex == null) return + setPage(1) + setShowSubCluster(false) + setIsLoadingClusterDetail(true) + setClusterDetail(null) + + getClusterDetail(jobId, clusterIndex, 1) + .then((data: ClusterDetailResponse) => setClusterDetail(data)) + .catch(() => setClusterDetail(null)) + .finally(() => setIsLoadingClusterDetail(false)) + }, [jobId, clusterIndex, setClusterDetail, setIsLoadingClusterDetail]) + + // Load annotations + useEffect(() => { + getAnnotations(jobId) + .then(setAnnotations) + .catch(() => setAnnotations(null)) + }, [jobId, setAnnotations]) + + // Sync local notes/tags state with annotation + useEffect(() => { + setNotes(annotation?.notes ?? '') + setTagsInput(annotation?.tags?.join(', ') ?? '') + }, [annotation]) + + const handlePageChange = useCallback((newPage: number) => { + if (clusterIndex == null) return + setPage(newPage) + setIsLoadingClusterDetail(true) + getClusterDetail(jobId, clusterIndex, newPage) + .then((data: ClusterDetailResponse) => setClusterDetail(data)) + .catch(() => {/* keep previous data */}) + .finally(() => setIsLoadingClusterDetail(false)) + }, [jobId, clusterIndex, setClusterDetail, setIsLoadingClusterDetail]) + + const handleSaveName = useCallback(() => { + if (clusterIndex == null) return + setIsEditingName(false) + if (editName.trim()) { + updateAnnotation(jobId, clusterIndex, { name: editName.trim() }) + .then(setAnnotations) + .catch(() => {/* silent */}) + } + }, [jobId, clusterIndex, editName, setAnnotations]) + + const handleNotesChange = useCallback((value: string) => { + setNotes(value) + if (notesTimeoutRef.current) clearTimeout(notesTimeoutRef.current) + notesTimeoutRef.current = setTimeout(() => { + if (clusterIndex != null) { + updateAnnotation(jobId, clusterIndex, { notes: value }) + .then(setAnnotations) + .catch(() => {/* silent */}) + } + }, 800) + }, [jobId, clusterIndex, setAnnotations]) + + const handleTagsChange = useCallback((value: string) => { + setTagsInput(value) + if (tagsTimeoutRef.current) clearTimeout(tagsTimeoutRef.current) + tagsTimeoutRef.current = setTimeout(() => { + if (clusterIndex != null) { + const tags = value.split(',').map((t) => t.trim()).filter(Boolean) + updateAnnotation(jobId, clusterIndex, { tags }) + .then(setAnnotations) + .catch(() => {/* silent */}) + } + }, 800) + }, [jobId, clusterIndex, setAnnotations]) + + const handleItemClick = useCallback((id: string) => { + setHighlightedIds(new Set([id])) + }, [setHighlightedIds]) + + if (clusterIndex == null) return null + + const totalPages = clusterDetail ? Math.ceil(clusterDetail.total_items / clusterDetail.page_size) : 0 + const displayName = annotation?.name ?? cluster?.name ?? `Cluster ${clusterIndex}` + + return ( +
+ {/* Header */} +
+
+ + {isEditingName ? ( + setEditName(e.target.value)} + onBlur={handleSaveName} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName() + if (e.key === 'Escape') setIsEditingName(false) + }} + className="text-sm font-bold text-gray-900 border-b border-blue-500 outline-none bg-transparent w-full" + autoFocus + /> + ) : ( + + )} + {clusterDetail && ( + + ({clusterDetail.total_items} items) + + )} +
+ +
+ + {/* Sub-cluster toggle */} +
+ +
+ + {/* Sub-cluster view */} + {showSubCluster && ( +
+ +
+ )} + + {/* Items list */} +
+ {isLoadingClusterDetail && ( +
+
+
+ )} + + {clusterDetail && !isLoadingClusterDetail && ( +
+ {clusterDetail.items.map((item) => ( + + ))} +
+ )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} / {totalPages} + + +
+ )} + + {/* Annotation section */} +
+
+ +