@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,233 @@
1
+ /**
2
+ * Nl2SqlService methods: alerts.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ */
5
+ import { evaluateAlert } from '../analysis.js'
6
+ import { admitSql } from '../sql-guard.js'
7
+ import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
8
+
9
+ export const methods = {
10
+ // ---------------------------------------------------------------- alerts
11
+
12
+ /**
13
+ * Alert: a metric (from the library) or a SQL query returning a time series
14
+ * (first column time, value column), evaluated on a schedule; triggers push
15
+ * to a channel.
16
+ */
17
+ listAlerts() {
18
+ return (this.state.alerts ?? []).map(a => ({ ...a, datasourceName: this.findDatasource(a.datasourceId)?.name ?? a.datasourceId, channelName: this.state.channels.find(c => c.id === a.channelId)?.name ?? null }))
19
+ },
20
+
21
+ /** Validate an alert definition (shared by save and preview). */
22
+ async normalizeAlert(input, forceId) {
23
+ const existing = input.id ? (this.state.alerts ?? []).find(a => a.id === input.id) : undefined
24
+ if (input.id && !existing) throw new UserError('nl2sql/invalid', '告警不存在')
25
+ const ds = this.requireDatasource(input.datasourceId ?? existing?.datasourceId)
26
+ const a = existing ? { ...existing } : { id: forceId ?? newId('al'), createdAt: Date.now(), state: 'ok' }
27
+ a.name = requireText(input.name ?? a.name, 'name', 60)
28
+ a.datasourceId = ds.id
29
+ a.source = oneOf(input.source ?? a.source ?? 'metric', ['metric', 'sql'], 'source')
30
+ if (a.source === 'metric') {
31
+ const m = this.findMetric(ds, input.metric ?? a.metric)
32
+ if (!m) throw new UserError('nl2sql/unknown-metric', '指标库中没有这个指标')
33
+ a.metric = m.name
34
+ a.grain = oneOf(input.grain ?? a.grain ?? 'day', ['hour', 'day', 'week', 'month'], 'grain')
35
+ a.filters = Array.isArray(input.filters) ? input.filters : (a.filters ?? [])
36
+ delete a.sql
37
+ } else {
38
+ a.sql = requireText(input.sql ?? a.sql, 'sql', 20000)
39
+ admitSql(a.sql, { dialect: ds.type ?? 'mysql' })
40
+ delete a.metric
41
+ }
42
+ const cond = input.condition ?? a.condition ?? { type: 'threshold', op: '>', value: 0 }
43
+ a.condition = { ...cond, type: oneOf(cond.type, ['threshold', 'change', 'anomaly', 'missing'], 'condition.type') }
44
+ a.every = oneOf(input.every ?? a.every ?? 'hour', ['15m', 'hour', 'day'], 'every')
45
+ a.at = /^\d{2}:\d{2}$/.test(String(input.at ?? a.at ?? '')) ? (input.at ?? a.at) : '09:00'
46
+ a.channelId = input.channelId !== undefined ? (this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : (a.channelId ?? null)
47
+ a.mention = input.mention !== undefined ? Boolean(input.mention) : Boolean(a.mention)
48
+ a.cooldownMin = clampInt(input.cooldownMin ?? a.cooldownMin ?? 60, 0, 10080, 'cooldownMin')
49
+ a.enabled = input.enabled !== undefined ? Boolean(input.enabled) : (a.enabled ?? true)
50
+ a.updatedAt = Date.now()
51
+ return a
52
+ },
53
+
54
+ async saveAlert(input) {
55
+ const a = await this.normalizeAlert(input)
56
+ this.state.alerts = upsert(this.state.alerts ?? [], a)
57
+ await this.save()
58
+ this.scheduleAlerts()
59
+ return a
60
+ },
61
+
62
+ async deleteAlert(id) {
63
+ this.state.alerts = (this.state.alerts ?? []).filter(a => a.id !== id)
64
+ await this.save()
65
+ return { deleted: id }
66
+ },
67
+
68
+ /** Evaluate one alert now; pushes when it triggers (respecting cooldown) unless dryRun. */
69
+ async checkAlert(id, opts = {}) {
70
+ if (opts.dryRun) return this.checkAlertInner(id, opts)
71
+ return this.withJobLock(`alert:${id}`, () => this.checkAlertInner(id, opts))
72
+ },
73
+
74
+ /** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
75
+ async previewAlert(input) {
76
+ const tmpId = `preview-${newId('al')}`
77
+ const saved = this.state.alerts
78
+ try {
79
+ const draft = await this.normalizeAlert({ ...input, id: undefined }, tmpId)
80
+ this.state.alerts = [...(saved ?? []), draft]
81
+ return await this.checkAlertInner(tmpId, { dryRun: true })
82
+ } finally {
83
+ this.state.alerts = saved
84
+ }
85
+ },
86
+
87
+ async checkAlertInner(id, { dryRun = false, force = false } = {}) {
88
+ const a = (this.state.alerts ?? []).find(x => x.id === id)
89
+ if (!a) throw new UserError('nl2sql/invalid', '告警不存在')
90
+ const ds = this.requireDatasource(a.datasourceId)
91
+ let result
92
+ if (a.source === 'metric') {
93
+ const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
94
+ result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
95
+ } else {
96
+ result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
97
+ }
98
+ // Series: first column = time/category, last numeric column = value.
99
+ const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
100
+ const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
101
+ if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
102
+ const ev = evaluateAlert(a.condition, series, { now: Date.now() })
103
+ a.lastCheckedAt = Date.now()
104
+ a.lastValue = ev.value ?? null
105
+ a.lastResultId = result.resultId
106
+ const cooled = !a.lastFiredAt || Date.now() - a.lastFiredAt >= a.cooldownMin * 60000
107
+ let pushed = false
108
+ if (ev.triggered) {
109
+ a.state = 'firing'
110
+ if (!dryRun && (cooled || force)) {
111
+ a.lastFiredAt = Date.now()
112
+ const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
113
+ this.records.put('alertEvent', event)
114
+ if (a.channelId) {
115
+ try {
116
+ await this.sendToChannel({ channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' })
117
+ pushed = true
118
+ event.pushed = true
119
+ } catch (error) {
120
+ event.pushError = error.message
121
+ }
122
+ this.records.put('alertEvent', event)
123
+ }
124
+ }
125
+ } else if (a.state === 'firing') {
126
+ a.state = 'ok'
127
+ if (!dryRun && a.channelId && a.notifyRecovery !== false) {
128
+ try { await this.sendToChannel({ channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }) } catch {}
129
+ }
130
+ } else {
131
+ a.state = 'ok'
132
+ }
133
+ await this.save()
134
+ return { ...ev, pushed, resultId: result.resultId, state: a.state }
135
+ },
136
+
137
+ listAlertEvents(alertId) {
138
+ return this.records.list('alertEvent', { ref: alertId, limit: 200 })
139
+ },
140
+
141
+ /**
142
+ * In-process scheduler (alerts + daily schema check). The Harness schedule
143
+ * service only delivers prompts to agent sessions, so deterministic checks run
144
+ * here. Reliability:
145
+ * - a first tick 20 s after start catches up on anything missed while
146
+ * Harness was closed (each job keeps its own last-run time, so a missed
147
+ * daily slot runs once, not once per missed day);
148
+ * - per-job in-flight locks (manual "check now" and the tick never overlap);
149
+ * - jobs run concurrently with a timeout, so one slow query does not block others;
150
+ * - every tick is recorded and shown on the page.
151
+ */
152
+ scheduleAlerts() {
153
+ if (this.alertTimer || this.disposed) return
154
+ const tick = () => { this.tickAlerts().catch(error => { this.state.scheduler = { ...(this.state.scheduler ?? {}), lastError: error.message } }) }
155
+ this.alertTimer = setInterval(tick, 60000)
156
+ this.alertTimer.unref?.()
157
+ this.alertKick = setTimeout(tick, this.config.schedulerDelayMs ?? 20000)
158
+ this.alertKick.unref?.()
159
+ },
160
+
161
+ /** Next due time (ms) of an alert after its last check. */
162
+ alertDueAt(a, now = Date.now()) {
163
+ const last = a.lastCheckedAt ?? 0
164
+ if (a.every === '15m') return last + 15 * 60000
165
+ if (a.every === 'hour') return last + 60 * 60000
166
+ // daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
167
+ const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
168
+ const slot = new Date(now)
169
+ slot.setHours(hh, mm, 0, 0)
170
+ if (slot.getTime() > now) slot.setDate(slot.getDate() - 1)
171
+ return last < slot.getTime() ? slot.getTime() : (() => { const n = new Date(slot); n.setDate(n.getDate() + 1); return n.getTime() })()
172
+ },
173
+
174
+ async withJobLock(key, fn) {
175
+ this.jobLocks ??= new Set()
176
+ if (this.jobLocks.has(key)) throw new UserError('nl2sql/busy', '该任务正在执行,请稍后再试')
177
+ this.jobLocks.add(key)
178
+ try { return await fn() } finally { this.jobLocks.delete(key) }
179
+ },
180
+
181
+ async tickAlerts(now = new Date()) {
182
+ if (this.alertBusy || this.disposed) return
183
+ this.alertBusy = true
184
+ const started = Date.now()
185
+ const ran = []
186
+ try {
187
+ const due = (this.state.alerts ?? []).filter(a => a.enabled && this.alertDueAt(a, now.getTime()) <= now.getTime())
188
+ const timeoutMs = this.config.alertTimeoutMs ?? 120000
189
+ await Promise.all(due.map(async (a) => {
190
+ try {
191
+ await Promise.race([
192
+ this.checkAlert(a.id),
193
+ new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
194
+ ])
195
+ a.lastError = undefined
196
+ ran.push({ id: a.id, ok: true })
197
+ } catch (error) {
198
+ if (error.code === 'nl2sql/busy') return
199
+ a.lastError = error.message
200
+ a.lastCheckedAt = Date.now()
201
+ ran.push({ id: a.id, ok: false, error: error.message })
202
+ }
203
+ }))
204
+ // Daily schema watch (catches up once after downtime).
205
+ const at = this.state.settings.schemaCheckDaily
206
+ if (at) {
207
+ const [hh, mm] = at.split(':').map(Number)
208
+ const slot = new Date(now)
209
+ slot.setHours(hh, mm, 0, 0)
210
+ if (slot > now) slot.setDate(slot.getDate() - 1)
211
+ if ((this.state.lastSchemaCheck ?? 0) < slot.getTime()) {
212
+ this.state.lastSchemaCheck = Date.now()
213
+ try { await this.withJobLock('schema-check', () => this.checkAllSchemas()); ran.push({ id: 'schema', ok: true }) } catch (error) { ran.push({ id: 'schema', ok: false, error: error.message }) }
214
+ }
215
+ }
216
+ this.state.scheduler = { lastTickAt: started, lastTickMs: Date.now() - started, lastRan: ran.slice(0, 50), lastError: undefined }
217
+ await this.save()
218
+ } finally {
219
+ this.alertBusy = false
220
+ }
221
+ },
222
+
223
+ schedulerStatus() {
224
+ const now = Date.now()
225
+ return {
226
+ ...(this.state.scheduler ?? {}),
227
+ running: Boolean(this.alertTimer),
228
+ nextAlerts: (this.state.alerts ?? []).filter(a => a.enabled).map(a => ({ id: a.id, name: a.name, dueAt: this.alertDueAt(a, now) })).sort((x, y) => x.dueAt - y.dueAt).slice(0, 20),
229
+ schemaCheckDaily: this.state.settings.schemaCheckDaily || null,
230
+ lastSchemaCheck: this.state.lastSchemaCheck ?? null,
231
+ }
232
+ },
233
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Nl2SqlService methods: dashboards.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ */
5
+ import { normalizeFilters } from '../filters.js'
6
+ import { referencedTables } from '../policy.js'
7
+ import { admitSql } from '../sql-guard.js'
8
+ import { UserError, newId, overviewPlans, requireText, upsert } from './shared.js'
9
+
10
+ /** Widget width: 'third' | 'half' | 'full' (anything else → 'half'). */
11
+ export function widgetWidth(width) {
12
+ return width === 'full' || width === 'third' ? width : 'half'
13
+ }
14
+
15
+ export const methods = {
16
+ // ------------------------------------------------------------ dashboard
17
+
18
+ listDashboard(dashboardId) {
19
+ return (dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()).widgets
20
+ },
21
+
22
+ requireDashboard(id) {
23
+ const d = this.state.dashboards.find(x => x.id === id)
24
+ if (!d) throw new UserError('nl2sql/unknown-dashboard', '看板不存在')
25
+ return d
26
+ },
27
+
28
+ listDashboards() {
29
+ return { current: this.currentDashboardObj().id, dashboards: this.state.dashboards.map(d => ({ id: d.id, name: d.name, description: d.description ?? '', widgets: d.widgets.length, filters: d.filters?.length ?? 0, createdAt: d.createdAt, updatedAt: d.updatedAt })) }
30
+ },
31
+
32
+ async saveDashboard({ id, name, description, copyFrom }) {
33
+ let d = id ? this.requireDashboard(id) : undefined
34
+ const nm = requireText(name ?? d?.name, 'name', 40)
35
+ if (this.state.dashboards.some(x => x.name === nm && x.id !== d?.id)) throw new UserError('nl2sql/duplicate', `看板名称已存在:${nm}`)
36
+ if (!d) {
37
+ const src = copyFrom ? this.requireDashboard(copyFrom) : undefined
38
+ d = { id: newId('db'), name: nm, description: '', widgets: src ? src.widgets.map(w => ({ ...w, id: newId('w') })) : [], filters: src ? [...(src.filters ?? [])] : [], createdAt: Date.now() }
39
+ this.state.dashboards.push(d)
40
+ this.state.currentDashboard = d.id
41
+ }
42
+ d.name = nm
43
+ if (description !== undefined) d.description = String(description).slice(0, 300)
44
+ d.updatedAt = Date.now()
45
+ this.syncLegacyDashboard()
46
+ await this.save()
47
+ return this.listDashboards()
48
+ },
49
+
50
+ async deleteDashboard(id) {
51
+ if (this.state.dashboards.length <= 1) throw new UserError('nl2sql/invalid', '至少保留一个看板')
52
+ this.requireDashboard(id)
53
+ this.state.dashboards = this.state.dashboards.filter(d => d.id !== id)
54
+ if (this.state.currentDashboard === id) this.state.currentDashboard = this.state.dashboards[0].id
55
+ for (const r of this.state.reports) if (r.dashboardId === id) delete r.dashboardId
56
+ this.syncLegacyDashboard()
57
+ await this.save()
58
+ return this.listDashboards()
59
+ },
60
+
61
+ async selectDashboard(id) {
62
+ this.requireDashboard(id)
63
+ this.state.currentDashboard = id
64
+ this.syncLegacyDashboard()
65
+ await this.save()
66
+ return this.listDashboards()
67
+ },
68
+
69
+ /** Move or copy a widget into another dashboard. */
70
+ async transferWidget({ id, toDashboardId, copy = false }) {
71
+ const target = this.requireDashboard(toDashboardId)
72
+ const from = this.state.dashboards.find(d => d.widgets.some(w => w.id === id))
73
+ if (!from) throw new UserError('nl2sql/unknown-widget', '看板组件不存在')
74
+ const w = from.widgets.find(x => x.id === id)
75
+ target.widgets.push(copy ? { ...w, id: newId('w') } : w)
76
+ if (!copy) from.widgets = from.widgets.filter(x => x.id !== id)
77
+ this.syncLegacyDashboard()
78
+ await this.save()
79
+ return { ok: true }
80
+ },
81
+
82
+ async pinWidget({ title, sql, datasourceId, chart, width, dashboardId }) {
83
+ const ds = this.requireDatasource(datasourceId)
84
+ admitSql(sql, { dialect: ds.type ?? 'mysql' })
85
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
86
+ const widget = { id: newId('w'), title: String(title || 'Untitled').slice(0, 120), sql, datasourceId: ds.id, chart: chart ?? { type: 'table' }, width: widgetWidth(width), createdAt: Date.now() }
87
+ d.widgets.push(widget)
88
+ this.syncLegacyDashboard()
89
+ await this.save()
90
+ return widget
91
+ },
92
+
93
+ findWidget(id) {
94
+ for (const d of this.state.dashboards) {
95
+ const w = d.widgets.find(x => x.id === id)
96
+ if (w) return { w, d }
97
+ }
98
+ return {}
99
+ },
100
+
101
+ async unpinWidget(id) {
102
+ for (const d of this.state.dashboards) d.widgets = d.widgets.filter(w => w.id !== id)
103
+ this.syncLegacyDashboard()
104
+ await this.save()
105
+ return { deleted: id }
106
+ },
107
+
108
+ async runWidget(id, filters, { useCache = true, actor } = {}) {
109
+ const { w, d } = this.findWidget(id)
110
+ if (!w) throw new UserError('nl2sql/unknown-widget', '看板组件不存在')
111
+ return this.runQuery({ governed: true, sql: w.sql, datasourceId: w.datasourceId, title: w.title, chart: w.chart, source: 'dashboard', confirmScan: true, filters: filters ?? d.filters ?? [], useCache, actor })
112
+ },
113
+
114
+ async updateWidget({ id, title, chart, width, sql }) {
115
+ const { w } = this.findWidget(id)
116
+ if (!w) throw new UserError('nl2sql/unknown-widget', '看板组件不存在')
117
+ if (title !== undefined) w.title = requireText(title, 'title', 120)
118
+ if (chart !== undefined) w.chart = chart
119
+ if (width !== undefined) w.width = widgetWidth(width)
120
+ if (sql !== undefined) { admitSql(sql, { dialect: this.findDatasource(w.datasourceId)?.type ?? 'mysql' }); w.sql = sql }
121
+ await this.save()
122
+ return w
123
+ },
124
+
125
+ /** Set the widget order of a dashboard; `ids` must be a permutation of its widget ids. */
126
+ async reorderWidgets({ dashboardId, ids } = {}) {
127
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
128
+ if (!Array.isArray(ids)) throw new UserError('nl2sql/invalid', 'ids 必须是数组')
129
+ const byId = new Map(d.widgets.map(w => [w.id, w]))
130
+ if (ids.length !== d.widgets.length || new Set(ids).size !== ids.length || ids.some(id => !byId.has(id))) throw new UserError('nl2sql/invalid', 'ids 必须恰好包含该看板的全部组件(不能重复或缺少)')
131
+ d.widgets = ids.map(id => byId.get(id))
132
+ d.updatedAt = Date.now()
133
+ this.syncLegacyDashboard()
134
+ await this.save()
135
+ return d.widgets
136
+ },
137
+
138
+ async moveWidget({ id, delta }) {
139
+ const list = this.findWidget(id).d?.widgets ?? []
140
+ const i = list.findIndex(x => x.id === id)
141
+ const j = i + (delta < 0 ? -1 : 1)
142
+ if (i < 0 || j < 0 || j >= list.length) return list
143
+ ;[list[i], list[j]] = [list[j], list[i]]
144
+ await this.save()
145
+ return list
146
+ },
147
+
148
+ listHistory({ limit = 100, datasourceId, status, source, search, actorId } = {}) {
149
+ const q = search ? String(search).toLowerCase() : ''
150
+ return this.records.list('history', { limit: Math.min(limit, 1000), ref: datasourceId, filter: (h) =>
151
+ (!actorId || h.actor?.id === actorId) &&
152
+ (!status || h.status === status || (status === 'ok' && h.status === undefined)) &&
153
+ (!source || h.source === source) &&
154
+ (!q || h.sql.toLowerCase().includes(q) || (h.title ?? '').toLowerCase().includes(q)) })
155
+ },
156
+
157
+ /**
158
+ * Delete history entries: one id, a list of ids, or every entry of the same
159
+ * question (`title`) for a data source (used by "最近的提问" where one row
160
+ * stands for repeated asks of the same question).
161
+ */
162
+ async deleteHistory({ id, ids, title, datasourceId } = {}) {
163
+ const list = [...new Set([...(ids ?? []), ...(id ? [id] : [])].map(String))]
164
+ if (!list.length && !title) throw new UserError('nl2sql/invalid', '缺少要删除的历史记录')
165
+ let deleted = 0
166
+ for (const x of list) {
167
+ if (this.records.get('history', x)) { this.records.delete('history', x); deleted++ }
168
+ }
169
+ if (title) {
170
+ const same = this.records.list('history', { ref: datasourceId, limit: 100000, filter: h => h.title === title && h.source === 'agent' })
171
+ for (const h of same) { this.records.delete('history', h.id); deleted++ }
172
+ }
173
+ if (!deleted) throw new UserError('nl2sql/not-found', '历史记录不存在或已删除')
174
+ this.auditAdmin('history.delete', title ? `「${String(title).slice(0, 60)}」${deleted} 条` : `${deleted} 条`)
175
+ await this.save()
176
+ return { deleted }
177
+ },
178
+
179
+ async clearHistory() {
180
+ this.records.clear('history')
181
+ await this.save()
182
+ return { cleared: true }
183
+ },
184
+
185
+ // ----------------------------------------------------- scheduled reports
186
+
187
+ scheduleService() {
188
+ const schedule = this.ctx.get('schedule')
189
+ if (!schedule) throw new UserError('nl2sql/no-schedule', '当前 Harness 没有启用定时任务服务(dsh-schedule),无法创建定时报告。')
190
+ return schedule
191
+ },
192
+
193
+ reportPrompt(report, ds) {
194
+ const dash = (report.dashboardId && this.state.dashboards.find(d => d.id === report.dashboardId)) || this.currentDashboardObj()
195
+ const scope = report.scope === 'dashboard'
196
+ ? `请依次执行「数据问答」看板「${dash.name}」中属于该数据源的全部图表对应的查询(SQL 见下),用 nl2sql_query 重新查询并生成图表:\n${dash.widgets.filter(w => w.datasourceId === ds.id).map((w, i) => `${i + 1}. ${w.title}\n${w.sql}`).join('\n\n') || '(看板暂无该数据源的图表,请根据下面的分析要求自行选择指标)'}`
197
+ : `分析要求:${report.question}`
198
+ return [
199
+ `[数据问答定时报告] ${report.title}`,
200
+ `先调用 nl2sql_datasource action=use datasource=${ds.id}(${ds.name})绑定数据源,然后完成以下分析。`,
201
+ scope,
202
+ report.instructions ? `补充要求:${report.instructions}` : '',
203
+ report.channelId && this.state.channels.some(c => c.id === report.channelId) ? `完成后调用 nl2sql_send,channel=${report.channelId}(${this.state.channels.find(c => c.id === report.channelId).name}),title="${report.title}",text 为你的报告摘要(Markdown),resultIds 为本次关键查询的 resultId(最多 6 个)。` : '',
204
+ `最后用中文写一段报告摘要:关键数字、与上一周期相比的变化(${report.period === 'weekly' ? '周环比' : '日环比'},若可计算)、异常点和建议关注事项。不要逐行复述表格数据。`,
205
+ ].filter(Boolean).join('\n\n')
206
+ },
207
+
208
+ timingFor(report) {
209
+ const tz = report.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone
210
+ if (report.period === 'weekly') return { kind: 'weekly', weekly: { time: report.time, time_zone: tz, weekdays: report.weekdays?.length ? report.weekdays : [1] } }
211
+ return { kind: 'daily', daily: { time: report.time, time_zone: tz } }
212
+ },
213
+
214
+ async listReports() {
215
+ let catalog = []
216
+ try { catalog = await this.ctx.get('schedule')?.catalog() ?? [] } catch {}
217
+ const byId = new Map(catalog.map(e => [e.id, e]))
218
+ return this.state.reports.map(r => {
219
+ const e = byId.get(r.scheduleId)
220
+ return { ...r, channelName: this.state.channels.find(c => c.id === r.channelId)?.name ?? null, status: e?.status ?? (r.paused && !r.scheduleId ? 'paused' : 'missing'), scheduledAt: e?.scheduledAt ?? null, lastDelivery: e?.lastDelivery ?? null, datasourceName: this.findDatasource(r.datasourceId)?.name ?? r.datasourceId }
221
+ })
222
+ },
223
+
224
+ async saveReport(input) {
225
+ const ds = this.requireDatasource(input.datasourceId)
226
+ const schedule = this.scheduleService()
227
+ const existing = input.id ? this.state.reports.find(r => r.id === input.id) : undefined
228
+ const report = {
229
+ id: existing?.id ?? newId('rp'),
230
+ title: requireText(input.title, 'title', 80),
231
+ datasourceId: ds.id,
232
+ scope: input.scope === 'dashboard' ? 'dashboard' : 'question',
233
+ question: input.scope === 'dashboard' ? String(input.question ?? '') : requireText(input.question, 'question', 2000),
234
+ instructions: String(input.instructions ?? '').slice(0, 1000),
235
+ period: input.period === 'weekly' ? 'weekly' : 'daily',
236
+ time: /^\d{2}:\d{2}$/.test(String(input.time)) ? `${input.time}:00` : /^\d{2}:\d{2}:\d{2}$/.test(String(input.time)) ? String(input.time) : '09:00:00',
237
+ weekdays: Array.isArray(input.weekdays) ? [...new Set(input.weekdays.map(Number).filter(n => n >= 1 && n <= 7))].sort() : [1],
238
+ timeZone: String(input.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone),
239
+ target: input.target === 'new' ? 'new' : 'new_once',
240
+ channelId: input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : undefined,
241
+ dashboardId: input.dashboardId && this.state.dashboards.some(d => d.id === input.dashboardId) ? input.dashboardId : undefined,
242
+ createdAt: existing?.createdAt ?? Date.now(),
243
+ updatedAt: Date.now(),
244
+ }
245
+ const request = {
246
+ title: `数据报告:${report.title}`.slice(0, 120),
247
+ prompt: this.reportPrompt(report, ds),
248
+ timing: this.timingFor(report),
249
+ }
250
+ let entry
251
+ if (existing?.scheduleId) {
252
+ entry = await schedule.edit({ id: existing.scheduleId, action: 'update', patch: { ...request, ...(existing.target !== report.target ? { target: { kind: report.target, create: { agentPreset: 'nl2sql' } } } : {}) } })
253
+ if (entry?.code === 'schedule_not_found') entry = undefined
254
+ }
255
+ if (!entry) {
256
+ entry = await schedule.create({ ...request, target: { kind: report.target, create: { agentPreset: 'nl2sql' } } })
257
+ }
258
+ report.scheduleId = entry.id
259
+ this.state.reports = upsert(this.state.reports, report)
260
+ await this.save()
261
+ return report
262
+ },
263
+
264
+ async setReportActive({ id, active }) {
265
+ const report = this.state.reports.find(r => r.id === id)
266
+ if (!report) throw new UserError('nl2sql/unknown-report', '报告不存在')
267
+ // Imported without a schedule service: activating creates the schedule now.
268
+ if (!report.scheduleId) {
269
+ if (!active) return { id: report.id, status: 'paused' }
270
+ const { paused: _p, ...def } = report
271
+ return this.saveReport({ ...def, time: def.time })
272
+ }
273
+ const res = await this.scheduleService().edit({ id: report.scheduleId, action: 'update', patch: { active: Boolean(active) } })
274
+ if (res?.code) throw new UserError('nl2sql/schedule', `更新定时任务失败:${res.code}`)
275
+ return res
276
+ },
277
+
278
+ async runReportNow(id) {
279
+ const report = this.state.reports.find(r => r.id === id)
280
+ if (!report) throw new UserError('nl2sql/unknown-report', '报告不存在')
281
+ const res = await this.scheduleService().run({ id: report.scheduleId })
282
+ if (res?.code === 'schedule_execution_pending') throw new UserError('nl2sql/schedule', '报告正在执行中,请稍后再试')
283
+ if (res?.code) throw new UserError('nl2sql/schedule', `执行失败:${res.code}`)
284
+ return res
285
+ },
286
+
287
+ async deleteReport(id) {
288
+ const report = this.state.reports.find(r => r.id === id)
289
+ if (!report) return { deleted: id }
290
+ try { await this.ctx.get('schedule')?.edit({ id: report.scheduleId, action: 'delete' }) } catch {}
291
+ this.state.reports = this.state.reports.filter(r => r.id !== id)
292
+ await this.save()
293
+ return { deleted: id }
294
+ },
295
+
296
+ // --------------------------------------------------------- auto overview
297
+
298
+ /** Build overview widgets for one table from its column roles; runs each query and returns results. */
299
+ async tableOverview({ datasourceId, table, pin = false }) {
300
+ const ds = this.requireDatasource(datasourceId)
301
+ const schema = await this.visibleSchema(ds)
302
+ const t = schema.tables.find(x => x.name === table)
303
+ if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
304
+ const plans = overviewPlans(ds.type ?? 'mysql', t, this.knowledgeFor(ds.id))
305
+ const results = []
306
+ for (const plan of plans) {
307
+ try {
308
+ const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, title: plan.title, chart: plan.chart, source: 'ui', confirmScan: true })
309
+ results.push(r)
310
+ if (pin) await this.pinWidget({ title: plan.title, sql: plan.sql, datasourceId: ds.id, chart: r.chart, width: plan.width })
311
+ } catch (error) {
312
+ results.push({ title: plan.title, sql: plan.sql, error: error.message })
313
+ }
314
+ }
315
+ return { table: t.name, results }
316
+ },
317
+
318
+ // ------------------------------------------------------ dashboard filters
319
+
320
+ /** Columns dashboard filters can target, from the visible schema of the widgets' tables. */
321
+ async filterCandidates(dashboardId) {
322
+ const time = new Map()
323
+ const dims = new Map()
324
+ for (const w of (dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()).widgets) {
325
+ const ds = this.findDatasource(w.datasourceId)
326
+ if (!ds) continue
327
+ let schema
328
+ try { schema = await this.visibleSchema(ds) } catch { continue }
329
+ const refs = referencedTables(w.sql, schema.tables, { dialect: ds.type ?? 'mysql' })
330
+ for (const t of schema.tables.filter(x => refs.includes(x.name))) {
331
+ for (const c of t.columns) {
332
+ if (c.roles.includes('time') && /date|time|stamp/.test(c.dataType)) {
333
+ time.set(c.name, { column: c.name, label: c.comment || c.name, tables: [...new Set([...(time.get(c.name)?.tables ?? []), t.name])] })
334
+ } else if ((c.roles.includes('enum') || c.roles.includes('geo')) && !c.roles.includes('sensitive')) {
335
+ const prev = dims.get(c.name)
336
+ dims.set(c.name, {
337
+ column: c.name, label: prev?.label || c.comment || c.name,
338
+ tables: [...new Set([...(prev?.tables ?? []), t.name])],
339
+ values: [...new Set([...(prev?.values ?? []), ...(c.values ?? []).map(v => String(v.value))])].slice(0, 50),
340
+ })
341
+ }
342
+ }
343
+ }
344
+ }
345
+ return { time: [...time.values()], dims: [...dims.values()].sort((a, b) => b.tables.length - a.tables.length).slice(0, 30) }
346
+ },
347
+
348
+ getDashboardFilters(dashboardId) {
349
+ return (dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()).filters ?? []
350
+ },
351
+
352
+ async saveDashboardFilters(filters, dashboardId) {
353
+ const d = dashboardId ? this.requireDashboard(dashboardId) : this.currentDashboardObj()
354
+ d.filters = normalizeFilters(filters)
355
+ this.syncLegacyDashboard()
356
+ await this.save()
357
+ return d.filters
358
+ },
359
+ }