@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.en.md +63 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/channels/dingtalk.js +574 -0
- package/channels/http.js +63 -0
- package/channels/index.js +138 -0
- package/channels/lark.js +1180 -0
- package/channels/message.js +267 -0
- package/channels/telegram.js +663 -0
- package/channels/wecom.js +585 -0
- package/cordis.patch.yml +68 -0
- package/docs/CHANNELS.md +184 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +8 -0
- package/index.js +658 -0
- package/lark-bot.js +9 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +5757 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +95 -4
- package/policy.js +362 -0
- package/service/access.js +239 -0
- package/service/admin.js +555 -0
- package/service/alerts.js +233 -0
- package/service/bots.js +665 -0
- package/service/channels.js +363 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +415 -0
- package/service/evaluation.js +121 -0
- package/service/push.js +163 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nl2SqlService methods: schema.
|
|
3
|
+
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
+
*/
|
|
5
|
+
import { DIALECTS } from '../drivers.js'
|
|
6
|
+
import { annotateSchema, columnMasked, filterSchema, normalizePolicy, policyActive } from '../policy.js'
|
|
7
|
+
import { admitSql } from '../sql-guard.js'
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { DIALECT_HINTS, UserError, classifyColumn, classifyTable, inferRelations, maskValue, newId, requireText, singular, upsert } from './shared.js'
|
|
11
|
+
|
|
12
|
+
/** Character bigrams (whitespace removed, works for Chinese) plus ASCII words of 3+ characters. */
|
|
13
|
+
export function textGrams(text) {
|
|
14
|
+
const s = String(text ?? '').toLowerCase().replace(/\s+/g, '')
|
|
15
|
+
const out = new Set()
|
|
16
|
+
for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2))
|
|
17
|
+
for (const w of String(text ?? '').toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []) out.add(w)
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const methods = {
|
|
22
|
+
// --------------------------------------------------------------- schema
|
|
23
|
+
|
|
24
|
+
async analyzeSchema(id, { refresh = false } = {}) {
|
|
25
|
+
const ds = this.requireDatasource(id)
|
|
26
|
+
if (!refresh) {
|
|
27
|
+
const cached = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
|
|
28
|
+
if (cached) {
|
|
29
|
+
this.schemas.set(ds.id, cached)
|
|
30
|
+
return cached
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const settings = this.settingsFor(ds)
|
|
34
|
+
const raw = await this.withDriver(ds, d => d.introspect(Math.max(settings.timeoutMs, 30000)))
|
|
35
|
+
const tables = raw.tables.map(t => ({ ...t, columns: [], indexes: [] }))
|
|
36
|
+
const byName = new Map(tables.map(t => [t.name, t]))
|
|
37
|
+
for (const c of raw.columns) {
|
|
38
|
+
const table = byName.get(c.table)
|
|
39
|
+
if (!table) continue
|
|
40
|
+
const { table: _t, ...col } = c
|
|
41
|
+
col.roles = classifyColumn(col, table)
|
|
42
|
+
table.columns.push(col)
|
|
43
|
+
}
|
|
44
|
+
const indexMap = new Map()
|
|
45
|
+
for (const i of raw.indexes) {
|
|
46
|
+
const k = `${i.table}\u0000${i.name}`
|
|
47
|
+
if (!indexMap.has(k)) indexMap.set(k, { table: i.table, name: i.name, unique: i.unique, columns: [] })
|
|
48
|
+
indexMap.get(k).columns.push(i.column)
|
|
49
|
+
}
|
|
50
|
+
for (const idx of indexMap.values()) byName.get(idx.table)?.indexes.push({ name: idx.name, unique: idx.unique, columns: idx.columns })
|
|
51
|
+
const fkRows = raw.foreignKeys
|
|
52
|
+
const versionRows = [{ version: raw.version }]
|
|
53
|
+
const declared = fkRows.map(r => ({ ...r, kind: 'declared', confidence: 1 }))
|
|
54
|
+
const relations = [...declared, ...inferRelations(tables, declared)]
|
|
55
|
+
for (const t of tables) t.role = classifyTable(t, relations)
|
|
56
|
+
|
|
57
|
+
// Enum value sampling, bounded and opt-out through settings.
|
|
58
|
+
if (settings.sampleMode !== 'off') {
|
|
59
|
+
const jobs = []
|
|
60
|
+
for (const t of tables) {
|
|
61
|
+
if (t.type === 'VIEW' || (t.rows ?? 0) > 2_000_000) continue
|
|
62
|
+
for (const c of t.columns) {
|
|
63
|
+
const cheapText = ds.type === 'sqlite' && c.roles.includes('text') && (t.rows ?? 0) <= 200000
|
|
64
|
+
if ((!c.roles.includes('enum') && !cheapText) || c.roles.includes('sensitive')) continue
|
|
65
|
+
jobs.push({ t, c })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const driver = await this.driver(ds)
|
|
69
|
+
for (const { t, c } of jobs.slice(0, 80)) {
|
|
70
|
+
try {
|
|
71
|
+
const rows = await driver.sampleColumn(t.name, c.name)
|
|
72
|
+
c.distinct = rows.length > 12 ? '>12' : rows.length
|
|
73
|
+
c.values = rows.slice(0, 12)
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const schema = {
|
|
78
|
+
datasourceId: ds.id,
|
|
79
|
+
dialect: ds.type ?? 'mysql',
|
|
80
|
+
database: ds.database,
|
|
81
|
+
version: versionRows[0]?.version ?? null,
|
|
82
|
+
analyzedAt: Date.now(),
|
|
83
|
+
tables,
|
|
84
|
+
relations,
|
|
85
|
+
}
|
|
86
|
+
const previous = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
|
|
87
|
+
if (previous && settings.schemaWatch !== false) {
|
|
88
|
+
try { this.recordSchemaChange(ds, previous, schema) } catch (error) { console.warn('nl2sql: schema diff failed: %s', error.message) }
|
|
89
|
+
}
|
|
90
|
+
this.schemas.set(ds.id, schema)
|
|
91
|
+
ds.schemaAnalyzedAt = schema.analyzedAt
|
|
92
|
+
if (raw.version && !ds.serverVersion) ds.serverVersion = raw.version
|
|
93
|
+
await writeFile(join(this.dir, `schema-${ds.id}.json`), JSON.stringify(schema), { mode: 0o600 })
|
|
94
|
+
await this.save()
|
|
95
|
+
return schema
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async readSchemaFile(id) {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(await readFile(join(this.dir, `schema-${id}.json`), 'utf8'))
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
policyFor(ds) {
|
|
107
|
+
return normalizePolicy(ds?.policy ?? null)
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
/** Schema restricted to what the access policy exposes (model, tools, knowledge drafts). */
|
|
111
|
+
async visibleSchema(ds, opts) {
|
|
112
|
+
return filterSchema(await this.analyzeSchema(ds.id, opts), this.policyFor(ds))
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/** Full schema annotated with policy flags (page only). */
|
|
116
|
+
async annotatedSchema(id, opts) {
|
|
117
|
+
const ds = this.requireDatasource(id)
|
|
118
|
+
return annotateSchema(await this.analyzeSchema(ds.id, opts), this.policyFor(ds))
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Rank tables by relevance to a question: character-bigram / word overlap
|
|
123
|
+
* with the table name, comment, note, columns, plus glossary / metric /
|
|
124
|
+
* verified-example knowledge that references the table, plus a prior for
|
|
125
|
+
* fact tables and large tables. Returns [{ table, score }] best first.
|
|
126
|
+
*/
|
|
127
|
+
rankTables(schema, ds, question) {
|
|
128
|
+
// Ranking uses non-ASCII bigrams and whole ASCII words (singular), so short Latin fragments don't match everything.
|
|
129
|
+
const grams = (text) => {
|
|
130
|
+
const out = new Set()
|
|
131
|
+
for (const x of textGrams(text)) if (x.length > 2 || /[^\x00-\x7f]/.test(x)) out.add(x.length > 3 ? singular(x) : x)
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
const q = grams(question)
|
|
135
|
+
const k = this.knowledgeFor(ds.id)
|
|
136
|
+
const cover = (grams) => {
|
|
137
|
+
if (!q.size) return 0
|
|
138
|
+
let hit = 0
|
|
139
|
+
for (const x of q) if (grams.has(x)) hit++
|
|
140
|
+
return hit / q.size
|
|
141
|
+
}
|
|
142
|
+
const mentions = (text, t) => {
|
|
143
|
+
const short = t.name.split('.').pop().toLowerCase()
|
|
144
|
+
return new RegExp(`(^|[^\\w])${short.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w]|$)`).test(String(text ?? '').toLowerCase())
|
|
145
|
+
}
|
|
146
|
+
const found = question ? this.searchKnowledge(ds.id, question, 20) : { examples: [], glossary: [], metrics: [] }
|
|
147
|
+
const prior = { fact: 0.15, dimension: 0.08, view: 0.04, other: 0, log: -0.05, config: -0.05 }
|
|
148
|
+
return schema.tables.map(t => {
|
|
149
|
+
const label = `${t.name.replace(/[_.]+/g, ' ')} ${t.comment ?? ''} ${k.notes[t.name] ?? ''}`
|
|
150
|
+
const cols = t.columns.map(c => `${c.name.replace(/_+/g, ' ')} ${c.comment ?? ''} ${k.notes[`${t.name}.${c.name}`] ?? ''}`).join(' ')
|
|
151
|
+
let score = 2 * cover(grams(label)) + 0.7 * cover(grams(cols))
|
|
152
|
+
for (const m of found.metrics) if (m.table && (m.table === t.name || mentions(m.table, t))) score += 1.5 * m.score + 0.3
|
|
153
|
+
for (const g of found.glossary) if (mentions(`${g.sql} ${g.definition}`, t)) score += g.score + 0.2
|
|
154
|
+
for (const e of found.examples) if (mentions(e.sql, t)) score += e.score + 0.2
|
|
155
|
+
score += prior[t.role] ?? 0
|
|
156
|
+
score += 0.05 * Math.min(1, Math.log10((t.rows ?? 0) + 1) / 8)
|
|
157
|
+
return { table: t, score }
|
|
158
|
+
}).sort((a, b) => b.score - a.score || (b.table.rows ?? 0) - (a.table.rows ?? 0))
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
/** Compact schema text for the model, honoring the sampling setting. */
|
|
162
|
+
schemaForModel(schema, ds, tableNames, { question } = {}) {
|
|
163
|
+
const settings = this.settingsFor(ds)
|
|
164
|
+
const lines = []
|
|
165
|
+
if (!tableNames || tableNames.length === 0) {
|
|
166
|
+
const dialect = DIALECTS[ds.type ?? 'mysql']
|
|
167
|
+
lines.push(`${ds.type === 'elasticsearch' ? 'index pattern' : 'database'}: ${schema.database} (${dialect.label} ${schema.version ?? '?'}; write SQL in the ${dialect.sqlName} dialect), ${schema.tables.length} ${ds.type === 'elasticsearch' ? 'indices' : 'tables'}, analyzed ${new Date(schema.analyzedAt).toISOString()}`)
|
|
168
|
+
lines.push(DIALECT_HINTS[ds.type ?? 'mysql'])
|
|
169
|
+
if (policyActive(this.policyFor(ds))) lines.push('access control: only the tables and columns listed here may be queried; hidden columns are omitted and system catalogs are blocked. Do not try to work around it.')
|
|
170
|
+
const max = settings.schemaOverviewMax ?? 80
|
|
171
|
+
const large = schema.tables.length > max
|
|
172
|
+
let detailed = schema.tables
|
|
173
|
+
if (large) {
|
|
174
|
+
const top = Math.max(1, settings.schemaOverviewTop ?? 15)
|
|
175
|
+
const ranked = this.rankTables(schema, ds, question)
|
|
176
|
+
detailed = ranked.slice(0, top).map(r => r.table)
|
|
177
|
+
lines.push(`large schema (${schema.tables.length} tables > ${max}): every table is listed by name only; ${detailed.length} tables ${question ? 'most relevant to the question' : 'ranked by role and size (pass `question` for relevance)'} are described in full below. Call nl2sql_schema with \`tables\` to read any other table before using it.`)
|
|
178
|
+
lines.push('all tables (table | role | ~rows):')
|
|
179
|
+
for (const t of schema.tables) lines.push(`${t.name} | ${t.role} | ${t.rows ?? '?'}`)
|
|
180
|
+
lines.push(`most relevant tables${question ? ` for: ${String(question).slice(0, 200)}` : ''}:`)
|
|
181
|
+
}
|
|
182
|
+
lines.push('table | role | ~rows | comment | columns(name:type[roles])')
|
|
183
|
+
for (const t of detailed) {
|
|
184
|
+
const cols = t.columns.map(c => `${c.name}:${c.dataType}${c.roles.length ? `[${c.roles.join(',')}]` : ''}`).join(', ')
|
|
185
|
+
const tnote = this.knowledgeFor(ds.id).notes[t.name]
|
|
186
|
+
lines.push(`${t.name} | ${t.role} | ${t.rows ?? '?'} | ${[t.comment, tnote].filter(Boolean).join('; ') || '-'} | ${cols}`)
|
|
187
|
+
}
|
|
188
|
+
const shown = new Set(detailed.map(t => t.name))
|
|
189
|
+
const rels = large ? schema.relations.filter(r => shown.has(r.from.table) || shown.has(r.to.table)) : schema.relations
|
|
190
|
+
if (rels.length) {
|
|
191
|
+
lines.push('relations (from -> to, kind, confidence):')
|
|
192
|
+
for (const r of rels) lines.push(`${r.from.table}.${r.from.column} -> ${r.to.table}.${r.to.column} (${r.kind}, ${r.confidence})`)
|
|
193
|
+
}
|
|
194
|
+
const k = this.knowledgeFor(ds.id)
|
|
195
|
+
if (ds.description) lines.push(`business context: ${ds.description}`)
|
|
196
|
+
if (k.glossary.length) {
|
|
197
|
+
lines.push('glossary (business term => meaning / SQL hint):')
|
|
198
|
+
for (const g of k.glossary) lines.push(`- ${g.term}${g.synonyms?.length ? ` (${g.synonyms.join('/')})` : ''} => ${g.definition}${g.sql ? ` [SQL: ${g.sql}]` : ''}`)
|
|
199
|
+
}
|
|
200
|
+
if (k.metrics.length) {
|
|
201
|
+
lines.push('metrics (use these definitions exactly when the user asks for them):')
|
|
202
|
+
for (const m of k.metrics) lines.push(`- ${m.name}${m.unit ? ` (${m.unit})` : ''}: ${m.expression}${m.table ? ` FROM ${m.table}` : ''}${m.filter ? ` WHERE ${m.filter}` : ''}${m.timeColumn ? `; time column ${m.timeColumn}` : ''}${m.description ? ` — ${m.description}` : ''}`)
|
|
203
|
+
}
|
|
204
|
+
if (k.examples.length) lines.push(`${k.examples.length} verified question→SQL examples exist; call nl2sql_knowledge action=search with the user's question to reuse them.`)
|
|
205
|
+
lines.push('Call nl2sql_schema with `tables` for full column comments, indexes and enum values.')
|
|
206
|
+
return lines.join('\n')
|
|
207
|
+
}
|
|
208
|
+
const notes = this.knowledgeFor(ds.id).notes
|
|
209
|
+
const wanted = new Set(tableNames.map(n => n.toLowerCase()))
|
|
210
|
+
const missing = tableNames.filter(n => !schema.tables.some(t => t.name.toLowerCase() === n.toLowerCase()))
|
|
211
|
+
for (const t of schema.tables) {
|
|
212
|
+
if (!wanted.has(t.name.toLowerCase())) continue
|
|
213
|
+
lines.push(`## ${t.name} (${t.type.toLowerCase()}, role=${t.role}, ~${t.rows ?? '?'} rows)${t.comment ? ` — ${t.comment}` : ''}`)
|
|
214
|
+
if (notes[t.name]) lines.push(`note: ${notes[t.name]}`)
|
|
215
|
+
for (const c of t.columns) {
|
|
216
|
+
const note = notes[`${t.name}.${c.name}`]
|
|
217
|
+
let line = `- ${c.name} ${c.columnType}${c.nullable ? '' : ' NOT NULL'}${c.key ? ` ${c.key}` : ''}${c.roles.length ? ` [${c.roles.join(',')}]` : ''}${c.comment ? ` — ${c.comment}` : ''}${note ? ` (note: ${note})` : ''}`
|
|
218
|
+
if (c.values && settings.sampleMode !== 'off' && !(settings.maskSensitive && c.roles.includes('sensitive'))) {
|
|
219
|
+
line += ` values: ${c.values.map(v => `${JSON.stringify(v.value)}(${v.count})`).join(', ')}${c.distinct === '>12' ? ', …' : ''}`
|
|
220
|
+
}
|
|
221
|
+
lines.push(line)
|
|
222
|
+
}
|
|
223
|
+
if (t.indexes.length) lines.push(`indexes: ${t.indexes.map(i => `${i.unique ? 'UNIQUE ' : ''}${i.name}(${i.columns.join(',')})`).join('; ')}`)
|
|
224
|
+
const rel = schema.relations.filter(r => r.from.table === t.name || r.to.table === t.name)
|
|
225
|
+
if (rel.length) lines.push(`relations: ${rel.map(r => `${r.from.table}.${r.from.column}->${r.to.table}.${r.to.column}(${r.kind})`).join('; ')}`)
|
|
226
|
+
lines.push('')
|
|
227
|
+
}
|
|
228
|
+
if (missing.length) lines.push(`unknown tables: ${missing.join(', ')}`)
|
|
229
|
+
return lines.join('\n')
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
async sampleRows(ds, table, limit, actor) {
|
|
233
|
+
const settings = this.settingsFor(ds)
|
|
234
|
+
if (actor && actor.kind !== 'owner' && (this.rowRulesFor(actor, ds).length || actor.role === 'viewer')) throw new UserError('nl2sql/row-policy', '当前成员不能查看原始样例数据行')
|
|
235
|
+
if (settings.sampleMode !== 'all') throw new UserError('nl2sql/privacy', '当前隐私设置不允许把样例数据行发送给模型(需将“表结构抽样”设为“全部抽样”)。')
|
|
236
|
+
const schema = await this.visibleSchema(ds)
|
|
237
|
+
const t = schema.tables.find(x => x.name.toLowerCase() === table.toLowerCase())
|
|
238
|
+
if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
|
|
239
|
+
const res = await this.withDriver(ds, d => d.query(d.previewSql(t.name, Math.min(Math.max(limit, 1), 20)), { timeoutMs: settings.timeoutMs, maxRows: 20 }))
|
|
240
|
+
const keep = res.columns.map((c, i) => (t.columns.some(x => x.name === c.name) ? i : -1)).filter(i => i >= 0)
|
|
241
|
+
const columns = keep.map(i => res.columns[i].name)
|
|
242
|
+
const masked = res.rows.map(r => keep.map(i => r[i]).map((cell, i) => {
|
|
243
|
+
const col = t.columns.find(c => c.name === columns[i])
|
|
244
|
+
const forced = columnMasked(this.policyFor(ds), t.name, columns[i]) || col?.policyMasked
|
|
245
|
+
return forced || (settings.maskSensitive && col?.roles.includes('sensitive')) ? maskValue(columns[i], cell) : cell
|
|
246
|
+
}))
|
|
247
|
+
return { columns, rows: masked }
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// ------------------------------------------------------ schema changes
|
|
251
|
+
|
|
252
|
+
/** Compare two analyzed schemas: added/removed tables, added/removed/changed columns. */
|
|
253
|
+
diffSchemas(before, after) {
|
|
254
|
+
const tb = new Map(before.tables.map(t => [t.name, t]))
|
|
255
|
+
const ta = new Map(after.tables.map(t => [t.name, t]))
|
|
256
|
+
const changes = []
|
|
257
|
+
for (const [name, t] of ta) if (!tb.has(name)) changes.push({ kind: 'table-added', table: name, detail: t.comment || '' })
|
|
258
|
+
for (const [name] of tb) if (!ta.has(name)) changes.push({ kind: 'table-removed', table: name })
|
|
259
|
+
for (const [name, t] of ta) {
|
|
260
|
+
const old = tb.get(name)
|
|
261
|
+
if (!old) continue
|
|
262
|
+
const oc = new Map(old.columns.map(c => [c.name, c]))
|
|
263
|
+
const nc = new Map(t.columns.map(c => [c.name, c]))
|
|
264
|
+
for (const [cn, c] of nc) if (!oc.has(cn)) changes.push({ kind: 'column-added', table: name, column: cn, detail: c.columnType })
|
|
265
|
+
for (const [cn, c] of oc) if (!nc.has(cn)) changes.push({ kind: 'column-removed', table: name, column: cn, detail: c.columnType })
|
|
266
|
+
for (const [cn, c] of nc) {
|
|
267
|
+
const o = oc.get(cn)
|
|
268
|
+
if (o && o.columnType !== c.columnType) changes.push({ kind: 'column-type', table: name, column: cn, detail: `${o.columnType} → ${c.columnType}` })
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return changes
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/** Things that reference removed/changed objects: widgets, examples, metrics, notes, alerts, policy. */
|
|
275
|
+
schemaImpacts(ds, changes) {
|
|
276
|
+
const removedTables = new Set(changes.filter(c => c.kind === 'table-removed').map(c => c.table.toLowerCase()))
|
|
277
|
+
const removedCols = changes.filter(c => c.kind === 'column-removed' || c.kind === 'column-type')
|
|
278
|
+
const hits = []
|
|
279
|
+
const touches = (sql) => {
|
|
280
|
+
if (!sql) return []
|
|
281
|
+
const low = String(sql).toLowerCase()
|
|
282
|
+
const out = []
|
|
283
|
+
for (const t of removedTables) if (new RegExp(`(^|[^\\w])${t.split('.').pop().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w]|$)`).test(low)) out.push(`表 ${t}`)
|
|
284
|
+
for (const c of removedCols) if (new RegExp(`(^|[^\\w])${c.column.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w]|$)`).test(low) && low.includes(c.table.split('.').pop().toLowerCase())) out.push(`${c.table}.${c.column}`)
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
for (const d of this.state.dashboards) for (const w of d.widgets) if (w.datasourceId === ds.id) { const t = touches(w.sql); if (t.length) hits.push({ kind: 'widget', id: w.id, name: `${d.name} / ${w.title}`, refs: t }) }
|
|
288
|
+
const k = this.knowledgeFor(ds.id)
|
|
289
|
+
for (const e of k.examples) { const t = touches(e.sql); if (t.length) hits.push({ kind: 'example', id: e.id, name: e.question, refs: t }) }
|
|
290
|
+
for (const m of k.metrics) { const t = touches(`${m.expression} ${m.table} ${m.filter} ${m.timeColumn}`); if (t.length) hits.push({ kind: 'metric', id: m.id, name: m.name, refs: t }) }
|
|
291
|
+
for (const key of Object.keys(k.notes)) { const [tb, col] = key.split('.'); if (removedTables.has(tb.toLowerCase()) || removedCols.some(c => c.table === tb && c.column === col)) hits.push({ kind: 'note', id: key, name: key, refs: [key] }) }
|
|
292
|
+
for (const a of this.state.alerts ?? []) if (a.datasourceId === ds.id) { const t = touches(a.sql); if (t.length) hits.push({ kind: 'alert', id: a.id, name: a.name, refs: t }) }
|
|
293
|
+
return hits
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
recordSchemaChange(ds, before, after) {
|
|
297
|
+
const changes = this.diffSchemas(before, after)
|
|
298
|
+
if (!changes.length) return undefined
|
|
299
|
+
const entry = { id: newId('sc'), at: Date.now(), datasourceId: ds.id, datasourceName: ds.name, changes: changes.slice(0, 500), impacts: this.schemaImpacts(ds, changes), status: 'open' }
|
|
300
|
+
this.records.put('schemaChange', entry)
|
|
301
|
+
try { this.store?.putSnapshot({ id: newId('ss'), kind: 'schema', ref: ds.id, createdAt: before.analyzedAt ?? Date.now(), body: { analyzedAt: before.analyzedAt, tables: before.tables.map(t => ({ name: t.name, columns: t.columns.map(c => ({ name: c.name, columnType: c.columnType })) })) } }) } catch {}
|
|
302
|
+
this.onSchemaChange?.(entry)
|
|
303
|
+
if (this.state.settings.schemaNotifyChannel) {
|
|
304
|
+
const lines = entry.changes.slice(0, 15).map(c => `- ${{ 'table-added': '新增表', 'table-removed': '删除表', 'column-added': '新增字段', 'column-removed': '删除字段', 'column-type': '类型变更' }[c.kind]}:${c.table}${c.column ? `.${c.column}` : ''}${c.detail ? `(${c.detail})` : ''}`)
|
|
305
|
+
const imp = entry.impacts.length ? `\n\n**受影响(${entry.impacts.length})**\n${entry.impacts.slice(0, 10).map(i => `- ${i.name}`).join('\n')}` : ''
|
|
306
|
+
this.sendToChannel({ channelId: this.state.settings.schemaNotifyChannel, title: `表结构变更:${ds.name}`, text: `${lines.join('\n')}${imp}`, source: 'schema' }).catch(() => {})
|
|
307
|
+
}
|
|
308
|
+
return entry
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
listSchemaChanges({ datasourceId, status } = {}) {
|
|
312
|
+
return this.records.list('schemaChange', { ref: datasourceId, status, limit: 200 })
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async ackSchemaChange({ id, status = 'acked' }) {
|
|
316
|
+
const c = this.records.get('schemaChange', id)
|
|
317
|
+
if (!c) throw new UserError('nl2sql/invalid', '变更记录不存在')
|
|
318
|
+
c.status = status === 'open' ? 'open' : 'acked'
|
|
319
|
+
c.ackedAt = Date.now()
|
|
320
|
+
this.records.put('schemaChange', c)
|
|
321
|
+
await this.save()
|
|
322
|
+
return c
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
/** Re-analyze every data source (scheduled schema watch). */
|
|
326
|
+
async checkAllSchemas() {
|
|
327
|
+
const out = []
|
|
328
|
+
for (const ds of this.state.datasources) {
|
|
329
|
+
const before = this.records.count('schemaChange', { ref: ds.id })
|
|
330
|
+
try {
|
|
331
|
+
await this.analyzeSchema(ds.id, { refresh: true })
|
|
332
|
+
out.push({ datasourceId: ds.id, name: ds.name, ok: true, changes: this.records.count('schemaChange', { ref: ds.id }) - before })
|
|
333
|
+
} catch (error) {
|
|
334
|
+
out.push({ datasourceId: ds.id, name: ds.name, ok: false, error: error.message })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return out
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
// --------------------------------------------------- knowledge import/export
|
|
341
|
+
|
|
342
|
+
exportKnowledge({ datasourceId, kinds } = {}) {
|
|
343
|
+
const ids = datasourceId ? [this.requireDatasource(datasourceId).id] : this.state.datasources.map(d => d.id)
|
|
344
|
+
const want = new Set(kinds?.length ? kinds : ['glossary', 'metric', 'note', 'example'])
|
|
345
|
+
return {
|
|
346
|
+
format: 'dsh-nl2sql-knowledge',
|
|
347
|
+
version: 1,
|
|
348
|
+
exportedAt: new Date().toISOString(),
|
|
349
|
+
datasources: ids.map(id => {
|
|
350
|
+
const ds = this.findDatasource(id)
|
|
351
|
+
const k = this.knowledgeFor(id)
|
|
352
|
+
return {
|
|
353
|
+
name: ds.name, type: ds.type ?? 'mysql', database: ds.database,
|
|
354
|
+
...(want.has('glossary') ? { glossary: k.glossary.map(({ id: _i, ...g }) => g) } : {}),
|
|
355
|
+
...(want.has('metric') ? { metrics: k.metrics.map(({ id: _i, ...m }) => m) } : {}),
|
|
356
|
+
...(want.has('note') ? { notes: k.notes } : {}),
|
|
357
|
+
...(want.has('example') ? { examples: k.examples.map(({ id: _i, ...e }) => e) } : {}),
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Import knowledge exported by exportKnowledge. Each source block maps onto
|
|
365
|
+
* `targetDatasourceId` (single block) or the data source with the same name.
|
|
366
|
+
* mode: merge (default; same term / metric name / question replaced) or replace.
|
|
367
|
+
*/
|
|
368
|
+
async importKnowledge({ data, targetDatasourceId, mode = 'merge', dryRun = false }) {
|
|
369
|
+
const doc = typeof data === 'string' ? JSON.parse(data) : data
|
|
370
|
+
if (doc?.format !== 'dsh-nl2sql-knowledge' || !Array.isArray(doc.datasources)) throw new UserError('nl2sql/invalid', '不是有效的业务知识导出文件(format 应为 dsh-nl2sql-knowledge)')
|
|
371
|
+
const report = []
|
|
372
|
+
for (const block of doc.datasources) {
|
|
373
|
+
const ds = targetDatasourceId && doc.datasources.length === 1 ? this.requireDatasource(targetDatasourceId) : this.findDatasource(block.name)
|
|
374
|
+
if (!ds) { report.push({ source: block.name, skipped: '找不到同名数据源,请指定目标数据源' }); continue }
|
|
375
|
+
const k = mode === 'replace' ? { glossary: [], metrics: [], notes: {}, examples: [] } : this.knowledgeFor(ds.id)
|
|
376
|
+
const count = { glossary: 0, metrics: 0, notes: 0, examples: 0, invalid: 0 }
|
|
377
|
+
for (const g of block.glossary ?? []) {
|
|
378
|
+
if (!g?.term || !g?.definition) { count.invalid++; continue }
|
|
379
|
+
const prev = k.glossary.find(x => x.term === g.term)
|
|
380
|
+
k.glossary = upsert(k.glossary, { id: prev?.id ?? newId('g'), term: String(g.term).slice(0, 60), synonyms: (g.synonyms ?? []).map(String).slice(0, 10), definition: String(g.definition).slice(0, 500), sql: String(g.sql ?? '').slice(0, 500) })
|
|
381
|
+
count.glossary++
|
|
382
|
+
}
|
|
383
|
+
for (const m of block.metrics ?? []) {
|
|
384
|
+
if (!m?.name || !m?.expression) { count.invalid++; continue }
|
|
385
|
+
const prev = k.metrics.find(x => x.name === m.name)
|
|
386
|
+
k.metrics = upsert(k.metrics, { id: prev?.id ?? newId('m'), name: String(m.name).slice(0, 60), expression: String(m.expression).slice(0, 500), table: String(m.table ?? '').slice(0, 64), filter: String(m.filter ?? '').slice(0, 500), timeColumn: String(m.timeColumn ?? '').slice(0, 64), unit: String(m.unit ?? '').slice(0, 20), description: String(m.description ?? '').slice(0, 500), dimensions: Array.isArray(m.dimensions) ? m.dimensions.map(String).slice(0, 20) : [] })
|
|
387
|
+
count.metrics++
|
|
388
|
+
}
|
|
389
|
+
for (const [key, text] of Object.entries(block.notes ?? {})) { if (typeof text === 'string' && text.trim()) { k.notes[String(key).slice(0, 200)] = text.slice(0, 1000); count.notes++ } }
|
|
390
|
+
for (const e of block.examples ?? []) {
|
|
391
|
+
try { admitSql(e?.sql ?? '', { dialect: ds.type ?? 'mysql' }) } catch { count.invalid++; continue }
|
|
392
|
+
if (!e?.question) { count.invalid++; continue }
|
|
393
|
+
const prev = k.examples.find(x => x.question === e.question)
|
|
394
|
+
k.examples = upsert(k.examples, { id: prev?.id ?? newId('e'), question: String(e.question).slice(0, 500), sql: e.sql, note: String(e.note ?? '').slice(0, 500), createdAt: e.createdAt ?? Date.now() }).slice(-500)
|
|
395
|
+
count.examples++
|
|
396
|
+
}
|
|
397
|
+
if (!dryRun) this.state.knowledge[ds.id] = k
|
|
398
|
+
report.push({ source: block.name, target: ds.name, ...count })
|
|
399
|
+
}
|
|
400
|
+
if (!dryRun) await this.save()
|
|
401
|
+
return { dryRun, mode, report }
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
// ------------------------------------------------------- semantic layer
|
|
405
|
+
|
|
406
|
+
knowledgeFor(id) {
|
|
407
|
+
const k = this.state.knowledge[id] ?? {}
|
|
408
|
+
return { glossary: k.glossary ?? [], metrics: k.metrics ?? [], notes: k.notes ?? {}, examples: k.examples ?? [] }
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
getKnowledge(datasourceId) {
|
|
412
|
+
this.requireDatasource(datasourceId)
|
|
413
|
+
return this.knowledgeFor(datasourceId)
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
async saveKnowledgeItem({ datasourceId, kind, item }) {
|
|
417
|
+
const ds = this.requireDatasource(datasourceId)
|
|
418
|
+
const k = this.knowledgeFor(ds.id)
|
|
419
|
+
if (kind === 'note') {
|
|
420
|
+
const key = requireText(item?.key, 'key', 200)
|
|
421
|
+
const text = String(item?.text ?? '').trim().slice(0, 1000)
|
|
422
|
+
if (text) k.notes[key] = text
|
|
423
|
+
else delete k.notes[key]
|
|
424
|
+
} else if (kind === 'glossary') {
|
|
425
|
+
const entry = {
|
|
426
|
+
id: item?.id || newId('g'),
|
|
427
|
+
term: requireText(item?.term, 'term', 60),
|
|
428
|
+
synonyms: (Array.isArray(item?.synonyms) ? item.synonyms : String(item?.synonyms ?? '').split(/[,,、]/)).map(x => String(x).trim()).filter(Boolean).slice(0, 10),
|
|
429
|
+
definition: requireText(item?.definition, 'definition', 500),
|
|
430
|
+
sql: String(item?.sql ?? '').trim().slice(0, 500),
|
|
431
|
+
}
|
|
432
|
+
k.glossary = upsert(k.glossary, entry)
|
|
433
|
+
} else if (kind === 'metric') {
|
|
434
|
+
const entry = {
|
|
435
|
+
id: item?.id || newId('m'),
|
|
436
|
+
name: requireText(item?.name, 'name', 60),
|
|
437
|
+
expression: requireText(item?.expression, 'expression', 500),
|
|
438
|
+
table: String(item?.table ?? '').trim().slice(0, 64),
|
|
439
|
+
filter: String(item?.filter ?? '').trim().slice(0, 500),
|
|
440
|
+
timeColumn: String(item?.timeColumn ?? '').trim().slice(0, 64),
|
|
441
|
+
unit: String(item?.unit ?? '').trim().slice(0, 20),
|
|
442
|
+
description: String(item?.description ?? '').trim().slice(0, 500),
|
|
443
|
+
dimensions: (Array.isArray(item?.dimensions) ? item.dimensions : String(item?.dimensions ?? '').split(/[,,、\s]+/)).map(x => String(x).trim()).filter(Boolean).slice(0, 20),
|
|
444
|
+
synonyms: (Array.isArray(item?.synonyms) ? item.synonyms : String(item?.synonyms ?? '').split(/[,,、]/)).map(x => String(x).trim()).filter(Boolean).slice(0, 10),
|
|
445
|
+
}
|
|
446
|
+
k.metrics = upsert(k.metrics, entry)
|
|
447
|
+
} else if (kind === 'example') {
|
|
448
|
+
const sql = requireText(item?.sql, 'sql', 20000)
|
|
449
|
+
admitSql(sql, { dialect: ds.type ?? 'mysql' })
|
|
450
|
+
const entry = { id: item?.id || newId('e'), question: requireText(item?.question, 'question', 500), sql, note: String(item?.note ?? '').slice(0, 500), createdAt: item?.createdAt ?? Date.now() }
|
|
451
|
+
k.examples = upsert(k.examples, entry).slice(-500)
|
|
452
|
+
} else {
|
|
453
|
+
throw new UserError('nl2sql/invalid', `unknown knowledge kind ${kind}`)
|
|
454
|
+
}
|
|
455
|
+
this.state.knowledge[ds.id] = k
|
|
456
|
+
await this.save()
|
|
457
|
+
return k
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
async deleteKnowledgeItem({ datasourceId, kind, id }) {
|
|
461
|
+
const ds = this.requireDatasource(datasourceId)
|
|
462
|
+
const k = this.knowledgeFor(ds.id)
|
|
463
|
+
if (kind === 'note') delete k.notes[id]
|
|
464
|
+
else if (kind === 'glossary') k.glossary = k.glossary.filter(x => x.id !== id)
|
|
465
|
+
else if (kind === 'metric') k.metrics = k.metrics.filter(x => x.id !== id)
|
|
466
|
+
else if (kind === 'example') k.examples = k.examples.filter(x => x.id !== id)
|
|
467
|
+
this.state.knowledge[ds.id] = k
|
|
468
|
+
await this.save()
|
|
469
|
+
return k
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
/** Rank stored examples and glossary/metric entries against a question (character bigram overlap, works for Chinese). */
|
|
473
|
+
searchKnowledge(datasourceId, question, limit = 5) {
|
|
474
|
+
const k = this.knowledgeFor(datasourceId)
|
|
475
|
+
const grams = textGrams
|
|
476
|
+
const q = grams(question)
|
|
477
|
+
const score = (text) => {
|
|
478
|
+
const g = grams(text)
|
|
479
|
+
let hit = 0
|
|
480
|
+
for (const x of g) if (q.has(x)) hit++
|
|
481
|
+
return g.size === 0 ? 0 : hit / Math.sqrt(g.size * Math.max(q.size, 1))
|
|
482
|
+
}
|
|
483
|
+
const rank = (list, textOf) => list.map(x => ({ x, s: score(textOf(x)) })).filter(r => r.s > 0.08).sort((a, b) => b.s - a.s).slice(0, limit).map(r => ({ ...r.x, score: Number(r.s.toFixed(3)) }))
|
|
484
|
+
return {
|
|
485
|
+
examples: rank(k.examples, e => e.question),
|
|
486
|
+
glossary: rank(k.glossary, g => `${g.term} ${(g.synonyms ?? []).join(' ')} ${g.definition}`),
|
|
487
|
+
metrics: rank(k.metrics, m => `${m.name} ${m.description}`),
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
|
|
491
|
+
/** Draft table/column notes from comments and enum samples, for the user to review. */
|
|
492
|
+
async draftNotes(datasourceId) {
|
|
493
|
+
const ds = this.requireDatasource(datasourceId)
|
|
494
|
+
const schema = await this.visibleSchema(ds)
|
|
495
|
+
const k = this.knowledgeFor(ds.id)
|
|
496
|
+
const drafts = []
|
|
497
|
+
for (const t of schema.tables) {
|
|
498
|
+
for (const c of t.columns) {
|
|
499
|
+
const key = `${t.name}.${c.name}`
|
|
500
|
+
if (k.notes[key] || !c.values?.length || c.values.length > 12) continue
|
|
501
|
+
if (!/(status|state|type|kind|level|状态|类型)/i.test(c.name + c.comment)) continue
|
|
502
|
+
drafts.push({ key, text: `取值:${c.values.map(v => `${v.value}`).join(' / ')}${c.comment ? `(${c.comment})` : ''},请补充每个取值的业务含义` })
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return drafts.slice(0, 60)
|
|
506
|
+
},
|
|
507
|
+
}
|