@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/CHANGELOG.md +24 -1
- package/README.md +22 -0
- package/README.zh-CN.md +22 -0
- package/bin/nl2sql-mcp.mjs +103 -0
- package/cordis.patch.yml +4 -0
- package/docs/mcp.md +119 -0
- package/index.js +17 -1
- package/lib/client.js +1680 -6
- package/package.json +7 -2
- package/service/alerts.js +20 -6
- 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 +3 -2
- package/service/reconcile.js +565 -0
- package/service/shared.js +27 -0
- package/service/subscriptions.js +563 -0
|
@@ -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
|
+
}
|