@onmartech/metabase-ai-assistant 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.env.example +38 -0
  2. package/LICENSE +201 -0
  3. package/README.md +364 -0
  4. package/README_MCP.md +279 -0
  5. package/package.json +99 -0
  6. package/src/ai/assistant.js +982 -0
  7. package/src/cli/interactive.js +500 -0
  8. package/src/database/connection-manager.js +350 -0
  9. package/src/database/direct-client.js +686 -0
  10. package/src/index.js +162 -0
  11. package/src/mcp/handlers/actions.js +213 -0
  12. package/src/mcp/handlers/ai.js +207 -0
  13. package/src/mcp/handlers/analytics.js +1647 -0
  14. package/src/mcp/handlers/cards.js +1544 -0
  15. package/src/mcp/handlers/collections.js +244 -0
  16. package/src/mcp/handlers/dashboard.js +207 -0
  17. package/src/mcp/handlers/dashboard_direct.js +292 -0
  18. package/src/mcp/handlers/database.js +322 -0
  19. package/src/mcp/handlers/docs.js +399 -0
  20. package/src/mcp/handlers/index.js +35 -0
  21. package/src/mcp/handlers/metadata.js +190 -0
  22. package/src/mcp/handlers/questions.js +134 -0
  23. package/src/mcp/handlers/schema.js +1699 -0
  24. package/src/mcp/handlers/sql.js +559 -0
  25. package/src/mcp/handlers/users.js +251 -0
  26. package/src/mcp/job-store.js +199 -0
  27. package/src/mcp/server.js +428 -0
  28. package/src/mcp/tool-registry.js +3244 -0
  29. package/src/mcp/tool-router.js +149 -0
  30. package/src/metabase/client.js +737 -0
  31. package/src/metabase/metadata-client.js +1852 -0
  32. package/src/utils/activity-logger.js +489 -0
  33. package/src/utils/cache.js +176 -0
  34. package/src/utils/config.js +131 -0
  35. package/src/utils/definition-tables.js +938 -0
  36. package/src/utils/file-operations.js +496 -0
  37. package/src/utils/logger.js +45 -0
  38. package/src/utils/parametric-questions.js +627 -0
  39. package/src/utils/response-optimizer.js +190 -0
  40. package/src/utils/sql-sanitizer.js +97 -0
@@ -0,0 +1,982 @@
1
+ import { Anthropic } from '@anthropic-ai/sdk';
2
+ import OpenAI from 'openai';
3
+ import { logger } from '../utils/logger.js';
4
+ import { FileOperations } from '../utils/file-operations.js';
5
+
6
+ export class MetabaseAIAssistant {
7
+ constructor(config) {
8
+ this.metabaseClient = config.metabaseClient;
9
+ this.aiProvider = config.aiProvider || 'anthropic';
10
+ this.fileOps = new FileOperations(config.fileOptions);
11
+
12
+ if (this.aiProvider === 'anthropic') {
13
+ this.ai = new Anthropic({
14
+ apiKey: config.anthropicApiKey
15
+ });
16
+ } else {
17
+ this.ai = new OpenAI({
18
+ apiKey: config.openaiApiKey
19
+ });
20
+ }
21
+ }
22
+
23
+ async analyzeRequest(userRequest) {
24
+ const prompt = `
25
+ Analyze the following user request for Metabase operations.
26
+ Determine what type of operation is needed and extract relevant parameters.
27
+
28
+ User Request: "${userRequest}"
29
+
30
+ Respond with a JSON object containing:
31
+ - operation_type: (model|question|sql|metric|dashboard|segment)
32
+ - action: (create|update|query|analyze)
33
+ - parameters: relevant extracted parameters
34
+ - suggested_approach: brief description of recommended approach
35
+ `;
36
+
37
+ const response = await this.getAIResponse(prompt);
38
+ return JSON.parse(response);
39
+ }
40
+
41
+ async generateSQL(description, schema) {
42
+ const prompt = `
43
+ Generate SQL query based on the following description:
44
+ "${description}"
45
+
46
+ Available schema:
47
+ ${JSON.stringify(schema, null, 2)}
48
+
49
+ Requirements:
50
+ - Use proper SQL syntax
51
+ - Include appropriate JOINs if needed
52
+ - Add meaningful aliases
53
+ - Consider performance optimization
54
+
55
+ Return only the SQL query without explanation.
56
+ `;
57
+
58
+ return await this.getAIResponse(prompt);
59
+ }
60
+
61
+ async suggestVisualization(data, questionType) {
62
+ // Analyze data structure first
63
+ const dataAnalysis = this.analyzeDataStructure(data);
64
+
65
+ const prompt = `
66
+ Based on the following data structure and question type, suggest the best visualization:
67
+
68
+ Question Type: ${questionType}
69
+ Data Analysis: ${JSON.stringify(dataAnalysis, null, 2)}
70
+ Data Sample: ${JSON.stringify(data.slice(0, 3), null, 2)}
71
+
72
+ Available visualization types:
73
+ - table: For detailed data viewing
74
+ - bar: For categorical comparisons
75
+ - line: For trends over time
76
+ - area: For cumulative trends
77
+ - pie: For part-to-whole relationships (max 10 categories)
78
+ - number: For single metrics/KPIs
79
+ - gauge: For metrics with targets
80
+ - scatter: For correlation analysis
81
+ - funnel: For conversion analysis
82
+ - combo: For multiple metrics
83
+ - waterfall: For incremental changes
84
+ - map: For geographical data
85
+
86
+ Respond with JSON containing:
87
+ - visualization_type: best chart type from above
88
+ - settings: detailed visualization settings object
89
+ - reasoning: brief explanation of why this visualization was chosen
90
+ - alternative_options: array of 2-3 other suitable options
91
+ `;
92
+
93
+ const response = await this.getAIResponse(prompt);
94
+ return JSON.parse(response);
95
+ }
96
+
97
+ analyzeDataStructure(data) {
98
+ if (!data || data.length === 0) {
99
+ return { isEmpty: true };
100
+ }
101
+
102
+ const sample = data[0];
103
+ const columns = Object.keys(sample);
104
+ const analysis = {
105
+ columnCount: columns.length,
106
+ rowCount: data.length,
107
+ hasTimeColumn: false,
108
+ hasNumericColumns: false,
109
+ hasCategoricalColumns: false,
110
+ hasGeographicColumns: false,
111
+ numericColumns: [],
112
+ categoricalColumns: [],
113
+ timeColumns: [],
114
+ geographicColumns: []
115
+ };
116
+
117
+ // Analyze each column
118
+ columns.forEach(col => {
119
+ const values = data.map(row => row[col]).filter(v => v !== null && v !== undefined);
120
+
121
+ if (values.length === 0) return;
122
+
123
+ // Check for time/date columns
124
+ if (this.isTimeColumn(col, values)) {
125
+ analysis.hasTimeColumn = true;
126
+ analysis.timeColumns.push(col);
127
+ }
128
+ // Check for geographic columns
129
+ else if (this.isGeographicColumn(col, values)) {
130
+ analysis.hasGeographicColumns = true;
131
+ analysis.geographicColumns.push(col);
132
+ }
133
+ // Check for numeric columns
134
+ else if (this.isNumericColumn(values)) {
135
+ analysis.hasNumericColumns = true;
136
+ analysis.numericColumns.push(col);
137
+ }
138
+ // Otherwise categorical
139
+ else {
140
+ analysis.hasCategoricalColumns = true;
141
+ analysis.categoricalColumns.push({
142
+ name: col,
143
+ uniqueValues: [...new Set(values)].length
144
+ });
145
+ }
146
+ });
147
+
148
+ return analysis;
149
+ }
150
+
151
+ isTimeColumn(columnName, values) {
152
+ const timePattern = /date|time|created|updated|timestamp/i;
153
+ if (timePattern.test(columnName)) return true;
154
+
155
+ // Check if values look like dates
156
+ const sampleValue = values[0];
157
+ if (typeof sampleValue === 'string') {
158
+ return !isNaN(Date.parse(sampleValue));
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ isGeographicColumn(columnName, values) {
165
+ const geoPattern = /country|state|city|region|location|lat|lng|longitude|latitude/i;
166
+ return geoPattern.test(columnName);
167
+ }
168
+
169
+ isNumericColumn(values) {
170
+ const numericValues = values.filter(v => typeof v === 'number' && !isNaN(v));
171
+ return numericValues.length > values.length * 0.8; // 80% numeric
172
+ }
173
+
174
+ async createModel(description, databaseId, options = {}) {
175
+ logger.info(`Creating model for: ${description}`);
176
+
177
+ try {
178
+ // Get comprehensive database schema
179
+ const schemas = await this.metabaseClient.getDatabaseSchemas(databaseId);
180
+ const allTables = [];
181
+
182
+ for (const schema of schemas) {
183
+ const tables = await this.metabaseClient.getDatabaseTables(databaseId, schema);
184
+ allTables.push(...tables);
185
+ }
186
+
187
+ // Enhanced model creation prompt
188
+ const modelPrompt = `
189
+ Create a comprehensive Metabase model based on the description: "${description}"
190
+
191
+ Available database schema:
192
+ ${JSON.stringify(allTables, null, 2)}
193
+
194
+ Requirements:
195
+ 1. Generate optimized SQL query for the model
196
+ 2. Include proper JOINs for related tables
197
+ 3. Add meaningful column aliases
198
+ 4. Consider indexing and performance
199
+ 5. Include data validation where appropriate
200
+
201
+ Respond with JSON containing:
202
+ - sql: the SQL query
203
+ - model_name: descriptive model name
204
+ - description: detailed model description
205
+ - suggested_fields: array of important fields with display names
206
+ - relationships: suggested relationships with other models
207
+ - semantic_type: semantic types for key fields
208
+ `;
209
+
210
+ const modelSpec = JSON.parse(await this.getAIResponse(modelPrompt));
211
+
212
+ // Create the model with enhanced configuration
213
+ const model = await this.metabaseClient.createModel({
214
+ name: modelSpec.model_name || this.generateName(description, 'Model'),
215
+ description: modelSpec.description || description,
216
+ database_id: databaseId,
217
+ collection_id: options.collection_id,
218
+ dataset_query: {
219
+ database: databaseId,
220
+ type: 'native',
221
+ native: {
222
+ query: modelSpec.sql,
223
+ template_tags: {}
224
+ }
225
+ },
226
+ display: 'table',
227
+ visualization_settings: {
228
+ 'table.pivot_column': null,
229
+ 'table.cell_column': null
230
+ }
231
+ });
232
+
233
+ // Add semantic types and field descriptions if model creation was successful
234
+ if (model.id && modelSpec.suggested_fields) {
235
+ await this.enhanceModelFields(model.id, modelSpec.suggested_fields);
236
+ }
237
+
238
+ logger.info(`Enhanced model created: ${model.id} - ${model.name}`);
239
+ return {
240
+ model,
241
+ suggestions: {
242
+ fields: modelSpec.suggested_fields,
243
+ relationships: modelSpec.relationships
244
+ }
245
+ };
246
+
247
+ } catch (error) {
248
+ logger.error(`Failed to create model: ${error.message}`);
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ async enhanceModelFields(modelId, suggestedFields) {
254
+ try {
255
+ // Get model fields
256
+ const modelFields = await this.metabaseClient.getModelFields(modelId);
257
+
258
+ // Update field properties based on suggestions
259
+ for (const suggestion of suggestedFields) {
260
+ const field = modelFields.find(f =>
261
+ f.name.toLowerCase() === suggestion.field_name.toLowerCase()
262
+ );
263
+
264
+ if (field) {
265
+ await this.metabaseClient.updateField(field.id, {
266
+ display_name: suggestion.display_name,
267
+ description: suggestion.description,
268
+ semantic_type: suggestion.semantic_type,
269
+ visibility_type: suggestion.visibility_type || 'normal'
270
+ });
271
+ }
272
+ }
273
+
274
+ logger.info(`Enhanced ${suggestedFields.length} fields for model ${modelId}`);
275
+ } catch (error) {
276
+ logger.warn(`Failed to enhance model fields: ${error.message}`);
277
+ }
278
+ }
279
+
280
+ async createQuestion(description, databaseId, collectionId) {
281
+ logger.info(`Creating question for: ${description}`);
282
+
283
+ // Get database schema
284
+ const tables = await this.metabaseClient.getDatabaseTables(databaseId);
285
+
286
+ // Generate SQL
287
+ const sql = await this.generateSQL(description, tables);
288
+
289
+ // Execute query to get sample data
290
+ const result = await this.metabaseClient.executeNativeQuery(databaseId, sql + ' LIMIT 10');
291
+
292
+ // Suggest visualization
293
+ const vizSuggestion = await this.suggestVisualization(result.data.rows, description);
294
+
295
+ // Create question
296
+ const question = await this.metabaseClient.createSQLQuestion(
297
+ this.generateName(description, 'Question'),
298
+ description,
299
+ databaseId,
300
+ sql,
301
+ collectionId
302
+ );
303
+
304
+ // Update with visualization settings
305
+ if (vizSuggestion.visualization_type !== 'table') {
306
+ await this.metabaseClient.updateQuestion(question.id, {
307
+ display: vizSuggestion.visualization_type,
308
+ visualization_settings: vizSuggestion.settings
309
+ });
310
+ }
311
+
312
+ logger.info(`Question created: ${question.id}`);
313
+ return question;
314
+ }
315
+
316
+ async createMetric(description, tableId, options = {}) {
317
+ logger.info(`Creating metric for: ${description}`);
318
+
319
+ try {
320
+ // Get table schema for better context
321
+ const tableInfo = await this.metabaseClient.getTable(tableId);
322
+ const fields = await this.metabaseClient.getTableFields(tableId);
323
+
324
+ const metricPrompt = `
325
+ Create a comprehensive metric definition based on: "${description}"
326
+
327
+ Available table: ${tableInfo.display_name || tableInfo.name}
328
+ Available fields: ${JSON.stringify(fields.map(f => ({
329
+ name: f.name,
330
+ display_name: f.display_name,
331
+ base_type: f.base_type,
332
+ semantic_type: f.semantic_type
333
+ })), null, 2)}
334
+
335
+ Create a metric that:
336
+ 1. Uses appropriate aggregation function
337
+ 2. Includes meaningful filters if needed
338
+ 3. Has clear business meaning
339
+ 4. Follows metric best practices
340
+
341
+ Respond with JSON containing:
342
+ - name: clear, business-friendly metric name
343
+ - description: detailed description explaining what it measures
344
+ - aggregation: aggregation definition array
345
+ - filter: filter conditions (if any)
346
+ - field_id: the field ID to aggregate on
347
+ - semantic_type: metric semantic type
348
+ - points_of_interest: key insights this metric provides
349
+ `;
350
+
351
+ const metricDef = JSON.parse(await this.getAIResponse(metricPrompt));
352
+
353
+ // Find the field ID if specified by name
354
+ let fieldId = metricDef.field_id;
355
+ if (!fieldId && metricDef.field_name) {
356
+ const field = fields.find(f =>
357
+ f.name.toLowerCase() === metricDef.field_name.toLowerCase() ||
358
+ f.display_name.toLowerCase() === metricDef.field_name.toLowerCase()
359
+ );
360
+ fieldId = field ? field.id : null;
361
+ }
362
+
363
+ // Build metric definition
364
+ const definition = {
365
+ 'source-table': tableId
366
+ };
367
+
368
+ // Add aggregation
369
+ if (metricDef.aggregation) {
370
+ if (fieldId) {
371
+ definition.aggregation = [metricDef.aggregation[0], ['field', fieldId, null]];
372
+ } else {
373
+ definition.aggregation = metricDef.aggregation;
374
+ }
375
+ }
376
+
377
+ // Add filters if specified
378
+ if (metricDef.filter && metricDef.filter.length > 0) {
379
+ definition.filter = metricDef.filter;
380
+ }
381
+
382
+ const metric = await this.metabaseClient.createMetric({
383
+ name: metricDef.name,
384
+ description: metricDef.description,
385
+ table_id: tableId,
386
+ definition,
387
+ show_in_getting_started: options.featured || false
388
+ });
389
+
390
+ logger.info(`Enhanced metric created: ${metric.id} - ${metric.name}`);
391
+
392
+ return {
393
+ metric,
394
+ insights: metricDef.points_of_interest || [],
395
+ suggested_visualizations: this.suggestMetricVisualizations(metricDef)
396
+ };
397
+
398
+ } catch (error) {
399
+ logger.error(`Failed to create metric: ${error.message}`);
400
+ throw error;
401
+ }
402
+ }
403
+
404
+ suggestMetricVisualizations(metricDef) {
405
+ const suggestions = [];
406
+
407
+ // Single number for KPIs
408
+ suggestions.push({
409
+ type: 'number',
410
+ title: `${metricDef.name} - Current Value`,
411
+ description: 'Display current metric value'
412
+ });
413
+
414
+ // Line chart for trends
415
+ suggestions.push({
416
+ type: 'line',
417
+ title: `${metricDef.name} - Trend Over Time`,
418
+ description: 'Show how metric changes over time'
419
+ });
420
+
421
+ // Gauge for performance metrics
422
+ if (metricDef.semantic_type === 'performance' ||
423
+ metricDef.name.toLowerCase().includes('rate') ||
424
+ metricDef.name.toLowerCase().includes('ratio')) {
425
+ suggestions.push({
426
+ type: 'gauge',
427
+ title: `${metricDef.name} - Performance Gauge`,
428
+ description: 'Show metric with target ranges'
429
+ });
430
+ }
431
+
432
+ return suggestions;
433
+ }
434
+
435
+ async createDashboard(description, questions = [], options = {}) {
436
+ logger.info(`Creating dashboard: ${description}`);
437
+
438
+ try {
439
+ // Analyze questions to understand dashboard type and optimal layout
440
+ const dashboardAnalysis = await this.analyzeDashboardRequirements(description, questions);
441
+
442
+ // Create dashboard with enhanced configuration
443
+ const dashboard = await this.metabaseClient.createDashboard({
444
+ name: dashboardAnalysis.name || this.generateName(description, 'Dashboard'),
445
+ description: dashboardAnalysis.description || description,
446
+ collection_id: options.collection_id,
447
+ parameters: dashboardAnalysis.suggested_filters || []
448
+ });
449
+
450
+ if (questions.length > 0) {
451
+ // Generate intelligent layout
452
+ const layout = await this.generateOptimalLayout(questions, dashboardAnalysis);
453
+
454
+ // Add questions to dashboard with optimized positioning
455
+ for (let i = 0; i < questions.length; i++) {
456
+ const cardLayout = layout[i];
457
+ await this.metabaseClient.addCardToDashboard(
458
+ dashboard.id,
459
+ questions[i].id,
460
+ cardLayout
461
+ );
462
+ }
463
+ }
464
+
465
+ // Add recommended filters if any
466
+ if (dashboardAnalysis.recommended_filters) {
467
+ for (const filter of dashboardAnalysis.recommended_filters) {
468
+ await this.metabaseClient.addDashboardFilter(dashboard.id, filter);
469
+ }
470
+ }
471
+
472
+ logger.info(`Enhanced dashboard created: ${dashboard.id} - ${dashboard.name}`);
473
+
474
+ return {
475
+ dashboard,
476
+ analysis: dashboardAnalysis,
477
+ layout_suggestions: dashboardAnalysis.layout_tips
478
+ };
479
+
480
+ } catch (error) {
481
+ logger.error(`Failed to create dashboard: ${error.message}`);
482
+ throw error;
483
+ }
484
+ }
485
+
486
+ async analyzeDashboardRequirements(description, questions) {
487
+ const questionTypes = questions.map(q => ({
488
+ id: q.id,
489
+ name: q.name,
490
+ display: q.display || 'table',
491
+ description: q.description
492
+ }));
493
+
494
+ const analysisPrompt = `
495
+ Analyze dashboard requirements based on:
496
+ Description: "${description}"
497
+ Questions: ${JSON.stringify(questionTypes, null, 2)}
498
+
499
+ Determine:
500
+ 1. Dashboard type (executive, operational, analytical, marketing, financial)
501
+ 2. Target audience (executives, analysts, managers, end-users)
502
+ 3. Primary purpose (monitoring, analysis, reporting)
503
+ 4. Optimal layout strategy
504
+ 5. Recommended filters
505
+
506
+ Respond with JSON containing:
507
+ - dashboard_type: type classification
508
+ - target_audience: primary users
509
+ - name: improved dashboard name
510
+ - description: enhanced description
511
+ - layout_strategy: (executive-summary|analytical-deep-dive|operational-monitoring|marketing-funnel)
512
+ - recommended_filters: array of useful filters
513
+ - layout_tips: layout optimization suggestions
514
+ `;
515
+
516
+ const response = await this.getAIResponse(analysisPrompt);
517
+ return JSON.parse(response);
518
+ }
519
+
520
+ async generateOptimalLayout(questions, dashboardAnalysis) {
521
+ const GRID_WIDTH = 12;
522
+ const layout = [];
523
+ let currentRow = 0;
524
+ let currentCol = 0;
525
+
526
+ // Layout strategies based on dashboard type
527
+ const strategies = {
528
+ 'executive-summary': this.getExecutiveLayout,
529
+ 'analytical-deep-dive': this.getAnalyticalLayout,
530
+ 'operational-monitoring': this.getOperationalLayout,
531
+ 'marketing-funnel': this.getMarketingLayout
532
+ };
533
+
534
+ const strategy = strategies[dashboardAnalysis.layout_strategy] || this.getDefaultLayout;
535
+ return strategy.call(this, questions);
536
+ }
537
+
538
+ getExecutiveLayout(questions) {
539
+ const layout = [];
540
+ let currentRow = 0;
541
+
542
+ for (let i = 0; i < questions.length; i++) {
543
+ const question = questions[i];
544
+ let cardLayout;
545
+
546
+ // First row: Key metrics (numbers/gauges)
547
+ if (i < 4 && (question.display === 'number' || question.display === 'gauge')) {
548
+ cardLayout = {
549
+ row: 0,
550
+ col: i * 3,
551
+ sizeX: 3,
552
+ sizeY: 3
553
+ };
554
+ }
555
+ // Second row: Main charts
556
+ else if (i >= 4 && i < 6) {
557
+ cardLayout = {
558
+ row: 4,
559
+ col: (i - 4) * 6,
560
+ sizeX: 6,
561
+ sizeY: 5
562
+ };
563
+ }
564
+ // Additional rows: Supporting charts
565
+ else {
566
+ const row = Math.floor((i - 6) / 2) * 5 + 10;
567
+ const col = ((i - 6) % 2) * 6;
568
+ cardLayout = {
569
+ row: row,
570
+ col: col,
571
+ sizeX: 6,
572
+ sizeY: 4
573
+ };
574
+ }
575
+
576
+ layout.push(cardLayout);
577
+ }
578
+
579
+ return layout;
580
+ }
581
+
582
+ getAnalyticalLayout(questions) {
583
+ const layout = [];
584
+ let currentRow = 0;
585
+
586
+ // Analytical layout: Focus on detailed charts
587
+ for (let i = 0; i < questions.length; i++) {
588
+ const question = questions[i];
589
+
590
+ if (question.display === 'table') {
591
+ // Full width for tables
592
+ layout.push({
593
+ row: currentRow,
594
+ col: 0,
595
+ sizeX: 12,
596
+ sizeY: 6
597
+ });
598
+ currentRow += 7;
599
+ } else {
600
+ // Charts take half width
601
+ const col = (i % 2) * 6;
602
+ const row = currentRow + Math.floor(i / 2) * 5;
603
+
604
+ layout.push({
605
+ row: row,
606
+ col: col,
607
+ sizeX: 6,
608
+ sizeY: 5
609
+ });
610
+
611
+ if (i % 2 === 1) currentRow += 6;
612
+ }
613
+ }
614
+
615
+ return layout;
616
+ }
617
+
618
+ getOperationalLayout(questions) {
619
+ // Operational: Compact, monitoring-focused
620
+ return questions.map((question, i) => {
621
+ const row = Math.floor(i / 3) * 4;
622
+ const col = (i % 3) * 4;
623
+
624
+ return {
625
+ row: row,
626
+ col: col,
627
+ sizeX: 4,
628
+ sizeY: 4
629
+ };
630
+ });
631
+ }
632
+
633
+ getMarketingLayout(questions) {
634
+ const layout = [];
635
+
636
+ // Marketing: Funnel-like progression
637
+ for (let i = 0; i < questions.length; i++) {
638
+ if (i < 3) {
639
+ // Top metrics
640
+ layout.push({
641
+ row: 0,
642
+ col: i * 4,
643
+ sizeX: 4,
644
+ sizeY: 3
645
+ });
646
+ } else if (i < 5) {
647
+ // Main funnel charts
648
+ layout.push({
649
+ row: 4,
650
+ col: (i - 3) * 6,
651
+ sizeX: 6,
652
+ sizeY: 5
653
+ });
654
+ } else {
655
+ // Supporting analytics
656
+ const row = Math.floor((i - 5) / 2) * 4 + 10;
657
+ const col = ((i - 5) % 2) * 6;
658
+ layout.push({
659
+ row: row,
660
+ col: col,
661
+ sizeX: 6,
662
+ sizeY: 4
663
+ });
664
+ }
665
+ }
666
+
667
+ return layout;
668
+ }
669
+
670
+ getDefaultLayout(questions) {
671
+ // Default responsive layout
672
+ return questions.map((question, i) => {
673
+ const row = Math.floor(i / 2) * 5;
674
+ const col = (i % 2) * 6;
675
+
676
+ return {
677
+ row: row,
678
+ col: col,
679
+ sizeX: 6,
680
+ sizeY: 4
681
+ };
682
+ });
683
+ }
684
+
685
+ async optimizeQuery(sql) {
686
+ const prompt = `
687
+ Optimize the following SQL query for better performance:
688
+
689
+ ${sql}
690
+
691
+ Provide:
692
+ 1. Optimized query
693
+ 2. List of optimizations applied
694
+ 3. Expected performance improvements
695
+
696
+ Return as JSON with: optimized_sql, optimizations[], improvements
697
+ `;
698
+
699
+ const response = await this.getAIResponse(prompt);
700
+ return JSON.parse(response);
701
+ }
702
+
703
+ async explainQuery(sql) {
704
+ const prompt = `
705
+ Explain the following SQL query in simple terms:
706
+
707
+ ${sql}
708
+
709
+ Provide:
710
+ 1. What the query does
711
+ 2. Tables and relationships used
712
+ 3. Any potential issues or improvements
713
+ `;
714
+
715
+ return await this.getAIResponse(prompt);
716
+ }
717
+
718
+ // Helper methods
719
+ async getAIResponse(prompt) {
720
+ try {
721
+ if (this.aiProvider === 'anthropic') {
722
+ const response = await this.ai.messages.create({
723
+ model: 'claude-3-sonnet-20240229',
724
+ max_tokens: 4000,
725
+ messages: [{ role: 'user', content: prompt }]
726
+ });
727
+ return response.content[0].text;
728
+ } else {
729
+ const response = await this.ai.chat.completions.create({
730
+ model: 'gpt-4-turbo-preview',
731
+ messages: [{ role: 'user', content: prompt }],
732
+ response_format: { type: 'text' }
733
+ });
734
+ return response.choices[0].message.content;
735
+ }
736
+ } catch (error) {
737
+ logger.error('AI response error:', error);
738
+ throw error;
739
+ }
740
+ }
741
+
742
+ generateName(description, type) {
743
+ const words = description.split(' ').slice(0, 5).join(' ');
744
+ return `${words} - ${type} (AI Generated)`;
745
+ }
746
+
747
+ // File Operations
748
+ async exportDashboard(dashboardId, format = 'json') {
749
+ try {
750
+ const dashboard = await this.metabaseClient.getDashboard(dashboardId);
751
+ const result = await this.fileOps.exportDashboard(dashboard, format);
752
+
753
+ logger.info(`Dashboard ${dashboardId} exported to ${result.path}`);
754
+ return result;
755
+ } catch (error) {
756
+ logger.error(`Failed to export dashboard ${dashboardId}:`, error);
757
+ throw error;
758
+ }
759
+ }
760
+
761
+ async exportQuestion(questionId, format = 'json') {
762
+ try {
763
+ const question = await this.metabaseClient.getQuestion(questionId);
764
+ const result = await this.fileOps.exportQuestion(question, format);
765
+
766
+ logger.info(`Question ${questionId} exported to ${result.path}`);
767
+ return result;
768
+ } catch (error) {
769
+ logger.error(`Failed to export question ${questionId}:`, error);
770
+ throw error;
771
+ }
772
+ }
773
+
774
+ async exportModel(modelId, format = 'json') {
775
+ try {
776
+ const model = await this.metabaseClient.getModel(modelId);
777
+ const result = await this.fileOps.exportModel(model, format);
778
+
779
+ logger.info(`Model ${modelId} exported to ${result.path}`);
780
+ return result;
781
+ } catch (error) {
782
+ logger.error(`Failed to export model ${modelId}:`, error);
783
+ throw error;
784
+ }
785
+ }
786
+
787
+ async exportQueryResults(sql, databaseId, filename, format = 'csv') {
788
+ try {
789
+ const results = await this.metabaseClient.executeNativeQuery(databaseId, sql);
790
+ const result = await this.fileOps.exportQueryResults(results, filename, format);
791
+
792
+ logger.info(`Query results exported to ${result.path}`);
793
+ return result;
794
+ } catch (error) {
795
+ logger.error(`Failed to export query results:`, error);
796
+ throw error;
797
+ }
798
+ }
799
+
800
+ async batchExportDashboards(dashboardIds, format = 'json') {
801
+ try {
802
+ const dashboards = await Promise.all(
803
+ dashboardIds.map(id => this.metabaseClient.getDashboard(id))
804
+ );
805
+
806
+ const results = await this.fileOps.batchExport(dashboards, 'dashboard', format);
807
+
808
+ logger.info(`Batch exported ${dashboards.length} dashboards`);
809
+ return results;
810
+ } catch (error) {
811
+ logger.error('Failed to batch export dashboards:', error);
812
+ throw error;
813
+ }
814
+ }
815
+
816
+ async batchExportQuestions(questionIds, format = 'json') {
817
+ try {
818
+ const questions = await Promise.all(
819
+ questionIds.map(id => this.metabaseClient.getQuestion(id))
820
+ );
821
+
822
+ const results = await this.fileOps.batchExport(questions, 'question', format);
823
+
824
+ logger.info(`Batch exported ${questions.length} questions`);
825
+ return results;
826
+ } catch (error) {
827
+ logger.error('Failed to batch export questions:', error);
828
+ throw error;
829
+ }
830
+ }
831
+
832
+ async createBackup() {
833
+ try {
834
+ // Gather all Metabase configuration
835
+ const [databases, collections, dashboards, questions, metrics] = await Promise.all([
836
+ this.metabaseClient.getDatabases(),
837
+ this.metabaseClient.getCollections(),
838
+ this.metabaseClient.getDashboards(),
839
+ this.metabaseClient.getQuestions(),
840
+ this.metabaseClient.getMetrics()
841
+ ]);
842
+
843
+ const config = {
844
+ databases,
845
+ collections,
846
+ dashboards,
847
+ questions,
848
+ metrics
849
+ };
850
+
851
+ const result = await this.fileOps.backupMetabaseConfig(config);
852
+
853
+ logger.info(`Full Metabase backup created: ${result.path}`);
854
+ return result;
855
+ } catch (error) {
856
+ logger.error('Failed to create backup:', error);
857
+ throw error;
858
+ }
859
+ }
860
+
861
+ async restoreFromBackup(backupFilename) {
862
+ try {
863
+ const backup = await this.fileOps.restoreMetabaseConfig(backupFilename);
864
+
865
+ logger.info(`Backup loaded from: ${backupFilename}`);
866
+ return {
867
+ success: true,
868
+ backup,
869
+ note: 'Backup loaded successfully. Manual restoration steps may be required.'
870
+ };
871
+ } catch (error) {
872
+ logger.error('Failed to restore from backup:', error);
873
+ throw error;
874
+ }
875
+ }
876
+
877
+ async listExportedFiles(directory = '') {
878
+ try {
879
+ const files = await this.fileOps.listFiles(directory);
880
+ return files;
881
+ } catch (error) {
882
+ logger.error(`Failed to list files in ${directory}:`, error);
883
+ throw error;
884
+ }
885
+ }
886
+
887
+ async getStorageStats() {
888
+ try {
889
+ const stats = await this.fileOps.getStorageStats();
890
+ return stats;
891
+ } catch (error) {
892
+ logger.error('Failed to get storage stats:', error);
893
+ throw error;
894
+ }
895
+ }
896
+
897
+ async cleanupOldFiles(maxAgeInDays = 7) {
898
+ try {
899
+ const maxAge = maxAgeInDays * 24 * 60 * 60 * 1000;
900
+ const deleted = await this.fileOps.cleanupOldFiles(maxAge);
901
+
902
+ logger.info(`Cleanup completed. Deleted ${deleted.length} old files`);
903
+ return deleted;
904
+ } catch (error) {
905
+ logger.error('Failed to cleanup old files:', error);
906
+ throw error;
907
+ }
908
+ }
909
+
910
+ // Enhanced dashboard creation with file export
911
+ async createDashboardWithExport(description, questions = [], options = {}) {
912
+ try {
913
+ // Create dashboard
914
+ const dashboardResult = await this.createDashboard(description, questions, options);
915
+
916
+ // Export dashboard if requested
917
+ if (options.autoExport) {
918
+ const exportResult = await this.exportDashboard(
919
+ dashboardResult.dashboard.id,
920
+ options.exportFormat || 'json'
921
+ );
922
+
923
+ dashboardResult.exportPath = exportResult.path;
924
+ }
925
+
926
+ return dashboardResult;
927
+ } catch (error) {
928
+ logger.error('Failed to create dashboard with export:', error);
929
+ throw error;
930
+ }
931
+ }
932
+
933
+ // Database schema documentation generator
934
+ async generateDatabaseDocumentation(databaseId, options = {}) {
935
+ try {
936
+ const database = await this.metabaseClient.getDatabase(databaseId);
937
+ const schemas = await this.metabaseClient.getDatabaseSchemas(databaseId);
938
+
939
+ let documentation = `# Database Documentation: ${database.name}\n\n`;
940
+ documentation += `**Database ID:** ${database.id}\n`;
941
+ documentation += `**Engine:** ${database.engine}\n`;
942
+ documentation += `**Created:** ${database.created_at}\n\n`;
943
+
944
+ for (const schema of schemas) {
945
+ documentation += `## Schema: ${schema}\n\n`;
946
+
947
+ const tables = await this.metabaseClient.getDatabaseTables(databaseId, schema);
948
+
949
+ for (const table of tables) {
950
+ documentation += `### ${table.display_name || table.name}\n`;
951
+ if (table.description) {
952
+ documentation += `${table.description}\n`;
953
+ }
954
+
955
+ documentation += `**Table ID:** ${table.id}\n`;
956
+ documentation += `**Rows:** ${table.row_count || 'Unknown'}\n\n`;
957
+
958
+ if (table.fields && table.fields.length > 0) {
959
+ documentation += `**Columns:**\n`;
960
+ table.fields.forEach(field => {
961
+ documentation += `- **${field.display_name || field.name}** (${field.base_type})`;
962
+ if (field.description) {
963
+ documentation += `: ${field.description}`;
964
+ }
965
+ documentation += '\n';
966
+ });
967
+ documentation += '\n';
968
+ }
969
+ }
970
+ }
971
+
972
+ const filename = `db-documentation-${database.id}-${Date.now()}.md`;
973
+ const result = await this.fileOps.writeFile(`documentation/${filename}`, documentation);
974
+
975
+ logger.info(`Database documentation generated: ${result.path}`);
976
+ return result;
977
+ } catch (error) {
978
+ logger.error('Failed to generate database documentation:', error);
979
+ throw error;
980
+ }
981
+ }
982
+ }