diff --git a/docs/docs/tools/pulse/generate-pulse-insight-brief.md b/docs/docs/tools/pulse/generate-pulse-insight-brief.md new file mode 100644 index 00000000..e42f4485 --- /dev/null +++ b/docs/docs/tools/pulse/generate-pulse-insight-brief.md @@ -0,0 +1,279 @@ +--- +sidebar_position: 7 +--- + +# Generate Pulse Insight Brief + +Generates AI-powered conversational insights for Pulse metrics based on natural language questions. +This tool enables interactive, multi-turn conversations about your metrics data. + +## What is an Insight Brief? + +An **insight brief** is an AI-generated response to questions about Pulse metrics that provides: + +- **Natural language answers** - Conversational responses to specific questions +- **Contextual summaries** - AI-powered analysis based on metric data +- **Action-oriented advice** - Recommendations and next steps +- **Conversational format** - Optimized for chat interfaces and follow-up questions +- **Multi-turn support** - Maintains conversation context across multiple questions + +### Comparison with Other Bundle Types + +| Bundle Type | Purpose | Best For | +| ------------- | ---------------------------------- | ---------------------------------------------- | +| **Brief** | AI-powered conversational insights | Chat interfaces, Q&A, multi-turn conversations | +| **Detail** | Comprehensive analysis | Investigation, dashboard views, deep dives | +| **Ban** | Current value snapshot | Banner displays, at-a-glance metrics | +| **Breakdown** | Dimension analysis | Understanding categorical distributions | + +## APIs called + +- [Generate insight brief](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_pulse.htm#PulseInsightsService_GenerateInsightBrief) + +## Required arguments + +### `briefRequest` + +The request to generate an insight brief. This includes the conversation history, language/locale +settings, and metric context. + +**Key fields:** + +- `language`: Language for the response (e.g., 'LANGUAGE_EN_US') +- `locale`: Locale for formatting (e.g., 'LOCALE_EN_US') +- `messages`: Array of conversation messages (see below) +- `now`: Optional current time in 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD' format +- `time_zone`: Optional timezone for date/time calculations + +### Message Structure + +Each message in the `messages` array contains: + +- `action_type`: Type of action + - `ACTION_TYPE_ANSWER` - Answer a specific question + - `ACTION_TYPE_SUMMARIZE` - Provide a summary + - `ACTION_TYPE_ADVISE` - Give recommendations +- `content`: The question or response text (string) +- `role`: Who sent the message + - `ROLE_USER` - User's question + - `ROLE_ASSISTANT` - AI's response +- `metric_group_context`: Array of metrics being analyzed +- `metric_group_context_resolved`: Whether metric context is resolved (boolean) + +## Multi-Turn Conversations + +**Important:** To enable follow-up questions and richer insights, you must include the full +conversation history in the `messages` array: + +1. Add the initial user question with `role: 'ROLE_USER'` +2. Add the assistant's response with `role: 'ROLE_ASSISTANT'` and `content` containing the previous + response text +3. Add the follow-up question with `role: 'ROLE_USER'` + +Without conversation history, follow-up questions may lack context and provide less detailed +answers. + +### Example: Initial Question + +```json +{ + "language": "LANGUAGE_EN_US", + "locale": "LOCALE_EN_US", + "messages": [ + { + "action_type": "ACTION_TYPE_SUMMARIZE", + "content": "What are the key insights for Sales?", + "role": "ROLE_USER", + "metric_group_context": [ + { + "metadata": { + "name": "Sales", + "metric_id": "CF32DDCC-362B-4869-9487-37DA4D152552", + "definition_id": "BBC908D8-29ED-48AB-A78E-ACF8A424C8C3" + }, + "metric": { + "definition": { + "datasource": { + "id": "A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11" + }, + "basic_specification": { + "measure": { + "field": "Sales", + "aggregation": "AGGREGATION_SUM" + }, + "time_dimension": { + "field": "Order Date" + }, + "filters": [] + }, + "is_running_total": false + }, + "metric_specification": { + "filters": [], + "measurement_period": { + "granularity": "GRANULARITY_BY_MONTH", + "range": "RANGE_CURRENT_PARTIAL" + }, + "comparison": { + "comparison": "TIME_COMPARISON_PREVIOUS_PERIOD" + } + }, + "extension_options": { + "allowed_dimensions": ["Region", "Category"], + "allowed_granularities": ["GRANULARITY_BY_DAY", "GRANULARITY_BY_MONTH"], + "offset_from_today": 0 + }, + "representation_options": { + "type": "NUMBER_FORMAT_TYPE_NUMBER", + "number_units": { + "singular_noun": "dollar", + "plural_noun": "dollars" + }, + "sentiment_type": "SENTIMENT_TYPE_NONE", + "row_level_id_field": { + "identifier_col": "Order ID" + }, + "row_level_entity_names": { + "entity_name_singular": "Order", + "entity_name_plural": "Orders" + }, + "row_level_name_field": { + "name_col": "Order Name" + }, + "currency_code": "CURRENCY_CODE_USD" + }, + "insights_options": { + "settings": [ + { "type": "INSIGHT_TYPE_TOP_DRIVERS", "disabled": false }, + { "type": "INSIGHT_TYPE_METRIC_FORECAST", "disabled": false } + ] + } + } + } + ], + "metric_group_context_resolved": true + } + ] +} +``` + +### Example: Follow-up Question with Conversation History + +```json +{ + "language": "LANGUAGE_EN_US", + "locale": "LOCALE_EN_US", + "messages": [ + { + "action_type": "ACTION_TYPE_SUMMARIZE", + "content": "What are the key insights for Sales?", + "role": "ROLE_USER", + "metric_group_context": [ + /* ... */ + ], + "metric_group_context_resolved": true + }, + { + "action_type": "ACTION_TYPE_SUMMARIZE", + "content": "Sales increased 5% with growth in Region A and B...", + "role": "ROLE_ASSISTANT", + "metric_group_context": [ + /* ... */ + ], + "metric_group_context_resolved": true + }, + { + "action_type": "ACTION_TYPE_ANSWER", + "content": "What factors contributed to the increase?", + "role": "ROLE_USER", + "metric_group_context": [ + /* ... */ + ], + "metric_group_context_resolved": true + } + ] +} +``` + +## Use Cases + +### Conversational Analytics + +Interactive Q&A about metrics: + +``` +User: "What are the key insights for Sales?" +AI: "Sales is up 5% with growth in West region..." + +User: "What factors contributed to the increase?" +AI: "The increase was driven by Technology category growth..." +``` + +### Executive Briefings + +Natural language metric summaries: + +``` +"What should I know about my metrics today?" +→ AI-generated summary of key changes, trends, and recommendations +``` + +## Example Response + +```json +{ + "data": { + "markup": "- **Forecast for November 22, 2025**: The forecasted value for Sales is $150K, with a confidence range of $145K to $155K.\n\n- **Month-to-Date Comparison**: Sales for November 2025 month-to-date is $150K, which is a 5.0% increase compared to October 2025 month-to-date ($142.9K).\n\nOverall, the metric shows a positive trend with a slight increase month-to-date and a stable forecast.", + "generation_id": "abc123...", + "source_insights": [ + { + "type": "forecast", + "markup": "The forecast for Sales for November 22, 2025 is $150K with a confidence range of $145K to $155K.", + "viz": { + /* Vega-Lite visualization spec */ + }, + "facts": { + /* Insight facts and data */ + } + }, + { + "type": "popc", + "markup": "Sales was $150K (November 2025 month to date), up 5.0% ($7.1K) compared to the prior period.", + "viz": { + /* Vega-Lite visualization spec */ + }, + "facts": { + /* Insight facts and data */ + } + } + ], + "follow_up_questions": [ + { "content": "What factors contributed to the increase in Sales?" }, + { "content": "How does the forecast compare to historical trends?" } + ], + "group_context": [ + /* Full metric context */ + ], + "not_enough_information": false + } +} +``` + +## Response Fields + +- `markup`: AI-generated markdown text with the answer +- `generation_id`: Unique ID for this generation +- `source_insights`: Array of underlying insights used to generate the response + - Each insight includes `type`, `markup`, `viz` (Vega-Lite spec), and `facts` +- `follow_up_questions`: Suggested next questions to continue the conversation +- `group_context`: The full metric context used +- `not_enough_information`: Boolean indicating if the AI had insufficient data + +## Notes + +- **Conversational**: Designed for multi-turn Q&A about metrics +- **Context-aware**: Maintains conversation history for richer responses +- **AI-powered**: Uses natural language understanding to answer questions +- **Visualization-rich**: Includes Vega-Lite specs for charts +- **Follow-up suggestions**: Provides relevant next questions +- **Ideal for chat interfaces**: Slack, Teams, ChatGPT, Claude, etc. diff --git a/package-lock.json b/package-lock.json index 372125a0..80522186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tableau/mcp-server", - "version": "1.12.4", + "version": "1.12.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tableau/mcp-server", - "version": "1.12.4", + "version": "1.12.5", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index e3c7dfcb..a3f6e5aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tableau/mcp-server", "description": "An MCP server for Tableau, providing a suite of tools that will make it easier for developers to build AI applications that integrate with Tableau.", - "version": "1.12.4", + "version": "1.12.5", "repository": { "type": "git", "url": "git+https://github.com/tableau/tableau-mcp.git" diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index 2d2e967f..0406deb1 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -27,7 +27,8 @@ type JwtScopes = | 'tableau:insight_metrics:read' | 'tableau:metric_subscriptions:read' | 'tableau:insights:read' - | 'tableau:views:download'; + | 'tableau:views:download' + | 'tableau:insight_brief:create'; const getNewRestApiInstanceAsync = async ( config: Config, diff --git a/src/sdks/tableau/apis/pulseApi.ts b/src/sdks/tableau/apis/pulseApi.ts index be0f24da..e37a5e4d 100644 --- a/src/sdks/tableau/apis/pulseApi.ts +++ b/src/sdks/tableau/apis/pulseApi.ts @@ -4,6 +4,8 @@ import { z } from 'zod'; import { pulseBundleRequestSchema, pulseBundleResponseSchema, + pulseInsightBriefRequestSchema, + pulseInsightBriefResponseSchema, pulseInsightBundleTypeEnum, pulseMetricDefinitionSchema, pulseMetricDefinitionViewEnum, @@ -147,8 +149,25 @@ const generatePulseMetricValueInsightBundleRestEndpoint = makeEndpoint({ response: pulseBundleResponseSchema, }); +const generatePulseInsightBriefRestEndpoint = makeEndpoint({ + method: 'post', + path: '/pulse/insights/brief', + alias: 'generatePulseInsightBrief', + description: + 'Generates an AI-powered insight brief for Pulse metrics based on natural language questions.', + parameters: [ + { + name: 'brief_request', + type: 'Body', + schema: pulseInsightBriefRequestSchema, + }, + ], + response: pulseInsightBriefResponseSchema, +}); + const pulseApi = makeApi([ generatePulseMetricValueInsightBundleRestEndpoint, + generatePulseInsightBriefRestEndpoint, listAllPulseMetricDefinitionsRestEndpoint, listPulseMetricDefinitionsFromMetricDefinitionIdsRestEndpoint, listPulseMetricsFromMetricDefinitionIdRestEndpoint, diff --git a/src/sdks/tableau/methods/pulseMethods.ts b/src/sdks/tableau/methods/pulseMethods.ts index cc494932..3983fb62 100644 --- a/src/sdks/tableau/methods/pulseMethods.ts +++ b/src/sdks/tableau/methods/pulseMethods.ts @@ -9,6 +9,8 @@ import { PulsePagination } from '../types/pagination.js'; import { pulseBundleRequestSchema, PulseBundleResponse, + pulseInsightBriefRequestSchema, + PulseInsightBriefResponse, PulseInsightBundleType, PulseMetric, PulseMetricDefinition, @@ -145,6 +147,18 @@ export default class PulseMethods extends AuthenticatedMethods }); }; + generatePulseInsightBrief = async ( + briefRequest: z.infer, + ): Promise> => { + return await guardAgainstPulseDisabled(async () => { + const response = await this._apiClient.generatePulseInsightBrief( + briefRequest, + this.authHeader, + ); + return response; + }); + }; + /** * Returns the generated bundle of the current aggregate value for the Pulse metric. * diff --git a/src/sdks/tableau/types/pulse.ts b/src/sdks/tableau/types/pulse.ts index 9d8e57c0..d2b006c9 100644 --- a/src/sdks/tableau/types/pulse.ts +++ b/src/sdks/tableau/types/pulse.ts @@ -269,6 +269,66 @@ export const outputFormatEnumSchema = z.enum([ ]); export type OutputFormatEnumType = z.infer; +// Tableau datetime format: YYYY-MM-DD HH:MM:SS or YYYY-MM-DD +// If no time is specified, midnight (00:00:00) is used +export const tableauDateTimeSchema = z + .string() + .regex( + /^(\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?)?$/, + 'Format must be YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, or empty. If no time is specified, midnight (00:00:00) is used.', + ); + +export const actionTypeEnumSchema = z.enum([ + 'ACTION_TYPE_UNDEFINED', + 'ACTION_TYPE_ANSWER', + 'ACTION_TYPE_SUMMARIZE', + 'ACTION_TYPE_ADVISE', +]); +export type ActionTypeEnumType = z.infer; + +export const roleEnumSchema = z.enum(['ROLE_UNDEFINED', 'ROLE_USER', 'ROLE_ASSISTANT']); +export type RoleEnumType = z.infer; + +export const metricGroupContextSchema = z.array( + z.object({ + metadata: z.object({ + name: z.string(), + metric_id: z.string(), + definition_id: z.string(), + }), + metric: z.object({ + definition: pulseSpecificationSchema, + metric_specification: pulseMetricSpecificationSchema, + extension_options: pulseExtensionOptionsSchema, + representation_options: pulseRepresentationOptionsSchema, + insights_options: insightOptionsSchema, + goals: z + .object({ + datasource_goals: datasourceGoalsSchema.optional(), + metric_goals: pulseGoalsSchema.optional(), + }) + .optional(), + candidates: z.array(z.any()).optional(), + }), + }), +); + +export const messagesSchema = z.object({ + action_type: actionTypeEnumSchema, + content: z.string(), + metric_group_context: metricGroupContextSchema, + metric_group_context_resolved: z.boolean(), + role: roleEnumSchema, +}); + +export const pulseInsightBriefRequestSchema = z.object({ + language: languageEnumSchema, + locale: localeEnumSchema, + messages: z.array(messagesSchema), + now: tableauDateTimeSchema.optional(), + time_zone: z.string().optional(), +}); + export const pulseBundleRequestSchema = z.object({ bundle_request: z.object({ version: z.number(), @@ -302,21 +362,67 @@ export const pulseBundleRequestSchema = z.object({ }), }); +export const insightSchema = z.object({ + type: z.string(), + version: z.number(), + content: z.string().optional(), + markup: z.string().optional(), + viz: z.any().optional(), + facts: z.any().optional(), + characterization: z.string().optional(), + question: z.string(), + score: z.number(), +}); + +export const sourceInsightSchema = z + .object({ + type: z.string(), + version: z.number(), + content: z.string(), + markup: z.string(), + viz: z.any(), + facts: z.any(), + characterization: z.string(), + question: z.string(), + score: z.number(), + id: z.string(), + generation_id: z.string(), + insight_feedback_metadata: z.object({ + candidate_definition_id: z.string(), + dimension_hash: z.string(), + score: z.number(), + type: z.string(), + }), + table: z.object({ + columns: z.array( + z.object({ + label: z.string(), + }), + ), + rows: z.array( + z.object({ + entries: z.array( + z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + }), + value: z.object({ + formatted_value: z.string(), + }), + }), + ), + }), + ), + }), + }) + .partial(); + export const popcBanInsightGroupSchema = z.object({ type: z.string(), insights: z.array( z.object({ - result: z.object({ - type: z.string(), - version: z.number(), - content: z.string().optional(), - markup: z.string().optional(), - viz: z.any().optional(), - facts: z.any().optional(), - characterization: z.string().optional(), - question: z.string(), - score: z.number(), - }), + result: insightSchema, insight_type: z.string(), }), ), @@ -344,7 +450,22 @@ export const pulseBundleResponseSchema = z.object({ }), }); +export const pulseInsightBriefResponseSchema = z.object({ + follow_up_questions: z.array( + z.object({ + content: z.string(), + metric_group_context_resolved: z.boolean().optional(), + }), + ), + generation_id: z.string(), + group_context: metricGroupContextSchema, + markup: z.string(), + not_enough_information: z.boolean(), + source_insights: z.array(sourceInsightSchema), +}); + export type PulseBundleResponse = z.infer; +export type PulseInsightBriefResponse = z.infer; export const pulseInsightBundleTypeEnum = ['ban', 'springboard', 'basic', 'detail'] as const; export type PulseInsightBundleType = (typeof pulseInsightBundleTypeEnum)[number]; diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts new file mode 100644 index 00000000..1df73d1e --- /dev/null +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -0,0 +1,350 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; + +import { Server } from '../../../server.js'; +import { getGeneratePulseInsightBriefTool } from './generatePulseInsightBriefTool.js'; + +const mocks = vi.hoisted(() => ({ + mockGeneratePulseInsightBrief: vi.fn(), + mockGetConfig: vi.fn(), +})); + +vi.mock('../../../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + pulseMethods: { + generatePulseInsightBrief: mocks.mockGeneratePulseInsightBrief, + }, + }), + ), +})); + +vi.mock('../../../config.js', () => ({ + getConfig: mocks.mockGetConfig, +})); + +describe('getGeneratePulseInsightBriefTool', () => { + const briefRequest = { + language: 'LANGUAGE_EN_US' as const, + locale: 'LOCALE_EN_US' as const, + messages: [ + { + action_type: 'ACTION_TYPE_SUMMARIZE' as const, + content: 'What are the key insights for my sales metric?', + role: 'ROLE_USER' as const, + metric_group_context: [ + { + metadata: { + name: 'Sales Metric', + metric_id: 'CF32DDCC-362B-4869-9487-37DA4D152552', + definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', + }, + metric: { + definition: { + datasource: { + id: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11', + }, + basic_specification: { + measure: { + field: 'Sales', + aggregation: 'AGGREGATION_SUM' as const, + }, + time_dimension: { + field: 'Order Date', + }, + filters: [], + }, + is_running_total: false, + }, + metric_specification: { + filters: [], + measurement_period: { + granularity: 'GRANULARITY_BY_MONTH' as const, + range: 'RANGE_CURRENT_PARTIAL' as const, + }, + comparison: { + comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD' as const, + }, + }, + extension_options: { + allowed_dimensions: [], + allowed_granularities: [], + offset_from_today: 0, + }, + representation_options: { + type: 'NUMBER_FORMAT_TYPE_NUMBER' as const, + number_units: { + singular_noun: 'dollar', + plural_noun: 'dollars', + }, + sentiment_type: 'SENTIMENT_TYPE_NONE' as const, + row_level_id_field: { + identifier_col: 'Order ID', + }, + row_level_entity_names: { + entity_name_singular: 'Order', + entity_name_plural: 'Orders', + }, + row_level_name_field: { + name_col: 'Order Name', + }, + currency_code: 'CURRENCY_CODE_USD' as const, + }, + insights_options: { + settings: [], + }, + }, + }, + ], + metric_group_context_resolved: true, + }, + ], + now: '2025-11-15 00:00:00', + time_zone: 'UTC', + }; + + const mockBriefResponse = { + markup: 'Your sales metric shows strong performance this month with a 15% increase.', + generation_id: 'test-generation-id', + source_insights: [ + { + type: 'trend', + markup: 'Sales have been trending upward consistently', + }, + ], + follow_up_questions: [ + { content: 'What factors contributed to the increase?' }, + { content: 'How does this compare to last year?' }, + ], + group_context: briefRequest.messages[0].metric_group_context, + not_enough_information: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: null, + workbookIds: null, + }, + }); + }); + + it('should have correct tool name', () => { + const tool = getGeneratePulseInsightBriefTool(new Server()); + expect(tool.name).toBe('generate-pulse-insight-brief'); + }); + + it('should have correct annotations', () => { + const tool = getGeneratePulseInsightBriefTool(new Server()); + expect(tool.annotations).toEqual({ + title: 'Generate Pulse Insight Brief', + readOnlyHint: true, + openWorldHint: false, + }); + }); + + it('should have brief request in params schema', () => { + const tool = getGeneratePulseInsightBriefTool(new Server()); + expect(tool.paramsSchema).toHaveProperty('briefRequest'); + }); + + it('should call generatePulseInsightBrief with brief request', async () => { + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new Ok(mockBriefResponse)); + const result = await getToolResult(); + expect(mocks.mockGeneratePulseInsightBrief).toHaveBeenCalledWith(briefRequest); + expect(result.isError).toBe(false); + const parsedValue = JSON.parse(result.content[0].text as string); + expect(parsedValue).toEqual(mockBriefResponse); + }); + + it('should handle API errors gracefully', async () => { + const errorMessage = 'API Error'; + mocks.mockGeneratePulseInsightBrief.mockRejectedValue(new Error(errorMessage)); + const result = await getToolResult(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain(errorMessage); + }); + + it('should return an error when executing the tool against Tableau Server', async () => { + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new Err('tableau-server')); + const result = await getToolResult(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Pulse is not available on Tableau Server.'); + }); + + it('should return an error when Pulse is disabled', async () => { + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new Err('pulse-disabled')); + const result = await getToolResult(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Pulse is disabled on this Tableau Cloud site.'); + }); + + it('should filter out metrics when datasource is not in the allowed set', async () => { + const allowedDatasourceId = 'ALLOWED-DATASOURCE-ID'; + const notAllowedDatasourceId = 'NOT-ALLOWED-DATASOURCE-ID'; + + const twoMetricRequest = { + ...briefRequest, + messages: [ + { + ...briefRequest.messages[0], + metric_group_context: [ + { + metadata: { + name: 'Allowed Metric', + metric_id: 'METRIC-1', + definition_id: 'DEF-1', + }, + metric: { + definition: { + datasource: { id: allowedDatasourceId }, + basic_specification: { + measure: { field: 'Sales', aggregation: 'AGGREGATION_SUM' as const }, + time_dimension: { field: 'Order Date' }, + filters: [], + }, + is_running_total: false, + }, + metric_specification: { + filters: [], + measurement_period: { + granularity: 'GRANULARITY_BY_MONTH' as const, + range: 'RANGE_CURRENT_PARTIAL' as const, + }, + comparison: { comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD' as const }, + }, + extension_options: { + allowed_dimensions: [], + allowed_granularities: [], + offset_from_today: 0, + }, + representation_options: { + type: 'NUMBER_FORMAT_TYPE_NUMBER' as const, + number_units: { singular_noun: 'unit', plural_noun: 'units' }, + sentiment_type: 'SENTIMENT_TYPE_NONE' as const, + row_level_id_field: { identifier_col: 'ID' }, + row_level_entity_names: { + entity_name_singular: 'Item', + entity_name_plural: 'Items', + }, + row_level_name_field: { name_col: 'Name' }, + currency_code: 'CURRENCY_CODE_USD' as const, + }, + insights_options: { settings: [] }, + }, + }, + { + metadata: { + name: 'Not Allowed Metric', + metric_id: 'METRIC-2', + definition_id: 'DEF-2', + }, + metric: { + definition: { + datasource: { id: notAllowedDatasourceId }, + basic_specification: { + measure: { field: 'Profit', aggregation: 'AGGREGATION_SUM' as const }, + time_dimension: { field: 'Order Date' }, + filters: [], + }, + is_running_total: false, + }, + metric_specification: { + filters: [], + measurement_period: { + granularity: 'GRANULARITY_BY_MONTH' as const, + range: 'RANGE_CURRENT_PARTIAL' as const, + }, + comparison: { comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD' as const }, + }, + extension_options: { + allowed_dimensions: [], + allowed_granularities: [], + offset_from_today: 0, + }, + representation_options: { + type: 'NUMBER_FORMAT_TYPE_NUMBER' as const, + number_units: { singular_noun: 'unit', plural_noun: 'units' }, + sentiment_type: 'SENTIMENT_TYPE_NONE' as const, + row_level_id_field: { identifier_col: 'ID' }, + row_level_entity_names: { + entity_name_singular: 'Item', + entity_name_plural: 'Items', + }, + row_level_name_field: { name_col: 'Name' }, + currency_code: 'CURRENCY_CODE_USD' as const, + }, + insights_options: { settings: [] }, + }, + }, + ], + }, + ], + }; + + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: new Set([allowedDatasourceId]), + workbookIds: null, + }, + }); + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new Ok(mockBriefResponse)); + + const tool = getGeneratePulseInsightBriefTool(new Server()); + const result = await tool.callback( + { briefRequest: twoMetricRequest }, + { + signal: new AbortController().signal, + requestId: 'test-request-id', + sendNotification: vi.fn(), + sendRequest: vi.fn(), + }, + ); + + // Should succeed + expect(result.isError).toBe(false); + + // Verify that the API was called with only the allowed metric + expect(mocks.mockGeneratePulseInsightBrief).toHaveBeenCalled(); + const calledWith = mocks.mockGeneratePulseInsightBrief.mock.calls[0][0]; + expect(calledWith.messages[0].metric_group_context).toHaveLength(1); + expect(calledWith.messages[0].metric_group_context[0].metadata.name).toBe('Allowed Metric'); + expect(calledWith.messages[0].metric_group_context[0].metric.definition.datasource.id).toBe( + allowedDatasourceId, + ); + }); + + it('should return an error when all metrics are filtered out', async () => { + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: new Set(['ALLOWED-DATASOURCE-ID']), + workbookIds: null, + }, + }); + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new Ok(mockBriefResponse)); + + const result = await getToolResult(); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'One or more messages in the request contain only metrics derived from data sources that are not in the allowed set.', + ); + }); + + async function getToolResult(): Promise { + const tool = getGeneratePulseInsightBriefTool(new Server()); + return await tool.callback( + { briefRequest }, + { + signal: new AbortController().signal, + requestId: 'test-request-id', + sendNotification: vi.fn(), + sendRequest: vi.fn(), + }, + ); + } +}); diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts new file mode 100644 index 00000000..1f3596d1 --- /dev/null +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -0,0 +1,266 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err } from 'ts-results-es'; + +import { getConfig } from '../../../config.js'; +import { useRestApi } from '../../../restApiInstance.js'; +import { PulseDisabledError } from '../../../sdks/tableau/methods/pulseMethods.js'; +import { + pulseInsightBriefRequestSchema, + PulseInsightBriefResponse, +} from '../../../sdks/tableau/types/pulse.js'; +import { Server } from '../../../server.js'; +import { getTableauAuthInfo } from '../../../server/oauth/getTableauAuthInfo.js'; +import { Tool } from '../../tool.js'; +import { getPulseDisabledError } from '../getPulseDisabledError.js'; + +const paramsSchema = { + briefRequest: pulseInsightBriefRequestSchema, +}; + +export type GeneratePulseInsightBriefError = + | { + type: 'feature-disabled'; + reason: PulseDisabledError; + } + | { + type: 'datasource-not-allowed'; + message: string; + }; + +export const getGeneratePulseInsightBriefTool = (server: Server): Tool => { + const generatePulseInsightBriefTool = new Tool({ + server, + name: 'generate-pulse-insight-brief', + description: ` +Generate a concise insight brief for Pulse Metrics using Tableau REST API. This endpoint provides AI-powered conversational insights based on natural language questions about your metrics. + +**What is an Insight Brief?** +An insight brief is an AI-generated response to questions about Pulse metrics. It provides: +- Natural language answers to specific questions +- Contextual summaries based on metric data +- Action-oriented advice and recommendations +- Conversational format optimized for chat interfaces + +**Insight Brief vs. Other Bundle Types:** +- **Brief**: AI-powered conversational insights based on natural language questions (this endpoint) +- **Detail**: Comprehensive analysis with full visualizations and trend breakdowns +- **Ban**: Current value with period-over-period change and top dimensional insights +- **Breakdown**: Emphasizes categorical dimension analysis and distributions + +**IMPORTANT Requirements:** + +1. **Same Datasource Recommendation**: The API works best when all metrics in \`metric_group_context\` come from the same datasource, + as this allows the backend to apply consistent filters across metrics. While the API may accept metrics from different datasources, + it is recommended to group metrics by datasource and make separate API calls per datasource for optimal results. + +2. **Complete Metric Data**: The \`metric_group_context\` must include complete metric data from the metric definition: + - \`extension_options\` with actual \`allowed_dimensions\` and \`allowed_granularities\` arrays (not empty) + - \`representation_options\` with correct \`sentiment_type\`, \`currency_code\`, and format settings + - \`insights_options.settings\` with all insight types and their enabled/disabled state + - Incomplete data will cause API errors even if it passes schema validation + +3. **Multi-Turn Conversations**: To enable follow-up questions and conversational analysis, include the full conversation + history in the \`messages\` array: + - Add the initial user question with \`role: 'ROLE_USER'\` + - Add the assistant's response with \`role: 'ROLE_ASSISTANT'\` and \`content\` containing the previous response text + - Add the follow-up question with \`role: 'ROLE_USER'\` + - Without conversation history, follow-up questions may lack context + +**Parameters:** +- \`briefRequest\` (required): The request to generate a brief for. This includes: + - \`language\`: Language for the response (e.g., 'LANGUAGE_EN_US') + - \`locale\`: Locale for formatting (e.g., 'LOCALE_EN_US') + - \`messages\`: Array of conversation messages containing: + - \`action_type\`: Type of action ('ACTION_TYPE_ANSWER', 'ACTION_TYPE_SUMMARIZE', 'ACTION_TYPE_ADVISE') + - \`content\`: The user's question or prompt (string, natural language) + - \`role\`: Who initiated the request ('ROLE_USER' or 'ROLE_ASSISTANT') + - \`metric_group_context\`: Array of metrics to analyze (metadata + metric specification) + - \`metric_group_context_resolved\`: Whether the metric context has been resolved (boolean) + - \`now\`: Optional current time in 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD' format (defaults to midnight if time omitted) + - \`time_zone\`: Optional timezone for date/time calculations + +**Action Types:** +- \`ACTION_TYPE_ANSWER\`: Answer a specific question about the metric +- \`ACTION_TYPE_SUMMARIZE\`: Provide a summary of metric insights +- \`ACTION_TYPE_ADVISE\`: Give recommendations or advice based on metric data + +**Example Usage:** +- Ask a question about a metric: + briefRequest: { + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + messages: [ + { + action_type: 'ACTION_TYPE_ANSWER', + content: 'Why did sales increase this month?', + role: 'ROLE_USER', + metric_group_context: [ + { + metadata: { + name: 'Sales', + id: 'CF32DDCC-362B-4869-9487-37DA4D152552', + definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', + }, + metric: { + definition: { /* metric definition */ }, + specification: { /* metric specification */ }, + }, + } + ], + metric_group_context_resolved: true, + } + ], + now: '2025-11-14 15:30:00', + time_zone: 'America/Los_Angeles', + } + +- Get a summary of multiple metrics: + briefRequest: { + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + messages: [ + { + action_type: 'ACTION_TYPE_SUMMARIZE', + content: 'Summarize the key changes across my metrics', + role: 'ROLE_USER', + metric_group_context: [ + { metadata: { /* Sales metric */ }, metric: { /* ... */ } }, + { metadata: { /* Revenue metric */ }, metric: { /* ... */ } }, + { metadata: { /* Customers metric */ }, metric: { /* ... */ } }, + ], + metric_group_context_resolved: true, + } + ], + } + +- Get advice based on metric performance: + briefRequest: { + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + messages: [ + { + action_type: 'ACTION_TYPE_ADVISE', + content: 'What should I focus on to improve revenue?', + role: 'ROLE_USER', + metric_group_context: [ + { metadata: { /* Revenue metric */ }, metric: { /* ... */ } }, + ], + metric_group_context_resolved: true, + } + ], + } + +- Ask a follow-up question (includes conversation history): + briefRequest: { + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + messages: [ + { + action_type: 'ACTION_TYPE_SUMMARIZE', + content: 'What are the key insights for Sales?', + role: 'ROLE_USER', + metric_group_context: [ { metadata: { /* ... */ }, metric: { /* ... */ } } ], + metric_group_context_resolved: true, + }, + { + action_type: 'ACTION_TYPE_SUMMARIZE', + content: 'Sales increased 5% with growth in Region A and B...', + role: 'ROLE_ASSISTANT', + metric_group_context: [ { metadata: { /* ... */ }, metric: { /* ... */ } } ], + metric_group_context_resolved: true, + }, + { + action_type: 'ACTION_TYPE_ANSWER', + content: 'What factors contributed to the increase?', + role: 'ROLE_USER', + metric_group_context: [ { metadata: { /* ... */ }, metric: { /* ... */ } } ], + metric_group_context_resolved: true, + } + ], + } + +**Use Cases:** +- **Conversational analytics** - Natural language Q&A about metrics +- **Executive briefings** - "What should I know about my metrics today?" +- **Intelligent alerts** - Context-aware notifications with explanations +- **Multi-metric analysis** - Ask questions across multiple metrics at once +`, + paramsSchema, + annotations: { + title: 'Generate Pulse Insight Brief', + readOnlyHint: true, + openWorldHint: false, + }, + callback: async ({ briefRequest }, { requestId, authInfo }): Promise => { + const config = getConfig(); + return await generatePulseInsightBriefTool.logAndExecute< + PulseInsightBriefResponse, + GeneratePulseInsightBriefError + >({ + requestId, + authInfo, + args: { briefRequest }, + callback: async () => { + // Filter out metrics that are not in the allowed datasource set + const { datasourceIds } = config.boundedContext; + if (datasourceIds) { + for (const message of briefRequest.messages) { + if (message.metric_group_context) { + message.metric_group_context = message.metric_group_context.filter( + (metricContext) => + datasourceIds.has(metricContext.metric.definition.datasource.id), + ); + + // If filtering removed all metrics from this message, return an error + if (message.metric_group_context.length === 0) { + return new Err({ + type: 'datasource-not-allowed', + message: [ + 'The set of allowed metric insights that can be queried is limited by the server configuration.', + 'One or more messages in the request contain only metrics derived from data sources that are not in the allowed set.', + ].join(' '), + }); + } + } + } + } + + const result = await useRestApi({ + config, + requestId, + server, + jwtScopes: ['tableau:insight_brief:create'], + authInfo: getTableauAuthInfo(authInfo), + callback: async (restApi) => + await restApi.pulseMethods.generatePulseInsightBrief(briefRequest), + }); + + if (result.isErr()) { + return new Err({ + type: 'feature-disabled', + reason: result.error, + }); + } + + return result; + }, + constrainSuccessResult: (insightBrief) => { + return { + type: 'success', + result: insightBrief, + }; + }, + getErrorText: (error) => { + switch (error.type) { + case 'feature-disabled': + return getPulseDisabledError(error.reason); + case 'datasource-not-allowed': + return error.message; + } + }, + }); + }, + }); + + return generatePulseInsightBriefTool; +}; diff --git a/src/tools/toolName.ts b/src/tools/toolName.ts index aeb8a0a9..137e95a4 100644 --- a/src/tools/toolName.ts +++ b/src/tools/toolName.ts @@ -13,6 +13,7 @@ export const toolNames = [ 'list-pulse-metrics-from-metric-ids', 'list-pulse-metric-subscriptions', 'generate-pulse-metric-value-insight-bundle', + 'generate-pulse-insight-brief', 'search-content', ] as const; export type ToolName = (typeof toolNames)[number]; @@ -37,6 +38,7 @@ export const toolGroups = { 'list-pulse-metrics-from-metric-ids', 'list-pulse-metric-subscriptions', 'generate-pulse-metric-value-insight-bundle', + 'generate-pulse-insight-brief', ], 'content-exploration': ['search-content'], } as const satisfies Record>; diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 7732e9b3..9dbc212c 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -1,6 +1,7 @@ import { getSearchContentTool } from './contentExploration/searchContent.js'; import { getGetDatasourceMetadataTool } from './getDatasourceMetadata/getDatasourceMetadata.js'; import { getListDatasourcesTool } from './listDatasources/listDatasources.js'; +import { getGeneratePulseInsightBriefTool } from './pulse/generateInsightBrief/generatePulseInsightBriefTool.js'; import { getGeneratePulseMetricValueInsightBundleTool } from './pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.js'; import { getListAllPulseMetricDefinitionsTool } from './pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.js'; import { getListPulseMetricDefinitionsFromDefinitionIdsTool } from './pulse/listMetricDefinitionsFromDefinitionIds/listPulseMetricDefinitionsFromDefinitionIds.js'; @@ -24,6 +25,7 @@ export const toolFactories = [ getListPulseMetricsFromMetricIdsTool, getListPulseMetricSubscriptionsTool, getGeneratePulseMetricValueInsightBundleTool, + getGeneratePulseInsightBriefTool, getGetWorkbookTool, getGetViewDataTool, getGetViewImageTool,