@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/service/bots.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Nl2SqlService methods: inbound IM bots (see docs/CHANNELS.md).
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
- if (resolved.refused) { await reply(resolved.refused); return }
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|数据源|切换数据源|切换|新对话|reset|start)\s*(.*)$/i)
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命令:数据源(查看可用数据源)、切换 <名称>、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
300
- if (verb === '数据源') { await reply(`可用数据源:${allowedDs.map(d => d.name).join('、') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}`); return }
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 target = allowedDs.find(d => d.name === cmd[2].trim() || d.id === cmd[2].trim())
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
- const ds = this.findDatasource(conv.datasourceId) ?? allowedDs[0]
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 prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果;不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
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: `数据问答 · ${ds.name} · ${actor.name}` })
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()
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Nl2SqlService methods: IM channels (see docs/CHANNELS.md).
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
  },
@@ -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 DEVELOPMENT.md). */
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