@hedeqiang/dsh-nl2sql 0.11.1 → 0.12.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 +66 -31
- package/README.md +144 -58
- package/README.zh-CN.md +175 -0
- package/channels/dingtalk.js +3 -3
- package/channels/index.js +1 -1
- package/channels/lark.js +35 -7
- package/channels/message.js +19 -2
- package/channels/telegram.js +20 -4
- package/channels/wecom.js +2 -2
- package/im.js +1 -1
- package/index.js +3 -1
- package/lark-bot.js +1 -1
- package/lib/client.js +188 -28
- package/package.json +8 -4
- package/service/access.js +38 -1
- package/service/bots.js +77 -10
- package/service/channels.js +23 -7
- package/service/push.js +2 -2
- package/service/suggestions.js +1 -1
- package/README.en.md +0 -63
- /package/docs/{CHANNELS.md → channels.md} +0 -0
package/service/bots.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Nl2SqlService methods: inbound IM bots (see docs/
|
|
2
|
+
* Nl2SqlService methods: inbound IM bots (see docs/channels.md).
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*
|
|
5
5
|
* Every channel whose kind has `capabilities.bot` may carry
|
|
@@ -279,10 +279,15 @@ export const methods = {
|
|
|
279
279
|
const name = label(platform)
|
|
280
280
|
const userId = m.sender?.id != null ? String(m.sender.id) : undefined
|
|
281
281
|
const resolved = this.resolveActor({ platform, userId, email: m.sender?.email, name: m.sender?.name, chatId: m.chatId != null ? String(m.chatId) : m.chatId, chatType: m.chatType })
|
|
282
|
-
|
|
282
|
+
// Remember who/which group talked to the bot: the member form offers them as one-click picks.
|
|
283
|
+
let chatName
|
|
284
|
+
if (m.chatType === 'group') { try { chatName = typeof m.chatName === 'function' ? await m.chatName() : m.chatName } catch {} }
|
|
285
|
+
if (userId) this.noteSeenIdentity({ platform, kind: 'person', id: userId, name: m.sender?.name, email: m.sender?.email, channelId: m.channelId, refused: Boolean(resolved.refused) })
|
|
286
|
+
if (m.chatType === 'group' && m.chatId) this.noteSeenIdentity({ platform, kind: 'chat', id: String(m.chatId), name: chatName, chatName, channelId: m.channelId, refused: Boolean(resolved.refused) })
|
|
287
|
+
if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
|
|
283
288
|
const actor = resolved.actor
|
|
284
289
|
// Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
|
|
285
|
-
const cmd = text.match(/^\/?(帮助|help
|
|
290
|
+
const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|自动|auto|新对话|reset|start)\s*(.*)$/i)
|
|
286
291
|
// Members with different scopes must not share a conversation: per-user sessions
|
|
287
292
|
// whenever the sender's effective scope or rules differ from the chat's.
|
|
288
293
|
const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
|
|
@@ -292,23 +297,35 @@ export const methods = {
|
|
|
292
297
|
this.state.botConversations ??= {}
|
|
293
298
|
const convs = this.state.botConversations
|
|
294
299
|
const conv = convs[key] ?? { key, channelId: m.channelId ?? null, platform, chatId: m.chatId, chatType: m.chatType, sessionId: null, datasourceId: actor.defaultDatasourceId, createdAt: Date.now() }
|
|
300
|
+
if (chatName && !conv.chatName) conv.chatName = chatName
|
|
295
301
|
const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
296
302
|
if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
|
|
297
303
|
if (cmd) {
|
|
298
304
|
const verb = cmd[1].toLowerCase()
|
|
299
|
-
if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换
|
|
300
|
-
if (verb === '数据源') { await reply(
|
|
305
|
+
if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、自动(按问题自动选择数据源)、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
|
|
306
|
+
if (verb === '数据源') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
|
|
307
|
+
if (verb === '自动' || verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
|
|
301
308
|
if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
|
|
302
|
-
const
|
|
309
|
+
const want = cmd[2].trim().toLowerCase()
|
|
310
|
+
const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd[2].trim()) ?? (want ? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want) : []).find((_, __, arr) => arr.length === 1)
|
|
303
311
|
if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
|
|
304
312
|
conv.datasourceId = target.id
|
|
313
|
+
conv.datasourcePinned = true
|
|
305
314
|
if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
|
|
306
315
|
convs[key] = conv
|
|
307
316
|
await this.save()
|
|
308
|
-
await reply(`已切换到数据源「${target.name}
|
|
317
|
+
await reply(`已切换到数据源「${target.name}」。之后的提问都会查询这个数据源;发送「自动」恢复按问题自动选择。`)
|
|
309
318
|
return
|
|
310
319
|
}
|
|
311
|
-
|
|
320
|
+
// Which data source answers: an explicit 切换 wins; otherwise the question picks the most
|
|
321
|
+
// relevant one among those the member may use (schema + business knowledge), so people
|
|
322
|
+
// don't need to know data source names. The agent can still switch with nl2sql_datasource.
|
|
323
|
+
let ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
|
|
324
|
+
let routed = null
|
|
325
|
+
if (!conv.datasourcePinned && allowedDs.length > 1) {
|
|
326
|
+
routed = await this.routeDatasource(text, allowedDs).catch(() => null)
|
|
327
|
+
if (routed?.ds) ds = routed.ds
|
|
328
|
+
}
|
|
312
329
|
if (!ds) { await reply('没有可访问的数据源,请联系管理员。'); return }
|
|
313
330
|
this.assertAccess(actor, ds)
|
|
314
331
|
if (!this.ctx.get('sessionController')) { await reply('当前 Harness 不支持创建会话(缺少 sessionController)。'); return }
|
|
@@ -338,9 +355,17 @@ export const methods = {
|
|
|
338
355
|
await this.save()
|
|
339
356
|
}
|
|
340
357
|
const where = m.chatType === 'group' ? `群「${conv.chatName ?? m.chatId}」` : '私聊'
|
|
341
|
-
const
|
|
358
|
+
const dsLine = allowedDs.length > 1
|
|
359
|
+
? (conv.datasourcePinned
|
|
360
|
+
? `数据源:${ds.name}(群内已用「切换」指定,请只查询这个数据源)。`
|
|
361
|
+
: `当前数据源:${ds.name}${routed?.ds ? '(按问题自动选择)' : ''}。该成员可用的数据源:\n${await this.datasourceCatalog(allowedDs)}\n请先判断问题属于哪个数据源:若不是当前数据源,先用 nl2sql_datasource action=use 切换再查询;在回答开头注明使用的数据源。`)
|
|
362
|
+
: `数据源:${ds.name}。`
|
|
363
|
+
const prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(${dsLine}\n回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
|
|
342
364
|
const requestId = platform === 'lark' ? `lark-${m.messageId}` : `${platform}-${m.chatId}-${m.messageId}`
|
|
365
|
+
if (conv.sessionId && this.state.sessions[conv.sessionId] !== ds.id) { this.state.sessions[conv.sessionId] = ds.id; await this.save() }
|
|
343
366
|
const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `${name} · ${conv.chatName ?? actor.name}`, requestId, text: prompt, prepare, timeoutMs: 600000 })
|
|
367
|
+
const usedDs = this.findDatasource(this.state.sessions[conv.sessionId]) ?? ds
|
|
368
|
+
if (usedDs.id !== conv.datasourceId) { conv.datasourceId = usedDs.id; convs[key] = conv }
|
|
344
369
|
let body = turn.text
|
|
345
370
|
if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
|
|
346
371
|
if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
|
|
@@ -350,7 +375,7 @@ export const methods = {
|
|
|
350
375
|
let status = turn.reason === 'idle' ? 'ok' : 'error'
|
|
351
376
|
let error = turn.reason === 'idle' ? undefined : turn.reason
|
|
352
377
|
try {
|
|
353
|
-
await m.reply({ title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: shown, footer: `数据问答 · ${
|
|
378
|
+
await m.reply({ title: text.length > 40 ? `${text.slice(0, 40)}…` : text, text: body || '(没有生成文字回答)', results: shown, footer: `数据问答 · ${usedDs.name} · ${actor.name}` })
|
|
354
379
|
} catch (e) {
|
|
355
380
|
status = 'error'
|
|
356
381
|
error = e.message
|
|
@@ -362,6 +387,48 @@ export const methods = {
|
|
|
362
387
|
})
|
|
363
388
|
},
|
|
364
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Pick the data source a question is about, among `candidates`, only on
|
|
392
|
+
* strong evidence: the question names the source (name / database /
|
|
393
|
+
* description), or it matches that source's business knowledge (verified
|
|
394
|
+
* examples, metrics, glossary). Schema keyword overlap alone is too weak
|
|
395
|
+
* (English table names vs Chinese questions), so otherwise returns null and
|
|
396
|
+
* the agent chooses from the catalog in the prompt.
|
|
397
|
+
*/
|
|
398
|
+
async routeDatasource(question, candidates) {
|
|
399
|
+
const q = String(question ?? '').toLowerCase()
|
|
400
|
+
const scored = candidates.map(d => {
|
|
401
|
+
const named = [d.name, d.database, d.description].filter(x => x && String(x).length >= 2).some(x => q.includes(String(x).toLowerCase()))
|
|
402
|
+
let know = 0
|
|
403
|
+
try {
|
|
404
|
+
const f = this.searchKnowledge(d.id, question, 5)
|
|
405
|
+
know = Math.max(0, ...f.examples.map(x => x.score), ...f.metrics.map(x => x.score), ...f.glossary.map(x => x.score))
|
|
406
|
+
} catch {}
|
|
407
|
+
return { ds: d, score: (named ? 2 : 0) + know }
|
|
408
|
+
}).sort((a, b) => b.score - a.score)
|
|
409
|
+
const [top, second] = scored
|
|
410
|
+
if (!top || top.score < 0.5) return null
|
|
411
|
+
return top.score - (second?.score ?? 0) >= 0.3 ? { ds: top.ds, score: top.score } : null
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/** One line per data source for the bot prompt: name, type, description and main tables. */
|
|
415
|
+
async datasourceCatalog(list) {
|
|
416
|
+
const lines = []
|
|
417
|
+
for (const d of list) {
|
|
418
|
+
let tables = ''
|
|
419
|
+
try {
|
|
420
|
+
const schema = this.schemas.get(d.id) ?? await this.readSchemaFile(d.id)
|
|
421
|
+
if (schema?.tables?.length) {
|
|
422
|
+
const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
|
|
423
|
+
const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
|
|
424
|
+
tables = `;主要表:${top.map(t => `${t.name}${t.comment ? `(${String(t.comment).split(/[\s,,。;;((]/)[0].slice(0, 12)})` : ''}`).join(', ')}${schema.tables.length > 12 ? ` 等 ${schema.tables.length} 张` : ''}`
|
|
425
|
+
}
|
|
426
|
+
} catch {}
|
|
427
|
+
lines.push(`- ${d.name}(${d.type ?? 'mysql'}${d.database ? ` · ${d.database}` : ''})${d.description ? `:${d.description.slice(0, 60)}` : ''}${tables}`)
|
|
428
|
+
}
|
|
429
|
+
return lines.join('\n')
|
|
430
|
+
},
|
|
431
|
+
|
|
365
432
|
/** Run tasks with the same key one after another. */
|
|
366
433
|
serialize(key, task) {
|
|
367
434
|
this.queues ??= new Map()
|
package/service/channels.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Nl2SqlService methods: IM channels (see docs/
|
|
2
|
+
* Nl2SqlService methods: IM channels (see docs/channels.md).
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*
|
|
5
5
|
* A channel record (state.channels[]):
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* Secrets live in one credential record per channel: `nl2sql/ch-<id>`.
|
|
12
12
|
*/
|
|
13
13
|
import { ChannelError } from '../channels/http.js'
|
|
14
|
-
import { renderChartPng } from '../channels/message.js'
|
|
14
|
+
import { needsAttachment, renderChartPng } from '../channels/message.js'
|
|
15
|
+
import { writeXlsx } from '../xlsx.js'
|
|
15
16
|
import { getAdapter, kindOf, listCatalog, loadAdapters, requireKind, secretKeys } from '../channels/index.js'
|
|
16
17
|
import { UserError, newId, pick, requireText, upsert } from './shared.js'
|
|
17
18
|
|
|
@@ -292,7 +293,7 @@ export const methods = {
|
|
|
292
293
|
* process' cache) or given inline; each is re-checked against privacy:
|
|
293
294
|
* masked values stay masked because results are stored masked.
|
|
294
295
|
*/
|
|
295
|
-
async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll }) {
|
|
296
|
+
async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode, template, mentionAll, attachFull }) {
|
|
296
297
|
await loadAdapters()
|
|
297
298
|
const c = this.requireChannel(channelId)
|
|
298
299
|
const resolved = []
|
|
@@ -319,15 +320,30 @@ export const methods = {
|
|
|
319
320
|
...(template ? { template } : {}),
|
|
320
321
|
footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
|
|
321
322
|
}
|
|
323
|
+
// Results with more rows than the message can show: attach the full data as Excel
|
|
324
|
+
// where the platform can send files (default on; per send `attachFull: false` to skip).
|
|
325
|
+
const caps = found.kind.capabilities ?? {}
|
|
326
|
+
const shown = caps.tableRows ?? 10
|
|
327
|
+
const wantAttach = attachFull ?? c.attachFull ?? true
|
|
328
|
+
if (caps.files && wantAttach) {
|
|
329
|
+
const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
|
|
330
|
+
if (idx.length) {
|
|
331
|
+
const sheets = idx.map(i => ({ name: (message.results[i].title || `结果${i + 1}`).slice(0, 31), columns: message.results[i].columns, rows: message.results[i].rows }))
|
|
332
|
+
if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
|
|
333
|
+
const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
|
|
334
|
+
const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
|
|
335
|
+
message.attachment = { buffer, fileName: `${safe}_${new Date().toISOString().slice(0, 10)}.xlsx`, resultIndexes: idx }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
322
338
|
const started = Date.now()
|
|
323
339
|
try {
|
|
324
340
|
if (c.needsSecret) throw new ChannelError('channel/config', `渠道「${c.name}」需要重新填写密钥后才能使用`)
|
|
325
341
|
const sent = await found.kind.send(this.adapterChannel(c), await this.channelSecrets(c), message, { renderChartPng, logger: console, fetch: globalThis.fetch })
|
|
326
342
|
c.lastSentAt = Date.now()
|
|
327
343
|
delete c.lastError
|
|
328
|
-
this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, source, sessionId, elapsedMs: Date.now() - started })
|
|
344
|
+
this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent?.images, files: sent?.files, ...(sent?.warning ? { error: sent.warning } : {}), source, sessionId, elapsedMs: Date.now() - started })
|
|
329
345
|
await this.save()
|
|
330
|
-
return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, results: resolved.length }
|
|
346
|
+
return { ok: true, channel: c.name, platform: c.platform, messageId: sent?.messageId ?? null, images: sent?.images ?? 0, files: sent?.files ?? 0, ...(sent?.warning ? { warning: sent.warning } : {}), results: resolved.length }
|
|
331
347
|
} catch (error) {
|
|
332
348
|
c.lastError = error.message
|
|
333
349
|
this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
|
|
@@ -336,8 +352,8 @@ export const methods = {
|
|
|
336
352
|
}
|
|
337
353
|
},
|
|
338
354
|
|
|
339
|
-
recordDelivery({ channel, title, status, error, results, images, source, sessionId, elapsedMs }) {
|
|
340
|
-
this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, ...(channel.platform ? { platform: channel.platform } : {}), title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
|
|
355
|
+
recordDelivery({ channel, title, status, error, results, images, files, source, sessionId, elapsedMs }) {
|
|
356
|
+
this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, ...(channel.platform ? { platform: channel.platform } : {}), title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, files: files ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
|
|
341
357
|
},
|
|
342
358
|
|
|
343
359
|
listDeliveries(limit = 100) {
|
package/service/push.js
CHANGED
|
@@ -64,7 +64,7 @@ export const methods = {
|
|
|
64
64
|
},
|
|
65
65
|
|
|
66
66
|
/** Send the current dashboard (with saved global filters) to a channel. */
|
|
67
|
-
async sendDashboard({ channelId, title, filters, dashboardId, chartMode }) {
|
|
67
|
+
async sendDashboard({ channelId, title, filters, dashboardId, chartMode, attachFull }) {
|
|
68
68
|
const results = []
|
|
69
69
|
const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
|
|
70
70
|
for (const w of d.widgets.slice(0, 10)) {
|
|
@@ -76,7 +76,7 @@ export const methods = {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
if (!results.length) throw new UserError('nl2sql/invalid', '看板为空')
|
|
79
|
-
const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode })
|
|
79
|
+
const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode, attachFull })
|
|
80
80
|
await this.saveDashboardSnapshot(d, results, 'push')
|
|
81
81
|
return sent
|
|
82
82
|
},
|
package/service/suggestions.js
CHANGED
|
@@ -14,7 +14,7 @@ import { admitSql, lexSql } from '../sql-guard.js'
|
|
|
14
14
|
import { textGrams } from './schema.js'
|
|
15
15
|
import { SENSITIVE_NAME, UserError, hashText } from './shared.js'
|
|
16
16
|
|
|
17
|
-
/** Thresholds (see
|
|
17
|
+
/** Thresholds (see docs/development.md). */
|
|
18
18
|
export const SUGGEST = {
|
|
19
19
|
shapeMin: 2, // agent queries with the same literal-free shape
|
|
20
20
|
metricMin: 3, // distinct successful queries using the same aggregate + filter on one table
|
package/README.en.md
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# dsh-nl2sql · Data Q&A
|
|
2
|
-
|
|
3
|
-
An enterprise NL2SQL plugin for DeepSeek Harness: connect databases, ask in plain language, and the agent writes read-only SQL, runs it and charts the result in the conversation. Pin results to dashboards, push them to Lark / Feishu, or ask the bot directly in a Lark group.
|
|
4
|
-
|
|
5
|
-
[中文](README.md)
|
|
6
|
-
|
|
7
|
-
## Features
|
|
8
|
-
|
|
9
|
-
- **Data sources:** MySQL 5.7+, PostgreSQL, Elasticsearch SQL (optionally over SSH), and CSV / Excel uploads stored as local SQLite.
|
|
10
|
-
- **Schema analysis:** table roles, column semantics (time / measure / enum / sensitive), inferred relations, ER view.
|
|
11
|
-
- **Asking:** in a Harness conversation through the `nl2sql_*` tools; results render as ECharts charts or tables.
|
|
12
|
-
- **Drill-down and "why did it change":** split by a dimension, show rows, rank dimensions by their contribution to a change.
|
|
13
|
-
- **Metric library and business knowledge:** glossary, governed metrics (`nl2sql_metric`), column notes, verified examples; import / export; accuracy evaluation against the examples.
|
|
14
|
-
- **Dashboards:** multiple boards, global filters, drag-to-reorder, three widths, snapshots, Excel export.
|
|
15
|
-
- **Channels:** Lark / Feishu, Telegram, WeCom and DingTalk through pluggable adapters ([docs/CHANNELS.md](docs/CHANNELS.md)); push Markdown and results (native charts, images or tables per platform), and @-mention bots that need no public URL.
|
|
16
|
-
- **Automation:** scheduled reports, metric alerts (threshold / change / anomaly / missing), schema change detection.
|
|
17
|
-
- **Safety:**
|
|
18
|
-
- read-only admission with per-dialect lexing, read-only transactions, server-side timeouts, streamed truncation;
|
|
19
|
-
- table / column access policies with masking, full-scan guard;
|
|
20
|
-
- member scopes, viewer role and row-level rules for Lark users;
|
|
21
|
-
- a setting for what result data reaches the model (column statistics by default); audit attributed to people.
|
|
22
|
-
|
|
23
|
-
## Install
|
|
24
|
-
|
|
25
|
-
Requires DeepSeek Harness (desktop or `dsh web`).
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
git clone <this repo> dsh-nl2sql && cd dsh-nl2sql
|
|
29
|
-
pnpm install --ignore-scripts
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Install the directory as a bundle (plugin manager `install_bundle` with the absolute path), or add `@hedeqiang/dsh-nl2sql` to your profile's `dsh.profile.bundles` with a `link:` dependency. Once published, install `@hedeqiang/dsh-nl2sql` from npm.
|
|
33
|
-
|
|
34
|
-
## Use
|
|
35
|
-
|
|
36
|
-
1. Sidebar → **Data Q&A** → **Manage → Data sources**: add a database (a read-only account is recommended) or upload a file.
|
|
37
|
-
2. **Schema** → analyze.
|
|
38
|
-
3. Ask on the **Ask** page, or pick the Data Q&A preset in any conversation.
|
|
39
|
-
4. Pin useful charts to a **Dashboard**; add glossary terms, metrics and examples under **Knowledge**.
|
|
40
|
-
5. **Manage → Push channels / Alerts & reports / Members & access** for Lark push, automation and the Lark bot.
|
|
41
|
-
|
|
42
|
-
## Data and privacy
|
|
43
|
-
|
|
44
|
-
- **Configuration:** `~/.dsh/nl2sql/state.json`.
|
|
45
|
-
- **Results, cache and logs:** `~/.dsh/nl2sql/results.sqlite`.
|
|
46
|
-
- **Uploaded files:** `~/.dsh/nl2sql/files/`.
|
|
47
|
-
- **Passwords and app secrets:** the Harness credential store.
|
|
48
|
-
- **What reaches the model:** **Settings → Data shown to the model** decides this; by default it is schema, enum values and per-column result statistics.
|
|
49
|
-
- **Lark:** content pushed to Lark leaves the machine.
|
|
50
|
-
|
|
51
|
-
## Development
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
npm test # whole suite; local MySQL / PostgreSQL optional (skipped when absent)
|
|
55
|
-
npm test -- --fast # unit + UI tests only
|
|
56
|
-
node scripts/build-client.mjs # regenerate lib/client.js after editing client-src/
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
See [DEVELOPMENT.md](DEVELOPMENT.md) and [eval/README.md](eval/README.md).
|
|
60
|
-
|
|
61
|
-
## License
|
|
62
|
-
|
|
63
|
-
[MIT](LICENSE)
|
|
File without changes
|