diff --git a/cluster-info.txt b/cluster-info.txt new file mode 100644 index 000000000..f4a6b3cc0 --- /dev/null +++ b/cluster-info.txt @@ -0,0 +1,4 @@ +- `3f2b607` Merge pull request #21274 from gooddata/smac/LX-2141 +- `63dd6bf` feat(gen-ai): refactor widgets polymorphism and enhance OpenAPI spec +- `499bb70` feat(gen-ai): add support for filters for visualizations +- `075d22d` Merge pull request #21389 from gooddata/dho/trivial-fix \ No newline at end of file diff --git a/cluster.json b/cluster.json new file mode 100644 index 000000000..51d344a47 --- /dev/null +++ b/cluster.json @@ -0,0 +1,38 @@ +{ + "id": "C007", + "title": "Add UIContext, WidgetDescriptor schemas and UserContext for AI chatbot", + "services": [ + "gooddata-afm-client" + ], + "commits": [ + { + "sha": "3f2b607eb32bf10c55f66abb5438ff0c20f77873", + "author": "Mara3l", + "author_email": "110542281+Mara3l@users.noreply.github.com", + "message": "Merge pull request #21274 from gooddata/smac/LX-2141" + }, + { + "sha": "63dd6bf3ce25565a27c5b78dbf92f6239a59ede3", + "author": "Roman Rakus", + "author_email": "roman.rakus@gooddata.com", + "message": "feat(gen-ai): refactor widgets polymorphism and enhance OpenAPI spec" + }, + { + "sha": "499bb7071ee78cef0128b4cb1e414f42f6a5f7d4", + "author": "Roman Rakus", + "author_email": "roman.rakus@gooddata.com", + "message": "feat(gen-ai): add support for filters for visualizations" + }, + { + "sha": "075d22d5fcdec6924d837217eef929d013a4d4cb", + "author": "Dan Homola", + "author_email": "dan.homola@gooddata.com", + "message": "Merge pull request #21389 from gooddata/dho/trivial-fix" + } + ], + "diff": "--- a/gooddata-afm-client.json\n+++ b/gooddata-afm-client.json\n+ \"DashboardContext\": { \"properties\": { \"id\": {...}, \"widgets\": { \"$ref\": \"WidgetDescriptor\" } }, \"required\": [\"id\",\"widgets\"] },\n+ \"InsightWidgetDescriptor\": { \"properties\": { \"filters\": {...}, \"resultId\": {...}, \"title\": {...}, \"visualizationId\": {...}, \"widgetId\": {...} }, \"required\": [\"title\",\"visualizationId\",\"widgetId\"] },\n+ \"ObjectReference\": { \"properties\": { \"id\": {...}, \"type\": { \"enum\": [\"WIDGET\",\"METRIC\",\"ATTRIBUTE\",\"DASHBOARD\"] } }, \"required\": [\"id\",\"type\"] },\n+ \"ObjectReferenceGroup\": { \"properties\": { \"context\": { \"$ref\": \"ObjectReference\" }, \"objects\": {...} }, \"required\": [\"objects\"] },\n+ \"RichTextWidgetDescriptor\": { \"properties\": { \"filters\": {...}, \"title\": {...}, \"widgetId\": {...} }, \"required\": [\"title\",\"widgetId\"] },\n+ \"UIContext\": { \"description\": \"Ambient UI state.\", \"properties\": { \"dashboard\": { \"$ref\": \"DashboardContext\" } } },\n+ \"VisualizationSwitcherWidgetDescriptor\": { \"required\": [\"activeVisualizationId\",\"title\",\"visualizationIds\",\"widgetId\"] },\n+ \"WidgetDescriptor\": { \"discriminator\": { \"propertyName\": \"widgetType\" }, ... },\n \"UserContext\": {\n- \"description\": \"User context affecting AI behavior.\",\n+ \"description\": \"User context with ambient UI state and explicitly referenced objects.\",\n+ \"referencedObjects\": { \"items\": { \"$ref\": \"ObjectReferenceGroup\" }, \"type\": \"array\" },\n+ \"view\": { \"$ref\": \"UIContext\" }\n }", + "jira_tickets": [ + "LX-2141" + ], + "sdk_impact": "new_feature" +} \ No newline at end of file diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index 77397b92d..e37030ab1 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -274,6 +274,18 @@ 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 import ( + DashboardContext, + InsightWidgetDescriptor, + ObjectReference, + ObjectReferenceGroup, + ObjectReferenceType, + RichTextWidgetDescriptor, + UIContext, + UserContext, + VisualizationSwitcherWidgetDescriptor, + WidgetDescriptor, +) from gooddata_sdk.compute.model.execution import ( BareExecutionResponse, Execution, diff --git a/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat.py b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat.py new file mode 100644 index 000000000..cc1f3c082 --- /dev/null +++ b/packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat.py @@ -0,0 +1,168 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from typing import Any, Literal, TypeAlias + +import attrs +from gooddata_api_client.model.dashboard_context import DashboardContext as ApiDashboardContext +from gooddata_api_client.model.insight_widget_descriptor import InsightWidgetDescriptor as ApiInsightWidgetDescriptor +from gooddata_api_client.model.object_reference import ObjectReference as ApiObjectReference +from gooddata_api_client.model.object_reference_group import ObjectReferenceGroup as ApiObjectReferenceGroup +from gooddata_api_client.model.rich_text_widget_descriptor import ( + RichTextWidgetDescriptor as ApiRichTextWidgetDescriptor, +) +from gooddata_api_client.model.ui_context import UIContext as ApiUIContext +from gooddata_api_client.model.user_context import UserContext as ApiUserContext +from gooddata_api_client.model.visualization_switcher_widget_descriptor import ( + VisualizationSwitcherWidgetDescriptor as ApiVisualizationSwitcherWidgetDescriptor, +) + +ObjectReferenceType: TypeAlias = Literal["WIDGET", "METRIC", "ATTRIBUTE", "DASHBOARD"] + + +@attrs.define(kw_only=True) +class ObjectReference: + """Reference to a specific object (e.g. widget, metric, attribute, or dashboard).""" + + id: str + type: ObjectReferenceType + + def as_api_model(self) -> ApiObjectReference: + return ApiObjectReference(id=self.id, type=self.type, _check_type=False) + + +@attrs.define(kw_only=True) +class ObjectReferenceGroup: + """Group of object references, optionally scoped by a context (e.g. a dashboard).""" + + objects: list[ObjectReference] = attrs.field(factory=list) + context: ObjectReference | None = None + + def as_api_model(self) -> ApiObjectReferenceGroup: + kwargs: dict[str, Any] = {} + if self.context is not None: + kwargs["context"] = self.context.as_api_model() + return ApiObjectReferenceGroup( + objects=[obj.as_api_model() for obj in self.objects], + _check_type=False, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class InsightWidgetDescriptor: + """Widget descriptor for insight (visualization) widgets on a dashboard.""" + + title: str + visualization_id: str + widget_id: str + filters: list[Any] = attrs.field(factory=list) + result_id: str | None = None + + def as_api_model(self) -> ApiInsightWidgetDescriptor: + kwargs: dict[str, Any] = {} + if self.filters: + kwargs["filters"] = self.filters + if self.result_id is not None: + kwargs["result_id"] = self.result_id + return ApiInsightWidgetDescriptor( + title=self.title, + visualization_id=self.visualization_id, + widget_id=self.widget_id, + _check_type=False, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class RichTextWidgetDescriptor: + """Widget descriptor for rich text widgets on a dashboard.""" + + title: str + widget_id: str + filters: list[Any] = attrs.field(factory=list) + + def as_api_model(self) -> ApiRichTextWidgetDescriptor: + kwargs: dict[str, Any] = {} + if self.filters: + kwargs["filters"] = self.filters + return ApiRichTextWidgetDescriptor( + title=self.title, + widget_id=self.widget_id, + _check_type=False, + **kwargs, + ) + + +@attrs.define(kw_only=True) +class VisualizationSwitcherWidgetDescriptor: + """Widget descriptor for visualization switcher widgets on a dashboard.""" + + active_visualization_id: str + title: str + visualization_ids: list[str] + widget_id: str + filters: list[Any] = attrs.field(factory=list) + result_id: str | None = None + + def as_api_model(self) -> ApiVisualizationSwitcherWidgetDescriptor: + kwargs: dict[str, Any] = {} + if self.filters: + kwargs["filters"] = self.filters + if self.result_id is not None: + kwargs["result_id"] = self.result_id + return ApiVisualizationSwitcherWidgetDescriptor( + active_visualization_id=self.active_visualization_id, + title=self.title, + visualization_ids=self.visualization_ids, + widget_id=self.widget_id, + _check_type=False, + **kwargs, + ) + + +WidgetDescriptor: TypeAlias = InsightWidgetDescriptor | RichTextWidgetDescriptor | VisualizationSwitcherWidgetDescriptor + + +@attrs.define(kw_only=True) +class DashboardContext: + """Context describing the dashboard currently open in the UI.""" + + id: str + widgets: list[WidgetDescriptor] = attrs.field(factory=list) + + def as_api_model(self) -> ApiDashboardContext: + return ApiDashboardContext( + id=self.id, + widgets=[w.as_api_model() for w in self.widgets], + _check_type=False, + ) + + +@attrs.define(kw_only=True) +class UIContext: + """Ambient UI state passed to the AI chatbot.""" + + dashboard: DashboardContext | None = None + + def as_api_model(self) -> ApiUIContext: + kwargs: dict[str, Any] = {} + if self.dashboard is not None: + kwargs["dashboard"] = self.dashboard.as_api_model() + return ApiUIContext(_check_type=False, **kwargs) + + +@attrs.define(kw_only=True) +class UserContext: + """User context with ambient UI state and explicitly referenced objects for the AI chatbot.""" + + referenced_objects: list[ObjectReferenceGroup] = attrs.field(factory=list) + view: UIContext | None = None + + def as_api_model(self) -> ApiUserContext: + kwargs: dict[str, Any] = {} + if self.referenced_objects: + kwargs["referenced_objects"] = [obj.as_api_model() for obj in self.referenced_objects] + if self.view is not None: + kwargs["view"] = self.view.as_api_model() + return ApiUserContext(_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..5e057b4e0 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 import UserContext 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: UserContext | None = None, + ) -> ChatResult: """ Chat with AI in GoodData workspace. Args: workspace_id (str): workspace identifier question (str): question for the AI + user_context (UserContext | None): optional user context with ambient UI state and + explicitly referenced objects. Defaults to None. Returns: ChatResult: Chat response """ - chat_request = ChatRequest(question=question) + kwargs: dict[str, Any] = {} + if user_context is not None: + kwargs["user_context"] = user_context.as_api_model() + chat_request = ChatRequest(question=question, **kwargs) 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: UserContext | 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 (UserContext | None): optional user context with ambient UI state and + explicitly referenced objects. Defaults to None. Returns: Iterator[Any]: Yields parsed JSON objects from each SSE event's data field """ - chat_request = ChatRequest(question=question) + kwargs: dict[str, Any] = {} + if user_context is not None: + kwargs["user_context"] = user_context.as_api_model() + chat_request = ChatRequest(question=question, **kwargs) 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_models.py b/packages/gooddata-sdk/tests/compute/test_chat_models.py new file mode 100644 index 000000000..57ee4f6ae --- /dev/null +++ b/packages/gooddata-sdk/tests/compute/test_chat_models.py @@ -0,0 +1,161 @@ +# (C) 2024 GoodData Corporation +from __future__ import annotations + +from gooddata_sdk.compute.model.chat import ( + DashboardContext, + InsightWidgetDescriptor, + ObjectReference, + ObjectReferenceGroup, + RichTextWidgetDescriptor, + UIContext, + UserContext, + VisualizationSwitcherWidgetDescriptor, +) + + +def test_object_reference_as_api_model(): + obj_ref = ObjectReference(id="widget-1", type="WIDGET") + api_model = obj_ref.as_api_model() + assert api_model.id == "widget-1" + assert api_model.type == "WIDGET" + + +def test_object_reference_group_as_api_model_minimal(): + obj_ref = ObjectReference(id="metric-1", type="METRIC") + group = ObjectReferenceGroup(objects=[obj_ref]) + api_model = group.as_api_model() + assert len(api_model.objects) == 1 + assert api_model.objects[0].id == "metric-1" + assert not hasattr(api_model, "context") or api_model.get("context") is None + + +def test_object_reference_group_as_api_model_with_context(): + obj_ref = ObjectReference(id="widget-1", type="WIDGET") + context_ref = ObjectReference(id="dashboard-1", type="DASHBOARD") + group = ObjectReferenceGroup(objects=[obj_ref], context=context_ref) + api_model = group.as_api_model() + assert len(api_model.objects) == 1 + assert api_model.context.id == "dashboard-1" + assert api_model.context.type == "DASHBOARD" + + +def test_insight_widget_descriptor_as_api_model_minimal(): + widget = InsightWidgetDescriptor( + title="Revenue Chart", + visualization_id="vis-123", + widget_id="widget-456", + ) + api_model = widget.as_api_model() + assert api_model.title == "Revenue Chart" + assert api_model.visualization_id == "vis-123" + assert api_model.widget_id == "widget-456" + + +def test_insight_widget_descriptor_as_api_model_with_result_id(): + widget = InsightWidgetDescriptor( + title="Revenue Chart", + visualization_id="vis-123", + widget_id="widget-456", + result_id="result-789", + ) + api_model = widget.as_api_model() + assert api_model.result_id == "result-789" + + +def test_rich_text_widget_descriptor_as_api_model(): + widget = RichTextWidgetDescriptor(title="Notes Widget", widget_id="widget-rt-1") + api_model = widget.as_api_model() + assert api_model.title == "Notes Widget" + assert api_model.widget_id == "widget-rt-1" + + +def test_visualization_switcher_widget_descriptor_as_api_model(): + widget = VisualizationSwitcherWidgetDescriptor( + active_visualization_id="vis-1", + title="Switcher Widget", + visualization_ids=["vis-1", "vis-2", "vis-3"], + widget_id="widget-vs-1", + ) + api_model = widget.as_api_model() + assert api_model.active_visualization_id == "vis-1" + assert api_model.title == "Switcher Widget" + assert api_model.visualization_ids == ["vis-1", "vis-2", "vis-3"] + assert api_model.widget_id == "widget-vs-1" + + +def test_dashboard_context_as_api_model(): + widget = InsightWidgetDescriptor( + title="Revenue Chart", + visualization_id="vis-123", + widget_id="widget-456", + ) + dashboard = DashboardContext(id="dashboard-1", widgets=[widget]) + api_model = dashboard.as_api_model() + assert api_model.id == "dashboard-1" + assert len(api_model.widgets) == 1 + + +def test_ui_context_as_api_model_empty(): + ui_ctx = UIContext() + api_model = ui_ctx.as_api_model() + assert api_model is not None + + +def test_ui_context_as_api_model_with_dashboard(): + widget = RichTextWidgetDescriptor(title="Notes", widget_id="w-1") + dashboard = DashboardContext(id="dash-1", widgets=[widget]) + ui_ctx = UIContext(dashboard=dashboard) + api_model = ui_ctx.as_api_model() + assert api_model.dashboard.id == "dash-1" + + +def test_user_context_as_api_model_empty(): + user_ctx = UserContext() + api_model = user_ctx.as_api_model() + assert api_model is not None + + +def test_user_context_as_api_model_with_view(): + widget = InsightWidgetDescriptor( + title="Revenue", + visualization_id="vis-1", + widget_id="w-1", + ) + dashboard = DashboardContext(id="dash-1", widgets=[widget]) + ui_ctx = UIContext(dashboard=dashboard) + user_ctx = UserContext(view=ui_ctx) + api_model = user_ctx.as_api_model() + assert api_model.view.dashboard.id == "dash-1" + + +def test_user_context_as_api_model_with_referenced_objects(): + obj_ref = ObjectReference(id="metric-1", type="METRIC") + group = ObjectReferenceGroup(objects=[obj_ref]) + user_ctx = UserContext(referenced_objects=[group]) + api_model = user_ctx.as_api_model() + assert len(api_model.referenced_objects) == 1 + assert api_model.referenced_objects[0].objects[0].id == "metric-1" + + +def test_user_context_as_api_model_full(): + insight_widget = InsightWidgetDescriptor( + title="Revenue Chart", + visualization_id="vis-123", + widget_id="widget-1", + result_id="result-abc", + ) + dashboard = DashboardContext(id="dash-1", widgets=[insight_widget]) + ui_ctx = UIContext(dashboard=dashboard) + + context_ref = ObjectReference(id="dashboard-1", type="DASHBOARD") + widget_ref = ObjectReference(id="widget-1", type="WIDGET") + group = ObjectReferenceGroup(objects=[widget_ref], context=context_ref) + + user_ctx = UserContext( + view=ui_ctx, + referenced_objects=[group], + ) + api_model = user_ctx.as_api_model() + assert api_model.view.dashboard.id == "dash-1" + assert len(api_model.referenced_objects) == 1 + assert api_model.referenced_objects[0].context.id == "dashboard-1" diff --git a/result.json b/result.json new file mode 100644 index 000000000..09ae5434c --- /dev/null +++ b/result.json @@ -0,0 +1,13 @@ +{ + "status": "implemented", + "cluster_id": "C007", + "summary": "Added SDK wrapper classes for the new OpenAPI schemas (UIContext, DashboardContext, WidgetDescriptor variants, ObjectReference, ObjectReferenceGroup, UserContext) to support AI chatbot user context passing. Updated ComputeService.ai_chat() and ai_chat_stream() to accept an optional `user_context: UserContext | None = None` keyword-only parameter. All new public classes are exported from gooddata_sdk/__init__.py. 14 unit tests added and passing.", + "files_changed": [ + "packages/gooddata-sdk/src/gooddata_sdk/compute/model/chat.py", + "packages/gooddata-sdk/src/gooddata_sdk/compute/service.py", + "packages/gooddata-sdk/src/gooddata_sdk/__init__.py", + "packages/gooddata-sdk/tests/compute/test_chat_models.py" + ], + "reason": "", + "cost_usd": 1.7839665 +} \ No newline at end of file