nl-query.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. 'use strict';
  2. /**
  3. * 智能问数 · NL2Query(两段式)
  4. * ----------------------------------------------------------------------------
  5. * 参考 spring-ai-alibaba/DataAgent 的「意图理解 → 查询规划 → 报告呈现」链路,
  6. * 但不引入 SQL/向量库/Java 栈:
  7. * · 第一段(规划):基于 napos-service 列注册表(同义词 + 口径)把问题翻译成
  8. * 结构化查询 JSON —— 行数据不出网,模型只做规划不做计算
  9. * · 查询执行:本地数据湖取数/聚合/排序(sanitizeQuery 钳制参数)
  10. * · 第二段(结论):把聚合后的少量结果(Top≤10 行 + 区间合计)发回模型,
  11. * 写一句带真实数字的回答;失败自动降级为模板句
  12. * · 结果呈现:reply 文本 + ECharts 图表配置(前端渲染)
  13. *
  14. * 需要 OpenAI 兼容的对话模型(config.chatModel / chatApiKey / chatApiBase)。
  15. */
  16. const https = require('https');
  17. const http = require('http');
  18. const COLUMN_KEYS = new Set(['date', 'shopName', 'bizHours', 'abnormalCloseHours', 'validOrderCnt',
  19. 'incomeAmt', 'grossAmt', 'goodsSaleAmt', 'expUv', 'orderUserCnt',
  20. 'clkExpRate', 'ordClkRate', 'perActualPay', 'expenseAmt', 'actOrdCnt', 'actSubsidyAmt',
  21. 'shopRoi', 'rebuyRate30d']);
  22. const z2 = (n) => String(n).padStart(2, '0');
  23. function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
  24. function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
  25. function normDay(v) { return /^\d{8}$/.test(String(v || '')) ? String(v) : ''; }
  26. // ---------- OpenAI 兼容对话调用 ----------
  27. function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000 }) {
  28. return new Promise((resolve, reject) => {
  29. let base;
  30. try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
  31. const body = JSON.stringify({ model, messages, temperature: 0, stream: false });
  32. const options = {
  33. hostname: base.hostname,
  34. port: base.port || 443,
  35. path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
  36. method: 'POST',
  37. headers: {
  38. Authorization: `Bearer ${apiKey}`,
  39. 'Content-Type': 'application/json; charset=utf-8',
  40. 'Content-Length': Buffer.byteLength(body),
  41. },
  42. timeout: timeoutMs,
  43. };
  44. const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
  45. const chunks = [];
  46. res.on('data', (c) => chunks.push(c));
  47. res.on('end', () => {
  48. const raw = Buffer.concat(chunks).toString('utf8');
  49. let json = null;
  50. try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
  51. if (res.statusCode !== 200) {
  52. const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
  53. return reject(new Error(`对话模型请求失败: ${msg}`));
  54. }
  55. const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
  56. if (!content) return reject(new Error('对话模型返回为空'));
  57. resolve(content);
  58. });
  59. });
  60. req.on('error', reject);
  61. req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
  62. req.write(body);
  63. req.end();
  64. });
  65. }
  66. /** 从模型输出中稳健地提取第一个 JSON 对象 */
  67. function extractJson(text) {
  68. const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  69. const candidate = fenced ? fenced[1] : text;
  70. const start = candidate.indexOf('{');
  71. const end = candidate.lastIndexOf('}');
  72. if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
  73. return JSON.parse(candidate.slice(start, end + 1));
  74. }
  75. // ---------- Prompt 构造 ----------
  76. function semanticModelText() {
  77. const { REPORT_COLUMNS } = require('./napos-service.js');
  78. return REPORT_COLUMNS.filter((c) => c.key !== 'date' && c.key !== 'shopName').map((c) => {
  79. const syn = (c.synonyms || []).join('/');
  80. const desc = c.description || '';
  81. return `- ${c.key} | ${c.label} | 同义词:${syn} | ${desc}${c.reserved ? ' | ⚠️预留列,当前无数据,不要用于查询' : ''}`;
  82. }).join('\n');
  83. }
  84. function buildMessages({ q, entity, shops, dateRange, history }) {
  85. const shopLines = (shops || []).slice(0, 300)
  86. .map((s) => `${s.shopId}|${s.shopName}`).join('\n');
  87. const system = `你是「淘宝闪购门店经营数据」的问数助手。把用户的自然语言问题翻译成一个结构化查询 JSON。
  88. ## 今天日期
  89. ${ymdLocal(new Date())}(YYYYMMDD)。数据可用区间:${dateRange.from} ~ ${dateRange.to}(区间外无数据;当天未结算,查询截止日默认取昨天)。
  90. ## 指标语义(key | 名称 | 同义词 | 口径)
  91. ${semanticModelText()}
  92. ## 门店清单(shopId|shopName)
  93. ${shopLines || '(暂无门店清单,shopNames 留空)'}
  94. ## 输出格式(只输出一个 JSON 对象,不要输出其他文字)
  95. {
  96. "mode": "detail|shop|date", // detail=按日期×门店明细; shop=按门店汇总; date=按日期汇总。问"每家/各门店/排行/Top"用 shop;问"每天/趋势/最近几天"用 date;问具体某天各门店用 detail
  97. "from": "YYYYMMDD", // 缺省 = 昨天-29
  98. "to": "YYYYMMDD", // 缺省 = 昨天
  99. "shopNames": ["门店名"], // 可选。用户指定门店时给名称(支持部分匹配);没指定给 []
  100. "metrics": ["grossAmt"], // 展示的指标 key 数组,1~4 个,必须来自上面的指标语义;用户说"营业额和单量"就是 ["grossAmt","validOrderCnt"]
  101. "sort": "grossAmt", // 排序指标 key,可选;shop 模式默认营业额降序
  102. "sortDir": "desc",
  103. "limit": 10, // 可选,shop 模式 Top N;用户问"全部/所有"给 0 表示不限
  104. "chart": { "type": "bar|line|none", "x": "shopName|date", "y": ["grossAmt"] }, // 推荐图表:mode=shop→bar(x=shopName),mode=date→line(x=date),用户只是问一个数字→none
  105. "chartTitle": "图表标题"
  106. }
  107. ## 规则
  108. - 对话可能带上下文(之前的问答):用户说"那它呢/换成二店/再看单量"时,结合上一轮的门店与指标补全本轮查询
  109. - 指标同义词要对齐:如"流水/GMV/销售额"→grossAmt,"单量"→validOrderCnt,"进店率"→clkExpRate,"访购率"→ordClkRate
  110. - "上月/最近30天/本周"等要换算成具体 from/to,且不晚于 ${dateRange.to}
  111. - 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时 metrics 仍可带上(展示为空),无需额外说明
  112. - 除非用户明确要全部门店,shop 模式默认 limit 10`;
  113. const msgs = [{ role: 'system', content: system }];
  114. for (const h of (Array.isArray(history) ? history : []).slice(-8)) {
  115. if (!h || !h.content) continue;
  116. if (h.role !== 'user' && h.role !== 'assistant') continue;
  117. msgs.push({ role: h.role, content: String(h.content).slice(0, 600) });
  118. }
  119. msgs.push({ role: 'user', content: String(q || '').slice(0, 500) });
  120. return msgs;
  121. }
  122. // ---------- 查询执行 ----------
  123. function matchShopsByName(shops, names) {
  124. if (!Array.isArray(names) || !names.length) return null;
  125. const picked = new Set();
  126. for (const raw of names) {
  127. const want = String(raw || '').trim().toLowerCase();
  128. if (!want) continue;
  129. let hit = (shops || []).find((s) => String(s.shopName).toLowerCase() === want);
  130. if (!hit) hit = (shops || []).find((s) => String(s.shopName).toLowerCase().includes(want));
  131. if (hit) picked.add(String(hit.shopId));
  132. }
  133. return picked.size ? [...picked] : null;
  134. }
  135. function sanitizeQuery(raw, shops, dateRange) {
  136. const q = raw || {};
  137. const mode = ['detail', 'shop', 'date'].includes(q.mode) ? q.mode : 'shop';
  138. const from = normDay(q.from) || daysAgoYmd(30);
  139. const to = normDay(q.to) || daysAgoYmd(1);
  140. const clamped = {
  141. mode,
  142. from: from < dateRange.from ? dateRange.from : from,
  143. to: to > dateRange.to ? dateRange.to : (to > from ? to : dateRange.to),
  144. shopIds: matchShopsByName(shops, q.shopNames),
  145. metrics: (Array.isArray(q.metrics) ? q.metrics : ['grossAmt'])
  146. .map(String).filter((k) => COLUMN_KEYS.has(k)).slice(0, 4),
  147. sort: COLUMN_KEYS.has(q.sort) ? q.sort : 'grossAmt',
  148. sortDir: q.sortDir === 'asc' ? 'asc' : 'desc',
  149. limit: Number(q.limit) > 0 ? Math.min(Number(q.limit), 200) : 0,
  150. chart: q.chart && ['bar', 'line', 'none'].includes(q.chart.type)
  151. ? { type: q.chart.type, x: q.chart.x === 'date' ? 'date' : 'shopName', y: (Array.isArray(q.chart.y) ? q.chart.y : ['grossAmt']).filter((k) => COLUMN_KEYS.has(k)).slice(0, 2) }
  152. : { type: mode === 'date' ? 'line' : 'bar', x: mode === 'date' ? 'date' : 'shopName', y: ['grossAmt'] },
  153. chartTitle: String(q.chartTitle || '').slice(0, 60),
  154. };
  155. if (!clamped.metrics.length) clamped.metrics = ['grossAmt'];
  156. if (!clamped.chart.y.length) clamped.chart.y = [clamped.metrics[0]];
  157. return clamped;
  158. }
  159. /** 执行 sanitize 后的查询,返回面向展示的 rows/columns/summary */
  160. function executeQuery(entityKey, query, svc) {
  161. const common = { entity: entityKey, from: query.from, to: query.to };
  162. let result;
  163. if (query.mode === 'detail') {
  164. result = svc.queryReport({ ...common, scope: 'shop', shops: query.shopIds || undefined });
  165. } else {
  166. result = svc.aggregateReport({ ...common, group: query.mode === 'date' ? 'date' : 'shop' });
  167. if (query.shopIds && query.mode === 'shop') {
  168. const wanted = new Set(query.shopIds.map(String));
  169. result.rows = result.rows.filter((r) => wanted.has(String(r.shopId)));
  170. }
  171. }
  172. let rows = result.rows || [];
  173. const sortKey = query.sort;
  174. rows = rows.slice().sort((a, b) => {
  175. const av = a[sortKey], bv = b[sortKey];
  176. const an = Number(av), bn = Number(bv);
  177. if (Number.isFinite(an) && Number.isFinite(bn)) return query.sortDir === 'asc' ? an - bn : bn - an;
  178. return String(av || '').localeCompare(String(bv || ''), 'zh-CN') * (query.sortDir === 'asc' ? 1 : -1);
  179. });
  180. if (query.limit > 0) rows = rows.slice(0, query.limit);
  181. return {
  182. columns: (svc.REPORT_COLUMNS || []).filter((c) => ['date', 'shopName'].includes(c.key) || query.metrics.includes(c.key)),
  183. rows,
  184. summary: result.summary,
  185. applied: {
  186. mode: query.mode, from: query.from, to: query.to,
  187. shopIds: query.shopIds || [], metrics: query.metrics,
  188. sort: query.sort, sortDir: query.sortDir, limit: query.limit,
  189. },
  190. };
  191. }
  192. // ---------- 第二段:结果结论(少量聚合数据回传模型写结论句) ----------
  193. const MODE_LABEL = { shop: '按门店汇总', date: '按日期汇总', detail: '日期×门店明细' };
  194. const ymdDash = (v) => String(v || '').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3');
  195. function colRegistry() { return require('./napos-service.js').REPORT_COLUMNS; }
  196. /** 与前端报表一致的数值格式化(pct 存小数显示乘 100) */
  197. function fmtVal(type, v) {
  198. const n = Number(v);
  199. if (v == null || v === '' || !Number.isFinite(n)) return '无数据';
  200. if (type === 'pct') return `${(n * 100).toFixed(2)}%`;
  201. if (type === 'money') return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}元`;
  202. if (type === 'int') return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
  203. return n.toFixed(2);
  204. }
  205. /** 把执行结果压缩成给模型看的摘要(纯函数,smoke 直接回归):
  206. * 口径行 + Top≤10 行(店名/日期 × 所选指标)+ 区间合计/均值 + 空结果说明。 */
  207. function buildResultDigest(query, result) {
  208. const reg = new Map(colRegistry().map((c) => [c.key, c]));
  209. const label = (k) => (reg.get(k) || {}).label || k;
  210. const type = (k) => (reg.get(k) || {}).type || 'num';
  211. const lines = [];
  212. lines.push(`口径:${MODE_LABEL[query.mode] || query.mode},区间 ${ymdDash(query.from)} ~ ${ymdDash(query.to)}`
  213. + (query.shopIds && query.shopIds.length ? `,已限定 ${query.shopIds.length} 家门店` : '')
  214. + `,指标:${query.metrics.map(label).join('、')}`);
  215. const rows = (result.rows || []).slice(0, 10);
  216. for (const r of rows) {
  217. const name = query.mode === 'date' ? ymdDash(r.date)
  218. : query.mode === 'shop' ? String(r.shopName || r.shopId || '')
  219. : `${ymdDash(r.date)} ${r.shopName || ''}`;
  220. lines.push(`${name}:${query.metrics.map((k) => `${label(k)} ${fmtVal(type(k), r[k])}`).join(',')}`);
  221. }
  222. if ((result.rows || []).length > 10) lines.push(`(其余 ${result.rows.length - 10} 行略)`);
  223. const s = result.summary || {};
  224. const totals = query.metrics
  225. .map((k) => `${label(k)}${type(k) === 'pct' ? '均值' : '合计'} ${fmtVal(type(k), s[k])}`)
  226. .filter((t) => !t.endsWith('无数据'));
  227. if (totals.length) lines.push(`区间合计:${totals.join(',')}`);
  228. if (!rows.length) lines.push('(查询结果为空:区间内可能没有数据)');
  229. return lines.join('\n');
  230. }
  231. /** 第二段失败时的兜底话术(不引用数字,保证接口形状不变) */
  232. function fallbackReply(query) {
  233. const reg = new Map(colRegistry().map((c) => [c.key, c]));
  234. const labels = query.metrics.map((k) => (reg.get(k) || {}).label || k).join('、');
  235. return `已查询 ${ymdDash(query.from)} ~ ${ymdDash(query.to)} 的${labels}数据(${MODE_LABEL[query.mode] || query.mode}),详细结果见下方数据与图表。`;
  236. }
  237. /** 第二段:digest 回传模型写一句带真实数字的结论;任何失败降级为模板句 */
  238. async function conclude({ q, cfg, query, result }) {
  239. try {
  240. const out = await chatCompletion({
  241. apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel,
  242. messages: [
  243. { role: 'system', content: '你是数据结论助手。只依据用户提供的查询结果数据,用中文一两句话直接回答用户的问题:优先给出关键数字(大金额可用"万元"表述),点出最大值/最小值或明显变化;数据为空或全为 0 时如实说明;禁止编造、推算或引用数据之外的信息。' },
  244. { role: 'user', content: `问题:${q}\n\n查询结果数据:\n${buildResultDigest(query, result)}` },
  245. ],
  246. });
  247. const text = String(out).trim();
  248. if (text) return text.slice(0, 500);
  249. } catch { /* 第二段失败不影响查询结果返回 */ }
  250. return fallbackReply(query);
  251. }
  252. // ---------- 对外入口 ----------
  253. /** 湖内可用日期范围(用于 prompt 约束 + 查询钳制) */
  254. function lakeDateRange(entityKey, svc) {
  255. const dates = svc.store.datesInLake(entityKey);
  256. if (!dates.length) {
  257. const to = daysAgoYmd(1);
  258. return { from: daysAgoYmd(30), to };
  259. }
  260. return { from: dates[0], to: dates[dates.length - 1] };
  261. }
  262. /**
  263. * 智能问数主入口。
  264. * @param {object} o { q, entity, cfg: {chatApiBase, chatApiKey, chatModel}, svc: napos-service }
  265. */
  266. async function ask(o) {
  267. const { q, entity, cfg, svc } = o;
  268. if (!q || !String(q).trim()) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
  269. for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
  270. if (!cfg[k]) {
  271. const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
  272. e.code = 'ASK_NO_MODEL';
  273. throw e;
  274. }
  275. }
  276. const meta = svc.store.getEntity(entity);
  277. const shops = (meta && meta.shops) || [];
  278. const dateRange = lakeDateRange(entity, svc);
  279. const messages = buildMessages({ q, entity, shops, dateRange, history: o.history });
  280. let raw;
  281. try {
  282. raw = await chatCompletion({ apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, messages });
  283. } catch (e) {
  284. const err = new Error(e.message);
  285. err.code = 'ASK_LLM_FAIL';
  286. throw err;
  287. }
  288. const parsed = extractJson(raw);
  289. const query = sanitizeQuery(parsed, shops, dateRange);
  290. const result = executeQuery(entity, query, svc);
  291. const reply = await conclude({ q, cfg, query, result });
  292. return { reply, ...result, chart: query.chart, chartTitle: query.chartTitle };
  293. }
  294. /** 设置页「测试对话模型」:1 token 的最小请求 */
  295. async function ping(cfg) {
  296. const reply = await chatCompletion({
  297. apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, timeoutMs: 20000,
  298. messages: [{ role: 'user', content: '回复"OK"两个字母即可' }],
  299. });
  300. return String(reply).trim().slice(0, 40);
  301. }
  302. module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange, buildResultDigest, fallbackReply };