@hedeqiang/dsh-nl2sql 0.12.2 → 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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Feature "proactive": data finds people.
3
+ * - Daily digest (state.digests): yesterday vs 7-day mean and vs same weekday last week per
4
+ * metric (or dashboard time-series widget), attribution of the top movers, Markdown summary +
5
+ * up to 3 charts pushed to a channel at HH:MM. Record kind 'digestRun'.
6
+ * - Alert auto-attribution: beforeAlertPush(alert, result) → "主要来自:…" line (≤ 20 s).
7
+ * - Goals (state.goals): actual-to-date, run-rate projection (weekday-aware), status; optional
8
+ * weekly push (state.goalPush).
9
+ * - Forecast: forecast({ resultId, horizon }) for single-measure time series; agent tool nl2sql_forecast.
10
+ * Every query goes through queryMetric / runWidget / explainChange (→ runQuery).
11
+ */
12
+ import { isAdditive } from '../analysis.js'
13
+ import { forecastPoints, dailyStats, isNotable, periodBounds, projectRunRate, goalStatus, parseTimeLabel } from './forecast.js'
14
+ import { UserError, clampInt, fmtNum, lastDailySlot, newId, oneOf, requireText, upsert } from './shared.js'
15
+
16
+ const DAY = 86400000
17
+ const pad = (n) => String(n).padStart(2, '0')
18
+ /** Local calendar date 'YYYY-MM-DD'. */
19
+ const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
20
+ const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n)
21
+ const midnight = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate())
22
+ /** A time label (any driver format) → local 'YYYY-MM-DD', or null. */
23
+ function dayKey(v) {
24
+ const s = String(v ?? '')
25
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s)
26
+ if (!m) return null
27
+ // Midnight UTC ISO from a DATE column is the date itself; other ISO timestamps are converted to local.
28
+ if (/T/.test(s) && !/T00:00:00(\.0+)?Z?$/.test(s)) { const d = new Date(s); return Number.isNaN(d.getTime()) ? null : ymd(d) }
29
+ return `${m[1]}-${m[2]}-${m[3]}`
30
+ }
31
+
32
+ function timeout(ms, label) {
33
+ return new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`${label} 超时`)), ms); t.unref?.() })
34
+ }
35
+ const withTimeout = (p, ms, label = '') => Promise.race([p, timeout(ms, label)])
36
+
37
+ function numText(v, lang) {
38
+ if (v === null || v === undefined || !Number.isFinite(Number(v))) return '—'
39
+ if (lang === 'zh') return fmtNum(v)
40
+ const n = Number(v)
41
+ const a = Math.abs(n)
42
+ if (a >= 1e9) return `${(n / 1e9).toFixed(2)}B`
43
+ if (a >= 1e6) return `${(n / 1e6).toFixed(2)}M`
44
+ if (a >= 1e4) return `${(n / 1e3).toFixed(1)}K`
45
+ return Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)
46
+ }
47
+ const pctText = (p) => (p === null || p === undefined ? '—' : `${p >= 0 ? '+' : ''}${(p * 100).toFixed(0)}%`)
48
+
49
+ /** Time series view of a result: { xi, yi, points:[{x,y}] } or null (needs a time x and one measure). */
50
+ function seriesOf(result) {
51
+ if (!result?.columns?.length || !Array.isArray(result.rows)) return null
52
+ const cols = result.columns
53
+ if (result.chart?.series) return null
54
+ let xi = cols.findIndex(c => c.name === result.chart?.x)
55
+ if (xi < 0 || cols[xi].kind !== 'time') xi = cols.findIndex(c => c.kind === 'time')
56
+ if (xi < 0) return null
57
+ const ys = (result.chart?.y?.length ? result.chart.y : cols.filter(c => c.kind === 'number').map(c => c.name)).map(n => cols.findIndex(c => c.name === n)).filter(i => i >= 0 && i !== xi && cols[i].kind === 'number')
58
+ if (ys.length !== 1) return null
59
+ // Several rows per x (another dimension column) cannot be read as one series.
60
+ const xsSeen = new Set(result.rows.map(r => String(r[xi])))
61
+ if (xsSeen.size !== result.rows.length) return null
62
+ const yi = ys[0]
63
+ return { xi, yi, x: cols[xi].name, measure: cols[yi].name, points: result.rows.map(r => ({ x: r[xi], y: r[yi] === null ? null : Number(r[yi]) })) }
64
+ }
65
+
66
+ const T = {
67
+ zh: {
68
+ title: (name) => `📊 ${name}`,
69
+ line: (m, v, avg, wk) => `**${m}** 昨天 ${v},比 7 日均值${avg}${wk ? `,比上周同期${wk}` : ''}`,
70
+ hi: (_p, a) => `高 ${(a * 100).toFixed(0)}%`, lo: (_p, a) => `低 ${(a * 100).toFixed(0)}%`, flat: '持平',
71
+ from: (s) => `(主要来自 ${s})`,
72
+ notable: '值得关注', normal: '其他指标', none: '昨天各指标均在正常波动范围内。',
73
+ err: (m, e) => `- ${m}:取数失败(${e})`,
74
+ main: '主要来自:',
75
+ goal: '目标进度',
76
+ },
77
+ en: {
78
+ title: (name) => `📊 ${name}`,
79
+ line: (m, v, avg, wk) => `**${m}** yesterday ${v}, ${avg} vs 7-day avg${wk ? `, ${wk} vs same day last week` : ''}`,
80
+ hi: (p) => `${p}`, lo: (p) => `${p}`, flat: 'flat',
81
+ from: (s) => ` (mainly ${s})`,
82
+ notable: 'Notable', normal: 'Other metrics', none: 'All metrics were within their normal range yesterday.',
83
+ err: (m, e) => `- ${m}: query failed (${e})`,
84
+ main: 'Mainly from: ',
85
+ goal: 'Goal progress',
86
+ },
87
+ }
88
+
89
+ function cmpText(p, L) {
90
+ if (p === null || p === undefined) return '—'
91
+ if (Math.abs(p) < 0.005) return L.flat
92
+ return p > 0 ? L.hi(pctText(p), Math.abs(p)) : L.lo(pctText(p), Math.abs(p))
93
+ }
94
+
95
+ /** "dim=value +Δ" list for the top contributors of an explainChange result. */
96
+ function contributors(why, n, lang) {
97
+ const top = why?.dimensions?.find(d => d.attribution)
98
+ if (!top) return null
99
+ const items = top.attribution.items.filter(i => i.value !== '其他' && Math.sign(i.delta) === Math.sign(top.attribution.total.delta || i.delta) && i.delta !== 0).slice(0, n)
100
+ if (!items.length) return null
101
+ return { dimension: top.dimension, text: items.map(i => `${i.value ?? 'NULL'} ${i.delta >= 0 ? '+' : ''}${numText(i.delta, lang)}`).join(lang === 'zh' ? '、' : ', ') }
102
+ }
103
+
104
+ export const methods = {
105
+ // ------------------------------------------------------------ digests
106
+
107
+ listDigests() {
108
+ return (this.state.digests ?? []).map(d => ({ ...d, datasourceName: this.findDatasource(d.datasourceId)?.name ?? d.datasourceId, channelName: this.state.channels.find(c => c.id === d.channelId)?.name ?? null }))
109
+ },
110
+
111
+ async saveDigest(input = {}) {
112
+ const existing = input.id ? (this.state.digests ?? []).find(d => d.id === input.id) : undefined
113
+ if (input.id && !existing) throw new UserError('nl2sql/invalid', '日报不存在')
114
+ const d = existing ? { ...existing } : { id: newId('dg'), createdAt: Date.now() }
115
+ d.name = requireText(input.name ?? d.name, 'name', 60)
116
+ const ds = this.requireDatasource(input.datasourceId ?? d.datasourceId)
117
+ d.datasourceId = ds.id
118
+ const dashboardId = input.dashboardId !== undefined ? input.dashboardId : d.dashboardId
119
+ if (dashboardId) {
120
+ this.requireDashboard(dashboardId)
121
+ d.dashboardId = dashboardId
122
+ d.metrics = []
123
+ } else {
124
+ const metrics = Array.isArray(input.metrics) ? input.metrics : (d.metrics ?? [])
125
+ if (!metrics.length) throw new UserError('nl2sql/invalid', '请至少选择一个指标或一个看板', 'metrics')
126
+ d.metrics = metrics.slice(0, 20).map(ref => {
127
+ const m = this.findMetric(ds, ref)
128
+ if (!m) throw new UserError('nl2sql/unknown-metric', `指标库中没有「${ref}」`)
129
+ return m.name
130
+ })
131
+ delete d.dashboardId
132
+ }
133
+ d.channelId = input.channelId !== undefined ? (input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : (d.channelId ?? null)
134
+ d.time = /^\d{2}:\d{2}$/.test(String(input.time ?? d.time ?? '')) ? (input.time ?? d.time) : '09:00'
135
+ d.enabled = input.enabled !== undefined ? Boolean(input.enabled) : (d.enabled ?? true)
136
+ d.language = oneOf(input.language ?? d.language ?? 'zh', ['zh', 'en'], 'language')
137
+ d.threshold = Number.isFinite(Number(input.threshold ?? d.threshold)) ? Math.max(1, Math.min(500, Number(input.threshold ?? d.threshold ?? 30))) : 30
138
+ d.updatedAt = Date.now()
139
+ // Runtime fields from the stored object (a run may have finished meanwhile).
140
+ const cur = (this.state.digests ?? []).find(x => x.id === d.id)
141
+ if (cur) for (const k of ['lastRunAt', 'lastStatus', 'lastError']) d[k] = cur[k]
142
+ this.state.digests = upsert(this.state.digests ?? [], d)
143
+ await this.save()
144
+ return d
145
+ },
146
+
147
+ async deleteDigest(id) {
148
+ this.state.digests = (this.state.digests ?? []).filter(d => d.id !== id)
149
+ await this.save()
150
+ return { deleted: id }
151
+ },
152
+
153
+ listDigestRuns(digestId) {
154
+ return this.records.list('digestRun', { ref: digestId, limit: 50 })
155
+ },
156
+
157
+ /** Run a digest: preview (default) or send (send: true). */
158
+ async runDigest({ id, send = false, now = new Date() } = {}) {
159
+ const d = (this.state.digests ?? []).find(x => x.id === id)
160
+ if (!d) throw new UserError('nl2sql/invalid', '日报不存在')
161
+ const run = { id: newId('dr'), digestId: d.id, name: d.name, at: Date.now(), preview: !send }
162
+ try {
163
+ const out = await this.computeDigest(d, now)
164
+ Object.assign(run, { status: 'ok', title: out.title, text: out.text, items: out.items.map(({ result: _r, ...x }) => x), resultIds: out.resultIds })
165
+ if (send) {
166
+ if (!d.channelId) throw new UserError('nl2sql/invalid', '日报未设置推送渠道')
167
+ await this.sendToChannel({ channelId: d.channelId, title: out.title, text: out.text, resultIds: out.resultIds, source: 'digest' })
168
+ run.pushed = true
169
+ }
170
+ return { ...run, results: out.resultIds.map(r => this.lookupResult(r)).filter(Boolean) }
171
+ } catch (error) {
172
+ run.status = 'error'
173
+ run.error = error.message
174
+ throw error
175
+ } finally {
176
+ this.records.put('digestRun', run)
177
+ if (send) {
178
+ const cur = (this.state.digests ?? []).find(x => x.id === d.id)
179
+ if (cur) { cur.lastRunAt = Date.now(); cur.lastStatus = run.status; cur.lastError = run.error }
180
+ await this.save()
181
+ }
182
+ }
183
+ },
184
+
185
+ /** Series per metric / widget for the digest → statistics → summary. */
186
+ async computeDigest(d, now = new Date()) {
187
+ const lang = d.language === 'en' ? 'en' : 'zh'
188
+ const L = T[lang]
189
+ const today = midnight(now)
190
+ const yesterday = ymd(addDays(today, -1))
191
+ const days = Array.from({ length: 15 }, (_, i) => ymd(addDays(today, i - 15))) // 15 days ending yesterday
192
+ const sources = []
193
+ if (d.dashboardId) {
194
+ const board = this.requireDashboard(d.dashboardId)
195
+ for (const w of board.widgets.slice(0, 20)) sources.push({ name: w.title || w.id, load: () => this.runWidget(w.id, undefined, { useCache: false }) })
196
+ } else {
197
+ for (const m of d.metrics ?? []) {
198
+ sources.push({ name: m, load: () => this.queryMetric({ datasourceId: d.datasourceId, metric: m, grain: 'day', from: days[0], to: ymd(today), title: `${m} · ${lang === 'zh' ? '近 15 天' : 'last 15 days'}`, source: 'digest', chart: { type: 'line', x: '时间', y: [m] } }) })
199
+ }
200
+ }
201
+ const items = []
202
+ for (const s of sources) {
203
+ try {
204
+ const result = await withTimeout(s.load(), 60000, s.name)
205
+ const series = seriesOf(result)
206
+ if (!series) { if (!d.dashboardId) items.push({ name: s.name, error: 'not a daily series' }); continue }
207
+ const byDay = new Map()
208
+ let rawLabel = {}
209
+ for (const p of series.points) { const k = dayKey(p.x); if (!k) continue; byDay.set(k, (byDay.get(k) ?? 0) + (p.y ?? 0)); rawLabel[k] = p.x }
210
+ if (!byDay.size) { if (!d.dashboardId) items.push({ name: s.name, error: 'no daily data' }); continue }
211
+ // Dashboard widgets may cover other ranges: only use them when they reach yesterday's window.
212
+ if (d.dashboardId && ![...byDay.keys()].some(k => k >= days[7])) continue
213
+ const values = days.map(k => byDay.get(k) ?? 0)
214
+ const st = dailyStats(values)
215
+ items.push({ name: d.dashboardId ? s.name : s.name, measure: series.measure, stats: st, notable: isNotable(st, { z: 2, pct: (d.threshold ?? 30) / 100 }), result, labels: { current: rawLabel[yesterday], baseline: rawLabel[days[7]] ?? rawLabel[days[13]] } })
216
+ } catch (error) {
217
+ items.push({ name: s.name, error: error.message })
218
+ }
219
+ }
220
+ // Attribution for the top 1–2 notable movers.
221
+ const movers = items.filter(i => i.notable).sort((a, b) => Math.abs(b.stats.z ?? b.stats.vsAvg ?? 0) - Math.abs(a.stats.z ?? a.stats.vsAvg ?? 0))
222
+ for (const it of movers.slice(0, 2)) {
223
+ if (it.labels.current === undefined || it.labels.baseline === undefined) continue
224
+ try {
225
+ const why = await withTimeout(this.explainChange({ resultId: it.result.resultId, current: it.labels.current, baseline: it.labels.baseline }), 30000, 'attribution')
226
+ const c = contributors(why, 2, lang)
227
+ if (c) it.mainly = c.text
228
+ } catch {}
229
+ }
230
+ const line = (it) => `- ${L.line(it.name, numText(it.stats.value, lang), cmpText(it.stats.vsAvg, L), it.stats.vsLastWeek === null ? null : cmpText(it.stats.vsLastWeek, L))}${it.mainly ? L.from(it.mainly) : ''}`
231
+ const ok = items.filter(i => !i.error)
232
+ const lines = []
233
+ if (movers.length) lines.push(`**${L.notable}**`, ...movers.map(line))
234
+ else lines.push(L.none)
235
+ const rest = ok.filter(i => !i.notable)
236
+ if (rest.length) lines.push('', `<font color='grey'>${L.normal}</font>`, ...rest.map(line))
237
+ for (const i of items.filter(x => x.error)) lines.push(L.err(i.name, i.error))
238
+ const charts = [...movers, ...rest].slice(0, 3).map(i => i.result.resultId)
239
+ return {
240
+ title: `${L.title(d.name)} · ${yesterday}`,
241
+ text: lines.join('\n'),
242
+ items: items.map(i => ({ name: i.name, error: i.error, notable: i.notable, mainly: i.mainly, ...(i.stats ? { stats: i.stats } : {}), resultId: i.result?.resultId })),
243
+ resultIds: charts,
244
+ }
245
+ },
246
+
247
+ // ------------------------------------------------------------ alert attribution
248
+
249
+ /**
250
+ * Called by checkAlertInner before pushing a fired alert: for a time series with ≥ 2 points,
251
+ * attribute the change of the last point vs the previous one. Returns a Markdown line or null.
252
+ */
253
+ async beforeAlertPush(alert, result) {
254
+ const s = seriesOf(result)
255
+ if (!s || s.points.length < 2) return null
256
+ // Chat-created alerts run as their creator: attribute under the same row rules.
257
+ const actor = alert?.chatTarget && this.chatAlertActor ? await this.chatAlertActor(alert).catch(() => null) : undefined
258
+ if (actor === null) return null
259
+ const why = await withTimeout(this.explainChange({ resultId: result.resultId, actor }), this.config?.alertAttributionMs ?? 20000, 'attribution').catch(() => null)
260
+ const c = contributors(why, 3, 'zh')
261
+ return c ? `主要来自:${c.dimension} — ${c.text}` : null
262
+ },
263
+
264
+ // ------------------------------------------------------------ goals
265
+
266
+ listGoals() {
267
+ return (this.state.goals ?? []).map(g => ({ ...g, datasourceName: this.findDatasource(g.datasourceId)?.name ?? g.datasourceId }))
268
+ },
269
+
270
+ async saveGoal(input = {}) {
271
+ const existing = input.id ? (this.state.goals ?? []).find(g => g.id === input.id) : undefined
272
+ if (input.id && !existing) throw new UserError('nl2sql/invalid', '目标不存在')
273
+ const g = existing ? { ...existing } : { id: newId('gl'), createdAt: Date.now() }
274
+ g.name = requireText(input.name ?? g.name, 'name', 60)
275
+ const ds = this.requireDatasource(input.datasourceId ?? g.datasourceId)
276
+ g.datasourceId = ds.id
277
+ const m = this.findMetric(ds, input.metric ?? g.metric)
278
+ if (!m) throw new UserError('nl2sql/unknown-metric', '指标库中没有这个指标')
279
+ if (!m.timeColumn) throw new UserError('nl2sql/invalid', `指标「${m.name}」未配置时间列,无法按周期统计`)
280
+ g.metric = m.name
281
+ g.period = oneOf(input.period ?? g.period ?? 'month', ['month', 'quarter', 'year'], 'period')
282
+ const target = Number(input.target ?? g.target)
283
+ if (!Number.isFinite(target)) throw new UserError('nl2sql/invalid', '目标值必须是数字', 'target')
284
+ g.target = target
285
+ g.direction = oneOf(input.direction ?? g.direction ?? 'up', ['up', 'down'], 'direction')
286
+ const dash = input.dashboardId !== undefined ? input.dashboardId : g.dashboardId
287
+ if (dash) { this.requireDashboard(dash); g.dashboardId = dash } else delete g.dashboardId
288
+ g.updatedAt = Date.now()
289
+ this.state.goals = upsert(this.state.goals ?? [], g)
290
+ await this.save()
291
+ return g
292
+ },
293
+
294
+ async deleteGoal(id) {
295
+ this.state.goals = (this.state.goals ?? []).filter(g => g.id !== id)
296
+ await this.save()
297
+ return { deleted: id }
298
+ },
299
+
300
+ /** Goals relevant to a dashboard (tied to it, or untied goals on its data sources); all when no dashboard. */
301
+ goalsFor(dashboardId) {
302
+ const goals = this.state.goals ?? []
303
+ if (!dashboardId) return goals
304
+ const board = this.state.dashboards.find(d => d.id === dashboardId)
305
+ const dsIds = new Set((board?.widgets ?? []).map(w => w.datasourceId))
306
+ return goals.filter(g => g.dashboardId === dashboardId || (!g.dashboardId && dsIds.has(g.datasourceId)))
307
+ },
308
+
309
+ /** Progress of one goal at `now`. */
310
+ async goalProgressOne(g, now = new Date()) {
311
+ const { start, end } = periodBounds(g.period, now)
312
+ const today = midnight(now)
313
+ const tomorrow = addDays(today, 1)
314
+ const ds = this.requireDatasource(g.datasourceId)
315
+ const metric = this.findMetric(ds, g.metric)
316
+ if (!metric) throw new UserError('nl2sql/unknown-metric', `指标库中没有「${g.metric}」`)
317
+ const base = { datasourceId: ds.id, metric: g.metric, source: 'goal' }
318
+ const totalRes = await this.queryMetric({ ...base, from: ymd(start), to: ymd(tomorrow), title: `${g.name} · 累计` })
319
+ const actual = Number(totalRes.rows[0]?.[totalRes.rows[0].length - 1] ?? 0) || 0
320
+ let projected = actual
321
+ let weekdayAware = false
322
+ const additive = isAdditive(metric.expression)
323
+ if (additive) {
324
+ // Run rate from the trailing 28 complete days (independent of the period start).
325
+ const from = addDays(today, -28)
326
+ const daily = await this.queryMetric({ ...base, grain: 'day', from: ymd(from), to: ymd(tomorrow), title: `${g.name} · 日` })
327
+ const s = seriesOf(daily)
328
+ const byDay = new Map()
329
+ for (const p of s?.points ?? []) { const k = dayKey(p.x); if (k) byDay.set(k, (byDay.get(k) ?? 0) + (p.y ?? 0)) }
330
+ const todayValue = byDay.get(ymd(today)) ?? 0
331
+ const hist = []
332
+ for (let d = from; d < today; d = addDays(d, 1)) hist.push({ date: d, value: byDay.get(ymd(d)) ?? 0 })
333
+ const p = projectRunRate({ daily: hist, actualComplete: actual - todayValue, today, end })
334
+ projected = p.projected ?? actual
335
+ weekdayAware = p.weekdayAware
336
+ }
337
+ const ended = now >= end
338
+ const status = goalStatus({ direction: g.direction, target: g.target, actual, projected, ended })
339
+ const ratio = (v) => (g.target ? v / g.target : null)
340
+ const elapsed = (Math.min(now, end) - start) / (end - start)
341
+ return { ...g, actual, projected, pct: ratio(actual), projectedPct: ratio(projected), elapsedPct: elapsed, status, additive, weekdayAware, periodStart: ymd(start), periodEnd: ymd(addDays(end, -1)) }
342
+ },
343
+
344
+ async goalProgress({ dashboardId, now } = {}) {
345
+ const at = now ? new Date(now) : new Date()
346
+ const out = []
347
+ for (const g of this.goalsFor(dashboardId)) {
348
+ try { out.push(await this.goalProgressOne(g, at)) } catch (error) { out.push({ ...g, status: 'error', error: error.message }) }
349
+ }
350
+ return out
351
+ },
352
+
353
+ getGoalPush() {
354
+ return { enabled: false, channelId: null, weekday: 1, time: '09:00', ...(this.state.goalPush ?? {}) }
355
+ },
356
+
357
+ async saveGoalPush(input = {}) {
358
+ const cur = this.getGoalPush()
359
+ const next = {
360
+ ...cur,
361
+ enabled: input.enabled !== undefined ? Boolean(input.enabled) : cur.enabled,
362
+ channelId: input.channelId !== undefined ? (input.channelId && this.state.channels.some(c => c.id === input.channelId) ? input.channelId : null) : cur.channelId,
363
+ weekday: input.weekday !== undefined ? clampInt(input.weekday, 0, 6, 'weekday') : cur.weekday,
364
+ time: /^\d{2}:\d{2}$/.test(String(input.time ?? '')) ? input.time : cur.time,
365
+ }
366
+ if (!cur.updatedAt || input.enabled) next.since = next.since ?? Date.now()
367
+ next.updatedAt = Date.now()
368
+ this.state.goalPush = next
369
+ await this.save()
370
+ return next
371
+ },
372
+
373
+ /** Markdown goal status report (also used by the weekly push). */
374
+ async goalReport({ send = false, now } = {}) {
375
+ const list = await this.goalProgress({ now })
376
+ const icon = { 'on-track': '🟢', 'at-risk': '🟠', achieved: '✅', missed: '🔴', error: '⚠️' }
377
+ const label = { 'on-track': '进度正常', 'at-risk': '有风险', achieved: '已达成', missed: '未达成', error: '出错' }
378
+ const text = list.length
379
+ ? list.map(g => g.error ? `- ${icon.error} **${g.name}**:${g.error}` : `- ${icon[g.status]} **${g.name}**(${g.metric}):${fmtNum(g.actual)} / ${fmtNum(g.target)}(${(g.pct * 100).toFixed(0)}%),预计 ${fmtNum(g.projected)}(${(g.projectedPct * 100).toFixed(0)}%)· ${label[g.status]}`).join('\n')
380
+ : '尚未设置目标。'
381
+ if (send) {
382
+ const cfg = this.getGoalPush()
383
+ if (!cfg.channelId) throw new UserError('nl2sql/invalid', '目标周报未设置推送渠道')
384
+ await this.sendToChannel({ channelId: cfg.channelId, title: '🎯 目标进度', text, source: 'goal' })
385
+ }
386
+ return { text, goals: list }
387
+ },
388
+
389
+ /** Scheduler jobs: due digests and the weekly goal push. */
390
+ jobs_proactive(now) {
391
+ const jobs = []
392
+ for (const d of this.state.digests ?? []) {
393
+ if (!d.enabled || !d.channelId) continue
394
+ jobs.push({
395
+ id: `digest:${d.id}`,
396
+ due: (at) => (d.lastRunAt ?? d.createdAt ?? 0) < lastDailySlot(d.time, at),
397
+ run: () => this.runDigest({ id: d.id, send: true, now }),
398
+ })
399
+ }
400
+ const gp = this.state.goalPush
401
+ if (gp?.enabled && gp.channelId && (this.state.goals ?? []).length) {
402
+ jobs.push({
403
+ id: 'goal-push',
404
+ due: (at) => {
405
+ const slot = new Date(lastDailySlot(gp.time, at))
406
+ while (slot.getDay() !== gp.weekday) slot.setDate(slot.getDate() - 1)
407
+ return (gp.lastRunAt ?? gp.since ?? gp.updatedAt ?? 0) < slot.getTime()
408
+ },
409
+ run: async () => {
410
+ try { await this.goalReport({ send: true, now }) } finally { if (this.state.goalPush) this.state.goalPush.lastRunAt = Date.now(); await this.save() }
411
+ },
412
+ })
413
+ }
414
+ return jobs
415
+ },
416
+
417
+ // ------------------------------------------------------------ forecast
418
+
419
+ /**
420
+ * Forecast a single-measure time-series result. Returns the history, the forecast with a
421
+ * 95% band, and `resultId` of a derived result (history + forecast columns) that can be sent or charted.
422
+ */
423
+ async forecast({ resultId, horizon = 7, sessionId, actor } = {}) {
424
+ const r = this.lookupResult(resultId)
425
+ if (!r) throw new UserError('nl2sql/result-expired', '结果已过期,请重新查询')
426
+ this.assertResultAccess(r, actor ?? this.actorFor(sessionId))
427
+ const s = seriesOf(r)
428
+ if (!s) throw new UserError('nl2sql/invalid', '只能预测「时间 + 单个数值指标」的结果(不含分组系列)')
429
+ const H = clampInt(horizon ?? 7, 1, 90, 'horizon')
430
+ const points = s.points.filter(p => p.y !== null && Number.isFinite(p.y))
431
+ if (points.length < 3) throw new UserError('nl2sql/invalid', '至少需要 3 个时间点才能预测')
432
+ let fc
433
+ try { fc = forecastPoints(points, { horizon: H }) } catch (error) { throw new UserError('nl2sql/invalid', error.message) }
434
+ const round = (v) => (Number.isFinite(v) ? Math.round(v * 100) / 100 : null)
435
+ const cols = [{ name: s.x, kind: fc.kind === 'index' ? 'text' : 'time' }, { name: s.measure, kind: 'number' }, { name: '预测', kind: 'number' }, { name: '下限', kind: 'number' }, { name: '上限', kind: 'number' }]
436
+ const lastHist = fc.history.at(-1)
437
+ const rows = [
438
+ ...fc.history.map(p => [p.x, p.y, null, null, null]),
439
+ ...fc.forecast.map(f => [f.x, null, round(f.value), round(f.lower), round(f.upper)]),
440
+ ]
441
+ // Join the forecast line to the last observation.
442
+ if (lastHist) rows[fc.history.length - 1][2] = lastHist.y
443
+ const derived = {
444
+ ...r,
445
+ resultId: newId('r'),
446
+ title: `${r.title || s.measure} · 预测 ${H}${fc.kind === 'day' ? ' 天' : ' 期'}`,
447
+ columns: cols,
448
+ rows,
449
+ rowCount: rows.length,
450
+ truncated: false,
451
+ chart: { type: 'line', x: s.x, y: [s.measure, '预测'] },
452
+ suggestedChart: undefined,
453
+ forecastOf: r.resultId,
454
+ createdAt: Date.now(),
455
+ }
456
+ this.remember(derived)
457
+ const total = fc.forecast.reduce((a, f) => a + f.value, 0)
458
+ const methodName = fc.method === 'holt-winters' ? 'Holt-Winters(周季节性)' : 'Holt 线性趋势'
459
+ const summary = `基于 ${fc.history.length} 个历史点(${methodName}),未来 ${H} 期预测:${fc.forecast.slice(0, 3).map(f => `${f.x} ≈ ${fmtNum(f.value)}`).join(',')}${H > 3 ? ' …' : ''};${H} 期合计约 ${fmtNum(total)},95% 区间宽度 ±${fmtNum(1.96 * fc.sigma)}(第 1 期)。${fc.filled ? `缺失的 ${fc.filled} 天按 0 补齐。` : ''}`
460
+ return {
461
+ resultId: r.resultId,
462
+ forecastResultId: derived.resultId,
463
+ datasourceId: r.datasourceId,
464
+ title: r.title,
465
+ x: s.x,
466
+ measure: s.measure,
467
+ horizon: H,
468
+ method: fc.method,
469
+ params: fc.params,
470
+ sigma: fc.sigma,
471
+ kind: fc.kind,
472
+ filled: fc.filled,
473
+ history: fc.history,
474
+ forecast: fc.forecast.map(f => ({ x: f.x, h: f.h, value: round(f.value), lower: round(f.lower), upper: round(f.upper) })),
475
+ summary,
476
+ }
477
+ },
478
+ }
479
+
480
+ export const FEATURE = {
481
+ name: 'proactive',
482
+ methods,
483
+ rpc: (service) => ({
484
+ listDigests: () => service.listDigests(),
485
+ saveDigest: (p) => service.saveDigest(p ?? {}),
486
+ deleteDigest: (p) => service.deleteDigest(p?.id),
487
+ runDigest: (p) => service.runDigest({ id: p?.id, send: Boolean(p?.send) }),
488
+ listDigestRuns: (p) => service.listDigestRuns(p?.digestId),
489
+ listGoals: () => service.listGoals(),
490
+ saveGoal: (p) => service.saveGoal(p ?? {}),
491
+ deleteGoal: (p) => service.deleteGoal(p?.id),
492
+ goalProgress: (p) => service.goalProgress({ dashboardId: p?.dashboardId }),
493
+ getGoalPush: () => service.getGoalPush(),
494
+ saveGoalPush: (p) => service.saveGoalPush(p ?? {}),
495
+ sendGoalReport: () => service.goalReport({ send: true }),
496
+ forecast: (p) => service.forecast({ resultId: p?.resultId, horizon: p?.horizon ?? 7 }),
497
+ }),
498
+ tools(ctx, service, { textBlock }) {
499
+ ctx.tools.register({
500
+ name: 'nl2sql_forecast',
501
+ description: 'Forecast the next N periods of an existing time-series result (resultId from nl2sql_query / nl2sql_metric with a time column and ONE numeric measure, no series split). Uses Holt-Winters with weekly seasonality for daily data with ≥ 21 points, else Holt linear trend; returns point forecasts with a 95% interval and shows a chart (history + dashed forecast + band) to the user. Use for "预测/forecast/下周会是多少" questions; state that it is a statistical extrapolation.',
502
+ parameters: {
503
+ type: 'object',
504
+ properties: {
505
+ resultId: { type: 'string', description: 'resultId of a time-series result.' },
506
+ horizon: { type: 'integer', minimum: 1, maximum: 90, description: 'Periods to forecast (default 7).' },
507
+ },
508
+ required: ['resultId'],
509
+ additionalProperties: false,
510
+ },
511
+ output: {
512
+ schema: { type: 'object' },
513
+ render: (_args, value) => textBlock(JSON.stringify(value.model, null, 1)),
514
+ presentationMeta: (_args, value) => value.display,
515
+ },
516
+ isConcurrencySafe: () => true,
517
+ presentCall: (args) => ({ card: 'generic', title: `预测未来 ${args?.horizon ?? 7} 期`, kind: 'read' }),
518
+ execute: async (args, exec) => {
519
+ try {
520
+ const f = await service.forecast({ resultId: args.resultId, horizon: args.horizon ?? 7, sessionId: exec.agent?.id })
521
+ return {
522
+ model: { summary: f.summary, method: f.method, forecastResultId: f.forecastResultId, measure: f.measure, forecast: f.forecast },
523
+ display: JSON.parse(JSON.stringify({ forecast: { ...f, history: f.history.slice(-120) } }, (_k, v) => (typeof v === 'number' && !Number.isFinite(v) ? null : v))),
524
+ }
525
+ } catch (error) {
526
+ throw error instanceof UserError ? new Error(error.message) : error
527
+ }
528
+ },
529
+ })
530
+ },
531
+ }
package/service/query.js CHANGED
@@ -14,7 +14,7 @@ import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, columnKind, fillChartHin
14
14
  export const methods = {
15
15
  // ---------------------------------------------------------------- query
16
16
 
17
- async runQuery({ sql, datasourceId, sessionId, title, chart, source, confirmScan, filters, useCache, actor, governed }) {
17
+ async runQuery({ sql, datasourceId, sessionId, title, chart, source, confirmScan, filters, useCache, actor, governed, maxRows }) {
18
18
  actor ??= this.actorFor(sessionId)
19
19
  const ds = this.datasourceForSession(sessionId, datasourceId)
20
20
  this.assertAccess(actor, ds)
@@ -24,7 +24,8 @@ export const methods = {
24
24
  this.state.sessions[sessionId] = ds.id
25
25
  await this.save()
26
26
  }
27
- const settings = this.settingsFor(ds)
27
+ // Optional per-call row cap (reconciliation loads up to 200k rows per side); invalid values are ignored.
28
+ const settings = { ...this.settingsFor(ds), ...(Number.isInteger(maxRows) && maxRows > 0 ? { maxRows: Math.min(maxRows, 200000) } : {}) }
28
29
  const dialect = ds.type ?? 'mysql'
29
30
  let admitted = admitSql(sql, { dialect })
30
31
  const policy = this.policyFor(ds)