@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.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.
- package/CHANGELOG.md +39 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/analysis.js +4 -1
- package/bin/nl2sql-mcp.mjs +103 -0
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/filters.js +87 -33
- package/index.js +21 -3
- package/lib/client.js +2122 -153
- package/package.json +6 -1
- package/service/alerts.js +60 -26
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/features.js +21 -0
- package/service/forecast.js +252 -0
- package/service/freshness.js +747 -0
- package/service/mcp.js +677 -0
- package/service/pptx.js +362 -0
- package/service/present.js +516 -0
- package/service/proactive.js +531 -0
- package/service/query.js +39 -10
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
|
@@ -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
|
+
}
|