redash-mcp 2.2.1 → 3.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.
@@ -0,0 +1,117 @@
1
+ import { fetchSchema } from "../redash-client.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { loadExamples, findRelevantExamples, formatExamplesForPrompt } from "./few-shot.js";
4
+ import { pruneSchema, formatPrunedSchema } from "./schema-pruning.js";
5
+ import { assessComplexity } from "./complexity.js";
6
+ import { getEffectiveMap } from "./keyword-map.js";
7
+ import { isLLMAvailable, selectTablesWithLLM } from "./llm-table-selector.js";
8
+ export async function handleSmartQuery(params) {
9
+ const { question, data_source_id, context } = params;
10
+ const config = await loadConfig();
11
+ let fullSchema;
12
+ try {
13
+ fullSchema = await fetchSchema(data_source_id);
14
+ }
15
+ catch (e) {
16
+ return { action: "explain", explanation: `Schema fetch failed: ${e.message}` };
17
+ }
18
+ // Defensive: ensure columns are objects
19
+ for (const table of fullSchema) {
20
+ table.columns = (table.columns ?? []).map((c) => typeof c === "string" ? { name: c, type: "unknown" } : c);
21
+ }
22
+ const allExamples = config.bird.fewShot.enabled
23
+ ? await loadExamples(data_source_id)
24
+ : [];
25
+ const keywordMap = await getEffectiveMap(data_source_id);
26
+ const combinedQuestion = context ? `${question} ${context}` : question;
27
+ let prunedTables;
28
+ if (config.bird.schemaPruning.enabled) {
29
+ prunedTables = pruneSchema(combinedQuestion, fullSchema, allExamples, config.bird.schemaPruning.topK, keywordMap);
30
+ }
31
+ else {
32
+ prunedTables = fullSchema.slice(0, 10).map((t) => ({
33
+ name: t.name,
34
+ columns: t.columns ?? [],
35
+ score: 0,
36
+ }));
37
+ }
38
+ // LLM fallback: when token matching fails to find relevant tables
39
+ const maxScore = Math.max(...prunedTables.map((t) => t.score), 0);
40
+ if (maxScore === 0 && isLLMAvailable()) {
41
+ const llmSelected = await selectTablesWithLLM(combinedQuestion, fullSchema, config.bird.schemaPruning.topK);
42
+ if (llmSelected.length > 0) {
43
+ const selectedSet = new Set(llmSelected);
44
+ prunedTables = fullSchema
45
+ .filter((t) => selectedSet.has(t.name))
46
+ .map((t) => ({
47
+ name: t.name,
48
+ columns: t.columns ?? [],
49
+ score: 1,
50
+ }));
51
+ }
52
+ }
53
+ if (!context) {
54
+ const clarifications = detectVagueness(question, prunedTables);
55
+ if (clarifications.length > 0) {
56
+ return {
57
+ action: "clarify",
58
+ clarificationQuestions: clarifications,
59
+ };
60
+ }
61
+ }
62
+ const prunedTableNames = prunedTables.map((t) => t.name);
63
+ const matchedExamples = findRelevantExamples(question, prunedTableNames, allExamples, config.bird.fewShot.maxExamplesPerQuery);
64
+ const complexity = config.bird.complexity.enabled
65
+ ? assessComplexity(context ? `${question} ${context}` : question, prunedTables)
66
+ : undefined;
67
+ const guidanceParts = [];
68
+ if (context) {
69
+ guidanceParts.push(`User clarification: ${context}`);
70
+ }
71
+ if (complexity) {
72
+ guidanceParts.push(`Difficulty: ${complexity.level} (${complexity.reasoning})`);
73
+ if (complexity.hints.length > 0) {
74
+ guidanceParts.push(`Hints: ${complexity.hints.join(". ")}`);
75
+ }
76
+ }
77
+ return {
78
+ action: "generate",
79
+ schema: formatPrunedSchema(prunedTables),
80
+ fewShotExamples: formatExamplesForPrompt(matchedExamples),
81
+ complexity,
82
+ guidance: guidanceParts.join("\n"),
83
+ };
84
+ }
85
+ function detectVagueness(question, prunedTables) {
86
+ const clarifications = [];
87
+ const q = question.toLowerCase();
88
+ const timeKeywords = [
89
+ "recent", "lately", "last", "previous", "this",
90
+ "최근", "지난", "이번", "저번", "올해", "작년",
91
+ ];
92
+ const timeSpecifiers = [
93
+ "day", "week", "month", "year", "quarter", "hour",
94
+ "일", "주", "월", "년", "분기", "시간",
95
+ /\d{4}[-\/]\d{1,2}/, /\d{1,2}[-\/]\d{1,2}/,
96
+ ];
97
+ const hasTimeKeyword = timeKeywords.some((kw) => q.includes(kw));
98
+ const hasTimeSpecifier = timeSpecifiers.some((spec) => spec instanceof RegExp ? spec.test(q) : q.includes(spec));
99
+ if (hasTimeKeyword && !hasTimeSpecifier) {
100
+ clarifications.push("Which specific time period? (e.g., last 7 days, last month, 2025-01-01 ~ 2025-03-31)");
101
+ }
102
+ const wordCount = question.trim().split(/\s+/).length;
103
+ if (wordCount < 4) {
104
+ clarifications.push("Could you provide more details about what data you need?");
105
+ }
106
+ const maxScore = Math.max(...prunedTables.map((t) => t.score), 0);
107
+ if (maxScore === 0 && prunedTables.length > 0) {
108
+ clarifications.push("I couldn't identify which tables are relevant. Could you mention specific entities (e.g., users, orders, payments)?");
109
+ }
110
+ if (maxScore > 0) {
111
+ const topTables = prunedTables.filter((t) => t.score === maxScore);
112
+ if (topTables.length > 3) {
113
+ clarifications.push(`Multiple tables match your question (${topTables.slice(0, 5).map((t) => t.name).join(", ")}). Could you be more specific about which data you need?`);
114
+ }
115
+ }
116
+ return clarifications;
117
+ }
@@ -0,0 +1,288 @@
1
+ import { z } from "zod";
2
+ import { handleSmartQuery } from "./smart-query.js";
3
+ import { loadExamples, addExample, removeExample } from "./few-shot.js";
4
+ import { recordFeedback } from "./feedback.js";
5
+ import { loadTestSuite, addTestCase, removeTestCase, runEvaluation, formatEvalResults } from "./evaluation.js";
6
+ import { loadConfig, getConfigDir } from "./config.js";
7
+ import { getEffectiveMap, addMappings, removeMappings, resetMappings, loadKeywordMap, DEFAULT_KEYWORD_MAP } from "./keyword-map.js";
8
+ export function registerBirdTools(server) {
9
+ if (process.env.REDASH_BIRD_ENABLED === "false")
10
+ return;
11
+ server.tool("smart_query", "BIRD SQL-based intelligent query tool. Analyzes natural-language questions to (1) extract relevant schema, (2) match similar few-shot examples, (3) assess complexity, (4) request clarification for ambiguous questions. Call this tool before run_query. For a new data source, first inspect the schema with list_tables, then register keyword-to-table mappings via manage_keyword_map to significantly improve accuracy.", {
12
+ data_source_id: z.number().describe("Data source ID (from list_data_sources)"),
13
+ question: z.string().describe("Natural-language question (e.g., 'How many payments were completed last month?')"),
14
+ context: z.string().optional().describe("User's answer to a previous clarification question (for multi-turn)"),
15
+ }, { readOnlyHint: true }, async ({ data_source_id, question, context }) => {
16
+ const result = await handleSmartQuery({ question, data_source_id, context });
17
+ if (result.action === "clarify") {
18
+ const text = [
19
+ "## Clarification needed\n",
20
+ "Before generating SQL, I need more information:\n",
21
+ ...(result.clarificationQuestions?.map((q, i) => `${i + 1}. ${q}`) ?? []),
22
+ "\nPlease provide answers, then call smart_query again with the `context` parameter containing your answers.",
23
+ ].join("\n");
24
+ return { content: [{ type: "text", text }] };
25
+ }
26
+ if (result.action === "explain") {
27
+ return {
28
+ content: [{ type: "text", text: result.explanation ?? "Cannot generate SQL for this question." }],
29
+ };
30
+ }
31
+ const parts = [];
32
+ if (result.schema) {
33
+ parts.push(result.schema);
34
+ }
35
+ if (result.fewShotExamples) {
36
+ parts.push(result.fewShotExamples);
37
+ }
38
+ if (result.complexity) {
39
+ parts.push(`## Complexity: ${result.complexity.level}\n`);
40
+ }
41
+ if (result.guidance) {
42
+ parts.push(`## Guidance\n${result.guidance}`);
43
+ }
44
+ parts.push("\n---\nUse the schema and examples above to generate SQL, then execute with `run_query`.");
45
+ return { content: [{ type: "text", text: parts.join("\n") }] };
46
+ });
47
+ server.tool("manage_few_shot_examples", "Manage few-shot examples (list/add/remove). Register domain-specific examples to improve SQL accuracy.", {
48
+ data_source_id: z.number().describe("Data source ID"),
49
+ action: z.enum(["list", "add", "remove"]).describe("Action to perform"),
50
+ example: z
51
+ .object({
52
+ question: z.string().describe("Natural-language question"),
53
+ sql: z.string().describe("Correct SQL"),
54
+ tables: z.array(z.string()).describe("List of table names used"),
55
+ tags: z.array(z.string()).optional().describe("Tags (e.g., payments, date-filter)"),
56
+ notes: z.string().optional().describe("Domain knowledge notes (e.g., payment status values are paid, pending...)"),
57
+ })
58
+ .optional()
59
+ .describe("Example to add (required when action=add)"),
60
+ example_id: z.string().optional().describe("Example ID to remove (required when action=remove)"),
61
+ }, { destructiveHint: true }, async ({ data_source_id, action, example, example_id }) => {
62
+ if (action === "list") {
63
+ const examples = await loadExamples(data_source_id);
64
+ if (examples.length === 0) {
65
+ return { content: [{ type: "text", text: "No few-shot examples registered." }] };
66
+ }
67
+ const text = examples
68
+ .map((e) => `**[${e.id}]** ${e.question}\n\`\`\`sql\n${e.sql}\n\`\`\`\nTables: ${e.tables.join(", ")} | Tags: ${e.tags.join(", ")} | Source: ${e.source}`)
69
+ .join("\n\n---\n\n");
70
+ return { content: [{ type: "text", text: `Total ${examples.length} example(s):\n\n${text}` }] };
71
+ }
72
+ if (action === "add") {
73
+ if (!example) {
74
+ return { content: [{ type: "text", text: "The example parameter is required." }] };
75
+ }
76
+ const added = await addExample(data_source_id, {
77
+ question: example.question,
78
+ sql: example.sql,
79
+ tables: example.tables,
80
+ tags: example.tags ?? [],
81
+ notes: example.notes ?? "",
82
+ source: "manual",
83
+ });
84
+ return {
85
+ content: [{ type: "text", text: `Few-shot example added. (ID: ${added.id})` }],
86
+ };
87
+ }
88
+ if (action === "remove") {
89
+ if (!example_id) {
90
+ return { content: [{ type: "text", text: "The example_id parameter is required." }] };
91
+ }
92
+ const removed = await removeExample(data_source_id, example_id);
93
+ return {
94
+ content: [
95
+ {
96
+ type: "text",
97
+ text: removed ? `Example ${example_id} has been removed.` : `Example ${example_id} not found.`,
98
+ },
99
+ ],
100
+ };
101
+ }
102
+ return { content: [{ type: "text", text: "Invalid action" }] };
103
+ });
104
+ server.tool("submit_query_feedback", "Submit feedback on generated SQL. Incorrect SQL is automatically classified and may be promoted to a few-shot example.", {
105
+ data_source_id: z.number().describe("Data source ID"),
106
+ question: z.string().describe("Original natural-language question"),
107
+ generated_sql: z.string().describe("Generated SQL"),
108
+ correct_sql: z.string().optional().describe("Correct SQL (provide when rating=down for automatic learning)"),
109
+ rating: z.enum(["up", "down"]).describe("Rating: up (correct) or down (incorrect)"),
110
+ }, { destructiveHint: true }, async ({ data_source_id, question, generated_sql, correct_sql, rating }) => {
111
+ const entry = await recordFeedback(data_source_id, {
112
+ question,
113
+ generatedSql: generated_sql,
114
+ correctSql: correct_sql,
115
+ rating,
116
+ });
117
+ const parts = [`Feedback recorded. (ID: ${entry.id})`];
118
+ if (entry.errorType) {
119
+ parts.push(`Error type: ${entry.errorType}`);
120
+ }
121
+ if (entry.promotedToFewShot) {
122
+ parts.push("Repeated errors of the same type — auto-promoted to few-shot example.");
123
+ }
124
+ return { content: [{ type: "text", text: parts.join("\n") }] };
125
+ });
126
+ server.tool("evaluate_queries", "Manage SQL accuracy evaluation. Add/list test cases, run evaluations, and view results.", {
127
+ data_source_id: z.number().describe("Data source ID"),
128
+ action: z.enum(["list_tests", "add_test", "remove_test", "run", "results"]).describe("Action to perform"),
129
+ test_case: z
130
+ .object({
131
+ question: z.string().describe("Natural-language question"),
132
+ ground_truth_sql: z.string().describe("Ground-truth SQL"),
133
+ difficulty: z.enum(["simple", "medium", "complex"]).describe("Difficulty level"),
134
+ tags: z.array(z.string()).optional().describe("Tags"),
135
+ })
136
+ .optional()
137
+ .describe("Test case to add (required when action=add_test)"),
138
+ test_case_id: z.string().optional().describe("Test case ID to remove (required when action=remove_test)"),
139
+ generated_sqls: z
140
+ .array(z.object({
141
+ test_case_id: z.string().describe("Test case ID"),
142
+ generated_sql: z.string().describe("Generated SQL"),
143
+ }))
144
+ .optional()
145
+ .describe("List of SQL to evaluate (required when action=run)"),
146
+ }, { destructiveHint: true }, async ({ data_source_id, action, test_case, test_case_id, generated_sqls }) => {
147
+ if (action === "list_tests") {
148
+ const store = await loadTestSuite(data_source_id);
149
+ if (store.testCases.length === 0) {
150
+ return { content: [{ type: "text", text: "No test cases registered." }] };
151
+ }
152
+ const text = store.testCases
153
+ .map((tc) => `**[${tc.id}]** ${tc.question}\nDifficulty: ${tc.difficulty} | Tags: ${tc.tags.join(", ")}\n\`\`\`sql\n${tc.groundTruthSql}\n\`\`\``)
154
+ .join("\n\n---\n\n");
155
+ return { content: [{ type: "text", text: `Total ${store.testCases.length} test case(s):\n\n${text}` }] };
156
+ }
157
+ if (action === "add_test") {
158
+ if (!test_case) {
159
+ return { content: [{ type: "text", text: "The test_case parameter is required." }] };
160
+ }
161
+ const added = await addTestCase(data_source_id, {
162
+ question: test_case.question,
163
+ groundTruthSql: test_case.ground_truth_sql,
164
+ difficulty: test_case.difficulty,
165
+ tags: test_case.tags ?? [],
166
+ });
167
+ return {
168
+ content: [{ type: "text", text: `Test case added. (ID: ${added.id})` }],
169
+ };
170
+ }
171
+ if (action === "remove_test") {
172
+ if (!test_case_id) {
173
+ return { content: [{ type: "text", text: "The test_case_id parameter is required." }] };
174
+ }
175
+ const removed = await removeTestCase(data_source_id, test_case_id);
176
+ return {
177
+ content: [
178
+ {
179
+ type: "text",
180
+ text: removed ? `Test case ${test_case_id} has been removed.` : `Test case ${test_case_id} not found.`,
181
+ },
182
+ ],
183
+ };
184
+ }
185
+ if (action === "run") {
186
+ if (!generated_sqls || generated_sqls.length === 0) {
187
+ return { content: [{ type: "text", text: "The generated_sqls parameter is required." }] };
188
+ }
189
+ const run = await runEvaluation(data_source_id, generated_sqls.map((gs) => ({
190
+ testCaseId: gs.test_case_id,
191
+ generatedSql: gs.generated_sql,
192
+ })));
193
+ return { content: [{ type: "text", text: formatEvalResults(run) }] };
194
+ }
195
+ if (action === "results") {
196
+ const store = await loadTestSuite(data_source_id);
197
+ if (store.runs.length === 0) {
198
+ return { content: [{ type: "text", text: "No evaluation runs found." }] };
199
+ }
200
+ const lastRun = store.runs[store.runs.length - 1];
201
+ return { content: [{ type: "text", text: formatEvalResults(lastRun) }] };
202
+ }
203
+ return { content: [{ type: "text", text: "Invalid action" }] };
204
+ });
205
+ server.tool("manage_keyword_map", "Manage keyword-to-table-name mappings. After inspecting the schema with list_tables, register domain-specific mappings to improve smart_query table-matching accuracy. e.g., {\"revenue\": [\"payment\"], \"creator\": [\"creator\"]}", {
206
+ data_source_id: z.number().describe("Data source ID"),
207
+ action: z.enum(["list", "add", "remove", "reset"]).describe("Action to perform"),
208
+ mappings: z
209
+ .record(z.array(z.string()))
210
+ .optional()
211
+ .describe("Mappings to add (required when action=add). e.g., {\"revenue\": [\"payment\", \"billing\"]}"),
212
+ keywords: z
213
+ .array(z.string())
214
+ .optional()
215
+ .describe("Keywords to remove (required when action=remove). e.g., [\"revenue\", \"order\"]"),
216
+ }, { destructiveHint: true }, async ({ data_source_id, action, mappings, keywords }) => {
217
+ if (action === "list") {
218
+ const effective = await getEffectiveMap(data_source_id);
219
+ const custom = await loadKeywordMap(data_source_id);
220
+ const defaultCount = Object.keys(DEFAULT_KEYWORD_MAP).length;
221
+ const customCount = Object.keys(custom).length;
222
+ const lines = [
223
+ `## Keyword Map (Data Source ${data_source_id})\n`,
224
+ `Default: ${defaultCount} | Custom: ${customCount} | Total: ${Object.keys(effective).length}\n`,
225
+ ];
226
+ if (customCount > 0) {
227
+ lines.push("### Custom mappings:");
228
+ for (const [ko, en] of Object.entries(custom)) {
229
+ lines.push(`- **${ko}** → ${en.join(", ")}`);
230
+ }
231
+ lines.push("");
232
+ }
233
+ lines.push("### All effective mappings:");
234
+ for (const [ko, en] of Object.entries(effective)) {
235
+ const isCustom = ko in custom;
236
+ lines.push(`- ${isCustom ? "**" : ""}${ko}${isCustom ? "** (custom)" : ""} → ${en.join(", ")}`);
237
+ }
238
+ return { content: [{ type: "text", text: lines.join("\n") }] };
239
+ }
240
+ if (action === "add") {
241
+ if (!mappings || Object.keys(mappings).length === 0) {
242
+ return { content: [{ type: "text", text: "The mappings parameter is required. e.g., {\"revenue\": [\"payment\"]}" }] };
243
+ }
244
+ const updated = await addMappings(data_source_id, mappings);
245
+ const added = Object.keys(mappings);
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: `${added.length} keyword mapping(s) added/updated: ${added.join(", ")}`,
251
+ },
252
+ ],
253
+ };
254
+ }
255
+ if (action === "remove") {
256
+ if (!keywords || keywords.length === 0) {
257
+ return { content: [{ type: "text", text: "The keywords parameter is required." }] };
258
+ }
259
+ await removeMappings(data_source_id, keywords);
260
+ return {
261
+ content: [{ type: "text", text: `${keywords.length} custom keyword(s) removed: ${keywords.join(", ")}` }],
262
+ };
263
+ }
264
+ if (action === "reset") {
265
+ await resetMappings(data_source_id);
266
+ return {
267
+ content: [{ type: "text", text: "Custom mappings have been reset. Only default mappings will be used." }],
268
+ };
269
+ }
270
+ return { content: [{ type: "text", text: "Invalid action" }] };
271
+ });
272
+ server.tool("get_bird_config", "View BIRD SQL configuration and status.", {}, { readOnlyHint: true }, async () => {
273
+ const config = await loadConfig();
274
+ const configDir = getConfigDir();
275
+ const lines = [
276
+ "## BIRD SQL Configuration\n",
277
+ `Config directory: \`${configDir}\`\n`,
278
+ "### Settings:",
279
+ `- Schema Pruning: ${config.bird.schemaPruning.enabled ? "ON" : "OFF"} (Top-K: ${config.bird.schemaPruning.topK})`,
280
+ `- Few-shot Examples: ${config.bird.fewShot.enabled ? "ON" : "OFF"} (Max per query: ${config.bird.fewShot.maxExamplesPerQuery})`,
281
+ `- Feedback Loop: ${config.bird.feedback.enabled ? "ON" : "OFF"} (Auto-promote threshold: ${config.bird.feedback.autoPromoteThreshold})`,
282
+ `- Complexity Assessment: ${config.bird.complexity.enabled ? "ON" : "OFF"}`,
283
+ "",
284
+ `Edit \`${configDir}/config.json\` to customize settings.`,
285
+ ];
286
+ return { content: [{ type: "text", text: lines.join("\n") }] };
287
+ });
288
+ }
@@ -0,0 +1 @@
1
+ export {};