ok need to debug dash and metrics

This commit is contained in:
dal 2025-08-15 15:57:35 -06:00
parent c476aebd47
commit 3f3b9233f3
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
8 changed files with 568 additions and 1659 deletions

View File

@ -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([]);
});
});
});

View File

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>[]) {
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<string, Set<string>>();
// First, map dashboard names to their IDs from the files
const dashboardNameToId = new Map<string, string>();
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<string>();
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<string, ExtractedFile>();
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<string, unknown>;
// Handle files array in input
if (inputObj.files && Array.isArray(inputObj.files)) {
for (const file of inputObj.files as Record<string, unknown>[]) {
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<string, unknown>;
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
],
};
});
}
}

View File

@ -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);
});
});
});

View File

@ -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<string, ExtractedFile>();
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<string, string[]>();
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<string>();
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<string>();
// 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(),
},
],
}));
}

View File

@ -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) {

View File

@ -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

View File

@ -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', () => {

View File

@ -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<typeof DashboardConfigSchema>;