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.
@@ -0,0 +1 @@
1
+ ghu_dsrWVzfpwaX2kRTPNSRLrvL3zmHffn3VITVr
@@ -0,0 +1 @@
1
+ {"token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtY3AtcmVnaXN0cnkiLCJleHAiOjE3NzQ3OTU0NjgsIm5iZiI6MTc3NDc5NTE2OCwiaWF0IjoxNzc0Nzk1MTY4LCJhdXRoX21ldGhvZCI6ImdpdGh1Yi1hdCIsImF1dGhfbWV0aG9kX3N1YiI6InNlb2I3MTciLCJwZXJtaXNzaW9ucyI6W3siYWN0aW9uIjoicHVibGlzaCIsInJlc291cmNlIjoiaW8uZ2l0aHViLnNlb2I3MTcvKiJ9XX0.Pkr8qgN33oPEpJbyouEcfZf3fbEL0gcQ4-_fuqHbBrjqPFzN3It0AbrNMBSVTkHNLkdF9PWF1kj3U61refW4Aw","expires_at":1774795468}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 seob717
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md CHANGED
@@ -58,6 +58,14 @@ npx redash-mcp setup
58
58
 
59
59
  설치 마법사가 실행되며 Claude Desktop, Claude Code(CLI), 또는 둘 다 선택하여 설정할 수 있습니다.
60
60
 
61
+ ### 셸 스크립트로 설치
62
+
63
+ Node.js, Claude Desktop, MCP 설정을 한번에 처리합니다:
64
+
65
+ ```bash
66
+ curl -fsSL https://raw.githubusercontent.com/seob717/redash-mcp/main/install.sh | bash
67
+ ```
68
+
61
69
  ### 수동 설치
62
70
 
63
71
  #### 1. Redash API 키 발급
@@ -140,3 +148,76 @@ Claude에게 자연어로 요청하면 됩니다:
140
148
  - "저장된 쿼리 목록 보여줘"
141
149
  - "매출 대시보드 위젯 목록 알려줘"
142
150
  - "일별 가입자 수가 100명 이하로 떨어지면 알림 만들어줘"
151
+
152
+ ### 예제 1: 자연어로 데이터 조회
153
+
154
+ > **프롬프트**: "이번 달 신규 가입자 수 알려줘"
155
+
156
+ **실행 흐름:**
157
+ 1. `list_data_sources` → 데이터소스 목록 확인
158
+ 2. `smart_query` → 질문 분석, 관련 테이블(`User`) 자동 선택, SQL 생성 가이드 제공
159
+ 3. `run_query` → 생성된 SQL 실행
160
+
161
+ **결과:**
162
+ ```
163
+ 이번 달 신규 가입자는 18,197명입니다.
164
+ ```
165
+
166
+ ### 예제 2: 복잡한 비즈니스 질문
167
+
168
+ > **프롬프트**: "지난주 신규 가입자 중 결제한 사용자 비율은?"
169
+
170
+ **실행 흐름:**
171
+ 1. `smart_query` → 질문 분석, `User`·`Payment` 테이블 자동 선택, JOIN 쿼리 가이드 제공
172
+ 2. `run_query` → SQL 실행
173
+
174
+ **결과:**
175
+ ```
176
+ 지난주 신규 가입자 1,204명 중 결제한 사용자는 312명 (25.9%)입니다.
177
+ ```
178
+
179
+ ### 예제 3: 쿼리 저장 + 대시보드 생성
180
+
181
+ > **프롬프트**: "월별 매출 추이 쿼리를 만들고 대시보드에 추가해줘"
182
+
183
+ **실행 흐름:**
184
+ 1. `smart_query` → 매출 관련 테이블 분석
185
+ 2. `create_query` → "월별 매출 추이" 쿼리 저장
186
+ 3. `create_dashboard` → "매출 대시보드" 생성
187
+ 4. `get_query` → 저장된 쿼리의 시각화 ID 확인
188
+ 5. `add_widget` → 대시보드에 차트 위젯 추가
189
+
190
+ **결과:**
191
+ ```
192
+ "매출 대시보드"가 생성되었고, 월별 매출 추이 차트가 추가되었습니다.
193
+ Redash에서 확인: https://your-redash.com/dashboard/monthly-revenue
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Privacy Policy
199
+
200
+ ### 데이터 수집 및 처리
201
+
202
+ redash-mcp는 **로컬 MCP 서버**로, 사용자의 Redash 인스턴스와 직접 통신합니다. 중간 서버를 거치지 않습니다.
203
+
204
+ | 항목 | 설명 |
205
+ |------|------|
206
+ | **Redash API Key** | 로컬 환경변수(`REDASH_API_KEY`)로만 저장. 외부 전송 없음 |
207
+ | **쿼리 내용 및 결과** | MCP 프로토콜을 통해 로컬 클라이언트(Claude Desktop/Code)에만 전달 |
208
+ | **BIRD SQL 설정** | 로컬 파일(`~/.redash-mcp/`)에만 저장. Few-shot 예제, 키워드 맵, 피드백 등 |
209
+ | **LLM Fallback** | `ANTHROPIC_API_KEY` 설정 시, 테이블명 목록만 Anthropic API로 전송. 쿼리 데이터·결과는 전송하지 않음 |
210
+
211
+ ### 제3자 공유
212
+
213
+ 사용자 데이터를 제3자에게 판매하거나 공유하지 않습니다. LLM Fallback 기능 사용 시 Anthropic API로 테이블명 목록만 전송되며, 이는 사용자가 `ANTHROPIC_API_KEY`를 설정한 경우에만 활성화됩니다.
214
+
215
+ ### 데이터 보존
216
+
217
+ - 설정 파일: `~/.redash-mcp/` 디렉토리에 로컬 저장 (사용자가 직접 삭제 가능)
218
+ - 쿼리 캐시: 메모리에만 저장, 서버 종료 시 소멸
219
+ - 스키마 캐시: 메모리에만 저장, 10분 TTL 후 자동 소멸
220
+
221
+ ### 연락처
222
+
223
+ 문의 및 보안 이슈 보고: [GitHub Issues](https://github.com/seob717/redash-mcp/issues)
package/README.md CHANGED
@@ -58,6 +58,14 @@ npx redash-mcp setup
58
58
 
59
59
  The setup wizard will guide you through configuring Claude Desktop, Claude Code (CLI), or both.
60
60
 
61
+ ### Shell Script Install
62
+
63
+ Installs Node.js, Claude Desktop, and MCP config all at once:
64
+
65
+ ```bash
66
+ curl -fsSL https://raw.githubusercontent.com/seob717/redash-mcp/main/install.sh | bash
67
+ ```
68
+
61
69
  ### Manual Setup
62
70
 
63
71
  #### 1. Get your Redash API Key
@@ -140,3 +148,76 @@ Just ask Claude in natural language:
140
148
  - "List all saved queries"
141
149
  - "Show widgets in the revenue dashboard"
142
150
  - "Create an alert when daily signups drop below 100"
151
+
152
+ ### Example 1: Query data with natural language
153
+
154
+ > **Prompt**: "How many new users signed up this month?"
155
+
156
+ **Tool flow:**
157
+ 1. `list_data_sources` → Identify the target data source
158
+ 2. `smart_query` → Analyze the question, auto-select the `User` table, provide SQL generation guidance
159
+ 3. `run_query` → Execute the generated SQL
160
+
161
+ **Result:**
162
+ ```
163
+ There were 18,197 new signups this month.
164
+ ```
165
+
166
+ ### Example 2: Complex business questions
167
+
168
+ > **Prompt**: "What percentage of last week's new users made a purchase?"
169
+
170
+ **Tool flow:**
171
+ 1. `smart_query` → Analyze the question, auto-select `User` and `Payment` tables, provide JOIN query guidance
172
+ 2. `run_query` → Execute the SQL
173
+
174
+ **Result:**
175
+ ```
176
+ Out of 1,204 new users last week, 312 made a purchase (25.9%).
177
+ ```
178
+
179
+ ### Example 3: Create a query and dashboard
180
+
181
+ > **Prompt**: "Create a monthly revenue trend query and add it to a dashboard"
182
+
183
+ **Tool flow:**
184
+ 1. `smart_query` → Analyze revenue-related tables
185
+ 2. `create_query` → Save the "Monthly Revenue Trend" query
186
+ 3. `create_dashboard` → Create a "Revenue Dashboard"
187
+ 4. `get_query` → Get the visualization ID from the saved query
188
+ 5. `add_widget` → Add the chart widget to the dashboard
189
+
190
+ **Result:**
191
+ ```
192
+ Created "Revenue Dashboard" with the monthly revenue trend chart.
193
+ View in Redash: https://your-redash.com/dashboard/monthly-revenue
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Privacy Policy
199
+
200
+ ### Data Collection and Processing
201
+
202
+ redash-mcp is a **local MCP server** that communicates directly with your Redash instance. No intermediate servers are involved.
203
+
204
+ | Item | Description |
205
+ |------|-------------|
206
+ | **Redash API Key** | Stored only as a local environment variable (`REDASH_API_KEY`). Never transmitted externally. |
207
+ | **Query content & results** | Delivered only to the local MCP client (Claude Desktop/Code) via the MCP protocol. |
208
+ | **BIRD SQL settings** | Stored only in local files (`~/.redash-mcp/`). Includes few-shot examples, keyword maps, and feedback. |
209
+ | **LLM Fallback** | When `ANTHROPIC_API_KEY` is set, only table name lists are sent to the Anthropic API. Query data and results are never transmitted. |
210
+
211
+ ### Third-Party Sharing
212
+
213
+ We do not sell or share user data with third parties. When the LLM Fallback feature is active, only table name lists are sent to the Anthropic API, and only when the user has explicitly configured an `ANTHROPIC_API_KEY`.
214
+
215
+ ### Data Retention
216
+
217
+ - **Config files**: Stored locally in `~/.redash-mcp/` (user can delete at any time)
218
+ - **Query cache**: In-memory only, cleared on server shutdown
219
+ - **Schema cache**: In-memory only, auto-expires after 10-minute TTL
220
+
221
+ ### Contact
222
+
223
+ For inquiries and security reports: [GitHub Issues](https://github.com/seob717/redash-mcp/issues)
@@ -0,0 +1,87 @@
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
+ }
@@ -0,0 +1,73 @@
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
+ }
@@ -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
+ }