redash-mcp 2.2.2 → 3.0.1
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/.mcpregistry_github_token +1 -0
- package/.mcpregistry_registry_token +1 -0
- package/LICENSE +21 -0
- package/README.ko.md +81 -0
- package/README.md +81 -0
- package/dist/bird/complexity.js +87 -0
- package/dist/bird/config.js +73 -0
- package/dist/bird/evaluation.js +184 -0
- package/dist/bird/feedback.js +154 -0
- package/dist/bird/few-shot.js +95 -0
- package/dist/bird/keyword-map.js +56 -0
- package/dist/bird/llm-table-selector.js +59 -0
- package/dist/bird/schema-pruning.js +107 -0
- package/dist/bird/smart-query.js +117 -0
- package/dist/bird/tools.js +288 -0
- package/dist/bird/types.js +1 -0
- package/dist/index.js +101 -163
- package/dist/query-cache.js +0 -2
- package/dist/redash-client.js +64 -0
- package/dist/setup.js +17 -17
- package/dist/sql-guard.js +16 -24
- package/manifest.json +46 -0
- package/package.json +16 -1
- package/server.json +36 -0
- package/.gitattributes +0 -1
- package/docs/sql-safety-guard.md +0 -197
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
await saveFeedback(dataSourceId, entries);
|
|
33
|
+
if (newEntry.rating === "down" && newEntry.correctSql && newEntry.errorType) {
|
|
34
|
+
const config = await loadConfig();
|
|
35
|
+
if (config.bird.feedback.enabled) {
|
|
36
|
+
const sameErrorCount = entries.filter((e) => e.errorType === newEntry.errorType && e.rating === "down" && !e.promotedToFewShot).length;
|
|
37
|
+
if (sameErrorCount >= config.bird.feedback.autoPromoteThreshold) {
|
|
38
|
+
await promoteToFewShot(dataSourceId, newEntry);
|
|
39
|
+
newEntry.promotedToFewShot = true;
|
|
40
|
+
await saveFeedback(dataSourceId, entries);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return newEntry;
|
|
45
|
+
}
|
|
46
|
+
export function classifyError(generatedSql, correctSql) {
|
|
47
|
+
const genTables = extractTables(generatedSql);
|
|
48
|
+
const correctTables = extractTables(correctSql);
|
|
49
|
+
const genColumns = extractColumns(generatedSql);
|
|
50
|
+
const correctColumns = extractColumns(correctSql);
|
|
51
|
+
const tableDiff = symmetricDifference(genTables, correctTables);
|
|
52
|
+
if (tableDiff.size > 0) {
|
|
53
|
+
return "wrong_table";
|
|
54
|
+
}
|
|
55
|
+
const colDiff = symmetricDifference(genColumns, correctColumns);
|
|
56
|
+
if (colDiff.size > 0) {
|
|
57
|
+
const genJoins = extractJoins(generatedSql);
|
|
58
|
+
const correctJoins = extractJoins(correctSql);
|
|
59
|
+
if (genJoins !== correctJoins) {
|
|
60
|
+
return "wrong_join";
|
|
61
|
+
}
|
|
62
|
+
return "wrong_column";
|
|
63
|
+
}
|
|
64
|
+
const genWhere = extractWhere(generatedSql);
|
|
65
|
+
const correctWhere = extractWhere(correctSql);
|
|
66
|
+
if (genWhere !== correctWhere) {
|
|
67
|
+
return "wrong_filter";
|
|
68
|
+
}
|
|
69
|
+
const genGroup = extractGroupBy(generatedSql);
|
|
70
|
+
const correctGroup = extractGroupBy(correctSql);
|
|
71
|
+
if (genGroup !== correctGroup) {
|
|
72
|
+
return "wrong_aggregation";
|
|
73
|
+
}
|
|
74
|
+
return "other";
|
|
75
|
+
}
|
|
76
|
+
async function promoteToFewShot(dataSourceId, entry) {
|
|
77
|
+
if (!entry.correctSql)
|
|
78
|
+
return;
|
|
79
|
+
const tables = [...extractTables(entry.correctSql)];
|
|
80
|
+
await addExample(dataSourceId, {
|
|
81
|
+
question: entry.question,
|
|
82
|
+
sql: entry.correctSql,
|
|
83
|
+
tables,
|
|
84
|
+
tags: [entry.errorType ?? "correction"],
|
|
85
|
+
notes: `Auto-promoted from feedback. Original error: ${entry.errorType}`,
|
|
86
|
+
source: "feedback",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function extractTables(sql) {
|
|
90
|
+
const tables = new Set();
|
|
91
|
+
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
92
|
+
const fromMatch = normalized.match(/\bfrom\s+(\w+)/g);
|
|
93
|
+
if (fromMatch) {
|
|
94
|
+
for (const m of fromMatch) {
|
|
95
|
+
const t = m.replace(/^from\s+/i, "").trim();
|
|
96
|
+
if (t && !SQL_KEYWORDS.has(t))
|
|
97
|
+
tables.add(t);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const joinMatch = normalized.match(/\bjoin\s+(\w+)/g);
|
|
101
|
+
if (joinMatch) {
|
|
102
|
+
for (const m of joinMatch) {
|
|
103
|
+
const t = m.replace(/^join\s+/i, "").trim();
|
|
104
|
+
if (t && !SQL_KEYWORDS.has(t))
|
|
105
|
+
tables.add(t);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return tables;
|
|
109
|
+
}
|
|
110
|
+
function extractColumns(sql) {
|
|
111
|
+
const columns = new Set();
|
|
112
|
+
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
113
|
+
const selectMatch = normalized.match(/select\s+(.*?)\s+from/);
|
|
114
|
+
if (selectMatch) {
|
|
115
|
+
const cols = selectMatch[1].split(",").map((c) => c.trim().replace(/.*\bas\b\s*/i, ""));
|
|
116
|
+
for (const c of cols) {
|
|
117
|
+
if (c !== "*")
|
|
118
|
+
columns.add(c.replace(/.*\./, ""));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return columns;
|
|
122
|
+
}
|
|
123
|
+
function extractWhere(sql) {
|
|
124
|
+
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
125
|
+
const match = normalized.match(/\bwhere\s+(.*?)(?:\bgroup\b|\border\b|\blimit\b|\bhaving\b|$)/);
|
|
126
|
+
return match ? match[1].trim() : "";
|
|
127
|
+
}
|
|
128
|
+
function extractGroupBy(sql) {
|
|
129
|
+
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
130
|
+
const match = normalized.match(/\bgroup\s+by\s+(.*?)(?:\border\b|\blimit\b|\bhaving\b|$)/);
|
|
131
|
+
return match ? match[1].trim() : "";
|
|
132
|
+
}
|
|
133
|
+
function extractJoins(sql) {
|
|
134
|
+
const normalized = sql.toLowerCase().replace(/\s+/g, " ");
|
|
135
|
+
const matches = normalized.match(/\bjoin\s+.*?\bon\s+.*?(?=\bjoin\b|\bwhere\b|\bgroup\b|\border\b|\blimit\b|$)/g);
|
|
136
|
+
return matches ? matches.sort().join("; ") : "";
|
|
137
|
+
}
|
|
138
|
+
function symmetricDifference(a, b) {
|
|
139
|
+
const diff = new Set();
|
|
140
|
+
for (const item of a)
|
|
141
|
+
if (!b.has(item))
|
|
142
|
+
diff.add(item);
|
|
143
|
+
for (const item of b)
|
|
144
|
+
if (!a.has(item))
|
|
145
|
+
diff.add(item);
|
|
146
|
+
return diff;
|
|
147
|
+
}
|
|
148
|
+
const SQL_KEYWORDS = new Set([
|
|
149
|
+
"select", "from", "where", "join", "inner", "left", "right", "outer",
|
|
150
|
+
"cross", "on", "and", "or", "not", "in", "exists", "between", "like",
|
|
151
|
+
"is", "null", "true", "false", "as", "case", "when", "then", "else",
|
|
152
|
+
"end", "group", "by", "order", "having", "limit", "offset", "union",
|
|
153
|
+
"all", "distinct", "set", "values", "into", "insert", "update", "delete",
|
|
154
|
+
]);
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { ensureConfigDir, getConfigDir } from "./config.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_KEYWORD_MAP = {};
|
|
5
|
+
function getMapPath(dataSourceId) {
|
|
6
|
+
return path.join(getConfigDir(), "keyword-map", `${dataSourceId}.json`);
|
|
7
|
+
}
|
|
8
|
+
export async function loadKeywordMap(dataSourceId) {
|
|
9
|
+
try {
|
|
10
|
+
const raw = await readFile(getMapPath(dataSourceId), "utf-8");
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
return parsed.mappings ?? {};
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function saveKeywordMap(dataSourceId, mappings) {
|
|
19
|
+
await ensureConfigDir();
|
|
20
|
+
const data = {
|
|
21
|
+
dataSourceId,
|
|
22
|
+
mappings,
|
|
23
|
+
updatedAt: new Date().toISOString(),
|
|
24
|
+
};
|
|
25
|
+
await writeFile(getMapPath(dataSourceId), JSON.stringify(data, null, 2), "utf-8");
|
|
26
|
+
}
|
|
27
|
+
export async function addMappings(dataSourceId, newMappings) {
|
|
28
|
+
const existing = await loadKeywordMap(dataSourceId);
|
|
29
|
+
for (const [key, values] of Object.entries(newMappings)) {
|
|
30
|
+
const existingValues = existing[key] ?? [];
|
|
31
|
+
existing[key] = [...new Set([...existingValues, ...values])];
|
|
32
|
+
}
|
|
33
|
+
await saveKeywordMap(dataSourceId, existing);
|
|
34
|
+
return existing;
|
|
35
|
+
}
|
|
36
|
+
export async function removeMappings(dataSourceId, keywords) {
|
|
37
|
+
const existing = await loadKeywordMap(dataSourceId);
|
|
38
|
+
for (const key of keywords) {
|
|
39
|
+
delete existing[key];
|
|
40
|
+
}
|
|
41
|
+
await saveKeywordMap(dataSourceId, existing);
|
|
42
|
+
return existing;
|
|
43
|
+
}
|
|
44
|
+
export async function resetMappings(dataSourceId) {
|
|
45
|
+
await saveKeywordMap(dataSourceId, {});
|
|
46
|
+
}
|
|
47
|
+
export async function getEffectiveMap(dataSourceId) {
|
|
48
|
+
const custom = await loadKeywordMap(dataSourceId);
|
|
49
|
+
const merged = { ...DEFAULT_KEYWORD_MAP };
|
|
50
|
+
for (const [key, values] of Object.entries(custom)) {
|
|
51
|
+
const existing = merged[key] ?? [];
|
|
52
|
+
merged[key] = [...new Set([...existing, ...values])];
|
|
53
|
+
}
|
|
54
|
+
return merged;
|
|
55
|
+
}
|
|
56
|
+
export { DEFAULT_KEYWORD_MAP };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
let client = null;
|
|
3
|
+
function getClient() {
|
|
4
|
+
if (client)
|
|
5
|
+
return client;
|
|
6
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
7
|
+
if (!apiKey)
|
|
8
|
+
return null;
|
|
9
|
+
client = new Anthropic({ apiKey });
|
|
10
|
+
return client;
|
|
11
|
+
}
|
|
12
|
+
export function isLLMAvailable() {
|
|
13
|
+
return !!process.env.ANTHROPIC_API_KEY;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Uses Claude Haiku to select relevant tables from a list based on a natural language question.
|
|
17
|
+
* Returns table names that are likely relevant to the question.
|
|
18
|
+
*/
|
|
19
|
+
export async function selectTablesWithLLM(question, fullSchema, topK) {
|
|
20
|
+
const anthropic = getClient();
|
|
21
|
+
if (!anthropic)
|
|
22
|
+
return [];
|
|
23
|
+
try {
|
|
24
|
+
const tableList = fullSchema
|
|
25
|
+
.map((t) => {
|
|
26
|
+
const cols = (t.columns ?? []).map((c) => c.name).join(", ");
|
|
27
|
+
return `- ${t.name} (${cols})`;
|
|
28
|
+
})
|
|
29
|
+
.join("\n");
|
|
30
|
+
const response = await anthropic.messages.create({
|
|
31
|
+
model: "claude-haiku-4-5-20251001",
|
|
32
|
+
max_tokens: 300,
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
role: "user",
|
|
36
|
+
content: `Given this database schema, select the ${topK} most relevant tables for the question.
|
|
37
|
+
|
|
38
|
+
Question: ${question}
|
|
39
|
+
|
|
40
|
+
Tables:
|
|
41
|
+
${tableList}
|
|
42
|
+
|
|
43
|
+
Reply with ONLY a JSON array of table names, e.g. ["User", "Order"]. No explanation.`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
const text = response.content[0].type === "text" ? response.content[0].text : "";
|
|
48
|
+
const match = text.match(/\[.*\]/s);
|
|
49
|
+
if (!match)
|
|
50
|
+
return [];
|
|
51
|
+
const parsed = JSON.parse(match[0]);
|
|
52
|
+
if (!Array.isArray(parsed))
|
|
53
|
+
return [];
|
|
54
|
+
return parsed.filter((name) => typeof name === "string" && fullSchema.some((t) => t.name === name));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export function pruneSchema(question, fullSchema, fewShotExamples, topK, keywordMap) {
|
|
2
|
+
const tokens = tokenizeQuestion(question);
|
|
3
|
+
if (tokens.length === 0) {
|
|
4
|
+
return fullSchema.slice(0, topK).map((t) => ({
|
|
5
|
+
name: t.name,
|
|
6
|
+
columns: t.columns ?? [],
|
|
7
|
+
score: 0,
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
const fewShotTableSet = new Set();
|
|
11
|
+
for (const ex of fewShotExamples) {
|
|
12
|
+
for (const t of ex.tables) {
|
|
13
|
+
fewShotTableSet.add(t.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const scored = fullSchema.map((table) => {
|
|
17
|
+
const score = scoreTable(table, tokens, fewShotTableSet, keywordMap);
|
|
18
|
+
return {
|
|
19
|
+
name: table.name,
|
|
20
|
+
columns: table.columns ?? [],
|
|
21
|
+
score,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return scored
|
|
25
|
+
.sort((a, b) => b.score - a.score)
|
|
26
|
+
.slice(0, topK);
|
|
27
|
+
}
|
|
28
|
+
function expandTokens(tokens, keywordMap) {
|
|
29
|
+
if (!keywordMap)
|
|
30
|
+
return tokens;
|
|
31
|
+
const expanded = [...tokens];
|
|
32
|
+
for (const token of tokens) {
|
|
33
|
+
if (keywordMap[token]) {
|
|
34
|
+
expanded.push(...keywordMap[token]);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const [keyword, mappings] of Object.entries(keywordMap)) {
|
|
38
|
+
if (token.includes(keyword) || keyword.includes(token)) {
|
|
39
|
+
expanded.push(...mappings);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(expanded)];
|
|
44
|
+
}
|
|
45
|
+
function scoreTable(table, tokens, fewShotTables, keywordMap) {
|
|
46
|
+
let score = 0;
|
|
47
|
+
const tableLower = table.name.toLowerCase();
|
|
48
|
+
const expandedTokens = expandTokens(tokens, keywordMap);
|
|
49
|
+
for (const token of expandedTokens) {
|
|
50
|
+
if (tableLower.includes(token)) {
|
|
51
|
+
score += 3;
|
|
52
|
+
}
|
|
53
|
+
else if (tableLower.replace(/_/g, "").includes(token)) {
|
|
54
|
+
score += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const col of table.columns ?? []) {
|
|
58
|
+
const colLower = col.name.toLowerCase();
|
|
59
|
+
for (const token of expandedTokens) {
|
|
60
|
+
if (colLower.includes(token)) {
|
|
61
|
+
score += 1;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (fewShotTables.has(tableLower)) {
|
|
67
|
+
score += 2;
|
|
68
|
+
}
|
|
69
|
+
return score;
|
|
70
|
+
}
|
|
71
|
+
export function formatPrunedSchema(tables) {
|
|
72
|
+
const lines = ["## Relevant tables:\n"];
|
|
73
|
+
for (const table of tables) {
|
|
74
|
+
lines.push(`### ${table.name}`);
|
|
75
|
+
if (table.columns.length > 0) {
|
|
76
|
+
lines.push("| Column | Type |");
|
|
77
|
+
lines.push("| --- | --- |");
|
|
78
|
+
for (const col of table.columns) {
|
|
79
|
+
lines.push(`| ${col.name} | ${col.type ?? "unknown"} |`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
lines.push("");
|
|
83
|
+
}
|
|
84
|
+
return lines.join("\n");
|
|
85
|
+
}
|
|
86
|
+
function tokenizeQuestion(question) {
|
|
87
|
+
const STOP_WORDS = new Set([
|
|
88
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
89
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
90
|
+
"should", "may", "might", "can", "need",
|
|
91
|
+
"to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
|
|
92
|
+
"and", "but", "or", "not", "no", "so",
|
|
93
|
+
"me", "my", "i", "you", "your", "we", "our", "they", "their",
|
|
94
|
+
"it", "its", "this", "that", "what", "which", "how", "where", "when", "why",
|
|
95
|
+
"show", "give", "tell", "get", "find", "list", "display", "many", "much",
|
|
96
|
+
"select", "count", "sum", "avg", "all", "each", "every",
|
|
97
|
+
"의", "가", "이", "은", "는", "을", "를", "에", "에서", "와", "과",
|
|
98
|
+
"도", "로", "으로", "만", "까지", "부터",
|
|
99
|
+
"좀", "해줘", "알려줘", "보여줘", "해", "하는", "된", "인", "수",
|
|
100
|
+
"총", "전체", "모든", "몇", "얼마나",
|
|
101
|
+
]);
|
|
102
|
+
return question
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^\w\sㄱ-ㅎㅏ-ㅣ가-힣]/g, " ")
|
|
105
|
+
.split(/\s+/)
|
|
106
|
+
.filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
107
|
+
}
|
|
@@ -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
|
+
}
|