redash-mcp 3.1.3 → 3.1.4
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.
- package/dist/index.js +43010 -512
- package/manifest.json +27 -2
- package/package.json +5 -3
- package/dist/bird/complexity.js +0 -87
- package/dist/bird/config.js +0 -73
- package/dist/bird/evaluation.js +0 -172
- package/dist/bird/feedback.js +0 -153
- package/dist/bird/few-shot.js +0 -95
- package/dist/bird/keyword-map.js +0 -56
- package/dist/bird/llm-table-selector.js +0 -59
- package/dist/bird/schema-pruning.js +0 -107
- package/dist/bird/smart-query.js +0 -122
- package/dist/bird/tools.js +0 -319
- package/dist/bird/types.js +0 -1
- package/dist/query-cache.js +0 -87
- package/dist/redash-client.js +0 -124
- package/dist/setup.js +0 -137
- package/dist/sql-guard.js +0 -151
- package/dist/tool-error.js +0 -40
package/manifest.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "redash-mcp",
|
|
4
|
-
"version": "3.1.
|
|
4
|
+
"version": "3.1.4",
|
|
5
5
|
"description": "MCP server for Redash -- query data, manage dashboards, and run SQL with natural language. 24 tools including BIRD SQL methodology.",
|
|
6
|
+
"long_description": "Connects an MCP client to a Redash instance: list data sources, inspect schemas, run SQL, manage saved queries / dashboards / alerts, and use BIRD SQL helpers (smart_query, keyword maps, few-shot examples, feedback, evaluation). Built-in SQL safety guards flag PII access and expensive scans; safety mode can be tuned per install. **Third-party data flow:** if you also set ANTHROPIC_API_KEY in the shell environment, the BIRD smart_query tool may send table-name lists (never query results or row data) to the Anthropic API as a fallback for table selection. Leave ANTHROPIC_API_KEY unset to disable this entirely.",
|
|
6
7
|
"icon": "icon.png",
|
|
7
8
|
"author": {
|
|
8
9
|
"name": "seob717",
|
|
@@ -25,7 +26,10 @@
|
|
|
25
26
|
"args": ["-y", "redash-mcp"],
|
|
26
27
|
"env": {
|
|
27
28
|
"REDASH_URL": "${user_config.redash_url}",
|
|
28
|
-
"REDASH_API_KEY": "${user_config.redash_api_key}"
|
|
29
|
+
"REDASH_API_KEY": "${user_config.redash_api_key}",
|
|
30
|
+
"REDASH_SAFETY_MODE": "${user_config.safety_mode}",
|
|
31
|
+
"REDASH_SAFETY_DISABLE_PII": "${user_config.disable_pii_guard}",
|
|
32
|
+
"REDASH_SAFETY_DISABLE_COST": "${user_config.disable_cost_guard}"
|
|
29
33
|
}
|
|
30
34
|
}
|
|
31
35
|
},
|
|
@@ -42,6 +46,27 @@
|
|
|
42
46
|
"description": "Your Redash user API key (found in Settings > Account)",
|
|
43
47
|
"sensitive": true,
|
|
44
48
|
"required": true
|
|
49
|
+
},
|
|
50
|
+
"safety_mode": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"title": "SQL Safety Mode",
|
|
53
|
+
"description": "How strictly to enforce SQL safety guards. 'strict' blocks risky queries (DELETE without WHERE, full table scans on large tables, PII access). 'warn' (default) returns warnings but executes the query. 'off' disables all guards -- not recommended.",
|
|
54
|
+
"default": "warn",
|
|
55
|
+
"required": false
|
|
56
|
+
},
|
|
57
|
+
"disable_pii_guard": {
|
|
58
|
+
"type": "boolean",
|
|
59
|
+
"title": "Disable PII guard (NOT RECOMMENDED)",
|
|
60
|
+
"description": "Only enable if you understand the consequences. When enabled, queries that read columns flagged as PII (email, phone, ssn, etc.) will not be warned about or blocked.",
|
|
61
|
+
"default": false,
|
|
62
|
+
"required": false
|
|
63
|
+
},
|
|
64
|
+
"disable_cost_guard": {
|
|
65
|
+
"type": "boolean",
|
|
66
|
+
"title": "Disable cost guard (NOT RECOMMENDED)",
|
|
67
|
+
"description": "Only enable if you understand the consequences. When enabled, expensive queries (full scans, large cross joins) will not be warned about or blocked.",
|
|
68
|
+
"default": false,
|
|
69
|
+
"required": false
|
|
45
70
|
}
|
|
46
71
|
}
|
|
47
72
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redash-mcp",
|
|
3
3
|
"mcpName": "io.github.seob717/redash-mcp",
|
|
4
|
-
"version": "3.1.
|
|
4
|
+
"version": "3.1.4",
|
|
5
5
|
"description": "MCP server for Redash",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "seob717",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"model-context-protocol"
|
|
15
15
|
],
|
|
16
16
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
17
|
+
"node": ">=20"
|
|
18
18
|
},
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"redash-mcp": "dist/index.js"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"build": "npm run typecheck && node scripts/build.mjs",
|
|
34
35
|
"dev": "tsx src/index.ts",
|
|
35
36
|
"start": "node dist/index.js",
|
|
36
37
|
"test": "vitest run",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/node": "^22.0.0",
|
|
48
|
+
"esbuild": "^0.27.7",
|
|
47
49
|
"tsx": "^4.0.0",
|
|
48
50
|
"typescript": "^5.0.0",
|
|
49
51
|
"vitest": "^4.1.6"
|
package/dist/bird/complexity.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
export function assessComplexity(question, prunedTables) {
|
|
2
|
-
let score = 0;
|
|
3
|
-
const hints = [];
|
|
4
|
-
const reasons = [];
|
|
5
|
-
const q = question.toLowerCase();
|
|
6
|
-
const relevantTables = prunedTables.filter((t) => t.score > 0).length;
|
|
7
|
-
if (relevantTables > 1) {
|
|
8
|
-
score += (relevantTables - 1) * 2;
|
|
9
|
-
reasons.push(`${relevantTables} tables involved`);
|
|
10
|
-
}
|
|
11
|
-
const joinKeywords = ["join", "combine", "relate", "between tables", "연결", "결합", "조인"];
|
|
12
|
-
if (joinKeywords.some((kw) => q.includes(kw))) {
|
|
13
|
-
score += 2;
|
|
14
|
-
reasons.push("explicit join requested");
|
|
15
|
-
hints.push("Verify JOIN conditions match the correct foreign keys.");
|
|
16
|
-
}
|
|
17
|
-
const subqueryKeywords = [
|
|
18
|
-
"for each", "among those", "within", "nested", "sub",
|
|
19
|
-
"중에서", "각각", "그중", "해당하는",
|
|
20
|
-
];
|
|
21
|
-
if (subqueryKeywords.some((kw) => q.includes(kw))) {
|
|
22
|
-
score += 2;
|
|
23
|
-
reasons.push("subquery likely needed");
|
|
24
|
-
hints.push("Consider using CTEs for readability.");
|
|
25
|
-
}
|
|
26
|
-
const windowKeywords = [
|
|
27
|
-
"ranking", "rank", "running total", "cumulative", "previous",
|
|
28
|
-
"percentile", "top n", "nth",
|
|
29
|
-
"순위", "누적", "이전", "상위",
|
|
30
|
-
];
|
|
31
|
-
if (windowKeywords.some((kw) => q.includes(kw))) {
|
|
32
|
-
score += 3;
|
|
33
|
-
reasons.push("window functions may be needed");
|
|
34
|
-
hints.push("Use window functions (ROW_NUMBER, RANK, LAG/LEAD) as appropriate.");
|
|
35
|
-
}
|
|
36
|
-
const pivotKeywords = [
|
|
37
|
-
"compare across", "breakdown by", "pivot", "crosstab",
|
|
38
|
-
"비교", "교차", "피벗", "대비",
|
|
39
|
-
];
|
|
40
|
-
if (pivotKeywords.some((kw) => q.includes(kw))) {
|
|
41
|
-
score += 2;
|
|
42
|
-
reasons.push("cross-comparison or pivoting needed");
|
|
43
|
-
hints.push("Consider CASE WHEN for pivot-style queries.");
|
|
44
|
-
}
|
|
45
|
-
const timeCompareKeywords = [
|
|
46
|
-
"year over year", "month over month", "yoy", "mom",
|
|
47
|
-
"growth rate", "change rate", "trend",
|
|
48
|
-
"전년", "전월", "증감", "추이", "변화율", "성장률",
|
|
49
|
-
];
|
|
50
|
-
if (timeCompareKeywords.some((kw) => q.includes(kw))) {
|
|
51
|
-
score += 2;
|
|
52
|
-
reasons.push("time-based comparison needed");
|
|
53
|
-
hints.push("Use LAG() or self-join for period-over-period comparisons.");
|
|
54
|
-
}
|
|
55
|
-
const ratioKeywords = [
|
|
56
|
-
"ratio", "rate", "conversion", "percentage", "proportion",
|
|
57
|
-
"비율", "전환율", "퍼센트", "점유율",
|
|
58
|
-
];
|
|
59
|
-
if (ratioKeywords.some((kw) => q.includes(kw))) {
|
|
60
|
-
score += 1;
|
|
61
|
-
reasons.push("ratio calculation involved");
|
|
62
|
-
}
|
|
63
|
-
let level;
|
|
64
|
-
if (score <= 2) {
|
|
65
|
-
level = "simple";
|
|
66
|
-
}
|
|
67
|
-
else if (score <= 5) {
|
|
68
|
-
level = "medium";
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
level = "complex";
|
|
72
|
-
}
|
|
73
|
-
if (level === "simple" && hints.length === 0) {
|
|
74
|
-
hints.push("Straightforward query. Focus on correct column names and filter values.");
|
|
75
|
-
}
|
|
76
|
-
if (level === "medium" && hints.length === 0) {
|
|
77
|
-
hints.push("Moderate complexity. Double-check JOIN conditions and GROUP BY columns.");
|
|
78
|
-
}
|
|
79
|
-
if (level === "complex" && hints.length === 0) {
|
|
80
|
-
hints.push("Complex query. Consider breaking into CTEs for clarity and correctness.");
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
level,
|
|
84
|
-
reasoning: reasons.length > 0 ? reasons.join("; ") : "single table with basic filtering",
|
|
85
|
-
hints,
|
|
86
|
-
};
|
|
87
|
-
}
|
package/dist/bird/config.js
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import os from "node:os";
|
|
5
|
-
const DEFAULT_CONFIG = {
|
|
6
|
-
bird: {
|
|
7
|
-
enabled: true,
|
|
8
|
-
schemaPruning: {
|
|
9
|
-
enabled: true,
|
|
10
|
-
topK: 7,
|
|
11
|
-
includeSampleValues: true,
|
|
12
|
-
maxSampleValues: 5,
|
|
13
|
-
},
|
|
14
|
-
fewShot: {
|
|
15
|
-
enabled: true,
|
|
16
|
-
maxExamplesPerQuery: 3,
|
|
17
|
-
},
|
|
18
|
-
feedback: {
|
|
19
|
-
enabled: true,
|
|
20
|
-
autoPromoteThreshold: 3,
|
|
21
|
-
},
|
|
22
|
-
complexity: {
|
|
23
|
-
enabled: true,
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
};
|
|
27
|
-
export function getConfigDir() {
|
|
28
|
-
return process.env.REDASH_MCP_CONFIG_DIR || path.join(os.homedir(), ".redash-mcp");
|
|
29
|
-
}
|
|
30
|
-
export async function ensureConfigDir() {
|
|
31
|
-
const dir = getConfigDir();
|
|
32
|
-
for (const sub of ["", "few-shot", "feedback", "eval", "keyword-map"]) {
|
|
33
|
-
const p = path.join(dir, sub);
|
|
34
|
-
if (!existsSync(p)) {
|
|
35
|
-
await mkdir(p, { recursive: true });
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
export function getDataSourcePath(subdir, dataSourceId) {
|
|
40
|
-
return path.join(getConfigDir(), subdir, `${dataSourceId}.json`);
|
|
41
|
-
}
|
|
42
|
-
export async function loadConfig() {
|
|
43
|
-
const configPath = path.join(getConfigDir(), "config.json");
|
|
44
|
-
try {
|
|
45
|
-
const raw = await readFile(configPath, "utf-8");
|
|
46
|
-
const parsed = JSON.parse(raw);
|
|
47
|
-
return deepMerge(DEFAULT_CONFIG, parsed);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return DEFAULT_CONFIG;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export async function saveConfig(config) {
|
|
54
|
-
await ensureConfigDir();
|
|
55
|
-
const configPath = path.join(getConfigDir(), "config.json");
|
|
56
|
-
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
57
|
-
}
|
|
58
|
-
function deepMerge(target, source) {
|
|
59
|
-
const result = { ...target };
|
|
60
|
-
for (const key of Object.keys(source)) {
|
|
61
|
-
if (source[key] &&
|
|
62
|
-
typeof source[key] === "object" &&
|
|
63
|
-
!Array.isArray(source[key]) &&
|
|
64
|
-
target[key] &&
|
|
65
|
-
typeof target[key] === "object") {
|
|
66
|
-
result[key] = deepMerge(target[key], source[key]);
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
result[key] = source[key];
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return result;
|
|
73
|
-
}
|
package/dist/bird/evaluation.js
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { ensureConfigDir, getDataSourcePath } from "./config.js";
|
|
3
|
-
import { redashFetch, pollQueryResult } from "../redash-client.js";
|
|
4
|
-
export async function loadTestSuite(dataSourceId) {
|
|
5
|
-
try {
|
|
6
|
-
const raw = await readFile(getDataSourcePath("eval", dataSourceId), "utf-8");
|
|
7
|
-
return JSON.parse(raw);
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
return { dataSourceId, testCases: [], runs: [] };
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
async function saveTestSuite(dataSourceId, store) {
|
|
14
|
-
await ensureConfigDir();
|
|
15
|
-
await writeFile(getDataSourcePath("eval", dataSourceId), JSON.stringify(store, null, 2), "utf-8");
|
|
16
|
-
}
|
|
17
|
-
export async function addTestCase(dataSourceId, testCase) {
|
|
18
|
-
const store = await loadTestSuite(dataSourceId);
|
|
19
|
-
const newCase = {
|
|
20
|
-
...testCase,
|
|
21
|
-
id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
22
|
-
};
|
|
23
|
-
store.testCases.push(newCase);
|
|
24
|
-
await saveTestSuite(dataSourceId, store);
|
|
25
|
-
return newCase;
|
|
26
|
-
}
|
|
27
|
-
export async function removeTestCase(dataSourceId, testCaseId) {
|
|
28
|
-
const store = await loadTestSuite(dataSourceId);
|
|
29
|
-
const filtered = store.testCases.filter((tc) => tc.id !== testCaseId);
|
|
30
|
-
if (filtered.length === store.testCases.length)
|
|
31
|
-
return false;
|
|
32
|
-
store.testCases = filtered;
|
|
33
|
-
await saveTestSuite(dataSourceId, store);
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
export async function runEvaluation(dataSourceId, generatedSqls) {
|
|
37
|
-
const store = await loadTestSuite(dataSourceId);
|
|
38
|
-
const results = [];
|
|
39
|
-
for (const { testCaseId, generatedSql } of generatedSqls) {
|
|
40
|
-
const testCase = store.testCases.find((tc) => tc.id === testCaseId);
|
|
41
|
-
if (!testCase) {
|
|
42
|
-
results.push({
|
|
43
|
-
testCaseId,
|
|
44
|
-
generatedSql,
|
|
45
|
-
match: false,
|
|
46
|
-
details: "Test case not found",
|
|
47
|
-
});
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
const match = await compareQueryResults(dataSourceId, testCase.groundTruthSql, generatedSql);
|
|
52
|
-
results.push({
|
|
53
|
-
testCaseId,
|
|
54
|
-
generatedSql,
|
|
55
|
-
match: match.isMatch,
|
|
56
|
-
details: match.details,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
catch (err) {
|
|
60
|
-
results.push({
|
|
61
|
-
testCaseId,
|
|
62
|
-
generatedSql,
|
|
63
|
-
match: false,
|
|
64
|
-
details: `Execution error: ${err.message}`,
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const matchCount = results.filter((r) => r.match).length;
|
|
69
|
-
const total = results.length;
|
|
70
|
-
const byDifficulty = (level) => {
|
|
71
|
-
const relevant = generatedSqls
|
|
72
|
-
.map((gs) => ({
|
|
73
|
-
...gs,
|
|
74
|
-
testCase: store.testCases.find((tc) => tc.id === gs.testCaseId),
|
|
75
|
-
}))
|
|
76
|
-
.filter((gs) => gs.testCase?.difficulty === level);
|
|
77
|
-
if (relevant.length === 0)
|
|
78
|
-
return 0;
|
|
79
|
-
const matches = relevant.filter((gs) => results.find((r) => r.testCaseId === gs.testCaseId)?.match).length;
|
|
80
|
-
return matches / relevant.length;
|
|
81
|
-
};
|
|
82
|
-
const run = {
|
|
83
|
-
runId: `run_${Date.now()}`,
|
|
84
|
-
timestamp: new Date().toISOString(),
|
|
85
|
-
results,
|
|
86
|
-
accuracy: {
|
|
87
|
-
overall: total > 0 ? matchCount / total : 0,
|
|
88
|
-
simple: byDifficulty("simple"),
|
|
89
|
-
medium: byDifficulty("medium"),
|
|
90
|
-
complex: byDifficulty("complex"),
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
store.runs.push(run);
|
|
94
|
-
if (store.runs.length > 10) {
|
|
95
|
-
store.runs = store.runs.slice(-10);
|
|
96
|
-
}
|
|
97
|
-
await saveTestSuite(dataSourceId, store);
|
|
98
|
-
return run;
|
|
99
|
-
}
|
|
100
|
-
async function compareQueryResults(dataSourceId, groundTruthSql, generatedSql) {
|
|
101
|
-
const [gtResult, genResult] = await Promise.all([
|
|
102
|
-
executeQuery(dataSourceId, groundTruthSql),
|
|
103
|
-
executeQuery(dataSourceId, generatedSql),
|
|
104
|
-
]);
|
|
105
|
-
const gtCols = new Set(gtResult.columns);
|
|
106
|
-
const genCols = new Set(genResult.columns);
|
|
107
|
-
if (gtCols.size !== genCols.size || ![...gtCols].every((c) => genCols.has(c))) {
|
|
108
|
-
return {
|
|
109
|
-
isMatch: false,
|
|
110
|
-
details: `Column mismatch: expected [${[...gtCols].join(", ")}], got [${[...genCols].join(", ")}]`,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
if (gtResult.rows.length !== genResult.rows.length) {
|
|
114
|
-
return {
|
|
115
|
-
isMatch: false,
|
|
116
|
-
details: `Row count mismatch: expected ${gtResult.rows.length}, got ${genResult.rows.length}`,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
const gtSorted = sortRows(gtResult.rows, gtResult.columns);
|
|
120
|
-
const genSorted = sortRows(genResult.rows, genResult.columns);
|
|
121
|
-
for (let i = 0; i < gtSorted.length; i++) {
|
|
122
|
-
if (gtSorted[i] !== genSorted[i]) {
|
|
123
|
-
return {
|
|
124
|
-
isMatch: false,
|
|
125
|
-
details: `Data mismatch at row ${i + 1}`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return { isMatch: true, details: "Exact match" };
|
|
130
|
-
}
|
|
131
|
-
async function executeQuery(dataSourceId, sql) {
|
|
132
|
-
const res = await redashFetch("/query_results", {
|
|
133
|
-
method: "POST",
|
|
134
|
-
body: JSON.stringify({ data_source_id: dataSourceId, query: sql, max_age: 0 }),
|
|
135
|
-
});
|
|
136
|
-
let result;
|
|
137
|
-
if (res.job) {
|
|
138
|
-
result = await pollQueryResult(res.job.id, 30);
|
|
139
|
-
}
|
|
140
|
-
else {
|
|
141
|
-
result = res;
|
|
142
|
-
}
|
|
143
|
-
const qr = result.query_result;
|
|
144
|
-
return {
|
|
145
|
-
columns: qr.data.columns.map((c) => c.name),
|
|
146
|
-
rows: qr.data.rows,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
function sortRows(rows, columns) {
|
|
150
|
-
return rows
|
|
151
|
-
.map((row) => columns.map((c) => String(row[c] ?? "")).join("|"))
|
|
152
|
-
.sort();
|
|
153
|
-
}
|
|
154
|
-
export function formatEvalResults(run) {
|
|
155
|
-
const lines = [
|
|
156
|
-
`## Evaluation Results (${run.timestamp})`,
|
|
157
|
-
"",
|
|
158
|
-
`**Overall Accuracy**: ${(run.accuracy.overall * 100).toFixed(1)}% (${run.results.filter((r) => r.match).length}/${run.results.length})`,
|
|
159
|
-
`- Simple: ${(run.accuracy.simple * 100).toFixed(1)}%`,
|
|
160
|
-
`- Medium: ${(run.accuracy.medium * 100).toFixed(1)}%`,
|
|
161
|
-
`- Complex: ${(run.accuracy.complex * 100).toFixed(1)}%`,
|
|
162
|
-
"",
|
|
163
|
-
];
|
|
164
|
-
const failures = run.results.filter((r) => !r.match);
|
|
165
|
-
if (failures.length > 0) {
|
|
166
|
-
lines.push("### Failed Cases:");
|
|
167
|
-
for (const f of failures) {
|
|
168
|
-
lines.push(`- **${f.testCaseId}**: ${f.details}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return lines.join("\n");
|
|
172
|
-
}
|
package/dist/bird/feedback.js
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { ensureConfigDir, getDataSourcePath, loadConfig } from "./config.js";
|
|
3
|
-
import { addExample } from "./few-shot.js";
|
|
4
|
-
export async function loadFeedback(dataSourceId) {
|
|
5
|
-
try {
|
|
6
|
-
const raw = await readFile(getDataSourcePath("feedback", dataSourceId), "utf-8");
|
|
7
|
-
const parsed = JSON.parse(raw);
|
|
8
|
-
return parsed.entries ?? [];
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return [];
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
async function saveFeedback(dataSourceId, entries) {
|
|
15
|
-
await ensureConfigDir();
|
|
16
|
-
const data = { dataSourceId, entries };
|
|
17
|
-
await writeFile(getDataSourcePath("feedback", dataSourceId), JSON.stringify(data, null, 2), "utf-8");
|
|
18
|
-
}
|
|
19
|
-
export async function recordFeedback(dataSourceId, entry) {
|
|
20
|
-
const entries = await loadFeedback(dataSourceId);
|
|
21
|
-
const errorType = entry.rating === "down" && entry.correctSql
|
|
22
|
-
? classifyError(entry.generatedSql, entry.correctSql)
|
|
23
|
-
: undefined;
|
|
24
|
-
const newEntry = {
|
|
25
|
-
...entry,
|
|
26
|
-
id: `fb_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
27
|
-
errorType,
|
|
28
|
-
promotedToFewShot: false,
|
|
29
|
-
createdAt: new Date().toISOString(),
|
|
30
|
-
};
|
|
31
|
-
entries.push(newEntry);
|
|
32
|
-
if (newEntry.rating === "down" && newEntry.correctSql && newEntry.errorType) {
|
|
33
|
-
const config = await loadConfig();
|
|
34
|
-
if (config.bird.feedback.enabled) {
|
|
35
|
-
const sameErrorCount = entries.filter((e) => e.errorType === newEntry.errorType && e.rating === "down" && !e.promotedToFewShot).length;
|
|
36
|
-
if (sameErrorCount >= config.bird.feedback.autoPromoteThreshold) {
|
|
37
|
-
await promoteToFewShot(dataSourceId, newEntry);
|
|
38
|
-
newEntry.promotedToFewShot = true;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
await saveFeedback(dataSourceId, entries);
|
|
43
|
-
return newEntry;
|
|
44
|
-
}
|
|
45
|
-
export function classifyError(generatedSql, correctSql) {
|
|
46
|
-
const genTables = extractTables(generatedSql);
|
|
47
|
-
const correctTables = extractTables(correctSql);
|
|
48
|
-
const genColumns = extractColumns(generatedSql);
|
|
49
|
-
const correctColumns = extractColumns(correctSql);
|
|
50
|
-
const tableDiff = symmetricDifference(genTables, correctTables);
|
|
51
|
-
if (tableDiff.size > 0) {
|
|
52
|
-
return "wrong_table";
|
|
53
|
-
}
|
|
54
|
-
const colDiff = symmetricDifference(genColumns, correctColumns);
|
|
55
|
-
if (colDiff.size > 0) {
|
|
56
|
-
const genJoins = extractJoins(generatedSql);
|
|
57
|
-
const correctJoins = extractJoins(correctSql);
|
|
58
|
-
if (genJoins !== correctJoins) {
|
|
59
|
-
return "wrong_join";
|
|
60
|
-
}
|
|
61
|
-
return "wrong_column";
|
|
62
|
-
}
|
|
63
|
-
const genWhere = extractWhere(generatedSql);
|
|
64
|
-
const correctWhere = extractWhere(correctSql);
|
|
65
|
-
if (genWhere !== correctWhere) {
|
|
66
|
-
return "wrong_filter";
|
|
67
|
-
}
|
|
68
|
-
const genGroup = extractGroupBy(generatedSql);
|
|
69
|
-
const correctGroup = extractGroupBy(correctSql);
|
|
70
|
-
if (genGroup !== correctGroup) {
|
|
71
|
-
return "wrong_aggregation";
|
|
72
|
-
}
|
|
73
|
-
return "other";
|
|
74
|
-
}
|
|
75
|
-
async function promoteToFewShot(dataSourceId, entry) {
|
|
76
|
-
if (!entry.correctSql)
|
|
77
|
-
return;
|
|
78
|
-
const tables = [...extractTables(entry.correctSql)];
|
|
79
|
-
await addExample(dataSourceId, {
|
|
80
|
-
question: entry.question,
|
|
81
|
-
sql: entry.correctSql,
|
|
82
|
-
tables,
|
|
83
|
-
tags: [entry.errorType ?? "correction"],
|
|
84
|
-
notes: `Auto-promoted from feedback. Original error: ${entry.errorType}`,
|
|
85
|
-
source: "feedback",
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
function extractTables(sql) {
|
|
89
|
-
const tables = new Set();
|
|
90
|
-
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
91
|
-
const fromMatch = normalized.match(/\bfrom\s+(\w+)/g);
|
|
92
|
-
if (fromMatch) {
|
|
93
|
-
for (const m of fromMatch) {
|
|
94
|
-
const t = m.replace(/^from\s+/i, "").trim();
|
|
95
|
-
if (t && !SQL_KEYWORDS.has(t))
|
|
96
|
-
tables.add(t);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const joinMatch = normalized.match(/\bjoin\s+(\w+)/g);
|
|
100
|
-
if (joinMatch) {
|
|
101
|
-
for (const m of joinMatch) {
|
|
102
|
-
const t = m.replace(/^join\s+/i, "").trim();
|
|
103
|
-
if (t && !SQL_KEYWORDS.has(t))
|
|
104
|
-
tables.add(t);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
return tables;
|
|
108
|
-
}
|
|
109
|
-
function extractColumns(sql) {
|
|
110
|
-
const columns = new Set();
|
|
111
|
-
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
112
|
-
const selectMatch = normalized.match(/select\s+(.*?)\s+from/);
|
|
113
|
-
if (selectMatch) {
|
|
114
|
-
const cols = selectMatch[1].split(",").map((c) => c.trim().replace(/.*\bas\b\s*/i, ""));
|
|
115
|
-
for (const c of cols) {
|
|
116
|
-
if (c !== "*")
|
|
117
|
-
columns.add(c.replace(/.*\./, ""));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return columns;
|
|
121
|
-
}
|
|
122
|
-
function extractWhere(sql) {
|
|
123
|
-
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
124
|
-
const match = normalized.match(/\bwhere\s+(.*?)(?:\bgroup\b|\border\b|\blimit\b|\bhaving\b|$)/);
|
|
125
|
-
return match ? match[1].trim() : "";
|
|
126
|
-
}
|
|
127
|
-
function extractGroupBy(sql) {
|
|
128
|
-
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
129
|
-
const match = normalized.match(/\bgroup\s+by\s+(.*?)(?:\border\b|\blimit\b|\bhaving\b|$)/);
|
|
130
|
-
return match ? match[1].trim() : "";
|
|
131
|
-
}
|
|
132
|
-
function extractJoins(sql) {
|
|
133
|
-
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
134
|
-
const matches = normalized.match(/\bjoin\s+.*?\bon\s+.*?(?=\bjoin\b|\bwhere\b|\bgroup\b|\border\b|\blimit\b|$)/g);
|
|
135
|
-
return matches ? matches.sort().join("; ") : "";
|
|
136
|
-
}
|
|
137
|
-
function symmetricDifference(a, b) {
|
|
138
|
-
const diff = new Set();
|
|
139
|
-
for (const item of a)
|
|
140
|
-
if (!b.has(item))
|
|
141
|
-
diff.add(item);
|
|
142
|
-
for (const item of b)
|
|
143
|
-
if (!a.has(item))
|
|
144
|
-
diff.add(item);
|
|
145
|
-
return diff;
|
|
146
|
-
}
|
|
147
|
-
const SQL_KEYWORDS = new Set([
|
|
148
|
-
"select", "from", "where", "join", "inner", "left", "right", "outer",
|
|
149
|
-
"cross", "on", "and", "or", "not", "in", "exists", "between", "like",
|
|
150
|
-
"is", "null", "true", "false", "as", "case", "when", "then", "else",
|
|
151
|
-
"end", "group", "by", "order", "having", "limit", "offset", "union",
|
|
152
|
-
"all", "distinct", "set", "values", "into", "insert", "update", "delete",
|
|
153
|
-
]);
|
package/dist/bird/few-shot.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { ensureConfigDir, getDataSourcePath } from "./config.js";
|
|
3
|
-
export async function loadExamples(dataSourceId) {
|
|
4
|
-
try {
|
|
5
|
-
const raw = await readFile(getDataSourcePath("few-shot", dataSourceId), "utf-8");
|
|
6
|
-
const parsed = JSON.parse(raw);
|
|
7
|
-
return parsed.examples ?? [];
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
return [];
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
export async function saveExamples(dataSourceId, examples) {
|
|
14
|
-
await ensureConfigDir();
|
|
15
|
-
const data = { dataSourceId, examples };
|
|
16
|
-
await writeFile(getDataSourcePath("few-shot", dataSourceId), JSON.stringify(data, null, 2), "utf-8");
|
|
17
|
-
}
|
|
18
|
-
export async function addExample(dataSourceId, example) {
|
|
19
|
-
const examples = await loadExamples(dataSourceId);
|
|
20
|
-
const newExample = {
|
|
21
|
-
...example,
|
|
22
|
-
id: `ex_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
23
|
-
createdAt: new Date().toISOString(),
|
|
24
|
-
};
|
|
25
|
-
examples.push(newExample);
|
|
26
|
-
await saveExamples(dataSourceId, examples);
|
|
27
|
-
return newExample;
|
|
28
|
-
}
|
|
29
|
-
export async function removeExample(dataSourceId, exampleId) {
|
|
30
|
-
const examples = await loadExamples(dataSourceId);
|
|
31
|
-
const filtered = examples.filter((e) => e.id !== exampleId);
|
|
32
|
-
if (filtered.length === examples.length)
|
|
33
|
-
return false;
|
|
34
|
-
await saveExamples(dataSourceId, filtered);
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
export function findRelevantExamples(question, prunedTableNames, allExamples, maxCount) {
|
|
38
|
-
if (allExamples.length === 0)
|
|
39
|
-
return [];
|
|
40
|
-
const questionTokens = tokenize(question);
|
|
41
|
-
const scored = allExamples.map((example) => {
|
|
42
|
-
let score = 0;
|
|
43
|
-
const tableOverlap = example.tables.filter((t) => prunedTableNames.some((pt) => pt.toLowerCase() === t.toLowerCase())).length;
|
|
44
|
-
score += tableOverlap * 3;
|
|
45
|
-
const exampleTokens = tokenize(`${example.question} ${example.tags.join(" ")}`);
|
|
46
|
-
const keywordOverlap = questionTokens.filter((t) => exampleTokens.includes(t)).length;
|
|
47
|
-
score += keywordOverlap;
|
|
48
|
-
return { example, score };
|
|
49
|
-
});
|
|
50
|
-
return scored
|
|
51
|
-
.filter((s) => s.score > 0)
|
|
52
|
-
.sort((a, b) => b.score - a.score)
|
|
53
|
-
.slice(0, maxCount)
|
|
54
|
-
.map((s) => s.example);
|
|
55
|
-
}
|
|
56
|
-
export function formatExamplesForPrompt(examples) {
|
|
57
|
-
if (examples.length === 0)
|
|
58
|
-
return "";
|
|
59
|
-
const lines = ["## Similar query examples:\n"];
|
|
60
|
-
examples.forEach((ex, i) => {
|
|
61
|
-
lines.push(`### Example ${i + 1}: "${ex.question}"`);
|
|
62
|
-
lines.push("```sql");
|
|
63
|
-
lines.push(ex.sql);
|
|
64
|
-
lines.push("```");
|
|
65
|
-
if (ex.notes) {
|
|
66
|
-
lines.push(`> Note: ${ex.notes}`);
|
|
67
|
-
}
|
|
68
|
-
lines.push("");
|
|
69
|
-
});
|
|
70
|
-
return lines.join("\n");
|
|
71
|
-
}
|
|
72
|
-
function tokenize(text) {
|
|
73
|
-
const STOP_WORDS = new Set([
|
|
74
|
-
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
75
|
-
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
76
|
-
"should", "may", "might", "shall", "can", "need", "dare", "ought",
|
|
77
|
-
"to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
|
|
78
|
-
"into", "through", "during", "before", "after", "above", "below",
|
|
79
|
-
"and", "but", "or", "not", "no", "nor", "so", "yet", "both",
|
|
80
|
-
"each", "all", "any", "few", "more", "most", "other", "some",
|
|
81
|
-
"such", "than", "too", "very", "just", "about",
|
|
82
|
-
"me", "my", "i", "you", "your", "we", "our", "they", "their",
|
|
83
|
-
"it", "its", "this", "that", "these", "those", "what", "which",
|
|
84
|
-
"who", "whom", "how", "where", "when", "why",
|
|
85
|
-
"show", "give", "tell", "get", "find", "list", "display",
|
|
86
|
-
"의", "가", "이", "은", "는", "을", "를", "에", "에서", "와", "과",
|
|
87
|
-
"도", "로", "으로", "만", "까지", "부터", "에게", "한테", "께",
|
|
88
|
-
"좀", "해줘", "알려줘", "보여줘", "해", "하는", "된", "인",
|
|
89
|
-
]);
|
|
90
|
-
return text
|
|
91
|
-
.toLowerCase()
|
|
92
|
-
.replace(/[^\w\sㄱ-ㅎㅏ-ㅣ가-힣]/g, " ")
|
|
93
|
-
.split(/\s+/)
|
|
94
|
-
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
95
|
-
}
|