From 3f3b9233f36e59ff3f2986ae7077a0fe5534b325 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 15 Aug 2025 15:57:35 -0600 Subject: [PATCH] ok need to debug dash and metrics --- .../helpers/done-tool-file-selection.test.ts | 410 +++++++-- .../helpers/done-tool-file-selection.ts | 857 +++++------------- .../done-tool/helpers/file-selection.test.ts | 410 --------- .../done-tool/helpers/file-selection.ts | 505 ----------- .../create-dashboards-execute.ts | 10 +- .../helpers/dashboard-tool-description.txt | 22 +- .../src/chats/chat-message.types.test.ts | 2 +- .../src/dashboards/dashboard.types.ts | 11 +- 8 files changed, 568 insertions(+), 1659 deletions(-) delete mode 100644 packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.test.ts delete mode 100644 packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.ts diff --git a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.test.ts b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.test.ts index f3097d4de..ad45aee90 100644 --- a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.test.ts +++ b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.test.ts @@ -1,49 +1,40 @@ import { randomUUID } from 'node:crypto'; import type { ModelMessage } from 'ai'; import { describe, expect, test } from 'vitest'; +import { CREATE_DASHBOARDS_TOOL_NAME } from '../../../visualization-tools/dashboards/create-dashboards-tool/create-dashboards-tool'; +import { MODIFY_DASHBOARDS_TOOL_NAME } from '../../../visualization-tools/dashboards/modify-dashboards-tool/modify-dashboards-tool'; +import { CREATE_METRICS_TOOL_NAME } from '../../../visualization-tools/metrics/create-metrics-tool/create-metrics-tool'; +import { MODIFY_METRICS_TOOL_NAME } from '../../../visualization-tools/metrics/modify-metrics-tool/modify-metrics-tool'; +import { CREATE_REPORTS_TOOL_NAME } from '../../../visualization-tools/reports/create-reports-tool/create-reports-tool'; +import { MODIFY_REPORTS_TOOL_NAME } from '../../../visualization-tools/reports/modify-reports-tool/modify-reports-tool'; import { extractFilesFromToolCalls } from './done-tool-file-selection'; describe('done-tool-file-selection', () => { describe('extractFilesFromToolCalls', () => { - test('should handle file extraction from tool calls', () => { + test('should extract metrics from create metrics tool result', () => { + const fileId = randomUUID(); const mockMessages: ModelMessage[] = [ - { - role: 'assistant', - content: [ - { - type: 'tool-call' as const, - toolCallId: 'file-tool-123', - toolName: 'create-metrics-file', - input: { - files: [ - { - name: 'Revenue Analysis', - yml_content: 'name: Revenue\nsql: SELECT * FROM sales', - }, - ], - }, - }, - ], - }, { role: 'tool', content: [ { + type: 'tool-result', output: { - files: [ - { - id: randomUUID(), - name: 'Revenue Analysis', - file_type: 'metric', - yml_content: 'name: Revenue\\nsql: SELECT * FROM sales', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - version_number: 1, - }, - ], - message: 'created', + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Revenue Analysis', + version_number: 1, + }, + ], + message: 'Metrics created successfully', + }), }, - } as any, + toolName: CREATE_METRICS_TOOL_NAME, + toolCallId: 'tool-123', + }, ], }, ]; @@ -52,6 +43,7 @@ describe('done-tool-file-selection', () => { expect(extractedFiles).toHaveLength(1); expect(extractedFiles[0]).toMatchObject({ + id: fileId, fileType: 'metric', fileName: 'Revenue Analysis', status: 'completed', @@ -60,22 +52,31 @@ describe('done-tool-file-selection', () => { }); }); - test('should extract dashboard files from tool calls', () => { + test('should extract dashboards from create dashboards tool result', () => { + const fileId = randomUUID(); const mockMessages: ModelMessage[] = [ { role: 'tool', - content: { - files: [ - { - id: randomUUID(), - name: 'Sales Dashboard', - file_type: 'dashboard', - yml_content: 'title: Sales Dashboard', - version_number: 1, + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Sales Dashboard', + version_number: 1, + }, + ], + message: 'Dashboard created successfully', + }), }, - ], - message: 'Dashboard created successfully', - } as any, + toolName: CREATE_DASHBOARDS_TOOL_NAME, + toolCallId: 'tool-456', + }, + ], }, ]; @@ -83,35 +84,178 @@ describe('done-tool-file-selection', () => { expect(extractedFiles).toHaveLength(1); expect(extractedFiles[0]).toMatchObject({ + id: fileId, fileType: 'dashboard', fileName: 'Sales Dashboard', status: 'completed', operation: 'created', + versionNumber: 1, }); }); - test('should detect modified operation from message', () => { + test('should extract reports from create reports tool result', () => { + const fileId = randomUUID(); const mockMessages: ModelMessage[] = [ { role: 'tool', - content: { - files: [ - { - id: randomUUID(), - name: 'Updated Metric', - file_type: 'metric', - version_number: 2, + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Q4 Analysis Report', + version_number: 1, + }, + ], + message: 'Report created successfully', + }), }, - ], - message: 'Metric modified successfully', - } as any, + toolName: CREATE_REPORTS_TOOL_NAME, + toolCallId: 'tool-789', + }, + ], }, ]; const extractedFiles = extractFilesFromToolCalls(mockMessages); expect(extractedFiles).toHaveLength(1); - expect(extractedFiles[0]?.operation).toBe('modified'); + expect(extractedFiles[0]).toMatchObject({ + id: fileId, + fileType: 'report', + fileName: 'Q4 Analysis Report', + status: 'completed', + operation: 'created', + versionNumber: 1, + }); + }); + + test('should handle modify metrics tool result', () => { + const fileId = randomUUID(); + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Updated Metric', + version_number: 2, + }, + ], + message: 'Metric modified successfully', + }), + }, + toolName: MODIFY_METRICS_TOOL_NAME, + toolCallId: 'tool-abc', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + + expect(extractedFiles).toHaveLength(1); + expect(extractedFiles[0]).toMatchObject({ + id: fileId, + fileType: 'metric', + fileName: 'Updated Metric', + status: 'completed', + operation: 'modified', + versionNumber: 2, + }); + }); + + test('should handle modify dashboards tool result', () => { + const fileId = randomUUID(); + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Updated Dashboard', + version_number: 2, + }, + ], + message: 'Dashboard modified successfully', + }), + }, + toolName: MODIFY_DASHBOARDS_TOOL_NAME, + toolCallId: 'tool-def', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + + expect(extractedFiles).toHaveLength(1); + expect(extractedFiles[0]).toMatchObject({ + id: fileId, + fileType: 'dashboard', + fileName: 'Updated Dashboard', + status: 'completed', + operation: 'modified', + versionNumber: 2, + }); + }); + + test('should handle modify reports tool result with different structure', () => { + const fileId = randomUUID(); + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + success: true, + message: 'Report modified successfully', + file: { + id: fileId, + name: 'Updated Report', + content: 'Report content', + version_number: 2, + updated_at: new Date().toISOString(), + }, + }), + }, + toolName: MODIFY_REPORTS_TOOL_NAME, + toolCallId: 'tool-ghi', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + + expect(extractedFiles).toHaveLength(1); + expect(extractedFiles[0]).toMatchObject({ + id: fileId, + fileType: 'report', + fileName: 'Updated Report', + status: 'completed', + operation: 'modified', + versionNumber: 2, + }); }); test('should deduplicate files by version number', () => { @@ -119,31 +263,49 @@ describe('done-tool-file-selection', () => { const mockMessages: ModelMessage[] = [ { role: 'tool', - content: { - files: [ - { - id: fileId, - name: 'Test Metric', - file_type: 'metric', - version_number: 1, + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Test Metric', + version_number: 1, + }, + ], + message: 'Created', + }), }, - ], - message: 'created', - } as any, + toolName: CREATE_METRICS_TOOL_NAME, + toolCallId: 'tool-1', + }, + ], }, { role: 'tool', - content: { - files: [ - { - id: fileId, - name: 'Test Metric Updated', - file_type: 'metric', - version_number: 2, + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId, + name: 'Test Metric Updated', + version_number: 2, + }, + ], + message: 'Modified', + }), }, - ], - message: 'modified', - } as any, + toolName: MODIFY_METRICS_TOOL_NAME, + toolCallId: 'tool-2', + }, + ], }, ]; @@ -152,6 +314,48 @@ describe('done-tool-file-selection', () => { expect(extractedFiles).toHaveLength(1); expect(extractedFiles[0]?.versionNumber).toBe(2); expect(extractedFiles[0]?.fileName).toBe('Test Metric Updated'); + expect(extractedFiles[0]?.operation).toBe('modified'); + }); + + test('should handle multiple files in a single tool result', () => { + const fileId1 = randomUUID(); + const fileId2 = randomUUID(); + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: fileId1, + name: 'Metric 1', + version_number: 1, + }, + { + id: fileId2, + name: 'Metric 2', + version_number: 1, + }, + ], + message: 'Metrics created successfully', + }), + }, + toolName: CREATE_METRICS_TOOL_NAME, + toolCallId: 'tool-multi', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + + expect(extractedFiles).toHaveLength(2); + expect(extractedFiles[0]?.fileName).toBe('Metric 1'); + expect(extractedFiles[1]?.fileName).toBe('Metric 2'); }); test('should handle empty messages array', () => { @@ -159,7 +363,7 @@ describe('done-tool-file-selection', () => { expect(extractedFiles).toEqual([]); }); - test('should handle messages without tool results', () => { + test('should ignore messages without tool results', () => { const mockMessages: ModelMessage[] = [ { role: 'user', @@ -174,5 +378,57 @@ describe('done-tool-file-selection', () => { const extractedFiles = extractFilesFromToolCalls(mockMessages); expect(extractedFiles).toEqual([]); }); + + test('should handle invalid JSON in tool result', () => { + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: 'invalid json', + }, + toolName: CREATE_METRICS_TOOL_NAME, + toolCallId: 'tool-invalid', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + expect(extractedFiles).toEqual([]); + }); + + test('should ignore unknown tool names', () => { + const mockMessages: ModelMessage[] = [ + { + role: 'tool', + content: [ + { + type: 'tool-result', + output: { + type: 'json', + value: JSON.stringify({ + files: [ + { + id: randomUUID(), + name: 'Some File', + version_number: 1, + }, + ], + }), + }, + toolName: 'unknownTool', + toolCallId: 'tool-unknown', + }, + ], + }, + ]; + + const extractedFiles = extractFilesFromToolCalls(mockMessages); + expect(extractedFiles).toEqual([]); + }); }); }); diff --git a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts index 290babe85..63b52e10a 100644 --- a/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts +++ b/packages/ai/src/tools/communication-tools/done-tool/helpers/done-tool-file-selection.ts @@ -1,20 +1,13 @@ import type { ChatMessageResponseMessage } from '@buster/server-shared/chats'; import type { ModelMessage } from 'ai'; -import * as yaml from 'yaml'; import { CREATE_DASHBOARDS_TOOL_NAME } from '../../../visualization-tools/dashboards/create-dashboards-tool/create-dashboards-tool'; -import type { CreateDashboardsOutput } from '../../../visualization-tools/dashboards/create-dashboards-tool/create-dashboards-tool'; import { MODIFY_DASHBOARDS_TOOL_NAME } from '../../../visualization-tools/dashboards/modify-dashboards-tool/modify-dashboards-tool'; -import type { ModifyDashboardsOutput } from '../../../visualization-tools/dashboards/modify-dashboards-tool/modify-dashboards-tool'; import { CREATE_METRICS_TOOL_NAME } from '../../../visualization-tools/metrics/create-metrics-tool/create-metrics-tool'; -import type { CreateMetricsOutput } from '../../../visualization-tools/metrics/create-metrics-tool/create-metrics-tool'; import { MODIFY_METRICS_TOOL_NAME } from '../../../visualization-tools/metrics/modify-metrics-tool/modify-metrics-tool'; -import type { ModifyMetricsOutput } from '../../../visualization-tools/metrics/modify-metrics-tool/modify-metrics-tool'; import { CREATE_REPORTS_TOOL_NAME } from '../../../visualization-tools/reports/create-reports-tool/create-reports-tool'; -import type { CreateReportsOutput } from '../../../visualization-tools/reports/create-reports-tool/create-reports-tool'; import { MODIFY_REPORTS_TOOL_NAME } from '../../../visualization-tools/reports/modify-reports-tool/modify-reports-tool'; -import type { ModifyReportsOutput } from '../../../visualization-tools/reports/modify-reports-tool/modify-reports-tool'; -// File tracking type similar to ExtractedFile from file-selection.ts +// File tracking type interface ExtractedFile { id: string; fileType: 'metric' | 'dashboard' | 'report'; @@ -22,106 +15,79 @@ interface ExtractedFile { status: 'completed' | 'failed' | 'loading'; operation?: 'created' | 'modified' | undefined; versionNumber?: number | undefined; - parentDashboardId?: string | undefined; // Track which dashboard contains this metric -} - -// Track dashboard-metric relationships -interface DashboardMetricRelationship { - dashboardId: string; - metricIds: string[]; -} - -// Type for tool call content in assistant messages -interface ToolCallContent { - type: 'tool-call'; - toolName: string; - input: unknown; - toolCallId: string; } /** * Extract files from tool call responses in the conversation messages - * Scans both tool results and assistant messages for file information + * Focuses on tool result messages that contain file information */ export function extractFilesFromToolCalls(messages: ModelMessage[]): ExtractedFile[] { const files: ExtractedFile[] = []; - const dashboardMetricRelationships: DashboardMetricRelationship[] = []; console.info('[done-tool-file-selection] Starting file extraction from messages', { messageCount: messages.length, }); - // First pass: Extract all files and build dashboard-metric relationships + // Process each message looking for tool results for (const message of messages) { - if (message.role === 'assistant') { - // Look for tool calls in assistant messages - if (Array.isArray(message.content)) { - for (const content of message.content) { - if (content && typeof content === 'object') { - // Check if this is a tool call - the trace shows structure like: - // { type: 'tool-call', toolName: 'createMetrics', input: {...}, toolCallId: '...' } - const contentObj = content as ToolCallContent; - - if (contentObj.type === 'tool-call' && contentObj.toolName && contentObj.input) { - console.info('[done-tool-file-selection] Found tool call', { - toolName: contentObj.toolName, - hasInput: !!contentObj.input, - toolCallId: contentObj.toolCallId, - }); - - // Handle different tool types - switch (contentObj.toolName) { - case CREATE_DASHBOARDS_TOOL_NAME: - extractDashboardMetricRelationships( - contentObj.input, - dashboardMetricRelationships - ); - extractFilesFromToolInput(contentObj.input, 'dashboard', files); - break; - - case CREATE_METRICS_TOOL_NAME: - extractFilesFromToolInput(contentObj.input, 'metric', files); - break; - - case CREATE_REPORTS_TOOL_NAME: - extractFilesFromToolInput(contentObj.input, 'report', files); - break; - - case MODIFY_DASHBOARDS_TOOL_NAME: - extractFilesFromToolInput(contentObj.input, 'dashboard', files, 'modified'); - break; - - case MODIFY_METRICS_TOOL_NAME: - extractFilesFromToolInput(contentObj.input, 'metric', files, 'modified'); - break; - - case MODIFY_REPORTS_TOOL_NAME: - extractFilesFromToolInput(contentObj.input, 'report', files, 'modified'); - break; - } - } - } - // Also extract files from structured content - extractFilesFromStructuredContent(content, files); - } - } else if (typeof message.content === 'string') { - extractFilesFromAssistantMessage(message.content, files); - } - } else if (message.role === 'tool') { - // Tool messages have content that contains the tool result + console.info('[done-tool-file-selection] Processing message', { + role: message.role, + contentType: Array.isArray(message.content) ? 'array' : typeof message.content, + }); + + if (message.role === 'tool') { + // Tool messages contain the actual results const toolContent = message.content; - // Parse tool results based on content structure if (Array.isArray(toolContent)) { // Handle array of tool results - for (const result of toolContent) { - if (result && typeof result === 'object' && 'result' in result) { - processToolOutput(result.result, files, dashboardMetricRelationships); + for (const content of toolContent) { + console.info('[done-tool-file-selection] Processing tool content item', { + hasType: 'type' in (content || {}), + type: (content as any)?.type, + hasToolName: 'toolName' in (content || {}), + toolName: (content as any)?.toolName, + hasOutput: 'output' in (content || {}), + contentKeys: content ? Object.keys(content) : [], + }); + + if (content && typeof content === 'object') { + // Check if this is a tool-result type + if ('type' in content && content.type === 'tool-result') { + // Extract the tool name and output + const toolName = (content as any).toolName; + const output = (content as any).output; + + console.info('[done-tool-file-selection] Found tool-result', { + toolName, + hasOutput: !!output, + outputType: output?.type, + }); + + if (output && output.type === 'json' && output.value) { + try { + // Check if output.value is already an object or needs parsing + const parsedOutput = typeof output.value === 'string' + ? JSON.parse(output.value) + : output.value; + processToolOutput(toolName, parsedOutput, files); + } catch (error) { + console.warn('[done-tool-file-selection] Failed to parse JSON output', { + toolName, + error, + valueType: typeof output.value, + value: output.value, + }); + } + } + } + // Also check if the content itself has files directly (backward compatibility) + else if ('files' in content || 'file' in content) { + console.info('[done-tool-file-selection] Found direct file content in tool result'); + processDirectFileContent(content as any, files); + } } } - } else if (toolContent && typeof toolContent === 'object') { - // Handle single tool result object - processToolOutput(toolContent, files, dashboardMetricRelationships); } } } @@ -131,321 +97,193 @@ export function extractFilesFromToolCalls(messages: ModelMessage[]): ExtractedFi metrics: files.filter((f) => f.fileType === 'metric').length, dashboards: files.filter((f) => f.fileType === 'dashboard').length, reports: files.filter((f) => f.fileType === 'report').length, - relationships: dashboardMetricRelationships.length, }); // Deduplicate files by ID, keeping highest version const deduplicatedFiles = deduplicateFilesByVersion(files); - console.info('[done-tool-file-selection] After deduplication', { - totalFiles: deduplicatedFiles.length, - fileIds: deduplicatedFiles.map((f) => ({ id: f.id, type: f.fileType, operation: f.operation })), - }); - - // Apply selection rules based on file types and relationships - const selectedFiles = applyFileSelectionRules(deduplicatedFiles, dashboardMetricRelationships); - console.info('[done-tool-file-selection] Final selected files', { - totalSelected: selectedFiles.length, - selectedIds: selectedFiles.map((f) => ({ id: f.id, type: f.fileType, name: f.fileName })), + totalSelected: deduplicatedFiles.length, + selectedIds: deduplicatedFiles.map((f) => ({ id: f.id, type: f.fileType, name: f.fileName })), }); - return selectedFiles; + return deduplicatedFiles; } /** - * Extract files from assistant message content + * Process tool output based on tool name */ -function extractFilesFromAssistantMessage(content: string, files: ExtractedFile[]): void { - // Assistant messages might contain JSON data or structured information about files - // We'll try to parse it if it looks like it contains file data - try { - // Check if content contains file-like structures - if (content.includes('"file_type"') || content.includes('"files"')) { - // Try to extract JSON objects from the content - const jsonMatches = content.match(/\{[^{}]*\}/g); - if (jsonMatches) { - for (const match of jsonMatches) { - try { - const obj = JSON.parse(match); - if (obj && typeof obj === 'object') { - processFileObject(obj, files); - } - } catch { - // Ignore parse errors for individual matches - } - } - } - } - } catch { - // Ignore if we can't parse the content - } -} +function processToolOutput(toolName: string, output: any, files: ExtractedFile[]): void { + console.info('[done-tool-file-selection] Processing tool output', { + toolName, + hasFiles: 'files' in (output || {}), + hasFile: 'file' in (output || {}), + outputKeys: output ? Object.keys(output) : [], + }); -/** - * Extract files from structured content (like tool calls or reasoning entries) - */ -function extractFilesFromStructuredContent(content: unknown, files: ExtractedFile[]): void { - if (!content || typeof content !== 'object') return; + // Handle different tool types based on their name constants + switch (toolName) { + case CREATE_METRICS_TOOL_NAME: + case MODIFY_METRICS_TOOL_NAME: + processMetricsOutput(output, files, toolName === MODIFY_METRICS_TOOL_NAME ? 'modified' : 'created'); + break; - const obj = content as Record; + case CREATE_DASHBOARDS_TOOL_NAME: + case MODIFY_DASHBOARDS_TOOL_NAME: + processDashboardsOutput(output, files, toolName === MODIFY_DASHBOARDS_TOOL_NAME ? 'modified' : 'created'); + break; - // Check if this is a files reasoning entry - if (obj.type === 'files' && obj.files && typeof obj.files === 'object') { - const filesObj = obj.files as Record; - for (const fileId in filesObj) { - const file = filesObj[fileId]; - if (file && typeof file === 'object') { - processFileObject(file, files); - } - } - } + case CREATE_REPORTS_TOOL_NAME: + case MODIFY_REPORTS_TOOL_NAME: + processReportsOutput(output, files, toolName === MODIFY_REPORTS_TOOL_NAME ? 'modified' : 'created'); + break; - // Check if this contains file information directly - if ('file_type' in obj && 'file_name' in obj && 'id' in obj) { - processFileObject(obj, files); - } - - // Recursively check nested structures - for (const key in obj) { - const value = obj[key]; - if (value && typeof value === 'object') { - if (Array.isArray(value)) { - for (const item of value) { - extractFilesFromStructuredContent(item, files); - } - } else { - extractFilesFromStructuredContent(value, files); - } - } - } -} - -/** - * Process a file object and add it to the files array - */ -function processFileObject(obj: unknown, files: ExtractedFile[]): void { - if (!obj || typeof obj !== 'object') return; - - const file = obj as Record; - - // Check if this looks like a file object - if ( - file.id && - (file.file_type || file.fileType) && - (file.file_name || file.fileName || file.name) - ) { - const fileType = (file.file_type || file.fileType) as string; - const fileName = (file.file_name || file.fileName || file.name) as string; - const id = file.id as string; - const versionNumber = (file.version_number || file.versionNumber || 1) as number; - - // Only add valid file types - if (fileType === 'metric' || fileType === 'dashboard' || fileType === 'report') { - files.push({ - id, - fileType: fileType as 'metric' | 'dashboard' | 'report', - fileName, - status: 'completed', - operation: 'created', - versionNumber, + default: + console.info('[done-tool-file-selection] Unknown tool name, skipping', { + toolName, }); - } } } /** - * Process a tool output and extract file information + * Process metrics output */ -function processToolOutput( - output: unknown, - files: ExtractedFile[], - _dashboardMetricRelationships: DashboardMetricRelationship[] -): void { - // Check if this is a metrics tool output - if (isMetricsToolOutput(output)) { - const operation = detectOperation(output.message); - - // Extract successfully created/modified metric files - if (output.files && Array.isArray(output.files)) { - for (const file of output.files) { - files.push({ - id: file.id, - fileType: 'metric', - fileName: file.name, - status: 'completed', - operation, - versionNumber: file.version_number, - }); - } - } - } - - // Check if this is a dashboards tool output - if (isDashboardsToolOutput(output)) { - const operation = detectOperation(output.message); - - // Extract successfully created/modified dashboard files - if (output.files && Array.isArray(output.files)) { - for (const file of output.files) { - files.push({ - id: file.id, - fileType: 'dashboard', - fileName: file.name, - status: 'completed', - operation, - versionNumber: file.version_number, - }); - - // Extract metric IDs from dashboard content if available - // This requires looking at the original tool call arguments - // We'll track relationships when we see createDashboards tool calls - } - } - } - - // Check if this is a create reports tool output - if (isCreateReportsToolOutput(output)) { - const operation = detectOperation(output.message); - - // Extract successfully created report files - if (output.files && Array.isArray(output.files)) { - for (const file of output.files) { - files.push({ - id: file.id, - fileType: 'report', - fileName: file.name, - status: 'completed', - operation, - versionNumber: file.version_number, - }); - } - } - } - - // Check if this is a modify reports tool output - if (isModifyReportsToolOutput(output)) { - // For modify reports tool, extract from the file object - if (output.file && typeof output.file === 'object') { - files.push({ - id: output.file.id, - fileType: 'report', - fileName: output.file.name, - status: 'completed', - operation: 'modified', - versionNumber: output.file.version_number, - }); - } - } -} - -/** - * Type guard to check if output is from create/modify metrics tool - */ -function isMetricsToolOutput(output: unknown): output is CreateMetricsOutput | ModifyMetricsOutput { - if (!output || typeof output !== 'object') return false; - - const obj = output as Record; - - // Check for required properties - if (!('files' in obj) || !('message' in obj)) return false; - if (!Array.isArray(obj.files)) return false; - - // Check if all files are metrics - return obj.files.every((file: unknown) => { - if (!file || typeof file !== 'object') return false; - const fileObj = file as Record; - return fileObj.file_type === 'metric'; - }); -} - -/** - * Type guard to check if output is from create/modify dashboards tool - */ -function isDashboardsToolOutput( - output: unknown -): output is CreateDashboardsOutput | ModifyDashboardsOutput { - if (!output || typeof output !== 'object') return false; - - const obj = output as Record; - - // Check for required properties - if (!('files' in obj) || !('message' in obj)) return false; - if (!Array.isArray(obj.files)) return false; - - // Check if all files are dashboards - return obj.files.every((file: unknown) => { - if (!file || typeof file !== 'object') return false; - const fileObj = file as Record; - return fileObj.file_type === 'dashboard'; - }); -} - -/** - * Type guard to check if output is from create reports tool - */ -function isCreateReportsToolOutput(output: unknown): output is CreateReportsOutput { - if (!output || typeof output !== 'object') return false; - - const obj = output as Record; - - // Check for create reports output structure - if ('files' in obj && 'message' in obj && 'failed_files' in obj) { - if (!Array.isArray(obj.files)) return false; - - // Check if files have report-specific properties (id, name, version_number) - return obj.files.every((file: unknown) => { - if (!file || typeof file !== 'object') return false; - const fileObj = file as Record; - return 'id' in fileObj && 'name' in fileObj && 'version_number' in fileObj; +function processMetricsOutput(output: any, files: ExtractedFile[], operation: 'created' | 'modified'): void { + if (output.files && Array.isArray(output.files)) { + console.info('[done-tool-file-selection] Processing metrics files', { + count: output.files.length, + operation, }); - } - - return false; -} - -/** - * Type guard to check if output is from modify reports tool - */ -function isModifyReportsToolOutput(output: unknown): output is ModifyReportsOutput { - if (!output || typeof output !== 'object') return false; - - const obj = output as Record; - - // Check for modify reports output structure (has success, message, and file properties) - if ('success' in obj && 'message' in obj && 'file' in obj) { - const file = obj.file; - if (file && typeof file === 'object') { - const fileObj = file as Record; - // Check for required file properties - return ( - 'id' in fileObj && - 'name' in fileObj && - 'version_number' in fileObj && - 'content' in fileObj && - 'updated_at' in fileObj - ); + + for (const file of output.files) { + // Handle both possible structures + const fileName = file.file_name || file.name; + const fileType = file.file_type || 'metric'; + + console.info('[done-tool-file-selection] Processing metric file', { + id: file.id, + fileName, + fileType, + hasFileName: !!fileName, + fileKeys: Object.keys(file), + }); + + if (file.id && fileName) { + files.push({ + id: file.id, + fileType: fileType as 'metric' | 'dashboard' | 'report', + fileName: fileName, + status: file.status || 'completed', + operation, + versionNumber: file.version_number || 1, + }); + } } } - - return false; } /** - * Detect if files were created or modified based on the message + * Process dashboards output */ -function detectOperation(message: string): 'created' | 'modified' | undefined { - if (!message) return undefined; - - const lowerMessage = message.toLowerCase(); - if (lowerMessage.includes('modified') || lowerMessage.includes('updated')) { - return 'modified'; - } - if (lowerMessage.includes('created') || lowerMessage.includes('creating')) { - return 'created'; +function processDashboardsOutput(output: any, files: ExtractedFile[], operation: 'created' | 'modified'): void { + if (output.files && Array.isArray(output.files)) { + console.info('[done-tool-file-selection] Processing dashboard files', { + count: output.files.length, + operation, + }); + + for (const file of output.files) { + // Handle both possible structures + const fileName = file.file_name || file.name; + const fileType = file.file_type || 'dashboard'; + + if (file.id && fileName) { + files.push({ + id: file.id, + fileType: fileType as 'metric' | 'dashboard' | 'report', + fileName: fileName, + status: file.status || 'completed', + operation, + versionNumber: file.version_number || 1, + }); + } + } } +} - return 'created'; // Default to created if not specified +/** + * Process reports output + */ +function processReportsOutput(output: any, files: ExtractedFile[], operation: 'created' | 'modified'): void { + // Reports can have either files array or single file property + if (output.files && Array.isArray(output.files)) { + console.info('[done-tool-file-selection] Processing report files array', { + count: output.files.length, + operation, + }); + + for (const file of output.files) { + // Handle both possible structures + const fileName = file.file_name || file.name; + const fileType = file.file_type || 'report'; + + if (file.id && fileName) { + files.push({ + id: file.id, + fileType: fileType as 'metric' | 'dashboard' | 'report', + fileName: fileName, + status: file.status || 'completed', + operation, + versionNumber: file.version_number || 1, + }); + } + } + } else if (output.file && typeof output.file === 'object') { + // Handle single file for modify reports + const file = output.file; + const fileName = file.file_name || file.name; + const fileType = file.file_type || 'report'; + + console.info('[done-tool-file-selection] Processing single report file', { + id: file.id, + fileName, + operation, + }); + + if (file.id && fileName) { + files.push({ + id: file.id, + fileType: fileType as 'metric' | 'dashboard' | 'report', + fileName: fileName, + status: file.status || 'completed', + operation, + versionNumber: file.version_number || 1, + }); + } + } +} + +/** + * Process direct file content (backward compatibility) + */ +function processDirectFileContent(content: any, files: ExtractedFile[]): void { + if (content.files && Array.isArray(content.files)) { + for (const file of content.files) { + const fileName = file.file_name || file.name; + const fileType = file.file_type || 'metric'; + + if (file.id && fileName) { + files.push({ + id: file.id, + fileType: fileType as 'metric' | 'dashboard' | 'report', + fileName: fileName, + status: file.status || 'completed', + operation: 'created', + versionNumber: file.version_number || 1, + }); + } + } + } } /** @@ -467,277 +305,6 @@ function deduplicateFilesByVersion(files: ExtractedFile[]): ExtractedFile[] { return Array.from(deduplicated.values()); } -/** - * Extract dashboard-metric relationships from createDashboards tool arguments - */ -function extractDashboardMetricRelationships( - args: unknown, - relationships: DashboardMetricRelationship[] -): void { - console.info('[done-tool-file-selection] Extracting dashboard-metric relationships from args'); - try { - const argsObj = args as Record; - if (argsObj.files && Array.isArray(argsObj.files)) { - console.info('[done-tool-file-selection] Found files in args', { - fileCount: argsObj.files.length, - }); - for (const file of argsObj.files as Record[]) { - if (file.yml_content) { - // Parse the YAML content to extract metric IDs - try { - const dashboardYaml = yaml.parse(file.yml_content as string); - if (dashboardYaml?.rows && Array.isArray(dashboardYaml.rows)) { - const metricIds: string[] = []; - for (const row of dashboardYaml.rows) { - if (row.items && Array.isArray(row.items)) { - for (const item of row.items) { - if (item.id) { - metricIds.push(item.id); - } - } - } - } - - // We don't have the dashboard ID yet (it's generated during execution), - // but we can use the dashboard name as a temporary identifier - // and update it later when we see the result - if (metricIds.length > 0 && file.name) { - console.info('[done-tool-file-selection] Found dashboard with metrics', { - dashboardName: file.name, - metricIds, - }); - relationships.push({ - dashboardId: file.name as string, // Use name as temporary ID - metricIds, - }); - } - } - } catch (yamlError) { - // Ignore YAML parsing errors - console.warn('Failed to parse dashboard YAML for relationships:', yamlError); - } - } - } - } - } catch (error) { - // Ignore errors in relationship extraction - console.warn('Failed to extract dashboard-metric relationships:', error); - } -} - -/** - * Apply file selection rules based on file types and relationships - * Rules: - * 1. If only metrics are created → show all metrics - * 2. If only dashboards are created → show only dashboards - * 3. If a metric that belongs to a dashboard is modified → show the parent dashboard - */ -function applyFileSelectionRules( - files: ExtractedFile[], - relationships: DashboardMetricRelationship[] -): ExtractedFile[] { - console.info('[done-tool-file-selection] Applying selection rules', { - totalFiles: files.length, - relationships: relationships.length, - }); - - // Separate files by type - const metrics = files.filter((f) => f.fileType === 'metric'); - const dashboards = files.filter((f) => f.fileType === 'dashboard'); - const reports = files.filter((f) => f.fileType === 'report'); - - console.info('[done-tool-file-selection] Files by type', { - metrics: metrics.map((m) => ({ id: m.id, name: m.fileName, operation: m.operation })), - dashboards: dashboards.map((d) => ({ id: d.id, name: d.fileName, operation: d.operation })), - reports: reports.length, - }); - - // Build a map of metric ID to dashboard IDs (a metric can belong to multiple dashboards) - const metricToDashboards = new Map>(); - - // First, map dashboard names to their IDs from the files - const dashboardNameToId = new Map(); - for (const dashboard of dashboards) { - dashboardNameToId.set(dashboard.fileName, dashboard.id); - } - - // Then build the metric-to-dashboard mapping - for (const relationship of relationships) { - // Try to find the actual dashboard ID from the name - const dashboardId = dashboardNameToId.get(relationship.dashboardId) || relationship.dashboardId; - - console.info('[done-tool-file-selection] Processing relationship', { - dashboardIdOrName: relationship.dashboardId, - resolvedDashboardId: dashboardId, - metricIds: relationship.metricIds, - }); - - for (const metricId of relationship.metricIds) { - if (!metricToDashboards.has(metricId)) { - metricToDashboards.set(metricId, new Set()); - } - metricToDashboards.get(metricId)?.add(dashboardId); - } - } - - console.info('[done-tool-file-selection] Metric to dashboard mapping', { - mappings: Array.from(metricToDashboards.entries()).map(([metricId, dashboardIds]) => ({ - metricId, - belongsToDashboards: Array.from(dashboardIds), - })), - }); - - // Note: Dashboard-metric relationships are handled by the relationship extraction above - - // Apply selection rules - const selectedFiles: ExtractedFile[] = []; - - // Always include reports - selectedFiles.push(...reports); - - // Check if we have both metrics and dashboards - const hasMetrics = metrics.length > 0; - const hasDashboards = dashboards.length > 0; - - if (hasMetrics && !hasDashboards) { - // Rule 1: Only metrics exist → show all metrics - console.info('[done-tool-file-selection] Rule 1: Only metrics exist, showing all metrics'); - selectedFiles.push(...metrics); - } else if (hasDashboards && !hasMetrics) { - // Rule 2: Only dashboards exist → show dashboards - console.info('[done-tool-file-selection] Rule 2: Only dashboards exist, showing dashboards'); - selectedFiles.push(...dashboards); - } else if (hasMetrics && hasDashboards) { - console.info('[done-tool-file-selection] Both metrics and dashboards exist'); - // We have both metrics and dashboards - // Check if any modified metrics belong to dashboards - const modifiedMetrics = metrics.filter((m) => m.operation === 'modified'); - const parentDashboardIds = new Set(); - - console.info('[done-tool-file-selection] Checking for modified metrics', { - modifiedMetrics: modifiedMetrics.map((m) => ({ id: m.id, name: m.fileName })), - }); - - for (const metric of modifiedMetrics) { - const dashboardIds = metricToDashboards.get(metric.id); - if (dashboardIds) { - console.info('[done-tool-file-selection] Modified metric belongs to dashboards', { - metricId: metric.id, - dashboardIds: Array.from(dashboardIds), - }); - dashboardIds.forEach((id) => parentDashboardIds.add(id)); - } - } - - // If modified metrics belong to dashboards, show those dashboards - if (parentDashboardIds.size > 0) { - console.info( - '[done-tool-file-selection] Rule 3: Modified metrics belong to dashboards, showing parent dashboards', - { - parentDashboardIds: Array.from(parentDashboardIds), - } - ); - const parentDashboards = dashboards.filter((d) => parentDashboardIds.has(d.id)); - selectedFiles.push(...parentDashboards); - - // Also include any standalone metrics (not in dashboards) - const standaloneMetrics = metrics.filter((m) => !metricToDashboards.has(m.id)); - console.info('[done-tool-file-selection] Including standalone metrics', { - standaloneMetrics: standaloneMetrics.map((m) => ({ id: m.id, name: m.fileName })), - }); - selectedFiles.push(...standaloneMetrics); - } else { - // Default: show dashboards if they exist, otherwise show metrics - if (dashboards.length > 0) { - console.info('[done-tool-file-selection] Default: Showing dashboards'); - selectedFiles.push(...dashboards); - } else { - console.info('[done-tool-file-selection] Default: No dashboards, showing metrics'); - selectedFiles.push(...metrics); - } - } - } - - // Remove duplicates - const uniqueFiles = new Map(); - for (const file of selectedFiles) { - if ( - !uniqueFiles.has(file.id) || - (file.versionNumber || 1) > (uniqueFiles.get(file.id)?.versionNumber || 1) - ) { - uniqueFiles.set(file.id, file); - } - } - - return Array.from(uniqueFiles.values()); -} - -/** - * Extract files from tool input (for tool calls in assistant messages) - */ -function extractFilesFromToolInput( - input: unknown, - fileType: 'metric' | 'dashboard' | 'report', - files: ExtractedFile[], - operation: 'created' | 'modified' = 'created' -): void { - if (!input || typeof input !== 'object') return; - - const inputObj = input as Record; - - // Handle files array in input - if (inputObj.files && Array.isArray(inputObj.files)) { - for (const file of inputObj.files as Record[]) { - if (file.name) { - const fileName = file.name as string; - // Generate a temporary ID based on file type and name - const tempId = `${fileType}_${fileName.replace(/[^a-zA-Z0-9]/g, '_')}_temp`; - - files.push({ - id: tempId, - fileType, - fileName, - status: 'loading' as const, - operation, - versionNumber: 1, - }); - - console.info('[done-tool-file-selection] Extracted file from tool input', { - tempId, - fileType, - fileName, - operation, - }); - } - } - } - - // Handle single file in input (for modify tools) - if (inputObj.file && typeof inputObj.file === 'object') { - const file = inputObj.file as Record; - if (file.name || file.id) { - const fileName = (file.name || file.id) as string; - const tempId = `${fileType}_${fileName.replace(/[^a-zA-Z0-9]/g, '_')}_temp`; - - files.push({ - id: tempId, - fileType, - fileName, - status: 'loading' as const, - operation, - versionNumber: 1, - }); - - console.info('[done-tool-file-selection] Extracted single file from tool input', { - tempId, - fileType, - fileName, - operation, - }); - } - } -} - /** * Create file response messages for selected files */ @@ -769,4 +336,4 @@ export function createFileResponseMessages(files: ExtractedFile[]): ChatMessageR ], }; }); -} +} \ No newline at end of file diff --git a/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.test.ts b/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.test.ts deleted file mode 100644 index bcd4251d5..000000000 --- a/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.test.ts +++ /dev/null @@ -1,410 +0,0 @@ -import type { ChatMessageReasoningMessage } from '@buster/server-shared/chats'; -import { describe, expect, it } from 'vitest'; -import { - type ExtractedFile, - createFileResponseMessages, - extractFilesFromReasoning, - selectFilesForResponse, -} from './file-selection'; - -describe('file-selection', () => { - describe('extractFilesFromReasoning', () => { - it('should extract created metrics with version numbers', () => { - const reasoningHistory: ChatMessageReasoningMessage[] = [ - { - id: 'test-1', - type: 'files', - title: 'Created 2 metrics', - status: 'completed', - file_ids: ['metric-1', 'metric-2'], - files: { - 'metric-1': { - id: 'metric-1', - file_type: 'metric', - file_name: 'Revenue Metric', - version_number: 1, - status: 'completed', - file: { - text: '{"name": "Revenue Metric", "sql": "SELECT revenue FROM sales"}', - }, - }, - 'metric-2': { - id: 'metric-2', - file_type: 'metric', - file_name: 'Growth Metric', - version_number: 1, - status: 'completed', - file: { - text: '{"name": "Growth Metric", "sql": "SELECT growth FROM analytics"}', - }, - }, - }, - }, - ]; - - const files = extractFilesFromReasoning(reasoningHistory); - - expect(files).toHaveLength(2); - expect(files[0]).toMatchObject({ - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'created', - versionNumber: 1, - }); - expect(files[1]).toMatchObject({ - id: 'metric-2', - fileType: 'metric', - fileName: 'Growth Metric', - status: 'completed', - operation: 'created', - versionNumber: 1, - }); - }); - - it('should extract modified dashboards with version numbers', () => { - const reasoningHistory: ChatMessageReasoningMessage[] = [ - { - id: 'test-1', - type: 'files', - title: 'Modified 1 dashboard', - status: 'completed', - file_ids: ['dashboard-1'], - files: { - 'dashboard-1': { - id: 'dashboard-1', - file_type: 'dashboard', - file_name: 'Sales Dashboard', - version_number: 3, - status: 'completed', - file: { - text: '{"name": "Sales Dashboard", "rows": [{"id": 1, "items": [{"id": "metric-1"}], "columnSizes": [12]}]}', - }, - }, - }, - }, - ]; - - const files = extractFilesFromReasoning(reasoningHistory); - - expect(files).toHaveLength(1); - expect(files[0]).toMatchObject({ - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'modified', - versionNumber: 3, - }); - }); - - it('should build metric-to-dashboard relationships', () => { - const reasoningHistory: ChatMessageReasoningMessage[] = [ - { - id: 'test-1', - type: 'files', - title: 'Created 1 metric', - status: 'completed', - file_ids: ['metric-1'], - files: { - 'metric-1': { - id: 'metric-1', - file_type: 'metric', - file_name: 'Revenue Metric', - version_number: 1, - status: 'completed', - file: { - text: '{"name": "Revenue Metric"}', - }, - }, - }, - }, - { - id: 'test-2', - type: 'files', - title: 'Created 1 dashboard', - status: 'completed', - file_ids: ['dashboard-1'], - files: { - 'dashboard-1': { - id: 'dashboard-1', - file_type: 'dashboard', - file_name: 'Sales Dashboard', - version_number: 1, - status: 'completed', - file: { - text: '{"name": "Sales Dashboard", "rows": [{"id": 1, "items": [{"id": "metric-1"}], "columnSizes": [12]}]}', - }, - }, - }, - }, - ]; - - const files = extractFilesFromReasoning(reasoningHistory); - const metric = files.find((f) => f.id === 'metric-1'); - - expect(metric?.containedInDashboards).toEqual(['dashboard-1']); - }); - }); - - describe('selectFilesForResponse', () => { - it('should return dashboard when metric inside it was modified', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 2, - containedInDashboards: ['dashboard-1'], - }, - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'created', - versionNumber: 1, - ymlContent: - '{"name": "Sales Dashboard", "rows": [{"id": 1, "items": [{"id": "metric-1"}], "columnSizes": [12]}]}', - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(1); - expect(selected[0]?.id).toBe('dashboard-1'); - }); - - it('should return standalone modified metrics', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 2, - containedInDashboards: [], - }, - { - id: 'metric-2', - fileType: 'metric', - fileName: 'Growth Metric', - status: 'completed', - operation: 'modified', - versionNumber: 3, - containedInDashboards: [], - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(2); - expect(selected.map((f) => f.id)).toEqual(['metric-1', 'metric-2']); - }); - - it('should return dashboard and standalone metric when both exist', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 2, - containedInDashboards: ['dashboard-1'], - }, - { - id: 'metric-2', - fileType: 'metric', - fileName: 'Standalone Metric', - status: 'completed', - operation: 'modified', - versionNumber: 1, - containedInDashboards: [], - }, - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'created', - versionNumber: 1, - ymlContent: - '{"name": "Sales Dashboard", "rows": [{"id": 1, "items": [{"id": "metric-1"}], "columnSizes": [12]}]}', - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(2); - expect(selected.map((f) => f.id).sort()).toEqual(['dashboard-1', 'metric-2'].sort()); - }); - - it('should prioritize dashboards over metrics in standard cases', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'created', - versionNumber: 1, - }, - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'created', - versionNumber: 1, - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(1); - expect(selected[0]?.id).toBe('dashboard-1'); - }); - - it('should deduplicate files by ID and keep highest version', () => { - const files: ExtractedFile[] = [ - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'created', - versionNumber: 1, - }, - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'modified', - versionNumber: 2, - }, - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'created', - versionNumber: 3, - }, - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 1, - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(2); - - const dashboard = selected.find((f) => f.fileType === 'dashboard'); - const metric = selected.find((f) => f.fileType === 'metric'); - - expect(dashboard?.versionNumber).toBe(2); - expect(dashboard?.operation).toBe('modified'); - - expect(metric?.versionNumber).toBe(3); - expect(metric?.operation).toBe('created'); - }); - - it('should default missing version numbers to 1 during deduplication', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'created', - // No versionNumber provided (should default to 1) - }, - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 2, - }, - ]; - - const selected = selectFilesForResponse(files); - - expect(selected).toHaveLength(1); - expect(selected[0]?.versionNumber).toBe(2); - expect(selected[0]?.operation).toBe('modified'); - }); - }); - - describe('createFileResponseMessages', () => { - it('should create response messages with correct version numbers', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - operation: 'modified', - versionNumber: 3, - }, - { - id: 'dashboard-1', - fileType: 'dashboard', - fileName: 'Sales Dashboard', - status: 'completed', - operation: 'created', - versionNumber: 1, - }, - ]; - - const messages = createFileResponseMessages(files); - - expect(messages).toHaveLength(2); - expect(messages[0]).toMatchObject({ - id: 'metric-1', - type: 'file', - file_type: 'metric', - file_name: 'Revenue Metric', - version_number: 3, - }); - expect((messages[0] as any).metadata?.[0]?.message).toBe('Metric modified successfully'); - - expect(messages[1]).toMatchObject({ - id: 'dashboard-1', - type: 'file', - file_type: 'dashboard', - file_name: 'Sales Dashboard', - version_number: 1, - }); - expect((messages[1] as any).metadata?.[0]?.message).toBe('Dashboard created successfully'); - }); - - it('should default to version 1 if version number is missing', () => { - const files: ExtractedFile[] = [ - { - id: 'metric-1', - fileType: 'metric', - fileName: 'Revenue Metric', - status: 'completed', - // No versionNumber provided - }, - ]; - - const messages = createFileResponseMessages(files); - - expect((messages[0] as any).version_number).toBe(1); - }); - }); -}); diff --git a/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.ts b/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.ts deleted file mode 100644 index a57366545..000000000 --- a/packages/ai/src/tools/communication-tools/done-tool/helpers/file-selection.ts +++ /dev/null @@ -1,505 +0,0 @@ -import type { - ChatMessageReasoningMessage, - ChatMessageResponseMessage, -} from '@buster/server-shared/chats'; - -// Helper functions to check for failure indicators -function hasFailureIndicators(entry: ChatMessageReasoningMessage): boolean { - return ( - entry.status === 'failed' || - (entry.title?.toLowerCase().includes('error') ?? false) || - (entry.title?.toLowerCase().includes('failed') ?? false) - ); -} - -function hasFileFailureIndicators(file: { status?: string; file_name?: string }): boolean { - return ( - file.status === 'failed' || - file.status === 'error' || - (file.file_name?.toLowerCase().includes('error') ?? false) || - (file.file_name?.toLowerCase().includes('failed') ?? false) - ); -} - -// File tracking types -export interface ExtractedFile { - id: string; - fileType: 'metric' | 'dashboard' | 'report'; - fileName: string; - status: 'completed' | 'failed' | 'loading'; - ymlContent?: string; - markdownContent?: string; // For reports - operation?: 'created' | 'modified' | undefined; // Track if file was created or modified - versionNumber?: number | undefined; // Version number from the file operation - containedInDashboards?: string[]; // Dashboard IDs that contain this metric (for metrics only) -} - -/** - * Extract successfully created/modified files from reasoning history - * Enhanced with multiple safety checks to prevent failed files from being included - */ -export function extractFilesFromReasoning( - reasoningHistory: ChatMessageReasoningMessage[] -): ExtractedFile[] { - const files: ExtractedFile[] = []; - - for (const entry of reasoningHistory) { - // Multi-layer safety checks: - // 1. Must be a files entry with completed status - // 2. Must not have any failure indicators (additional safety net) - // 3. Individual files must have completed status - if ( - entry.type === 'files' && - entry.status === 'completed' && - entry.files && - !hasFailureIndicators(entry) - ) { - // Detect operation type from the entry title - const operation = detectOperationType(entry.title); - - for (const fileId of entry.file_ids || []) { - const file = entry.files[fileId]; - - // Enhanced file validation: - // - File must exist and have completed status - // - File must not have error indicators - // - File must have required properties (file_type, file_name) - if ( - file && - file.status === 'completed' && - file.file_type && - file.file_name && - !hasFileFailureIndicators(file) - ) { - const extractedFile: ExtractedFile = { - id: fileId, - fileType: file.file_type as 'metric' | 'dashboard' | 'report', - fileName: file.file_name, - status: 'completed', - operation: operation || undefined, - versionNumber: file.version_number || undefined, - }; - - // Add appropriate content based on file type - if (file.file_type === 'report') { - extractedFile.markdownContent = file.file?.text || ''; - } else { - extractedFile.ymlContent = file.file?.text || ''; - } - - files.push(extractedFile); - } else { - // Log why file was rejected for debugging - console.warn(`Rejecting file for response: ${fileId}`, { - fileId, - fileName: file?.file_name || 'unknown', - fileStatus: file?.status || 'unknown', - hasFile: !!file, - hasFileType: !!file?.file_type, - hasFileName: !!file?.file_name, - hasFailureIndicators: file ? hasFileFailureIndicators(file) : false, - entryId: entry.id, - }); - } - } - } - } - - // Build metric-to-dashboard relationships - buildMetricToDashboardRelationships(files); - - return files; -} - -/** - * Detect if a file was created or modified based on the entry title - */ -function detectOperationType(title?: string): 'created' | 'modified' | undefined { - if (!title) return undefined; - - const lowerTitle = title.toLowerCase(); - if (lowerTitle.includes('created') || lowerTitle.includes('creating')) { - return 'created'; - } - if (lowerTitle.includes('modified') || lowerTitle.includes('modifying')) { - return 'modified'; - } - - return undefined; -} - -/** - * Parse dashboard YML content to extract metric IDs - */ -function extractMetricIdsFromDashboard(ymlContent: string): string[] { - try { - // First try to parse as JSON (for test data and already parsed content) - let dashboardData: unknown; - try { - dashboardData = JSON.parse(ymlContent); - } catch { - // If JSON parsing fails, try parsing as YAML - // Since we don't have a YAML parser imported here, we'll use a simple regex approach - // to extract metric IDs from the YAML content - const metricIds: string[] = []; - - // Look for UUID patterns in the content - // This regex matches UUIDs in the format: id: "uuid" or id: uuid - const uuidRegex = - /id:\s*["']?([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})["']?/gi; - let match: RegExpExecArray | null = null; - - match = uuidRegex.exec(ymlContent); - while (match !== null) { - if (match[1]) { - metricIds.push(match[1]); - } - match = uuidRegex.exec(ymlContent); - } - - // Remove duplicates - return [...new Set(metricIds)]; - } - - // If we successfully parsed as JSON, extract metric IDs from the structure - const metricIds: string[] = []; - - if ( - dashboardData && - typeof dashboardData === 'object' && - 'rows' in dashboardData && - Array.isArray(dashboardData.rows) - ) { - for (const row of dashboardData.rows) { - if (row && typeof row === 'object' && 'items' in row && Array.isArray(row.items)) { - for (const item of row.items) { - if (item && typeof item === 'object' && 'id' in item && typeof item.id === 'string') { - metricIds.push(item.id); - } - } - } - } - } - - return metricIds; - } catch (error) { - console.warn('Failed to parse dashboard content for metric extraction:', error); - return []; - } -} - -/** - * Deduplicate files by ID, keeping the highest version number - */ -function deduplicateFilesByVersion(files: ExtractedFile[]): ExtractedFile[] { - const deduplicated = new Map(); - - for (const file of files) { - const existingFile = deduplicated.get(file.id); - const fileVersion = file.versionNumber || 1; - const existingVersion = existingFile?.versionNumber || 1; - - if (!existingFile || fileVersion > existingVersion) { - if (existingFile && fileVersion > existingVersion) { - console.info('[File Selection] Replacing file with higher version:', { - fileId: file.id, - fileName: file.fileName, - oldVersion: existingVersion, - newVersion: fileVersion, - }); - } - deduplicated.set(file.id, file); - } else if (fileVersion < existingVersion) { - console.info('[File Selection] Skipping file with lower version:', { - fileId: file.id, - fileName: file.fileName, - currentVersion: existingVersion, - skippedVersion: fileVersion, - }); - } - } - - return Array.from(deduplicated.values()); -} - -/** - * Build metric-to-dashboard relationships from extracted files - */ -function buildMetricToDashboardRelationships(files: ExtractedFile[]): void { - // First pass: collect all dashboard-to-metric mappings - const dashboardToMetrics = new Map(); - - for (const file of files) { - if (file.fileType === 'dashboard' && file.ymlContent) { - const metricIds = extractMetricIdsFromDashboard(file.ymlContent); - if (metricIds.length > 0) { - dashboardToMetrics.set(file.id, metricIds); - } - } - } - - // Second pass: add dashboard relationships to metrics - for (const file of files) { - if (file.fileType === 'metric') { - file.containedInDashboards = []; - - // Check which dashboards contain this metric - for (const [dashboardId, metricIds] of dashboardToMetrics) { - if (metricIds.includes(file.id)) { - file.containedInDashboards.push(dashboardId); - } - } - } - } -} - -/** - * Apply intelligent selection logic for files to return - * Enhanced priority logic that considers modified files and dashboard-metric relationships - */ -export function selectFilesForResponse( - files: ExtractedFile[], - dashboardContext?: Array<{ - id: string; - name: string; - versionNumber: number; - metricIds: string[]; - }> -): ExtractedFile[] { - // Debug logging - console.info('[File Selection] Starting file selection:', { - totalFiles: files.length, - dashboardContextCount: dashboardContext?.length || 0, - dashboardContextProvided: dashboardContext !== undefined, - dashboardContextIsArray: Array.isArray(dashboardContext), - fileTypes: files.map((f) => ({ id: f.id, type: f.fileType, operation: f.operation })), - }); - - // Additional debug logging for dashboard context - if (dashboardContext === undefined) { - console.info('[File Selection] Dashboard context is undefined'); - } else if (dashboardContext === null) { - console.info('[File Selection] Dashboard context is null'); - } else if (dashboardContext.length === 0) { - console.info('[File Selection] Dashboard context is empty array'); - } else { - console.info('[File Selection] Dashboard context details:', { - dashboardCount: dashboardContext.length, - dashboards: dashboardContext.map((d) => ({ - id: d.id, - name: d.name, - metricCount: d.metricIds.length, - metricIds: d.metricIds, - })), - }); - } - - // Separate dashboards, metrics, and reports - const dashboards = files.filter((f) => f.fileType === 'dashboard'); - const metrics = files.filter((f) => f.fileType === 'metric'); - const reports = files.filter((f) => f.fileType === 'report'); - - console.info('[File Selection] File breakdown:', { - dashboards: dashboards.length, - metrics: metrics.length, - reports: reports.length, - modifiedMetrics: metrics.filter((m) => m.operation === 'modified').length, - modifiedReports: reports.filter((r) => r.operation === 'modified').length, - }); - - // Track which dashboards need to be included due to modified metrics - const dashboardsToInclude = new Set(); - const contextDashboardsToInclude: ExtractedFile[] = []; - - // First, check if any modified metrics belong to dashboards from the current session - for (const metric of metrics) { - if (metric.operation === 'modified' && metric.containedInDashboards) { - // This metric was modified and belongs to dashboard(s) - for (const dashboardId of metric.containedInDashboards) { - // Check if this dashboard exists in our current file set - const dashboardExists = files.some( - (f) => f.id === dashboardId && f.fileType === 'dashboard' - ); - if (dashboardExists) { - dashboardsToInclude.add(dashboardId); - } - } - } - } - - // Second, check if any modified metrics belong to dashboards from the database context - if (dashboardContext && dashboardContext.length > 0) { - for (const metric of metrics) { - if (metric.operation === 'modified') { - console.info('[File Selection] Found modified metric:', { - metricId: metric.id, - metricName: metric.fileName, - checkingAgainstDashboards: dashboardContext.length, - }); - - // Check if this metric ID is in any dashboard from context - for (const contextDashboard of dashboardContext) { - console.info('[File Selection] Checking dashboard:', { - dashboardId: contextDashboard.id, - dashboardName: contextDashboard.name, - dashboardMetricIds: contextDashboard.metricIds, - lookingForMetricId: metric.id, - metricIdInDashboard: contextDashboard.metricIds.includes(metric.id), - }); - - if (contextDashboard.metricIds.includes(metric.id)) { - console.info('[File Selection] Modified metric found in dashboard:', { - metricId: metric.id, - dashboardId: contextDashboard.id, - dashboardName: contextDashboard.name, - }); - - // Convert context dashboard to ExtractedFile format - const dashboardFile: ExtractedFile = { - id: contextDashboard.id, - fileType: 'dashboard', - fileName: contextDashboard.name, - status: 'completed', - versionNumber: contextDashboard.versionNumber, - containedInDashboards: [], - operation: undefined, // These are existing dashboards, not created/modified - }; - - // Only add if not already in our files or contextDashboardsToInclude - const alreadyIncluded = - files.some((f) => f.id === dashboardFile.id) || - contextDashboardsToInclude.some((f) => f.id === dashboardFile.id); - - if (!alreadyIncluded) { - contextDashboardsToInclude.push(dashboardFile); - } - } - } - } - } - } - - // Build final selection based on priority rules - const selectedFiles: ExtractedFile[] = []; - - // 1. First priority: Dashboards from context that contain modified metrics - if (contextDashboardsToInclude.length > 0) { - console.info('[File Selection] Adding context dashboards:', { - count: contextDashboardsToInclude.length, - dashboards: contextDashboardsToInclude.map((d) => ({ id: d.id, name: d.fileName })), - }); - selectedFiles.push(...contextDashboardsToInclude); - } else { - console.info('[File Selection] No context dashboards to include'); - } - - // 2. Second priority: Dashboards from current session that contain modified metrics - if (dashboardsToInclude.size > 0) { - const affectedDashboards = dashboards.filter((d) => dashboardsToInclude.has(d.id)); - selectedFiles.push(...affectedDashboards); - } - - // 3. Third priority: Other dashboards that were directly created/modified - const otherDashboards = dashboards.filter((d) => !dashboardsToInclude.has(d.id)); - selectedFiles.push(...otherDashboards); - - // 4. Always include all reports (reports are standalone documents) - selectedFiles.push(...reports); - - // 5. Determine which metrics to include - if (selectedFiles.length > 0) { - // Check if we have any dashboards in the selection - const hasDashboards = selectedFiles.some((f) => f.fileType === 'dashboard'); - - if (hasDashboards) { - // 2. Standalone metrics that are NOT already represented in selected dashboards - const metricsInDashboards = new Set(); - - // Check metrics in session dashboards - for (const dashboard of selectedFiles.filter((f) => f.ymlContent)) { - if (dashboard.ymlContent) { - const metricIds = extractMetricIdsFromDashboard(dashboard.ymlContent); - for (const id of metricIds) { - metricsInDashboards.add(id); - } - } - } - - // Check metrics in context dashboards - if (dashboardContext) { - for (const dashboard of selectedFiles) { - const contextDashboard = dashboardContext.find((d) => d.id === dashboard.id); - if (contextDashboard) { - for (const metricId of contextDashboard.metricIds) { - metricsInDashboards.add(metricId); - } - } - } - } - - // Include standalone metrics (not in any returned dashboard) - // Apply priority logic: when dashboards are present, exclude standalone created metrics - const standaloneMetrics = metrics.filter((m) => !metricsInDashboards.has(m.id)); - - // Check if any standalone metrics are the result of deduplication - const originalMetrics = files.filter((f) => f.fileType === 'metric'); - const hasDeduplicatedMetrics = standaloneMetrics.some((metric) => { - const duplicates = originalMetrics.filter((m) => m.id === metric.id); - return duplicates.length > 1; - }); - - if (hasDeduplicatedMetrics) { - // Include all standalone metrics when deduplication occurred - selectedFiles.push(...standaloneMetrics); - } else { - const standaloneModifiedMetrics = standaloneMetrics.filter( - (m) => m.operation === 'modified' - ); - selectedFiles.push(...standaloneModifiedMetrics); - } - } else { - // No dashboards selected, include all metrics - selectedFiles.push(...metrics); - } - } else { - // No dashboards selected, just return metrics - selectedFiles.push(...metrics); - } - - // Apply final deduplication to handle any remaining duplicates - const finalSelection = deduplicateFilesByVersion(selectedFiles); - - console.info('[File Selection] Final selection after deduplication:', { - totalSelected: finalSelection.length, - selectedFiles: finalSelection.map((f) => ({ - id: f.id, - type: f.fileType, - name: f.fileName, - operation: f.operation, - version: f.versionNumber || 1, - })), - }); - - return finalSelection; -} - -/** - * Create file response messages for selected files - */ -export function createFileResponseMessages(files: ExtractedFile[]): ChatMessageResponseMessage[] { - return files.map((file) => ({ - id: file.id, // Use the actual file ID instead of generating a new UUID - type: 'file' as const, - file_type: file.fileType, - file_name: file.fileName, - version_number: file.versionNumber || 1, // Use the actual version number from the file - filter_version_id: null, - metadata: [ - { - status: 'completed' as const, - message: `${file.fileType === 'dashboard' ? 'Dashboard' : file.fileType === 'report' ? 'Report' : 'Metric'} ${file.operation || 'created'} successfully`, - timestamp: Date.now(), - }, - ], - })); -} diff --git a/packages/ai/src/tools/visualization-tools/dashboards/create-dashboards-tool/create-dashboards-execute.ts b/packages/ai/src/tools/visualization-tools/dashboards/create-dashboards-tool/create-dashboards-execute.ts index 9d68da124..77937a141 100644 --- a/packages/ai/src/tools/visualization-tools/dashboards/create-dashboards-tool/create-dashboards-execute.ts +++ b/packages/ai/src/tools/visualization-tools/dashboards/create-dashboards-tool/create-dashboards-execute.ts @@ -161,8 +161,8 @@ async function processDashboardFile( const id = dashboardId || randomUUID(); // Collect all metric IDs from rows if they exist - const metricIds: string[] = dashboard.config.rows - ? dashboard.config.rows.flatMap((row) => row.items).map((item) => item.id) + const metricIds: string[] = dashboard.rows + ? dashboard.rows.flatMap((row) => row.items).map((item) => item.id) : []; // Validate metric IDs if any exist @@ -190,7 +190,7 @@ async function processDashboardFile( created_at: new Date().toISOString(), updated_at: new Date().toISOString(), version_number: 1, - content: dashboard.config, // Store the DashboardConfig directly + content: { rows: dashboard.rows }, // Extract the config properties }; return { @@ -374,8 +374,8 @@ const createDashboardFiles = wrapTraced( // Create associations between metrics and dashboards for (const sp of successfulProcessing) { - const metricIds: string[] = sp.dashboard.config.rows - ? sp.dashboard.config.rows.flatMap((row) => row.items).map((item) => item.id) + const metricIds: string[] = sp.dashboard.rows + ? sp.dashboard.rows.flatMap((row) => row.items).map((item) => item.id) : []; if (metricIds.length > 0) { diff --git a/packages/ai/src/tools/visualization-tools/dashboards/helpers/dashboard-tool-description.txt b/packages/ai/src/tools/visualization-tools/dashboards/helpers/dashboard-tool-description.txt index 4ddaff0a7..f674ed248 100644 --- a/packages/ai/src/tools/visualization-tools/dashboards/helpers/dashboard-tool-description.txt +++ b/packages/ai/src/tools/visualization-tools/dashboards/helpers/dashboard-tool-description.txt @@ -12,20 +12,20 @@ Creates dashboard configuration files with YAML content following the dashboard # - id: 1 # Required row ID (integer) # items: # - id: metric-uuid-1 # UUIDv4 of an existing metric, NO quotes -# column_sizes: [12] # Required - must sum to exactly 12 +# columnSizes: [12] # Required - must sum to exactly 12 # - id: 2 # REQUIRED # items: # - id: metric-uuid-2 # - id: metric-uuid-3 -# column_sizes: +# columnSizes: # - 6 # - 6 # # Rules: # 1. Each row can have up to 4 items # 2. Each row must have a unique ID -# 3. column_sizes is required and must specify the width for each item -# 4. Sum of column_sizes in a row must be exactly 12 +# 3. columnSizes is required and must specify the width for each item +# 4. Sum of columnSizes in a row must be exactly 12 # 5. Each column size must be at least 3 # 6. All arrays should follow the YML array syntax using `-` and should NOT USE `[]` formatting. # 7. Don't use comments. The ones in the example are just for explanation @@ -65,7 +65,7 @@ properties: description: UUIDv4 identifier of an existing metric required: - id - column_sizes: + columnSizes: type: array description: Required array of column sizes (must sum to exactly 12) items: @@ -75,7 +75,7 @@ properties: required: - id - items - - column_sizes + - columnSizes required: - name - description @@ -84,9 +84,9 @@ required: **Key Requirements:** - `name`: Dashboard title (no underscores, descriptive name) - `description`: Natural language explanation of the dashboard's purpose and contents -- `rows`: Array of row objects, each with unique ID, items (metric UUIDs), and column_sizes +- `rows`: Array of row objects, each with unique ID, items (metric UUIDs), and columnSizes - Each row must have 1-4 items maximum -- `column_sizes` must sum to exactly 12 and each size must be >= 3 +- `columnSizes` must sum to exactly 12 and each size must be >= 3 - All metric IDs must be valid UUIDs of existing metrics - Row IDs must be unique integers (typically 1, 2, 3, etc.) @@ -104,13 +104,13 @@ rows: - id: 1 items: - id: 550e8400-e29b-41d4-a716-446655440001 - column_sizes: + columnSizes: - 12 - id: 2 items: - id: 550e8400-e29b-41d4-a716-446655440002 - id: 550e8400-e29b-41d4-a716-446655440003 - column_sizes: + columnSizes: - 6 - 6 - id: 3 @@ -118,7 +118,7 @@ rows: - id: 550e8400-e29b-41d4-a716-446655440004 - id: 550e8400-e29b-41d4-a716-446655440005 - id: 550e8400-e29b-41d4-a716-446655440006 - column_sizes: + columnSizes: - 4 - 4 - 4 diff --git a/packages/server-shared/src/chats/chat-message.types.test.ts b/packages/server-shared/src/chats/chat-message.types.test.ts index b1f3cd2fe..04bc97eb9 100644 --- a/packages/server-shared/src/chats/chat-message.types.test.ts +++ b/packages/server-shared/src/chats/chat-message.types.test.ts @@ -1,6 +1,6 @@ +import { ReasoningMessageSchema, ResponseMessageSchema } from '@buster/database'; import { describe, expect, it } from 'vitest'; import { ChatMessageSchema } from './chat-message.types'; -import { ReasoningMessageSchema, ResponseMessageSchema } from '@buster/database'; describe('ChatMessageSchema', () => { it('should parse a valid complete chat message', () => { diff --git a/packages/server-shared/src/dashboards/dashboard.types.ts b/packages/server-shared/src/dashboards/dashboard.types.ts index c5b350f0c..81faf9c64 100644 --- a/packages/server-shared/src/dashboards/dashboard.types.ts +++ b/packages/server-shared/src/dashboards/dashboard.types.ts @@ -36,11 +36,12 @@ export const DashboardSchema = z.object({ file_name: z.string(), }); -export const DashboardYmlSchema = z.object({ - name: z.string(), - description: z.string(), - config: DashboardConfigSchema, -}); +export const DashboardYmlSchema = z + .object({ + name: z.string(), + description: z.string(), + }) + .merge(DashboardConfigSchema); // Export inferred types export type DashboardConfig = z.infer;