From 0e4a3d0c42afc15cc6e5068bb8d9b3a7a3a96a81 Mon Sep 17 00:00:00 2001 From: t-li Date: Wed, 8 Jul 2026 17:45:52 -0700 Subject: [PATCH] @W-23334008: Add Chiron datasource-context insight tools + datasource LUID resolver Add a deterministic path to generate Pulse insights from Studio datasource context (no persisted Pulse metric/definition required), plus a resolver to map a Desktop workbook's published datasource to a unique LUID. - generate-chiron-insight-from-datasource-context: validates datasource/fields/ filters, builds inline metric context, calls generate-pulse-* internally, and auto-falls back from the AI brief to the deterministic bundle when Tableau+ is disabled on the site. - resolve-datasource-luid: resolves a datasource contentUrl (exact, case- sensitive) to a unique published LUID, disambiguating same-named datasources (the REST contentUrl filter is case-insensitive). - Expose contentUrl on the DataSource schema (also surfaced by list-datasources). - Register + scope the new tools; unit tests for builder, wrapper (incl. brief-> bundle fallback), and resolver (case-collision). Co-authored-by: Cursor --- src/sdks/tableau/types/dataSource.ts | 4 + src/server/oauth/scopes.ts | 12 + .../datasources/resolveDatasourceLuid.test.ts | 86 ++++ .../web/datasources/resolveDatasourceLuid.ts | 141 ++++++ ...onInsightFromDatasourceContextTool.test.ts | 189 +++++++ ...eChironInsightFromDatasourceContextTool.ts | 175 +++++++ .../web/pulse/chiron/requestBuilder.test.ts | 123 +++++ src/tools/web/pulse/chiron/requestBuilder.ts | 461 ++++++++++++++++++ src/tools/web/toolName.ts | 5 + src/tools/web/tools.ts | 4 + 10 files changed, 1200 insertions(+) create mode 100644 src/tools/web/datasources/resolveDatasourceLuid.test.ts create mode 100644 src/tools/web/datasources/resolveDatasourceLuid.ts create mode 100644 src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.test.ts create mode 100644 src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.ts create mode 100644 src/tools/web/pulse/chiron/requestBuilder.test.ts create mode 100644 src/tools/web/pulse/chiron/requestBuilder.ts diff --git a/src/sdks/tableau/types/dataSource.ts b/src/sdks/tableau/types/dataSource.ts index 89c950759..cb509cfd0 100644 --- a/src/sdks/tableau/types/dataSource.ts +++ b/src/sdks/tableau/types/dataSource.ts @@ -6,6 +6,10 @@ import { tagsSchema } from './tags.js'; export const dataSourceSchema = z.object({ id: z.string(), name: z.string(), + // contentUrl is the URL-safe slug that is UNIQUE per site (case-sensitively). + // Unlike name, it disambiguates same-named datasources — needed to map a Desktop + // workbook's to exactly one published datasource. + contentUrl: z.string().optional(), description: z.string().optional(), project: projectSchema, owner: z diff --git a/src/server/oauth/scopes.ts b/src/server/oauth/scopes.ts index 830203e3a..c6f9325a5 100644 --- a/src/server/oauth/scopes.ts +++ b/src/server/oauth/scopes.ts @@ -94,6 +94,10 @@ const toolScopeMap: Record< mcp: ['tableau:mcp:datasource:read'], api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']), }, + 'resolve-datasource-luid': { + mcp: ['tableau:mcp:datasource:read'], + api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']), + }, 'list-extract-refresh-tasks': { mcp: ['tableau:mcp:tasks:read'], api: new Set(['tableau:tasks:read', 'tableau:users:read']), @@ -273,6 +277,14 @@ const toolScopeMap: Record< mcp: ['tableau:mcp:insight:create'], api: new Set(['tableau:insight_brief:create', 'tableau:mcp_site_settings:read']), }, + 'generate-chiron-insight-from-datasource-context': { + mcp: ['tableau:mcp:insight:create'], + api: new Set([ + 'tableau:insight_brief:create', + 'tableau:insights:read', + 'tableau:mcp_site_settings:read', + ]), + }, 'search-content': { mcp: ['tableau:mcp:content:read'], api: new Set(['tableau:content:read', 'tableau:mcp_site_settings:read']), diff --git a/src/tools/web/datasources/resolveDatasourceLuid.test.ts b/src/tools/web/datasources/resolveDatasourceLuid.test.ts new file mode 100644 index 000000000..803920ccb --- /dev/null +++ b/src/tools/web/datasources/resolveDatasourceLuid.test.ts @@ -0,0 +1,86 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +import { WebMcpServer } from '../../../server.web.js'; +import { stubDefaultEnvVars } from '../../../testShared.js'; +import invariant from '../../../utils/invariant.js'; +import { Provider } from '../../../utils/provider.js'; +import { getMockRequestHandlerExtra } from '../toolContext.mock.js'; +import { getResolveDatasourceLuidTool } from './resolveDatasourceLuid.js'; + +const mocks = vi.hoisted(() => ({ mockListDatasources: vi.fn() })); + +vi.mock('../../../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + siteId: 'site-1', + datasourcesMethods: { listDatasources: mocks.mockListDatasources }, + }), + ), +})); + +const proj = (name: string): { id: string; name: string } => ({ id: `p-${name}`, name }); +const tags = { tag: [] }; + +// The case-insensitive server filter returns BOTH "Superstore" and "superstore". +const superstoreCaseCollision = [ + { + id: '78667ee4', + name: 'Superstore', + contentUrl: 'Superstore', + project: proj('Tableau Samples'), + tags, + }, + { + id: 'e27f64e8', + name: 'Superstore', + contentUrl: 'superstore', + project: proj('Personal Work'), + tags, + }, +]; + +describe('getResolveDatasourceLuidTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + stubDefaultEnvVars(); + mocks.mockListDatasources.mockResolvedValue({ + pagination: { pageNumber: '1', pageSize: '100', totalAvailable: '2' }, + datasources: superstoreCaseCollision, + }); + }); + + it('has the correct tool name', () => { + const tool = getResolveDatasourceLuidTool(new WebMcpServer()); + expect(tool.name).toBe('resolve-datasource-luid'); + }); + + it('exact case-sensitive match resolves the unique LUID despite case-insensitive filter', async () => { + const result = await run({ contentUrl: 'Superstore' }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.luid).toBe('78667ee4'); + expect(payload.contentUrl).toBe('Superstore'); + expect(payload.projectName).toBe('Tableau Samples'); + }); + + it('lowercase contentUrl resolves the other datasource', async () => { + const result = await run({ contentUrl: 'superstore' }); + expect(result.isError).toBe(false); + invariant(result.content[0].type === 'text'); + expect(JSON.parse(result.content[0].text).luid).toBe('e27f64e8'); + }); + + it('returns not-found for an unknown contentUrl', async () => { + const result = await run({ contentUrl: 'DoesNotExist' }); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('No published datasource found'); + }); + + async function run(args: { contentUrl: string; projectName?: string }): Promise { + const tool = getResolveDatasourceLuidTool(new WebMcpServer()); + const callback = await Provider.from(tool.callback); + return await callback(args, getMockRequestHandlerExtra()); + } +}); diff --git a/src/tools/web/datasources/resolveDatasourceLuid.ts b/src/tools/web/datasources/resolveDatasourceLuid.ts new file mode 100644 index 000000000..222b3749f --- /dev/null +++ b/src/tools/web/datasources/resolveDatasourceLuid.ts @@ -0,0 +1,141 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; +import { z } from 'zod'; + +import { useRestApi } from '../../../restApiInstance.js'; +import { DataSource } from '../../../sdks/tableau/types/dataSource.js'; +import { WebMcpServer } from '../../../server.web.js'; +import { paginate } from '../../../utils/paginate.js'; +import { ConstrainedResult, WebTool } from '../tool.js'; + +const paramsSchema = { + contentUrl: z.string().min(1), + projectName: z.string().optional(), +}; + +type ResolvedDatasource = { + luid: string; + name: string; + contentUrl: string; + projectName?: string; +}; + +/** + * Resolve a published datasource's LUID from its `contentUrl` — the URL-safe slug a + * Tableau Desktop workbook exposes in ``. + * + * Why this exists (and why `list-datasources` alone is not enough): + * - `name` is NOT unique per site (e.g. three datasources all named "Superstore"). + * - `contentUrl` IS unique per site, BUT the REST `contentUrl:eq:` filter is + * case-INSENSITIVE, so it can still return >1 (e.g. "Superstore" and "superstore"). + * This tool applies an exact, case-SENSITIVE post-filter (optionally narrowed by + * project) so the caller gets exactly one LUID or a clear ambiguity/not-found result. + */ +export const getResolveDatasourceLuidTool = ( + server: WebMcpServer, +): WebTool => { + const resolveDatasourceLuidTool = new WebTool({ + server, + name: 'resolve-datasource-luid', + description: ` +Resolve a published datasource's LUID from its \`contentUrl\` (the unique, URL-safe slug). + +Use this to map a Tableau Desktop workbook's published datasource to a Cloud/Server LUID +before calling Pulse/Chiron insight tools. The Desktop workbook exposes the datasource as +\`\` — pass that \`id\` as \`contentUrl\`. + +Why not \`list-datasources\`? Datasource \`name\` is not unique per site, and the REST +\`contentUrl\` filter is case-insensitive; this tool exact-matches (case-sensitive) so you +get exactly one datasource. + +**Parameters:** +- \`contentUrl\` (required): exact, case-sensitive contentUrl from the Desktop repository-location id. +- \`projectName\` (optional): narrow further if two datasources somehow collide. + +**Returns:** \`{ luid, name, contentUrl, projectName }\` for the single match, or an +explicit not-found / ambiguous message. +`, + paramsSchema, + annotations: { + title: 'Resolve Datasource LUID', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + callback: async ({ contentUrl, projectName }, extra): Promise => { + const configWithOverrides = await extra.getConfigWithOverrides(); + return await resolveDatasourceLuidTool.logAndExecute>({ + extra, + args: { contentUrl, projectName }, + callback: async () => { + const datasources = await useRestApi({ + ...extra, + jwtScopes: resolveDatasourceLuidTool.requiredApiScopes, + callback: async (restApi) => + await paginate({ + pageConfig: { pageSize: 100, limit: 1000 }, + getDataFn: async (pageConfig) => { + const { pagination, datasources: data } = + await restApi.datasourcesMethods.listDatasources({ + siteId: restApi.siteId, + // Server filter is case-insensitive; we exact-match below. + filter: `contentUrl:eq:${contentUrl}`, + pageSize: pageConfig.pageSize, + pageNumber: pageConfig.pageNumber, + }); + return { pagination, data }; + }, + }), + }); + + return new Ok(datasources); + }, + constrainSuccessResult: (datasources): ConstrainedResult> => { + const { datasourceIds } = configWithOverrides.boundedContext; + + let matches = datasources.filter((ds) => ds.contentUrl === contentUrl); + if (projectName) { + matches = matches.filter((ds) => ds.project.name === projectName); + } + if (datasourceIds) { + matches = matches.filter((ds) => datasourceIds.has(ds.id)); + } + + if (matches.length === 0) { + return { + type: 'empty', + message: `No published datasource found with contentUrl "${contentUrl}"${ + projectName ? ` in project "${projectName}"` : '' + }. Confirm the contentUrl (exact case) from the Desktop repository-location and that you have access.`, + }; + } + + if (matches.length > 1) { + const candidates = matches + .map((ds) => `${ds.name} (LUID ${ds.id}, project "${ds.project.name}")`) + .join('; '); + return { + type: 'error', + message: `Ambiguous: ${matches.length} datasources share contentUrl "${contentUrl}". Re-call with projectName to disambiguate. Candidates: ${candidates}`, + }; + } + + return { type: 'success', result: matches }; + }, + getSuccessResult: (matches) => { + const ds = matches[0]; + const resolved: ResolvedDatasource = { + luid: ds.id, + name: ds.name, + contentUrl: ds.contentUrl ?? contentUrl, + projectName: ds.project.name, + }; + return { isError: false, content: [{ type: 'text', text: JSON.stringify(resolved) }] }; + }, + }); + }, + }); + + return resolveDatasourceLuidTool; +}; diff --git a/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.test.ts b/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.test.ts new file mode 100644 index 000000000..d887f9d34 --- /dev/null +++ b/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.test.ts @@ -0,0 +1,189 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok } from 'ts-results-es'; + +import { PulseInsightsDisabledError } from '../../../../errors/mcpToolError.js'; +import { WebMcpServer } from '../../../../server.web.js'; +import { stubDefaultEnvVars } from '../../../../testShared.js'; +import invariant from '../../../../utils/invariant.js'; +import { Provider } from '../../../../utils/provider.js'; +import { getMockRequestHandlerExtra } from '../../toolContext.mock.js'; +import { getGenerateChironInsightFromDatasourceContextTool } from './generateChironInsightFromDatasourceContextTool.js'; +import { ChironInsightRequest } from './requestBuilder.js'; + +const mocks = vi.hoisted(() => ({ + mockGeneratePulseInsightBrief: vi.fn(), + mockGeneratePulseMetricValueInsightBundle: vi.fn(), +})); + +vi.mock('../../../../restApiInstance.js', () => ({ + useRestApi: vi.fn().mockImplementation(async ({ callback }) => + callback({ + pulseMethods: { + generatePulseInsightBrief: mocks.mockGeneratePulseInsightBrief, + generatePulseMetricValueInsightBundle: mocks.mockGeneratePulseMetricValueInsightBundle, + }, + }), + ), +})); + +const request: ChironInsightRequest = { + datasource: { + id: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11', + isPublished: true, + }, + schema: { + fields: [ + { + name: 'Sales', + role: 'measure', + dataType: 'number', + supportedAggregations: ['AGGREGATION_SUM'], + }, + { + name: 'Order Date', + role: 'time', + dataType: 'date', + }, + { + name: 'Region', + role: 'dimension', + dataType: 'string', + allowedFilterValues: ['West', 'East'], + }, + ], + }, + context: { + measureField: 'Sales', + timeField: 'Order Date', + dimensionFields: ['Region'], + filters: [{ field: 'Region', operator: 'OPERATOR_IN', values: ['West'] }], + }, + insight: { + mode: 'summary', + output: 'brief', + }, + options: { + aggregation: 'AGGREGATION_SUM', + granularity: 'GRANULARITY_BY_MONTH', + range: 'RANGE_CURRENT_PARTIAL', + comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD', + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + timeZone: 'UTC', + }, +}; + +describe('getGenerateChironInsightFromDatasourceContextTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + stubDefaultEnvVars(); + }); + + it('registers the expected tool name', () => { + const tool = getGenerateChironInsightFromDatasourceContextTool(new WebMcpServer()); + expect(tool.name).toBe('generate-chiron-insight-from-datasource-context'); + }); + + it('generates a brief and returns provenance with generated request', async () => { + mocks.mockGeneratePulseInsightBrief.mockResolvedValue( + new Ok({ + follow_up_questions: [], + generation_id: 'gen-id', + group_context: [], + markup: '

summary

', + not_enough_information: false, + source_insights: [], + }), + ); + + const result = await getToolResult(request); + + expect(result.isError).toBe(false); + expect(mocks.mockGeneratePulseInsightBrief).toHaveBeenCalledTimes(1); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.output).toBe('brief'); + expect(payload.generatedRequest).toHaveProperty('messages'); + expect(payload.provenance.datasourceId).toBe(request.datasource.id); + }); + + it('fails before API call for invalid filter values', async () => { + const result = await getToolResult({ + ...request, + context: { + ...request.context, + filters: [{ field: 'Region', operator: 'OPERATOR_IN', values: ['South'] }], + }, + }); + + expect(result.isError).toBe(true); + expect(mocks.mockGeneratePulseInsightBrief).not.toHaveBeenCalled(); + expect(mocks.mockGeneratePulseMetricValueInsightBundle).not.toHaveBeenCalled(); + invariant(result.content[0].type === 'text'); + expect(result.content[0].text).toContain('Filter value South is not allowed for field Region'); + }); + + it('generates bundle output when requested', async () => { + mocks.mockGeneratePulseMetricValueInsightBundle.mockResolvedValue( + new Ok({ + bundle_response: { + result: { + insight_groups: [], + has_errors: false, + characterization: 'CHARACTERIZATION_UNSPECIFIED', + }, + }, + }), + ); + + const result = await getToolResult({ + ...request, + insight: { + mode: 'trend', + output: 'bundle', + bundleType: 'detail', + }, + }); + + expect(result.isError).toBe(false); + expect(mocks.mockGeneratePulseMetricValueInsightBundle).toHaveBeenCalledTimes(1); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.output).toBe('bundle'); + expect(payload.bundleType).toBe('detail'); + expect(payload.generatedRequest).toHaveProperty('bundle_request'); + }); + + it('falls back to bundle when the AI brief is disabled on the site', async () => { + mocks.mockGeneratePulseInsightBrief.mockResolvedValue(new PulseInsightsDisabledError().toErr()); + mocks.mockGeneratePulseMetricValueInsightBundle.mockResolvedValue( + new Ok({ + bundle_response: { + result: { + insight_groups: [], + has_errors: false, + characterization: 'CHARACTERIZATION_UNSPECIFIED', + }, + }, + }), + ); + + // request defaults to output: 'brief' + const result = await getToolResult(request); + + expect(result.isError).toBe(false); + expect(mocks.mockGeneratePulseInsightBrief).toHaveBeenCalledTimes(1); + expect(mocks.mockGeneratePulseMetricValueInsightBundle).toHaveBeenCalledTimes(1); + invariant(result.content[0].type === 'text'); + const payload = JSON.parse(result.content[0].text); + expect(payload.output).toBe('bundle'); + expect(payload.fallback).toBe('brief_unavailable_used_bundle'); + }); + + async function getToolResult(input: ChironInsightRequest): Promise { + const tool = getGenerateChironInsightFromDatasourceContextTool(new WebMcpServer()); + const callback = await Provider.from(tool.callback); + return await callback({ request: input }, getMockRequestHandlerExtra()); + } +}); diff --git a/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.ts b/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.ts new file mode 100644 index 000000000..73fa5acea --- /dev/null +++ b/src/tools/web/pulse/chiron/generateChironInsightFromDatasourceContextTool.ts @@ -0,0 +1,175 @@ +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Ok, Result } from 'ts-results-es'; + +import { + ArgsValidationError, + DatasourceNotAllowedError, + McpToolError, + PulseInsightsDisabledError, +} from '../../../../errors/mcpToolError.js'; +import { useRestApi } from '../../../../restApiInstance.js'; +import { + PulseBundleResponse, + PulseInsightBriefResponse, +} from '../../../../sdks/tableau/types/pulse.js'; +import { WebMcpServer } from '../../../../server.web.js'; +import { WebTool } from '../../tool.js'; +import { validateBriefRequest, validateBundleRequest } from '../validatePulsePayload.js'; +import { buildChironRequests, chironInsightRequestSchema } from './requestBuilder.js'; + +const paramsSchema = { + request: chironInsightRequestSchema, +}; + +type ChironInsightResult = { + output: 'brief' | 'bundle'; + bundleType: 'ban' | 'springboard' | 'basic' | 'detail'; + generatedRequest: Record; + response: PulseInsightBriefResponse | PulseBundleResponse; + provenance: { + datasourceId: string; + }; + // Set when the requested brief (AI) output was unavailable on this site and the + // deterministic bundle was used instead, so callers know why output != requested. + fallback?: 'brief_unavailable_used_bundle'; +}; + +export const getGenerateChironInsightFromDatasourceContextTool = ( + server: WebMcpServer, +): WebTool => { + const generateChironInsightFromDatasourceContextTool = new WebTool({ + server, + name: 'generate-chiron-insight-from-datasource-context', + description: ` +Generate a deterministic Pulse insight from Studio datasource/canvas context. + +This Chiron wrapper tool validates datasource context, fields, dimensions, and filters, then constructs +inline metric context and calls the appropriate Pulse generate endpoint internally. + +Safety behavior: +- Rejects unpublished/local datasources +- Rejects unknown fields and invalid aggregations +- Rejects unvalidated filter values +- Enforces datasource bounds from INCLUDE_DATASOURCE_IDS if configured + +Governance behavior: +- Does not require or call Pulse definition/metric list APIs +- Sends no metric_id/definition_id (Chiron has no stored metric or definition); the insight is generated from inline datasource context alone +- Returns the generated request payload with insight response for provenance +`, + paramsSchema, + annotations: { + title: 'Generate Chiron Insight From Datasource Context', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + callback: async ({ request }, extra): Promise => { + return await generateChironInsightFromDatasourceContextTool.logAndExecute( + { + extra, + args: { request }, + callback: async () => { + const configWithOverrides = await extra.getConfigWithOverrides(); + + if ( + configWithOverrides.boundedContext.datasourceIds && + !configWithOverrides.boundedContext.datasourceIds.has(request.datasource.id) + ) { + return new DatasourceNotAllowedError( + 'The requested datasource is not in the server-configured allowed datasource set.', + ).toErr(); + } + + let builtRequest; + try { + builtRequest = buildChironRequests(request); + } catch (error) { + return new ArgsValidationError( + error instanceof Error ? error.message : 'Invalid Chiron request context.', + ).toErr(); + } + + const bundleType = request.insight.bundleType ?? 'ban'; + + // Deterministic bundle path (works without Tableau+). Also used as the + // automatic fallback when the AI brief is not enabled on the site. + const runBundle = async ( + fallback?: 'brief_unavailable_used_bundle', + ): Promise> => { + const validationError = validateBundleRequest(builtRequest.bundleRequest); + if (validationError) { + return new ArgsValidationError(validationError).toErr(); + } + + const bundleResult = await useRestApi({ + ...extra, + jwtScopes: generateChironInsightFromDatasourceContextTool.requiredApiScopes, + callback: async (restApi) => + await restApi.pulseMethods.generatePulseMetricValueInsightBundle( + builtRequest.bundleRequest, + bundleType, + ), + }); + + if (bundleResult.isErr()) { + return bundleResult; + } + + return new Ok({ + output: 'bundle' as const, + bundleType, + generatedRequest: builtRequest.bundleRequest, + response: bundleResult.value, + provenance: { datasourceId: request.datasource.id }, + ...(fallback ? { fallback } : {}), + }); + }; + + if (request.insight.output === 'bundle') { + return await runBundle(); + } + + const validationError = validateBriefRequest(builtRequest.briefRequest); + if (validationError) { + return new ArgsValidationError(validationError).toErr(); + } + + const briefResult = await useRestApi({ + ...extra, + jwtScopes: generateChironInsightFromDatasourceContextTool.requiredApiScopes, + callback: async (restApi) => + await restApi.pulseMethods.generatePulseInsightBrief(builtRequest.briefRequest), + }); + + if (briefResult.isErr()) { + // On sites without Tableau+ the AI brief endpoint is disabled; fall back + // to the deterministic bundle so Chiron still returns a governed insight. + if (briefResult.error instanceof PulseInsightsDisabledError) { + return await runBundle('brief_unavailable_used_bundle'); + } + return briefResult; + } + + return new Ok({ + output: 'brief', + bundleType, + generatedRequest: builtRequest.briefRequest, + response: briefResult.value, + provenance: { + datasourceId: request.datasource.id, + }, + }); + }, + constrainSuccessResult: (result) => ({ + type: 'success', + result, + }), + }, + ); + }, + }); + + return generateChironInsightFromDatasourceContextTool; +}; diff --git a/src/tools/web/pulse/chiron/requestBuilder.test.ts b/src/tools/web/pulse/chiron/requestBuilder.test.ts new file mode 100644 index 000000000..c26387483 --- /dev/null +++ b/src/tools/web/pulse/chiron/requestBuilder.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { buildChironRequests, ChironInsightRequest } from './requestBuilder.js'; + +const baseInput: ChironInsightRequest = { + datasource: { + id: 'A6FC3C9F-4F40-4906-8DB0-AC70C5FB5A11', + isPublished: true, + }, + schema: { + fields: [ + { + name: 'Sales', + role: 'measure', + dataType: 'number', + supportedAggregations: ['AGGREGATION_SUM', 'AGGREGATION_AVERAGE'], + }, + { + name: 'Order Date', + role: 'time', + dataType: 'date', + }, + { + name: 'Region', + role: 'dimension', + dataType: 'string', + allowedFilterValues: ['West', 'East'], + }, + ], + }, + context: { + measureField: 'Sales', + timeField: 'Order Date', + dimensionFields: ['Region'], + filters: [ + { + field: 'Region', + operator: 'OPERATOR_IN', + values: ['West'], + }, + ], + }, + insight: { + mode: 'trend', + output: 'brief', + }, + options: { + aggregation: 'AGGREGATION_SUM', + granularity: 'GRANULARITY_BY_MONTH', + range: 'RANGE_CURRENT_PARTIAL', + comparison: 'TIME_COMPARISON_PREVIOUS_PERIOD', + language: 'LANGUAGE_EN_US', + locale: 'LOCALE_EN_US', + timeZone: 'UTC', + }, +}; + +describe('buildChironRequests', () => { + it('builds deterministic requests for the same input', () => { + const first = buildChironRequests(baseInput); + const second = buildChironRequests(baseInput); + + expect(first).toEqual(second); + }); + + it('omits metric_id/definition_id since Chiron has no stored metric or definition', () => { + const built = buildChironRequests(baseInput); + + const bundleMetadata = built.bundleRequest.bundle_request.input.metadata; + expect(bundleMetadata).not.toHaveProperty('metric_id'); + expect(bundleMetadata).not.toHaveProperty('definition_id'); + expect(bundleMetadata.name).toBe('Sales (trend)'); + + const briefMetadata = built.briefRequest.messages[0].metric_group_context[0].metadata; + expect(briefMetadata).not.toHaveProperty('metric_id'); + expect(briefMetadata).not.toHaveProperty('definition_id'); + expect(briefMetadata.name).toBe('Sales (trend)'); + + // The datasource id remains the load-bearing key the Insights Service uses. + expect(built.bundleRequest.bundle_request.input.metric.definition.datasource.id).toBe( + baseInput.datasource.id, + ); + }); + + it('rejects unpublished datasource input', () => { + expect(() => + buildChironRequests({ + ...baseInput, + datasource: { ...baseInput.datasource, isPublished: false }, + }), + ).toThrow('Local or unpublished datasources are not allowed.'); + }); + + it('rejects unknown fields in context', () => { + expect(() => + buildChironRequests({ + ...baseInput, + context: { + ...baseInput.context, + measureField: 'Gross Sales', + }, + }), + ).toThrow('Field Gross Sales does not exist in the datasource schema.'); + }); + + it('rejects filter values outside context-provided allowed values', () => { + expect(() => + buildChironRequests({ + ...baseInput, + context: { + ...baseInput.context, + filters: [ + { + field: 'Region', + operator: 'OPERATOR_IN', + values: ['South'], + }, + ], + }, + }), + ).toThrow('Filter value South is not allowed for field Region.'); + }); +}); diff --git a/src/tools/web/pulse/chiron/requestBuilder.ts b/src/tools/web/pulse/chiron/requestBuilder.ts new file mode 100644 index 000000000..cfeae435d --- /dev/null +++ b/src/tools/web/pulse/chiron/requestBuilder.ts @@ -0,0 +1,461 @@ +import z from 'zod'; + +import { pulseInsightBundleTypeEnum } from '../../../../sdks/tableau/types/pulse.js'; + +const primitiveValueSchema = z.union([z.string(), z.number(), z.boolean()]); + +export const chironStudioFieldSchema = z.object({ + name: z.string().min(1), + role: z.enum(['measure', 'time', 'dimension']), + dataType: z.enum(['number', 'string', 'boolean', 'date', 'datetime']), + supportedAggregations: z.array(z.string()).optional(), + allowedFilterValues: z.array(primitiveValueSchema).optional(), +}); + +export const chironFilterSchema = z.object({ + field: z.string().min(1), + operator: z.string().default('OPERATOR_IN'), + values: z.array(primitiveValueSchema).min(1), +}); + +export const chironInsightModeEnum = [ + 'trend', + 'top_contributors', + 'summary', + 'snapshot', + 'before_after', + 'no_data_check', +] as const; + +export const chironInsightRequestSchema = z.object({ + datasource: z.object({ + id: z.string().min(1), + isPublished: z.boolean(), + }), + schema: z.object({ + fields: z.array(chironStudioFieldSchema).min(1), + }), + context: z.object({ + measureField: z.string().min(1), + timeField: z.string().min(1), + dimensionFields: z.array(z.string().min(1)).default([]), + filters: z.array(chironFilterSchema).default([]), + }), + insight: z.object({ + mode: z.enum(chironInsightModeEnum), + output: z.enum(['brief', 'bundle']).default('brief'), + bundleType: z.enum(pulseInsightBundleTypeEnum).optional(), + question: z.string().optional(), + }), + options: z + .object({ + aggregation: z.string().default('AGGREGATION_SUM'), + granularity: z.string().default('GRANULARITY_BY_MONTH'), + range: z.string().default('RANGE_CURRENT_PARTIAL'), + comparison: z.string().default('TIME_COMPARISON_PREVIOUS_PERIOD'), + language: z.string().default('LANGUAGE_EN_US'), + locale: z.string().default('LOCALE_EN_US'), + timeZone: z.string().default('UTC'), + now: z.string().optional(), + sentimentType: z.string().default('SENTIMENT_TYPE_NONE'), + numberFormatType: z.string().default('NUMBER_FORMAT_TYPE_NUMBER'), + currencyCode: z.string().optional(), + goalTarget: z.number().optional(), + }) + .default({}), +}); + +export type ChironInsightRequest = z.infer; + +export type ChironBuiltRequests = { + briefRequest: { + language: string; + locale: string; + messages: Array<{ + action_type: 'ACTION_TYPE_ANSWER' | 'ACTION_TYPE_SUMMARIZE' | 'ACTION_TYPE_ADVISE'; + content: string; + role: 'ROLE_USER'; + metric_group_context: Array<{ + metadata: { + name: string; + }; + metric: { + definition: { + datasource: { id: string }; + basic_specification: { + measure: { field: string; aggregation: string }; + time_dimension: { field: string }; + filters: Array<{ + field: string; + operator: string; + categorical_values: Array<{ + string_value?: string; + bool_value?: boolean; + }>; + }>; + }; + is_running_total: boolean; + }; + metric_specification: { + filters: Array<{ + field: string; + operator: string; + categorical_values: Array<{ + string_value?: string; + bool_value?: boolean; + }>; + }>; + measurement_period: { + granularity: string; + range: string; + }; + comparison: { comparison: string }; + }; + extension_options: { + allowed_dimensions: string[]; + allowed_granularities: string[]; + offset_from_today: number; + }; + representation_options: { + type: string; + sentiment_type: string; + currency_code?: string; + }; + insights_options: { + show_insights: true; + settings: Array<{ type: string; disabled: boolean }>; + }; + goals?: { + datasource_goals: []; + metric_goals: { + target: { value: number }; + }; + }; + candidates: []; + }; + }>; + metric_group_context_resolved: true; + }>; + now?: string; + time_zone: string; + }; + bundleRequest: { + bundle_request: { + version: 1; + options: { + output_format: 'OUTPUT_FORMAT_HTML'; + time_zone: string; + language: string; + locale: string; + }; + input: { + metadata: { + name: string; + }; + metric: { + definition: { + datasource: { id: string }; + basic_specification: { + measure: { field: string; aggregation: string }; + time_dimension: { field: string }; + filters: Array<{ + field: string; + operator: string; + categorical_values: Array<{ + string_value?: string; + bool_value?: boolean; + }>; + }>; + }; + is_running_total: false; + }; + metric_specification: { + filters: Array<{ + field: string; + operator: string; + categorical_values: Array<{ + string_value?: string; + bool_value?: boolean; + }>; + }>; + measurement_period: { + granularity: string; + range: string; + }; + comparison: { comparison: string }; + }; + extension_options: { + allowed_dimensions: string[]; + allowed_granularities: string[]; + offset_from_today: number; + }; + representation_options: { + type: string; + sentiment_type: string; + currency_code?: string; + }; + insights_options: { + show_insights: true; + settings: Array<{ type: string; disabled: boolean }>; + }; + goals?: { + target: { value: number }; + }; + }; + }; + }; + }; +}; + +const MAX_ALLOWED_DIMENSIONS = 8; + +export function buildChironRequests(rawInput: ChironInsightRequest): ChironBuiltRequests { + const input = chironInsightRequestSchema.parse(rawInput); + validateInput(input); + + const measureField = getFieldByName(input.schema.fields, input.context.measureField); + const timeField = getFieldByName(input.schema.fields, input.context.timeField); + const dimensionFields = dedupeAndSort(input.context.dimensionFields); + const pulseFilters = buildPulseFilters(input.context.filters); + + if (dimensionFields.length > MAX_ALLOWED_DIMENSIONS) { + throw new Error( + `Too many allowed dimensions. Received ${dimensionFields.length}, max is ${MAX_ALLOWED_DIMENSIONS}.`, + ); + } + + if ( + measureField.supportedAggregations && + !measureField.supportedAggregations.includes(input.options.aggregation) + ) { + throw new Error( + `Aggregation ${input.options.aggregation} is not valid for measure ${measureField.name}.`, + ); + } + + // Chiron generates insights from live Studio datasource context, so there is no + // stored Pulse metric or definition — hence no metric_id / definition_id to send. + // The Insights Service brief/bundle endpoints key off the inline datasource + spec + // and treat these IDs as log-only, so we omit them rather than fabricate values + // that would pollute the service's metric_luids / definition_luids telemetry. + const metricName = `${measureField.name} (${input.insight.mode})`; + // The Pulse Insights API rejects any fabricated insights_options.settings[].type + // (only its own internal enum is valid). Send an empty settings list and let the + // Insight Service decide which insights to run; the requested mode still drives the + // brief action_type / question and the bundle type instead. + const settings: Array<{ type: string; disabled: boolean }> = []; + + const sharedMetric = { + definition: { + datasource: { id: input.datasource.id }, + basic_specification: { + measure: { field: measureField.name, aggregation: input.options.aggregation }, + time_dimension: { field: timeField.name }, + filters: pulseFilters, + }, + is_running_total: false, + }, + metric_specification: { + filters: pulseFilters, + measurement_period: { + granularity: input.options.granularity, + range: input.options.range, + }, + comparison: { + comparison: input.options.comparison, + }, + }, + extension_options: { + allowed_dimensions: dimensionFields, + allowed_granularities: [input.options.granularity], + offset_from_today: 0, + }, + representation_options: { + type: input.options.numberFormatType, + sentiment_type: input.options.sentimentType, + ...(input.options.currencyCode ? { currency_code: input.options.currencyCode } : {}), + }, + insights_options: { + show_insights: true as const, + settings, + }, + }; + + const metricWithGoals = input.options.goalTarget + ? { + ...sharedMetric, + goals: { + target: { value: input.options.goalTarget }, + }, + } + : sharedMetric; + + const bundleRequest = { + bundle_request: { + version: 1 as const, + options: { + output_format: 'OUTPUT_FORMAT_HTML' as const, + time_zone: input.options.timeZone, + language: input.options.language, + locale: input.options.locale, + }, + input: { + metadata: { + name: metricName, + }, + metric: metricWithGoals, + }, + }, + }; + + const briefRequest = { + language: input.options.language, + locale: input.options.locale, + messages: [ + { + action_type: getActionType(input.insight.mode), + content: + input.insight.question ?? getDefaultQuestion(input.insight.mode, measureField.name), + role: 'ROLE_USER' as const, + metric_group_context: [ + { + metadata: { + name: metricName, + }, + metric: { + ...metricWithGoals, + ...(input.options.goalTarget + ? { + goals: { + datasource_goals: [], + metric_goals: { + target: { value: input.options.goalTarget }, + }, + }, + } + : {}), + candidates: [], + }, + }, + ], + metric_group_context_resolved: true as const, + }, + ], + ...(input.options.now ? { now: input.options.now } : {}), + time_zone: input.options.timeZone, + }; + + return { + briefRequest, + bundleRequest, + }; +} + +function validateInput(input: ChironInsightRequest): void { + if (!input.datasource.isPublished) { + throw new Error('Local or unpublished datasources are not allowed.'); + } + + const measureField = getFieldByName(input.schema.fields, input.context.measureField); + if (measureField.role !== 'measure') { + throw new Error(`Field ${measureField.name} must be a measure field.`); + } + + const timeField = getFieldByName(input.schema.fields, input.context.timeField); + if (timeField.role !== 'time') { + throw new Error(`Field ${timeField.name} must be a time field.`); + } + + for (const dimensionFieldName of input.context.dimensionFields) { + const dimensionField = getFieldByName(input.schema.fields, dimensionFieldName); + if (dimensionField.role !== 'dimension') { + throw new Error(`Field ${dimensionField.name} must be a dimension field.`); + } + } + + for (const filter of input.context.filters) { + const filterField = getFieldByName(input.schema.fields, filter.field); + if (filterField.allowedFilterValues) { + for (const value of filter.values) { + const isAllowed = filterField.allowedFilterValues.some((allowed) => allowed === value); + if (!isAllowed) { + throw new Error( + `Filter value ${String(value)} is not allowed for field ${filter.field}.`, + ); + } + } + } + } +} + +function getFieldByName( + fields: Array>, + fieldName: string, +): z.infer { + const field = fields.find((candidate) => candidate.name === fieldName); + if (!field) { + throw new Error(`Field ${fieldName} does not exist in the datasource schema.`); + } + + return field; +} + +function buildPulseFilters(filters: Array>): Array<{ + field: string; + operator: string; + categorical_values: Array<{ string_value?: string; bool_value?: boolean }>; +}> { + const normalized = filters + .map((filter) => ({ + field: filter.field, + operator: filter.operator, + values: [...filter.values].sort((a, b) => String(a).localeCompare(String(b))), + })) + .sort((a, b) => a.field.localeCompare(b.field)); + + return normalized.map((filter) => ({ + field: filter.field, + operator: filter.operator, + categorical_values: filter.values.map((value) => + typeof value === 'boolean' ? { bool_value: value } : { string_value: String(value) }, + ), + })); +} + +function getActionType( + mode: (typeof chironInsightModeEnum)[number], +): 'ACTION_TYPE_ANSWER' | 'ACTION_TYPE_SUMMARIZE' | 'ACTION_TYPE_ADVISE' { + if (mode === 'summary' || mode === 'snapshot') { + return 'ACTION_TYPE_SUMMARIZE'; + } + + if (mode === 'top_contributors' || mode === 'before_after') { + return 'ACTION_TYPE_ANSWER'; + } + + return 'ACTION_TYPE_ADVISE'; +} + +function getDefaultQuestion( + mode: (typeof chironInsightModeEnum)[number], + measureField: string, +): string { + switch (mode) { + case 'trend': + return `Summarize the trend for ${measureField}.`; + case 'top_contributors': + return `What are the top contributors for ${measureField}?`; + case 'summary': + return `Summarize ${measureField} performance.`; + case 'snapshot': + return `Give a snapshot of ${measureField}.`; + case 'before_after': + return `What changed before and after for ${measureField}?`; + case 'no_data_check': + return `Check for missing data signals in ${measureField}.`; + } +} + +function dedupeAndSort(values: string[]): string[] { + return [...new Set(values)].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/tools/web/toolName.ts b/src/tools/web/toolName.ts index 431a859fc..f1ab5877b 100644 --- a/src/tools/web/toolName.ts +++ b/src/tools/web/toolName.ts @@ -1,5 +1,6 @@ export const webToolNames = [ 'list-datasources', + 'resolve-datasource-luid', 'delete-datasource', 'confirm-delete-datasource', 'list-extract-refresh-tasks', @@ -31,6 +32,7 @@ export const webToolNames = [ 'list-pulse-metric-subscriptions', 'generate-pulse-metric-value-insight-bundle', 'generate-pulse-insight-brief', + 'generate-chiron-insight-from-datasource-context', 'search-content', 'revoke-access-token', 'reset-consent', @@ -47,6 +49,7 @@ export const webToolGroupNames = [ 'project', 'view', 'pulse', + 'chiron', 'content-exploration', 'tasks', 'jobs', @@ -59,6 +62,7 @@ export type WebToolGroupName = (typeof webToolGroupNames)[number]; export const webToolGroups = { datasource: [ 'list-datasources', + 'resolve-datasource-luid', 'get-datasource-metadata', 'query-datasource', 'delete-datasource', @@ -84,6 +88,7 @@ export const webToolGroups = { 'generate-pulse-metric-value-insight-bundle', 'generate-pulse-insight-brief', ], + chiron: ['generate-chiron-insight-from-datasource-context'], 'content-exploration': ['search-content'], tasks: [ 'list-extract-refresh-tasks', diff --git a/src/tools/web/tools.ts b/src/tools/web/tools.ts index d4ee2fbb0..e729e3d74 100644 --- a/src/tools/web/tools.ts +++ b/src/tools/web/tools.ts @@ -6,6 +6,7 @@ import { getSearchContentTool } from './contentExploration/searchContent.js'; import { getConfirmDeleteDatasourceTool } from './datasources/confirmDeleteDatasource.js'; import { getDeleteDatasourceTool } from './datasources/deleteDatasource.js'; import { getListDatasourcesTool } from './datasources/listDatasources.js'; +import { getResolveDatasourceLuidTool } from './datasources/resolveDatasourceLuid.js'; import { getConfirmDeleteExtractRefreshTaskTool } from './extractRefreshTasks/confirmDeleteExtractRefreshTask.js'; import { getConfirmUpdateCloudExtractRefreshTaskTool } from './extractRefreshTasks/confirmUpdateCloudExtractRefreshTask.js'; import { getDeleteExtractRefreshTaskTool } from './extractRefreshTasks/deleteExtractRefreshTask.js'; @@ -15,6 +16,7 @@ import { getGetDatasourceMetadataTool } from './getDatasourceMetadata/getDatasou import { getEmbedTokenTool } from './getEmbedToken/getEmbedToken.js'; import { getListJobsTool } from './jobs/listJobs.js'; import { getListProjectsTool } from './projects/listProjects.js'; +import { getGenerateChironInsightFromDatasourceContextTool } from './pulse/chiron/generateChironInsightFromDatasourceContextTool.js'; import { getGeneratePulseInsightBriefTool } from './pulse/generateInsightBrief/generatePulseInsightBriefTool.js'; import { getGeneratePulseMetricValueInsightBundleTool } from './pulse/generateMetricValueInsightBundle/generatePulseMetricValueInsightBundleTool.js'; import { getListAllPulseMetricDefinitionsTool } from './pulse/listAllMetricDefinitions/listAllPulseMetricDefinitions.js'; @@ -42,6 +44,7 @@ export const webToolFactories = [ getGetDatasourceMetadataTool, getEmbedTokenTool, getListDatasourcesTool, + getResolveDatasourceLuidTool, getDeleteDatasourceTool, getConfirmDeleteDatasourceTool, getListExtractRefreshTasksTool, @@ -59,6 +62,7 @@ export const webToolFactories = [ getListPulseMetricSubscriptionsTool, getGeneratePulseMetricValueInsightBundleTool, getGeneratePulseInsightBriefTool, + getGenerateChironInsightFromDatasourceContextTool, getGetWorkbookTool, getGetViewTool, getGetViewDataTool,