| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- 'use strict';
- /**
- * 智能问数 · NL2Query
- * ----------------------------------------------------------------------------
- * 参考 spring-ai-alibaba/DataAgent 的「意图理解 → 查询规划 → 报告呈现」链路,
- * 但不引入 SQL/向量库/Java 栈:
- * · 语义对齐:基于 napos-service 的列注册表(同义词 + 口径描述),一个 prompt 装得下
- * · 查询执行:把自然语言翻译成现有 /api/reports 查询参数,复用数据湖查询
- * · 结果呈现:reply 文本 + ECharts 图表配置(前端渲染)
- *
- * 需要 OpenAI 兼容的对话模型(config.chatModel / chatApiKey / chatApiBase)。
- */
- const https = require('https');
- const http = require('http');
- const COLUMN_KEYS = new Set(['date', 'shopName', 'bizHours', 'abnormalCloseHours', 'validOrderCnt',
- 'incomeAmt', 'grossAmt', 'goodsSaleAmt', 'expUv', 'orderUserCnt',
- 'clkExpRate', 'ordClkRate', 'perActualPay', 'expenseAmt', 'actOrdCnt', 'actSubsidyAmt',
- 'shopRoi', 'rebuyRate30d']);
- const z2 = (n) => String(n).padStart(2, '0');
- function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
- function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
- function normDay(v) { return /^\d{8}$/.test(String(v || '')) ? String(v) : ''; }
- // ---------- OpenAI 兼容对话调用 ----------
- function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000 }) {
- return new Promise((resolve, reject) => {
- let base;
- try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
- const body = JSON.stringify({ model, messages, temperature: 0, stream: false });
- const options = {
- hostname: base.hostname,
- port: base.port || 443,
- path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json; charset=utf-8',
- 'Content-Length': Buffer.byteLength(body),
- },
- timeout: timeoutMs,
- };
- const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
- const chunks = [];
- res.on('data', (c) => chunks.push(c));
- res.on('end', () => {
- const raw = Buffer.concat(chunks).toString('utf8');
- let json = null;
- try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
- if (res.statusCode !== 200) {
- const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
- return reject(new Error(`对话模型请求失败: ${msg}`));
- }
- const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
- if (!content) return reject(new Error('对话模型返回为空'));
- resolve(content);
- });
- });
- req.on('error', reject);
- req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
- req.write(body);
- req.end();
- });
- }
- /** 从模型输出中稳健地提取第一个 JSON 对象 */
- function extractJson(text) {
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
- const candidate = fenced ? fenced[1] : text;
- const start = candidate.indexOf('{');
- const end = candidate.lastIndexOf('}');
- if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
- return JSON.parse(candidate.slice(start, end + 1));
- }
- // ---------- Prompt 构造 ----------
- function semanticModelText() {
- const { REPORT_COLUMNS } = require('./napos-service.js');
- return REPORT_COLUMNS.filter((c) => c.key !== 'date' && c.key !== 'shopName').map((c) => {
- const syn = (c.synonyms || []).join('/');
- const desc = c.description || '';
- return `- ${c.key} | ${c.label} | 同义词:${syn} | ${desc}${c.reserved ? ' | ⚠️预留列,当前无数据,不要用于查询' : ''}`;
- }).join('\n');
- }
- function buildMessages({ q, entity, shops, dateRange, history }) {
- const shopLines = (shops || []).slice(0, 300)
- .map((s) => `${s.shopId}|${s.shopName}`).join('\n');
- const system = `你是「淘宝闪购门店经营数据」的问数助手。把用户的自然语言问题翻译成一个结构化查询 JSON。
- ## 今天日期
- ${ymdLocal(new Date())}(YYYYMMDD)。数据可用区间:${dateRange.from} ~ ${dateRange.to}(区间外无数据;当天未结算,查询截止日默认取昨天)。
- ## 指标语义(key | 名称 | 同义词 | 口径)
- ${semanticModelText()}
- ## 门店清单(shopId|shopName)
- ${shopLines || '(暂无门店清单,shopNames 留空)'}
- ## 输出格式(只输出一个 JSON 对象,不要输出其他文字)
- {
- "reply": "结合对话上下文,用一两句话直接回答用户本轮问题(可沿用上一轮已确认的门店/指标);不要编造具体数字",
- "mode": "detail|shop|date", // detail=按日期×门店明细; shop=按门店汇总; date=按日期汇总。问"每家/各门店/排行/Top"用 shop;问"每天/趋势/最近几天"用 date;问具体某天各门店用 detail
- "from": "YYYYMMDD", // 缺省 = 昨天-29
- "to": "YYYYMMDD", // 缺省 = 昨天
- "shopNames": ["门店名"], // 可选。用户指定门店时给名称(支持部分匹配);没指定给 []
- "metrics": ["grossAmt"], // 展示的指标 key 数组,1~4 个,必须来自上面的指标语义;用户说"营业额和单量"就是 ["grossAmt","validOrderCnt"]
- "sort": "grossAmt", // 排序指标 key,可选;shop 模式默认营业额降序
- "sortDir": "desc",
- "limit": 10, // 可选,shop 模式 Top N;用户问"全部/所有"给 0 表示不限
- "chart": { "type": "bar|line|none", "x": "shopName|date", "y": ["grossAmt"] }, // 推荐图表:mode=shop→bar(x=shopName),mode=date→line(x=date),用户只是问一个数字→none
- "chartTitle": "图表标题"
- }
- ## 规则
- - 对话可能带上下文(之前的问答):用户说"那它呢/换成二店/再看单量"时,结合上一轮的门店与指标补全本轮查询
- - 指标同义词要对齐:如"流水/GMV/销售额"→grossAmt,"单量"→validOrderCnt,"进店率"→clkExpRate,"访购率"→ordClkRate
- - "上月/最近30天/本周"等要换算成具体 from/to,且不晚于 ${dateRange.to}
- - 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时,reply 里说明该数据源暂未接入,metrics 仍可带上(展示为空)
- - 除非用户明确要全部门店,shop 模式默认 limit 10`;
- const msgs = [{ role: 'system', content: system }];
- for (const h of (Array.isArray(history) ? history : []).slice(-8)) {
- if (!h || !h.content) continue;
- if (h.role !== 'user' && h.role !== 'assistant') continue;
- msgs.push({ role: h.role, content: String(h.content).slice(0, 600) });
- }
- msgs.push({ role: 'user', content: String(q || '').slice(0, 500) });
- return msgs;
- }
- // ---------- 查询执行 ----------
- function matchShopsByName(shops, names) {
- if (!Array.isArray(names) || !names.length) return null;
- const picked = new Set();
- for (const raw of names) {
- const want = String(raw || '').trim().toLowerCase();
- if (!want) continue;
- let hit = (shops || []).find((s) => String(s.shopName).toLowerCase() === want);
- if (!hit) hit = (shops || []).find((s) => String(s.shopName).toLowerCase().includes(want));
- if (hit) picked.add(String(hit.shopId));
- }
- return picked.size ? [...picked] : null;
- }
- function sanitizeQuery(raw, shops, dateRange) {
- const q = raw || {};
- const mode = ['detail', 'shop', 'date'].includes(q.mode) ? q.mode : 'shop';
- const from = normDay(q.from) || daysAgoYmd(30);
- const to = normDay(q.to) || daysAgoYmd(1);
- const clamped = {
- mode,
- from: from < dateRange.from ? dateRange.from : from,
- to: to > dateRange.to ? dateRange.to : (to > from ? to : dateRange.to),
- shopIds: matchShopsByName(shops, q.shopNames),
- metrics: (Array.isArray(q.metrics) ? q.metrics : ['grossAmt'])
- .map(String).filter((k) => COLUMN_KEYS.has(k)).slice(0, 4),
- sort: COLUMN_KEYS.has(q.sort) ? q.sort : 'grossAmt',
- sortDir: q.sortDir === 'asc' ? 'asc' : 'desc',
- limit: Number(q.limit) > 0 ? Math.min(Number(q.limit), 200) : 0,
- chart: q.chart && ['bar', 'line', 'none'].includes(q.chart.type)
- ? { 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) }
- : { type: mode === 'date' ? 'line' : 'bar', x: mode === 'date' ? 'date' : 'shopName', y: ['grossAmt'] },
- chartTitle: String(q.chartTitle || '').slice(0, 60),
- };
- if (!clamped.metrics.length) clamped.metrics = ['grossAmt'];
- if (!clamped.chart.y.length) clamped.chart.y = [clamped.metrics[0]];
- return clamped;
- }
- /** 执行 sanitize 后的查询,返回面向展示的 rows/columns/summary */
- function executeQuery(entityKey, query, svc) {
- const common = { entity: entityKey, from: query.from, to: query.to };
- let result;
- if (query.mode === 'detail') {
- result = svc.queryReport({ ...common, scope: 'shop', shops: query.shopIds || undefined });
- } else {
- result = svc.aggregateReport({ ...common, group: query.mode === 'date' ? 'date' : 'shop' });
- if (query.shopIds && query.mode === 'shop') {
- const wanted = new Set(query.shopIds.map(String));
- result.rows = result.rows.filter((r) => wanted.has(String(r.shopId)));
- }
- }
- let rows = result.rows || [];
- const sortKey = query.sort;
- rows = rows.slice().sort((a, b) => {
- const av = a[sortKey], bv = b[sortKey];
- const an = Number(av), bn = Number(bv);
- if (Number.isFinite(an) && Number.isFinite(bn)) return query.sortDir === 'asc' ? an - bn : bn - an;
- return String(av || '').localeCompare(String(bv || ''), 'zh-CN') * (query.sortDir === 'asc' ? 1 : -1);
- });
- if (query.limit > 0) rows = rows.slice(0, query.limit);
- return {
- columns: (svc.REPORT_COLUMNS || []).filter((c) => ['date', 'shopName'].includes(c.key) || query.metrics.includes(c.key)),
- rows,
- summary: result.summary,
- applied: {
- mode: query.mode, from: query.from, to: query.to,
- shopIds: query.shopIds || [], metrics: query.metrics,
- sort: query.sort, sortDir: query.sortDir, limit: query.limit,
- },
- };
- }
- // ---------- 对外入口 ----------
- /** 湖内可用日期范围(用于 prompt 约束 + 查询钳制) */
- function lakeDateRange(entityKey, svc) {
- const dates = svc.store.datesInLake(entityKey);
- if (!dates.length) {
- const to = daysAgoYmd(1);
- return { from: daysAgoYmd(30), to };
- }
- return { from: dates[0], to: dates[dates.length - 1] };
- }
- /**
- * 智能问数主入口。
- * @param {object} o { q, entity, cfg: {chatApiBase, chatApiKey, chatModel}, svc: napos-service }
- */
- async function ask(o) {
- const { q, entity, cfg, svc } = o;
- if (!q || !String(q).trim()) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
- for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
- if (!cfg[k]) {
- const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
- e.code = 'ASK_NO_MODEL';
- throw e;
- }
- }
- const meta = svc.store.getEntity(entity);
- const shops = (meta && meta.shops) || [];
- const dateRange = lakeDateRange(entity, svc);
- const messages = buildMessages({ q, entity, shops, dateRange, history: o.history });
- let raw;
- try {
- raw = await chatCompletion({ apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, messages });
- } catch (e) {
- const err = new Error(e.message);
- err.code = 'ASK_LLM_FAIL';
- throw err;
- }
- const parsed = extractJson(raw);
- const query = sanitizeQuery(parsed, shops, dateRange);
- const result = executeQuery(entity, query, svc);
- return { reply: String(parsed.reply || '').slice(0, 500), ...result, chart: query.chart, chartTitle: query.chartTitle };
- }
- /** 设置页「测试对话模型」:1 token 的最小请求 */
- async function ping(cfg) {
- const reply = await chatCompletion({
- apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel, timeoutMs: 20000,
- messages: [{ role: 'user', content: '回复"OK"两个字母即可' }],
- });
- return String(reply).trim().slice(0, 40);
- }
- module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange };
|