nl-query.js 12 KB

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