@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Shared helpers, constants and record storage used by the service modules.
3
+ */
4
+ import { createHash, randomBytes } from 'node:crypto'
5
+ import { DIALECTS, describeError } from '../drivers.js'
6
+ import { sqliteDialectHints } from '../sqlite-driver.js'
7
+
8
+
9
+ export const SUPPORTED_TYPES = Object.keys(DIALECTS)
10
+
11
+ export const RPC_PATH = '/api/nl2sql.rpc'
12
+ export const ROLE_RANK = { viewer: 0, analyst: 1, admin: 2 }
13
+ export const PRESET_ID = 'nl2sql'
14
+ export const CREDENTIAL_SCOPE = 'nl2sql'
15
+ export const RESULT_CACHE_LIMIT = 300
16
+
17
+ export const DEFAULT_SETTINGS = {
18
+ maxRows: 500,
19
+ timeoutMs: 15000,
20
+ sampleMode: 'enum', // off | enum | all
21
+ resultToModel: 'summary', // none | summary | rows
22
+ modelRows: 20,
23
+ maskSensitive: true,
24
+ scanGuardRows: 1000000, // EXPLAIN rows above which a full scan needs confirmation (0 = off)
25
+ cacheTtlSec: 300, // identical query reuse window for dashboards / reruns (0 = off)
26
+ schemaWatch: true, // detect schema changes on re-analysis
27
+ botDefaultRole: 'none', // role for unknown IM bot users: none | viewer | analyst (legacy name: larkDefaultRole)
28
+ schemaOverviewMax: 80, // above this many tables the schema overview lists names only, plus the most relevant tables in full
29
+ schemaOverviewTop: 15, // tables described in full in a large-schema overview
30
+ retention: { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }, // records kept per log kind
31
+ }
32
+
33
+ /** Clamp bounds for settings.retention values. */
34
+ export const RETENTION_MIN = 100
35
+ export const RETENTION_MAX = 200000
36
+
37
+ // ---------------------------------------------------------------- utilities
38
+
39
+ export const newId = (prefix) => `${prefix}-${randomBytes(5).toString('hex')}`
40
+
41
+ export class UserError extends Error {
42
+ constructor(code, message) {
43
+ super(message)
44
+ this.code = code
45
+ }
46
+ }
47
+
48
+ export function pick(obj, keys) {
49
+ const out = {}
50
+ for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k]
51
+ return out
52
+ }
53
+
54
+ // "token" alone is ambiguous (crypto token_code / token_price are business data):
55
+ // only credential-like token names count as sensitive.
56
+ export const SENSITIVE_NAME = /(phone|mobile|tel|email|mail|id_?card|idno|identity|passport|password|passwd|pwd|secret|(?:access|auth|api|refresh|session|bearer|csrf|jwt|push|device)_?token|^token$|token_?(?:hash|secret|key)$|bank_?card|card_?no|身份证|手机|电话|邮箱|密码)/i
57
+
58
+ export function maskValue(column, value) {
59
+ if (value === null || value === undefined) return value
60
+ const s = String(value)
61
+ if (/(password|passwd|pwd|secret|token|密码)/i.test(column)) return '******'
62
+ if (/(email|mail|邮箱)/i.test(column) && s.includes('@')) {
63
+ const [u, d] = s.split('@')
64
+ return `${u.slice(0, 1)}***@${d}`
65
+ }
66
+ if (s.length <= 4) return '****'
67
+ const keep = Math.min(3, Math.floor(s.length / 4))
68
+ return `${s.slice(0, keep)}${'*'.repeat(Math.max(4, s.length - keep * 2))}${s.slice(-keep)}`
69
+ }
70
+
71
+ // ---------------------------------------------------------- schema analysis
72
+
73
+ export const NUMERIC_TYPES = new Set(['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'decimal', 'numeric', 'float', 'double', 'real'])
74
+ export const TIME_TYPES = new Set(['date', 'datetime', 'timestamp', 'time', 'year'])
75
+ export const TEXT_TYPES = new Set(['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'enum', 'set'])
76
+ export const ENUM_NAME = /(status|state|type|kind|level|category|gender|sex|channel|source|platform|stage|grade|flag|mode|tag|role|class|状态|类型)/i
77
+ export const GEO_NAME = /^(province|city|district|region|country|area|county|town|street|prov|省|市|区|县|地区)(_?(name|code))?$|(_province|_city|_district|_region|_country)$/i
78
+
79
+ export function classifyColumn(col, table) {
80
+ const name = col.name.toLowerCase()
81
+ const t = col.dataType
82
+ const roles = []
83
+ if (col.key === 'PRI' || name === 'id' || /_id$|^id_/.test(name)) roles.push('id')
84
+ if (TIME_TYPES.has(t) || /(_at|_time|_date|^date|^time|^dt$|_dt$|日期|时间)/.test(name) && (TIME_TYPES.has(t) || t === 'int' || t === 'bigint' || t === 'varchar')) roles.push('time')
85
+ if (t === 'enum' || t === 'set' || (ENUM_NAME.test(name) && (t === 'tinyint' || t === 'smallint' || t === 'char' || t === 'varchar' || t === 'int'))) roles.push('enum')
86
+ if (NUMERIC_TYPES.has(t) && !roles.includes('id') && !roles.includes('enum') && !roles.includes('time') && !/^is_|^has_/.test(name)) roles.push('measure')
87
+ if (TEXT_TYPES.has(t) && GEO_NAME.test(name)) roles.push('geo')
88
+ if (SENSITIVE_NAME.test(name) || SENSITIVE_NAME.test(col.comment ?? '')) roles.push('sensitive')
89
+ if (/^is_|^has_/.test(name) || col.columnType === 'tinyint(1)' || t === 'bit') roles.push('flag')
90
+ if (roles.length === 0 && TEXT_TYPES.has(t)) roles.push('text')
91
+ void table
92
+ return roles
93
+ }
94
+
95
+ export function singular(word) {
96
+ if (word.endsWith('ies')) return `${word.slice(0, -3)}y`
97
+ if (word.endsWith('ses')) return word.slice(0, -2)
98
+ if (word.endsWith('s')) return word.slice(0, -1)
99
+ return word
100
+ }
101
+
102
+ export function inferRelations(tables, declared) {
103
+ const byName = new Map(tables.map(t => [t.name.toLowerCase(), t]))
104
+ const declaredKey = new Set(declared.map(r => `${r.from.table}.${r.from.column}`))
105
+ const out = []
106
+ for (const table of tables) {
107
+ for (const col of table.columns) {
108
+ const m = col.name.toLowerCase().match(/^(.*)_id$/)
109
+ if (!m || declaredKey.has(`${table.name}.${col.name}`)) continue
110
+ const base = m[1]
111
+ const candidates = [base, `${base}s`, `${base}es`, base.replace(/y$/, 'ies'), singular(base)]
112
+ const prefixes = ['', ...new Set(tables.map(t => t.name.toLowerCase().match(/^([a-z0-9]+_)/)?.[1]).filter(Boolean))]
113
+ let target
114
+ for (const p of prefixes) {
115
+ for (const c of candidates) {
116
+ const hit = byName.get(`${p}${c}`)
117
+ if (hit && hit !== table) { target = hit; break }
118
+ }
119
+ if (target) break
120
+ }
121
+ if (!target) continue
122
+ const pk = target.columns.find(c => c.key === 'PRI') ?? target.columns.find(c => c.name.toLowerCase() === 'id')
123
+ if (!pk) continue
124
+ const sameType = pk.dataType === col.dataType
125
+ out.push({
126
+ from: { table: table.name, column: col.name },
127
+ to: { table: target.name, column: pk.name },
128
+ kind: 'inferred',
129
+ confidence: sameType ? 0.8 : 0.55,
130
+ })
131
+ }
132
+ }
133
+ return out
134
+ }
135
+
136
+ export function classifyTable(table, relations) {
137
+ const name = table.name.toLowerCase()
138
+ const roles = table.columns.flatMap(c => c.roles)
139
+ const referencedBy = relations.filter(r => r.to.table === table.name).length
140
+ const references = relations.filter(r => r.from.table === table.name).length
141
+ if (table.type === 'VIEW') return 'view'
142
+ if (/(log|logs|history|audit|trace|record)s?$|^log_/.test(name)) return 'log'
143
+ if (/(config|setting|dict|dictionary|option|param|enum)s?$/.test(name)) return 'config'
144
+ const hasTime = roles.includes('time')
145
+ const measures = roles.filter(r => r === 'measure').length
146
+ if (hasTime && measures >= 1 && (references >= 1 || (table.rows ?? 0) > 10000)) return 'fact'
147
+ if (referencedBy >= 1) return 'dimension'
148
+ if (hasTime && measures >= 1) return 'fact'
149
+ return 'other'
150
+ }
151
+
152
+ // -------------------------------------------------------------- chart advice
153
+
154
+ export function suggestChart(columns, rows) {
155
+ const time = columns.filter(c => c.kind === 'time')
156
+ const num = columns.filter(c => c.kind === 'number')
157
+ const cat = columns.filter(c => c.kind === 'text')
158
+ if (rows.length === 1 && num.length >= 1 && columns.length <= 4) return { type: 'kpi', y: num.map(c => c.name) }
159
+ if (time.length >= 1 && num.length >= 1) {
160
+ return { type: 'line', x: time[0].name, y: num.slice(0, 4).map(c => c.name), ...(cat.length >= 1 && num.length === 1 ? { series: cat[0].name } : {}) }
161
+ }
162
+ if (cat.length >= 1 && num.length >= 1) {
163
+ if (num.length === 1 && rows.length <= 8 && cat.length === 1) return { type: 'pie', x: cat[0].name, y: [num[0].name] }
164
+ if (cat.length >= 2 && num.length === 1) return { type: 'bar', x: cat[0].name, y: [num[0].name], series: cat[1].name }
165
+ return { type: 'bar', x: cat[0].name, y: num.slice(0, 4).map(c => c.name) }
166
+ }
167
+ if (num.length >= 2) return { type: 'scatter', x: num[0].name, y: [num[1].name] }
168
+ return { type: 'table' }
169
+ }
170
+
171
+ export function columnKind(field, rows, index) {
172
+ // Numeric codes such as status/type/level are categories, not measures.
173
+ if (field.kind === 'number' && ENUM_NAME.test(field.name) && new Set(rows.map(r => r[index])).size <= 20) return 'text'
174
+ if (field.kind && field.kind !== 'auto') return field.kind
175
+ const values = rows.map(r => r[index]).filter(v => v !== null && v !== undefined).slice(0, 50)
176
+ if (values.length && values.every(v => typeof v === 'string' && /^\d{4}-\d{2}(-\d{2})?([ T]\d{2}:\d{2}(:\d{2})?)?/.test(v))) return 'time'
177
+ if (values.length && values.every(v => typeof v === 'number' || (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v) && v.length < 16)) && !/(^id$|_id$|code$|no$)/i.test(field.name)) return 'number'
178
+ return 'text'
179
+ }
180
+
181
+ export const DIALECT_HINTS = {
182
+ mysql: 'MySQL: quote identifiers with backticks; MySQL 5.7 has no CTE or window functions; respect ONLY_FULL_GROUP_BY; DATE_FORMAT/DATE() for time buckets.',
183
+ postgres: 'PostgreSQL: quote identifiers with double quotes when they contain upper case or special characters; schema-qualified names appear as schema.table; use date_trunc(), to_char(), ::date casts, FILTER (WHERE ...), CTEs and window functions freely; ILIKE for case-insensitive match.',
184
+ elasticsearch: 'Elasticsearch SQL: each index is a table (quote names containing - or * with double quotes); nested object fields use dot paths; text fields are not aggregatable — group/filter on keyword fields or the .keyword sub-field; no JOINs and no sub-selects in FROM; use HISTOGRAM(ts, INTERVAL 1 DAY) or DATE_TRUNC for time buckets; LIMIT is required for large indices; MATCH(field, \'text\') for full-text search.',
185
+ sqlite: sqliteDialectHints,
186
+ }
187
+
188
+ export function summarize(columns, rows) {
189
+ return columns.map((c, i) => {
190
+ const values = rows.map(r => r[i]).filter(v => v !== null && v !== undefined)
191
+ const s = { name: c.name, kind: c.kind, nonNull: values.length }
192
+ if (c.kind === 'number') {
193
+ const nums = values.map(Number).filter(Number.isFinite)
194
+ if (nums.length) {
195
+ s.min = Math.min(...nums)
196
+ s.max = Math.max(...nums)
197
+ s.sum = Number(nums.reduce((a, b) => a + b, 0).toFixed(4))
198
+ s.avg = Number((s.sum / nums.length).toFixed(4))
199
+ }
200
+ } else if (c.kind === 'time') {
201
+ const sorted = values.map(String).sort()
202
+ if (sorted.length) { s.min = sorted[0]; s.max = sorted[sorted.length - 1] }
203
+ } else {
204
+ const counts = new Map()
205
+ for (const v of values) counts.set(String(v), (counts.get(String(v)) ?? 0) + 1)
206
+ s.distinct = counts.size
207
+ s.top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([value, count]) => ({ value: value.slice(0, 60), count }))
208
+ }
209
+ return s
210
+ })
211
+ }
212
+
213
+ // ------------------------------------------------------------------ service
214
+
215
+ /** state key → record kind, for the one-time migration out of state.json. */
216
+ export const RECORD_KINDS = [['history', 'history'], ['feedback', 'feedback'], ['delivery', 'deliveries'], ['alertEvent', 'alertEvents'], ['admin', 'adminLog'], ['evalRun', 'evalRuns'], ['schemaChange', 'schemaChanges']]
217
+ export const RECORD_LIMITS = { history: 20000, feedback: 5000, delivery: 5000, alertEvent: 5000, admin: 5000, evalRun: 200, schemaChange: 1000 }
218
+ /** Record kinds and the field used as their filter key. */
219
+ export const RECORD_REF = { history: 'datasourceId', feedback: 'datasourceId', delivery: 'channelId', alertEvent: 'alertId', admin: 'action', evalRun: 'datasourceId', schemaChange: 'datasourceId' }
220
+
221
+ /**
222
+ * Growing logs (audit history, feedback, push deliveries, alert events, admin
223
+ * log, evaluation runs, schema changes) live in SQLite, newest first. Falls
224
+ * back to memory when SQLite is unavailable.
225
+ */
226
+ export class Records {
227
+ /** @param {object} store ResultStore or undefined; @param {() => object} [limits] current retention per kind. */
228
+ constructor(store, limits) {
229
+ this.store = store
230
+ this.mem = new Map()
231
+ this.puts = 0
232
+ this.limitsOf = typeof limits === 'function' ? limits : () => limits ?? RECORD_LIMITS
233
+ }
234
+
235
+ limits() {
236
+ return { ...RECORD_LIMITS, ...(this.limitsOf() ?? {}) }
237
+ }
238
+
239
+ /** Apply the retention limits now (all kinds, or one). */
240
+ trim(kind) {
241
+ const limits = this.limits()
242
+ for (const [k, n] of Object.entries(limits)) {
243
+ if (kind && k !== kind) continue
244
+ if (this.store) this.store.trimRecords(k, n)
245
+ else if (this.mem.has(k)) this.mem.set(k, this.mem.get(k).slice(0, n))
246
+ }
247
+ }
248
+
249
+ put(kind, rec) {
250
+ const ref = rec[RECORD_REF[kind]] ?? null
251
+ if (this.store) {
252
+ this.store.putRecord(kind, rec, { ref: ref === null ? null : String(ref), status: rec.status ?? null })
253
+ if (++this.puts % 500 === 0) this.trim()
254
+ return
255
+ }
256
+ const list = this.mem.get(kind) ?? []
257
+ const i = list.findIndex(x => x.id === rec.id)
258
+ if (i >= 0) list[i] = rec
259
+ else list.unshift(rec)
260
+ this.mem.set(kind, list.slice(0, this.limits()[kind] ?? 1000))
261
+ }
262
+
263
+ /** Record count per kind. */
264
+ counts() {
265
+ const out = {}
266
+ for (const kind of Object.keys(RECORD_LIMITS)) out[kind] = this.count(kind)
267
+ return out
268
+ }
269
+
270
+ get(kind, id) {
271
+ return this.store ? this.store.getRecord(kind, id) : (this.mem.get(kind) ?? []).find(x => x.id === id)
272
+ }
273
+
274
+ list(kind, { ref, status, limit = 100, since, filter } = {}) {
275
+ if (this.store) return this.store.listRecords(kind, { ref: ref === undefined || ref === null || ref === '' ? undefined : String(ref), status, limit, since, filter })
276
+ return (this.mem.get(kind) ?? []).filter(r => (!ref || String(r[RECORD_REF[kind]]) === String(ref)) && (!status || r.status === status) && (!since || (r.at ?? 0) >= since) && (!filter || filter(r))).slice(0, limit)
277
+ }
278
+
279
+ count(kind, opts = {}) {
280
+ return this.store ? this.store.countRecords(kind, opts) : this.list(kind, { ...opts, limit: 1e9 }).length
281
+ }
282
+
283
+ delete(kind, id) {
284
+ if (this.store) this.store.deleteRecord(kind, id)
285
+ else this.mem.set(kind, (this.mem.get(kind) ?? []).filter(x => x.id !== id))
286
+ }
287
+
288
+ clear(kind) {
289
+ if (this.store) this.store.clearRecords(kind)
290
+ else this.mem.delete(kind)
291
+ }
292
+ }
293
+
294
+
295
+ /** Pick representative charts for one table: trend, composition, top N, distribution, KPI. */
296
+ export function dayExpr(dialect, C) {
297
+ return dialect === 'mysql' ? `DATE(${C})` : dialect === 'postgres' ? `date_trunc('day', ${C})::date` : dialect === 'sqlite' ? `date(${C})` : `HISTOGRAM(${C}, INTERVAL 1 DAY)`
298
+ }
299
+
300
+ export function overviewPlans(dialect, t, knowledge) {
301
+ const q = (n) => dialect === 'mysql' ? `\`${n.replace(/`/g, '``')}\`` : dialect === 'postgres' ? n.split('.').map(p => `"${p.replace(/"/g, '""')}"`).join('.') : `"${n.replace(/"/g, '""')}"`
302
+ // Aliases: one identifier even when the column name contains dots.
303
+ const a = (n) => dialect === 'mysql' ? q(n) : `"${n.replace(/"/g, '""')}"`
304
+ const T = q(t.name)
305
+ const time = t.columns.find(c => c.roles.includes('time') && /date|time|stamp/.test(c.dataType) && !/update|modif/i.test(c.name)) ?? t.columns.find(c => c.roles.includes('time') && /date|time|stamp/.test(c.dataType))
306
+ const measures = t.columns.filter(c => c.roles.includes('measure') && !c.roles.includes('flag')).slice(0, 2)
307
+ const enums = t.columns.filter(c => c.roles.includes('enum') && !c.roles.includes('sensitive')).slice(0, 2)
308
+ const label = t.comment ? t.comment.replace(/表.*$/, '') || t.name : t.name
309
+ const metric = knowledge.metrics.find(m => m.table === t.name)
310
+ const plans = []
311
+ const agg = measures.map(m => `, SUM(${q(m.name)}) AS ${q(`${m.name}_sum`)}`).join('')
312
+ const kpiAgg = measures.map(m => `, SUM(${q(m.name)}) AS ${q(`${m.name}_sum`)}, AVG(${q(m.name)}) AS ${q(`${m.name}_avg`)}`).join('')
313
+ const days = 30
314
+ if (time) {
315
+ const C = q(time.name)
316
+ const day = dayExpr(dialect, C)
317
+ const since = dialect === 'mysql' ? `${C} >= DATE_SUB(CURDATE(), INTERVAL ${days} DAY)` : dialect === 'postgres' ? `${C} >= current_date - interval '${days} days'` : dialect === 'sqlite' ? `${C} >= date('now', '-${days} days')` : `${C} >= NOW() - INTERVAL ${days} DAYS`
318
+ plans.push({ title: `${label} · 近 ${days} 天总览`, sql: `SELECT COUNT(*) AS ${q('total')}${kpiAgg} FROM ${T} WHERE ${since}`, chart: { type: 'kpi' }, width: 'full' })
319
+ plans.push({ title: `${label} · 每日趋势`, sql: `SELECT ${day} AS ${q('day')}, COUNT(*) AS ${q('cnt')}${agg} FROM ${T} WHERE ${since} GROUP BY ${dialect === 'elasticsearch' ? q('day') : day} ORDER BY ${q('day')}`, chart: { type: 'line' }, width: 'full' })
320
+ if (dialect !== 'elasticsearch') {
321
+ const week = dialect === 'mysql' ? `DATE_FORMAT(${C}, '%x-W%v')` : dialect === 'sqlite' ? `strftime('%Y-W%W', ${C})` : `to_char(date_trunc('week', ${C}), 'IYYY-"W"IW')`
322
+ const since84 = dialect === 'mysql' ? `${C} >= DATE_SUB(CURDATE(), INTERVAL 84 DAY)` : dialect === 'sqlite' ? `${C} >= date('now', '-84 days')` : `${C} >= current_date - interval '84 days'`
323
+ plans.push({ title: `${label} · 近 12 周(周环比)`, sql: `SELECT ${week} AS ${q('week')}, COUNT(*) AS ${q('cnt')}${agg} FROM ${T} WHERE ${since84} GROUP BY ${week} ORDER BY ${q('week')}`, chart: { type: 'bar' }, width: 'half' })
324
+ }
325
+ } else {
326
+ plans.push({ title: `${label} · 总览`, sql: `SELECT COUNT(*) AS ${q('total')}${kpiAgg} FROM ${T}`, chart: { type: 'kpi' }, width: 'full' })
327
+ }
328
+ for (const e of enums) {
329
+ const E = q(e.name)
330
+ plans.push({ title: `${label} · 按${e.comment ? e.comment.split(/[\s::((]/)[0] : e.name}分布`, sql: `SELECT ${E} AS ${a(e.name)}, COUNT(*) AS ${q('cnt')}${measures[0] ? `, SUM(${q(measures[0].name)}) AS ${q(`${measures[0].name}_sum`)}` : ''} FROM ${T} GROUP BY ${E} ORDER BY ${q('cnt')} DESC LIMIT 12`, chart: { type: 'pie' }, width: 'half' })
331
+ }
332
+ const dim = t.columns.find(c => (c.roles.includes('geo') || (c.roles.includes('text') && /name|title|city|region|channel|source|category/i.test(c.name))) && !c.roles.includes('sensitive'))
333
+ if (dim && measures[0]) {
334
+ plans.push({ title: `${label} · ${dim.comment || dim.name} Top 10`, sql: `SELECT ${q(dim.name)} AS ${a(dim.name)}, SUM(${q(measures[0].name)}) AS ${a(`${measures[0].name}_sum`)} FROM ${T} GROUP BY ${q(dim.name)} ORDER BY ${a(`${measures[0].name}_sum`)} DESC LIMIT 10`, chart: { type: 'barh' }, width: 'half' })
335
+ }
336
+ if (metric && time) {
337
+ const C = q(metric.timeColumn || time.name)
338
+ const day = dayExpr(dialect, C)
339
+ plans.push({ title: `${metric.name} · 每日`, sql: `SELECT ${day} AS ${q('day')}, ${metric.expression} AS ${q(metric.name)} FROM ${T}${metric.filter ? ` WHERE ${metric.filter}` : ''} GROUP BY ${dialect === 'elasticsearch' ? q('day') : day} ORDER BY ${q('day')} DESC LIMIT 30`, chart: { type: 'line' }, width: 'half' })
340
+ }
341
+ return plans.slice(0, 6)
342
+ }
343
+
344
+ export function upsert(list, entry) {
345
+ const i = list.findIndex(x => x.id === entry.id)
346
+ if (i < 0) return [...list, entry]
347
+ const next = [...list]
348
+ next[i] = entry
349
+ return next
350
+ }
351
+
352
+ // ------------------------------------------------------------------ helpers
353
+
354
+
355
+ export function clampInt(v, min, max, field) {
356
+ const n = Number(v)
357
+ if (!Number.isInteger(n) || n < min || n > max) throw new UserError('nl2sql/invalid', `${field} 必须是 ${min}–${max} 之间的整数`)
358
+ return n
359
+ }
360
+
361
+ export function oneOf(v, values, field) {
362
+ if (!values.includes(v)) throw new UserError('nl2sql/invalid', `${field} 必须是 ${values.join(' / ')} 之一`)
363
+ return v
364
+ }
365
+
366
+ export function requireText(v, field, max) {
367
+ const s = String(v ?? '').trim()
368
+ if (s === '' || s.length > max) throw new UserError('nl2sql/invalid', `${field} 不能为空且不超过 ${max} 个字符`)
369
+ return s
370
+ }
371
+
372
+ export function toUserError(error) {
373
+ if (error instanceof UserError) return error
374
+ const e = describeError(error)
375
+ return new UserError(e.code, e.message)
376
+ }
377
+
378
+ export const CHART_TYPES = ['line', 'bar', 'barh', 'stacked', 'area', 'pie', 'scatter', 'kpi', 'table', 'funnel']
379
+
380
+ export function normalizeChart(chart, columns) {
381
+ if (!chart || typeof chart !== 'object') return undefined
382
+ const type = CHART_TYPES.includes(chart.type) ? chart.type : undefined
383
+ if (!type) return undefined
384
+ const names = new Set(columns.map(c => c.name))
385
+ const y = (Array.isArray(chart.y) ? chart.y : chart.y ? [chart.y] : []).filter(n => names.has(n))
386
+ const out = { type }
387
+ if (chart.x && names.has(chart.x)) out.x = chart.x
388
+ if (y.length) out.y = y
389
+ if (chart.series && names.has(chart.series)) out.series = chart.series
390
+ if (type !== 'table' && type !== 'kpi' && (!out.x || !out.y)) return undefined
391
+ if (type === 'kpi' && !out.y) return undefined
392
+ return out
393
+ }
394
+
395
+ /** Complete a type-only chart hint (e.g. { type: 'pie' }) from the column kinds. */
396
+ export function fillChartHint(chart, columns, suggested) {
397
+ if (!chart?.type || !CHART_TYPES.includes(chart.type)) return undefined
398
+ const nums = columns.filter(c => c.kind === 'number').map(c => c.name)
399
+ const cats = columns.filter(c => c.kind !== 'number').map(c => c.name)
400
+ if (chart.type === 'table') return { type: 'table' }
401
+ if (chart.type === 'kpi') return nums.length ? { type: 'kpi', y: nums.slice(0, 6) } : undefined
402
+ if (chart.type === 'scatter') return nums.length >= 2 ? { type: 'scatter', x: nums[0], y: [nums[1]] } : undefined
403
+ const x = (suggested?.x && suggested.type !== 'scatter' ? suggested.x : undefined) ?? cats[0]
404
+ if (!x || !nums.length) return undefined
405
+ if (chart.type === 'pie' || chart.type === 'funnel') return { type: chart.type, x, y: [nums.filter(n => n !== x)[0] ?? nums[0]] }
406
+ return { type: chart.type, x, y: nums.filter(n => n !== x).slice(0, 4) }
407
+ }
408
+
409
+ // -------------------------------------------------------------------- tools
410
+
411
+ // -------------------------------------------------------------------- apply
412
+
413
+ export function hashText(text) {
414
+ return createHash('sha1').update(String(text)).digest('hex').slice(0, 10)
415
+ }
416
+
417
+ export function fmtNum(v) {
418
+ const n = Number(v)
419
+ if (!Number.isFinite(n)) return String(v)
420
+ if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(2)}亿`
421
+ if (Math.abs(n) >= 1e4) return `${(n / 1e4).toFixed(2)}万`
422
+ return Number.isInteger(n) ? n.toLocaleString('en-US') : n.toFixed(2)
423
+ }
424
+