@hedeqiang/dsh-nl2sql 0.14.2 → 0.14.3

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 CHANGED
@@ -5,6 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.14.3] - 2026-11-13
9
+
10
+ ### Changed
11
+ - **Cleaner bot reply cards:** intermediate queries the agent marks with `show: false`, and empty results when the answer has non-empty ones, are no longer shown (an empty probe of the wrong table read like "no such order").
12
+ - **Buttons only where they help:** results looking up one order / id / hash, single rows and empty results get no "split by dimension", "details" or "pin to dashboard" buttons.
13
+ - **Single records as field lists:** one-row results are shown as one "field: value" per line, with long identifiers in full (no truncation, no 万 / 亿 abbreviation of order numbers).
14
+ - **Codes with meanings:** status / type codes are shown as `10(处理中)` when the column comment (e.g. `0=新建,10=处理中,11=交易失败`) or a knowledge column note defines them — in IM cards and tables.
15
+
8
16
  ## [0.14.2] - 2026-11-13
9
17
 
10
18
  ### Fixed
@@ -151,7 +159,8 @@ These features are tested with local databases and fake IM / Lark servers; real
151
159
 
152
160
  - Redesigned UI; Lark push (Markdown and native charts); access control, feedback loop, dashboard global filters; PostgreSQL / Elasticsearch, SSH tunnels, privacy settings, automatic overviews, scheduled reports; semantic layer, ER graph, scan guard, history and audit, dashboards; first version.
153
161
 
154
- [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.2...HEAD
162
+ [Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.3...HEAD
163
+ [0.14.3]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.2...v0.14.3
155
164
  [0.14.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.1...v0.14.2
156
165
  [0.14.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...v0.14.1
157
166
  [0.14.0]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.13.1...v0.14.0
package/channels/lark.js CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { createHash, createHmac } from 'node:crypto'
18
18
  import { ChannelError, request } from './http.js'
19
- import { MD_TABLE, cellText, chartOption, chartable, dualAxis, escapeLarkMd, kpiText, isKpi, needsAttachment, num, oneLine, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
19
+ import { MD_TABLE, labelValue, cellText, chartOption, chartable, dualAxis, escapeLarkMd, kpiText, isKpi, needsAttachment, num, oneLine, omittedNote, parseMdTable, renderChartPng, resultMeta } from './message.js'
20
20
 
21
21
  export { ChannelError, ChannelError as ImError, chartOption, chartable, dualAxis, renderChartPng }
22
22
 
@@ -703,10 +703,12 @@ function tableElement(result, maxRows) {
703
703
  page_size: Math.min(10, Math.max(1, Math.min(maxRows, result.rows.length))),
704
704
  row_height: 'low',
705
705
  header_style: { background_style: 'grey', bold: true, text_size: 'normal' },
706
- columns: cols.map((c, i) => ({ name: `c${i}`, display_name: c.name, data_type: c.kind === 'number' ? 'number' : 'text', ...(c.kind === 'number' ? { format: { separator: true } } : {}), width: 'auto' })),
706
+ columns: cols.map((c, i) => { const num = c.kind === 'number' && !c.labels; return { name: `c${i}`, display_name: c.name, data_type: num ? 'number' : 'text', ...(num ? { format: { separator: true } } : {}), width: 'auto' } }),
707
707
  // NULL / '' numbers stay empty (Number(null) would show 0); line breaks would break rows.
708
708
  rows: result.rows.slice(0, maxRows).map(r => Object.fromEntries(cols.flatMap((c, i) => {
709
709
  const v = r[i]
710
+ const labelled = labelValue(c, v)
711
+ if (labelled) return [[`c${i}`, oneLine(labelled)]]
710
712
  if (c.kind !== 'number') return [[`c${i}`, oneLine(cellText(v))]]
711
713
  if (v === null || v === undefined || v === '') return [] // empty cell, not 0
712
714
  return [[`c${i}`, Number.isFinite(Number(v)) ? Number(v) : oneLine(cellText(v))]]
@@ -61,19 +61,40 @@ export function defangMd(s) {
61
61
  * A KPI-style result (one row) as `**col**:value | …` Markdown. NULL shows as '—'.
62
62
  * `escape` is applied to data-derived text (column names and values).
63
63
  */
64
- export function kpiText(result, { separator = ' | ', bold = true, escape = defangMd } = {}) {
64
+ export function kpiText(result, { separator = ' | ', bold = true, escape = defangMd, vertical } = {}) {
65
65
  const row = result.rows?.[0] ?? []
66
+ // A single record with several fields reads better as one field per line ("字段:值").
67
+ const listMode = vertical ?? (result.columns.length > 3 || result.columns.some((c, i) => String(row[i] ?? '').length > 24))
66
68
  return result.columns.map((c, i) => {
67
- const raw = c.kind === 'number' ? fmtNum(row[i]) : cellText(row[i])
69
+ const labelled = labelValue(c, row[i])
70
+ // Long identifiers (order / trade numbers) are shown in full in list mode so they can be copied.
71
+ const raw = labelled ?? (c.kind === 'number' && !isCode(c, row[i]) ? fmtNum(row[i]) : cellText(row[i], listMode ? 200 : 80))
68
72
  const value = raw === '' ? '—' : escape(oneLine(raw))
69
73
  const name = escape(oneLine(c.name))
70
74
  return `${bold ? `**${name}**` : name}:${value}`
71
- }).join(separator)
75
+ }).join(listMode ? '\n' : separator)
72
76
  }
73
77
 
74
- /** True when the result should be shown as KPI text rather than a table/chart. */
78
+ /**
79
+ * `10` → `10(处理中)` when the column carries value labels (from the column comment or a
80
+ * knowledge note, attached by runQuery as `column.labels`). Returns undefined when unlabelled.
81
+ */
82
+ export function labelValue(column, v) {
83
+ if (v === null || v === undefined || v === '' || !column?.labels) return undefined
84
+ const label = column.labels[String(v)]
85
+ return label ? `${v}(${label})` : undefined
86
+ }
87
+
88
+ /** Numeric-looking ids / codes (order numbers, status codes) must not be abbreviated (万 / 亿). */
89
+ function isCode(column, v) {
90
+ if (column?.labels) return true
91
+ const name = String(column?.name ?? '')
92
+ return /(^|_)(id|no|code|num|number|status|type|state|seq|hash)$|编号|单号|订单号|状态|类型/i.test(name) || (typeof v === 'string' && /^\d{12,}$/.test(v))
93
+ }
94
+
95
+ /** True when the result should be shown as KPI / field-list text rather than a table/chart. */
75
96
  export function isKpi(result) {
76
- return result.chart?.type === 'kpi' || (result.rows.length === 1 && result.columns.length <= 6)
97
+ return result.chart?.type === 'kpi' || (result.rows.length === 1 && result.columns.length <= 12)
77
98
  }
78
99
 
79
100
  /** "12 行(已截断) · 已筛选:…" */
package/index.js CHANGED
@@ -269,6 +269,7 @@ function registerTools(ctx, service) {
269
269
  properties: {
270
270
  sql: { type: 'string', description: 'The single read-only SQL statement.' },
271
271
  title: { type: 'string', description: 'Short human title for the result, in the user\'s language.' },
272
+ show: { type: 'boolean', description: 'IM bot answers only: false = an intermediate / exploratory query that should not be shown in the reply card (e.g. a lookup that found nothing, a probe of the wrong table). Default true.' },
272
273
  datasource: DATASOURCE_PARAM,
273
274
  chart: chartParam,
274
275
  confirmScan: { type: 'boolean', description: 'Set true only after a scan-guard refusal when the user accepts a full scan of a large table.' },
@@ -281,6 +282,7 @@ function registerTools(ctx, service) {
281
282
  presentCall: (args) => ({ card: 'generic', title: args?.title ? `查询:${args.title}` : '执行 SQL', kind: 'read' }),
282
283
  execute: wrap(async (args, exec) => {
283
284
  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) })
285
+ if (args.show === false) result.hidden = true
284
286
  return { model: service.modelView(result), display: displayMeta(result) }
285
287
  }),
286
288
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
6
  "bin": {
package/service/bots.js CHANGED
@@ -58,6 +58,29 @@ export function parseBotCommand(text) {
58
58
  return null
59
59
  }
60
60
 
61
+ /**
62
+ * Results shown in a bot reply: drop ones the agent marked as intermediate (`show: false`),
63
+ * drop empty results when the answer has non-empty ones (an empty probe of the wrong table
64
+ * reads like "no such order"), keep at most the last 4.
65
+ */
66
+ export function selectReplyResults(results) {
67
+ let list = results.filter(r => !r.hidden)
68
+ if (list.some(r => !r.error && r.rows?.length)) list = list.filter(r => r.error || r.rows?.length)
69
+ return list.slice(-4)
70
+ }
71
+
72
+ /**
73
+ * A point lookup: the WHERE pins a key column to one value (order no, id, hash…) and there is
74
+ * no GROUP BY. Splitting or pinning such a result to a dashboard makes no sense.
75
+ */
76
+ export function isPointLookup(r, dialect) {
77
+ const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
78
+ if (!parsed || parsed.groupBy) return false
79
+ const where = String(parsed.where ?? '')
80
+ const keyEq = /(?:^|[^\w])`?(?:\w+\.)?`?(\w*(?:id|_no|no|order_?no|trade_?no|code|hash|uuid|sn|number)|id)`?\s*(?:=\s*'?[\w-]{4,}'?|in\s*\(\s*'?[\w-]{4,}'?\s*\))/i
81
+ return keyEq.test(where) || /'\d{10,}'|\b\d{12,}\b/.test(where)
82
+ }
83
+
61
84
  function hasAggregate(r, dialect) {
62
85
  const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
63
86
  return Boolean(parsed?.select?.some(i => i.isAggregate))
@@ -384,7 +407,7 @@ export const methods = {
384
407
  ? `数据源:${ds.name}(群内已用「切换」指定,请只查询这个数据源)。`
385
408
  : `当前数据源:${ds.name}${routed?.ds ? '(按问题自动选择)' : ''}。该成员可用的数据源:\n${await this.datasourceCatalog(allowedDs)}\n请先判断问题属于哪个数据源:若不是当前数据源,先用 nl2sql_datasource action=use 切换再查询;在回答开头注明使用的数据源。`)
386
409
  : `数据源:${ds.name}。`
387
- 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。' : ''})`
410
+ const prompt = `[${name}${where} · ${actor.name}${actor.role === 'viewer' ? ' · 只读成员' : ''}] ${text}\n\n(${dsLine}\n回答会以${name}消息发回该聊天:请用简洁的中文 Markdown 写结论,必要时附上查询结果(排查中间步骤、查错表或查不到数据的探索性查询请在 nl2sql_query 中设 show=false,回复卡片只展示支撑结论的结果);不要调用 nl2sql_send,也不要向用户提问——有歧义时说明你的假设后直接回答。${actor.role === 'viewer' ? '该成员是只读角色:只能用 nl2sql_metric 查询指标库,或复用 nl2sql_knowledge 中已验证的样例 SQL;不能自由编写 SQL。' : ''})`
388
411
  const requestId = platform === 'lark' ? `lark-${m.messageId}` : `${platform}-${m.chatId}-${m.messageId}`
389
412
  if (conv.sessionId && this.state.sessions[conv.sessionId] !== ds.id) { this.state.sessions[conv.sessionId] = ds.id; await this.save() }
390
413
  const turn = await this.runAgentTurn({ sessionId: conv.sessionId, title: `${name} · ${conv.chatName ?? actor.name}`, requestId, text: prompt, prepare, timeoutMs: 600000 })
@@ -393,7 +416,7 @@ export const methods = {
393
416
  let body = turn.text
394
417
  if (turn.reason === 'timeout') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(查询超过 10 分钟仍未完成,已停止。请缩小范围后重试。)</font>`
395
418
  if (turn.reason === 'input-required') body = `${body ? `${body}\n\n` : ''}<font color='grey'>(需要在 Harness 中确认后才能继续,已取消本次回答。)</font>`
396
- const results = turn.resultIds.map(id => this.lookupResult(id)).filter(Boolean).filter(r => { try { this.assertResultAccess(r, { ...actor, kind: platform }); return true } catch { return false } }).slice(-4)
419
+ const results = selectReplyResults(turn.resultIds.map(id => this.lookupResult(id)).filter(Boolean).filter(r => { try { this.assertResultAccess(r, { ...actor, kind: platform }); return true } catch { return false } }))
397
420
  // Buttons (拆分 / 为什么变化 / 明细 / 固定到看板) carry opaque tokens only.
398
421
  const shown = buttons ? await this.withResultActions(results, { actor, sessionId: conv.sessionId, channelId: m.channelId, platform, chatId: m.chatId, chatType: m.chatType, replyTo: m.messageId, depth: 0 }) : results
399
422
  let status = turn.reason === 'idle' ? 'ok' : 'error'
@@ -610,9 +633,14 @@ export const methods = {
610
633
  const role = actor?.role ?? 'analyst'
611
634
  const token = this.createActionToken({ resultId: result.resultId, sessionId, channelId, platform, chatId, chatType, replyTo, depth, ownerRole: role })
612
635
  const aggregate = hasAggregate(result, dialect)
636
+ const rows = result.rows?.length ?? 0
637
+ // Nothing to analyse in empty results; point lookups (one order / one id) and single rows
638
+ // are not dashboard material and have no dimension to split by.
639
+ if (rows === 0) return undefined
640
+ const lookup = isPointLookup(result, dialect)
613
641
  const buttons = []
614
642
  let select
615
- if (aggregate) {
643
+ if (aggregate && rows > 1 && !lookup) {
616
644
  let dims = []
617
645
  try { dims = (await this.drillDimensions(result.resultId)).dimensions } catch {}
618
646
  const xName = result.chart?.x
@@ -623,8 +651,8 @@ export const methods = {
623
651
  const points = xi >= 0 ? result.rows.filter(r => r[xi] !== null && r[xi] !== undefined).length : 0
624
652
  const timeLike = xi >= 0 && (result.columns[xi].kind === 'time' || ['line', 'area'].includes(result.chart?.type))
625
653
  if (aggregate && timeLike && points >= 2) buttons.push({ text: '为什么变化', value: { a: 'why', t: token } })
626
- if (xi >= 0 && points >= 1 && role !== 'viewer') buttons.push({ text: '明细', value: { a: 'detail', t: token } })
627
- if (role === 'admin' || role === 'analyst') buttons.push({ text: '固定到看板', value: { a: 'pin', t: token } })
654
+ if (aggregate && !lookup && xi >= 0 && points >= 1 && role !== 'viewer') buttons.push({ text: '明细', value: { a: 'detail', t: token } })
655
+ if ((role === 'admin' || role === 'analyst') && !lookup && (aggregate || rows > 1)) buttons.push({ text: '固定到看板', value: { a: 'pin', t: token } })
628
656
  if (!select && !buttons.length) return undefined
629
657
  return { ...(select ? { select } : {}), buttons }
630
658
  } catch (error) {
package/service/query.js CHANGED
@@ -9,7 +9,7 @@ import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
9
9
  import { cacheKey } from '../store.js'
10
10
  import { createHash } from 'node:crypto'
11
11
  import { writeXlsx } from '../xlsx.js'
12
- import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, SENSITIVE_RULES_VERSION, isCredentialToken, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
12
+ import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, parseValueLabels, SENSITIVE_RULES_VERSION, isCredentialToken, UserError, callContext, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
13
13
 
14
14
  export const methods = {
15
15
  // ---------------------------------------------------------------- query
@@ -146,7 +146,19 @@ export const methods = {
146
146
  })
147
147
  }
148
148
  for (const row of cells) for (const i of sensitive) row[i] = maskValue(res.columns[i].name, row[i])
149
- const columns = res.columns.map((f, i) => ({ name: f.name, kind: columnKind(f, cells, i), ...(sensitive.has(i) ? { masked: true } : {}) }))
149
+ // Code → meaning for enum-like source columns (knowledge note first, then the column comment),
150
+ // so IM cards can show "10(处理中)" instead of a bare code.
151
+ const notes = this.knowledgeFor(ds.id).notes ?? {}
152
+ const labelsOf = (f) => {
153
+ if (!f.table || !f.column) return null
154
+ const col = schema?.tables.find(t => t.name === f.table)?.columns.find(c => c.name === f.column)
155
+ if (!col || col.roles?.includes('sensitive')) return null
156
+ return parseValueLabels(notes[`${f.table}.${f.column}`]) ?? parseValueLabels(col.comment)
157
+ }
158
+ const columns = res.columns.map((f, i) => {
159
+ const labels = sensitive.has(i) ? null : labelsOf(f)
160
+ return { name: f.name, kind: columnKind(f, cells, i), ...(sensitive.has(i) ? { masked: true } : {}), ...(labels ? { labels } : {}) }
161
+ })
150
162
  const suggested = suggestChart(columns, cells)
151
163
  const resultId = newId('r')
152
164
  const result = {
package/service/shared.js CHANGED
@@ -84,6 +84,31 @@ export function maskValue(column, value) {
84
84
  return `${s.slice(0, keep)}${'*'.repeat(Math.max(4, s.length - keep * 2))}${s.slice(-keep)}`
85
85
  }
86
86
 
87
+ /**
88
+ * Value labels from a column comment or a knowledge note, e.g.
89
+ * '交易状态: 0=交易开始,10=处理中,11=交易失败' → { '0': '交易开始', '10': '处理中', '11': '交易失败' }
90
+ * '订单类型: 1withdraw,2deposit' → { '1': 'withdraw', '2': 'deposit' }
91
+ * '资产类型, 0: crypto, 1: rwa' → { '0': 'crypto', '1': 'rwa' }
92
+ * Only short codes (≤ 6 chars, digits or letters) mapped to short labels; needs ≥ 2 pairs.
93
+ */
94
+ export function parseValueLabels(text) {
95
+ const src = String(text ?? '')
96
+ if (!src) return null
97
+ const out = {}
98
+ // A code is digits (or one letter); the label may follow directly ("1withdraw", "0内扣").
99
+ const re = /(?:^|[\s,,;;、::((/|])(-?\d{1,6})(?![0-9])\s*(?:=|:|:|-|→|\s)?\s*([^\s,,;;、::()()/|=0-9][^\s,,;;、::()()/|=]{0,23})/g
100
+ let m
101
+ while ((m = re.exec(src))) {
102
+ const [, code, label] = m
103
+ if (/^-?\d+$/.test(label) || label === code) continue
104
+ out[code] = label.replace(/[。.]$/, '')
105
+ }
106
+ // Prose like "version 2 of api 3" is not a mapping: require ≥ 2 pairs and most labels not plain English stop words.
107
+ const labels = Object.values(out)
108
+ if (labels.length < 2 || labels.filter(l => /^(of|and|or|to|in|on|the|a|an|is|for|at|by|with|位|个|天|次|分)$/i.test(l)).length * 2 >= labels.length) return null
109
+ return out
110
+ }
111
+
87
112
  // ---------------------------------------------------------- schema analysis
88
113
 
89
114
  export const NUMERIC_TYPES = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'])