@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
package/index.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the NL2SQL bundle.
|
|
3
|
+
*
|
|
4
|
+
* One service implements every operation; the Web page reaches it through the
|
|
5
|
+
* private `/nl2sql` Connection RPC channel and the Agent reaches it through the
|
|
6
|
+
* `nl2sql_*` tools. Data sources and settings live in a JSON file under the
|
|
7
|
+
* DSH home; passwords live in the Harness credential store.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { DIALECTS } from './drivers.js'
|
|
13
|
+
import { ResultStore } from './store.js'
|
|
14
|
+
import * as shared from './service/shared.js'
|
|
15
|
+
import { methods as m0 } from './service/datasources.js'
|
|
16
|
+
import { methods as m1 } from './service/access.js'
|
|
17
|
+
import { methods as m2 } from './service/schema.js'
|
|
18
|
+
import { methods as m3 } from './service/query.js'
|
|
19
|
+
import { methods as m4 } from './service/dashboards.js'
|
|
20
|
+
import { methods as m5 } from './service/push.js'
|
|
21
|
+
import { methods as m6 } from './service/lark.js'
|
|
22
|
+
import { methods as m7 } from './service/evaluation.js'
|
|
23
|
+
import { methods as m8 } from './service/alerts.js'
|
|
24
|
+
import { methods as m9 } from './service/uploads.js'
|
|
25
|
+
import { methods as m10 } from './service/admin.js'
|
|
26
|
+
import { methods as mLarkActions } from './service/lark-actions.js'
|
|
27
|
+
import { methods as mSuggest } from './service/suggestions.js'
|
|
28
|
+
const { CHART_TYPES, DEFAULT_SETTINGS, RECORD_KINDS, RPC_PATH, Records, UserError, newId, pick, toUserError } = shared
|
|
29
|
+
|
|
30
|
+
export const name = 'nl2sql'
|
|
31
|
+
export const inject = ['tools', 'connection']
|
|
32
|
+
|
|
33
|
+
class Nl2SqlService {
|
|
34
|
+
constructor(ctx, config) {
|
|
35
|
+
this.ctx = ctx
|
|
36
|
+
this.config = config ?? {}
|
|
37
|
+
this.dir = this.config.dataDir ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'nl2sql')
|
|
38
|
+
this.file = join(this.dir, 'state.json')
|
|
39
|
+
this.state = { version: 1, datasources: [], settings: structuredClone(DEFAULT_SETTINGS), current: null, sessions: {}, dashboard: [], knowledge: {}, reports: [], dashboardFilters: [], channels: [], dashboards: [], currentDashboard: null, evals: {}, alerts: [], users: [], larkBot: null }
|
|
40
|
+
this.drivers = new Map()
|
|
41
|
+
this.schemas = new Map()
|
|
42
|
+
this.results = new Map()
|
|
43
|
+
this.writing = Promise.resolve()
|
|
44
|
+
this.saves = 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async load() {
|
|
48
|
+
await mkdir(this.dir, { recursive: true })
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(await readFile(this.file, 'utf8'))
|
|
51
|
+
this.state = { ...this.state, ...raw, settings: { ...structuredClone(DEFAULT_SETTINGS), ...(raw.settings ?? {}), retention: { ...DEFAULT_SETTINGS.retention, ...(raw.settings?.retention ?? {}) } } }
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code !== 'ENOENT') console.warn('nl2sql: cannot read state, starting empty: %s', error.message)
|
|
54
|
+
}
|
|
55
|
+
// Multi-dashboard migration: the legacy single dashboard becomes "默认看板".
|
|
56
|
+
if (!this.state.dashboards?.length) {
|
|
57
|
+
this.state.dashboards = [{ id: 'db-default', name: '默认看板', description: '', widgets: this.state.dashboard ?? [], filters: this.state.dashboardFilters ?? [], createdAt: Date.now() }]
|
|
58
|
+
this.state.currentDashboard = 'db-default'
|
|
59
|
+
}
|
|
60
|
+
this.syncLegacyDashboard()
|
|
61
|
+
try {
|
|
62
|
+
this.store = new ResultStore(join(this.dir, 'results.sqlite'))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.warn('nl2sql: result store unavailable, results stay in memory: %s', error.message)
|
|
65
|
+
}
|
|
66
|
+
this.records = new Records(this.store, () => this.state.settings.retention)
|
|
67
|
+
// Staged uploads older than one hour are removed on start and on every new upload.
|
|
68
|
+
this.cleanupUploads().catch(() => {})
|
|
69
|
+
// One-time migration: growing logs move from state.json into SQLite.
|
|
70
|
+
if (this.store) {
|
|
71
|
+
const moved = {}
|
|
72
|
+
this.store.transaction(() => {
|
|
73
|
+
for (const [kind, key] of RECORD_KINDS) {
|
|
74
|
+
const list = this.state[key]
|
|
75
|
+
if (!Array.isArray(list) || !list.length) continue
|
|
76
|
+
for (const rec of [...list].reverse()) this.records.put(kind, rec.id ? rec : { ...rec, id: newId(kind.slice(0, 2)) })
|
|
77
|
+
moved[key] = list.length
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
let changed = false
|
|
81
|
+
for (const [, key] of RECORD_KINDS) if (key in this.state) { delete this.state[key]; changed = true }
|
|
82
|
+
if (changed) {
|
|
83
|
+
if (Object.keys(moved).length) console.warn('nl2sql: migrated logs to SQLite: %s', JSON.stringify(moved))
|
|
84
|
+
await this.save()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Keep the legacy `dashboard` / `dashboardFilters` fields pointing at the current dashboard. */
|
|
90
|
+
syncLegacyDashboard() {
|
|
91
|
+
const d = this.currentDashboardObj()
|
|
92
|
+
this.state.dashboard = d.widgets
|
|
93
|
+
this.state.dashboardFilters = d.filters ?? []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
currentDashboardObj() {
|
|
97
|
+
return this.state.dashboards.find(d => d.id === this.state.currentDashboard) ?? this.state.dashboards[0]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bound per-session maps: keep the most recent 2000 bindings; eval exclusions only for live eval sessions. */
|
|
101
|
+
pruneSessionMaps() {
|
|
102
|
+
const keep = 2000
|
|
103
|
+
const ids = Object.keys(this.state.sessions ?? {})
|
|
104
|
+
if (ids.length > keep) for (const id of ids.slice(0, ids.length - keep)) { delete this.state.sessions[id]; delete this.state.sessionActors?.[id]; delete this.state.evalExclusions?.[id] }
|
|
105
|
+
for (const id of Object.keys(this.state.sessionActors ?? {})) if (!(id in (this.state.sessions ?? {}))) delete this.state.sessionActors[id]
|
|
106
|
+
for (const id of Object.keys(this.state.evalExclusions ?? {})) if (!(id in (this.state.sessions ?? {}))) delete this.state.evalExclusions[id]
|
|
107
|
+
const convs = Object.values(this.state.larkSessions ?? {}).sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0))
|
|
108
|
+
if (convs.length > 500) for (const c of convs.slice(500)) delete this.state.larkSessions[c.key]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
save() {
|
|
112
|
+
if (++this.saves % 50 === 0 || this.saves === 1) this.pruneSessionMaps()
|
|
113
|
+
// `dashboard` / `dashboardFilters` mirror the current dashboard for old code paths; don't write them twice.
|
|
114
|
+
const { dashboard: _d, dashboardFilters: _f, ...persisted } = this.state
|
|
115
|
+
const snapshot = JSON.stringify(persisted, null, 2)
|
|
116
|
+
this.writing = this.writing.then(async () => {
|
|
117
|
+
const tmp = `${this.file}.tmp`
|
|
118
|
+
await writeFile(tmp, snapshot, { mode: 0o600 })
|
|
119
|
+
await rename(tmp, this.file)
|
|
120
|
+
}).catch(error => console.warn('nl2sql: save failed: %s', error.message))
|
|
121
|
+
return this.writing
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async dispose() {
|
|
125
|
+
this.disposed = true
|
|
126
|
+
if (this.alertTimer) clearInterval(this.alertTimer)
|
|
127
|
+
if (this.alertKick) clearTimeout(this.alertKick)
|
|
128
|
+
// Let in-flight evaluation workers notice disposal before the store closes.
|
|
129
|
+
const jobs = [...(this.evalJobs?.values() ?? [])]
|
|
130
|
+
if (jobs.length) {
|
|
131
|
+
let timer
|
|
132
|
+
await Promise.race([Promise.allSettled(jobs), new Promise(r => { timer = setTimeout(r, 3000) })])
|
|
133
|
+
clearTimeout(timer)
|
|
134
|
+
}
|
|
135
|
+
if (this.larkBot) { try { await this.larkBot.stop() } catch {} }
|
|
136
|
+
// Card-button analyses still running in the background (service/lark-actions.js).
|
|
137
|
+
if (this.larkActionJobs?.size) await Promise.race([Promise.allSettled([...this.larkActionJobs]), new Promise(r => setTimeout(r, 3000).unref?.())])
|
|
138
|
+
await Promise.allSettled([...this.drivers.values()].map(p => p.then(d => d.close())))
|
|
139
|
+
this.drivers.clear()
|
|
140
|
+
await this.writing
|
|
141
|
+
this.store?.close()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
}
|
|
145
|
+
for (const m of [m0, m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, mSuggest, mLarkActions]) Object.defineProperties(Nl2SqlService.prototype, Object.getOwnPropertyDescriptors(m))
|
|
146
|
+
const DATASOURCE_PARAM = { type: 'string', description: 'Data source id or name. Omit to use the one bound to this session.' }
|
|
147
|
+
|
|
148
|
+
function textBlock(text) {
|
|
149
|
+
return [{ type: 'text', text }]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function registerTools(ctx, service) {
|
|
153
|
+
const tools = ctx.tools
|
|
154
|
+
const wrap = (fn) => async (args, exec) => {
|
|
155
|
+
try {
|
|
156
|
+
return await fn(args ?? {}, exec)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
tools.register({
|
|
163
|
+
name: 'nl2sql_datasource',
|
|
164
|
+
description: 'List the configured enterprise data sources (MySQL, PostgreSQL, Elasticsearch) or choose the one this session queries. Data sources are created by the user in the Data Q&A page; you cannot create them or see passwords.',
|
|
165
|
+
parameters: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: {
|
|
168
|
+
action: { type: 'string', enum: ['list', 'use'], description: 'list: show data sources and the current binding. use: bind this session to `datasource`.' },
|
|
169
|
+
datasource: DATASOURCE_PARAM,
|
|
170
|
+
},
|
|
171
|
+
required: ['action'],
|
|
172
|
+
additionalProperties: false,
|
|
173
|
+
},
|
|
174
|
+
output: {
|
|
175
|
+
schema: { type: 'object' },
|
|
176
|
+
render: (_args, value) => textBlock(JSON.stringify(value, null, 1)),
|
|
177
|
+
},
|
|
178
|
+
isConcurrencySafe: () => true,
|
|
179
|
+
presentCall: (args) => ({ card: 'generic', title: args?.action === 'use' ? `选择数据源 ${args?.datasource ?? ''}` : '列出数据源', kind: 'read' }),
|
|
180
|
+
execute: wrap(async (args, exec) => {
|
|
181
|
+
const sessionId = exec.agent?.id
|
|
182
|
+
const actor = sessionId ? service.state.sessionActors?.[sessionId] : undefined
|
|
183
|
+
const visible = service.listDatasources().filter(d => !Array.isArray(actor?.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
184
|
+
if (args.action === 'use') {
|
|
185
|
+
if (!args.datasource) throw new Error('`datasource` is required for action=use')
|
|
186
|
+
const ds = service.requireDatasource(args.datasource)
|
|
187
|
+
service.assertAccess(actor, ds)
|
|
188
|
+
const info = await service.bindSession(sessionId, ds.id)
|
|
189
|
+
return { bound: info.datasourceId, datasources: visible.map(d => pick(d, ['id', 'name', 'type', 'database', 'serverVersion'])) }
|
|
190
|
+
}
|
|
191
|
+
const info = sessionId ? service.sessionInfo(sessionId) : null
|
|
192
|
+
return {
|
|
193
|
+
boundToSession: info?.boundDatasourceId ?? (actor ? null : service.state.current) ?? null,
|
|
194
|
+
datasources: visible.map(d => pick(d, ['id', 'name', 'type', 'host', 'database', 'serverVersion', 'description', 'schemaAnalyzedAt'])),
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
tools.register({
|
|
200
|
+
name: 'nl2sql_schema',
|
|
201
|
+
description: 'Read the analyzed schema of the session data source. Without `tables`: a compact overview of every table (role, approximate rows, comment, columns with semantic roles such as time/measure/enum/id/sensitive) and table relations (declared foreign keys plus inferred ones). With `tables`: full column types, comments, indexes and sampled enum values for those tables. With `sample`: up to 20 masked example rows of one table (only when privacy settings allow).',
|
|
202
|
+
parameters: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
datasource: DATASOURCE_PARAM,
|
|
206
|
+
tables: { type: 'array', items: { type: 'string' }, description: 'Table names to describe in detail.' },
|
|
207
|
+
sample: { type: 'string', description: 'One table name to fetch example rows from.' },
|
|
208
|
+
refresh: { type: 'boolean', description: 'Re-read information_schema instead of the cached analysis.' },
|
|
209
|
+
question: { type: 'string', description: 'The user\'s question, verbatim. Pass it with the overview call: on large databases only the tables most relevant to it are described in full (all other tables are listed by name).' },
|
|
210
|
+
},
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
},
|
|
213
|
+
output: {
|
|
214
|
+
schema: { type: 'object' },
|
|
215
|
+
render: (_args, value) => textBlock(value.text ?? JSON.stringify(value)),
|
|
216
|
+
},
|
|
217
|
+
isConcurrencySafe: () => true,
|
|
218
|
+
presentCall: (args) => ({ card: 'generic', title: args?.tables?.length ? `查看表结构 ${args.tables.join(', ')}` : args?.sample ? `样例数据 ${args.sample}` : '表结构概览', kind: 'read' }),
|
|
219
|
+
execute: wrap(async (args, exec) => {
|
|
220
|
+
const ds = service.datasourceForSession(exec.agent?.id, args.datasource)
|
|
221
|
+
const schema = await service.visibleSchema(ds, { refresh: Boolean(args.refresh) })
|
|
222
|
+
if (args.sample) {
|
|
223
|
+
const s = await service.sampleRows(ds, args.sample, 10, service.actorFor(exec.agent?.id))
|
|
224
|
+
return { datasource: ds.name, text: `${args.sample} sample rows\n${s.columns.join(' | ')}\n${s.rows.map(r => r.map(v => v === null ? 'NULL' : String(v)).join(' | ')).join('\n')}` }
|
|
225
|
+
}
|
|
226
|
+
return { datasource: ds.name, tableCount: schema.tables.length, text: service.schemaForModel(schema, ds, args.tables, { question: typeof args.question === 'string' ? args.question : undefined }) }
|
|
227
|
+
}),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const chartParam = {
|
|
231
|
+
type: 'object',
|
|
232
|
+
description: 'Optional chart for the result. Omit to let the UI pick automatically from column types.',
|
|
233
|
+
properties: {
|
|
234
|
+
type: { type: 'string', enum: CHART_TYPES, description: 'line/area for trends, bar/barh/stacked for comparisons, pie/funnel for composition, scatter for correlation, kpi for single numbers, table for details.' },
|
|
235
|
+
x: { type: 'string', description: 'Category or time column.' },
|
|
236
|
+
y: { type: 'array', items: { type: 'string' }, description: 'Numeric value columns.' },
|
|
237
|
+
series: { type: 'string', description: 'Optional column splitting one value column into series.' },
|
|
238
|
+
},
|
|
239
|
+
required: ['type'],
|
|
240
|
+
additionalProperties: false,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const queryOutput = {
|
|
244
|
+
schema: { type: 'object' },
|
|
245
|
+
render: (_args, value) => textBlock(JSON.stringify(value.model, null, 1)),
|
|
246
|
+
presentationMeta: (_args, value) => value.display,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
tools.register({
|
|
250
|
+
name: 'nl2sql_query',
|
|
251
|
+
description: 'Execute ONE read-only SQL statement on the session data source, written in that source\'s dialect (see the dialect line of nl2sql_schema): MySQL (5.7 has no CTE/window functions; ONLY_FULL_GROUP_BY), PostgreSQL, or Elasticsearch SQL (indices are tables, no JOINs). Rows are capped and a server time limit applies. The full result is shown to the user as a table/chart in the conversation; you receive a resultId plus a summary or a few rows, depending on privacy settings. Prefer aggregated queries for charts.',
|
|
252
|
+
parameters: {
|
|
253
|
+
type: 'object',
|
|
254
|
+
properties: {
|
|
255
|
+
sql: { type: 'string', description: 'The single read-only SQL statement.' },
|
|
256
|
+
title: { type: 'string', description: 'Short human title for the result, in the user\'s language.' },
|
|
257
|
+
datasource: DATASOURCE_PARAM,
|
|
258
|
+
chart: chartParam,
|
|
259
|
+
confirmScan: { type: 'boolean', description: 'Set true only after a scan-guard refusal when the user accepts a full scan of a large table.' },
|
|
260
|
+
},
|
|
261
|
+
required: ['sql'],
|
|
262
|
+
additionalProperties: false,
|
|
263
|
+
},
|
|
264
|
+
output: queryOutput,
|
|
265
|
+
timeoutMs: 660000,
|
|
266
|
+
presentCall: (args) => ({ card: 'generic', title: args?.title ? `查询:${args.title}` : '执行 SQL', kind: 'read' }),
|
|
267
|
+
execute: wrap(async (args, exec) => {
|
|
268
|
+
const result = await service.runQuery({ sql: args.sql, datasourceId: args.datasource, sessionId: exec.agent?.id, title: args.title, chart: args.chart, source: 'agent', confirmScan: Boolean(args.confirmScan) })
|
|
269
|
+
return { model: service.modelView(result), display: displayMeta(result) }
|
|
270
|
+
}),
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
tools.register({
|
|
274
|
+
name: 'nl2sql_chart',
|
|
275
|
+
description: 'Render an existing query result (by resultId from nl2sql_query) as a different chart without re-running SQL.',
|
|
276
|
+
parameters: {
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {
|
|
279
|
+
resultId: { type: 'string', description: 'resultId returned by nl2sql_query.' },
|
|
280
|
+
title: { type: 'string', description: 'Chart title.' },
|
|
281
|
+
chart: chartParam,
|
|
282
|
+
},
|
|
283
|
+
required: ['resultId', 'chart'],
|
|
284
|
+
additionalProperties: false,
|
|
285
|
+
},
|
|
286
|
+
output: queryOutput,
|
|
287
|
+
isConcurrencySafe: () => true,
|
|
288
|
+
presentCall: (args) => ({ card: 'generic', title: args?.title ? `图表:${args.title}` : '生成图表', kind: 'other' }),
|
|
289
|
+
execute: wrap(async (args, exec) => {
|
|
290
|
+
const result = await service.rechart({ ...args, sessionId: exec?.agent?.id })
|
|
291
|
+
return { model: { resultId: result.resultId, chart: result.chart }, display: displayMeta(result) }
|
|
292
|
+
}),
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function registerSendTool(ctx, service) {
|
|
297
|
+
ctx.tools.register({
|
|
298
|
+
name: 'nl2sql_send',
|
|
299
|
+
description: 'Send a message to an IM channel (Lark / Feishu group or user) configured by the user in the Data Q&A page: a title, a Markdown body, and up to 10 query results by resultId (rendered as native interactive charts, KPI lines or tables). The Markdown supports headings, bold/italic, lists, quotes, code blocks, links, tables (small ones inline, larger ones become table components), <font color=\'green|red|grey\'>…</font> and <at id=all></at>. Only send when the user asks to share/push/send, or when a scheduled report instructs you to. Use action=list to see channels.',
|
|
300
|
+
parameters: {
|
|
301
|
+
type: 'object',
|
|
302
|
+
properties: {
|
|
303
|
+
action: { type: 'string', enum: ['send', 'list'], description: 'send (default) or list channels.' },
|
|
304
|
+
channel: { type: 'string', description: 'Channel id or name; omit for the default channel.' },
|
|
305
|
+
title: { type: 'string', description: 'Card title.' },
|
|
306
|
+
text: { type: 'string', description: 'Markdown body: summary, conclusions, tables. Write it for the IM reader; do not paste raw result rows that are already attached via resultIds.' },
|
|
307
|
+
chartMode: { type: 'string', enum: ['native', 'image', 'table'], description: 'How attached results render: native card charts (default), PNG images (app bots only), or tables.' },
|
|
308
|
+
resultIds: { type: 'array', items: { type: 'string' }, description: 'resultIds from nl2sql_query / nl2sql_chart / nl2sql_overview to include.' },
|
|
309
|
+
includeSql: { type: 'boolean', description: 'Also include each result\'s SQL.' },
|
|
310
|
+
},
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
},
|
|
313
|
+
output: { schema: { type: 'object' }, render: (_args, value) => textBlock(JSON.stringify(value, null, 1)) },
|
|
314
|
+
presentCall: (args) => ({ card: 'generic', title: args?.action === 'list' ? '列出推送渠道' : `推送到 ${args?.channel ?? '默认渠道'}:${args?.title ?? ''}`, kind: 'other' }),
|
|
315
|
+
execute: async (args, exec) => {
|
|
316
|
+
try {
|
|
317
|
+
if (args.action === 'list') return { channels: service.listChannels().map(c => pick(c, ['id', 'name', 'type', 'description', 'isDefault'])) }
|
|
318
|
+
return await service.sendToChannel({ channelId: args.channel, title: args.title, text: args.text, resultIds: args.resultIds ?? [], includeSql: args.includeSql, chartMode: args.chartMode, source: 'agent', sessionId: exec.agent?.id })
|
|
319
|
+
} catch (error) {
|
|
320
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function registerOverviewTool(ctx, service) {
|
|
327
|
+
ctx.tools.register({
|
|
328
|
+
name: 'nl2sql_overview',
|
|
329
|
+
description: 'Generate an automatic overview of one table of the session data source: KPI totals, daily trend, weekly comparison, category distributions and top-N charts, chosen from the analyzed column roles. Each chart is shown to the user; you receive their resultIds and summaries. Set pin=true only when the user asks to put them on the dashboard.',
|
|
330
|
+
parameters: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
properties: {
|
|
333
|
+
table: { type: 'string', description: 'Table (or Elasticsearch index) name exactly as listed by nl2sql_schema.' },
|
|
334
|
+
pin: { type: 'boolean', description: 'Also pin every generated chart to the Data Q&A dashboard.' },
|
|
335
|
+
datasource: DATASOURCE_PARAM,
|
|
336
|
+
},
|
|
337
|
+
required: ['table'],
|
|
338
|
+
additionalProperties: false,
|
|
339
|
+
},
|
|
340
|
+
output: {
|
|
341
|
+
schema: { type: 'object' },
|
|
342
|
+
render: (_args, value) => textBlock(JSON.stringify(value.model, null, 1)),
|
|
343
|
+
presentationMeta: (_args, value) => value.display,
|
|
344
|
+
},
|
|
345
|
+
timeoutMs: 660000,
|
|
346
|
+
presentCall: (args) => ({ card: 'generic', title: `自动概览 ${args?.table ?? ''}`, kind: 'read' }),
|
|
347
|
+
execute: async (args, exec) => {
|
|
348
|
+
try {
|
|
349
|
+
const ds = service.datasourceForSession(exec.agent?.id, args.datasource)
|
|
350
|
+
service.assertCanWriteSql(service.actorFor(exec.agent?.id), '生成自动概览')
|
|
351
|
+
if (args.pin && service.actorFor(exec.agent?.id)) throw new Error('只有本机用户可以把图表固定到看板')
|
|
352
|
+
const { table, results } = await service.tableOverview({ datasourceId: ds.id, table: args.table, pin: Boolean(args.pin) })
|
|
353
|
+
return {
|
|
354
|
+
model: { table, pinned: Boolean(args.pin), charts: results.map(r => (r.error ? { title: r.title, error: r.error } : { title: r.title, ...service.modelView(r) })) },
|
|
355
|
+
display: { overview: true, table, results: results.map(r => (r.error ? { title: r.title, sql: r.sql, error: r.error } : displayMeta(r))) },
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function registerKnowledgeTool(ctx, service) {
|
|
365
|
+
ctx.tools.register({
|
|
366
|
+
name: 'nl2sql_knowledge',
|
|
367
|
+
description: 'Business knowledge of the session data source. action=search: find verified question→SQL examples, glossary terms and metric definitions related to `question`; call it before writing SQL for a non-trivial question and reuse matching definitions. action=save_example: after the user confirms a result is correct, store `question` and its final `sql` as a verified example for future questions.',
|
|
368
|
+
parameters: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
action: { type: 'string', enum: ['search', 'save_example'], description: 'search or save_example.' },
|
|
372
|
+
question: { type: 'string', description: 'The user question (search) or the question to store (save_example).' },
|
|
373
|
+
sql: { type: 'string', description: 'save_example only: the verified read-only SQL.' },
|
|
374
|
+
datasource: DATASOURCE_PARAM,
|
|
375
|
+
},
|
|
376
|
+
required: ['action', 'question'],
|
|
377
|
+
additionalProperties: false,
|
|
378
|
+
},
|
|
379
|
+
output: { schema: { type: 'object' }, render: (_args, value) => textBlock(JSON.stringify(value, null, 1)) },
|
|
380
|
+
isConcurrencySafe: (args) => args?.action === 'search',
|
|
381
|
+
presentCall: (args) => ({ card: 'generic', title: args?.action === 'save_example' ? '保存样例问答' : '检索业务知识', kind: args?.action === 'save_example' ? 'edit' : 'search' }),
|
|
382
|
+
execute: async (args, exec) => {
|
|
383
|
+
try {
|
|
384
|
+
const ds = service.datasourceForSession(exec.agent?.id, args.datasource)
|
|
385
|
+
if (args.action === 'save_example') {
|
|
386
|
+
if (!args.sql) throw new UserError('nl2sql/invalid', '`sql` is required for save_example')
|
|
387
|
+
const who = service.actorFor(exec.agent?.id)
|
|
388
|
+
if (who && who.role !== 'admin') throw new Error('只有本机用户或管理员可以保存样例问答')
|
|
389
|
+
await service.saveKnowledgeItem({ datasourceId: ds.id, kind: 'example', item: { question: args.question, sql: args.sql } })
|
|
390
|
+
return { saved: true, datasource: ds.name }
|
|
391
|
+
}
|
|
392
|
+
const found = service.searchKnowledge(ds.id, args.question)
|
|
393
|
+
// Evaluation sessions must not see the reference answer they are graded against.
|
|
394
|
+
const excluded = service.state.evalExclusions?.[exec.agent?.id]
|
|
395
|
+
if (excluded) found.examples = found.examples.filter(e => e.id !== excluded)
|
|
396
|
+
return { datasource: ds.name, ...found }
|
|
397
|
+
} catch (error) {
|
|
398
|
+
throw error instanceof UserError ? new Error(error.message) : error
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function displayMeta(result) {
|
|
405
|
+
// Tool presentation metadata must be lossless JSON: drop undefined / non-finite values.
|
|
406
|
+
return JSON.parse(JSON.stringify({
|
|
407
|
+
resultId: result.resultId,
|
|
408
|
+
datasourceId: result.datasourceId,
|
|
409
|
+
datasourceName: result.datasourceName,
|
|
410
|
+
title: result.title,
|
|
411
|
+
sql: result.sql,
|
|
412
|
+
rowCount: result.rowCount,
|
|
413
|
+
truncated: result.truncated,
|
|
414
|
+
elapsedMs: result.elapsedMs,
|
|
415
|
+
chart: result.chart,
|
|
416
|
+
columns: result.columns,
|
|
417
|
+
...(result.cached ? { cached: true } : {}),
|
|
418
|
+
}, (_k, v) => (typeof v === 'number' && !Number.isFinite(v) ? null : v)))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------- RPC
|
|
422
|
+
|
|
423
|
+
function registerRpc(ctx, service) {
|
|
424
|
+
const methods = {
|
|
425
|
+
listDatasources: () => service.listDatasources(),
|
|
426
|
+
saveDatasource: (p) => service.saveDatasource(p),
|
|
427
|
+
deleteDatasource: (p) => service.deleteDatasource(p.id),
|
|
428
|
+
setCurrent: (p) => service.setCurrent(p.id),
|
|
429
|
+
testConnection: (p) => service.testConnection(p),
|
|
430
|
+
analyzeSchema: (p) => service.annotatedSchema(p.id, { refresh: Boolean(p.refresh) }),
|
|
431
|
+
getSettings: () => service.getSettings(),
|
|
432
|
+
saveSettings: (p) => service.saveSettings(p),
|
|
433
|
+
runQuery: (p) => service.runQuery({ sql: p.sql, datasourceId: p.datasourceId, title: p.title, chart: p.chart, source: 'ui', confirmScan: Boolean(p.confirmScan), useCache: Boolean(p.useCache) }),
|
|
434
|
+
getResult: (p) => service.getResult(p.resultId),
|
|
435
|
+
sessionInfo: (p) => service.sessionInfo(p.sessionId),
|
|
436
|
+
bindSession: (p) => service.bindSession(p.sessionId, p.datasourceId ?? null),
|
|
437
|
+
listDashboard: (p) => service.listDashboard(p?.dashboardId),
|
|
438
|
+
pinWidget: (p) => service.pinWidget(p),
|
|
439
|
+
unpinWidget: (p) => service.unpinWidget(p.id),
|
|
440
|
+
runWidget: (p) => service.runWidget(p.id, p.filters, { useCache: p.fresh !== true }),
|
|
441
|
+
filterCandidates: (p) => service.filterCandidates(p?.dashboardId),
|
|
442
|
+
getDashboardFilters: (p) => service.getDashboardFilters(p?.dashboardId),
|
|
443
|
+
saveDashboardFilters: (p) => service.saveDashboardFilters(p.filters ?? [], p.dashboardId),
|
|
444
|
+
submitFeedback: (p) => service.submitFeedback(p),
|
|
445
|
+
listFeedback: (p) => service.listFeedback(p ?? {}),
|
|
446
|
+
feedbackStats: (p) => service.feedbackStats(p?.datasourceId),
|
|
447
|
+
resolveFeedback: (p) => service.resolveFeedback(p),
|
|
448
|
+
deleteFeedback: (p) => service.deleteFeedback(p.id),
|
|
449
|
+
savePolicy: (p) => service.savePolicy(p),
|
|
450
|
+
listChannels: () => service.listChannels(),
|
|
451
|
+
saveChannel: (p) => service.saveChannel(p),
|
|
452
|
+
deleteChannel: (p) => service.deleteChannel(p.id),
|
|
453
|
+
testChannel: (p) => service.testChannel(p.id),
|
|
454
|
+
listChannelChats: (p) => service.listChannelChats(p ?? {}),
|
|
455
|
+
sendToChannel: (p) => service.sendToChannel({ ...p, source: p.source === 'dashboard' ? 'dashboard' : 'ui' }),
|
|
456
|
+
sendDashboard: (p) => service.sendDashboard(p),
|
|
457
|
+
previewAssistantMessage: (p) => service.previewAssistantMessage(p),
|
|
458
|
+
sendAssistantMessage: (p) => service.sendAssistantMessage(p),
|
|
459
|
+
listDeliveries: () => service.listDeliveries(),
|
|
460
|
+
listHistory: (p) => service.listHistory(p ?? {}),
|
|
461
|
+
clearHistory: () => service.clearHistory(),
|
|
462
|
+
deleteHistory: (p) => service.deleteHistory(p ?? {}),
|
|
463
|
+
explain: (p) => service.explain({ sql: p.sql, datasourceId: p.datasourceId }),
|
|
464
|
+
getKnowledge: (p) => service.getKnowledge(p.datasourceId),
|
|
465
|
+
saveKnowledgeItem: (p) => service.saveKnowledgeItem(p),
|
|
466
|
+
deleteKnowledgeItem: (p) => service.deleteKnowledgeItem(p),
|
|
467
|
+
draftNotes: (p) => service.draftNotes(p.datasourceId),
|
|
468
|
+
updateWidget: (p) => service.updateWidget(p),
|
|
469
|
+
listReports: () => service.listReports(),
|
|
470
|
+
saveReport: (p) => service.saveReport(p),
|
|
471
|
+
setReportActive: (p) => service.setReportActive(p),
|
|
472
|
+
runReportNow: (p) => service.runReportNow(p.id),
|
|
473
|
+
deleteReport: (p) => service.deleteReport(p.id),
|
|
474
|
+
tableOverview: (p) => service.tableOverview(p),
|
|
475
|
+
dialects: () => Object.entries(DIALECTS).map(([id, d]) => ({ id, ...d })),
|
|
476
|
+
moveWidget: (p) => service.moveWidget(p),
|
|
477
|
+
listDashboards: () => service.listDashboards(),
|
|
478
|
+
saveDashboard: (p) => service.saveDashboard(p),
|
|
479
|
+
deleteDashboard: (p) => service.deleteDashboard(p.id),
|
|
480
|
+
selectDashboard: (p) => service.selectDashboard(p.id),
|
|
481
|
+
transferWidget: (p) => service.transferWidget(p),
|
|
482
|
+
snapshotDashboard: (p) => service.snapshotDashboard(p ?? {}),
|
|
483
|
+
listDashboardSnapshots: (p) => service.listDashboardSnapshots(p?.dashboardId),
|
|
484
|
+
cacheStats: () => service.cacheStats(),
|
|
485
|
+
exportExcel: (p) => service.exportExcel(p ?? {}),
|
|
486
|
+
clearCache: () => service.clearCache(),
|
|
487
|
+
exportKnowledge: (p) => service.exportKnowledge(p ?? {}),
|
|
488
|
+
importKnowledge: (p) => service.importKnowledge(p),
|
|
489
|
+
listSchemaChanges: (p) => service.listSchemaChanges(p ?? {}),
|
|
490
|
+
ackSchemaChange: (p) => service.ackSchemaChange(p),
|
|
491
|
+
checkAllSchemas: () => service.checkAllSchemas(),
|
|
492
|
+
listUsers: () => service.listUsers(),
|
|
493
|
+
saveUser: (p) => service.saveUser(p),
|
|
494
|
+
deleteUser: (p) => service.deleteUser(p.id),
|
|
495
|
+
usageByActor: (p) => service.usageByActor(p?.days ?? 30),
|
|
496
|
+
adminLog: () => service.records.list('admin', { limit: 200 }),
|
|
497
|
+
larkBotStatus: () => service.larkBotStatus(),
|
|
498
|
+
saveLarkBot: (p) => service.saveLarkBot(p),
|
|
499
|
+
restartLarkBot: async () => { await service.restartLarkBot(); return service.larkBotStatus() },
|
|
500
|
+
queryMetric: (p) => service.queryMetric({ ...p, source: 'ui' }),
|
|
501
|
+
parseUpload: (p) => service.parseUpload(p),
|
|
502
|
+
importUpload: (p) => service.importUpload(p),
|
|
503
|
+
listFileTables: (p) => service.listFileTables(p.datasourceId),
|
|
504
|
+
dropFileTable: (p) => service.dropFileTable(p),
|
|
505
|
+
drill: (p) => service.drill(p),
|
|
506
|
+
drillDimensions: (p) => service.drillDimensions(p.resultId),
|
|
507
|
+
explainChange: (p) => service.explainChange(p),
|
|
508
|
+
evalSet: (p) => service.evalSet(p.datasourceId),
|
|
509
|
+
setEvalCase: (p) => service.setEvalCase(p),
|
|
510
|
+
runEval: (p) => service.runEval(p),
|
|
511
|
+
listEvalRuns: (p) => service.listEvalRuns(p?.datasourceId),
|
|
512
|
+
getEvalRun: (p) => service.getEvalRun(p.id),
|
|
513
|
+
listAlerts: () => service.listAlerts(),
|
|
514
|
+
saveAlert: (p) => service.saveAlert(p),
|
|
515
|
+
deleteAlert: (p) => service.deleteAlert(p.id),
|
|
516
|
+
checkAlert: (p) => service.checkAlert(p.id, { dryRun: Boolean(p.dryRun), force: Boolean(p.force) }),
|
|
517
|
+
listAlertEvents: (p) => service.listAlertEvents(p?.alertId),
|
|
518
|
+
previewAlert: (p) => service.previewAlert(p),
|
|
519
|
+
schedulerStatus: () => service.schedulerStatus(),
|
|
520
|
+
// Uploads staged on the server (upload once, import by uploadId).
|
|
521
|
+
stageUpload: (p) => service.stageUpload(p),
|
|
522
|
+
stageUploadChunk: (p) => service.stageUploadChunk(p),
|
|
523
|
+
// Settings page.
|
|
524
|
+
resetSettings: (p) => service.resetSettings(p ?? {}),
|
|
525
|
+
storageStats: () => service.storageStats(),
|
|
526
|
+
purgeRecords: (p) => service.purgeRecords(p ?? {}),
|
|
527
|
+
exportConfig: () => service.exportConfig(),
|
|
528
|
+
importConfig: (p) => service.importConfig(p ?? {}),
|
|
529
|
+
aboutInfo: () => service.aboutInfo(),
|
|
530
|
+
// Ask page and onboarding.
|
|
531
|
+
askSuggestions: (p) => service.askSuggestions(p ?? {}),
|
|
532
|
+
onboardingStatus: () => service.onboardingStatus(),
|
|
533
|
+
dismissOnboarding: (p) => service.dismissOnboarding({ dismissed: p?.dismissed !== false }),
|
|
534
|
+
reorderWidgets: (p) => service.reorderWidgets(p ?? {}),
|
|
535
|
+
// Knowledge suggestions mined from history and feedback.
|
|
536
|
+
listKnowledgeSuggestions: (p) => service.listKnowledgeSuggestions(p ?? {}),
|
|
537
|
+
suggestionCount: (p) => service.suggestionCount(p ?? {}),
|
|
538
|
+
acceptSuggestion: (p) => service.acceptSuggestion(p ?? {}),
|
|
539
|
+
dismissSuggestion: (p) => service.dismissSuggestion(p ?? {}),
|
|
540
|
+
}
|
|
541
|
+
// One exact POST route inside Connection's authenticated /api fence:
|
|
542
|
+
// body { method, payload } → { ok, value } | { ok: false, error }.
|
|
543
|
+
ctx.connection.fetch.register({
|
|
544
|
+
path: RPC_PATH,
|
|
545
|
+
methods: ['POST'],
|
|
546
|
+
requestBody: 'buffered',
|
|
547
|
+
fetch: async (request) => {
|
|
548
|
+
let body
|
|
549
|
+
try {
|
|
550
|
+
body = await request.json()
|
|
551
|
+
} catch {
|
|
552
|
+
return Response.json({ ok: false, error: { code: 'nl2sql/bad-request', message: 'body is not JSON' } }, { status: 400 })
|
|
553
|
+
}
|
|
554
|
+
const method = typeof body?.method === 'string' ? body.method : ''
|
|
555
|
+
const fn = Object.hasOwn(methods, method) ? methods[method] : undefined
|
|
556
|
+
if (!fn) return Response.json({ ok: false, error: { code: 'nl2sql/unknown-method', message: `unknown method ${method}` } }, { status: 404 })
|
|
557
|
+
try {
|
|
558
|
+
const value = await fn(body.payload ?? {})
|
|
559
|
+
return Response.json({ ok: true, value: value ?? null }, { headers: { 'cache-control': 'no-store' } })
|
|
560
|
+
} catch (error) {
|
|
561
|
+
const e = toUserError(error)
|
|
562
|
+
return Response.json({ ok: false, error: { code: e.code ?? 'nl2sql/error', message: e.message } }, { headers: { 'cache-control': 'no-store' } })
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function apply(ctx, config) {
|
|
569
|
+
const service = new Nl2SqlService(ctx, config)
|
|
570
|
+
await service.load()
|
|
571
|
+
if (typeof config?.onService === 'function') config.onService(service) // test hook
|
|
572
|
+
ctx.effect(() => () => service.dispose(), 'nl2sql: pools')
|
|
573
|
+
registerRpc(ctx, service)
|
|
574
|
+
registerTools(ctx, service)
|
|
575
|
+
registerKnowledgeTool(ctx, service)
|
|
576
|
+
registerOverviewTool(ctx, service)
|
|
577
|
+
registerSendTool(ctx, service)
|
|
578
|
+
// Inbound Lark bot (long connection), if enabled.
|
|
579
|
+
service.restartLarkBot().catch(error => console.warn('nl2sql lark: start failed: %s', error.message))
|
|
580
|
+
service.scheduleAlerts()
|
|
581
|
+
registerAnalysisTools(ctx, service)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function registerAnalysisTools(ctx, service) {
|
|
585
|
+
const out = {
|
|
586
|
+
schema: { type: 'object' },
|
|
587
|
+
render: (_args, value) => textBlock(JSON.stringify(value.model ?? value, null, 1)),
|
|
588
|
+
presentationMeta: (_args, value) => (value.display ? JSON.parse(JSON.stringify(value.display)) : value.why ? JSON.parse(JSON.stringify({ why: value.why })) : null),
|
|
589
|
+
}
|
|
590
|
+
const fail = (fn) => async (args, exec) => {
|
|
591
|
+
try { return await fn(args ?? {}, exec) } catch (error) { throw error instanceof UserError ? new Error(error.message) : error }
|
|
592
|
+
}
|
|
593
|
+
ctx.tools.register({
|
|
594
|
+
name: 'nl2sql_metric',
|
|
595
|
+
description: 'Query a governed metric from the metric library by name (its expression, table, filter and time column are fixed by the definition — do not rewrite them yourself). Use this whenever the question asks for a metric that exists in the library (see nl2sql_knowledge search results). Optional time grain, dimensions (allowed ones only), time range and filters. Returns a chart result like nl2sql_query.',
|
|
596
|
+
parameters: {
|
|
597
|
+
type: 'object',
|
|
598
|
+
properties: {
|
|
599
|
+
metric: { type: 'string', description: 'Metric name (or synonym) from the metric library.' },
|
|
600
|
+
grain: { type: 'string', enum: ['hour', 'day', 'week', 'month', 'year'], description: 'Time bucket; omit for a single total.' },
|
|
601
|
+
dimensions: { type: 'array', items: { type: 'string' }, description: 'Columns to split by (must be allowed by the metric).' },
|
|
602
|
+
range: { type: 'string', enum: ['today', 'yesterday', 'last7d', 'last30d', 'last90d', 'thisMonth', 'lastMonth', 'thisYear'], description: 'Relative time range on the metric time column.' },
|
|
603
|
+
from: { type: 'string', description: 'Start date (inclusive), YYYY-MM-DD; use instead of range.' },
|
|
604
|
+
to: { type: 'string', description: 'End date (exclusive), YYYY-MM-DD.' },
|
|
605
|
+
filters: { type: 'array', items: { type: 'object', properties: { column: { type: 'string' }, op: { type: 'string', enum: ['=', '!=', 'in', '>', '>=', '<', '<='] }, value: { type: 'string' }, values: { type: 'array', items: { type: 'string' } } }, required: ['column', 'op'], additionalProperties: false }, description: 'Extra conditions.' },
|
|
606
|
+
title: { type: 'string', description: 'Short chart title.' },
|
|
607
|
+
datasource: DATASOURCE_PARAM,
|
|
608
|
+
},
|
|
609
|
+
required: ['metric'],
|
|
610
|
+
additionalProperties: false,
|
|
611
|
+
},
|
|
612
|
+
output: out,
|
|
613
|
+
timeoutMs: 660000,
|
|
614
|
+
presentCall: (args) => ({ card: 'generic', title: `指标:${args?.metric ?? ''}`, kind: 'read' }),
|
|
615
|
+
execute: fail(async (args, exec) => {
|
|
616
|
+
const r = await service.queryMetric({ ...args, datasourceId: args.datasource, sessionId: exec.agent?.id, source: 'agent' })
|
|
617
|
+
return { model: { ...service.modelView(r), metric: r.metric, sql: r.sql }, display: displayMeta(r) }
|
|
618
|
+
}),
|
|
619
|
+
})
|
|
620
|
+
ctx.tools.register({
|
|
621
|
+
name: 'nl2sql_drill',
|
|
622
|
+
description: 'Drill into an existing result: mode=breakdown splits the result (or one point of it, via xValue) by a dimension column; mode=detail lists the raw rows behind one point; mode=why compares two periods of a time-series result and ranks dimensions by how much of the change they explain (attribution). Use mode=why for questions like "为什么昨天下降了".',
|
|
623
|
+
parameters: {
|
|
624
|
+
type: 'object',
|
|
625
|
+
properties: {
|
|
626
|
+
resultId: { type: 'string', description: 'resultId from nl2sql_query / nl2sql_metric.' },
|
|
627
|
+
mode: { type: 'string', enum: ['breakdown', 'detail', 'why'] },
|
|
628
|
+
xValue: { type: 'string', description: 'The x value (category or time bucket) to drill into.' },
|
|
629
|
+
dimension: { type: 'string', description: 'breakdown: column to split by.' },
|
|
630
|
+
dimensions: { type: 'array', items: { type: 'string' }, description: 'why: candidate dimensions (default: inferred).' },
|
|
631
|
+
current: { type: 'string', description: 'why: current period x value (default: last point).' },
|
|
632
|
+
baseline: { type: 'string', description: 'why: baseline period x value (default: previous point).' },
|
|
633
|
+
},
|
|
634
|
+
required: ['resultId', 'mode'],
|
|
635
|
+
additionalProperties: false,
|
|
636
|
+
},
|
|
637
|
+
output: out,
|
|
638
|
+
timeoutMs: 660000,
|
|
639
|
+
presentCall: (args) => ({ card: 'generic', title: args?.mode === 'why' ? '变化归因' : args?.mode === 'detail' ? `明细 ${args?.xValue ?? ''}` : `按 ${args?.dimension ?? ''} 拆分`, kind: 'read' }),
|
|
640
|
+
execute: fail(async (args, exec) => {
|
|
641
|
+
if (args.mode === 'why') {
|
|
642
|
+
const r = await service.explainChange({ ...args, sessionId: exec.agent?.id })
|
|
643
|
+
return { why: r, model: { summary: r.summary, current: r.current, baseline: r.baseline, measure: r.measure, dimensions: r.dimensions.slice(0, 5).map(d => d.attribution ? { dimension: d.dimension, concentration: Number(d.attribution.concentration.toFixed(2)), top: d.attribution.items.slice(0, 5) } : { dimension: d.dimension, error: d.error }) } }
|
|
644
|
+
}
|
|
645
|
+
const r = await service.drill({ ...args, sessionId: exec.agent?.id, source: 'agent' })
|
|
646
|
+
return { model: service.modelView(r), display: displayMeta(r) }
|
|
647
|
+
}),
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|