@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.
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Feature: data freshness + data quality patrol ("health").
3
+ *
4
+ * Freshness: for every analyzed table with a time column, MAX(time column) via runQuery
5
+ * (source 'freshness'), stored per data source in state.freshness[dsId][table]; a scheduler
6
+ * job refreshes it (default every 30 min) and pushes once per stale episode (and once on
7
+ * recovery) to the configured channel (record kind 'freshnessEvent').
8
+ *
9
+ * Quality: bounded aggregate queries per table (row count, null rates, duplicate keys on
10
+ * unique indexes, mean ± 4σ outliers, future timestamps, negative amounts) → health score
11
+ * 0–100 per table and per data source with zh/en explanations (record kind 'qualityRun');
12
+ * optional daily job and a push when the data source score drops by more than N points.
13
+ *
14
+ * Every statement goes through runQuery (admission, access policy, row rules, masking, audit).
15
+ */
16
+ import { quoteIdent, quoteName } from '../analysis.js'
17
+ import { referencedTables } from '../policy.js'
18
+ import { UserError, lastDailySlot, newId } from './shared.js'
19
+
20
+ // ------------------------------------------------------------------ constants
21
+
22
+ export const FRESHNESS_DEFAULTS = { enabled: true, intervalMin: 30, staleHours: 24, overrides: {}, channelId: null }
23
+ export const QUALITY_DEFAULTS = { enabled: false, time: '06:30', channelId: null, dropPoints: 10, maxTableRows: 5000000, maxTables: 60 }
24
+ const DATE_TYPES = new Set(['date', 'datetime', 'timestamp', 'timestamptz'])
25
+ const NUM_TYPES = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'])
26
+ /** Time columns that legitimately point at the future or are not "data arrival" times. */
27
+ const NOT_ARRIVAL = /(deleted|next_|expire|expir|due|deadline|schedul|plan|valid|until|_end|end_|retry|birth|start_date|effective)/i
28
+ const FUTURE_OK = /(next_|expire|expir|due|deadline|schedul|plan|valid|until|_end|end_|retry|effective)/i
29
+ const AMOUNT_LIKE = /(amount|amt|price|fee|cost|balance|total|qty|quantity|money|revenue|gmv|pay|paid|salary|income|金额|价格|余额|费用|数量)/i
30
+ /** Columns that are legitimately empty most of the time: null rates are reported but not scored. */
31
+ const OPTIONAL_NAME = /(deleted|remark|note|comment|memo|desc|extra|ext_|_ext|reason|error|err_|msg|message|parent|avatar|url|tag|attach|image|img|icon|_at$|_time$|_date$)/i
32
+
33
+ // ------------------------------------------------------------------ pure helpers
34
+
35
+ /** Best "data arrival" time column of a table: created_at > updated_at > timestamp-like > other *_at. */
36
+ export function pickTimeColumn(table) {
37
+ const cands = (table?.columns ?? []).filter(c => c.roles?.includes('time') && !c.roles.includes('sensitive') && DATE_TYPES.has(String(c.dataType).toLowerCase()) && !NOT_ARRIVAL.test(c.name))
38
+ if (!cands.length) return undefined
39
+ const rank = (c) => {
40
+ const n = c.name.toLowerCase()
41
+ if (/^(created_at|create_time|created_time|gmt_create|created|creation_time|insert_time|inserted_at)$/.test(n)) return 0
42
+ if (/^(updated_at|update_time|updated_time|gmt_modified|modified_at|modify_time|last_modified)$/.test(n)) return 1
43
+ if (/^(@?timestamp|ts|event_time|event_at|log_time|occurred_at|time|datetime|dt)$/.test(n)) return 2
44
+ if (/(creat|insert)/.test(n)) return 3
45
+ if (/(updat|modif)/.test(n)) return 4
46
+ if (/(timestamp|time|date|_at$)/.test(n)) return 5
47
+ return 6
48
+ }
49
+ return [...cands].sort((a, b) => rank(a) - rank(b))[0]
50
+ }
51
+
52
+ /** Whether the column is the first column of some index (MAX can use it). */
53
+ export function isIndexed(table, column) {
54
+ return (table?.indexes ?? []).some(i => i.columns?.[0] === column)
55
+ }
56
+
57
+ const pad = (n) => String(n).padStart(2, '0')
58
+
59
+ /**
60
+ * Parse a database time value into epoch ms. Strings without an offset are local wall time
61
+ * (MySQL dateStrings, SQLite); a date-only value means "data through that day" (end of day).
62
+ */
63
+ export function parseTime(v) {
64
+ if (v === null || v === undefined || v === '') return null
65
+ if (typeof v === 'number') return v > 1e12 ? v : v * 1000
66
+ const s = String(v).trim()
67
+ if (/^\d{10}(\d{3})?$/.test(s)) return Number(s) * (s.length === 10 ? 1000 : 1)
68
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$/i)
69
+ if (!m) { const t = Date.parse(s); return Number.isFinite(t) ? t : null }
70
+ const [, y, mo, d, hh, mi, ss, frac, tz] = m
71
+ if (hh === undefined) return new Date(Number(y), Number(mo) - 1, Number(d) + 1, 0, 0, 0, 0).getTime() - 1
72
+ const ms = frac ? Math.round(Number(frac) * 1000) : 0
73
+ if (tz) {
74
+ const off = tz.toUpperCase() === 'Z' ? 'Z' : `${tz.slice(0, 3)}:${tz.slice(3).replace(':', '') || '00'}`
75
+ return Date.parse(`${y}-${mo}-${d}T${hh}:${mi}:${ss ?? '00'}.${String(ms).padStart(3, '0')}${off}`)
76
+ }
77
+ return new Date(Number(y), Number(mo) - 1, Number(d), Number(hh), Number(mi), Number(ss ?? 0), ms).getTime()
78
+ }
79
+
80
+ /** Local wall time "YYYY-MM-DD HH:mm". */
81
+ export function localStamp(ms) {
82
+ if (!Number.isFinite(ms)) return null
83
+ const d = new Date(ms)
84
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
85
+ }
86
+
87
+ /** "23 小时" / "23 hours". */
88
+ export function lagText(min, lang = 'zh') {
89
+ if (!Number.isFinite(min)) return lang === 'zh' ? '未知' : 'unknown'
90
+ const m = Math.max(0, Math.round(min))
91
+ if (m < 60) return lang === 'zh' ? `${m} 分钟` : `${m} min`
92
+ if (m < 48 * 60) { const h = Math.round(m / 60); return lang === 'zh' ? `${h} 小时` : `${h} hour${h === 1 ? '' : 's'}` }
93
+ const d = Math.round(m / 1440)
94
+ return lang === 'zh' ? `${d} 天` : `${d} days`
95
+ }
96
+
97
+ /** Human "停在昨天 17:55" style location of a timestamp relative to now. */
98
+ export function whenText(ms, now = Date.now(), lang = 'zh') {
99
+ if (!Number.isFinite(ms)) return '—'
100
+ const d = new Date(ms)
101
+ const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
102
+ const diff = Math.round((day(new Date(now)) - day(d)) / 86400000)
103
+ const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
104
+ if (diff === 0) return lang === 'zh' ? `今天 ${hm}` : `today ${hm}`
105
+ if (diff === 1) return lang === 'zh' ? `昨天 ${hm}` : `yesterday ${hm}`
106
+ if (diff === 2 && lang === 'zh') return `前天 ${hm}`
107
+ return `${d.getFullYear() === new Date(now).getFullYear() ? '' : `${d.getFullYear()}-`}${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`
108
+ }
109
+
110
+ /** Threshold (hours) for one table; 0 means "not monitored". */
111
+ export function thresholdFor(settings, dsId, table) {
112
+ const o = settings?.overrides?.[dsId]?.[table]
113
+ if (o !== undefined && o !== null && o !== '' && Number.isFinite(Number(o))) return Math.max(0, Number(o))
114
+ return Number(settings?.staleHours ?? FRESHNESS_DEFAULTS.staleHours)
115
+ }
116
+
117
+ /** Status of one freshness entry: ok / warn (> 50 % of threshold) / stale / empty / error / off. */
118
+ export function freshnessStatus(entry, thresholdHours) {
119
+ if (!entry) return 'unknown'
120
+ if (entry.error) return 'error'
121
+ if (entry.latestAt === null || entry.latestAt === undefined) return 'empty'
122
+ if (!thresholdHours) return 'off'
123
+ const lag = entry.lagMinutes ?? 0
124
+ if (lag > thresholdHours * 60) return 'stale'
125
+ if (lag > thresholdHours * 30) return 'warn'
126
+ return 'ok'
127
+ }
128
+
129
+ /** SQL fragment producing MAX(col) as a string-ish value, per dialect. */
130
+ function maxExpr(dialect, C, forUnion) {
131
+ if (!forUnion) return `MAX(${C})`
132
+ if (dialect === 'mysql') return `CAST(MAX(${C}) AS CHAR)`
133
+ if (dialect === 'postgres') return `MAX(${C})::text`
134
+ return `MAX(${C})`
135
+ }
136
+
137
+ /** "Now + 1 hour" in the database's own clock: rows dated further ahead are typos, not freshness. */
138
+ export function nowPlusHour(dialect) {
139
+ if (dialect === 'mysql') return 'NOW() + INTERVAL 1 HOUR'
140
+ if (dialect === 'postgres') return "now() + interval '1 hour'"
141
+ if (dialect === 'sqlite') return "datetime('now', 'localtime', '+1 hour')"
142
+ return 'NOW() + INTERVAL 1 HOUR'
143
+ }
144
+
145
+ /** One statement computing MAX(time) of a single table (ignoring future-dated rows). */
146
+ export function freshnessSql(dialect, table, column) {
147
+ const C = quoteIdent(column, dialect)
148
+ return `SELECT ${maxExpr(dialect, C, false)} AS latest_at FROM ${quoteName(table, dialect)} WHERE ${C} <= ${nowPlusHour(dialect)}`
149
+ }
150
+
151
+ /** One UNION ALL statement for several tables (mysql / postgres / sqlite). */
152
+ export function freshnessUnionSql(dialect, items) {
153
+ return items.map(({ table, column }, i) => { const C = quoteIdent(column, dialect); return `SELECT ${i} AS k, ${maxExpr(dialect, C, true)} AS latest_at FROM ${quoteName(table, dialect)} WHERE ${C} <= ${nowPlusHour(dialect)}` }).join(' UNION ALL ')
154
+ }
155
+
156
+ /** Plain decimal literal (no exponent) for a finite number. */
157
+ export function numLiteral(n) {
158
+ if (!Number.isFinite(n)) return '0'
159
+ if (Math.abs(n) < 1e15 && Math.abs(n) > 1e-6) return String(Number(n.toPrecision(15)))
160
+ return n.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 6 })
161
+ }
162
+
163
+ const num = (v) => (v === null || v === undefined || v === '' ? null : Number(v))
164
+
165
+ /**
166
+ * Health score of one table from its check results. Returns { score, issues[] } where each
167
+ * issue has { kind, column?, deduct, zh, en }.
168
+ */
169
+ export function scoreTable(r) {
170
+ const issues = []
171
+ const add = (kind, deduct, zh, en, extra = {}) => issues.push({ kind, deduct, zh, en, ...extra })
172
+ const pct = (x) => `${(x * 100).toFixed(x < 0.01 ? 2 : 1)}%`
173
+ if (r.rowCount === 0) return { score: null, issues: [{ kind: 'empty', deduct: 0, zh: '表为空,未评分', en: 'Empty table, not scored' }] }
174
+ // Null rates (core columns weigh more; optional-looking columns are not scored).
175
+ let nullDeduct = 0
176
+ for (const c of r.nulls ?? []) {
177
+ if (c.optional || c.rate <= 0.2) continue
178
+ const w = c.core ? 2 : 1
179
+ const d = (c.rate > 0.9 ? 4 : c.rate > 0.5 ? 2 : 1) * w
180
+ const take = Math.min(d, 20 - nullDeduct)
181
+ if (take <= 0) break
182
+ nullDeduct += take
183
+ add('nulls', take, `字段 ${c.column} 空值率 ${pct(c.rate)}`, `Column ${c.column} is ${pct(c.rate)} NULL`, { column: c.column })
184
+ }
185
+ let dupDeduct = 0
186
+ for (const k of r.duplicates ?? []) {
187
+ if (!(k.duplicates > 0)) continue
188
+ const take = Math.min(20, 30 - dupDeduct)
189
+ if (take <= 0) break
190
+ dupDeduct += take
191
+ add('duplicates', take, `唯一键 ${k.columns.join(', ')} 有 ${k.duplicates} 行重复${k.approximate ? '(近似)' : ''}`, `Unique key ${k.columns.join(', ')} has ${k.duplicates} duplicate row(s)${k.approximate ? ' (approximate)' : ''}`, { column: k.columns.join(',') })
192
+ }
193
+ let outDeduct = 0
194
+ for (const o of r.outliers ?? []) {
195
+ if (!(o.count > 0)) continue
196
+ const rate = o.count / Math.max(1, r.rowCount)
197
+ const take = Math.min(rate > 0.01 ? 8 : rate > 0.001 ? 4 : 2, 15 - outDeduct)
198
+ if (take <= 0) break
199
+ outDeduct += take
200
+ add('outliers', take, `字段 ${o.column} 有 ${o.count} 个异常值(超出均值 ±4σ:${numLiteral(o.low)} ~ ${numLiteral(o.high)})`, `Column ${o.column} has ${o.count} outlier(s) (beyond mean ± 4σ: ${numLiteral(o.low)} – ${numLiteral(o.high)})`, { column: o.column })
201
+ }
202
+ let futDeduct = 0
203
+ for (const f of r.future ?? []) {
204
+ if (!(f.count > 0)) continue
205
+ const rate = f.count / Math.max(1, r.rowCount)
206
+ const take = Math.min(rate > 0.001 ? 10 : 5, 20 - futDeduct)
207
+ if (take <= 0) break
208
+ futDeduct += take
209
+ add('future', take, `时间字段 ${f.column} 有 ${f.count} 行晚于当前时间`, `Time column ${f.column} has ${f.count} row(s) in the future`, { column: f.column })
210
+ }
211
+ let negDeduct = 0
212
+ for (const n of r.negatives ?? []) {
213
+ if (!(n.count > 0)) continue
214
+ const rate = n.count / Math.max(1, r.rowCount)
215
+ const take = Math.min(rate > 0.01 ? 10 : 5, 20 - negDeduct)
216
+ if (take <= 0) break
217
+ negDeduct += take
218
+ add('negative', take, `金额类字段 ${n.column} 有 ${n.count} 个负值`, `Amount-like column ${n.column} has ${n.count} negative value(s)`, { column: n.column })
219
+ }
220
+ if (r.freshness?.stale) add('stale', 15, `数据已 ${lagText(r.freshness.lagMinutes, 'zh')} 未更新(阈值 ${r.freshness.thresholdHours} 小时)`, `No new data for ${lagText(r.freshness.lagMinutes, 'en')} (threshold ${r.freshness.thresholdHours} h)`)
221
+ for (const e of r.errors ?? []) add('error', 0, `检查未完成:${e}`, `Check failed: ${e}`)
222
+ const score = Math.max(0, 100 - issues.reduce((s, i) => s + i.deduct, 0))
223
+ return { score, issues }
224
+ }
225
+
226
+ /** Data source score: average of table scores weighted by log10(rows + 10). */
227
+ export function scoreDatasource(tables) {
228
+ let w = 0
229
+ let s = 0
230
+ for (const t of tables) {
231
+ if (t.score === null || t.score === undefined) continue
232
+ const wt = Math.log10((t.rowCount ?? 0) + 10)
233
+ w += wt
234
+ s += wt * t.score
235
+ }
236
+ return w ? Math.round(s / w) : null
237
+ }
238
+
239
+ // ------------------------------------------------------------------ service methods
240
+
241
+ export const methods = {
242
+ // ---------------------------------------------------------------- settings
243
+
244
+ freshnessState() {
245
+ this.state.freshness ??= {}
246
+ const f = this.state.freshness
247
+ f.settings = { ...FRESHNESS_DEFAULTS, ...(f.settings ?? {}) }
248
+ f.settings.overrides ??= {}
249
+ return f
250
+ },
251
+
252
+ qualityState() {
253
+ this.state.quality ??= {}
254
+ const q = this.state.quality
255
+ q.settings = { ...QUALITY_DEFAULTS, ...(q.settings ?? {}) }
256
+ q.latest ??= {}
257
+ return q
258
+ },
259
+
260
+ healthSettings() {
261
+ return { freshness: structuredClone(this.freshnessState().settings), quality: structuredClone(this.qualityState().settings) }
262
+ },
263
+
264
+ async saveHealthSettings({ freshness, quality } = {}) {
265
+ const intIn = (v, min, max, field) => {
266
+ const n = Number(v)
267
+ if (!Number.isFinite(n) || n < min || n > max) throw new UserError('nl2sql/invalid', `${field} 必须在 ${min}–${max} 之间`)
268
+ return n
269
+ }
270
+ const channel = (v) => (v === undefined ? undefined : v && this.state.channels.some(c => c.id === v) ? v : null)
271
+ if (freshness) {
272
+ const s = this.freshnessState().settings
273
+ if (freshness.enabled !== undefined) s.enabled = Boolean(freshness.enabled)
274
+ if (freshness.intervalMin !== undefined) s.intervalMin = Math.round(intIn(freshness.intervalMin, 5, 1440, 'intervalMin'))
275
+ if (freshness.staleHours !== undefined) s.staleHours = intIn(freshness.staleHours, 0.25, 24 * 90, 'staleHours')
276
+ if (freshness.channelId !== undefined) s.channelId = channel(freshness.channelId)
277
+ if (freshness.overrides !== undefined) {
278
+ if (!freshness.overrides || typeof freshness.overrides !== 'object') throw new UserError('nl2sql/invalid', 'overrides 必须是对象')
279
+ const out = {}
280
+ for (const [dsId, tables] of Object.entries(freshness.overrides)) {
281
+ if (!this.findDatasource(dsId) || !tables || typeof tables !== 'object') continue
282
+ for (const [table, hours] of Object.entries(tables)) {
283
+ if (hours === null || hours === '' || hours === undefined) continue
284
+ ;(out[dsId] ??= {})[String(table).slice(0, 200)] = intIn(hours, 0, 24 * 90, `overrides.${table}`)
285
+ }
286
+ }
287
+ s.overrides = out
288
+ }
289
+ }
290
+ if (quality) {
291
+ const s = this.qualityState().settings
292
+ if (quality.enabled !== undefined) s.enabled = Boolean(quality.enabled)
293
+ if (quality.time !== undefined) {
294
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(quality.time))) throw new UserError('nl2sql/invalid', 'time 必须是 HH:MM')
295
+ s.time = String(quality.time)
296
+ }
297
+ if (quality.channelId !== undefined) s.channelId = channel(quality.channelId)
298
+ if (quality.dropPoints !== undefined) s.dropPoints = Math.round(intIn(quality.dropPoints, 1, 100, 'dropPoints'))
299
+ if (quality.maxTableRows !== undefined) s.maxTableRows = Math.round(intIn(quality.maxTableRows, 1000, 1e10, 'maxTableRows'))
300
+ if (quality.maxTables !== undefined) s.maxTables = Math.round(intIn(quality.maxTables, 1, 500, 'maxTables'))
301
+ }
302
+ await this.save()
303
+ return this.healthSettings()
304
+ },
305
+
306
+ // ---------------------------------------------------------------- freshness
307
+
308
+ /**
309
+ * Run MAX(time) for the given tables of `ds` (visible schema). Returns { [table]: entry }.
310
+ * Unrestricted callers batch tables into UNION ALL statements (one audit line per batch,
311
+ * per-table fallback on failure); restricted actors run one statement per table.
312
+ */
313
+ async measureFreshness(ds, tables, { actor, sessionId } = {}) {
314
+ const dialect = ds.type ?? 'mysql'
315
+ const out = {}
316
+ const items = tables.map(t => ({ table: t.name, column: pickTimeColumn(t)?.name, indexed: false, t })).filter(x => x.column)
317
+ for (const x of items) x.indexed = isIndexed(x.t, x.column)
318
+ const restricted = Boolean(actor && actor.kind !== 'owner' && (this.rowRulesFor(actor, ds).length || actor.role === 'viewer'))
319
+ const run = (sql, title, confirmScan) => this.runQuery({ sql, datasourceId: ds.id, title, source: 'freshness', actor, sessionId, confirmScan, governed: true })
320
+ const record = (x, value, error) => {
321
+ const checkedAt = Date.now()
322
+ const latestAt = error ? null : parseTime(value)
323
+ out[x.table] = { column: x.column, latestAt, latestRaw: value === undefined || value === null ? null : String(value), checkedAt, lagMinutes: latestAt === null ? null : Math.round((checkedAt - latestAt) / 60000), ...(latestAt !== null && latestAt > checkedAt + 60000 ? { future: true } : {}), ...(error ? { error: String(error).slice(0, 300) } : {}) }
324
+ }
325
+ const single = async (x) => {
326
+ try {
327
+ const r = await run(freshnessSql(dialect, x.table, x.column), `数据新鲜度 · ${x.table}`, x.indexed)
328
+ record(x, r.rows[0]?.[0])
329
+ } catch (error) { record(x, null, error.message) }
330
+ }
331
+ if (!restricted && dialect !== 'elasticsearch' && items.length > 1) {
332
+ for (let i = 0; i < items.length; i += 50) {
333
+ const batch = items.slice(i, i + 50)
334
+ try {
335
+ const r = await run(freshnessUnionSql(dialect, batch), `数据新鲜度 · ${batch.length} 张表`, batch.every(x => x.indexed))
336
+ const seen = new Set()
337
+ for (const row of r.rows) { const k = Number(row[0]); if (batch[k]) { record(batch[k], row[1]); seen.add(k) } }
338
+ for (let k = 0; k < batch.length; k++) if (!seen.has(k)) await single(batch[k])
339
+ } catch {
340
+ for (const x of batch) await single(x)
341
+ }
342
+ }
343
+ } else {
344
+ for (const x of items) await single(x)
345
+ }
346
+ return out
347
+ },
348
+
349
+ /** Refresh stored freshness of one data source (all or some tables) and notify episodes. */
350
+ async refreshFreshness(ds, { tables: only, notify = true } = {}) {
351
+ const schema = await this.cachedVisibleSchema(ds)
352
+ if (!schema) return {}
353
+ const f = this.freshnessState()
354
+ const wanted = only?.length ? schema.tables.filter(t => only.includes(t.name)) : schema.tables
355
+ const measured = await this.measureFreshness(ds, wanted.slice(0, 300))
356
+ const store = (f[ds.id] ??= {})
357
+ // Tables gone from the schema (or without a time column now) drop out on a full refresh.
358
+ if (!only?.length) for (const k of Object.keys(store)) if (!measured[k]) delete store[k]
359
+ const changes = []
360
+ for (const [table, m] of Object.entries(measured)) {
361
+ const prev = store[table]
362
+ const thresholdHours = thresholdFor(f.settings, ds.id, table)
363
+ const stale = freshnessStatus(m, thresholdHours) === 'stale'
364
+ const entry = { ...m, thresholdHours, stale }
365
+ if (m.error && prev?.stale) {
366
+ // A failed check does not end a stale episode.
367
+ Object.assign(entry, { stale: true, staleSince: prev.staleSince, notified: prev.notified, latestAt: prev.latestAt, lagMinutes: prev.latestAt === null ? null : Math.round((m.checkedAt - prev.latestAt) / 60000) })
368
+ } else if (stale) {
369
+ entry.staleSince = prev?.stale ? (prev.staleSince ?? m.checkedAt) : m.checkedAt
370
+ entry.notified = Boolean(prev?.stale && prev.notified)
371
+ if (!entry.notified) changes.push({ kind: 'stale', table, entry })
372
+ } else if (prev?.stale && prev.notified && !m.error) {
373
+ changes.push({ kind: 'recovered', table, entry })
374
+ }
375
+ store[table] = entry
376
+ }
377
+ if (notify && changes.length) await this.notifyFreshness(ds, changes)
378
+ await this.save()
379
+ return store
380
+ },
381
+
382
+ /** Record + push stale / recovered events (one message per kind per data source). */
383
+ async notifyFreshness(ds, changes) {
384
+ const f = this.freshnessState()
385
+ const channelId = f.settings.channelId && this.state.channels.some(c => c.id === f.settings.channelId) ? f.settings.channelId : null
386
+ for (const kind of ['stale', 'recovered']) {
387
+ const list = changes.filter(c => c.kind === kind)
388
+ if (!list.length) continue
389
+ const events = list.map(c => ({ id: newId('fe'), at: Date.now(), datasourceId: ds.id, datasourceName: ds.name, table: c.table, column: c.entry.column, kind, latestAt: c.entry.latestAt, lagMinutes: c.entry.lagMinutes, thresholdHours: c.entry.thresholdHours }))
390
+ let pushed = false
391
+ let pushError
392
+ if (channelId) {
393
+ const lines = list.map(c => kind === 'stale'
394
+ ? `- **${c.table}** 最新数据停在 ${whenText(c.entry.latestAt)}(滞后 ${lagText(c.entry.lagMinutes)},阈值 ${c.entry.thresholdHours} 小时)`
395
+ : `- **${c.table}** 已恢复,最新数据 ${whenText(c.entry.latestAt)}`)
396
+ try {
397
+ await this.sendToChannel({
398
+ channelId,
399
+ title: kind === 'stale' ? `⚠️ 数据延迟:${ds.name}` : `✅ 数据已恢复更新:${ds.name}`,
400
+ text: `${lines.join('\n')}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`,
401
+ source: 'freshness',
402
+ template: kind === 'stale' ? 'orange' : 'green',
403
+ })
404
+ pushed = true
405
+ } catch (error) { pushError = error.message }
406
+ }
407
+ for (const e of events) this.records.put('freshnessEvent', { ...e, pushed, ...(pushError ? { pushError } : {}) })
408
+ // Mark the episode notified (even without a channel: the event is recorded once per episode).
409
+ for (const c of list) if (kind === 'stale') c.entry.notified = true
410
+ }
411
+ },
412
+
413
+ /** Public view of the stored freshness of one data source. */
414
+ freshnessView(ds, store) {
415
+ const f = this.freshnessState()
416
+ const tables = Object.entries(store ?? {}).map(([table, e]) => {
417
+ const thresholdHours = thresholdFor(f.settings, ds.id, table)
418
+ return { table, column: e.column, latestAt: e.latestAt, latestLocal: localStamp(e.latestAt), checkedAt: e.checkedAt, lagMinutes: e.lagMinutes, thresholdHours, status: freshnessStatus(e, thresholdHours), stale: freshnessStatus(e, thresholdHours) === 'stale', ...(e.future ? { future: true } : {}), ...(e.error ? { error: e.error } : {}), ...(e.staleSince ? { staleSince: e.staleSince } : {}) }
419
+ }).sort((a, b) => (b.lagMinutes ?? -1) - (a.lagMinutes ?? -1))
420
+ const checkedAt = tables.reduce((m, t) => Math.max(m, t.checkedAt ?? 0), 0) || null
421
+ return { datasourceId: ds.id, datasourceName: ds.name, analyzed: Boolean(ds.schemaAnalyzedAt), checkedAt, staleCount: tables.filter(t => t.stale).length, tables }
422
+ },
423
+
424
+ /** RPC: stored freshness (computed on first use or when refresh); `sql` narrows to referenced tables. */
425
+ async freshnessFor({ datasourceId, refresh = false, sql } = {}) {
426
+ const ds = this.requireDatasource(datasourceId ?? this.state.current)
427
+ const f = this.freshnessState()
428
+ let store = f[ds.id]
429
+ if (refresh || !store) {
430
+ if (refresh) store = await this.withJobLock(`freshness:${ds.id}`, () => this.refreshFreshness(ds))
431
+ else store = await this.refreshFreshness(ds)
432
+ }
433
+ const view = this.freshnessView(ds, store)
434
+ if (typeof sql === 'string' && sql.trim()) {
435
+ const schema = await this.cachedVisibleSchema(ds)
436
+ let refs = []
437
+ try { refs = schema ? referencedTables(sql, schema.tables, { dialect: ds.type ?? 'mysql' }) : [] } catch {}
438
+ view.referenced = refs
439
+ view.tables = view.tables.filter(t => refs.includes(t.table))
440
+ view.staleCount = view.tables.filter(t => t.stale).length
441
+ }
442
+ return view
443
+ },
444
+
445
+ freshnessEvents({ datasourceId, limit = 50 } = {}) {
446
+ return this.records.list('freshnessEvent', { ref: datasourceId || undefined, limit: Math.min(500, Number(limit) || 50) })
447
+ },
448
+
449
+ /** Scheduler body: refresh every analyzed data source. */
450
+ async runFreshnessAll() {
451
+ const f = this.freshnessState()
452
+ f.lastRunAt = Date.now()
453
+ const out = []
454
+ for (const ds of this.state.datasources) {
455
+ if (!ds.schemaAnalyzedAt) continue
456
+ try { await this.withJobLock(`freshness:${ds.id}`, () => this.refreshFreshness(ds)); out.push({ id: ds.id, ok: true }) } catch (error) { if (error.code !== 'nl2sql/busy') out.push({ id: ds.id, ok: false, error: error.message }) }
457
+ }
458
+ f.lastRun = { at: f.lastRunAt, ms: Date.now() - f.lastRunAt, results: out }
459
+ await this.save()
460
+ return out
461
+ },
462
+
463
+ /**
464
+ * Agent tool body: freshness of some tables for a session actor. Owner sessions reuse
465
+ * stored values younger than 10 minutes; restricted actors (row rules, viewers) always
466
+ * measure live under their own rules and nothing is stored.
467
+ */
468
+ async freshnessForSession({ sessionId, datasource, tables }) {
469
+ const ds = this.datasourceForSession(sessionId, datasource)
470
+ const actor = this.actorFor(sessionId)
471
+ this.assertAccess(actor, ds)
472
+ const schema = await this.visibleSchema(ds)
473
+ const names = Array.isArray(tables) && tables.length ? tables.map(String) : null
474
+ const pickTables = names ? schema.tables.filter(t => names.some(n => n.toLowerCase() === t.name.toLowerCase())) : schema.tables
475
+ const unknown = names ? names.filter(n => !schema.tables.some(t => t.name.toLowerCase() === n.toLowerCase())) : []
476
+ const f = this.freshnessState()
477
+ const restricted = Boolean(actor && actor.kind !== 'owner' && (this.rowRulesFor(actor, ds).length || actor.role === 'viewer'))
478
+ let entries
479
+ if (restricted) {
480
+ entries = await this.measureFreshness(ds, pickTables, { actor, sessionId })
481
+ } else {
482
+ const store = f[ds.id] ?? {}
483
+ const old = pickTables.filter(t => pickTimeColumn(t) && !(store[t.name]?.checkedAt > Date.now() - 10 * 60000))
484
+ if (old.length) await this.refreshFreshness(ds, { tables: old.map(t => t.name) })
485
+ entries = f[ds.id] ?? {}
486
+ }
487
+ const now = Date.now()
488
+ const rows = []
489
+ for (const t of pickTables) {
490
+ const e = entries[t.name]
491
+ if (!e) { rows.push({ table: t.name, note: 'no time column; freshness unknown' }); continue }
492
+ const thresholdHours = thresholdFor(f.settings, ds.id, t.name)
493
+ const status = freshnessStatus(e, thresholdHours)
494
+ rows.push({ table: t.name, column: e.column, latestAt: localStamp(e.latestAt), lag: e.latestAt === null ? null : lagText(e.lagMinutes), lagMinutes: e.lagMinutes, thresholdHours, status, ...(e.error ? { error: e.error } : {}), ...(e.future ? { note: 'latest timestamp is in the future' } : {}) })
495
+ }
496
+ const stale = rows.filter(r => r.status === 'stale' || r.status === 'warn')
497
+ const warnings = stale.map(r => `⚠️ ${r.table} 最新数据停在${whenText(parseTime(r.latestAt), now)}(滞后 ${r.lag})`)
498
+ return { datasource: ds.name, now: localStamp(now), tables: rows.slice(0, 100), ...(unknown.length ? { unknown } : {}), ...(warnings.length ? { warnings } : {}) }
499
+ },
500
+
501
+ // ---------------------------------------------------------------- quality
502
+
503
+ /** Count rows matching each condition: one SUM(CASE) statement, or one per condition on ES. */
504
+ async countConditions(ds, table, conds, run) {
505
+ if (!conds.length) return []
506
+ const dialect = ds.type ?? 'mysql'
507
+ const T = quoteName(table, dialect)
508
+ if (dialect === 'elasticsearch') {
509
+ const out = []
510
+ for (const c of conds) {
511
+ try { const r = await run(`SELECT COUNT(*) AS n FROM ${T} WHERE ${c}`); out.push(num(r.rows[0]?.[0]) ?? 0) } catch (error) { out.push({ error: error.message }) }
512
+ }
513
+ return out
514
+ }
515
+ const r = await run(`SELECT ${conds.map((c, i) => `SUM(CASE WHEN ${c} THEN 1 ELSE 0 END) AS q${i}`).join(', ')} FROM ${T}`)
516
+ return conds.map((_, i) => num(r.rows[0]?.[i]) ?? 0)
517
+ },
518
+
519
+ /** Quality checks of one table (bounded aggregate statements). */
520
+ async scanTableQuality(ds, t, { settings, freshness }) {
521
+ const dialect = ds.type ?? 'mysql'
522
+ const q = (c) => quoteIdent(c, dialect)
523
+ const T = quoteName(t.name, dialect)
524
+ const errors = []
525
+ const run = (sql) => this.runQuery({ sql, datasourceId: ds.id, title: `数据质量 · ${t.name}`, source: 'quality', confirmScan: true, governed: true })
526
+ const out = { table: t.name, rowsEstimate: t.rows ?? null, rowCount: null, nulls: [], duplicates: [], outliers: [], future: [], negatives: [], errors }
527
+ if ((t.rows ?? 0) > settings.maxTableRows) {
528
+ // Huge table: only a row count estimate from the schema, no scans.
529
+ out.skipped = true
530
+ out.score = null
531
+ out.issues = [{ kind: 'skipped', deduct: 0, zh: `表过大(约 ${t.rows} 行,阈值 ${settings.maxTableRows}),已跳过`, en: `Table too large (~${t.rows} rows, limit ${settings.maxTableRows}), skipped` }]
532
+ return out
533
+ }
534
+ const cols = t.columns.filter(c => !(dialect === 'elasticsearch' && /^(text|match_only_text|object|nested|geo_point|geo_shape)$/.test(String(c.dataType))) && c.name !== '_id')
535
+ const nullCols = cols.filter(c => c.nullable !== false).slice(0, 40)
536
+ const measures = cols.filter(c => c.roles?.includes('measure') && !c.roles.includes('flag') && NUM_TYPES.has(String(c.dataType))).slice(0, 6)
537
+ // 1. profile: row count, non-null counts, measure moments.
538
+ // SQLite has no STDDEV_POP: AVG(x²) − AVG(x)² instead (s holds the second moment there).
539
+ const sdExpr = (C) => (dialect === 'sqlite' ? `AVG((${C} + 0.0) * (${C} + 0.0))` : `STDDEV_POP(${C})`)
540
+ const parts = ['COUNT(*) AS n', ...nullCols.map((c, i) => `COUNT(${q(c.name)}) AS c${i}`), ...measures.flatMap((c, i) => [`AVG(${q(c.name)}) AS m${i}`, `${sdExpr(q(c.name))} AS s${i}`, `MIN(${q(c.name)}) AS lo${i}`, `MAX(${q(c.name)}) AS hi${i}`])]
541
+ let row
542
+ try {
543
+ row = (await run(`SELECT ${parts.join(', ')} FROM ${T}`)).rows[0] ?? []
544
+ } catch (error) {
545
+ try { row = [(await run(`SELECT COUNT(*) AS n FROM ${T}`)).rows[0]?.[0]] } catch (e2) { errors.push(e2.message); out.score = null; out.issues = [{ kind: 'error', deduct: 0, zh: `检查失败:${e2.message}`, en: `Check failed: ${e2.message}` }]; return out }
546
+ errors.push(error.message)
547
+ }
548
+ const n = num(row[0]) ?? 0
549
+ out.rowCount = n
550
+ if (n > 0 && row.length > 1) {
551
+ nullCols.forEach((c, i) => {
552
+ const nonNull = num(row[1 + i])
553
+ if (nonNull === null) return
554
+ const rate = (n - nonNull) / n
555
+ if (rate > 0) out.nulls.push({ column: c.name, nulls: n - nonNull, rate: Number(rate.toFixed(4)), optional: OPTIONAL_NAME.test(c.name) && !/^(created|create_time|gmt_create)/i.test(c.name), core: Boolean(c.roles?.includes('measure') || (c.roles?.includes('id') && c.key !== 'PRI')) })
556
+ })
557
+ out.nulls.sort((a, b) => b.rate - a.rate)
558
+ out.nulls = out.nulls.slice(0, 12)
559
+ }
560
+ if (n === 0) { Object.assign(out, scoreTable(out)); return out }
561
+ // 2. conditions: outliers (mean ± 4σ), negatives on amount-like measures, future timestamps.
562
+ const conds = []
563
+ const base = 1 + nullCols.length
564
+ measures.forEach((c, i) => {
565
+ if (row.length <= base) return
566
+ const mean = num(row[base + i * 4]); const sq = num(row[base + i * 4 + 1]); const lo = num(row[base + i * 4 + 2]); const hi = num(row[base + i * 4 + 3])
567
+ if (mean === null || sq === null) return
568
+ const sd = dialect === 'sqlite' ? Math.sqrt(Math.max(0, sq - mean * mean)) : sq
569
+ if (sd > 0 && n >= 30) {
570
+ const low = mean - 4 * sd; const high = mean + 4 * sd
571
+ if ((lo !== null && lo < low) || (hi !== null && hi > high)) conds.push({ kind: 'outliers', column: c.name, low, high, sql: `(${q(c.name)} < ${numLiteral(low)} OR ${q(c.name)} > ${numLiteral(high)})` })
572
+ }
573
+ if (AMOUNT_LIKE.test(c.name) && lo !== null && lo < 0) conds.push({ kind: 'negatives', column: c.name, sql: `${q(c.name)} < 0` })
574
+ })
575
+ const nowLit = nowPlusHour(dialect)
576
+ for (const c of cols.filter(c => c.roles?.includes('time') && DATE_TYPES.has(String(c.dataType)) && !FUTURE_OK.test(c.name)).slice(0, 4)) {
577
+ conds.push({ kind: 'future', column: c.name, sql: `${q(c.name)} > ${nowLit}` })
578
+ }
579
+ if (conds.length) {
580
+ try {
581
+ const counts = await this.countConditions(ds, t.name, conds.map(c => c.sql), run)
582
+ conds.forEach((c, i) => {
583
+ const v = counts[i]
584
+ if (v && typeof v === 'object') { errors.push(v.error); return }
585
+ if (!(v > 0)) return
586
+ if (c.kind === 'outliers') out.outliers.push({ column: c.column, count: v, low: c.low, high: c.high })
587
+ else out[c.kind].push({ column: c.column, count: v })
588
+ })
589
+ } catch (error) { errors.push(error.message) }
590
+ }
591
+ // 3. duplicate keys on unique indexes (non-primary; ES/SQLite: id-like candidate keys).
592
+ let keys = (t.indexes ?? []).filter(i => i.unique && !/^(primary|.*_pkey)$/i.test(i.name)).map(i => i.columns)
593
+ if (!keys.length && (dialect === 'elasticsearch' || dialect === 'sqlite')) keys = cols.filter(c => /^(id|uuid)$|(_no|_uuid|_hash|_code)$/i.test(c.name) && !c.roles?.includes('sensitive')).slice(0, 2).map(c => [c.name])
594
+ const visible = new Set(cols.map(c => c.name))
595
+ for (const k of keys.filter(k => k.every(c => visible.has(c))).slice(0, 3)) {
596
+ try {
597
+ let dup
598
+ if (k.length === 1) {
599
+ const r = await run(`SELECT COUNT(${q(k[0])}) - COUNT(DISTINCT ${q(k[0])}) AS d FROM ${T}`)
600
+ dup = num(r.rows[0]?.[0]) ?? 0
601
+ } else if (dialect !== 'elasticsearch') {
602
+ const cl = k.map(q).join(', ')
603
+ const r = await run(`SELECT COALESCE(SUM(c - 1), 0) AS d FROM (SELECT COUNT(*) AS c FROM ${T} WHERE ${k.map(c => `${q(c)} IS NOT NULL`).join(' AND ')} GROUP BY ${cl} HAVING COUNT(*) > 1) dup`)
604
+ dup = num(r.rows[0]?.[0]) ?? 0
605
+ } else continue
606
+ out.duplicates.push({ columns: k, duplicates: Math.max(0, dup), ...(dialect === 'elasticsearch' ? { approximate: true } : {}) })
607
+ } catch (error) { errors.push(error.message) }
608
+ }
609
+ out.freshness = freshness ? { stale: freshness.stale, lagMinutes: freshness.lagMinutes, thresholdHours: freshness.thresholdHours } : undefined
610
+ Object.assign(out, scoreTable(out))
611
+ return out
612
+ },
613
+
614
+ /** RPC: run the quality patrol for a data source (all or some tables) and store the run. */
615
+ async qualityScan({ datasourceId, tables, trigger = 'manual' } = {}) {
616
+ const ds = this.requireDatasource(datasourceId ?? this.state.current)
617
+ return this.withJobLock(`quality:${ds.id}`, async () => {
618
+ const schema = await this.visibleSchema(ds)
619
+ const qs = this.qualityState()
620
+ const settings = qs.settings
621
+ const fstore = this.freshnessState()[ds.id] ?? {}
622
+ const fset = this.freshnessState().settings
623
+ const list = (Array.isArray(tables) && tables.length ? schema.tables.filter(t => tables.includes(t.name)) : schema.tables).filter(t => t.type !== 'VIEW' || (Array.isArray(tables) && tables.length)).slice(0, settings.maxTables)
624
+ const started = Date.now()
625
+ const results = []
626
+ for (const t of list) {
627
+ const fe = fstore[t.name]
628
+ const freshness = fe ? { ...fe, thresholdHours: thresholdFor(fset, ds.id, t.name), stale: freshnessStatus(fe, thresholdFor(fset, ds.id, t.name)) === 'stale' } : undefined
629
+ try { results.push(await this.scanTableQuality(ds, t, { settings, freshness })) } catch (error) { results.push({ table: t.name, score: null, errors: [error.message], issues: [{ kind: 'error', deduct: 0, zh: `检查失败:${error.message}`, en: `Check failed: ${error.message}` }] }) }
630
+ }
631
+ const score = scoreDatasource(results)
632
+ const prev = qs.latest[ds.id]
633
+ const partial = Boolean(Array.isArray(tables) && tables.length)
634
+ const run = { id: newId('qr'), at: Date.now(), datasourceId: ds.id, datasourceName: ds.name, trigger, partial, score, previousScore: prev?.score ?? null, durationMs: Date.now() - started, tableCount: results.length, issueCount: results.reduce((s, r) => s + (r.issues ?? []).filter(i => i.deduct > 0).length, 0), tables: results.sort((a, b) => (a.score ?? 101) - (b.score ?? 101)), summary: summarizeRun(results, score) }
635
+ // Score-drop push (full runs only).
636
+ if (!partial && prev?.score !== null && prev?.score !== undefined && score !== null && prev.score - score > settings.dropPoints && settings.channelId && this.state.channels.some(c => c.id === settings.channelId)) {
637
+ const worst = results.filter(r => r.score !== null).slice(0, 5)
638
+ const lines = worst.map(r => `- **${r.table}** ${r.score} 分:${(r.issues ?? []).filter(i => i.deduct > 0).slice(0, 2).map(i => i.zh).join(';') || '—'}`)
639
+ try {
640
+ await this.sendToChannel({ channelId: settings.channelId, title: `📉 数据健康分下降:${ds.name} ${prev.score} → ${score}`, text: `${lines.join('\n')}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, source: 'quality', template: 'orange' })
641
+ run.pushed = true
642
+ } catch (error) { run.pushError = error.message }
643
+ }
644
+ this.records.put('qualityRun', run)
645
+ if (!partial) qs.latest[ds.id] = { runId: run.id, at: run.at, score }
646
+ await this.save()
647
+ return run
648
+ })
649
+ },
650
+
651
+ /** RPC: latest run (full detail) + score history of a data source. */
652
+ qualityResults({ datasourceId, limit = 30 } = {}) {
653
+ const ds = this.requireDatasource(datasourceId ?? this.state.current)
654
+ const runs = this.records.list('qualityRun', { ref: ds.id, limit: Math.min(200, Number(limit) || 30) })
655
+ return { datasourceId: ds.id, latest: runs[0] ?? null, history: runs.map(r => ({ id: r.id, at: r.at, score: r.score, partial: r.partial, trigger: r.trigger, tableCount: r.tableCount, issueCount: r.issueCount })) }
656
+ },
657
+
658
+ /** Daily patrol: every analyzed data source. */
659
+ async runQualityAll() {
660
+ const qs = this.qualityState()
661
+ qs.lastRunAt = Date.now()
662
+ await this.save()
663
+ const out = []
664
+ for (const ds of this.state.datasources) {
665
+ if (!ds.schemaAnalyzedAt) continue
666
+ try { const r = await this.qualityScan({ datasourceId: ds.id, trigger: 'schedule' }); out.push({ id: ds.id, ok: true, score: r.score }) } catch (error) { if (error.code !== 'nl2sql/busy') out.push({ id: ds.id, ok: false, error: error.message }) }
667
+ }
668
+ return out
669
+ },
670
+
671
+ healthStatus() {
672
+ const f = this.freshnessState()
673
+ const q = this.qualityState()
674
+ const fDue = (f.lastRunAt ?? 0) + f.settings.intervalMin * 60000
675
+ return { freshness: { enabled: f.settings.enabled, lastRunAt: f.lastRunAt ?? null, nextAt: f.settings.enabled ? Math.max(fDue, Date.now()) : null }, quality: { enabled: q.settings.enabled, lastRunAt: q.lastRunAt ?? null, time: q.settings.time } }
676
+ },
677
+
678
+ /** Scheduler jobs (service/alerts.js tickAlerts). */
679
+ jobs_freshness(now) {
680
+ const f = this.freshnessState()
681
+ const q = this.qualityState()
682
+ return [
683
+ { id: 'freshness', due: (n = now) => f.settings.enabled && (n.getTime?.() ?? Number(n)) - (f.lastRunAt ?? 0) >= f.settings.intervalMin * 60000, run: () => this.runFreshnessAll() },
684
+ { id: 'quality', due: (n = now) => q.settings.enabled && (q.lastRunAt ?? 0) < lastDailySlot(q.settings.time, n instanceof Date ? n : new Date(n)), run: () => this.runQualityAll() },
685
+ ]
686
+ },
687
+ }
688
+
689
+ /** One-line zh/en summary of a run. */
690
+ function summarizeRun(results, score) {
691
+ const scored = results.filter(r => r.score !== null && r.score !== undefined)
692
+ const bad = scored.filter(r => r.score < 80)
693
+ const counts = {}
694
+ for (const r of results) for (const i of r.issues ?? []) if (i.deduct > 0) counts[i.kind] = (counts[i.kind] ?? 0) + 1
695
+ const names = { nulls: ['空值', 'nulls'], duplicates: ['重复键', 'duplicate keys'], outliers: ['异常值', 'outliers'], future: ['未来时间', 'future timestamps'], negative: ['负值', 'negative amounts'], stale: ['数据滞后', 'stale data'] }
696
+ const zhParts = Object.entries(counts).map(([k, v]) => `${names[k]?.[0] ?? k} ${v}`)
697
+ const enParts = Object.entries(counts).map(([k, v]) => `${names[k]?.[1] ?? k} ${v}`)
698
+ return {
699
+ zh: score === null ? '没有可评分的表' : `健康分 ${score}:${scored.length} 张表已评分,${bad.length} 张低于 80 分${zhParts.length ? `;问题:${zhParts.join('、')}` : ';未发现问题'}`,
700
+ en: score === null ? 'No tables could be scored' : `Health score ${score}: ${scored.length} tables scored, ${bad.length} below 80${enParts.length ? `; issues: ${enParts.join(', ')}` : '; no issues found'}`,
701
+ }
702
+ }
703
+
704
+ // ------------------------------------------------------------------ feature
705
+
706
+ export const FEATURE = {
707
+ name: 'freshness',
708
+ methods,
709
+ rpc: (service) => ({
710
+ freshnessFor: (p) => service.freshnessFor(p ?? {}),
711
+ freshnessEvents: (p) => service.freshnessEvents(p ?? {}),
712
+ qualityScan: (p) => service.qualityScan({ datasourceId: p?.datasourceId, tables: p?.tables }),
713
+ qualityResults: (p) => service.qualityResults(p ?? {}),
714
+ healthSettings: () => service.healthSettings(),
715
+ saveHealthSettings: (p) => service.saveHealthSettings(p ?? {}),
716
+ healthStatus: () => service.healthStatus(),
717
+ }),
718
+ tools: (ctx, service, { textBlock, DATASOURCE_PARAM }) => {
719
+ ctx.tools.register({
720
+ name: 'nl2sql_freshness',
721
+ description: 'Data freshness of tables in the session data source: the latest timestamp (MAX of the table\'s main time column, e.g. created_at) and how far it lags behind now, plus the stale threshold and status (ok / warn / stale / empty). Call it for questions about recent periods (today, yesterday, this week, 最近, 今天, 昨天, 本周) with the tables you are going to query, and warn the user when data is stale.',
722
+ parameters: {
723
+ type: 'object',
724
+ properties: {
725
+ datasource: DATASOURCE_PARAM,
726
+ tables: { type: 'array', items: { type: 'string' }, description: 'Tables to check (names as listed by nl2sql_schema). Omit for every table with a time column.' },
727
+ },
728
+ additionalProperties: false,
729
+ },
730
+ output: {
731
+ schema: { type: 'object' },
732
+ render: (_args, value) => textBlock(JSON.stringify(value, null, 1)),
733
+ presentationMeta: (_args, value) => JSON.parse(JSON.stringify({ freshness: { datasource: value.datasource, now: value.now, tables: (value.tables ?? []).slice(0, 30), warnings: value.warnings ?? [] } })),
734
+ },
735
+ isConcurrencySafe: () => true,
736
+ timeoutMs: 300000,
737
+ presentCall: (args) => ({ card: 'generic', title: args?.tables?.length ? `数据新鲜度 ${args.tables.join(', ')}` : '数据新鲜度', kind: 'read' }),
738
+ execute: async (args, exec) => {
739
+ try {
740
+ return await service.freshnessForSession({ sessionId: exec?.agent?.id, datasource: args?.datasource, tables: args?.tables })
741
+ } catch (error) {
742
+ throw error instanceof UserError ? new Error(error.message) : error
743
+ }
744
+ },
745
+ })
746
+ },
747
+ }