diff --git a/.gitignore b/.gitignore index 1170717..4a3e84c 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,6 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +.roo/ + diff --git a/package-lock.json b/package-lock.json index b8dce20..b39664a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@executeautomation/database-server", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@executeautomation/database-server", - "version": "1.0.2", + "version": "1.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.9.0", diff --git a/src/handlers/toolHandlers.ts b/src/handlers/toolHandlers.ts index 463bcc3..dbf9d26 100644 --- a/src/handlers/toolHandlers.ts +++ b/src/handlers/toolHandlers.ts @@ -3,7 +3,8 @@ import { formatErrorResponse } from '../utils/formatUtils.js'; // Import all tool implementations import { readQuery, writeQuery, exportQuery } from '../tools/queryTools.js'; import { createTable, alterTable, dropTable, listTables, describeTable } from '../tools/schemaTools.js'; -import { appendInsight, listInsights } from '../tools/insightTools.js'; +import { appendInsight, generatePlotlyChart, listInsights } from '../tools/insightTools.js'; +import { PlotlyChartConfig } from '../types/index.js'; /** * Handle listing available tools @@ -118,6 +119,66 @@ export function handleListTools() { properties: {}, }, }, + { + name: 'generate_plotly_chart', + description: 'Convert query results or DataFrame into interactive Plotly chart JSON', + inputSchema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { type: 'object' }, + description: 'Array of data objects (rows from query result or DataFrame)' + }, + chartType: { + type: 'string', + enum: ['bar', 'line', 'pie', 'scatter', 'histogram', 'box', 'heatmap'], + description: 'Type of chart to generate' + }, + xColumn: { + type: 'string', + description: 'Column name for X-axis (required for bar, line, scatter, histogram charts)' + }, + yColumn: { + type: 'string', + description: 'Column name for Y-axis (required for bar, line, scatter charts)' + }, + valueColumn: { + type: 'string', + description: 'Column name for values (required for pie charts)' + }, + labelColumn: { + type: 'string', + description: 'Column name for labels (required for pie charts)' + }, + title: { + type: 'string', + description: 'Chart title' + }, + colorColumn: { + type: 'string', + description: 'Column name for color grouping (optional)' + }, + aggregation: { + type: 'string', + enum: ['sum', 'avg', 'count', 'min', 'max', 'none'], + description: 'Aggregation method for grouped data', + default: 'none' + }, + width: { + type: 'number', + description: 'Chart width in pixels', + default: 800 + }, + height: { + type: 'number', + description: 'Chart height in pixels', + default: 600 + } + }, + required: ['data', 'chartType'] + } + }, ], }; } @@ -133,34 +194,88 @@ export async function handleToolCall(name: string, args: any) { switch (name) { case "read_query": return await readQuery(args.query); - + case "write_query": return await writeQuery(args.query); - + case "create_table": return await createTable(args.query); - + case "alter_table": return await alterTable(args.query); - + case "drop_table": return await dropTable(args.table_name, args.confirm); - + case "export_query": return await exportQuery(args.query, args.format); - + case "list_tables": return await listTables(); - + case "describe_table": return await describeTable(args.table_name); - + case "append_insight": return await appendInsight(args.insight); - + case "list_insights": return await listInsights(); - + case 'generate_plotly_chart': { + const config = args as unknown as PlotlyChartConfig; + + if (!config?.data || !config?.chartType) { + return { + content: [{ + type: 'text', + text: 'Missing required chart config: data and chartType are required.' + }], + isError: true + }; + } + + // Validate required fields based on chart type + const missingFields = []; + if (['bar', 'line', 'scatter'].includes(config.chartType)) { + if (!config.xColumn) missingFields.push('xColumn'); + if (!config.yColumn) missingFields.push('yColumn'); + } else if (config.chartType === 'pie') { + if (!config.valueColumn) missingFields.push('valueColumn'); + if (!config.labelColumn) missingFields.push('labelColumn'); + } else if (config.chartType === 'histogram') { + if (!config.xColumn) missingFields.push('xColumn'); + } + + if (missingFields.length > 0) { + return { + content: [{ + type: 'text', + text: `Missing required fields for ${config.chartType} chart: ${missingFields.join(', ')}.` + }], + isError: true + }; + } + + try { + const chartJson = await generatePlotlyChart(config); + return { + content: [{ + type: 'text', + text: JSON.stringify(chartJson, null, 2) + }] + }; + } catch (error) { + return { + content: [{ + type: 'text', + text: `Error generating chart: ${error}` + }], + isError: true + }; + } + }; + + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/tools/insightTools.ts b/src/tools/insightTools.ts index 0221da1..585e406 100644 --- a/src/tools/insightTools.ts +++ b/src/tools/insightTools.ts @@ -1,5 +1,7 @@ import { dbAll, dbExec, dbRun } from '../db/index.js'; -import { formatSuccessResponse } from '../utils/formatUtils.js'; +import { PlotlyChartConfig } from '../types/index.js'; +import { formatSuccessResponse, formatSuccessResponseHTML } from '../utils/formatUtils.js'; +import { aggregateData } from '../utils/helper.js'; /** * Add a business insight to the memo @@ -61,4 +63,155 @@ export async function listInsights() { } catch (error: any) { throw new Error(`Error listing insights: ${error.message}`); } -} \ No newline at end of file +} + +export async function generatePlotlyChart(config: PlotlyChartConfig): Promise { + const { + data, + chartType, + xColumn, + yColumn, + valueColumn, + labelColumn, + title, + colorColumn, + aggregation = 'none', + width = 800, + height = 600 + } = config; + + if (!data || data.length === 0) { + throw new Error('No data provided for chart generation'); + } + + let processedData = [...data]; + + if (aggregation && aggregation !== 'none' && xColumn) { + const targetColumn = yColumn || valueColumn; + if (targetColumn) { + processedData = aggregateData(data, xColumn, targetColumn, aggregation, colorColumn); + } + } + + const layout: any = { + title: title || `${chartType.charAt(0).toUpperCase() + chartType.slice(1)} Chart`, + width, + height + }; + + let chartData: any[] = []; + + try { + switch (chartType) { + case 'bar': + chartData = [{ + type: 'bar', + x: processedData.map(row => row[xColumn!]), + y: processedData.map(row => row[yColumn!]), + marker: colorColumn ? { color: processedData.map(row => row[colorColumn]) } : undefined + }]; + layout.xaxis = { title: xColumn }; + layout.yaxis = { title: yColumn }; + break; + + case 'line': + chartData = [{ + type: 'scatter', + mode: 'lines+markers', + x: processedData.map(row => row[xColumn!]), + y: processedData.map(row => row[yColumn!]), + line: colorColumn ? { color: processedData.map(row => row[colorColumn]) } : undefined + }]; + layout.xaxis = { title: xColumn }; + layout.yaxis = { title: yColumn }; + break; + + case 'pie': + chartData = [{ + type: 'pie', + labels: processedData.map(row => row[labelColumn!]), + values: processedData.map(row => row[valueColumn!]) + }]; + break; + + case 'scatter': + chartData = [{ + type: 'scatter', + mode: 'markers', + x: processedData.map(row => row[xColumn!]), + y: processedData.map(row => row[yColumn!]), + marker: colorColumn ? { color: processedData.map(row => row[colorColumn]), colorscale: 'Viridis' } : undefined + }]; + layout.xaxis = { title: xColumn }; + layout.yaxis = { title: yColumn }; + break; + + case 'histogram': + chartData = [{ + type: 'histogram', + x: processedData.map(row => row[xColumn!]).filter(Boolean) + }]; + layout.xaxis = { title: xColumn }; + layout.yaxis = { title: 'Frequency' }; + break; + + case 'box': + chartData = [{ + type: 'box', + y: processedData.map(row => row[xColumn!]).filter(Boolean), + name: xColumn + }]; + layout.yaxis = { title: xColumn }; + break; + + case 'heatmap': + if (!xColumn || !yColumn || !valueColumn) { + throw new Error('Heatmap requires xColumn, yColumn, and valueColumn'); + } + + const xValues = [...new Set(processedData.map(row => row[xColumn]))]; + const yValues = [...new Set(processedData.map(row => row[yColumn]))]; + + const matrix = yValues.map(y => + xValues.map(x => { + const match = processedData.find(row => row[xColumn] === x && row[yColumn] === y); + return match ? match[valueColumn] : 0; + }) + ); + + chartData = [{ + type: 'heatmap', + x: xValues, + y: yValues, + z: matrix, + colorscale: 'Viridis' + }]; + layout.xaxis = { title: xColumn }; + layout.yaxis = { title: yColumn }; + break; + + default: + throw new Error(`Unsupported chart type: ${chartType}`); + } + + // Return as full HTML string + return formatSuccessResponseHTML(` + + + + + + + +
+ + + + `); + + } catch (error) { + throw new Error(`Failed to generate ${chartType} chart: ${error}`); + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..265bf27 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,13 @@ +export interface PlotlyChartConfig { + data: any[]; + chartType: 'bar' | 'line' | 'pie' | 'scatter' | 'histogram' | 'box' | 'heatmap'; + xColumn?: string; + yColumn?: string; + valueColumn?: string; + labelColumn?: string; + title?: string; + colorColumn?: string; + aggregation?: 'sum' | 'avg' | 'count' | 'min' | 'max' | 'none'; + width?: number; + height?: number; +} \ No newline at end of file diff --git a/src/utils/formatUtils.ts b/src/utils/formatUtils.ts index cbf2bed..6f2cbc4 100644 --- a/src/utils/formatUtils.ts +++ b/src/utils/formatUtils.ts @@ -58,4 +58,13 @@ export function formatSuccessResponse(data: any): { content: Array<{type: string }], isError: false }; -} \ No newline at end of file +} +export function formatSuccessResponseHTML(html: string): { content: Array<{ type: string, text: string }>, isError: boolean } { + return { + content: [{ + type: "html", + text: html + }], + isError: false + }; +} diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..13a8c48 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,49 @@ +export function aggregateData( + data: any[], + groupBy: string, + valueColumn?: string, + aggregation?: string, + colorColumn?: string +): any[] { + const grouped = data.reduce((acc, row) => { + const key = colorColumn ? `${row[groupBy]}_${row[colorColumn]}` : row[groupBy]; + if (!acc[key]) { + acc[key] = { + [groupBy]: row[groupBy], + values: [], + ...(colorColumn && { [colorColumn]: row[colorColumn] }) + }; + } + acc[key].values.push(parseFloat(row[valueColumn || 0]) || 0); + return acc; + }, {} as any); + + return Object.values(grouped).map((group: any) => { + let aggregatedValue: number; + switch (aggregation) { + case 'sum': + aggregatedValue = group.values.reduce((sum: number, val: number) => sum + val, 0); + break; + case 'avg': + aggregatedValue = group.values.reduce((sum: number, val: number) => sum + val, 0) / group.values.length; + break; + case 'count': + aggregatedValue = group.values.length; + break; + case 'min': + aggregatedValue = Math.min(...group.values); + break; + case 'max': + aggregatedValue = Math.max(...group.values); + break; + default: + aggregatedValue = group.values[0]; + } + + return { + [groupBy]: group[groupBy], + [valueColumn || 0]: aggregatedValue, + ...(colorColumn && { [colorColumn]: group[colorColumn] }) + }; + }); +}