nl-query.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  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. // 对话通道与 JSON 提取已抽到 server/agent/llm.js(智能体主循环共用同一实现)
  17. const { chatCompletion, extractJson } = require('./server/agent/llm.js');
  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. // ---------- Prompt 构造 ----------
  27. function semanticModelText() {
  28. const { REPORT_COLUMNS } = require('./napos-service.js');
  29. return REPORT_COLUMNS.filter((c) => c.key !== 'date' && c.key !== 'shopName').map((c) => {
  30. const syn = (c.synonyms || []).join('/');
  31. const desc = c.description || '';
  32. return `- ${c.key} | ${c.label} | 同义词:${syn} | ${desc}${c.reserved ? ' | ⚠️预留列,当前无数据,不要用于查询' : ''}`;
  33. }).join('\n');
  34. }
  35. function buildMessages({ q, entity, shops, dateRange, history }) {
  36. const shopLines = (shops || []).slice(0, 300)
  37. .map((s) => `${s.shopId}|${s.shopName}`).join('\n');
  38. const system = `你是「淘宝闪购门店经营数据」的问数助手。把用户的自然语言问题翻译成一个结构化查询 JSON。
  39. ## 今天日期
  40. ${ymdLocal(new Date())}(YYYYMMDD)。数据可用区间:${dateRange.from} ~ ${dateRange.to}(区间外无数据;当天未结算,查询截止日默认取昨天)。
  41. ## 指标语义(key | 名称 | 同义词 | 口径)
  42. ${semanticModelText()}
  43. ## 门店清单(shopId|shopName)
  44. ${shopLines || '(暂无门店清单,shopNames 留空)'}
  45. ## 输出格式(只输出一个 JSON 对象,不要输出其他文字)
  46. {
  47. "mode": "detail|shop|date", // detail=按日期×门店明细; shop=按门店汇总; date=按日期汇总。问"每家/各门店/排行/Top"用 shop;问"每天/趋势/最近几天"用 date;问具体某天各门店用 detail
  48. "from": "YYYYMMDD", // 缺省 = 昨天-29
  49. "to": "YYYYMMDD", // 缺省 = 昨天
  50. "shopNames": ["门店名"], // 可选。用户指定门店时给名称(支持部分匹配);没指定给 []
  51. "metrics": ["grossAmt"], // 展示的指标 key 数组,1~4 个,必须来自上面的指标语义;用户说"营业额和单量"就是 ["grossAmt","validOrderCnt"]
  52. "sort": "grossAmt", // 排序指标 key,可选;shop 模式默认营业额降序
  53. "sortDir": "desc",
  54. "limit": 10, // 可选,shop 模式 Top N;用户问"全部/所有"给 0 表示不限
  55. "chart": { "type": "bar|line|none", "x": "shopName|date", "y": ["grossAmt"] }, // 推荐图表:mode=shop→bar(x=shopName),mode=date→line(x=date),用户只是问一个数字→none
  56. "chartTitle": "图表标题"
  57. }
  58. ## 规则
  59. - 对话可能带上下文(之前的问答):用户说"那它呢/换成二店/再看单量"时,结合上一轮的门店与指标补全本轮查询
  60. - 指标同义词要对齐:如"流水/GMV/销售额"→grossAmt,"单量"→validOrderCnt,"进店率"→clkExpRate,"访购率"→ordClkRate
  61. - "上月/最近30天/本周"等要换算成具体 from/to,且不晚于 ${dateRange.to}
  62. - 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时 metrics 仍可带上(展示为空),无需额外说明
  63. - 除非用户明确要全部门店,shop 模式默认 limit 10`;
  64. const msgs = [{ role: 'system', content: system }];
  65. for (const h of (Array.isArray(history) ? history : []).slice(-8)) {
  66. if (!h || !h.content) continue;
  67. if (h.role !== 'user' && h.role !== 'assistant') continue;
  68. msgs.push({ role: h.role, content: String(h.content).slice(0, 600) });
  69. }
  70. msgs.push({ role: 'user', content: String(q || '').slice(0, 500) });
  71. return msgs;
  72. }
  73. // ---------- 查询执行 ----------
  74. function matchShopsByName(shops, names) {
  75. if (!Array.isArray(names) || !names.length) return null;
  76. const picked = new Set();
  77. for (const raw of names) {
  78. const want = String(raw || '').trim().toLowerCase();
  79. if (!want) continue;
  80. let hit = (shops || []).find((s) => String(s.shopName).toLowerCase() === want);
  81. if (!hit) hit = (shops || []).find((s) => String(s.shopName).toLowerCase().includes(want));
  82. if (hit) picked.add(String(hit.shopId));
  83. }
  84. return picked.size ? [...picked] : null;
  85. }
  86. function sanitizeQuery(raw, shops, dateRange) {
  87. const q = raw || {};
  88. const mode = ['detail', 'shop', 'date'].includes(q.mode) ? q.mode : 'shop';
  89. const from = normDay(q.from) || daysAgoYmd(30);
  90. const to = normDay(q.to) || daysAgoYmd(1);
  91. const clamped = {
  92. mode,
  93. from: from < dateRange.from ? dateRange.from : from,
  94. to: to > dateRange.to ? dateRange.to : (to > from ? to : dateRange.to),
  95. shopIds: matchShopsByName(shops, q.shopNames),
  96. metrics: (Array.isArray(q.metrics) ? q.metrics : ['grossAmt'])
  97. .map(String).filter((k) => COLUMN_KEYS.has(k)).slice(0, 4),
  98. sort: COLUMN_KEYS.has(q.sort) ? q.sort : 'grossAmt',
  99. sortDir: q.sortDir === 'asc' ? 'asc' : 'desc',
  100. limit: Number(q.limit) > 0 ? Math.min(Number(q.limit), 200) : 0,
  101. chart: q.chart && ['bar', 'line', 'none'].includes(q.chart.type)
  102. ? { 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) }
  103. : { type: mode === 'date' ? 'line' : 'bar', x: mode === 'date' ? 'date' : 'shopName', y: ['grossAmt'] },
  104. chartTitle: String(q.chartTitle || '').slice(0, 60),
  105. };
  106. if (!clamped.metrics.length) clamped.metrics = ['grossAmt'];
  107. if (!clamped.chart.y.length) clamped.chart.y = [clamped.metrics[0]];
  108. return clamped;
  109. }
  110. /** 执行 sanitize 后的查询,返回面向展示的 rows/columns/summary */
  111. function executeQuery(entityKey, query, svc) {
  112. const common = { entity: entityKey, from: query.from, to: query.to };
  113. let result;
  114. if (query.mode === 'detail') {
  115. result = svc.queryReport({ ...common, scope: 'shop', shops: query.shopIds || undefined });
  116. } else {
  117. result = svc.aggregateReport({ ...common, group: query.mode === 'date' ? 'date' : 'shop' });
  118. if (query.shopIds && query.mode === 'shop') {
  119. const wanted = new Set(query.shopIds.map(String));
  120. result.rows = result.rows.filter((r) => wanted.has(String(r.shopId)));
  121. }
  122. }
  123. let rows = result.rows || [];
  124. const sortKey = query.sort;
  125. rows = rows.slice().sort((a, b) => {
  126. const av = a[sortKey], bv = b[sortKey];
  127. const an = Number(av), bn = Number(bv);
  128. if (Number.isFinite(an) && Number.isFinite(bn)) return query.sortDir === 'asc' ? an - bn : bn - an;
  129. return String(av || '').localeCompare(String(bv || ''), 'zh-CN') * (query.sortDir === 'asc' ? 1 : -1);
  130. });
  131. if (query.limit > 0) rows = rows.slice(0, query.limit);
  132. return {
  133. columns: (svc.REPORT_COLUMNS || []).filter((c) => ['date', 'shopName'].includes(c.key) || query.metrics.includes(c.key)),
  134. rows,
  135. summary: result.summary,
  136. applied: {
  137. mode: query.mode, from: query.from, to: query.to,
  138. shopIds: query.shopIds || [], metrics: query.metrics,
  139. sort: query.sort, sortDir: query.sortDir, limit: query.limit,
  140. },
  141. };
  142. }
  143. // ---------- 第二段:结果结论(少量聚合数据回传模型写结论句) ----------
  144. const MODE_LABEL = { shop: '按门店汇总', date: '按日期汇总', detail: '日期×门店明细' };
  145. const ymdDash = (v) => String(v || '').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3');
  146. function colRegistry() { return require('./napos-service.js').REPORT_COLUMNS; }
  147. /** 与前端报表一致的数值格式化(pct 存小数显示乘 100) */
  148. function fmtVal(type, v) {
  149. const n = Number(v);
  150. if (v == null || v === '' || !Number.isFinite(n)) return '无数据';
  151. if (type === 'pct') return `${(n * 100).toFixed(2)}%`;
  152. if (type === 'money') return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}元`;
  153. if (type === 'int') return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
  154. return n.toFixed(2);
  155. }
  156. /** 把执行结果压缩成给模型看的摘要(纯函数,smoke 直接回归):
  157. * 口径行 + Top≤10 行(店名/日期 × 所选指标)+ 区间合计/均值 + 空结果说明。 */
  158. function buildResultDigest(query, result) {
  159. const reg = new Map(colRegistry().map((c) => [c.key, c]));
  160. const label = (k) => (reg.get(k) || {}).label || k;
  161. const type = (k) => (reg.get(k) || {}).type || 'num';
  162. const lines = [];
  163. lines.push(`口径:${MODE_LABEL[query.mode] || query.mode},区间 ${ymdDash(query.from)} ~ ${ymdDash(query.to)}`
  164. + (query.shopIds && query.shopIds.length ? `,已限定 ${query.shopIds.length} 家门店` : '')
  165. + `,指标:${query.metrics.map(label).join('、')}`);
  166. const rows = (result.rows || []).slice(0, 10);
  167. for (const r of rows) {
  168. const name = query.mode === 'date' ? ymdDash(r.date)
  169. : query.mode === 'shop' ? String(r.shopName || r.shopId || '')
  170. : `${ymdDash(r.date)} ${r.shopName || ''}`;
  171. lines.push(`${name}:${query.metrics.map((k) => `${label(k)} ${fmtVal(type(k), r[k])}`).join(',')}`);
  172. }
  173. if ((result.rows || []).length > 10) lines.push(`(其余 ${result.rows.length - 10} 行略)`);
  174. const s = result.summary || {};
  175. const totals = query.metrics
  176. .map((k) => `${label(k)}${type(k) === 'pct' ? '均值' : '合计'} ${fmtVal(type(k), s[k])}`)
  177. .filter((t) => !t.endsWith('无数据'));
  178. if (totals.length) lines.push(`区间合计:${totals.join(',')}`);
  179. if (!rows.length) lines.push('(查询结果为空:区间内可能没有数据)');
  180. return lines.join('\n');
  181. }
  182. /** 第二段失败时的兜底话术(不引用数字,保证接口形状不变) */
  183. function fallbackReply(query) {
  184. const reg = new Map(colRegistry().map((c) => [c.key, c]));
  185. const labels = query.metrics.map((k) => (reg.get(k) || {}).label || k).join('、');
  186. return `已查询 ${ymdDash(query.from)} ~ ${ymdDash(query.to)} 的${labels}数据(${MODE_LABEL[query.mode] || query.mode}),详细结果见下方数据与图表。`;
  187. }
  188. /** 第二段:digest 回传模型写一句带真实数字的结论;任何失败降级为模板句 */
  189. async function conclude({ q, cfg, query, result }) {
  190. try {
  191. const out = await chatCompletion({
  192. apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel,
  193. messages: [
  194. { role: 'system', content: '你是数据结论助手。只依据用户提供的查询结果数据,用中文一两句话直接回答用户的问题:优先给出关键数字(大金额可用"万元"表述),点出最大值/最小值或明显变化;数据为空或全为 0 时如实说明;禁止编造、推算或引用数据之外的信息。' },
  195. { role: 'user', content: `问题:${q}\n\n查询结果数据:\n${buildResultDigest(query, result)}` },
  196. ],
  197. });
  198. const text = String(out).trim();
  199. if (text) return text.slice(0, 500);
  200. } catch { /* 第二段失败不影响查询结果返回 */ }
  201. return fallbackReply(query);
  202. }
  203. // ---------- 对外入口 ----------
  204. /** 湖内可用日期范围(用于 prompt 约束 + 查询钳制) */
  205. function lakeDateRange(entityKey, svc) {
  206. const dates = svc.store.datesInLake(entityKey);
  207. if (!dates.length) {
  208. const to = daysAgoYmd(1);
  209. return { from: daysAgoYmd(30), to };
  210. }
  211. return { from: dates[0], to: dates[dates.length - 1] };
  212. }
  213. /**
  214. * 智能问数主入口。
  215. * @param {object} o { q, entity, cfg: {chatApiBase, chatApiKey, chatModel}, svc: napos-service }
  216. */
  217. async function ask(o) {
  218. const { q, entity, cfg, svc } = o;
  219. if (!q || !String(q).trim()) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
  220. for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
  221. if (!cfg[k]) {
  222. const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
  223. e.code = 'ASK_NO_MODEL';
  224. throw e;
  225. }
  226. }
  227. const meta = svc.store.getEntity(entity);
  228. const shops = (meta && meta.shops) || [];
  229. const dateRange = lakeDateRange(entity, svc);
  230. const messages = buildMessages({ q, entity, shops, dateRange, history: o.history });
  231. let raw;
  232. try {
  233. raw = await chatCompletion({ apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, messages });
  234. } catch (e) {
  235. const err = new Error(e.message);
  236. err.code = 'ASK_LLM_FAIL';
  237. throw err;
  238. }
  239. const parsed = extractJson(raw);
  240. const query = sanitizeQuery(parsed, shops, dateRange);
  241. const result = executeQuery(entity, query, svc);
  242. const reply = await conclude({ q, cfg, query, result });
  243. return { reply, ...result, chart: query.chart, chartTitle: query.chartTitle };
  244. }
  245. /** 设置页「测试对话模型」:1 token 的最小请求 */
  246. async function ping(cfg) {
  247. const reply = await chatCompletion({
  248. apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, timeoutMs: 20000,
  249. messages: [{ role: 'user', content: '回复"OK"两个字母即可' }],
  250. });
  251. return String(reply).trim().slice(0, 40);
  252. }
  253. module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, executeQuery, lakeDateRange, buildResultDigest, fallbackReply };