From b3c01363ad2e42e8529d34920de7aaf0303281e1 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 29 Apr 2026 20:37:45 +0300 Subject: [PATCH 1/3] feat: expose analytics resources --- src/supervaizer/__init__.py | 20 ++ src/supervaizer/agent.py | 26 ++- src/supervaizer/analytics_resource.py | 146 +++++++++++++ src/supervaizer/analytics_routes.py | 213 +++++++++++++++++++ src/supervaizer/contracts.py | 59 ++++++ src/supervaizer/routers/api.py | 3 + tests/test_agent.py | 41 ++++ tests/test_analytics_resource.py | 100 +++++++++ tests/test_contracts.py | 34 +++ tests/test_routes.py | 292 ++++++++++++++++++++++++++ 10 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 src/supervaizer/analytics_resource.py create mode 100644 src/supervaizer/analytics_routes.py create mode 100644 tests/test_analytics_resource.py diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 32a0677..83324f8 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -35,6 +35,13 @@ "CaseNodeType": ("supervaizer.case", "CaseNodeType"), "CaseNodeUpdate": ("supervaizer.case", "CaseNodeUpdate"), "Cases": ("supervaizer.case", "Cases"), + "AnalyticsDataset": ("supervaizer.analytics_resource", "AnalyticsDataset"), + "AnalyticsFilter": ("supervaizer.analytics_resource", "AnalyticsFilter"), + "AnalyticsResource": ("supervaizer.analytics_resource", "AnalyticsResource"), + "AnalyticsResourceContext": ( + "supervaizer.analytics_resource", + "AnalyticsResourceContext", + ), "DataResource": ("supervaizer.data_resource", "DataResource"), "DataResourceContext": ("supervaizer.data_resource", "DataResourceContext"), "DataResourceField": ("supervaizer.data_resource", "DataResourceField"), @@ -68,6 +75,15 @@ "AgentMethodContract": ("supervaizer.contracts", "AgentMethodContract"), "AgentMethodsContract": ("supervaizer.contracts", "AgentMethodsContract"), "AgentRegistrationContract": ("supervaizer.contracts", "AgentRegistrationContract"), + "AnalyticsFilterContract": ("supervaizer.contracts", "AnalyticsFilterContract"), + "AnalyticsResourceContract": ( + "supervaizer.contracts", + "AnalyticsResourceContract", + ), + "AnalyticsResourceContextContract": ( + "supervaizer.contracts", + "AnalyticsResourceContextContract", + ), "ControllerContract": ("supervaizer.contracts", "ControllerContract"), "ControllerEndpoint": ("supervaizer.contracts", "ControllerEndpoint"), "DataResourceContract": ("supervaizer.contracts", "DataResourceContract"), @@ -84,6 +100,10 @@ "supervaizer.contracts", "build_data_resource_context_headers", ), + "build_analytics_context_headers": ( + "supervaizer.contracts", + "build_analytics_context_headers", + ), "controller_contract_info": ("supervaizer.contracts", "controller_contract_info"), "resolve_controller_endpoint": ( "supervaizer.contracts", diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 3fdb5ef..3acc078 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -36,6 +36,7 @@ from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup from supervaizer.case import CaseNodes +from supervaizer.analytics_resource import AnalyticsResource from supervaizer.data_resource import DataResource if TYPE_CHECKING: @@ -642,6 +643,11 @@ class AgentAbstract(SvBaseModel): description="Data resources this agent exposes for Studio CRUD access", exclude=True, ) + analytics_resources: list[AnalyticsResource] = Field( + default_factory=list, + description="Analytics resources this agent exposes for Studio dashboards", + exclude=True, + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -670,6 +676,7 @@ def __init__( custom_routes: Any | None = None, dynamic_choices_callback: Any | None = None, data_resources: list["DataResource"] | None = None, + analytics_resources: list["AnalyticsResource"] | None = None, **kwargs: Any, ) -> None: """ @@ -725,17 +732,27 @@ def __init__( custom_routes=custom_routes, dynamic_choices_callback=dynamic_choices_callback, data_resources=data_resources or [], + analytics_resources=analytics_resources or [], **kwargs, ) - seen_resource_names: set[str] = set() + seen_data_resource_names: set[str] = set() for r in self.data_resources: - if r.name in seen_resource_names: + if r.name in seen_data_resource_names: raise ValueError( f"Duplicate DataResource name {r.name!r} on agent {self.name!r}; " "each data resource must have a unique name per agent." ) - seen_resource_names.add(r.name) + seen_data_resource_names.add(r.name) + + seen_analytics_resource_names: set[str] = set() + for r in self.analytics_resources: + if r.name in seen_analytics_resource_names: + raise ValueError( + f"Duplicate AnalyticsResource name {r.name!r} on agent {self.name!r}; " + "each analytics resource must have a unique name per agent." + ) + seen_analytics_resource_names.add(r.name) def __str__(self) -> str: return f"{self.name} ({self.id})" @@ -774,6 +791,9 @@ def registration_info(self) -> Dict[str, Any]: "max_execution_time": self.max_execution_time, "instructions_path": self.instructions_path, "data_resources": [r.registration_info for r in self.data_resources], + "analytics_resources": [ + r.registration_info for r in self.analytics_resources + ], } def update_agent_from_server(self, server: "Server") -> Optional["Agent"]: diff --git a/src/supervaizer/analytics_resource.py b/src/supervaizer/analytics_resource.py new file mode 100644 index 0000000..7b1f472 --- /dev/null +++ b/src/supervaizer/analytics_resource.py @@ -0,0 +1,146 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""AnalyticsResource model for exposing Vega-Lite dashboards to Studio.""" + +from __future__ import annotations + +from typing import Any, Callable, Literal + +from pydantic import Field, model_validator + +from supervaizer.common import SvBaseModel + +_ANALYTICS_RESOURCE_NAME_PATTERN = r"^[a-z0-9][a-z0-9_-]*$" + + +class AnalyticsResourceContext(SvBaseModel): + """Studio request context passed to AnalyticsResource callbacks.""" + + workspace_id: str | None = None + workspace_slug: str | None = None + mission_id: str | None = None + job_id: str | None = None + agent_slug: str + request_id: str | None = None + filters: dict[str, Any] = Field(default_factory=dict) + + +class AnalyticsFilter(SvBaseModel): + """Describes a dashboard filter Studio can render and forward.""" + + id: str + type: Literal["enum", "date_range", "string", "number", "boolean"] = "enum" + label: str | None = None + default: Any = None + options: list[dict[str, Any]] = Field(default_factory=list) + + +class AnalyticsDataset(SvBaseModel): + """Describes a dataset served for one or more Vega-Lite dashboards.""" + + id: str + description: str = "" + + +class AnalyticsResource(SvBaseModel): + """Declares a named analytics surface exposed to Studio. + + Dashboard manifests use Vega-Lite JSON. Agents may declare static manifests, + dynamic callbacks, or both. Dataset callbacks return JSON values consumed by + the Vega-Lite ``data.url`` references in those manifests. + """ + + model_config = {"arbitrary_types_allowed": True} + + name: str = Field( + description=( + "URL-safe analytics resource identifier, e.g. 'interviewer'. " + "Lowercase letters, digits, underscores, and hyphens only; " + "must start with a letter or digit." + ), + pattern=_ANALYTICS_RESOURCE_NAME_PATTERN, + ) + display_name: str = Field(default="") + description: str = Field(default="") + dashboards: list[dict[str, Any]] = Field(default_factory=list) + datasets: list[AnalyticsDataset | dict[str, Any]] = Field(default_factory=list) + filters: list[AnalyticsFilter | dict[str, Any]] = Field(default_factory=list) + on_list_dashboards: Callable[..., list[dict[str, Any]]] | None = Field( + default=None, exclude=True + ) + on_get_dashboard: Callable[..., dict[str, Any] | None] | None = Field( + default=None, exclude=True + ) + on_get_dataset: ( + Callable[..., dict[str, Any] | list[dict[str, Any]] | None] | None + ) = Field(default=None, exclude=True) + + @model_validator(mode="after") + def check_dashboard_source(self) -> "AnalyticsResource": + if not self.dashboards and self.on_list_dashboards is None: + raise ValueError( + f"AnalyticsResource '{self.name}' must define dashboards or on_list_dashboards" + ) + for dashboard in self.dashboards: + if not dashboard.get("id"): + raise ValueError( + f"Static dashboard in AnalyticsResource '{self.name}' must define id" + ) + return self + + @property + def operations(self) -> dict[str, bool]: + return { + "list_dashboards": True, + "get_dashboard": True, + "get_dataset": self.on_get_dataset is not None, + } + + @property + def display_name_resolved(self) -> str: + return self.display_name or self.name.replace("_", " ").title() + + @property + def registration_info(self) -> dict[str, Any]: + return { + "name": self.name, + "display_name": self.display_name_resolved, + "description": self.description, + "dashboards": [ + _dashboard_summary(dashboard) for dashboard in self.dashboards + ], + "datasets": [ + dataset.model_dump(mode="json") + if isinstance(dataset, AnalyticsDataset) + else dataset + for dataset in self.datasets + ], + "filters": [ + filter_.model_dump(mode="json") + if isinstance(filter_, AnalyticsFilter) + else filter_ + for filter_ in self.filters + ], + "operations": self.operations, + } + + def list_dashboards(self) -> list[dict[str, Any]]: + return self.dashboards + + def get_static_dashboard(self, dashboard_id: str) -> dict[str, Any] | None: + for dashboard in self.dashboards: + if dashboard.get("id") == dashboard_id: + return dashboard + return None + + +def _dashboard_summary(dashboard: dict[str, Any]) -> dict[str, Any]: + return { + key: dashboard[key] + for key in ("id", "title", "description", "version") + if key in dashboard + } diff --git a/src/supervaizer/analytics_routes.py b/src/supervaizer/analytics_routes.py new file mode 100644 index 0000000..4710ba0 --- /dev/null +++ b/src/supervaizer/analytics_routes.py @@ -0,0 +1,213 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""FastAPI route generation for AnalyticsResource dashboard endpoints.""" + +from __future__ import annotations + +import inspect +from typing import TYPE_CHECKING, Any + +from fastapi import APIRouter, HTTPException, Request + +from supervaizer.analytics_resource import AnalyticsResource, AnalyticsResourceContext +from supervaizer.common import log + +if TYPE_CHECKING: + from supervaizer.agent import Agent + from supervaizer.server import Server + + +def create_agent_analytics_routes(server: "Server", agent: "Agent") -> APIRouter: + """Generate analytics REST routes for all AnalyticsResources on an agent.""" + router = APIRouter(prefix=agent.path, tags=["Analytics Resources"]) + agent_slug = agent.slug + for resource in agent.analytics_resources: + _add_resource_routes(router, resource, agent_slug) + return router + + +def _analytics_resource_operation_id( + agent_slug: str, resource_name: str, action: str +) -> str: + """Build a globally unique OpenAPI operation_id.""" + return f"{agent_slug}_{resource_name}_analytics_{action}" + + +def _add_resource_routes( + router: APIRouter, + resource: AnalyticsResource, + agent_slug: str, +) -> None: + prefix = f"/analytics/{resource.name}" + + list_op_id = _analytics_resource_operation_id( + agent_slug, resource.name, "list_dashboards" + ) + router.add_api_route( + f"{prefix}/dashboards/", + _make_list_dashboards_handler(resource, prefix, agent_slug), + methods=["GET"], + summary=f"List {resource.display_name_resolved} dashboards", + operation_id=list_op_id, + name=list_op_id, + ) + + get_op_id = _analytics_resource_operation_id( + agent_slug, resource.name, "get_dashboard" + ) + router.add_api_route( + f"{prefix}/dashboards/{{dashboard_id}}", + _make_get_dashboard_handler(resource, prefix, agent_slug), + methods=["GET"], + summary=f"Get {resource.display_name_resolved} dashboard", + operation_id=get_op_id, + name=get_op_id, + ) + + dataset_op_id = _analytics_resource_operation_id( + agent_slug, resource.name, "get_dataset" + ) + router.add_api_route( + f"{prefix}/dashboards/{{dashboard_id}}/datasets/{{dataset_id}}", + _make_get_dataset_handler(resource, prefix, agent_slug), + methods=["GET"], + summary=f"Get {resource.display_name_resolved} dashboard dataset", + operation_id=dataset_op_id, + name=dataset_op_id, + ) + + +def _make_list_dashboards_handler( + resource: AnalyticsResource, + prefix: str, + agent_slug: str, +) -> Any: + async def _handler(request: Request) -> list[dict[str, Any]]: + log.info( + f"📈 GET {prefix}/dashboards/ [AnalyticsResource list: {resource.name}]" + ) + if resource.on_list_dashboards is None: + return resource.list_dashboards() + return _call_with_context( + resource.on_list_dashboards, + _context_from_request(request, agent_slug), + ) + + return _handler + + +def _make_get_dashboard_handler( + resource: AnalyticsResource, + prefix: str, + agent_slug: str, +) -> Any: + async def _handler(request: Request, dashboard_id: str) -> dict[str, Any]: + log.info( + f"📈 GET {prefix}/dashboards/{dashboard_id} " + f"[AnalyticsResource get: {resource.name}]" + ) + if resource.on_get_dashboard is None: + result = resource.get_static_dashboard(dashboard_id) + else: + result = _call_with_context( + resource.on_get_dashboard, + _context_from_request(request, agent_slug), + dashboard_id, + ) + if result is None: + raise HTTPException( + status_code=404, + detail=f"{resource.name} dashboard '{dashboard_id}' not found", + ) + return result + + return _handler + + +def _make_get_dataset_handler( + resource: AnalyticsResource, + prefix: str, + agent_slug: str, +) -> Any: + async def _handler( + request: Request, + dashboard_id: str, + dataset_id: str, + ) -> dict[str, Any] | list[dict[str, Any]]: + log.info( + f"📈 GET {prefix}/dashboards/{dashboard_id}/datasets/{dataset_id} " + f"[AnalyticsResource dataset: {resource.name}]" + ) + result = _call_with_context( + resource.on_get_dataset, + _context_from_request(request, agent_slug), + dashboard_id, + dataset_id, + ) + if result is None: + raise HTTPException( + status_code=404, + detail=f"{resource.name} dataset '{dataset_id}' not found", + ) + return result + + return _handler + + +def _context_from_request( + request: Request, agent_slug: str +) -> AnalyticsResourceContext: + return AnalyticsResourceContext( + workspace_id=request.headers.get("X-Supervaize-Workspace-Id"), + workspace_slug=request.headers.get("X-Supervaize-Workspace-Slug"), + mission_id=request.headers.get("X-Supervaize-Mission-Id"), + job_id=request.headers.get("X-Supervaize-Job-Id"), + agent_slug=agent_slug, + request_id=request.headers.get("X-Supervaize-Request-Id"), + filters=_filters_from_request(request), + ) + + +def _filters_from_request(request: Request) -> dict[str, Any]: + filters: dict[str, Any] = {} + for key, value in request.query_params.multi_items(): + if key in filters: + filters[key] = _append_filter_value(filters[key], value) + continue + filters[key] = value + return filters + + +def _append_filter_value(existing: Any, value: str) -> list[Any]: + if isinstance(existing, list): + return [*existing, value] + return [existing, value] + + +def _accepts_context(callback: Any) -> bool: + try: + signature = inspect.signature(callback) + except (TypeError, ValueError): + return False + return "context" in signature.parameters + + +def _call_with_context( + callback: Any, + context: AnalyticsResourceContext, + *args: Any, +) -> Any: + if callback is None: + raise HTTPException( + status_code=501, detail="AnalyticsResource callback not configured" + ) + try: + if _accepts_context(callback): + return callback(*args, context=context) + return callback(*args) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 3d146eb..b692805 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -47,6 +47,9 @@ class ControllerEndpoint(StrEnum): DATA_RESOURCE = "DATA_RESOURCE" DATA_RESOURCE_ITEM = "DATA_RESOURCE_ITEM" DATA_RESOURCE_IMPORT = "DATA_RESOURCE_IMPORT" + ANALYTICS_DASHBOARDS = "ANALYTICS_DASHBOARDS" + ANALYTICS_DASHBOARD = "ANALYTICS_DASHBOARD" + ANALYTICS_DATASET = "ANALYTICS_DATASET" HEALTH_CHECK = "HEALTH_CHECK" CONTROLLER_CONTRACT = "CONTROLLER_CONTRACT" @@ -70,6 +73,9 @@ class ControllerEndpoint(StrEnum): ControllerEndpoint.DATA_RESOURCE: "/api/agents/{agent_slug}/data/{resource_name}/", ControllerEndpoint.DATA_RESOURCE_ITEM: "/api/agents/{agent_slug}/data/{resource_name}/{item_id}", ControllerEndpoint.DATA_RESOURCE_IMPORT: "/api/agents/{agent_slug}/data/{resource_name}/import/", + ControllerEndpoint.ANALYTICS_DASHBOARDS: "/api/agents/{agent_slug}/analytics/{resource_name}/dashboards/", + ControllerEndpoint.ANALYTICS_DASHBOARD: "/api/agents/{agent_slug}/analytics/{resource_name}/dashboards/{dashboard_id}", + ControllerEndpoint.ANALYTICS_DATASET: "/api/agents/{agent_slug}/analytics/{resource_name}/dashboards/{dashboard_id}/datasets/{dataset_id}", ControllerEndpoint.HEALTH_CHECK: ".well-known/health", ControllerEndpoint.CONTROLLER_CONTRACT: "/api/supervaizer/contract", } @@ -133,6 +139,36 @@ class DataResourceContract(ContractModel): operations: dict[str, bool] = Field(default_factory=dict) +class AnalyticsResourceContextContract(ContractModel): + workspace_id: str | None = None + workspace_slug: str | None = None + mission_id: str | None = None + job_id: str | None = None + agent_slug: str | None = None + request_id: str | None = None + filters: dict[str, Any] = Field(default_factory=dict) + + +class AnalyticsFilterContract(ContractModel): + id: str + type: str = "enum" + label: str | None = None + default: Any = None + options: list[dict[str, Any]] = Field(default_factory=list) + + +class AnalyticsResourceContract(ContractModel): + name: str + display_name: str + description: str = "" + dashboards: list[dict[str, Any]] = Field(default_factory=list) + datasets: list[dict[str, Any]] = Field(default_factory=list) + filters: list[AnalyticsFilterContract | dict[str, Any]] = Field( + default_factory=list + ) + operations: dict[str, bool] = Field(default_factory=dict) + + class AgentMethodFieldContract(ContractModel): name: str type: str | None = None @@ -189,6 +225,9 @@ class AgentRegistrationContract(ContractModel): data_resources: list[DataResourceContract | dict[str, Any]] = Field( default_factory=list ) + analytics_resources: list[AnalyticsResourceContract | dict[str, Any]] = Field( + default_factory=list + ) class ServerRegistrationContract(ControllerContract): @@ -287,6 +326,26 @@ def build_data_resource_context_headers( return headers +def build_analytics_context_headers( + *, + workspace_id: str | None = None, + workspace_slug: str | None = None, + mission_id: str | None = None, + job_id: str | None = None, + request_id: str | None = None, +) -> dict[str, str]: + """Build Supervaize context headers for Studio AnalyticsResource proxy calls.""" + headers = build_data_resource_context_headers( + workspace_id=workspace_id, + workspace_slug=workspace_slug, + mission_id=mission_id, + request_id=request_id, + ) + if job_id: + headers["X-Supervaize-Job-Id"] = str(job_id) + return headers + + def controller_contract_info() -> dict[str, Any]: """Return the JSON-serializable controller contract.""" return ControllerContract().model_dump(mode="json") diff --git a/src/supervaizer/routers/api.py b/src/supervaizer/routers/api.py index 38d1de3..9257ae7 100644 --- a/src/supervaizer/routers/api.py +++ b/src/supervaizer/routers/api.py @@ -30,6 +30,7 @@ def create_api_router(server: "Server") -> APIRouter: # <-- ADDED Router-level ``require_api_key`` covers every sub-route. Scope-specific routes add ``Depends(require_scope(...))`` themselves. """ + from supervaizer.analytics_routes import create_agent_analytics_routes from supervaizer.data_routes import create_agent_data_routes from supervaizer.routes import ( create_agents_routes, @@ -51,6 +52,8 @@ def create_api_router(server: "Server") -> APIRouter: # <-- ADDED for agent in server.agents: if agent.data_resources: api_router.include_router(create_agent_data_routes(server, agent)) + if agent.analytics_resources: + api_router.include_router(create_agent_analytics_routes(server, agent)) # Agent custom routes (full path: /api/agents/{slug}/... plus each route on the nested router) for agent in server.agents: diff --git a/tests/test_agent.py b/tests/test_agent.py index 7b36bd3..a354214 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1107,6 +1107,7 @@ def test_agent_data_resources_default_empty() -> None: description="description", ) assert agent.data_resources == [] + assert agent.analytics_resources == [] def test_agent_rejects_duplicate_data_resource_names() -> None: @@ -1154,3 +1155,43 @@ def test_agent_registration_info_includes_data_resources() -> None: assert len(info["data_resources"]) == 1 assert info["data_resources"][0]["name"] == "contacts" assert info["data_resources"][0]["operations"]["create"] is True + + +def test_agent_rejects_duplicate_analytics_resource_names() -> None: + """Two AnalyticsResources with the same name on one agent are invalid.""" + from supervaizer.analytics_resource import AnalyticsResource + + dup = AnalyticsResource(name="overview", dashboards=[{"id": "main"}]) + with pytest.raises(ValueError, match="Duplicate AnalyticsResource name"): + Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + analytics_resources=[dup, dup], + ) + + +def test_agent_registration_info_includes_analytics_resources() -> None: + """Agent.registration_info includes analytics_resources when declared.""" + from supervaizer.analytics_resource import AnalyticsResource + + analytics_resource = AnalyticsResource( + name="interviewer", + display_name="Interviewer Analytics", + dashboards=[{"id": "overview", "title": "Overview"}], + ) + agent = Agent( + name="agentName", + author="authorName", + developer="Dev", + version="1.0.0", + description="description", + analytics_resources=[analytics_resource], + ) + info = agent.registration_info + assert "analytics_resources" in info + assert len(info["analytics_resources"]) == 1 + assert info["analytics_resources"][0]["name"] == "interviewer" + assert info["analytics_resources"][0]["operations"]["get_dashboard"] is True diff --git a/tests/test_analytics_resource.py b/tests/test_analytics_resource.py new file mode 100644 index 0000000..84859bb --- /dev/null +++ b/tests/test_analytics_resource.py @@ -0,0 +1,100 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +import pytest +from pydantic import ValidationError + +from supervaizer.analytics_resource import ( + AnalyticsDataset, + AnalyticsFilter, + AnalyticsResource, +) + + +def test_analytics_resource_name_must_be_url_safe() -> None: + with pytest.raises(ValidationError): + AnalyticsResource(name="Bad Name", dashboards=[{"id": "overview"}]) + + +def test_analytics_resource_requires_dashboard_source() -> None: + with pytest.raises( + ValueError, match="must define dashboards or on_list_dashboards" + ): + AnalyticsResource(name="interviewer") + + +def test_static_dashboards_require_id() -> None: + with pytest.raises(ValueError, match="must define id"): + AnalyticsResource(name="interviewer", dashboards=[{"title": "Overview"}]) + + +def test_analytics_resource_registration_info() -> None: + resource = AnalyticsResource( + name="interviewer", + display_name="Interviewer Analytics", + description="Interview funnel health", + dashboards=[ + { + "id": "overview", + "title": "Overview", + "description": "Mission summary", + "version": "1.0.0", + "widgets": [{"id": "sessions", "spec": {"mark": "bar"}}], + } + ], + datasets=[AnalyticsDataset(id="sessions", description="Session rows")], + filters=[ + AnalyticsFilter( + id="status", + label="Status", + options=[{"value": "complete", "label": "Complete"}], + ) + ], + on_get_dataset=lambda dashboard_id, dataset_id: {"values": []}, + ) + + info = resource.registration_info + + assert info["name"] == "interviewer" + assert info["display_name"] == "Interviewer Analytics" + assert info["dashboards"] == [ + { + "id": "overview", + "title": "Overview", + "description": "Mission summary", + "version": "1.0.0", + } + ] + assert info["datasets"] == [{"id": "sessions", "description": "Session rows"}] + assert info["filters"][0]["id"] == "status" + assert info["operations"]["get_dataset"] is True + + +def test_static_dashboard_lookup() -> None: + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview", "title": "Overview"}], + ) + + assert resource.get_static_dashboard("overview") == { + "id": "overview", + "title": "Overview", + } + assert resource.get_static_dashboard("missing") is None + + +def test_callbacks_excluded_from_serialization() -> None: + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview"}], + on_get_dataset=lambda dashboard_id, dataset_id: {"values": []}, + ) + + dumped = resource.model_dump() + + assert "on_list_dashboards" not in dumped + assert "on_get_dashboard" not in dumped + assert "on_get_dataset" not in dumped diff --git a/tests/test_contracts.py b/tests/test_contracts.py index b497b8e..b3d4c7e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -15,6 +15,7 @@ ControllerEndpoint, ControllerContract, ServerRegistrationContract, + build_analytics_context_headers, build_data_resource_context_headers, controller_contract_info, resolve_controller_endpoint, @@ -38,6 +39,10 @@ def test_controller_contract_endpoints_are_api_prefixed() -> None: info["endpoints"]["DATA_RESOURCE"] == "/api/agents/{agent_slug}/data/{resource_name}/" ) + assert ( + info["endpoints"]["ANALYTICS_DASHBOARD"] + == "/api/agents/{agent_slug}/analytics/{resource_name}/dashboards/{dashboard_id}" + ) def test_contract_models_export_json_schema() -> None: @@ -86,6 +91,17 @@ def test_resolve_controller_endpoint() -> None: ) == "/api/agents/agent-interviewer/data/contacts/c1" ) + assert ( + resolve_controller_endpoint( + contract, + ControllerEndpoint.ANALYTICS_DATASET, + agent_slug="agent-interviewer", + resource_name="interviewer", + dashboard_id="overview", + dataset_id="sessions", + ) + == "/api/agents/agent-interviewer/analytics/interviewer/dashboards/overview/datasets/sessions" + ) def test_resolve_controller_endpoint_rejects_unknown_endpoint() -> None: @@ -113,3 +129,21 @@ def test_data_resource_context_headers() -> None: "X-Supervaize-Mission-Id": "mission-1", "X-Supervaize-Request-Id": "request-1", } + + +def test_analytics_context_headers() -> None: + headers = build_analytics_context_headers( + workspace_id="1", + workspace_slug="team-slug", + mission_id="mission-1", + job_id="job-1", + request_id="request-1", + ) + + assert headers == { + "X-Supervaize-Workspace-Id": "1", + "X-Supervaize-Workspace-Slug": "team-slug", + "X-Supervaize-Mission-Id": "mission-1", + "X-Supervaize-Request-Id": "request-1", + "X-Supervaize-Job-Id": "job-1", + } diff --git a/tests/test_routes.py b/tests/test_routes.py index a099d92..7c15c81 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -24,6 +24,7 @@ JobResponse, Server, ) +from supervaizer.analytics_resource import AnalyticsResource, AnalyticsResourceContext from supervaizer.data_resource import DataResource, DataResourceContext from supervaizer.lifecycle import EntityStatus from supervaizer.parameter import ParametersSetup @@ -495,6 +496,297 @@ def _make_data_resource_server( return server, agent +def _make_analytics_resource_server( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, + resource: AnalyticsResource, +) -> tuple[Server, Agent]: + methods = AgentMethods(job_start=agent_method_fixture) + agent = Agent( + name="Analytics Routes Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + analytics_resources=[resource], + ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server = Server( + scheme="http", + host="localhost", + port=8001, + environment="test", + mac_addr="E2-AC-ED-22-BF-B2", + debug=True, + agent_timeout=10, + private_key=private_key, + a2a_endpoints=False, + supervisor_account=account_fixture, + agents=[agent], + api_key="test-api-key", + ) + return server, agent + + +def test_analytics_resource_openapi_operation_ids_unique_per_agent( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + methods = AgentMethods(job_start=agent_method_fixture) + resource_a = AnalyticsResource( + name="overview", + dashboards=[{"id": "main", "title": "Main"}], + ) + resource_b = AnalyticsResource( + name="overview", + dashboards=[{"id": "main", "title": "Main"}], + ) + agent_a = Agent( + name="First Analytics Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + analytics_resources=[resource_a], + ) + agent_b = Agent( + name="Second Analytics Agent", + author="a", + developer="d", + version="1.0.0", + description="d", + methods=methods, + parameters_setup=parameters_setup_fixture, + analytics_resources=[resource_b], + ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + server = Server( + scheme="http", + host="localhost", + port=8001, + environment="test", + mac_addr="E2-AC-ED-22-BF-B2", + debug=True, + agent_timeout=10, + private_key=private_key, + a2a_endpoints=False, + supervisor_account=account_fixture, + agents=[agent_a, agent_b], + api_key="test-api-key", + ) + client = TestClient(server.app) + schema = client.get("/openapi.json").json() + op_ids: list[str] = [] + for path_item in schema.get("paths", {}).values(): + for op in path_item.values(): + if isinstance(op, dict) and "operationId" in op: + op_ids.append(op["operationId"]) + list_ids = [ + oid for oid in op_ids if oid.endswith("_overview_analytics_list_dashboards") + ] + assert len(list_ids) == 2 + assert len(set(list_ids)) == 2 + assert f"{agent_a.slug}_overview_analytics_list_dashboards" in list_ids + assert f"{agent_b.slug}_overview_analytics_list_dashboards" in list_ids + + +def test_analytics_resource_callbacks_receive_context( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + captured: dict[str, AnalyticsResourceContext] = {} + + def on_list_dashboards( + *, + context: AnalyticsResourceContext, + ) -> list[dict[str, Any]]: + captured["context"] = context + return [{"id": "overview", "title": "Overview"}] + + resource = AnalyticsResource( + name="interviewer", + on_list_dashboards=on_list_dashboards, + ) + server, agent = _make_analytics_resource_server( + account_fixture, + agent_method_fixture, + parameters_setup_fixture, + resource, + ) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/", + headers={ + "X-API-Key": "test-api-key", + "X-Supervaize-Workspace-Id": "team-1", + "X-Supervaize-Workspace-Slug": "team-slug", + "X-Supervaize-Mission-Id": "mission-1", + "X-Supervaize-Job-Id": "job-1", + "X-Supervaize-Request-Id": "request-1", + }, + params=[("status", "complete"), ("status", "failed"), ("range", "7d")], + ) + + assert response.status_code == 200 + context = captured["context"] + assert context.agent_slug == agent.slug + assert context.workspace_id == "team-1" + assert context.workspace_slug == "team-slug" + assert context.mission_id == "mission-1" + assert context.job_id == "job-1" + assert context.request_id == "request-1" + assert context.filters == {"status": ["complete", "failed"], "range": "7d"} + + +def test_analytics_resource_static_dashboard_routes( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview", "title": "Overview"}], + ) + server, agent = _make_analytics_resource_server( + account_fixture, + agent_method_fixture, + parameters_setup_fixture, + resource, + ) + client = TestClient(server.app) + headers = {"X-API-Key": "test-api-key"} + + list_response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/", + headers=headers, + ) + get_response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/overview", + headers=headers, + ) + missing_response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/missing", + headers=headers, + ) + + assert list_response.status_code == 200 + assert list_response.json() == [{"id": "overview", "title": "Overview"}] + assert get_response.status_code == 200 + assert get_response.json() == {"id": "overview", "title": "Overview"} + assert missing_response.status_code == 404 + + +def test_analytics_resource_dataset_route( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + def on_get_dataset( + dashboard_id: str, + dataset_id: str, + *, + context: AnalyticsResourceContext, + ) -> dict[str, Any]: + return { + "dashboard_id": dashboard_id, + "dataset_id": dataset_id, + "workspace_slug": context.workspace_slug, + "values": [{"status": "complete", "count": 3}], + } + + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview", "title": "Overview"}], + on_get_dataset=on_get_dataset, + ) + server, agent = _make_analytics_resource_server( + account_fixture, + agent_method_fixture, + parameters_setup_fixture, + resource, + ) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/overview/datasets/sessions", + headers={ + "X-API-Key": "test-api-key", + "X-Supervaize-Workspace-Slug": "team-slug", + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "dashboard_id": "overview", + "dataset_id": "sessions", + "workspace_slug": "team-slug", + "values": [{"status": "complete", "count": 3}], + } + + +def test_analytics_resource_dataset_route_requires_callback( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview", "title": "Overview"}], + ) + server, agent = _make_analytics_resource_server( + account_fixture, + agent_method_fixture, + parameters_setup_fixture, + resource, + ) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/overview/datasets/sessions", + headers={"X-API-Key": "test-api-key"}, + ) + + assert response.status_code == 501 + + +def test_analytics_resource_permission_error_returns_403( + account_fixture: Account, + agent_method_fixture: AgentMethod, + parameters_setup_fixture: ParametersSetup, +) -> None: + def on_get_dashboard(dashboard_id: str) -> dict[str, Any]: + raise PermissionError("workspace not allowed") + + resource = AnalyticsResource( + name="interviewer", + dashboards=[{"id": "overview", "title": "Overview"}], + on_get_dashboard=on_get_dashboard, + ) + server, agent = _make_analytics_resource_server( + account_fixture, + agent_method_fixture, + parameters_setup_fixture, + resource, + ) + client = TestClient(server.app) + + response = client.get( + f"/api/agents/{agent.slug}/analytics/interviewer/dashboards/overview", + headers={"X-API-Key": "test-api-key"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "workspace not allowed" + + def test_data_resource_create_requires_id_in_callback_result( account_fixture: Account, agent_method_fixture: AgentMethod, From c25718ec9a9029d4ca26dd4b734fabc030310c45 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 30 Apr 2026 00:51:07 +0300 Subject: [PATCH 2/3] docs: update analytics changelog --- docs/CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b22b25b..aceab99 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **AnalyticsResource SDK surface** — Agents can expose Vega-Lite analytics dashboards and datasets through `AnalyticsResource`. The SDK now includes analytics resources in agent registration metadata and serves authenticated dashboard manifests and dataset payloads under `/api/agents/{agent_slug}/analytics/...`, scoped by the Studio workspace context. + ### Changed - **`publish-pypi.yml` — post-publish release automation** — After PyPI publish, CI now runs the same GitHub release flow as `just gh-release` (`tools/gh-release-latest-tag.sh`) to create/update and mark the latest release from the newest `origin/main` tag. @@ -26,6 +30,17 @@ All notable changes to this project will be documented in this file. - **GitHub Actions Node 20 deprecation warnings** — Upgraded workflow action majors across CI/release/publish pipelines: `actions/checkout@v5`, `actions/setup-python@v6`, and `astral-sh/setup-uv@v7` to avoid Node 20 runtime deprecation warnings and align with Node 24 transition. +### Tests + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 574 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 69s | + ## [0.17.1] - 2026-04-26 ### Fixed From 2e6ecbf8181c4cca70a73db06211b8032625bacc Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 30 Apr 2026 03:36:38 +0300 Subject: [PATCH 3/3] docs: add hello world analytics example --- docs/CHANGELOG.md | 1 + src/supervaizer/examples/local_server.py | 92 ++++++++++++++++++++++++ tests/test_server.py | 1 + 3 files changed, 94 insertions(+) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aceab99..e71fb71 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. ### Added - **AnalyticsResource SDK surface** — Agents can expose Vega-Lite analytics dashboards and datasets through `AnalyticsResource`. The SDK now includes analytics resources in agent registration metadata and serves authenticated dashboard manifests and dataset payloads under `/api/agents/{agent_slug}/analytics/...`, scoped by the Studio workspace context. +- **Hello World analytics example** — The built-in local Hello World agent now declares an example `AnalyticsResource` with Vega-Lite widgets and a static dataset for Studio rendering. ### Changed diff --git a/src/supervaizer/examples/local_server.py b/src/supervaizer/examples/local_server.py index 10b88d1..b9a967e 100644 --- a/src/supervaizer/examples/local_server.py +++ b/src/supervaizer/examples/local_server.py @@ -23,6 +23,8 @@ Agent, AgentMethod, AgentMethods, + AnalyticsDataset, + AnalyticsResource, Parameter, ParametersSetup, Server, @@ -30,6 +32,81 @@ from supervaizer.agent import AgentMethodField +HELLO_WORLD_ANALYTICS_VALUES = [ + {"day": "2026-04-26", "cases": 3, "avg_duration_secs": 1.6}, + {"day": "2026-04-27", "cases": 5, "avg_duration_secs": 2.1}, + {"day": "2026-04-28", "cases": 2, "avg_duration_secs": 1.2}, +] + + +def _hello_world_dashboard(dashboard_id: str) -> dict | None: + if dashboard_id != "overview": + return None + return { + "id": "overview", + "title": "Hello World Analytics", + "description": "Example Vega-Lite dashboard exposed through AnalyticsResource.", + "widgets": [ + { + "id": "cases-by-day", + "title": "Cases by day", + "layout": {"w": 6}, + "data": {"mode": "ref", "datasetId": "daily_cases"}, + "visualization": { + "type": "vega-lite", + "spec": { + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "mark": {"type": "bar", "tooltip": True}, + "encoding": { + "x": { + "field": "day", + "type": "ordinal", + "axis": {"title": "Day"}, + }, + "y": { + "field": "cases", + "type": "quantitative", + "axis": {"title": "Cases"}, + }, + }, + }, + }, + }, + { + "id": "duration-by-day", + "title": "Average duration", + "layout": {"w": 6}, + "data": {"mode": "ref", "datasetId": "daily_cases"}, + "visualization": { + "type": "vega-lite", + "spec": { + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "mark": {"type": "line", "point": True, "tooltip": True}, + "encoding": { + "x": { + "field": "day", + "type": "ordinal", + "axis": {"title": "Day"}, + }, + "y": { + "field": "avg_duration_secs", + "type": "quantitative", + "axis": {"title": "Avg duration (s)"}, + }, + }, + }, + }, + }, + ], + } + + +def _hello_world_dataset(dashboard_id: str, dataset_id: str) -> dict | None: + if dashboard_id != "overview" or dataset_id != "daily_cases": + return None + return {"values": HELLO_WORLD_ANALYTICS_VALUES} + + def get_default_local_agent() -> Agent: """Default Hello World agent for local test mode (mirrors supervaize_hello_world).""" agent_name = "Hello World AI Agent" @@ -108,6 +185,21 @@ def get_default_local_agent() -> Agent: human_answer=human_answer_method, ), parameters_setup=parameters, + analytics_resources=[ + AnalyticsResource( + name="hello_world", + display_name="Hello World Analytics", + description="Example AnalyticsResource for local Studio rendering.", + dashboards=[{"id": "overview", "title": "Hello World Analytics"}], + datasets=[ + AnalyticsDataset( + id="daily_cases", description="Example local case metrics." + ) + ], + on_get_dashboard=_hello_world_dashboard, + on_get_dataset=_hello_world_dataset, + ) + ], ) diff --git a/tests/test_server.py b/tests/test_server.py index 5cc5bd9..54ffec7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -604,6 +604,7 @@ def test_local_mode_injects_hello_world_agent(self, agent_fixture: Agent) -> Non ) assert len(server.agents) == 2 assert server.agents[0].name == "Hello World AI Agent" + assert server.agents[0].analytics_resources[0].name == "hello_world" assert server.agents[1].name == agent_fixture.name finally: del os.environ["SUPERVAIZER_LOCAL_MODE"]