From ad98b0c70c4efef632b86e05240fd9e746a64489 Mon Sep 17 00:00:00 2001 From: yenkins-admin <5391010+yenkins-admin@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:53:14 +0000 Subject: [PATCH] feat(gooddata-sdk): [AUTO] Add UserContext, DashboardContext, UIContext, RichTextWidgetDescriptor --- .../gooddata-sdk/src/gooddata_sdk/__init__.py | 10 + .../compute/model/chat_context.py | 174 ++++++++++++++++++ .../src/gooddata_sdk/compute/service.py | 31 +++- .../tests/compute/test_chat_context.py | 156 ++++++++++++++++ 4 files changed, 367 insertions(+), 4 deletions(-) create mode 100644 packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat_context.py create mode 100644 packages/gooddata-sdk/tests/compute/test_chat_context.py diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..8511de39a 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -274,6 +274,16 @@ from gooddata_sdk.compute.compute_to_sdk_converter import ComputeToSdkConverter from gooddata_sdk.compute.model.attribute import Attribute from gooddata_sdk.compute.model.base import ExecModelEntity, ObjId +from gooddata_sdk.compute.model.chat_context import ( + CatalogActiveObjectIdentification, + CatalogDashboardContext, + CatalogObjectReference, + CatalogObjectReferenceGroup, + CatalogRichTextWidgetDescriptor, + CatalogUIContext, + CatalogUserContext, + CatalogWidgetDescriptor, +) from gooddata_sdk.compute.model.execution import ( BareExecutionResponse, Execution, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat_context.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat_context.py new file mode 100644 index 000000000..82e3dbd48 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat_context.py @@ -0,0 +1,174 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from typing import Any + +import attrs +from gooddata_api_client.model.active_object_identification import ActiveObjectIdentification +from gooddata_api_client.model.dashboard_context import DashboardContext +from gooddata_api_client.model.object_reference import ObjectReference +from gooddata_api_client.model.object_reference_group import ObjectReferenceGroup +from gooddata_api_client.model.rich_text_widget_descriptor import RichTextWidgetDescriptor +from gooddata_api_client.model.ui_context import UIContext +from gooddata_api_client.model.user_context import UserContext +from gooddata_api_client.model.widget_descriptor import WidgetDescriptor + +from gooddata_sdk.catalog.base import Base + + +@attrs.define(kw_only=True) +class CatalogObjectReference(Base): + """Reference to a GoodData object by its ID and type.""" + + id: str + type: str # Allowed: WIDGET, METRIC, ATTRIBUTE, DASHBOARD + + @staticmethod + def client_class() -> type[ObjectReference]: + return ObjectReference + + +@attrs.define(kw_only=True) +class CatalogObjectReferenceGroup(Base): + """Group of explicitly referenced objects, optionally scoped by a context.""" + + objects: list[CatalogObjectReference] = attrs.field(factory=list) + context: CatalogObjectReference | None = None + + @staticmethod + def client_class() -> type[ObjectReferenceGroup]: + return ObjectReferenceGroup + + def as_api_model(self) -> ObjectReferenceGroup: + api_objects = [o.to_api() for o in self.objects] + kwargs: dict[str, Any] = {} + if self.context is not None: + kwargs["context"] = self.context.to_api() + return ObjectReferenceGroup(_check_type=False, objects=api_objects, **kwargs) + + +@attrs.define(kw_only=True) +class CatalogActiveObjectIdentification(Base): + """Identifies the object currently active (open/selected) by the user.""" + + id: str + type: str + workspace_id: str + + @staticmethod + def client_class() -> type[ActiveObjectIdentification]: + return ActiveObjectIdentification + + +@attrs.define(kw_only=True) +class CatalogWidgetDescriptor(Base): + """Describes a widget currently visible on the dashboard.""" + + title: str + widget_id: str + widget_type: str + filters: list[dict[str, Any]] = attrs.field(factory=list) + + @staticmethod + def client_class() -> type[WidgetDescriptor]: + return WidgetDescriptor + + def as_api_model(self) -> WidgetDescriptor: + kwargs: dict[str, Any] = {} + if self.filters: + kwargs["filters"] = self.filters + return WidgetDescriptor( + _check_type=False, + title=self.title, + widget_id=self.widget_id, + widget_type=self.widget_type, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class CatalogRichTextWidgetDescriptor(Base): + """Describes a rich text widget currently visible on the dashboard.""" + + title: str + widget_id: str + filters: list[dict[str, Any]] = attrs.field(factory=list) + + @staticmethod + def client_class() -> type[RichTextWidgetDescriptor]: + return RichTextWidgetDescriptor + + def as_api_model(self) -> RichTextWidgetDescriptor: + kwargs: dict[str, Any] = {} + if self.filters: + kwargs["filters"] = self.filters + return RichTextWidgetDescriptor( + _check_type=False, + title=self.title, + widget_id=self.widget_id, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class CatalogDashboardContext(Base): + """Context describing the currently open dashboard and its visible widgets.""" + + id: str + widgets: list[CatalogWidgetDescriptor] = attrs.field(factory=list) + + @staticmethod + def client_class() -> type[DashboardContext]: + return DashboardContext + + def as_api_model(self) -> DashboardContext: + return DashboardContext( + _check_type=False, + id=self.id, + widgets=[w.as_api_model() for w in self.widgets], + ) + + +@attrs.define(kw_only=True) +class CatalogUIContext(Base): + """UI context describing the user's current view.""" + + dashboard: CatalogDashboardContext | None = None + + @staticmethod + def client_class() -> type[UIContext]: + return UIContext + + def as_api_model(self) -> UIContext: + kwargs: dict[str, Any] = {} + if self.dashboard is not None: + kwargs["dashboard"] = self.dashboard.as_api_model() + return UIContext(_check_type=False, **kwargs) + + +@attrs.define(kw_only=True) +class CatalogUserContext(Base): + """User context for AI chat, providing information about the user's current state. + + Pass an instance of this class to :py:meth:`ComputeService.ai_chat` or + :py:meth:`ComputeService.ai_chat_stream` to give the AI assistant richer + context about what the user is currently looking at. + """ + + active_object: CatalogActiveObjectIdentification | None = None + referenced_objects: list[CatalogObjectReferenceGroup] = attrs.field(factory=list) + view: CatalogUIContext | None = None + + @staticmethod + def client_class() -> type[UserContext]: + return UserContext + + def as_api_model(self) -> UserContext: + kwargs: dict[str, Any] = {} + if self.active_object is not None: + kwargs["active_object"] = self.active_object.to_api() + if self.referenced_objects: + kwargs["referenced_objects"] = [r.as_api_model() for r in self.referenced_objects] + if self.view is not None: + kwargs["view"] = self.view.as_api_model() + return UserContext(_check_type=False, **kwargs) diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/service.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/service.py index 6163798b9..dd067f91b 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/compute/service.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/service.py @@ -17,6 +17,7 @@ from gooddata_api_client.model.search_result import SearchResult from gooddata_sdk.client import GoodDataApiClient +from gooddata_sdk.compute.model.chat_context import CatalogUserContext from gooddata_sdk.compute.model.execution import ( Execution, ExecutionDefinition, @@ -135,17 +136,28 @@ def build_exec_def_from_chat_result( is_cancellable=is_cancellable, ) - def ai_chat(self, workspace_id: str, question: str) -> ChatResult: + def ai_chat( + self, + workspace_id: str, + question: str, + *, + user_context: CatalogUserContext | None = None, + ) -> ChatResult: """ Chat with AI in GoodData workspace. Args: workspace_id (str): workspace identifier question (str): question for the AI + user_context (CatalogUserContext, optional): context about the user's current state, + used to provide richer answers (e.g. active dashboard, referenced objects). Returns: ChatResult: Chat response """ - chat_request = ChatRequest(question=question) + chat_params: dict[str, Any] = {} + if user_context is not None: + chat_params["user_context"] = user_context.as_api_model() + chat_request = ChatRequest(question=question, **chat_params) response = self._actions_api.ai_chat(workspace_id, chat_request, _check_return_type=False) return response @@ -160,17 +172,28 @@ def _parse_sse_events(self, raw: str) -> Iterator[Any]: except json.JSONDecodeError: continue - def ai_chat_stream(self, workspace_id: str, question: str) -> Iterator[Any]: + def ai_chat_stream( + self, + workspace_id: str, + question: str, + *, + user_context: CatalogUserContext | None = None, + ) -> Iterator[Any]: """ Chat Stream with AI in GoodData workspace. Args: workspace_id (str): workspace identifier question (str): question for the AI + user_context (CatalogUserContext, optional): context about the user's current state, + used to provide richer answers (e.g. active dashboard, referenced objects). Returns: Iterator[Any]: Yields parsed JSON objects from each SSE event's data field """ - chat_request = ChatRequest(question=question) + chat_params: dict[str, Any] = {} + if user_context is not None: + chat_params["user_context"] = user_context.as_api_model() + chat_request = ChatRequest(question=question, **chat_params) response = self._actions_api.ai_chat_stream( workspace_id, chat_request, _check_return_type=False, _preload_content=False ) diff --git a/packages/gooddata-sdk/tests/compute/test_chat_context.py b/packages/gooddata-sdk/tests/compute/test_chat_context.py new file mode 100644 index 000000000..0172e323d --- /dev/null +++ b/packages/gooddata-sdk/tests/compute/test_chat_context.py @@ -0,0 +1,156 @@ +# (C) 2026 GoodData Corporation +from __future__ import annotations + +from gooddata_sdk.compute.model.chat_context import ( + CatalogActiveObjectIdentification, + CatalogDashboardContext, + CatalogObjectReference, + CatalogObjectReferenceGroup, + CatalogRichTextWidgetDescriptor, + CatalogUIContext, + CatalogUserContext, + CatalogWidgetDescriptor, +) + + +class TestCatalogObjectReference: + def test_construction_and_to_api(self): + obj = CatalogObjectReference(id="metric-1", type="METRIC") + api = obj.to_api() + assert api["id"] == "metric-1" + assert api["type"] == "METRIC" + + +class TestCatalogObjectReferenceGroup: + def test_as_api_model_with_objects(self): + ref = CatalogObjectReference(id="widget-1", type="WIDGET") + group = CatalogObjectReferenceGroup(objects=[ref]) + api = group.as_api_model() + assert len(api["objects"]) == 1 + assert api["objects"][0]["id"] == "widget-1" + + def test_as_api_model_with_context(self): + ref = CatalogObjectReference(id="attr-1", type="ATTRIBUTE") + ctx = CatalogObjectReference(id="dashboard-1", type="DASHBOARD") + group = CatalogObjectReferenceGroup(objects=[ref], context=ctx) + api = group.as_api_model() + assert api.context["id"] == "dashboard-1" + + def test_as_api_model_empty(self): + group = CatalogObjectReferenceGroup() + api = group.as_api_model() + assert api["objects"] == [] + assert "context" not in api or api.get("context") is None + + +class TestCatalogActiveObjectIdentification: + def test_construction_and_to_api(self): + active = CatalogActiveObjectIdentification( + id="dash-1", type="dashboard", workspace_id="ws-1" + ) + api = active.to_api() + assert api["id"] == "dash-1" + assert api["type"] == "dashboard" + assert api["workspace_id"] == "ws-1" + + +class TestCatalogWidgetDescriptor: + def test_as_api_model_minimal(self): + widget = CatalogWidgetDescriptor( + title="My Widget", widget_id="w-1", widget_type="INSIGHT" + ) + api = widget.as_api_model() + assert api["title"] == "My Widget" + assert api["widget_id"] == "w-1" + assert api["widget_type"] == "INSIGHT" + + def test_as_api_model_with_filters(self): + widget = CatalogWidgetDescriptor( + title="My Widget", + widget_id="w-1", + widget_type="INSIGHT", + filters=[{"positiveAttributeFilter": {"label": "attr.id"}}], + ) + api = widget.as_api_model() + assert len(api["filters"]) == 1 + + +class TestCatalogRichTextWidgetDescriptor: + def test_as_api_model_minimal(self): + widget = CatalogRichTextWidgetDescriptor(title="Rich Text", widget_id="rt-1") + api = widget.as_api_model() + assert api["title"] == "Rich Text" + assert api["widget_id"] == "rt-1" + + def test_as_api_model_with_filters(self): + widget = CatalogRichTextWidgetDescriptor( + title="Rich Text", + widget_id="rt-1", + filters=[{"absoluteDateFilter": {}}], + ) + api = widget.as_api_model() + assert len(api["filters"]) == 1 + + +class TestCatalogDashboardContext: + def test_as_api_model_no_widgets(self): + ctx = CatalogDashboardContext(id="dash-1") + api = ctx.as_api_model() + assert api["id"] == "dash-1" + assert api["widgets"] == [] + + def test_as_api_model_with_widgets(self): + widget = CatalogWidgetDescriptor( + title="W1", widget_id="w-1", widget_type="INSIGHT" + ) + ctx = CatalogDashboardContext(id="dash-1", widgets=[widget]) + api = ctx.as_api_model() + assert len(api["widgets"]) == 1 + assert api["widgets"][0]["title"] == "W1" + + +class TestCatalogUIContext: + def test_as_api_model_empty(self): + ctx = CatalogUIContext() + api = ctx.as_api_model() + assert api is not None + + def test_as_api_model_with_dashboard(self): + dashboard = CatalogDashboardContext(id="dash-1") + ctx = CatalogUIContext(dashboard=dashboard) + api = ctx.as_api_model() + assert api["dashboard"]["id"] == "dash-1" + + +class TestCatalogUserContext: + def test_as_api_model_empty(self): + ctx = CatalogUserContext() + api = ctx.as_api_model() + assert api is not None + + def test_as_api_model_full(self): + active = CatalogActiveObjectIdentification( + id="dash-1", type="dashboard", workspace_id="ws-1" + ) + ref = CatalogObjectReference(id="metric-1", type="METRIC") + group = CatalogObjectReferenceGroup(objects=[ref]) + dashboard = CatalogDashboardContext(id="dash-1") + view = CatalogUIContext(dashboard=dashboard) + + ctx = CatalogUserContext( + active_object=active, + referenced_objects=[group], + view=view, + ) + api = ctx.as_api_model() + assert api["active_object"]["id"] == "dash-1" + assert len(api["referenced_objects"]) == 1 + assert api["view"]["dashboard"]["id"] == "dash-1" + + def test_as_api_model_only_view(self): + dashboard = CatalogDashboardContext(id="dash-1") + view = CatalogUIContext(dashboard=dashboard) + ctx = CatalogUserContext(view=view) + api = ctx.as_api_model() + assert "active_object" not in api or api.get("active_object") is None + assert api["view"]["dashboard"]["id"] == "dash-1"