@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,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
|
+
}
|