@hedeqiang/dsh-nl2sql 0.12.2 → 0.13.1

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/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
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
+ "bin": {
7
+ "nl2sql-mcp": "bin/nl2sql-mcp.mjs"
8
+ },
6
9
  "exports": {
7
10
  ".": "./index.js",
8
11
  "./client": "./lib/client.js",
@@ -10,6 +13,8 @@
10
13
  "./icon": "./icon.svg"
11
14
  },
12
15
  "files": [
16
+ "bin",
17
+ "docs/mcp.md",
13
18
  "index.js",
14
19
  "service/*.js",
15
20
  "channels/*.js",
@@ -85,7 +90,7 @@
85
90
  "wecom"
86
91
  ],
87
92
  "engines": {
88
- "node": ">=24"
93
+ "node": ">=18"
89
94
  },
90
95
  "repository": {
91
96
  "type": "git",
package/service/alerts.js CHANGED
@@ -94,11 +94,13 @@ export const methods = {
94
94
  let a = draft ? structuredClone(draft) : { ...found }
95
95
  const ds = this.requireDatasource(a.datasourceId)
96
96
  let result
97
+ // Chat-created alerts (service/subscriptions.js) run as their creator, re-resolved now.
98
+ const actor = a.chatTarget && this.chatAlertActor ? await this.chatAlertActor(a) : undefined
97
99
  if (a.source === 'metric') {
98
100
  const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
99
- result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
101
+ result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert', actor })
100
102
  } else {
101
- result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
103
+ result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true, actor })
102
104
  }
103
105
  // Series: first column = time/category, last numeric column = value.
104
106
  const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
@@ -120,9 +122,12 @@ export const methods = {
120
122
  a.lastFiredAt = Date.now()
121
123
  const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
122
124
  this.records.put('alertEvent', event)
123
- if (a.channelId) {
125
+ if (a.channelId || (a.chatTarget && this.deliverAlertToChat)) {
124
126
  try {
125
- 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' })
127
+ // Optional extra Markdown from feature modules (e.g. change attribution, service/proactive.js).
128
+ const extra = await Promise.resolve(this.beforeAlertPush?.(a, result)).catch(() => null)
129
+ const msg = { channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**${extra ? `\n\n${extra}` : ''}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' }
130
+ if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg)
126
131
  pushed = true
127
132
  event.pushed = true
128
133
  } catch (error) {
@@ -133,8 +138,9 @@ export const methods = {
133
138
  }
134
139
  } else if (a.state === 'firing') {
135
140
  a.state = 'ok'
136
- if (!dryRun && a.channelId && a.notifyRecovery !== false) {
137
- try { await this.sendToChannel({ channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }) } catch {}
141
+ if (!dryRun && (a.channelId || a.chatTarget) && a.notifyRecovery !== false) {
142
+ const msg = { channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }
143
+ try { if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg) } catch {}
138
144
  }
139
145
  } else {
140
146
  a.state = 'ok'
@@ -221,6 +227,14 @@ export const methods = {
221
227
  ran.push({ id: a.id, ok: false, error: error.message })
222
228
  }
223
229
  }))
230
+ // Jobs registered by other modules (digest, reconciliation, freshness, quality, goals…):
231
+ // service.schedulerJobs() returns [{ id, due(now: Date) -> boolean, run() -> Promise }].
232
+ for (const job of (typeof this.schedulerJobs === 'function' ? this.schedulerJobs(now) : [])) {
233
+ let isDue = false
234
+ try { isDue = Boolean(job.due(now)) } catch {}
235
+ if (!isDue) continue
236
+ try { await this.withJobLock(`job:${job.id}`, () => job.run()); ran.push({ id: job.id, ok: true }) } catch (error) { if (error.code !== 'nl2sql/busy') ran.push({ id: job.id, ok: false, error: error.message }) }
237
+ }
224
238
  // Daily schema watch (catches up once after downtime).
225
239
  const at = this.state.settings.schemaCheckDaily
226
240
  if (at) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Feature modules. Each entry: { name, methods, rpc?(service), tools?(ctx, service, helpers), start?(ctx, service) }.
3
+ * - methods: mixed into the service prototype (`this` = service). Methods named `jobs_<name>(now)` return
4
+ * scheduler jobs [{ id, due(now), run() }] run by the in-process scheduler (service/alerts.js).
5
+ * - rpc: page RPC methods; tools: agent tools; start: called after apply (e.g. servers).
6
+ * New features add a file under service/ and one line here, so they do not edit index.js.
7
+ */
8
+ import { FEATURE as subscriptions } from './subscriptions.js'
9
+ import { FEATURE as reconcile } from './reconcile.js'
10
+ import { FEATURE as present } from './present.js'
11
+ import { FEATURE as mcp } from './mcp.js'
12
+ import { FEATURE as proactive } from './proactive.js'
13
+ import { FEATURE as freshness } from './freshness.js'
14
+ export const FEATURES = [
15
+ present,
16
+ mcp,
17
+ reconcile,
18
+ subscriptions,
19
+ proactive,
20
+ freshness,
21
+ ]
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Forecasting (pure functions, no I/O).
3
+ *
4
+ * - Holt-Winters additive with weekly seasonality (m = 7) for daily series with ≥ 21 points;
5
+ * - otherwise Holt's linear trend.
6
+ * Parameters are fitted by a small grid search minimising the one-step-ahead SSE.
7
+ * Prediction intervals: residual stddev × 1.96 × sqrt(h).
8
+ */
9
+
10
+ const GRID = [0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 0.9]
11
+ const GRID_TREND = [0.01, 0.05, 0.1, 0.2, 0.3]
12
+ const GRID_SEASON = [0.05, 0.1, 0.2, 0.3, 0.5]
13
+
14
+ /** Holt's linear trend: one-step fitted values, final level/trend. */
15
+ export function holtFit(y, alpha, beta) {
16
+ const n = y.length
17
+ if (n < 2) return { fitted: y.map(() => null), level: y[0] ?? 0, trend: 0, sse: 0, count: 0 }
18
+ let level = y[0]
19
+ let trend = y[1] - y[0]
20
+ const fitted = [null]
21
+ let sse = 0
22
+ let count = 0
23
+ for (let t = 1; t < n; t++) {
24
+ const f = level + trend
25
+ fitted.push(f)
26
+ if (t >= 2) { sse += (y[t] - f) ** 2; count++ }
27
+ const prev = level
28
+ level = alpha * y[t] + (1 - alpha) * (level + trend)
29
+ trend = beta * (level - prev) + (1 - beta) * trend
30
+ }
31
+ return { fitted, level, trend, sse, count }
32
+ }
33
+
34
+ /** Holt-Winters additive with season length m (needs ≥ 2 m points). */
35
+ export function holtWintersFit(y, alpha, beta, gamma, m = 7) {
36
+ const n = y.length
37
+ const mean = (a) => a.reduce((s, v) => s + v, 0) / a.length
38
+ const s1 = mean(y.slice(0, m))
39
+ const s2 = mean(y.slice(m, 2 * m))
40
+ let level = s1
41
+ let trend = (s2 - s1) / m
42
+ // Initial seasonal indices: average deviation from each full season's mean.
43
+ const seasons = Math.floor(n / m)
44
+ const season = new Array(m).fill(0)
45
+ for (let k = 0; k < seasons; k++) {
46
+ const mk = mean(y.slice(k * m, (k + 1) * m))
47
+ for (let i = 0; i < m; i++) season[i] += (y[k * m + i] - mk) / seasons
48
+ }
49
+ // Level at the end of the first season: start the recursion at t = m.
50
+ level = s1 + trend * (m - 1) / 2
51
+ const fitted = new Array(m).fill(null)
52
+ let sse = 0
53
+ let count = 0
54
+ for (let t = m; t < n; t++) {
55
+ const si = t % m
56
+ const f = level + trend + season[si]
57
+ fitted.push(f)
58
+ sse += (y[t] - f) ** 2
59
+ count++
60
+ const prev = level
61
+ level = alpha * (y[t] - season[si]) + (1 - alpha) * (level + trend)
62
+ trend = beta * (level - prev) + (1 - beta) * trend
63
+ season[si] = gamma * (y[t] - level) + (1 - gamma) * season[si]
64
+ }
65
+ return { fitted, level, trend, season, sse, count, n, m }
66
+ }
67
+
68
+ /**
69
+ * Fit and forecast `horizon` steps ahead.
70
+ * @param {number[]} values evenly spaced observations (no nulls)
71
+ * @param {{ horizon?: number, seasonal?: boolean, period?: number }} opts seasonal: allow weekly seasonality
72
+ * @returns {{ method, params, fitted, forecast: { h, value, lower, upper }[], sigma }}
73
+ */
74
+ export function forecastSeries(values, { horizon = 7, seasonal = true, period = 7 } = {}) {
75
+ const y = values.map(Number)
76
+ if (y.length < 3 || y.some(v => !Number.isFinite(v))) throw new Error('至少需要 3 个有效数值才能预测')
77
+ const H = Math.max(1, Math.min(365, Math.floor(horizon)))
78
+ const useHW = seasonal && y.length >= 3 * period
79
+ let best
80
+ if (useHW) {
81
+ for (const a of GRID) for (const b of GRID_TREND) for (const g of GRID_SEASON) {
82
+ const fit = holtWintersFit(y, a, b, g, period)
83
+ if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b, gamma: g } }
84
+ }
85
+ } else {
86
+ for (const a of GRID) for (const b of GRID_TREND) {
87
+ const fit = holtFit(y, a, b)
88
+ if (!best || fit.sse < best.fit.sse) best = { fit, params: { alpha: a, beta: b } }
89
+ }
90
+ }
91
+ const { fit, params } = best
92
+ const sigma = fit.count > 1 ? Math.sqrt(fit.sse / (fit.count - 1)) : 0
93
+ const forecast = []
94
+ for (let h = 1; h <= H; h++) {
95
+ const value = useHW
96
+ ? fit.level + h * fit.trend + fit.season[(y.length + h - 1) % period]
97
+ : fit.level + h * fit.trend
98
+ const w = 1.96 * sigma * Math.sqrt(h)
99
+ forecast.push({ h, value, lower: value - w, upper: value + w })
100
+ }
101
+ return { method: useHW ? 'holt-winters' : 'holt', params, fitted: fit.fitted, forecast, sigma }
102
+ }
103
+
104
+ // ------------------------------------------------------------ time axis
105
+
106
+ const DAY = 86400000
107
+ const pad = (n) => String(n).padStart(2, '0')
108
+ const fmtDay = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
109
+
110
+ /** Parse a time label to { kind, ms } (UTC-based arithmetic, display unchanged). */
111
+ export function parseTimeLabel(v) {
112
+ const s = String(v ?? '').trim()
113
+ let m
114
+ // Dates serialized as midnight ISO timestamps (driver Date → ISO) are days.
115
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})T00:00:00(?:\.0+)?Z?$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
116
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/.exec(s))) return { kind: 'hour', ms: Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) }
117
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s))) return { kind: 'day', ms: Date.UTC(+m[1], +m[2] - 1, +m[3]) }
118
+ if ((m = /^(\d{4})-(\d{2})$/.exec(s))) return { kind: 'month', ms: Date.UTC(+m[1], +m[2] - 1, 1) }
119
+ if ((m = /^(\d{4})-W(\d{2})$/.exec(s))) return { kind: 'week', ms: Date.UTC(+m[1], 0, 1) + (+m[2]) * 7 * DAY }
120
+ if ((m = /^(\d{4})$/.exec(s))) return { kind: 'year', ms: Date.UTC(+m[1], 0, 1) }
121
+ return null
122
+ }
123
+
124
+ /**
125
+ * Turn [{ x, y }] (sorted or not) into an evenly spaced series. Daily series get missing days
126
+ * filled with 0 (metrics like counts/sums have no row on days without data).
127
+ * Returns { kind, xs, ys, filled }.
128
+ */
129
+ export function regularize(points) {
130
+ const parsed = points.map(p => ({ ...p, t: parseTimeLabel(p.x) })).filter(p => p.y !== null && p.y !== undefined && Number.isFinite(Number(p.y)))
131
+ const timed = parsed.length >= 2 && parsed.every(p => p.t && p.t.kind === parsed[0].t.kind)
132
+ if (!timed) return { kind: 'index', xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
133
+ parsed.sort((a, b) => a.t.ms - b.t.ms)
134
+ const kind = parsed[0].t.kind
135
+ if (kind !== 'day') return { kind, xs: parsed.map(p => String(p.x)), ys: parsed.map(p => Number(p.y)), filled: 0 }
136
+ const byDay = new Map()
137
+ for (const p of parsed) byDay.set(p.t.ms, (byDay.get(p.t.ms) ?? 0) + Number(p.y))
138
+ const xs = []
139
+ const ys = []
140
+ let filled = 0
141
+ for (let ms = parsed[0].t.ms; ms <= parsed.at(-1).t.ms; ms += DAY) {
142
+ xs.push(fmtDay(new Date(ms)))
143
+ if (byDay.has(ms)) ys.push(byDay.get(ms)); else { ys.push(0); filled++ }
144
+ }
145
+ return { kind, xs, ys, filled }
146
+ }
147
+
148
+ /** Labels for the next `horizon` steps after the last label. */
149
+ export function futureLabels(lastLabel, kind, horizon) {
150
+ const t = parseTimeLabel(lastLabel)
151
+ const out = []
152
+ for (let h = 1; h <= horizon; h++) {
153
+ if (!t || kind === 'index' || kind === 'week') { out.push(`+${h}`); continue }
154
+ const d = new Date(t.ms)
155
+ if (kind === 'day') out.push(fmtDay(new Date(t.ms + h * DAY)))
156
+ else if (kind === 'hour') { const x = new Date(t.ms + h * 3600000); out.push(`${fmtDay(x)} ${pad(x.getUTCHours())}:00`) }
157
+ else if (kind === 'month') { const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + h, 1)); out.push(`${x.getUTCFullYear()}-${pad(x.getUTCMonth() + 1)}`) }
158
+ else if (kind === 'year') out.push(String(d.getUTCFullYear() + h))
159
+ }
160
+ return out
161
+ }
162
+
163
+ /** Forecast a labelled series: [{ x, y }] → history + labelled forecast. */
164
+ export function forecastPoints(points, { horizon = 7 } = {}) {
165
+ const reg = regularize(points)
166
+ const res = forecastSeries(reg.ys, { horizon, seasonal: reg.kind === 'day' })
167
+ const labels = futureLabels(reg.xs.at(-1), reg.kind, res.forecast.length)
168
+ return {
169
+ method: res.method,
170
+ params: res.params,
171
+ sigma: res.sigma,
172
+ kind: reg.kind,
173
+ filled: reg.filled,
174
+ history: reg.xs.map((x, i) => ({ x, y: reg.ys[i] })),
175
+ forecast: res.forecast.map((f, i) => ({ ...f, x: labels[i] })),
176
+ }
177
+ }
178
+
179
+ // ------------------------------------------------------------ digest statistics
180
+
181
+ /**
182
+ * Daily comparison for the digest: `values` is a day-indexed array ending with the day
183
+ * of interest. Compares the last value with the mean / stddev of the 7 days before it
184
+ * and with the same weekday one week earlier.
185
+ */
186
+ export function dailyStats(values) {
187
+ const y = values.map(Number)
188
+ const cur = y.at(-1)
189
+ const prev = y.slice(-8, -1)
190
+ const avg = prev.length ? prev.reduce((s, v) => s + v, 0) / prev.length : null
191
+ const sd = prev.length > 1 ? Math.sqrt(prev.reduce((s, v) => s + (v - avg) ** 2, 0) / (prev.length - 1)) : null
192
+ const lastWeek = y.length >= 8 ? y.at(-8) : null
193
+ const pct = (a, b) => (b === null || b === undefined || b === 0 ? null : (a - b) / Math.abs(b))
194
+ return {
195
+ value: cur,
196
+ avg7: avg,
197
+ sd7: sd,
198
+ z: sd ? (cur - avg) / sd : null,
199
+ vsAvg: pct(cur, avg),
200
+ lastWeek,
201
+ vsLastWeek: pct(cur, lastWeek),
202
+ }
203
+ }
204
+
205
+ /** Is a daily comparison notable? |z| > zLimit or |Δ% vs 7-day avg| > pctLimit. */
206
+ export function isNotable(stats, { z = 2, pct = 0.3 } = {}) {
207
+ return (stats.z !== null && Math.abs(stats.z) > z) || (stats.vsAvg !== null && Math.abs(stats.vsAvg) > pct)
208
+ }
209
+
210
+ // ------------------------------------------------------------ goals
211
+
212
+ /**
213
+ * Period bounds (local calendar) containing `now`: { start, end } as Date (end exclusive).
214
+ */
215
+ export function periodBounds(period, now = new Date()) {
216
+ const y = now.getFullYear()
217
+ const m = now.getMonth()
218
+ if (period === 'year') return { start: new Date(y, 0, 1), end: new Date(y + 1, 0, 1) }
219
+ if (period === 'quarter') { const q = Math.floor(m / 3) * 3; return { start: new Date(y, q, 1), end: new Date(y, q + 3, 1) } }
220
+ return { start: new Date(y, m, 1), end: new Date(y, m + 1, 1) }
221
+ }
222
+
223
+ /**
224
+ * Run-rate projection. `daily`: [{ date: Date (local midnight), value }] for complete days
225
+ * from the period start up to yesterday. Remaining days (today .. end-1) are projected with
226
+ * the per-weekday mean when every weekday has a sample, else the overall daily mean.
227
+ * Returns { projected, weekdayAware, remainingDays } (projected null without data).
228
+ */
229
+ export function projectRunRate({ daily, actualComplete, today, end }) {
230
+ const remaining = []
231
+ for (let d = new Date(today); d < end; d.setDate(d.getDate() + 1)) remaining.push(new Date(d))
232
+ if (!daily.length) return { projected: null, weekdayAware: false, remainingDays: remaining.length }
233
+ const all = daily.reduce((s, d) => s + d.value, 0) / daily.length
234
+ const byWd = new Map()
235
+ for (const d of daily) { const w = d.date.getDay(); const e = byWd.get(w) ?? { s: 0, n: 0 }; e.s += d.value; e.n++; byWd.set(w, e) }
236
+ const weekdayAware = byWd.size === 7
237
+ let rest = 0
238
+ for (const d of remaining) rest += weekdayAware ? byWd.get(d.getDay()).s / byWd.get(d.getDay()).n : all
239
+ return { projected: actualComplete + rest, weekdayAware, remainingDays: remaining.length }
240
+ }
241
+
242
+ /** Goal status from actual-to-date and projection. */
243
+ export function goalStatus({ direction = 'up', target, actual, projected, ended = false }) {
244
+ if (direction === 'down') {
245
+ if (actual > target) return 'missed'
246
+ if (ended) return 'achieved'
247
+ return projected === null || projected <= target ? 'on-track' : 'at-risk'
248
+ }
249
+ if (actual >= target) return 'achieved'
250
+ if (ended) return 'missed'
251
+ return projected !== null && projected >= target ? 'on-track' : 'at-risk'
252
+ }