@hedeqiang/dsh-nl2sql 0.14.2 → 0.14.4
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 +16 -1
- package/channels/lark.js +4 -2
- package/channels/message.js +26 -5
- package/index.js +2 -0
- package/lib/client.js +13 -6
- package/package.json +1 -1
- package/service/bots.js +33 -5
- package/service/freshness.js +8 -2
- package/service/query.js +14 -2
- package/service/shared.js +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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.4] - 2026-11-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Data freshness can be switched off, or hidden from the page only (**Manage → Data health → Settings**). When off, nothing is shown under results or in Schema, no scheduled checks run and the agent's `nl2sql_freshness` tool reports that it is off; "hide on page" keeps checks and notifications but removes the warning bars and the Schema panel. The Data health page keeps showing the data either way.
|
|
12
|
+
|
|
13
|
+
## [0.14.3] - 2026-11-13
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **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").
|
|
17
|
+
- **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.
|
|
18
|
+
- **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).
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
8
21
|
## [0.14.2] - 2026-11-13
|
|
9
22
|
|
|
10
23
|
### Fixed
|
|
@@ -151,7 +164,9 @@ These features are tested with local databases and fake IM / Lark servers; real
|
|
|
151
164
|
|
|
152
165
|
- 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
166
|
|
|
154
|
-
[Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.
|
|
167
|
+
[Unreleased]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.4...HEAD
|
|
168
|
+
[0.14.4]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.3...v0.14.4
|
|
169
|
+
[0.14.3]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.2...v0.14.3
|
|
155
170
|
[0.14.2]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.1...v0.14.2
|
|
156
171
|
[0.14.1]: https://github.com/hedeqiang/dsh-nl2sql/compare/v0.14.0...v0.14.1
|
|
157
172
|
[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) =>
|
|
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))]]
|
package/channels/message.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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 <=
|
|
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/lib/client.js
CHANGED
|
@@ -17,6 +17,7 @@ window.__ModuleLoader__.load({
|
|
|
17
17
|
// ------------------------------------------------------------ locale
|
|
18
18
|
|
|
19
19
|
const zh = {
|
|
20
|
+
'fq.set.showInUi': "在页面上显示数据新鲜度(查询结果下的滞后提示、表结构页的新鲜度面板)",
|
|
20
21
|
'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
|
|
21
22
|
'mcp.where.claudeCode': '在终端运行(HTTP 直连;下方为 stdio 桥接写法)。', 'mcp.where.cline': '写入 cline_mcp_settings.json(Cline 面板 → MCP Servers → Configure)。', 'mcp.where.windsurf': '写入 Windsurf 的 mcp_config.json(Cascade → MCP → 打开配置文件)。', 'mcp.where.codex': '写入 ~/.codex/config.toml(或项目的 .codex/config.toml)。', 'mcp.where.gemini': '写入 ~/.gemini/settings.json 或项目的 .gemini/settings.json。', 'mcp.where.zed': '写入 Zed 的 settings.json。', 'mcp.where.continue': '保存为工作区 .continue/mcpServers/nl2sql.yaml。',
|
|
22
23
|
'mcp.test': '测试连接', 'mcp.testOk': '连接正常:发现 {n} 个工具。', 'mcp.testFail': '连接失败:{error}', 'mcp.testFailSteps': '连接测试未通过:',
|
|
@@ -80,7 +81,7 @@ window.__ModuleLoader__.load({
|
|
|
80
81
|
"fq.admin.qOff": "每日质量巡检已关闭",
|
|
81
82
|
"fq.admin.noDs": "请先添加数据源",
|
|
82
83
|
"fq.set.title": "设置",
|
|
83
|
-
"fq.set.fEnabled": "
|
|
84
|
+
"fq.set.fEnabled": "启用数据新鲜度(定时检查、滞后通知、Agent 回答近期数据时的提醒);关闭后页面上也不再显示",
|
|
84
85
|
"fq.set.qEnabled": "每日数据质量巡检",
|
|
85
86
|
"fq.set.interval": "新鲜度检查间隔(分钟)",
|
|
86
87
|
"fq.set.time": "质量巡检时间",
|
|
@@ -1178,6 +1179,7 @@ window.__ModuleLoader__.load({
|
|
|
1178
1179
|
'policy.tablesLabel': '可访问的表',
|
|
1179
1180
|
}
|
|
1180
1181
|
const en = {
|
|
1182
|
+
'fq.set.showInUi': "Show data freshness on the page (stale-data bars under results, the freshness panel in Schema)",
|
|
1181
1183
|
'mcp.client.claudeCode': 'Claude Code', 'mcp.client.cline': 'Cline', 'mcp.client.windsurf': 'Windsurf', 'mcp.client.codex': 'Codex CLI', 'mcp.client.gemini': 'Gemini CLI', 'mcp.client.zed': 'Zed', 'mcp.client.continue': 'Continue',
|
|
1182
1184
|
'mcp.where.claudeCode': 'Run in a terminal (HTTP; the stdio bridge variant is below).', 'mcp.where.cline': 'Add to cline_mcp_settings.json (Cline panel → MCP Servers → Configure).', 'mcp.where.windsurf': 'Add to Windsurf\'s mcp_config.json (Cascade → MCP → open config file).', 'mcp.where.codex': 'Add to ~/.codex/config.toml (or the project\'s .codex/config.toml).', 'mcp.where.gemini': 'Add to ~/.gemini/settings.json or the project\'s .gemini/settings.json.', 'mcp.where.zed': 'Add to Zed\'s settings.json.', 'mcp.where.continue': 'Save as .continue/mcpServers/nl2sql.yaml in the workspace.',
|
|
1183
1185
|
'mcp.test': 'Test connection', 'mcp.testOk': 'Connected: {n} tools discovered.', 'mcp.testFail': 'Connection failed: {error}', 'mcp.testFailSteps': 'Connection test failed:',
|
|
@@ -1241,7 +1243,7 @@ window.__ModuleLoader__.load({
|
|
|
1241
1243
|
"fq.admin.qOff": "Daily quality patrol is off",
|
|
1242
1244
|
"fq.admin.noDs": "Add a data source first",
|
|
1243
1245
|
"fq.set.title": "Settings",
|
|
1244
|
-
"fq.set.fEnabled": "
|
|
1246
|
+
"fq.set.fEnabled": "Enable data freshness (scheduled checks, stale-data notifications, warnings in answers about recent data); when off, nothing is shown on the page either",
|
|
1245
1247
|
"fq.set.qEnabled": "Daily data quality patrol",
|
|
1246
1248
|
"fq.set.interval": "Freshness check interval (min)",
|
|
1247
1249
|
"fq.set.time": "Quality patrol time",
|
|
@@ -6630,19 +6632,21 @@ window.__ModuleLoader__.load({
|
|
|
6630
6632
|
const key = `${datasourceId}\u0000${sql ?? ''}`
|
|
6631
6633
|
const hit = map.get(key)
|
|
6632
6634
|
if (hit && Date.now() - hit.at < TTL) return hit.promise
|
|
6633
|
-
const promise = call('freshnessFor', { datasourceId, ...(sql ? { sql } : {}) })
|
|
6635
|
+
const promise = call('freshnessFor', { datasourceId, panel: true, ...(sql ? { sql } : {}) })
|
|
6634
6636
|
map.set(key, { at: Date.now(), promise })
|
|
6635
6637
|
promise.catch(() => map.delete(key))
|
|
6636
6638
|
return promise
|
|
6637
6639
|
},
|
|
6638
6640
|
async refresh(datasourceId) {
|
|
6639
|
-
const v = await call('freshnessFor', { datasourceId, refresh: true })
|
|
6641
|
+
const v = await call('freshnessFor', { datasourceId, refresh: true, panel: true })
|
|
6640
6642
|
for (const k of [...map.keys()]) if (k.startsWith(`${datasourceId}\u0000`)) map.delete(k)
|
|
6641
6643
|
map.set(`${datasourceId}\u0000`, { at: Date.now(), promise: Promise.resolve(v) })
|
|
6642
6644
|
rev++
|
|
6643
6645
|
for (const f of subs) f()
|
|
6644
6646
|
return v
|
|
6645
6647
|
},
|
|
6648
|
+
/** Drop everything (e.g. after the show / enable settings change) and re-render subscribers. */
|
|
6649
|
+
reset() { map.clear(); rev++; for (const f of subs) f() },
|
|
6646
6650
|
subscribe(f) { subs.add(f); return () => subs.delete(f) },
|
|
6647
6651
|
rev: () => rev,
|
|
6648
6652
|
}
|
|
@@ -6691,6 +6695,7 @@ window.__ModuleLoader__.load({
|
|
|
6691
6695
|
fqCache.get(dsId, sql).then(v => alive && setView(v), () => {})
|
|
6692
6696
|
return () => { alive = false }
|
|
6693
6697
|
}, [dsId, sql, rev])
|
|
6698
|
+
if (view?.disabled) return null
|
|
6694
6699
|
const stale = (view?.tables ?? []).filter(x => x.stale)
|
|
6695
6700
|
if (!stale.length) return null
|
|
6696
6701
|
return h('div', { className: 'n2s-fq-bar', role: 'status' },
|
|
@@ -6719,7 +6724,7 @@ window.__ModuleLoader__.load({
|
|
|
6719
6724
|
return () => { alive = false }
|
|
6720
6725
|
}, [ds?.id, rev])
|
|
6721
6726
|
const v = state.value
|
|
6722
|
-
if (!ds || state.error || !v || !v.tables?.length) return null
|
|
6727
|
+
if (!ds || state.error || !v || v.disabled || !v.tables?.length) return null
|
|
6723
6728
|
const stale = v.tables.filter(x => x.stale).length
|
|
6724
6729
|
const warn = v.tables.filter(x => x.status === 'warn').length
|
|
6725
6730
|
const expanded = open ?? stale > 0
|
|
@@ -6786,6 +6791,7 @@ window.__ModuleLoader__.load({
|
|
|
6786
6791
|
const { overrides: _o, ...freshness } = form.freshness
|
|
6787
6792
|
const out = await call('saveHealthSettings', { freshness: { ...freshness, intervalMin: Number(freshness.intervalMin), staleHours: Number(freshness.staleHours), channelId: freshness.channelId || null }, quality: { ...form.quality, dropPoints: Number(form.quality.dropPoints), channelId: form.quality.channelId || null } })
|
|
6788
6793
|
setForm(structuredClone(out))
|
|
6794
|
+
fqCache.reset()
|
|
6789
6795
|
notify(t('fq.saved'), 'ok')
|
|
6790
6796
|
onSaved?.()
|
|
6791
6797
|
} catch (e) { notifyError(e) }
|
|
@@ -6793,6 +6799,7 @@ window.__ModuleLoader__.load({
|
|
|
6793
6799
|
return h('div', { className: 'n2s-card' },
|
|
6794
6800
|
h('div', { className: 'n2s-form' },
|
|
6795
6801
|
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.freshness.enabled), onChange: e => setF('enabled', e.target.checked) }), t('fq.set.fEnabled')),
|
|
6802
|
+
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: form.freshness.showInUi !== false && Boolean(form.freshness.enabled), disabled: !form.freshness.enabled, onChange: e => setF('showInUi', e.target.checked) }), t('fq.set.showInUi')),
|
|
6796
6803
|
h('label', { className: 'n2s-check' }, h('input', { type: 'checkbox', checked: Boolean(form.quality.enabled), onChange: e => setQ('enabled', e.target.checked) }), t('fq.set.qEnabled')),
|
|
6797
6804
|
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.interval')), h('input', { className: 'n2s-input', type: 'number', min: 5, max: 1440, value: form.freshness.intervalMin, onChange: e => setF('intervalMin', e.target.value) })),
|
|
6798
6805
|
h('label', { className: 'n2s-field' }, h('span', null, t('fq.set.time')), h('input', { className: 'n2s-input', type: 'time', value: form.quality.time, onChange: e => setQ('time', e.target.value) })),
|
|
@@ -6807,7 +6814,7 @@ window.__ModuleLoader__.load({
|
|
|
6807
6814
|
|
|
6808
6815
|
function HealthFreshnessTable({ t, dsId }) {
|
|
6809
6816
|
const rev = useFqRev()
|
|
6810
|
-
const fr = useAsync(() => (dsId ?
|
|
6817
|
+
const fr = useAsync(() => (dsId ? call('freshnessFor', { datasourceId: dsId }) : null), [dsId, rev])
|
|
6811
6818
|
const settings = useAsync(() => call('healthSettings'), [dsId, rev])
|
|
6812
6819
|
const events = useAsync(() => (dsId ? call('freshnessEvents', { datasourceId: dsId, limit: 10 }) : []), [dsId, rev])
|
|
6813
6820
|
const [busy, setBusy] = useState(false)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.4",
|
|
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
|
|
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 } })
|
|
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/freshness.js
CHANGED
|
@@ -19,7 +19,8 @@ import { UserError, lastDailySlot, newId } from './shared.js'
|
|
|
19
19
|
|
|
20
20
|
// ------------------------------------------------------------------ constants
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// enabled: scheduled checks + notifications + the agent tool; showInUi: warning bars under results and the schema panel.
|
|
23
|
+
export const FRESHNESS_DEFAULTS = { enabled: true, showInUi: true, intervalMin: 30, staleHours: 24, overrides: {}, channelId: null }
|
|
23
24
|
export const QUALITY_DEFAULTS = { enabled: false, time: '06:30', channelId: null, dropPoints: 10, maxTableRows: 5000000, maxTables: 60 }
|
|
24
25
|
const DATE_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz'])
|
|
25
26
|
const NUM_TYPES = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'])
|
|
@@ -271,6 +272,7 @@ export const methods = {
|
|
|
271
272
|
if (freshness) {
|
|
272
273
|
const s = this.freshnessState().settings
|
|
273
274
|
if (freshness.enabled !== undefined) s.enabled = Boolean(freshness.enabled)
|
|
275
|
+
if (freshness.showInUi !== undefined) s.showInUi = Boolean(freshness.showInUi)
|
|
274
276
|
if (freshness.intervalMin !== undefined) s.intervalMin = Math.round(intIn(freshness.intervalMin, 5, 1440, 'intervalMin'))
|
|
275
277
|
if (freshness.staleHours !== undefined) s.staleHours = intIn(freshness.staleHours, 0.25, 24 * 90, 'staleHours')
|
|
276
278
|
if (freshness.channelId !== undefined) s.channelId = channel(freshness.channelId)
|
|
@@ -422,9 +424,12 @@ export const methods = {
|
|
|
422
424
|
},
|
|
423
425
|
|
|
424
426
|
/** RPC: stored freshness (computed on first use or when refresh); `sql` narrows to referenced tables. */
|
|
425
|
-
async freshnessFor({ datasourceId, refresh = false, sql } = {}) {
|
|
427
|
+
async freshnessFor({ datasourceId, refresh = false, sql, panel = false } = {}) {
|
|
426
428
|
const ds = this.requireDatasource(datasourceId ?? this.state.current)
|
|
427
429
|
const f = this.freshnessState()
|
|
430
|
+
// Page panels (result bars, schema panel) ask with panel: true; when the feature or its
|
|
431
|
+
// page display is off they get nothing and no query runs.
|
|
432
|
+
if (panel && (!f.settings.enabled || f.settings.showInUi === false)) return { datasourceId: ds.id, disabled: true, tables: [] }
|
|
428
433
|
let store = f[ds.id]
|
|
429
434
|
if (refresh || !store) {
|
|
430
435
|
if (refresh) store = await this.withJobLock(`freshness:${ds.id}`, () => this.refreshFreshness(ds))
|
|
@@ -737,6 +742,7 @@ export const FEATURE = {
|
|
|
737
742
|
presentCall: (args) => ({ card: 'generic', title: args?.tables?.length ? `数据新鲜度 ${args.tables.join(', ')}` : '数据新鲜度', kind: 'read' }),
|
|
738
743
|
execute: async (args, exec) => {
|
|
739
744
|
try {
|
|
745
|
+
if (!service.freshnessState().settings.enabled) return { disabled: true, message: 'Data freshness checks are turned off by the administrator (Data Q&A → Manage → Data health). Answer without freshness warnings.' }
|
|
740
746
|
return await service.freshnessForSession({ sessionId: exec?.agent?.id, datasource: args?.datasource, tables: args?.tables })
|
|
741
747
|
} catch (error) {
|
|
742
748
|
throw error instanceof UserError ? new Error(error.message) : 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
|
-
|
|
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'])
|