@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Nl2SqlService methods: push.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ */
5
+ import { ImError, RECEIVE_ID_TYPES, listBotChats, sendMessage } from '../im.js'
6
+ import { CREDENTIAL_SCOPE, UserError, newId, oneOf, pick, requireText, upsert } from './shared.js'
7
+
8
+ export const methods = {
9
+ // ---------------------------------------------------------- IM channels
10
+
11
+ publicChannel(c) {
12
+ return { ...pick(c, ['id', 'name', 'type', 'domain', 'appId', 'receiveIdType', 'receiveId', 'description', 'isDefault', 'createdAt', 'updatedAt', 'lastSentAt', 'lastError', 'chartMode']), hasSecret: Boolean(c.hasSecret), hasSign: Boolean(c.hasSign), ...(c.needsSecret ? { needsSecret: true } : {}) }
13
+ },
14
+
15
+ listChannels() {
16
+ return this.state.channels.map(c => this.publicChannel(c))
17
+ },
18
+
19
+ findChannel(ref) {
20
+ if (!ref) return this.state.channels.find(c => c.isDefault) ?? (this.state.channels.length === 1 ? this.state.channels[0] : undefined)
21
+ return this.state.channels.find(c => c.id === ref) ?? this.state.channels.find(c => c.name === ref)
22
+ },
23
+
24
+ requireChannel(ref) {
25
+ const c = this.findChannel(ref)
26
+ if (!c) throw new UserError('nl2sql/unknown-channel', ref ? `推送渠道不存在:${ref}` : `没有可用的推送渠道。${this.state.channels.length ? `可用渠道:${this.state.channels.map(x => x.name).join('、')}` : '请用户在「数据问答 → 推送」页面添加飞书/Lark 渠道。'}`)
27
+ return c
28
+ },
29
+
30
+ channelSecretKey(id) {
31
+ return `${CREDENTIAL_SCOPE}/${id}`
32
+ },
33
+
34
+ async saveChannel(input) {
35
+ const existing = input.id ? this.state.channels.find(c => c.id === input.id) : undefined
36
+ if (input.id && !existing) throw new UserError('nl2sql/unknown-channel', '推送渠道不存在')
37
+ const c = existing ? { ...existing } : { id: newId('ch'), createdAt: Date.now() }
38
+ c.name = requireText(input.name ?? c.name, 'name', 64)
39
+ if (this.state.channels.some(x => x.name === c.name && x.id !== c.id)) throw new UserError('nl2sql/duplicate', `渠道名称已存在:${c.name}`)
40
+ c.type = oneOf(input.type ?? c.type ?? 'lark_webhook', ['lark_webhook', 'lark_app'], 'type')
41
+ c.domain = input.domain === 'lark' ? 'lark' : input.domain === 'feishu' ? 'feishu' : (c.domain ?? 'feishu')
42
+ c.description = String(input.description ?? c.description ?? '').slice(0, 300)
43
+ c.chartMode = ['native', 'image', 'table'].includes(input.chartMode) ? input.chartMode : (c.chartMode ?? 'native')
44
+ if (c.chartMode === 'image' && c.type !== 'lark_app') c.chartMode = 'native'
45
+ if (c.type === 'lark_app') {
46
+ c.appId = requireText(input.appId ?? c.appId, 'appId', 64)
47
+ c.receiveIdType = oneOf(input.receiveIdType ?? c.receiveIdType ?? 'chat_id', RECEIVE_ID_TYPES, 'receiveIdType')
48
+ c.receiveId = requireText(input.receiveId ?? c.receiveId, 'receiveId', 200)
49
+ } else {
50
+ delete c.appId; delete c.receiveIdType; delete c.receiveId
51
+ }
52
+ const patch = {}
53
+ if (c.type === 'lark_webhook') {
54
+ if (typeof input.webhookUrl === 'string' && input.webhookUrl.trim()) {
55
+ const url = input.webhookUrl.trim()
56
+ if (!/^https:\/\/open\.(feishu\.cn|larksuite\.com)\/open-apis\/bot\/v2\/hook\/[\w-]+$/.test(url)) throw new UserError('nl2sql/invalid', 'Webhook 地址格式不正确,应为 https://open.feishu.cn/open-apis/bot/v2/hook/… (Lark 国际版为 open.larksuite.com)')
57
+ patch.webhookUrl = url
58
+ c.domain = url.includes('larksuite') ? 'lark' : 'feishu'
59
+ }
60
+ if (typeof input.signSecret === 'string' && (input.signSecret !== '' || input.clearSign)) patch.signSecret = input.signSecret.trim()
61
+ } else if (typeof input.appSecret === 'string' && input.appSecret !== '') {
62
+ patch.appSecret = input.appSecret.trim()
63
+ }
64
+ const id = `ch-${c.id.replace(/^ch-/, '')}`
65
+ const secrets = Object.keys(patch).length ? await this.writeSecrets(id, patch) : await this.readSecrets(id)
66
+ c.hasSecret = Boolean(c.type === 'lark_webhook' ? secrets.webhookUrl : secrets.appSecret)
67
+ c.hasSign = Boolean(secrets.signSecret)
68
+ if (!c.hasSecret) throw new UserError('nl2sql/invalid', c.type === 'lark_webhook' ? '请填写 Webhook 地址' : '请填写 App Secret')
69
+ delete c.needsSecret
70
+ if (input.isDefault || !this.state.channels.some(x => x.isDefault && x.id !== c.id)) {
71
+ for (const x of this.state.channels) x.isDefault = false
72
+ c.isDefault = true
73
+ }
74
+ c.updatedAt = Date.now()
75
+ this.state.channels = upsert(this.state.channels, c)
76
+ await this.save()
77
+ return this.publicChannel(c)
78
+ },
79
+
80
+ async deleteChannel(id) {
81
+ const c = this.state.channels.find(x => x.id === id)
82
+ if (!c) return { deleted: id }
83
+ this.state.channels = this.state.channels.filter(x => x.id !== id)
84
+ if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
85
+ for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
86
+ try { await this.ctx.get('credentials')?.deleteRecord(this.credentialKey(`ch-${id.replace(/^ch-/, '')}`)) } catch {}
87
+ await this.save()
88
+ return { deleted: id }
89
+ },
90
+
91
+ async channelSecrets(c) {
92
+ return this.readSecrets(`ch-${c.id.replace(/^ch-/, '')}`)
93
+ },
94
+
95
+ /** Chats of an app bot: a saved channel, or unsaved credentials from the form (nothing is stored). */
96
+ async listChannelChats({ id, appId, appSecret, domain }) {
97
+ const saved = id ? this.state.channels.find(c => c.id === id) : undefined
98
+ const c = saved ?? { type: 'lark_app', appId: requireText(appId, 'appId', 64), domain: domain === 'lark' ? 'lark' : 'feishu' }
99
+ if (c.type !== 'lark_app') throw new UserError('nl2sql/invalid', '只有应用机器人渠道可以列出群聊')
100
+ const secrets = appSecret ? { appSecret } : saved ? await this.channelSecrets(saved) : {}
101
+ if (!secrets.appSecret) throw new UserError('nl2sql/invalid', '请先填写 App Secret')
102
+ try {
103
+ return await listBotChats({ ...c, ...(appId ? { appId } : {}) }, secrets)
104
+ } catch (error) {
105
+ throw error instanceof ImError ? new UserError(error.code, error.message) : error
106
+ }
107
+ },
108
+
109
+ /**
110
+ * Send to an IM channel. Results are referenced by resultId (from this
111
+ * process' cache) or given inline; each is re-checked against privacy:
112
+ * masked values stay masked because results are stored masked.
113
+ */
114
+ async sendToChannel({ channelId, title, text, resultIds = [], results = [], includeSql = false, source = 'ui', sessionId, footer, chartMode }) {
115
+ const c = this.requireChannel(channelId)
116
+ const resolved = []
117
+ const sender = this.actorFor(sessionId)
118
+ for (const id of resultIds.slice(0, 10)) {
119
+ const r = this.lookupResult(id)
120
+ if (!r) throw new UserError('nl2sql/result-expired', `结果 ${id} 已过期,请重新查询后再发送。`)
121
+ if (source === 'agent') this.assertResultAccess(r, sender)
122
+ resolved.push(r)
123
+ }
124
+ resolved.push(...results.slice(0, 10))
125
+ if (!text && resolved.length === 0) throw new UserError('nl2sql/invalid', '没有可发送的内容')
126
+ const message = {
127
+ title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
128
+ text: text ? String(text).slice(0, 20000) : '',
129
+ chartMode: ['native', 'image', 'table'].includes(chartMode) ? chartMode : (c.chartMode ?? 'native'),
130
+ results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error'])),
131
+ includeSql: Boolean(includeSql),
132
+ footer: footer ?? `数据问答 · ${new Date().toLocaleString('zh-CN', { hour12: false })}${resolved[0]?.datasourceName ? ` · ${resolved[0].datasourceName}` : ''}`,
133
+ }
134
+ const started = Date.now()
135
+ try {
136
+ const sent = await sendMessage(c, await this.channelSecrets(c), message)
137
+ c.lastSentAt = Date.now()
138
+ delete c.lastError
139
+ this.recordDelivery({ channel: c, title: message.title, status: 'ok', results: resolved.length, images: sent.images, source, sessionId, elapsedMs: Date.now() - started })
140
+ await this.save()
141
+ return { ok: true, channel: c.name, messageId: sent.messageId ?? null, images: sent.images, results: resolved.length }
142
+ } catch (error) {
143
+ c.lastError = error.message
144
+ this.recordDelivery({ channel: c, title: message.title, status: 'error', error: error.message, results: resolved.length, source, sessionId, elapsedMs: Date.now() - started })
145
+ await this.save()
146
+ throw error instanceof ImError ? new UserError(error.code, error.message) : error
147
+ }
148
+ },
149
+
150
+ /** Text of one assistant message of a session, for "send this reply to IM". */
151
+ async assistantMessageText(sessionId, messageId) {
152
+ const read = (session) => {
153
+ for (const e of session.snapshotEvents()) {
154
+ if (e.type !== 'assistant/message' || e.data.message?.id !== messageId) continue
155
+ return e.data.message.content.filter(b => b.type === 'text').map(b => b.text).join('\n\n').trim()
156
+ }
157
+ return undefined
158
+ }
159
+ const live = this.ctx.get('agents')?.get(sessionId)
160
+ let text = live ? read(live.session) : undefined
161
+ if (text === undefined) {
162
+ const q = this.ctx.get('sessionQuery')
163
+ if (!q) throw new UserError('nl2sql/unavailable', '无法读取会话内容')
164
+ text = await q.inspectSession(sessionId, read)
165
+ }
166
+ if (!text) throw new UserError('nl2sql/invalid', '这条回复没有文字内容')
167
+ return text
168
+ },
169
+
170
+ /** resultIds of nl2sql tool results in the same turn as an assistant message. */
171
+ async turnResultIds(sessionId, messageId) {
172
+ const read = (session) => {
173
+ const events = session.snapshotEvents()
174
+ const at = events.findIndex(e => e.type === 'assistant/message' && e.data.message?.id === messageId)
175
+ if (at < 0) return []
176
+ const turn = events[at].data.turn
177
+ const ids = []
178
+ for (const e of events) {
179
+ if (e.type !== 'tool/result' || e.data.turn !== turn) continue
180
+ const meta = e.data.meta
181
+ if (meta?.resultId) ids.push(meta.resultId)
182
+ for (const r of meta?.results ?? []) if (r.resultId) ids.push(r.resultId)
183
+ }
184
+ return ids
185
+ }
186
+ const live = this.ctx.get('agents')?.get(sessionId)
187
+ if (live) return read(live.session)
188
+ return (await this.ctx.get('sessionQuery')?.inspectSession(sessionId, read)) ?? []
189
+ },
190
+
191
+ async sendAssistantMessage({ channelId, sessionId, messageId, title, withResults = true }) {
192
+ const text = await this.assistantMessageText(sessionId, messageId)
193
+ const resultIds = withResults ? (await this.turnResultIds(sessionId, messageId)).filter(id => this.lookupResult(id)).slice(-6) : []
194
+ const firstLine = text.split('\n').map(l => l.replace(/^#+\s*|\*\*/g, '').trim()).find(Boolean) ?? '数据问答'
195
+ return this.sendToChannel({ channelId, title: title || firstLine.slice(0, 60), text, resultIds, source: 'ui', sessionId })
196
+ },
197
+
198
+ async previewAssistantMessage({ sessionId, messageId }) {
199
+ const text = await this.assistantMessageText(sessionId, messageId)
200
+ const resultIds = (await this.turnResultIds(sessionId, messageId)).filter(id => this.lookupResult(id)).slice(-6)
201
+ return { text, resultIds, titles: resultIds.map(id => this.lookupResult(id)?.title ?? '') }
202
+ },
203
+
204
+ recordDelivery({ channel, title, status, error, results, images, source, sessionId, elapsedMs }) {
205
+ this.records.put('delivery', { id: newId('dl'), at: Date.now(), channelId: channel.id, channelName: channel.name, title, status, ...(error ? { error: String(error).slice(0, 500) } : {}), results, images: images ?? 0, source, sessionId: sessionId ?? null, elapsedMs })
206
+
207
+ },
208
+
209
+ listDeliveries(limit = 100) {
210
+ return this.records.list('delivery', { limit })
211
+ },
212
+
213
+ async testChannel(id) {
214
+ return this.sendToChannel({ channelId: id, title: '数据问答 · 推送测试', text: `这是一条测试消息。\n发送时间:${new Date().toLocaleString('zh-CN', { hour12: false })}`, source: 'test' })
215
+ },
216
+
217
+ /** Send the current dashboard (with saved global filters) to a channel. */
218
+ async sendDashboard({ channelId, title, filters, dashboardId, chartMode }) {
219
+ const results = []
220
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
221
+ for (const w of d.widgets.slice(0, 10)) {
222
+ try {
223
+ const r = await this.runWidget(w.id, filters ?? d.filters)
224
+ results.push({ ...r, title: w.title })
225
+ } catch (error) {
226
+ results.push({ title: w.title, error: error.message, columns: [], rows: [] })
227
+ }
228
+ }
229
+ if (!results.length) throw new UserError('nl2sql/invalid', '看板为空')
230
+ const sent = await this.sendToChannel({ channelId, title: title || d.name || '数据看板', results, source: 'dashboard', chartMode })
231
+ await this.saveDashboardSnapshot(d, results, 'push')
232
+ return sent
233
+ },
234
+
235
+ /** Run every widget of a dashboard and keep a snapshot (for history / export). */
236
+ async snapshotDashboard({ dashboardId, filters, reason = 'manual' } = {}) {
237
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
238
+ const results = []
239
+ for (const w of d.widgets.slice(0, 30)) {
240
+ try { results.push({ ...(await this.runWidget(w.id, filters ?? d.filters)), title: w.title }) } catch (error) { results.push({ title: w.title, error: error.message, columns: [], rows: [] }) }
241
+ }
242
+ return this.saveDashboardSnapshot(d, results, reason)
243
+ },
244
+
245
+ async saveDashboardSnapshot(d, results, reason) {
246
+ const snap = { id: newId('snap'), dashboardId: d.id, name: d.name, reason, at: Date.now(), filters: d.filters ?? [], resultIds: results.map(r => r.resultId).filter(Boolean), widgets: results.map(r => ({ title: r.title, resultId: r.resultId ?? null, error: r.error ?? null, rowCount: r.rowCount ?? 0 })) }
247
+ try { this.store?.putSnapshot({ id: snap.id, kind: 'dashboard', ref: d.id, createdAt: snap.at, body: snap }) } catch {}
248
+ return snap
249
+ },
250
+
251
+ listDashboardSnapshots(dashboardId) {
252
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
253
+ return (this.store?.listSnapshots('dashboard', d.id, 30) ?? []).map(s => s.body)
254
+ },
255
+
256
+ // -------------------------------------------------------------- feedback
257
+
258
+ async submitFeedback({ resultId, rating, comment, question, sql, datasourceId, sessionId, title }) {
259
+ const r = resultId ? this.lookupResult(resultId) : undefined
260
+ const ds = this.requireDatasource(r?.datasourceId ?? datasourceId)
261
+ const entry = {
262
+ id: newId('fb'),
263
+ at: Date.now(),
264
+ rating: rating === 'up' ? 'up' : 'down',
265
+ datasourceId: ds.id,
266
+ datasourceName: ds.name,
267
+ resultId: resultId ?? null,
268
+ sessionId: sessionId ?? null,
269
+ title: String(title ?? r?.title ?? '').slice(0, 120),
270
+ question: String(question ?? '').slice(0, 1000),
271
+ sql: String(sql ?? r?.baseSql ?? r?.sql ?? '').slice(0, 20000),
272
+ comment: String(comment ?? '').slice(0, 2000),
273
+ status: rating === 'up' ? 'resolved' : 'open',
274
+ }
275
+ if (!entry.sql) throw new UserError('nl2sql/invalid', '缺少 SQL')
276
+ // A newer rating of the same result replaces the earlier one.
277
+ if (resultId) for (const old of this.records.list('feedback', { limit: 5, filter: (f) => f.resultId === resultId })) this.records.delete('feedback', old.id)
278
+ this.records.put('feedback', entry)
279
+ await this.save()
280
+ return entry
281
+ },
282
+
283
+ listFeedback({ status, datasourceId, rating } = {}) {
284
+ return this.records.list('feedback', { ref: datasourceId, status, limit: 1000, filter: (f) => !rating || f.rating === rating })
285
+ },
286
+
287
+ feedbackStats(datasourceId) {
288
+ const list = this.records.list('feedback', { ref: datasourceId, limit: 100000 })
289
+ const up = list.filter(f => f.rating === 'up').length
290
+ return { up, down: list.length - up, open: list.filter(f => f.status === 'open').length, rate: list.length ? Number((up / list.length).toFixed(3)) : null }
291
+ },
292
+
293
+ async resolveFeedback({ id, status, resolution, saveExample, correctedSql, question }) {
294
+ const f = this.records.get('feedback', id)
295
+ if (!f) throw new UserError('nl2sql/unknown-feedback', '反馈不存在')
296
+ if (saveExample) {
297
+ await this.saveKnowledgeItem({ datasourceId: f.datasourceId, kind: 'example', item: { question: question || f.question || f.title, sql: correctedSql || f.sql, note: f.rating === 'down' && f.comment ? `修正自反馈:${f.comment}`.slice(0, 500) : '' } })
298
+ f.savedAsExample = true
299
+ }
300
+ f.status = status === 'open' ? 'open' : status === 'ignored' ? 'ignored' : 'resolved'
301
+ if (resolution !== undefined) f.resolution = String(resolution).slice(0, 1000)
302
+ if (correctedSql) f.correctedSql = String(correctedSql).slice(0, 20000)
303
+ f.resolvedAt = Date.now()
304
+ this.records.put('feedback', f)
305
+ await this.save()
306
+ return f
307
+ },
308
+
309
+ async deleteFeedback(id) {
310
+ this.records.delete('feedback', id)
311
+ await this.save()
312
+ return { deleted: id }
313
+ },
314
+ }