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,184 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { ensureConfigDir, getDataSourcePath } from "./config.js";
3
+ import { redashFetch } 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
+ for (let i = 0; i < 30; i++) {
139
+ const job = await redashFetch(`/jobs/${res.job.id}`);
140
+ if (job.job.status === 3) {
141
+ result = await redashFetch(`/query_results/${job.job.query_result_id}`);
142
+ break;
143
+ }
144
+ if (job.job.status === 4) {
145
+ throw new Error(`Query failed: ${job.job.error}`);
146
+ }
147
+ await new Promise((r) => setTimeout(r, 1000));
148
+ }
149
+ if (!result)
150
+ throw new Error("Query timed out");
151
+ }
152
+ else {
153
+ result = res;
154
+ }
155
+ const qr = result.query_result;
156
+ return {
157
+ columns: qr.data.columns.map((c) => c.name),
158
+ rows: qr.data.rows,
159
+ };
160
+ }
161
+ function sortRows(rows, columns) {
162
+ return rows
163
+ .map((row) => columns.map((c) => String(row[c] ?? "")).join("|"))
164
+ .sort();
165
+ }
166
+ export function formatEvalResults(run) {
167
+ const lines = [
168
+ `## Evaluation Results (${run.timestamp})`,
169
+ "",
170
+ `**Overall Accuracy**: ${(run.accuracy.overall * 100).toFixed(1)}% (${run.results.filter((r) => r.match).length}/${run.results.length})`,
171
+ `- Simple: ${(run.accuracy.simple * 100).toFixed(1)}%`,
172
+ `- Medium: ${(run.accuracy.medium * 100).toFixed(1)}%`,
173
+ `- Complex: ${(run.accuracy.complex * 100).toFixed(1)}%`,
174
+ "",
175
+ ];
176
+ const failures = run.results.filter((r) => !r.match);
177
+ if (failures.length > 0) {
178
+ lines.push("### Failed Cases:");
179
+ for (const f of failures) {
180
+ lines.push(`- **${f.testCaseId}**: ${f.details}`);
181
+ }
182
+ }
183
+ return lines.join("\n");
184
+ }
@@ -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
+ }