@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,772 @@
1
+ /**
2
+ * Nl2SqlService methods: knowledge suggestions mined from usage.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ *
5
+ * Deterministic (no model): 👍 / corrected 👎 feedback and repeated query
6
+ * shapes become example candidates; recurring aggregates become metric
7
+ * candidates; recurring CASE WHEN / aliased value mappings on enum columns
8
+ * become column notes; recurring words of question titles that point at one
9
+ * table / column become glossary drafts. A human accepts or dismisses each.
10
+ */
11
+ import { parseSelect } from '../analysis.js'
12
+ import { checkAccess, filterSchema, referencedTables } from '../policy.js'
13
+ import { admitSql, lexSql } from '../sql-guard.js'
14
+ import { textGrams } from './schema.js'
15
+ import { SENSITIVE_NAME, UserError, hashText } from './shared.js'
16
+
17
+ /** Thresholds (see DEVELOPMENT.md). */
18
+ export const SUGGEST = {
19
+ shapeMin: 2, // agent queries with the same literal-free shape
20
+ metricMin: 3, // distinct successful queries using the same aggregate + filter on one table
21
+ noteMin: 2, // distinct queries mapping values of one enum column
22
+ termMin: 3, // distinct question titles containing a term
23
+ questionSim: 0.8, // question similarity above which an example counts as existing
24
+ historyScan: 3000,
25
+ feedbackScan: 1000,
26
+ evidence: 5,
27
+ glossaryMax: 15,
28
+ dismissedMax: 2000,
29
+ }
30
+
31
+ const KIND_ORDER = { example: 0, metric: 1, note: 2, glossary: 3 }
32
+ const CJK = /[\u3400-\u9fff]/
33
+ const SQL_KEYWORDS = new Set(['SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'AS', 'ON', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'FULL', 'CROSS', 'GROUP', 'BY', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'DISTINCT', 'BETWEEN', 'LIKE', 'ILIKE', 'ASC', 'DESC', 'UNION', 'ALL', 'INTERVAL', 'USING', 'WITH', 'TRUE', 'FALSE', 'EXISTS', 'OVER', 'PARTITION'])
34
+
35
+ // ------------------------------------------------------------ tokenizer
36
+
37
+ function unquoteString(text, dialect) {
38
+ let s = text
39
+ const e = /^[eE]'/.test(s)
40
+ if (e) s = s.slice(1)
41
+ if (s.startsWith('$')) {
42
+ const m = /^\$[^$]*\$/.exec(s)
43
+ return m ? s.slice(m[0].length, s.length - m[0].length) : s
44
+ }
45
+ const q = s[0]
46
+ let body = s.slice(1, -1).split(q + q).join(q)
47
+ if (dialect === 'mysql' || e) body = body.replace(/\\(.)/g, '$1')
48
+ return body
49
+ }
50
+
51
+ /**
52
+ * Tokens of a statement using the dialect's lexical rules: str, num, name
53
+ * (dotted chains merged, `parts`), open, close, comma, op. Each token keeps
54
+ * its source offsets and parenthesis depth `d`.
55
+ */
56
+ export function sqlTokens(sql, dialect = 'mysql') {
57
+ const src = String(sql ?? '')
58
+ const { segments } = lexSql(src, { dialect })
59
+ const raw = []
60
+ for (const s of segments) {
61
+ if (s.kind === 'comment') continue
62
+ if (s.kind === 'string') { raw.push({ k: 'str', v: unquoteString(s.text, dialect), start: s.start, end: s.end }); continue }
63
+ if (s.kind === 'ident') { const q = s.text[0]; const close = q === '[' ? ']' : q; raw.push({ k: 'id', v: s.text.slice(1, -1).split(close + close).join(close), start: s.start, end: s.end }); continue }
64
+ const text = s.text
65
+ let i = 0
66
+ while (i < text.length) {
67
+ const c = text[i]
68
+ if (/\s/.test(c)) { i++; continue }
69
+ const at = s.start + i
70
+ if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(text[i + 1] ?? '') && !/[\w$\u00c0-\uffff]/.test(text[i - 1] ?? ''))) {
71
+ const m = /^(\d*\.?\d+(?:[eE][+-]?\d+)?|\d+\.)/.exec(text.slice(i))
72
+ const len = m ? m[0].length : 1
73
+ raw.push({ k: 'num', v: text.slice(i, i + len), start: at, end: at + len })
74
+ i += len
75
+ continue
76
+ }
77
+ if (/[A-Za-z_\u00c0-\uffff]/.test(c)) {
78
+ let j = i + 1
79
+ while (j < text.length && /[\w$\u00c0-\uffff]/.test(text[j])) j++
80
+ raw.push({ k: 'word', v: text.slice(i, j), start: at, end: s.start + j })
81
+ i = j
82
+ continue
83
+ }
84
+ const two = text.slice(i, i + 2)
85
+ if (['<=', '>=', '<>', '!=', '||', '::'].includes(two)) { raw.push({ k: 'op', v: two, start: at, end: at + 2 }); i += 2; continue }
86
+ raw.push({ k: c === '(' ? 'open' : c === ')' ? 'close' : c === ',' ? 'comma' : 'op', v: c, start: at, end: at + 1 })
87
+ i++
88
+ }
89
+ }
90
+ const out = []
91
+ for (let i = 0; i < raw.length; i++) {
92
+ const t = raw[i]
93
+ if (t.k === 'word' || t.k === 'id') {
94
+ const parts = [t.v]
95
+ let end = t.end
96
+ let lastStart = t.start
97
+ let j = i
98
+ while (raw[j + 1]?.k === 'op' && raw[j + 1].v === '.' && raw[j + 1].start === end && (raw[j + 2]?.k === 'word' || raw[j + 2]?.k === 'id' || (raw[j + 2]?.k === 'op' && raw[j + 2].v === '*'))) {
99
+ parts.push(raw[j + 2].v)
100
+ end = raw[j + 2].end
101
+ lastStart = raw[j + 2].start
102
+ j += 2
103
+ }
104
+ out.push({ k: 'name', parts, v: parts.join('.'), upper: parts.length === 1 && t.k === 'word' ? t.v.toUpperCase() : '', start: t.start, end, lastStart })
105
+ i = j
106
+ continue
107
+ }
108
+ out.push(t)
109
+ }
110
+ while (out.length && out.at(-1).k === 'op' && out.at(-1).v === ';') out.pop()
111
+ let d = 0
112
+ for (const t of out) {
113
+ if (t.k === 'close') d--
114
+ t.d = d
115
+ if (t.k === 'open') d++
116
+ }
117
+ return out
118
+ }
119
+
120
+ const litText = (t) => (t.k === 'str' ? `'${t.v.replace(/'/g, "''")}'` : Number.isFinite(Number(t.v)) ? String(Number(t.v)) : t.v)
121
+ const isLit = (t) => t?.k === 'str' || t?.k === 'num'
122
+
123
+ /**
124
+ * Literal-free shape: literals → ?, literal lists collapsed, identifiers
125
+ * unquoted and lower-cased, whitespace normalized.
126
+ */
127
+ export function sqlShape(sql, dialect = 'mysql') {
128
+ const s = sqlTokens(sql, dialect).map(t => (isLit(t) ? '?' : t.k === 'name' ? t.v.toLowerCase() : t.v)).join(' ')
129
+ return s.replace(/\?(?: , \?)+/g, '?').replace(/- \?/g, '?')
130
+ }
131
+
132
+ /** Canonical text keeping literals (case / quoting / whitespace insensitive). */
133
+ export function sqlCanonical(sql, dialect = 'mysql') {
134
+ return sqlTokens(sql, dialect).map(t => (isLit(t) ? litText(t) : t.k === 'name' ? t.v.toLowerCase() : t.v)).join(' ')
135
+ }
136
+
137
+ /** Source text of a token run; qualifiers in `strip` are dropped, literals optionally redacted. */
138
+ function textOf(src, toks, { strip, redact } = {}) {
139
+ let out = ''
140
+ let prev
141
+ for (const t of toks) {
142
+ if (prev !== undefined && t.start > prev) out += /\S/.test(src.slice(prev, t.start)) ? src.slice(prev, t.start) : ' '
143
+ if (t.k === 'name' && strip && t.parts.length > 1 && strip.has(t.parts.slice(0, -1).join('.').toLowerCase())) out += src.slice(t.lastStart, t.end)
144
+ else if (redact && isLit(t)) out += '?'
145
+ else out += src.slice(t.start, t.end)
146
+ prev = t.end
147
+ }
148
+ return out.trim()
149
+ }
150
+
151
+ /** Split at depth-0 separators of the run's base depth. */
152
+ function splitTop(toks, isSep) {
153
+ const out = []
154
+ let cur = []
155
+ const base = toks[0]?.d ?? 0
156
+ for (const t of toks) {
157
+ if (t.d === base && isSep(t)) { if (cur.length) out.push(cur); cur = []; continue }
158
+ cur.push(t)
159
+ }
160
+ if (cur.length) out.push(cur)
161
+ return out
162
+ }
163
+
164
+ /** Top-level AND conjuncts (BETWEEN … AND … kept together). */
165
+ function conjuncts(toks) {
166
+ const out = []
167
+ let cur = []
168
+ let between = false
169
+ const base = toks[0]?.d ?? 0
170
+ for (const t of toks) {
171
+ if (t.d === base && t.upper === 'BETWEEN') between = true
172
+ if (t.d === base && t.upper === 'AND') {
173
+ if (between) { between = false; cur.push(t); continue }
174
+ if (cur.length) out.push(cur)
175
+ cur = []
176
+ continue
177
+ }
178
+ cur.push(t)
179
+ }
180
+ if (cur.length) out.push(cur)
181
+ return out
182
+ }
183
+
184
+ // ------------------------------------------------------------ text helpers
185
+
186
+ /** Similarity of two questions: character bigram Dice after dropping filler characters and punctuation. */
187
+ export function questionSimilarity(a, b) {
188
+ const norm = (s) => String(s ?? '').toLowerCase().replace(/[的了个吗呢啊一下请帮我查询看看\s\p{P}]/gu, '')
189
+ const x = textGrams(norm(a))
190
+ const y = textGrams(norm(b))
191
+ if (!x.size || !y.size) return norm(a) === norm(b) ? 1 : 0
192
+ let inter = 0
193
+ for (const g of x) if (y.has(g)) inter++
194
+ return (2 * inter) / (x.size + y.size)
195
+ }
196
+
197
+ const LABEL_SUFFIX = /(的)?(笔数|单数|数量|次数|人数|个数|件数|总数|数|量|金额|总额|额|占比|比例|率|count|cnt|num|total|amount|sum)$/i
198
+ /** Value label from an aggregate alias such as 成功笔数 → 成功. */
199
+ export function labelFromAlias(alias) {
200
+ const s = String(alias ?? '').trim().replace(LABEL_SUFFIX, '').replace(/[_\s]+$/, '')
201
+ return s.length >= 1 && s.length <= 20 && CJK.test(s) ? s : undefined
202
+ }
203
+
204
+ const NAME_PREFIX = /^(最近|近|过去|每天|每日|每周|每月|每年|按天|按日|按周|按月|按年|今日|今天|昨日|昨天|本月|上月|本周|上周|今年|去年|各|按|的|统计|查询|计算)+/
205
+ const NAME_SUFFIX = /(趋势|分布|统计|情况|变化|排名|对比|明细|汇总|列表|走势|top\s*\d+|前\s*\d+)+$/i
206
+ /** Clean a metric name drawn from an alias or a title. */
207
+ export function cleanMetricName(text) {
208
+ let s = String(text ?? '').replace(/["'`]/g, '').replace(/[((][^))]*[))]$/, '').trim()
209
+ for (let i = 0; i < 3; i++) s = s.replace(/\d+\s*(天|日|周|个月|月|年|小时)/g, '').replace(NAME_PREFIX, '').replace(NAME_SUFFIX, '').replace(/^[\s·::,,、-]+|[\s·::,,、-]+$/g, '')
210
+ return s.slice(0, 60)
211
+ }
212
+
213
+ /** Longest common substring of all texts (CJK-aware, at least `min` characters). */
214
+ function commonSubstring(texts, min = 2) {
215
+ const list = texts.map(x => String(x ?? '').replace(/\s+/g, '')).filter(Boolean)
216
+ if (list.length < 2) return undefined
217
+ const base = [...list].sort((a, b) => a.length - b.length)[0]
218
+ for (let len = base.length; len >= min; len--) {
219
+ for (let i = 0; i + len <= base.length; i++) {
220
+ const sub = base.slice(i, i + len)
221
+ if (list.every(x => x.includes(sub))) return sub
222
+ }
223
+ }
224
+ return undefined
225
+ }
226
+
227
+ const TERM_STOP = new Set(['统计', '查询', '多少', '数量', '趋势', '分布', '排名', '情况', '对比', '变化', '明细', '列表', '汇总', '占比', '比例', '总数', '总量', '每天', '每日', '每周', '每月', '每年', '最近', '今天', '昨天', '本月', '上月', '本周', '上周', '今年', '去年', '按天', '按月', '一下', '哪些', '什么', '如何', '分析', '数据', '记录', '报表', '平均', '合计', '总计', '一共', '按照', '各个', '所有', '全部', '近期', '天数', '每个', '前十', '排行', '走势', '金额', '时间', '日期', '小时', '分钟'])
228
+ const EDGE_STOP = '的了和与及或按各每在是有中把被对从到为且个前后近内里次天日周月年吗呢啊'
229
+ /** Candidate glossary terms of a question title: CJK n-grams (2–6) and Latin words. */
230
+ export function titleTerms(title) {
231
+ const out = new Set()
232
+ const s = String(title ?? '')
233
+ for (const run of s.match(/[\u3400-\u9fff]+/g) ?? []) {
234
+ for (let n = 2; n <= Math.min(6, run.length); n++) {
235
+ for (let i = 0; i + n <= run.length; i++) {
236
+ const g = run.slice(i, i + n)
237
+ if (TERM_STOP.has(g) || EDGE_STOP.includes(g[0]) || EDGE_STOP.includes(g.at(-1))) continue
238
+ out.add(g)
239
+ }
240
+ }
241
+ }
242
+ for (const w of s.match(/\b[A-Za-z][A-Za-z0-9]{1,15}\b/g) ?? []) if (/[A-Z]/.test(w) && !SQL_KEYWORDS.has(w.toUpperCase())) out.add(w)
243
+ return out
244
+ }
245
+
246
+ // ------------------------------------------------------------ query analysis
247
+
248
+ /**
249
+ * Parse one statement against the visible schema: referenced tables, alias
250
+ * map, column resolver.
251
+ */
252
+ export function analyzeSql(sql, { dialect = 'mysql', tables }) {
253
+ const toks = sqlTokens(sql, dialect)
254
+ const parsed = parseSelect(sql, { dialect })
255
+ const refs = referencedTables(sql, tables, { dialect }).map(n => tables.find(t => t.name === n)).filter(Boolean)
256
+ const byQual = new Map()
257
+ for (const t of refs) { byQual.set(t.name.toLowerCase(), t); byQual.set(t.name.split('.').pop().toLowerCase(), t) }
258
+ // Aliases: `table [AS] alias` anywhere in FROM / JOIN lists.
259
+ for (let i = 0; i < toks.length; i++) {
260
+ const t = toks[i]
261
+ if (t.k !== 'name') continue
262
+ const tb = refs.find(r => r.name.toLowerCase() === t.v.toLowerCase() || r.name.split('.').pop().toLowerCase() === t.parts.at(-1).toLowerCase())
263
+ if (!tb || !['FROM', 'JOIN', ','].includes(toks[i - 1]?.upper || toks[i - 1]?.v)) continue
264
+ let a = toks[i + 1]
265
+ if (a?.upper === 'AS') a = toks[i + 2]
266
+ if (a?.k === 'name' && a.parts.length === 1 && !SQL_KEYWORDS.has(a.upper) && !/^(NATURAL|STRAIGHT_JOIN|FORCE|USE|IGNORE)$/.test(a.upper)) byQual.set(a.v.toLowerCase(), tb)
267
+ }
268
+ const resolve = (tok) => {
269
+ if (tok?.k !== 'name' || tok.parts.at(-1) === '*') return undefined
270
+ const at = toks.indexOf(tok)
271
+ if (at >= 0 && toks[at + 1]?.k === 'open') return undefined // function call
272
+ const col = tok.parts.at(-1).toLowerCase()
273
+ const qual = tok.parts.slice(0, -1).join('.').toLowerCase()
274
+ const cands = qual ? [byQual.get(qual)].filter(Boolean) : refs
275
+ const hits = cands.map(t => ({ table: t, column: t.columns.find(c => c.name.toLowerCase() === col) })).filter(x => x.column)
276
+ return hits.length === 1 ? hits[0] : undefined
277
+ }
278
+ const single = refs.length === 1 && parsed && !parsed.prefix && !/\bjoin\b|\(|,/i.test(parsed.from)
279
+ const strip = new Set([...byQual.keys()])
280
+ return { sql, toks, parsed, refs, resolve, single, strip, dialect }
281
+ }
282
+
283
+ const isSensitiveCol = (c) => !c || c.roles?.includes('sensitive') || SENSITIVE_NAME.test(c.name)
284
+ const enumLike = (c) => c && !isSensitiveCol(c) && (c.roles?.includes('enum') || c.roles?.includes('flag') || (Array.isArray(c.values) && c.values.length > 0 && c.values.length <= 12) || /^(tinyint|smallint|enum|bool|boolean|bit)$/.test(c.dataType ?? ''))
285
+
286
+ /** Whether a literal sits next to a sensitive column (so the SQL carries a sensitive value). */
287
+ export function carriesSensitiveValue(a) {
288
+ const toks = a.toks
289
+ for (let i = 0; i < toks.length; i++) {
290
+ const t = toks[i]
291
+ if (t.k !== 'name') continue
292
+ const r = a.resolve(t)
293
+ const sensitive = r ? isSensitiveCol(r.column) : SENSITIVE_NAME.test(t.parts.at(-1))
294
+ if (!sensitive) continue
295
+ for (let j = Math.max(0, i - 4); j <= Math.min(toks.length - 1, i + 4); j++) if (isLit(toks[j])) return true
296
+ }
297
+ return false
298
+ }
299
+
300
+ /** Columns of the WHERE / GROUP BY / select list a query uses, plus `col = literal` filters. */
301
+ function queryFacts(a) {
302
+ const cols = new Set()
303
+ const eq = new Map()
304
+ for (const t of a.toks) {
305
+ const r = a.resolve(t)
306
+ if (r && !isSensitiveCol(r.column) && !r.column.roles?.includes('time')) cols.add(`${r.table.name}.${r.column.name}`)
307
+ }
308
+ if (a.parsed?.where) {
309
+ const wt = sqlTokens(a.parsed.where, a.dialect)
310
+ for (const c of conjuncts(wt)) {
311
+ if (c.length === 3 && c[0].k === 'name' && c[1].v === '=' && isLit(c[2])) {
312
+ const r = a.resolve(a.toks.find(x => x.k === 'name' && x.v === c[0].v))
313
+ if (r && !isSensitiveCol(r.column)) eq.set(`${r.table.name}.${r.column.name}`, litText(c[2]))
314
+ }
315
+ }
316
+ }
317
+ return { tables: new Set(a.refs.map(t => t.name)), cols, eq }
318
+ }
319
+
320
+ /** Aggregate select items of a single-table query → metric facts. */
321
+ export function metricFacts(a) {
322
+ if (!a.single || !a.parsed) return []
323
+ const table = a.refs[0]
324
+ const resolveIn = (src) => (tok) => a.resolve(a.toks.find(x => x.k === 'name' && x.v === tok.v) ?? tok)
325
+ const whereSrc = a.parsed.where ?? ''
326
+ const wt = whereSrc ? sqlTokens(whereSrc, a.dialect) : []
327
+ const timeUses = new Map()
328
+ const useTime = (src, toks) => {
329
+ const res = resolveIn(src)
330
+ for (const t of toks) { const r = t.k === 'name' ? res(t) : undefined; if (r?.column.roles?.includes('time')) timeUses.set(r.column.name, (timeUses.get(r.column.name) ?? 0) + 1) }
331
+ }
332
+ const filters = []
333
+ for (const c of conjuncts(wt)) {
334
+ const res = resolveIn(whereSrc)
335
+ if (c.some(t => t.k === 'name' && res(t)?.column.roles?.includes('time'))) continue
336
+ if (c.length === 3 && isLit(c[0]) && c[1].v === '=' && isLit(c[2])) continue // 1 = 1
337
+ if (c.some(t => t.k === 'name' && isSensitiveCol(res(t)?.column) && res(t))) return [] // filter on a sensitive column: skip the query
338
+ let text = textOf(whereSrc, c, { strip: a.strip })
339
+ if (c.some(t => t.d === c[0].d && t.upper === 'OR')) text = `(${text})`
340
+ filters.push({ text, key: sqlCanonical(text, a.dialect) })
341
+ }
342
+ filters.sort((x, y) => x.key.localeCompare(y.key))
343
+ useTime(whereSrc, wt)
344
+ const dims = new Set()
345
+ if (a.parsed.groupBy) {
346
+ const gt = sqlTokens(a.parsed.groupBy, a.dialect)
347
+ useTime(a.parsed.groupBy, gt)
348
+ for (const item of splitTop(gt, t => t.k === 'comma')) {
349
+ let expr = item
350
+ if (item.length === 1 && item[0].k === 'num') { const sel = a.parsed.select[Number(item[0].v) - 1]; expr = sel ? sqlTokens(sel.expr, a.dialect) : [] } else if (item.length === 1 && item[0].k === 'name') {
351
+ const sel = a.parsed.select.find(s => s.alias && s.alias.toLowerCase() === item[0].v.toLowerCase())
352
+ if (sel) expr = sqlTokens(sel.expr, a.dialect)
353
+ }
354
+ if (expr.length === 1 && expr[0].k === 'name') {
355
+ const r = a.resolve(a.toks.find(x => x.k === 'name' && x.v === expr[0].v) ?? expr[0])
356
+ const c = r?.column
357
+ if (c && !isSensitiveCol(c) && !c.roles?.includes('time') && !c.roles?.includes('measure')) dims.add(c.name)
358
+ } else useTime(a.parsed.groupBy, expr)
359
+ }
360
+ }
361
+ const out = []
362
+ for (const item of a.parsed.select) {
363
+ if (!item.isAggregate) continue
364
+ const et = sqlTokens(item.expr, a.dialect)
365
+ if (et.some(t => t.upper === 'SELECT' || t.upper === 'OVER')) continue
366
+ if (et.some(t => t.k === 'name' && isSensitiveCol(a.resolve(a.toks.find(x => x.k === 'name' && x.v === t.v) ?? t)?.column) && a.resolve(a.toks.find(x => x.k === 'name' && x.v === t.v) ?? t))) continue
367
+ const expression = textOf(item.expr, et, { strip: a.strip })
368
+ if (expression.length > 500) continue
369
+ const filter = filters.map(f => f.text).join(' AND ')
370
+ out.push({
371
+ table: table.name,
372
+ expression,
373
+ filter,
374
+ key: `${table.name.toLowerCase()}|${sqlCanonical(expression, a.dialect)}|${filters.map(f => f.key).join(' and ')}`,
375
+ alias: item.alias,
376
+ dims: [...dims],
377
+ timeUses,
378
+ })
379
+ }
380
+ return out
381
+ }
382
+
383
+ /** Value → label mappings of enum columns: CASE WHEN … THEN '<label>' and aggregates aliased by label. */
384
+ export function valueMappings(a) {
385
+ const out = []
386
+ const toks = a.toks
387
+ const push = (nameTok, lit, label) => {
388
+ const r = a.resolve(nameTok)
389
+ if (!r || !enumLike(r.column) || !label) return
390
+ const l = String(label).trim()
391
+ if (!l || l.length > 30) return
392
+ out.push({ key: `${r.table.name}.${r.column.name}`, table: r.table.name, column: r.column.name, value: lit.k === 'str' ? lit.v : String(Number(lit.v)), label: l })
393
+ }
394
+ // CASE expressions.
395
+ for (let i = 0; i < toks.length; i++) {
396
+ if (toks[i].upper !== 'CASE') continue
397
+ let end = i + 1
398
+ let depth = 0
399
+ for (; end < toks.length; end++) {
400
+ if (toks[end].upper === 'CASE') depth++
401
+ if (toks[end].upper === 'END') { if (depth === 0) break; depth-- }
402
+ }
403
+ const body = toks.slice(i + 1, end)
404
+ const subject = body[0]?.k === 'name' && body[1]?.upper === 'WHEN' ? body[0] : undefined
405
+ for (let j = 0; j < body.length; j++) {
406
+ if (body[j].upper !== 'WHEN') continue
407
+ const thenAt = body.findIndex((t, k) => k > j && t.upper === 'THEN' && t.d === body[j].d)
408
+ if (thenAt < 0) break
409
+ const cond = body.slice(j + 1, thenAt)
410
+ const val = body[thenAt + 1]
411
+ const label = val?.k === 'str' && body[thenAt + 2] && /^(WHEN|ELSE|END)$/.test(body[thenAt + 2].upper || (thenAt + 2 >= body.length ? 'END' : '')) ? val.v : (val?.k === 'str' && thenAt + 2 >= body.length ? val.v : undefined)
412
+ if (!label || !CJK.test(label)) continue
413
+ if (subject && cond.length === 1 && isLit(cond[0])) push(subject, cond[0], label)
414
+ else if (!subject && cond[0]?.k === 'name' && cond[1]?.v === '=' && isLit(cond[2]) && cond.length === 3) push(cond[0], cond[2], label)
415
+ else if (!subject && cond[0]?.k === 'name' && cond[1]?.upper === 'IN' && cond[2]?.k === 'open') for (const lit of cond.slice(3).filter(isLit)) push(cond[0], lit, label)
416
+ }
417
+ i = end
418
+ }
419
+ // Aggregates counting one value, aliased by its meaning: SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS 成功笔数.
420
+ for (const item of a.parsed?.select ?? []) {
421
+ if (!item.isAggregate || !item.alias) continue
422
+ const label = labelFromAlias(item.alias)
423
+ if (!label) continue
424
+ const et = sqlTokens(item.expr, a.dialect)
425
+ if (et.some(t => t.k === 'str' && CJK.test(t.v))) continue
426
+ const preds = []
427
+ for (let k = 0; k + 2 < et.length; k++) if (et[k].k === 'name' && et[k + 1].v === '=' && isLit(et[k + 2])) preds.push([et[k], et[k + 2]])
428
+ if (preds.length !== 1) continue
429
+ const nameTok = toks.find(x => x.k === 'name' && x.v === preds[0][0].v) ?? preds[0][0]
430
+ push(nameTok, preds[0][1], label)
431
+ }
432
+ return out
433
+ }
434
+
435
+ // ------------------------------------------------------------ candidate mining
436
+
437
+ const evidenceOf = (q) => ({ at: q.at, question: q.question, sql: q.redacted, source: q.source })
438
+ const addEvidence = (list, ev) => {
439
+ if (!list.some(x => x.at === ev.at && x.sql === ev.sql)) list.push(ev)
440
+ list.sort((x, y) => (y.at ?? 0) - (x.at ?? 0))
441
+ list.splice(SUGGEST.evidence)
442
+ return list
443
+ }
444
+ const sid = (kind, key) => `sg-${hashText(`${kind}|${key}`)}`
445
+ const round = (x) => Number(Math.min(0.99, x).toFixed(2))
446
+
447
+ /**
448
+ * Pure miner: history / feedback entries plus visible schema and knowledge
449
+ * → suggestions. Exported for unit tests.
450
+ * @param {{ history: object[], feedback: object[], tables: object[], knowledge: object, dialect?: string, accessOk?: (sql: string) => boolean }} input
451
+ */
452
+ export function mineSuggestions({ history = [], feedback = [], tables = [], knowledge, dialect = 'mysql', accessOk = () => true }) {
453
+ const k = knowledge ?? { glossary: [], metrics: [], notes: {}, examples: [] }
454
+ const out = new Map()
455
+ const analyze = (sql) => { try { return analyzeSql(sql, { dialect, tables }) } catch { return undefined } }
456
+ const exampleCanon = new Set()
457
+ const exampleShapes = new Set()
458
+ for (const e of k.examples) { try { exampleCanon.add(sqlCanonical(e.sql, dialect)); exampleShapes.add(sqlShape(e.sql, dialect)) } catch {} }
459
+ const questionKnown = (q) => k.examples.some(e => questionSimilarity(e.question, q) >= SUGGEST.questionSim)
460
+
461
+ // Successful queries (history), each analyzed once.
462
+ const queries = []
463
+ for (const h of history) {
464
+ if (h.status !== 'ok' || !h.sql || h.cached) continue
465
+ if (/^归因 · /.test(h.title ?? '')) continue
466
+ if (!accessOk(h.sql)) continue
467
+ const a = analyze(h.sql)
468
+ if (!a) continue
469
+ const sensitive = carriesSensitiveValue(a)
470
+ queries.push({ ...h, question: h.title ?? '', a, sensitive, canon: sqlCanonical(h.sql, dialect), shape: sqlShape(h.sql, dialect), redacted: sensitive ? textOf(h.sql, a.toks, { redact: true }) : h.sql })
471
+ }
472
+
473
+ // 1. Examples from feedback.
474
+ for (const f of feedback) {
475
+ const corrected = f.rating === 'down' && f.status === 'resolved' && f.correctedSql
476
+ if (f.rating !== 'up' && !corrected) continue
477
+ if (f.savedAsExample) continue
478
+ const sql = corrected ? f.correctedSql : f.sql
479
+ const question = String(f.question || f.title || '').trim()
480
+ if (!sql || !question) continue
481
+ try { admitSql(sql, { dialect }) } catch { continue }
482
+ if (!accessOk(sql)) continue
483
+ const a = analyze(sql)
484
+ if (!a || carriesSensitiveValue(a)) continue
485
+ const canon = sqlCanonical(sql, dialect)
486
+ if (exampleCanon.has(canon) || questionKnown(question)) continue
487
+ const id = sid('example', canon)
488
+ const prev = out.get(id)
489
+ const ev = { at: f.at, question, sql, source: 'feedback' }
490
+ if (prev) { addEvidence(prev.evidence, ev); prev.score = round(prev.score + 0.02); continue }
491
+ out.set(id, {
492
+ id, kind: 'example', key: canon, shape: sqlShape(sql, dialect),
493
+ item: { question: question.slice(0, 500), sql, note: corrected && f.comment ? `修正自反馈:${f.comment}`.slice(0, 500) : '' },
494
+ evidence: [ev], score: corrected ? 0.95 : 0.9,
495
+ reason: corrected ? '👎 反馈已修正为正确 SQL,可作为样例问答复用' : '用户对这条查询结果点了 👍,可作为已验证的样例问答',
496
+ })
497
+ }
498
+
499
+ // 2. Examples from repeated agent query shapes.
500
+ const shapes = new Map()
501
+ for (const q of queries) {
502
+ if (q.source !== 'agent' || !q.question.trim() || q.sensitive) continue
503
+ if (!shapes.has(q.shape)) shapes.set(q.shape, [])
504
+ shapes.get(q.shape).push(q)
505
+ }
506
+ for (const [shape, list] of shapes) {
507
+ if (list.length < SUGGEST.shapeMin || exampleShapes.has(shape)) continue
508
+ const latest = [...list].sort((x, y) => (y.at ?? 0) - (x.at ?? 0))[0]
509
+ const fromFeedback = [...out.values()].find(s => s.kind === 'example' && s.shape === shape)
510
+ if (fromFeedback) { for (const q of list) addEvidence(fromFeedback.evidence, evidenceOf(q)); continue }
511
+ if (exampleCanon.has(latest.canon) || questionKnown(latest.question)) continue
512
+ const id = sid('example-shape', shape)
513
+ out.set(id, {
514
+ id, kind: 'example', key: shape, shape,
515
+ item: { question: latest.question.slice(0, 500), sql: latest.sql, note: '' },
516
+ evidence: list.map(evidenceOf).sort((x, y) => (y.at ?? 0) - (x.at ?? 0)).slice(0, SUGGEST.evidence),
517
+ score: round(0.4 + 0.1 * list.length),
518
+ reason: `同一结构的查询被助手执行了 ${list.length} 次(仅取值不同),可沉淀为样例问答`,
519
+ })
520
+ }
521
+
522
+ // 3. Metrics from recurring aggregates.
523
+ const metricGroups = new Map()
524
+ const tableTime = new Map()
525
+ for (const q of queries) {
526
+ for (const m of metricFacts(q.a)) {
527
+ if (!metricGroups.has(m.key)) metricGroups.set(m.key, { ...m, queries: new Map(), aliases: [], titles: [], dimsCount: new Map(), time: new Map() })
528
+ const g = metricGroups.get(m.key)
529
+ if (!g.queries.has(q.canon)) {
530
+ g.queries.set(q.canon, q)
531
+ if (m.alias) g.aliases.push(m.alias)
532
+ if (q.question) g.titles.push(q.question)
533
+ for (const d of m.dims) g.dimsCount.set(d, (g.dimsCount.get(d) ?? 0) + 1)
534
+ for (const [c, n] of m.timeUses) g.time.set(c, (g.time.get(c) ?? 0) + n)
535
+ }
536
+ const tt = tableTime.get(m.table) ?? new Map()
537
+ for (const [c, n] of m.timeUses) tt.set(c, (tt.get(c) ?? 0) + n)
538
+ tableTime.set(m.table, tt)
539
+ }
540
+ }
541
+ const metricKey = (m) => `${String(m.table ?? '').toLowerCase()}|${sqlCanonical(m.expression, dialect)}|${conjuncts(sqlTokens(m.filter ?? '', dialect)).map(c => sqlCanonical(c.map(t => (t.k === 'name' ? t.parts.at(-1) : isLit(t) ? litText(t) : t.v)).join(' '), dialect)).sort().join(' and ')}`
542
+ const libKeys = new Set(k.metrics.map(metricKey))
543
+ const libNames = new Set(k.metrics.flatMap(m => [m.name, ...(m.synonyms ?? [])]).map(x => String(x).toLowerCase()))
544
+ const top = (map) => [...map.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]))[0]?.[0]
545
+ const metricNames = new Set()
546
+ for (const g of metricGroups.values()) {
547
+ const n = g.queries.size
548
+ if (n < SUGGEST.metricMin) continue
549
+ const probe = metricKey({ table: g.table, expression: g.expression, filter: g.filter })
550
+ if (libKeys.has(probe)) continue
551
+ const aliasCount = new Map()
552
+ for (const x of g.aliases) aliasCount.set(x, (aliasCount.get(x) ?? 0) + 1)
553
+ const cjkAlias = top(new Map([...aliasCount].filter(([x]) => CJK.test(x))))
554
+ const fromTitles = commonSubstring(g.titles.map(cleanMetricName))
555
+ let name = cleanMetricName(cjkAlias ?? '') || (fromTitles && cleanMetricName(fromTitles).length >= 2 ? cleanMetricName(fromTitles) : '') || cleanMetricName(top(aliasCount) ?? '') || g.expression.slice(0, 60)
556
+ if (libNames.has(name.toLowerCase()) || metricNames.has(name)) name = `${name}(${g.table.split('.').pop()}${g.filter ? ',条件' : ''})`.slice(0, 60)
557
+ metricNames.add(name)
558
+ const timeColumn = top(g.time) ?? top(tableTime.get(g.table) ?? new Map()) ?? ''
559
+ const dimensions = [...g.dimsCount.entries()].sort((x, y) => y[1] - x[1]).map(([d]) => d).slice(0, 20)
560
+ const synonyms = [...new Set(g.aliases.filter(x => CJK.test(x)).map(cleanMetricName).filter(x => x && x !== name))].slice(0, 5)
561
+ const list = [...g.queries.values()]
562
+ const id = sid('metric', g.key)
563
+ out.set(id, {
564
+ id, kind: 'metric', key: g.key,
565
+ item: { name, expression: g.expression, table: g.table, filter: g.filter, timeColumn, dimensions, synonyms, unit: '', description: '' },
566
+ evidence: list.map(evidenceOf).sort((x, y) => (y.at ?? 0) - (x.at ?? 0)).slice(0, SUGGEST.evidence),
567
+ score: round(0.3 + 0.1 * n),
568
+ reason: `${n} 条成功的查询在 ${g.table} 上使用了相同的聚合口径 ${g.expression}${g.filter ? `(条件 ${g.filter})` : ''},指标库中还没有`,
569
+ })
570
+ }
571
+
572
+ // 4. Notes from recurring value mappings.
573
+ const noteGroups = new Map()
574
+ for (const q of queries) {
575
+ const seen = new Set()
576
+ for (const m of valueMappings(q.a)) {
577
+ if (k.notes[m.key]) continue
578
+ if (!noteGroups.has(m.key)) noteGroups.set(m.key, { ...m, queries: new Map(), labels: new Map() })
579
+ const g = noteGroups.get(m.key)
580
+ g.queries.set(q.canon, q)
581
+ const tag = `${q.canon}\u0000${m.value}\u0000${m.label}`
582
+ if (seen.has(tag)) continue
583
+ seen.add(tag)
584
+ const lv = g.labels.get(m.value) ?? new Map()
585
+ lv.set(m.label, (lv.get(m.label) ?? 0) + 1)
586
+ g.labels.set(m.value, lv)
587
+ }
588
+ }
589
+ for (const g of noteGroups.values()) {
590
+ const n = g.queries.size
591
+ if (n < SUGGEST.noteMin) continue
592
+ const pairs = [...g.labels.entries()].map(([v, labels]) => [v, top(labels)]).sort((x, y) => (Number.isFinite(Number(x[0])) && Number.isFinite(Number(y[0])) ? Number(x[0]) - Number(y[0]) : x[0].localeCompare(y[0])))
593
+ const conflicts = [...g.labels.values()].filter(l => l.size > 1).length
594
+ const text = pairs.map(([v, l]) => `${v}=${l}`).join(',')
595
+ const id = sid('note', g.key)
596
+ out.set(id, {
597
+ id, kind: 'note', key: g.key,
598
+ item: { key: g.key, text: text.slice(0, 1000) },
599
+ evidence: [...g.queries.values()].map(evidenceOf).sort((x, y) => (y.at ?? 0) - (x.at ?? 0)).slice(0, SUGGEST.evidence),
600
+ score: round(0.5 + 0.1 * n - 0.1 * conflicts),
601
+ reason: `${n} 条查询用 CASE WHEN / 别名把 ${g.key} 的取值映射为业务含义${conflicts ? `(有 ${conflicts} 个取值的叫法不一致,已取最常用的)` : ''},该字段还没有备注`,
602
+ })
603
+ }
604
+
605
+ // 5. Glossary terms from question titles.
606
+ const byTitle = new Map()
607
+ for (const q of queries) {
608
+ const title = q.question.trim()
609
+ if (!title || q.sensitive) continue
610
+ const prev = byTitle.get(title)
611
+ if (!prev || (q.at ?? 0) > (prev.at ?? 0)) byTitle.set(title, q)
612
+ }
613
+ const titled = [...byTitle.values()].map(q => ({ q, facts: queryFacts(q.a), terms: titleTerms(q.question) }))
614
+ const support = new Map()
615
+ titled.forEach((x, i) => { for (const term of x.terms) { if (!support.has(term)) support.set(term, new Set()); support.get(term).add(i) } })
616
+ const known = new Set([...k.glossary.flatMap(g => [g.term, ...(g.synonyms ?? [])]), ...k.metrics.flatMap(m => [m.name, ...(m.synonyms ?? [])]), ...metricNames].map(x => String(x).toLowerCase()))
617
+ let terms = [...support.entries()].filter(([term, s]) => s.size >= SUGGEST.termMin && !/\d/.test(term))
618
+ // Keep maximal terms: drop a term contained in a longer one with the same support.
619
+ terms.sort((x, y) => y[0].length - x[0].length)
620
+ const kept = []
621
+ for (const [term, s] of terms) if (!kept.some(([t2, s2]) => t2.includes(term) && s2.size >= s.size)) kept.push([term, s])
622
+ // Known terms are not suggested again (fragments of longer terms were dropped above).
623
+ terms = kept.filter(([term]) => !known.has(term.toLowerCase()))
624
+ const glossary = []
625
+ const tableByName = new Map(tables.map(t => [t.name, t]))
626
+ for (const [term, s] of terms) {
627
+ const inside = [...s].map(i => titled[i])
628
+ const outside = titled.filter((_, i) => !s.has(i))
629
+ const share = (list, pred) => (list.length ? list.filter(pred).length / list.length : 0)
630
+ // Column: present in most of the term's queries, rarely elsewhere.
631
+ const cands = new Map()
632
+ for (const x of inside) for (const c of x.facts.cols) cands.set(c, (cands.get(c) ?? 0) + 1)
633
+ let best
634
+ for (const [c] of cands) {
635
+ const sIn = share(inside, x => x.facts.cols.has(c))
636
+ const sOut = share(outside, x => x.facts.cols.has(c))
637
+ const lift = sIn - sOut
638
+ if (sIn >= 0.6 && lift >= 0.34 && (!best || lift > best.lift || (lift === best.lift && sIn > best.sIn))) best = { c, sIn, lift }
639
+ }
640
+ let item
641
+ let reason
642
+ if (best) {
643
+ const [tName, ...rest] = best.c.split(/\.(?=[^.]+$)/)
644
+ const col = rest[0]
645
+ const column = tableByName.get(tName)?.columns.find(c => c.name === col)
646
+ const eqVals = new Map()
647
+ for (const x of inside) { const v = x.facts.eq.get(best.c); if (v !== undefined) eqVals.set(v, (eqVals.get(v) ?? 0) + 1) }
648
+ const v = top(eqVals)
649
+ const consistent = v !== undefined && eqVals.get(v) / inside.length >= 0.8
650
+ const comment = column?.comment ? `(${String(column.comment).slice(0, 60)})` : ''
651
+ item = { term, synonyms: [], definition: consistent ? `指 ${tName}.${col}${comment} = ${v} 的记录` : `指 ${tName}.${col}${comment}`, sql: consistent ? `${col} = ${v}` : '' }
652
+ reason = `「${term}」出现在 ${s.size} 个问题标题中,对应的查询都用到了 ${best.c}${consistent ? `(条件 ${col} = ${v})` : ''},而其他问题很少用到`
653
+ } else {
654
+ const tcount = new Map()
655
+ for (const x of inside) for (const tb of x.facts.tables) tcount.set(tb, (tcount.get(tb) ?? 0) + 1)
656
+ const tb = top(tcount)
657
+ if (!tb) continue
658
+ const sIn = share(inside, x => x.facts.tables.has(tb))
659
+ const sOut = share(outside, x => x.facts.tables.has(tb))
660
+ if (sIn < 0.8 || !outside.length || sOut > 0.5) continue
661
+ const comment = tableByName.get(tb)?.comment ? `(${String(tableByName.get(tb).comment).slice(0, 60)})` : ''
662
+ item = { term, synonyms: [], definition: `指 ${tb}${comment} 表中的记录`, sql: '' }
663
+ reason = `「${term}」出现在 ${s.size} 个问题标题中,对应的查询都读取 ${tb},而其他问题大多不读这张表`
664
+ }
665
+ const id = sid('glossary', term.toLowerCase())
666
+ glossary.push({
667
+ id, kind: 'glossary', key: term.toLowerCase(), item,
668
+ evidence: inside.map(x => evidenceOf(x.q)).sort((x, y) => (y.at ?? 0) - (x.at ?? 0)).slice(0, SUGGEST.evidence),
669
+ score: round(0.25 + 0.05 * s.size + (best ? 0.15 : 0) + Math.min(0.1, 0.02 * term.length)),
670
+ reason,
671
+ })
672
+ }
673
+ glossary.sort((x, y) => y.score - x.score).slice(0, SUGGEST.glossaryMax).forEach(g => out.set(g.id, g))
674
+
675
+ return [...out.values()].map(({ shape: _s, key: _k, ...s }) => s).sort((x, y) => (KIND_ORDER[x.kind] - KIND_ORDER[y.kind]) || (y.score - x.score))
676
+ }
677
+
678
+ // ------------------------------------------------------------ service methods
679
+
680
+ export const methods = {
681
+ /** All current suggestions for a data source (dismissed ones removed). */
682
+ async suggestKnowledge({ datasourceId, limit = 50 } = {}) {
683
+ const ds = this.requireDatasource(datasourceId)
684
+ const dialect = ds.type ?? 'mysql'
685
+ const k = this.knowledgeFor(ds.id)
686
+ const dismissed = new Set(this.state.suggestionDismissed?.[ds.id] ?? [])
687
+ const newest = (kind) => this.records.list(kind, { ref: ds.id, limit: 1 })[0]?.id ?? ''
688
+ let schema
689
+ // Cached analysis only: listing suggestions (and the tab badge) never connects to the database.
690
+ try { schema = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id) } catch { schema = undefined }
691
+ const memoKey = JSON.stringify([this.records.count('history', { ref: ds.id }), newest('history'), this.records.count('feedback', { ref: ds.id }), newest('feedback'), hashText(JSON.stringify(k)), dismissed.size, ds.updatedAt ?? 0, JSON.stringify(ds.policy ?? null), schema?.analyzedAt ?? 0])
692
+ this.suggestMemo ??= new Map()
693
+ let all = this.suggestMemo.get(ds.id)?.key === memoKey ? this.suggestMemo.get(ds.id).value : undefined
694
+ if (!all) {
695
+ const policy = this.policyFor(ds)
696
+ if (!schema) all = []
697
+ else {
698
+ const visible = filterSchema(schema, policy)
699
+ const accessOk = (sql) => {
700
+ try {
701
+ const admitted = admitSql(sql, { dialect })
702
+ return !checkAccess(admitted.sql, policy, schema, admitted.head, { dialect })
703
+ } catch { return false }
704
+ }
705
+ all = mineSuggestions({
706
+ history: this.records.list('history', { ref: ds.id, status: 'ok', limit: SUGGEST.historyScan }),
707
+ feedback: this.records.list('feedback', { ref: ds.id, limit: SUGGEST.feedbackScan }),
708
+ tables: visible.tables,
709
+ knowledge: k,
710
+ dialect,
711
+ accessOk,
712
+ })
713
+ }
714
+ this.suggestMemo.set(ds.id, { key: memoKey, value: all })
715
+ }
716
+ const list = all.filter(s => !dismissed.has(s.id))
717
+ const n = Math.max(1, Math.min(500, Number(limit) || 50))
718
+ return list.slice(0, n)
719
+ },
720
+
721
+ async listKnowledgeSuggestions({ datasourceId, limit } = {}) {
722
+ const ds = this.requireDatasource(datasourceId)
723
+ const suggestions = await this.suggestKnowledge({ datasourceId: ds.id, limit: limit ?? 200 })
724
+ return { datasourceId: ds.id, total: suggestions.length, suggestions }
725
+ },
726
+
727
+ async suggestionCount({ datasourceId } = {}) {
728
+ const ds = this.requireDatasource(datasourceId)
729
+ const list = await this.suggestKnowledge({ datasourceId: ds.id, limit: 500 })
730
+ const byKind = { example: 0, metric: 0, note: 0, glossary: 0 }
731
+ for (const s of list) byKind[s.kind]++
732
+ return { datasourceId: ds.id, count: list.length, byKind }
733
+ },
734
+
735
+ /** Save a suggestion (optionally edited) to the knowledge base and stop suggesting it. */
736
+ async acceptSuggestion({ datasourceId, id, item } = {}) {
737
+ const ds = this.requireDatasource(datasourceId)
738
+ const list = await this.suggestKnowledge({ datasourceId: ds.id, limit: 500 })
739
+ const s = list.find(x => x.id === id)
740
+ if (!s) throw new UserError('nl2sql/unknown-suggestion', '建议不存在或已处理,请刷新列表')
741
+ const next = { ...s.item, ...(item && typeof item === 'object' ? item : {}) }
742
+ delete next.id
743
+ if (s.kind === 'example') {
744
+ const dialect = ds.type ?? 'mysql'
745
+ let admitted
746
+ try { admitted = admitSql(String(next.sql ?? ''), { dialect }) } catch (error) { throw new UserError('nl2sql/invalid', `SQL 未通过只读检查:${error.message}`) }
747
+ const policy = this.policyFor(ds)
748
+ const denied = checkAccess(admitted.sql, policy, await this.analyzeSchema(ds.id), admitted.head, { dialect })
749
+ if (denied) throw new UserError('nl2sql/access-denied', denied)
750
+ next.sql = admitted.sql
751
+ }
752
+ const knowledge = await this.saveKnowledgeItem({ datasourceId: ds.id, kind: s.kind, item: next })
753
+ await this.markSuggestion(ds.id, s.id)
754
+ return { accepted: s.id, kind: s.kind, knowledge }
755
+ },
756
+
757
+ async dismissSuggestion({ datasourceId, id } = {}) {
758
+ const ds = this.requireDatasource(datasourceId)
759
+ if (typeof id !== 'string' || !/^sg-[0-9a-f]{10}$/.test(id)) throw new UserError('nl2sql/invalid', '无效的建议 ID')
760
+ await this.markSuggestion(ds.id, id)
761
+ return { dismissed: id }
762
+ },
763
+
764
+ /** Remember a handled (accepted or dismissed) suggestion id. */
765
+ async markSuggestion(dsId, id) {
766
+ this.state.suggestionDismissed ??= {}
767
+ const list = (this.state.suggestionDismissed[dsId] ?? []).filter(x => x !== id)
768
+ list.push(id)
769
+ this.state.suggestionDismissed[dsId] = list.slice(-SUGGEST.dismissedMax)
770
+ await this.save()
771
+ },
772
+ }