@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }