Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ 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.
- **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

- **`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.
Expand All @@ -26,6 +31,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
Expand Down
20 changes: 20 additions & 0 deletions src/supervaizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand All @@ -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",
Expand Down
26 changes: 23 additions & 3 deletions src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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})"
Expand Down Expand Up @@ -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"]:
Expand Down
146 changes: 146 additions & 0 deletions src/supervaizer/analytics_resource.py
Original file line number Diff line number Diff line change
@@ -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"):
Comment on lines +88 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject duplicate static dashboard IDs

AnalyticsResource validates that each static dashboard has an id, but it does not enforce uniqueness, so two entries can share the same id in dashboards. In that case list_dashboards can return both dashboards while get_static_dashboard always returns only the first match, making later duplicates unreachable and causing inconsistent client behavior when fetching a listed dashboard by id.

Useful? React with 👍 / 👎.

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,
Comment on lines +98 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Set get_dashboard false when no dashboard resolver exists

AnalyticsResource.operations always reports get_dashboard: True, but _make_get_dashboard_handler can only return a dashboard when on_get_dashboard is set or a static dashboard exists. Because check_dashboard_source allows list-only resources (on_list_dashboards without static dashboards), those resources advertise get_dashboard support yet every GET .../dashboards/{id} call returns 404. This breaks clients that trust the registration metadata and then fetch listed dashboard IDs individually.

Useful? React with 👍 / 👎.

}

@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
}
Loading