From 8741002859dd2106356eaa5856bb385916605d21 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 14 Nov 2025 18:50:23 -0800 Subject: [PATCH 01/15] initial discover wip --- .../pulse/generate-pulse-insight-brief.md | 217 +++++++++++++++++ src/sdks/tableau/apis/pulseApi.ts | 18 ++ src/sdks/tableau/methods/pulseMethods.ts | 14 ++ src/sdks/tableau/types/pulse.ts | 83 ++++++- .../generatePulseInsightBriefTool.test.ts | 177 ++++++++++++++ .../generatePulseInsightBriefTool.ts | 218 ++++++++++++++++++ src/tools/toolName.ts | 2 + src/tools/tools.ts | 2 + 8 files changed, 720 insertions(+), 11 deletions(-) create mode 100644 docs/docs/tools/pulse/generate-pulse-insight-brief.md create mode 100644 src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts create mode 100644 src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts 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..c049481e --- /dev/null +++ b/docs/docs/tools/pulse/generate-pulse-insight-brief.md @@ -0,0 +1,217 @@ +--- +sidebar_position: 7 +--- + +# Generate Pulse Insight Brief + +Generates a concise insight brief for a Pulse metric, optimized for quick consumption in emails, notifications, or mobile displays. + +## What is an Insight Brief? + +An **insight brief** is a condensed, text-focused summary of metric changes that provides: + +- **Quick overview** - Key insights without detailed visualizations +- **Concise format** - Optimized for notifications, emails, and mobile +- **Action-oriented** - Highlights what changed and why +- **Minimal data** - Just the essentials for fast consumption + +### Comparison with Other Bundle Types + +| Bundle Type | Purpose | Best For | +|------------|---------|----------| +| **Brief** | Quick summary | Notifications, emails, mobile, daily digests | +| **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_GenerateInsightBundleBreakdown) + +## Required arguments + +### `bundleRequest` + +The request to generate a brief for. This requires the full Pulse Metric and Pulse Metric Definition information. + +Example: + +```json +{ + "bundle_request": { + "version": 1, + "options": { + "output_format": "OUTPUT_FORMAT_HTML", + "time_zone": "UTC", + "language": "LANGUAGE_EN_US", + "locale": "LOCALE_EN_US" + }, + "input": { + "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" + }, + "row_level_id_field": { + "identifier_col": "Order ID" + }, + "row_level_entity_names": { + "entity_name_singular": "Order" + }, + "row_level_name_field": { + "name_col": "Order Name" + }, + "currency_code": "CURRENCY_CODE_USD" + }, + "insights_options": { + "settings": [] + } + } + } + } +} +``` + +## Use Cases + +### Daily Digest Emails +Generate brief summaries for automated email digests: +``` +"Sales was $150K (Nov 2025), up 23% vs. prior month. +West region drove most of the growth (+45%)." +``` + +### Mobile Notifications +Push notifications with concise metric updates: +``` +"🔔 Customer Count up 15% today - highest this quarter" +``` + +### Slack/Teams Bots +Quick metric updates in chat channels: +``` +"/pulse-brief sales" +→ Sales: $50K (+12%) | Top: West region | Status: Above target +``` + +### Dashboard Tooltips +Contextual metric summaries on hover: +``` +[Hover over metric card] +→ Brief: "Q4 Sales exceeded target by 8%, + primarily due to Technology category growth" +``` + +### Executive Summaries +Concise reporting for leadership: +``` +Daily Brief - Nov 14, 2025: +• Revenue: $2.1M (+5%) +• New Customers: 142 (+18%) +• Support Tickets: 89 (-12%) +``` + +## Example Response + +```json +{ + "bundle_response": { + "result": { + "insight_groups": [ + { + "type": "brief", + "insights": [ + { + "insight_type": "popc", + "result": { + "markup": "Sales was $150,000 (November 2025 month to date), up 23.5% (29,000) compared to the prior period (October 2025)." + } + }, + { + "insight_type": "top-drivers", + "result": { + "markup": "West region increased the most (+45%), contributing $13,500 of the growth." + } + } + ], + "summaries": [ + "Sales increased 23.5% vs. last month, driven primarily by West region." + ] + } + ], + "has_errors": false, + "characterization": "CHARACTERIZATION_UNSPECIFIED" + } + } +} +``` + +## Building a "Pulse Discover" Feature + +You can use insight briefs to build a custom Pulse Discover-like experience: + +1. **List user subscriptions** - Get all metrics a user follows +2. **Generate briefs** - For each subscription, get the insight brief +3. **Rank by importance** - Use AI to identify which metrics need attention +4. **Create daily digest** - Summarize into a morning brief + +```typescript +// Pseudocode example +const subscriptions = await listPulseMetricSubscriptions(userId); +const briefs = await Promise.all( + subscriptions.map(sub => generatePulseInsightBrief(sub.metric)) +); +const digest = await AI.summarize(briefs, { + prioritize: "significant_changes", + format: "daily_brief" +}); +``` + +## Notes + +- Insight briefs are **text-heavy** with minimal visualizations +- Optimized for **mobile and notification** contexts +- Focus on **what changed** and **key drivers** +- Typically **shorter** than detail bundles +- Ideal for **automated reporting** systems + + + diff --git a/src/sdks/tableau/apis/pulseApi.ts b/src/sdks/tableau/apis/pulseApi.ts index be0f24da..5db9a966 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,24 @@ 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..a2f0ee4a 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..57dfdb66 100644 --- a/src/sdks/tableau/types/pulse.ts +++ b/src/sdks/tableau/types/pulse.ts @@ -269,6 +269,53 @@ 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: pulseMetadataSchema, + metric: pulseMetricSchema, + }), +); + +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 +349,23 @@ 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 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 +393,19 @@ export const pulseBundleResponseSchema = z.object({ }), }); + +export const pulseInsightBriefResponseSchema = z.object({ + follow_up_questions: z.array(z.string()), + generation_id: z.string(), + group_context: metricGroupContextSchema, + markup: z.string(), + not_enough_information: z.boolean(), + source_insights: z.array(insightSchema), +}) + + 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..eb22968e --- /dev/null +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -0,0 +1,177 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err, Ok } from 'ts-results-es'; + +import { Server } from '../../../server.js'; +import { exportedForTesting as resourceAccessCheckerExportedForTesting } from '../../resourceAccessChecker.js'; +import { getGeneratePulseInsightBriefTool } from './generatePulseInsightBriefTool.js'; + +const { resetResourceAccessCheckerSingleton } = resourceAccessCheckerExportedForTesting; +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', + id: 'CF32DDCC-362B-4869-9487-37DA4D152552', + definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', + }, + metric: { + id: 'CF32DDCC-362B-4869-9487-37DA4D152552', + specification: { + filters: [], + measurement_period: { + granularity: 'GRANULARITY_BY_MONTH' as const, + range: 'RANGE_CURRENT_PARTIAL' as const, + }, + comparison: { + comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD' as const, + }, + }, + definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', + is_default: true, + schema_version: '1.0.0', + metric_version: 1, + is_followed: true, + datasource_luid: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11', + }, + }, + ], + metric_group_context_resolved: true, + }], + now: '2025-11-15 00:00:00', + time_zone: 'UTC', + }; + + const mockBriefResponse = { + answer: 'Your sales metric shows strong performance this month with a 15% increase.', + insights: [ + { + type: 'trend', + description: 'Sales have been trending upward consistently', + }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + resetResourceAccessCheckerSingleton(); + mocks.mockGetConfig.mockReturnValue({ + disableMetadataApiRequests: false, + 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 return data source not allowed error when datasource is not allowed', async () => { + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: new Set(['some-other-datasource-luid']), + workbookIds: null, + }, + }); + + const result = await getToolResult(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe( + [ + 'The set of allowed metric insights that can be queried is limited by the server configuration.', + 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', + 'from the data source with LUID A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11, which is not in the allowed set of data sources.', + ].join(' '), + ); + + expect(mocks.mockGeneratePulseInsightBrief).not.toHaveBeenCalled(); + }); + + 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..2746074b --- /dev/null +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -0,0 +1,218 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Err } from 'ts-results-es'; +import z from 'zod'; + +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 + +**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 (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, + } + ], + } + +**Use Cases:** +- **Conversational analytics** - Natural language Q&A about metrics +- **ChatGPT/Claude integration** - AI-powered metric insights +- **Slack/Teams bots** - Interactive metric exploration +- **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 () => { + // const { datasourceIds } = config.boundedContext; + // if (datasourceIds) { + // // Validate all datasources in the metric group context + // const metricsContext = briefRequest.messages.metric_group_context; + // for (const metricContext of metricsContext) { + // const datasourceLuid = metricContext.metric.datasource_luid; + + // if (!datasourceIds.has(datasourceLuid)) { + // return new Err({ + // type: 'datasource-not-allowed', + // message: [ + // 'The set of allowed metric insights that can be queried is limited by the server configuration.', + // 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', + // `from the data source with LUID ${datasourceLuid}, which is not in the allowed set of data sources.`, + // ].join(' '), + // }); + // } + // } + // } + + const result = await useRestApi({ + config, + requestId, + server, + jwtScopes: ['tableau:insights:read'], + 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, From e4b5525c948c63e8afeaff736031c9ba9d8fc287 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 01:21:58 -0800 Subject: [PATCH 02/15] working pulse discover --- src/restApiInstance.ts | 3 +- src/sdks/tableau/types/pulse.ts | 19 +++++- .../generatePulseInsightBriefTool.ts | 62 ++++++++++++------- 3 files changed, 59 insertions(+), 25 deletions(-) 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/types/pulse.ts b/src/sdks/tableau/types/pulse.ts index 57dfdb66..f004d9a5 100644 --- a/src/sdks/tableau/types/pulse.ts +++ b/src/sdks/tableau/types/pulse.ts @@ -295,8 +295,23 @@ export type RoleEnumType = z.infer; export const metricGroupContextSchema = z.array( z.object({ - metadata: pulseMetadataSchema, - metric: pulseMetricSchema, + 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(), + }), }), ); diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 2746074b..7e3a4e54 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -54,13 +54,22 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I - \`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 (natural language) + - \`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 +**Important: Multi-Turn Conversations** +To enable follow-up questions and conversational analysis, 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'\` + +This allows the AI to generate richer insights by understanding the conversation context. Without the conversation history, +follow-up questions may not have enough context to provide detailed answers. + **Action Types:** - \`ACTION_TYPE_ANSWER\`: Answer a specific question about the metric - \`ACTION_TYPE_SUMMARIZE\`: Provide a summary of metric insights @@ -132,6 +141,35 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I ], } +- 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 - **ChatGPT/Claude integration** - AI-powered metric insights @@ -156,31 +194,11 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I authInfo, args: { briefRequest }, callback: async () => { - // const { datasourceIds } = config.boundedContext; - // if (datasourceIds) { - // // Validate all datasources in the metric group context - // const metricsContext = briefRequest.messages.metric_group_context; - // for (const metricContext of metricsContext) { - // const datasourceLuid = metricContext.metric.datasource_luid; - - // if (!datasourceIds.has(datasourceLuid)) { - // return new Err({ - // type: 'datasource-not-allowed', - // message: [ - // 'The set of allowed metric insights that can be queried is limited by the server configuration.', - // 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', - // `from the data source with LUID ${datasourceLuid}, which is not in the allowed set of data sources.`, - // ].join(' '), - // }); - // } - // } - // } - const result = await useRestApi({ config, requestId, server, - jwtScopes: ['tableau:insights:read'], + jwtScopes: ['tableau:insight_brief:create'], authInfo: getTableauAuthInfo(authInfo), callback: async (restApi) => await restApi.pulseMethods.generatePulseInsightBrief(briefRequest), From c58fdd38a7c41cf33519eea6252a405c24bbb211 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 01:31:30 -0800 Subject: [PATCH 03/15] updated md --- .../pulse/generate-pulse-insight-brief.md | 399 +++++++++++------- 1 file changed, 243 insertions(+), 156 deletions(-) diff --git a/docs/docs/tools/pulse/generate-pulse-insight-brief.md b/docs/docs/tools/pulse/generate-pulse-insight-brief.md index c049481e..9d7dabe6 100644 --- a/docs/docs/tools/pulse/generate-pulse-insight-brief.md +++ b/docs/docs/tools/pulse/generate-pulse-insight-brief.md @@ -4,214 +4,301 @@ sidebar_position: 7 # Generate Pulse Insight Brief -Generates a concise insight brief for a Pulse metric, optimized for quick consumption in emails, notifications, or mobile displays. +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 a condensed, text-focused summary of metric changes that provides: +An **insight brief** is an AI-generated response to questions about Pulse metrics that provides: -- **Quick overview** - Key insights without detailed visualizations -- **Concise format** - Optimized for notifications, emails, and mobile -- **Action-oriented** - Highlights what changed and why -- **Minimal data** - Just the essentials for fast consumption +- **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** | Quick summary | Notifications, emails, mobile, daily digests | -| **Detail** | Comprehensive analysis | Investigation, dashboard views, deep dives | -| **Ban** | Current value snapshot | Banner displays, at-a-glance metrics | -| **Breakdown** | Dimension analysis | Understanding categorical distributions | +| 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_GenerateInsightBundleBreakdown) +- [Generate insight brief](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_pulse.htm#PulseInsightsService_GenerateInsightBrief) ## Required arguments -### `bundleRequest` +### `briefRequest` -The request to generate a brief for. This requires the full Pulse Metric and Pulse Metric Definition information. +The request to generate an insight brief. This includes the conversation history, language/locale +settings, and metric context. -Example: +**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 { - "bundle_request": { - "version": 1, - "options": { - "output_format": "OUTPUT_FORMAT_HTML", - "time_zone": "UTC", - "language": "LANGUAGE_EN_US", - "locale": "LOCALE_EN_US" - }, - "input": { - "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" + "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" }, - "basic_specification": { - "measure": { - "field": "Sales", - "aggregation": "AGGREGATION_SUM" + "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 }, - "time_dimension": { - "field": "Order Date" + "metric_specification": { + "filters": [], + "measurement_period": { + "granularity": "GRANULARITY_BY_MONTH", + "range": "RANGE_CURRENT_PARTIAL" + }, + "comparison": { + "comparison": "TIME_COMPARISON_PREVIOUS_PERIOD" + } }, - "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 } + ] + } } - }, - "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" - }, - "row_level_id_field": { - "identifier_col": "Order ID" - }, - "row_level_entity_names": { - "entity_name_singular": "Order" - }, - "row_level_name_field": { - "name_col": "Order Name" - }, - "currency_code": "CURRENCY_CODE_USD" - }, - "insights_options": { - "settings": [] } - } + ], + "metric_group_context_resolved": true } - } + ] } ``` -## Use Cases +### Example: Follow-up Question with Conversation History -### Daily Digest Emails -Generate brief summaries for automated email digests: +```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 + } + ] +} ``` -"Sales was $150K (Nov 2025), up 23% vs. prior month. -West region drove most of the growth (+45%)." + +## 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..." -### Mobile Notifications -Push notifications with concise metric updates: +User: "What factors contributed to the increase?" +AI: "The increase was driven by Technology category growth..." ``` -"🔔 Customer Count up 15% today - highest this quarter" + +### ChatGPT/Claude Integration + +Build AI-powered metric exploration: + +```typescript +// Example integration +const response = await generatePulseInsightBrief({ + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + messages: conversationHistory, +}); ``` ### Slack/Teams Bots -Quick metric updates in chat channels: -``` -"/pulse-brief sales" -→ Sales: $50K (+12%) | Top: West region | Status: Above target -``` -### Dashboard Tooltips -Contextual metric summaries on hover: +Interactive metric exploration in chat: + ``` -[Hover over metric card] -→ Brief: "Q4 Sales exceeded target by 8%, - primarily due to Technology category growth" +/pulse ask "Why did revenue drop this week?" +→ "Revenue decreased 8% due to lower activity in the Enterprise segment..." + +/pulse followup "How does this compare to last year?" +→ "This is actually 12% higher than the same week last year..." ``` -### Executive Summaries -Concise reporting for leadership: +### Executive Briefings + +Natural language metric summaries: + ``` -Daily Brief - Nov 14, 2025: -• Revenue: $2.1M (+5%) -• New Customers: 142 (+18%) -• Support Tickets: 89 (-12%) +"What should I know about my metrics today?" +→ AI-generated summary of key changes, trends, and recommendations ``` ## Example Response ```json { - "bundle_response": { - "result": { - "insight_groups": [ - { - "type": "brief", - "insights": [ - { - "insight_type": "popc", - "result": { - "markup": "Sales was $150,000 (November 2025 month to date), up 23.5% (29,000) compared to the prior period (October 2025)." - } - }, - { - "insight_type": "top-drivers", - "result": { - "markup": "West region increased the most (+45%), contributing $13,500 of the growth." - } - } - ], - "summaries": [ - "Sales increased 23.5% vs. last month, driven primarily by West region." - ] + "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 */ } - ], - "has_errors": false, - "characterization": "CHARACTERIZATION_UNSPECIFIED" - } + }, + { + "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 } } ``` -## Building a "Pulse Discover" Feature - -You can use insight briefs to build a custom Pulse Discover-like experience: +## Response Fields -1. **List user subscriptions** - Get all metrics a user follows -2. **Generate briefs** - For each subscription, get the insight brief -3. **Rank by importance** - Use AI to identify which metrics need attention -4. **Create daily digest** - Summarize into a morning brief - -```typescript -// Pseudocode example -const subscriptions = await listPulseMetricSubscriptions(userId); -const briefs = await Promise.all( - subscriptions.map(sub => generatePulseInsightBrief(sub.metric)) -); -const digest = await AI.summarize(briefs, { - prioritize: "significant_changes", - format: "daily_brief" -}); -``` +- `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 -- Insight briefs are **text-heavy** with minimal visualizations -- Optimized for **mobile and notification** contexts -- Focus on **what changed** and **key drivers** -- Typically **shorter** than detail bundles -- Ideal for **automated reporting** systems - - - +- **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. From 81fb8323b62ac0464780ff5fdcf3d3c396869da0 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 09:59:16 -0800 Subject: [PATCH 04/15] fix broken tests, remove unused one --- src/sdks/tableau/types/pulse.ts | 43 ++++++++- .../generatePulseInsightBriefTool.test.ts | 89 ++++++++++++------- 2 files changed, 96 insertions(+), 36 deletions(-) diff --git a/src/sdks/tableau/types/pulse.ts b/src/sdks/tableau/types/pulse.ts index f004d9a5..79ee120b 100644 --- a/src/sdks/tableau/types/pulse.ts +++ b/src/sdks/tableau/types/pulse.ts @@ -376,6 +376,42 @@ export const insightSchema = z.object({ 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( @@ -410,12 +446,15 @@ export const pulseBundleResponseSchema = z.object({ export const pulseInsightBriefResponseSchema = z.object({ - follow_up_questions: z.array(z.string()), + 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(insightSchema), + source_insights: z.array(sourceInsightSchema), }) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index eb22968e..60c91cfe 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -37,12 +37,27 @@ describe('getGeneratePulseInsightBriefTool', () => { { metadata: { name: 'Sales Metric', - id: 'CF32DDCC-362B-4869-9487-37DA4D152552', + metric_id: 'CF32DDCC-362B-4869-9487-37DA4D152552', definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', }, metric: { - id: 'CF32DDCC-362B-4869-9487-37DA4D152552', - specification: { + 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, @@ -52,12 +67,33 @@ describe('getGeneratePulseInsightBriefTool', () => { comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD' as const, }, }, - definition_id: 'BBC908D8-29ED-48AB-A78E-ACF8A424C8C3', - is_default: true, - schema_version: '1.0.0', - metric_version: 1, - is_followed: true, - datasource_luid: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11', + 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: [], + }, }, }, ], @@ -68,13 +104,20 @@ describe('getGeneratePulseInsightBriefTool', () => { }; const mockBriefResponse = { - answer: 'Your sales metric shows strong performance this month with a 15% increase.', - insights: [ + markup: 'Your sales metric shows strong performance this month with a 15% increase.', + generation_id: 'test-generation-id', + source_insights: [ { type: 'trend', - description: 'Sales have been trending upward consistently', + 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(() => { @@ -140,28 +183,6 @@ describe('getGeneratePulseInsightBriefTool', () => { expect(result.content[0].text).toContain('Pulse is disabled on this Tableau Cloud site.'); }); - it('should return data source not allowed error when datasource is not allowed', async () => { - mocks.mockGetConfig.mockReturnValue({ - boundedContext: { - projectIds: null, - datasourceIds: new Set(['some-other-datasource-luid']), - workbookIds: null, - }, - }); - - const result = await getToolResult(); - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe( - [ - 'The set of allowed metric insights that can be queried is limited by the server configuration.', - 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', - 'from the data source with LUID A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11, which is not in the allowed set of data sources.', - ].join(' '), - ); - - expect(mocks.mockGeneratePulseInsightBrief).not.toHaveBeenCalled(); - }); - async function getToolResult(): Promise { const tool = getGeneratePulseInsightBriefTool(new Server()); return await tool.callback( From 28059afef735215d374208823be744c7eef5b5a2 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 10:03:04 -0800 Subject: [PATCH 05/15] bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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" From dd5885f44209cfae5a5d30985c6e31214611844c Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 10:05:51 -0800 Subject: [PATCH 06/15] remove some unneccessary description details --- .../pulse/generate-pulse-insight-brief.md | 25 ------------------- .../generatePulseInsightBriefTool.ts | 2 -- 2 files changed, 27 deletions(-) diff --git a/docs/docs/tools/pulse/generate-pulse-insight-brief.md b/docs/docs/tools/pulse/generate-pulse-insight-brief.md index 9d7dabe6..e42f4485 100644 --- a/docs/docs/tools/pulse/generate-pulse-insight-brief.md +++ b/docs/docs/tools/pulse/generate-pulse-insight-brief.md @@ -209,31 +209,6 @@ User: "What factors contributed to the increase?" AI: "The increase was driven by Technology category growth..." ``` -### ChatGPT/Claude Integration - -Build AI-powered metric exploration: - -```typescript -// Example integration -const response = await generatePulseInsightBrief({ - language: 'LANGUAGE_EN_US', - locale: 'LOCALE_EN_US', - messages: conversationHistory, -}); -``` - -### Slack/Teams Bots - -Interactive metric exploration in chat: - -``` -/pulse ask "Why did revenue drop this week?" -→ "Revenue decreased 8% due to lower activity in the Enterprise segment..." - -/pulse followup "How does this compare to last year?" -→ "This is actually 12% higher than the same week last year..." -``` - ### Executive Briefings Natural language metric summaries: diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 7e3a4e54..246d06a1 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -172,8 +172,6 @@ follow-up questions may not have enough context to provide detailed answers. **Use Cases:** - **Conversational analytics** - Natural language Q&A about metrics -- **ChatGPT/Claude integration** - AI-powered metric insights -- **Slack/Teams bots** - Interactive metric exploration - **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 From 752351a105d728dd30bae84ab1e4e7c4524f5319 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 10:09:00 -0800 Subject: [PATCH 07/15] fix lint --- src/sdks/tableau/apis/pulseApi.ts | 3 +- src/sdks/tableau/types/pulse.ts | 104 ++++++++------- .../generatePulseInsightBriefTool.test.ts | 126 +++++++++--------- .../generatePulseInsightBriefTool.ts | 2 - 4 files changed, 121 insertions(+), 114 deletions(-) diff --git a/src/sdks/tableau/apis/pulseApi.ts b/src/sdks/tableau/apis/pulseApi.ts index 5db9a966..e37a5e4d 100644 --- a/src/sdks/tableau/apis/pulseApi.ts +++ b/src/sdks/tableau/apis/pulseApi.ts @@ -153,7 +153,8 @@ 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.', + description: + 'Generates an AI-powered insight brief for Pulse metrics based on natural language questions.', parameters: [ { name: 'brief_request', diff --git a/src/sdks/tableau/types/pulse.ts b/src/sdks/tableau/types/pulse.ts index 79ee120b..d2b006c9 100644 --- a/src/sdks/tableau/types/pulse.ts +++ b/src/sdks/tableau/types/pulse.ts @@ -286,11 +286,7 @@ export const actionTypeEnumSchema = z.enum([ ]); export type ActionTypeEnumType = z.infer; -export const roleEnumSchema = z.enum([ - 'ROLE_UNDEFINED', - 'ROLE_USER', - 'ROLE_ASSISTANT', -]); +export const roleEnumSchema = z.enum(['ROLE_UNDEFINED', 'ROLE_USER', 'ROLE_ASSISTANT']); export type RoleEnumType = z.infer; export const metricGroupContextSchema = z.array( @@ -306,10 +302,12 @@ export const metricGroupContextSchema = z.array( extension_options: pulseExtensionOptionsSchema, representation_options: pulseRepresentationOptionsSchema, insights_options: insightOptionsSchema, - goals: z.object({ - datasource_goals: datasourceGoalsSchema.optional(), - metric_goals: pulseGoalsSchema.optional(), - }).optional(), + goals: z + .object({ + datasource_goals: datasourceGoalsSchema.optional(), + metric_goals: pulseGoalsSchema.optional(), + }) + .optional(), candidates: z.array(z.any()).optional(), }), }), @@ -374,43 +372,51 @@ export const insightSchema = z.object({ 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(), +export const sourceInsightSchema = z + .object({ 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(), + 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(), }), - value: z.object({ - formatted_value: 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() + ), + }), + }) + .partial(); export const popcBanInsightGroupSchema = z.object({ type: z.string(), @@ -444,19 +450,19 @@ 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(), - })), + 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; diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index 60c91cfe..fdee2bab 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -29,76 +29,78 @@ 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, + 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', }, - time_dimension: { - field: 'Order Date', + basic_specification: { + measure: { + field: 'Sales', + aggregation: 'AGGREGATION_SUM' as const, + }, + time_dimension: { + field: 'Order Date', + }, + filters: [], }, - filters: [], + is_running_total: false, }, - 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', + 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, + }, }, - sentiment_type: 'SENTIMENT_TYPE_NONE' as const, - row_level_id_field: { - identifier_col: 'Order ID', + extension_options: { + allowed_dimensions: [], + allowed_granularities: [], + offset_from_today: 0, }, - row_level_entity_names: { - entity_name_singular: 'Order', - entity_name_plural: 'Orders', + 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, }, - row_level_name_field: { - name_col: 'Order Name', + insights_options: { + settings: [], }, - currency_code: 'CURRENCY_CODE_USD' as const, - }, - insights_options: { - settings: [], }, }, - }, - ], - metric_group_context_resolved: true, - }], + ], + metric_group_context_resolved: true, + }, + ], now: '2025-11-15 00:00:00', time_zone: 'UTC', }; @@ -114,7 +116,7 @@ describe('getGeneratePulseInsightBriefTool', () => { ], follow_up_questions: [ { content: 'What factors contributed to the increase?' }, - { content: 'How does this compare to last year?' } + { content: 'How does this compare to last year?' }, ], group_context: briefRequest.messages[0].metric_group_context, not_enough_information: false, diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 246d06a1..a915f1b0 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -1,6 +1,5 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Err } from 'ts-results-es'; -import z from 'zod'; import { getConfig } from '../../../config.js'; import { useRestApi } from '../../../restApiInstance.js'; @@ -231,4 +230,3 @@ follow-up questions may not have enough context to provide detailed answers. return generatePulseInsightBriefTool; }; - From 71a5ba23c38d618ac6a44947d66663452f710b41 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Fri, 21 Nov 2025 13:37:16 -0800 Subject: [PATCH 08/15] adding reccomendations --- .../generatePulseInsightBriefTool.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index a915f1b0..4014e9c0 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -47,6 +47,25 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I - **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') @@ -60,15 +79,6 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I - \`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 -**Important: Multi-Turn Conversations** -To enable follow-up questions and conversational analysis, 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'\` - -This allows the AI to generate richer insights by understanding the conversation context. Without the conversation history, -follow-up questions may not have enough context to provide detailed answers. - **Action Types:** - \`ACTION_TYPE_ANSWER\`: Answer a specific question about the metric - \`ACTION_TYPE_SUMMARIZE\`: Provide a summary of metric insights From c12855758f4b35390c5cf0a7f6a28ea0d47fa28d Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 25 Nov 2025 15:04:43 -0800 Subject: [PATCH 09/15] removing unused test mocks and impossible response states --- src/sdks/tableau/methods/pulseMethods.ts | 2 +- .../generatePulseInsightBriefTool.test.ts | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/sdks/tableau/methods/pulseMethods.ts b/src/sdks/tableau/methods/pulseMethods.ts index a2f0ee4a..3983fb62 100644 --- a/src/sdks/tableau/methods/pulseMethods.ts +++ b/src/sdks/tableau/methods/pulseMethods.ts @@ -155,7 +155,7 @@ export default class PulseMethods extends AuthenticatedMethods briefRequest, this.authHeader, ); - return response ?? {}; + return response; }); }; diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index fdee2bab..5782a5bb 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -125,14 +125,6 @@ describe('getGeneratePulseInsightBriefTool', () => { beforeEach(() => { vi.clearAllMocks(); resetResourceAccessCheckerSingleton(); - mocks.mockGetConfig.mockReturnValue({ - disableMetadataApiRequests: false, - boundedContext: { - projectIds: null, - datasourceIds: null, - workbookIds: null, - }, - }); }); it('should have correct tool name', () => { From 14272fc0d47eaf783647b71229a7ec494d3fb32a Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 25 Nov 2025 15:24:41 -0800 Subject: [PATCH 10/15] tool scoping is supported --- .../generatePulseInsightBriefTool.test.ts | 22 ++++++++++++++++++ .../generatePulseInsightBriefTool.ts | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index 5782a5bb..01db609a 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -125,6 +125,13 @@ describe('getGeneratePulseInsightBriefTool', () => { beforeEach(() => { vi.clearAllMocks(); resetResourceAccessCheckerSingleton(); + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: null, + workbookIds: null, + }, + }); }); it('should have correct tool name', () => { @@ -177,6 +184,21 @@ describe('getGeneratePulseInsightBriefTool', () => { expect(result.content[0].text).toContain('Pulse is disabled on this Tableau Cloud site.'); }); + it('should return an error when datasource is not in the allowed set', async () => { + mocks.mockGetConfig.mockReturnValue({ + boundedContext: { + projectIds: null, + datasourceIds: new Set(['some-other-datasource-luid']), + workbookIds: null, + }, + }); + const result = await getToolResult(); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'The set of allowed metric insights that can be queried is limited by the server configuration.', + ); + }); + async function getToolResult(): Promise { const tool = getGeneratePulseInsightBriefTool(new Server()); return await tool.callback( diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 4014e9c0..a1f76015 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -201,6 +201,29 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I authInfo, args: { briefRequest }, callback: async () => { + // Check datasource access for all metrics in all messages + const { datasourceIds } = config.boundedContext; + if (datasourceIds) { + for (const message of briefRequest.messages) { + if (message.metric_group_context) { + for (const metricContext of message.metric_group_context) { + const datasourceLuid = metricContext.metric.definition.datasource.id; + + if (!datasourceIds.has(datasourceLuid)) { + return new Err({ + type: 'datasource-not-allowed', + message: [ + 'The set of allowed metric insights that can be queried is limited by the server configuration.', + 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', + `from the data source with LUID ${datasourceLuid}, which is not in the allowed set of data sources.`, + ].join(' '), + }); + } + } + } + } + } + const result = await useRestApi({ config, requestId, From cb03b8d6a7d31c282ab4c9c7ebc7a3feb8a9d39a Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 25 Nov 2025 15:29:54 -0800 Subject: [PATCH 11/15] removing unnecessary test config --- .../generateInsightBrief/generatePulseInsightBriefTool.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index 01db609a..b2876cf5 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -2,10 +2,8 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Err, Ok } from 'ts-results-es'; import { Server } from '../../../server.js'; -import { exportedForTesting as resourceAccessCheckerExportedForTesting } from '../../resourceAccessChecker.js'; import { getGeneratePulseInsightBriefTool } from './generatePulseInsightBriefTool.js'; -const { resetResourceAccessCheckerSingleton } = resourceAccessCheckerExportedForTesting; const mocks = vi.hoisted(() => ({ mockGeneratePulseInsightBrief: vi.fn(), mockGetConfig: vi.fn(), @@ -124,7 +122,6 @@ describe('getGeneratePulseInsightBriefTool', () => { beforeEach(() => { vi.clearAllMocks(); - resetResourceAccessCheckerSingleton(); mocks.mockGetConfig.mockReturnValue({ boundedContext: { projectIds: null, From 6c93f0b08b61a421f9e9afa65aeb93bec94b34a6 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Tue, 25 Nov 2025 15:33:07 -0800 Subject: [PATCH 12/15] fix lint --- .../pulse/generateInsightBrief/generatePulseInsightBriefTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index a1f76015..b9bc1305 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -208,7 +208,7 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I if (message.metric_group_context) { for (const metricContext of message.metric_group_context) { const datasourceLuid = metricContext.metric.definition.datasource.id; - + if (!datasourceIds.has(datasourceLuid)) { return new Err({ type: 'datasource-not-allowed', From e5471fb1898ad5237e93b19af1d424613b605959 Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Wed, 26 Nov 2025 14:30:03 -0800 Subject: [PATCH 13/15] scoping filters out disallowed metrics in the request param --- .../generatePulseInsightBriefTool.test.ts | 133 +++++++++++++++++- .../generatePulseInsightBriefTool.ts | 20 +-- 2 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index b2876cf5..e09970f5 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -181,18 +181,139 @@ describe('getGeneratePulseInsightBriefTool', () => { expect(result.content[0].text).toContain('Pulse is disabled on this Tableau Cloud site.'); }); - it('should return an error when datasource is not in the allowed set', async () => { + 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(['some-other-datasource-luid']), + datasourceIds: new Set([allowedDatasourceId]), workbookIds: null, }, }); - const result = await getToolResult(); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain( - 'The set of allowed metric insights that can be queried is limited by the server configuration.', + 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, ); }); diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index b9bc1305..6ef887bd 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -201,25 +201,15 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I authInfo, args: { briefRequest }, callback: async () => { - // Check datasource access for all metrics in all messages + // 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) { - for (const metricContext of message.metric_group_context) { - const datasourceLuid = metricContext.metric.definition.datasource.id; - - if (!datasourceIds.has(datasourceLuid)) { - return new Err({ - type: 'datasource-not-allowed', - message: [ - 'The set of allowed metric insights that can be queried is limited by the server configuration.', - 'Generating the Pulse Insight Brief is not allowed because one or more metrics are derived', - `from the data source with LUID ${datasourceLuid}, which is not in the allowed set of data sources.`, - ].join(' '), - }); - } - } + message.metric_group_context = message.metric_group_context.filter( + (metricContext) => + datasourceIds.has(metricContext.metric.definition.datasource.id), + ); } } } From 8ea116b51052ebf4f728e1a8b0f60b253390df2d Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Wed, 26 Nov 2025 22:38:12 -0800 Subject: [PATCH 14/15] updated context filter logic --- .../generatePulseInsightBriefTool.test.ts | 18 ++++++++++++++++++ .../generatePulseInsightBriefTool.ts | 11 +++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index e09970f5..a660928c 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -317,6 +317,24 @@ describe('getGeneratePulseInsightBriefTool', () => { ); }); + 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( + 'All metrics in the request are derived from data sources that are not in the allowed set.', + ); + }); + async function getToolResult(): Promise { const tool = getGeneratePulseInsightBriefTool(new Server()); return await tool.callback( diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts index 6ef887bd..1f3596d1 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.ts @@ -210,6 +210,17 @@ An insight brief is an AI-generated response to questions about Pulse metrics. I (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(' '), + }); + } } } } From 9d11a47f4ebd64852d47629267437fa50832cade Mon Sep 17 00:00:00 2001 From: jarhun88 Date: Thu, 27 Nov 2025 03:32:08 -0800 Subject: [PATCH 15/15] update test --- .../generateInsightBrief/generatePulseInsightBriefTool.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts index a660928c..1df73d1e 100644 --- a/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts +++ b/src/tools/pulse/generateInsightBrief/generatePulseInsightBriefTool.test.ts @@ -331,7 +331,7 @@ describe('getGeneratePulseInsightBriefTool', () => { expect(result.isError).toBe(true); expect(result.content[0].text).toContain( - 'All metrics in the request are derived from data sources that are not in the allowed set.', + 'One or more messages in the request contain only metrics derived from data sources that are not in the allowed set.', ); });