|
|
@@ -541,7 +541,7 @@ function buildReport(data, meta = {}) {
|
|
|
findings.push({
|
|
|
severity: rev.delta < -5 ? 'warning' : rev.delta > 5 ? 'positive' : 'neutral',
|
|
|
finding: `营业额 ${money(rev.value)} 元,较上期 ${rev.delta > 0 ? '增长' : '下降'} ${Math.abs(rev.delta)}%`,
|
|
|
- evidence: [`revenue:${money(rev.value)}`, `previous:${money(rev.prev)}`],
|
|
|
+ evidence: [`营业额:${money(rev.value)}`, `上期营业额:${money(rev.prev)}`],
|
|
|
interpretation: rev.delta < 0 ? '规模收缩,需要结合订单量与流量拆解原因' : '规模增长,关注是否由活动或流量投放带来',
|
|
|
confidence: 'high',
|
|
|
});
|
|
|
@@ -552,7 +552,7 @@ function buildReport(data, meta = {}) {
|
|
|
findings.push({
|
|
|
severity: conv.delta != null && conv.delta < -0.5 ? 'warning' : 'neutral',
|
|
|
finding: `进店转化率 ${pct(conv.value)}%${conv.delta != null ? `,较上期 ${conv.delta > 0 ? '+' : ''}${conv.delta}pp` : ''}`,
|
|
|
- evidence: [`conversion:${conv.value}`],
|
|
|
+ evidence: [`进店转化率:${pct(conv.value)}%`],
|
|
|
interpretation: '转化率反映进店后的下单意愿,需结合定价、售罄与出餐时长判断',
|
|
|
confidence: 'medium',
|
|
|
});
|
|
|
@@ -569,7 +569,7 @@ function buildReport(data, meta = {}) {
|
|
|
findings.push({
|
|
|
severity: (zeroByReason['no-conversion'] || []).length ? 'warning' : 'neutral',
|
|
|
finding: `零单合计 ${zeroTotal} 店次:${parts.join('、')}`,
|
|
|
- evidence: ['zeroOrder'],
|
|
|
+ evidence: [`零单店次:${zeroTotal}`],
|
|
|
interpretation: '未开业/闭店属于营业状态问题;有进店无下单才是转化问题,需要优先处理',
|
|
|
confidence: 'high',
|
|
|
});
|
|
|
@@ -580,7 +580,7 @@ function buildReport(data, meta = {}) {
|
|
|
findings.push({
|
|
|
severity: share > 40 ? 'warning' : 'neutral',
|
|
|
finding: `头部门店「${ranking[0].shopName}」贡献营业额 ${money(ranking[0].revenue)} 元,占比 ${share}%`,
|
|
|
- evidence: [`top_shop:${ranking[0].shopId}`],
|
|
|
+ evidence: [`头部门店:${ranking[0].shopName}(${money(ranking[0].revenue)} 元)`],
|
|
|
interpretation: share > 40 ? '集中度偏高,单店波动会显著影响大盘' : '门店贡献相对分散',
|
|
|
confidence: 'high',
|
|
|
});
|
|
|
@@ -590,7 +590,7 @@ function buildReport(data, meta = {}) {
|
|
|
findings.push({
|
|
|
severity: 'neutral',
|
|
|
finding: '所选区间之前没有可比数据,本期不计算环比趋势',
|
|
|
- evidence: ['prev:null'],
|
|
|
+ evidence: ['上期数据:无'],
|
|
|
interpretation: '只能做横向门店对比与当日诊断,不要据此判断长期趋势',
|
|
|
confidence: 'high',
|
|
|
});
|
|
|
@@ -602,7 +602,7 @@ function buildReport(data, meta = {}) {
|
|
|
severity: reason === 'no-conversion' ? 'P1' : 'P2',
|
|
|
store_id: list[0].shopId || null,
|
|
|
title: `${REASON_LABEL[reason] || reason}:${list.length} 店次`,
|
|
|
- evidence: list.slice(0, 5).map((z) => `${z.date} ${z.shopName}:${z.reasonText}`),
|
|
|
+ evidence: list.slice(0, 5).map((z) => `${formatYmd(z.date)} ${z.shopName}:${z.reasonText}`),
|
|
|
hypotheses: reason === 'no-conversion'
|
|
|
? ['菜品/定价与周边相比缺乏竞争力', '出餐或配送时长导致流失', '活动力度不足或库存售罄']
|
|
|
: ['门店未上线或当日排班缺失', '平台侧闭店/审核状态异常'],
|
|
|
@@ -614,7 +614,7 @@ function buildReport(data, meta = {}) {
|
|
|
if (conv.delta != null && conv.delta < -0.5) {
|
|
|
alerts.push({
|
|
|
severity: 'P1', store_id: null, title: '进店转化率下滑',
|
|
|
- evidence: [`conversion:${conv.value}`, `previous:${conv.prev}`],
|
|
|
+ evidence: [`本期进店转化率:${pct(conv.value)}%`, `上期进店转化率:${pct(conv.prev)}%`],
|
|
|
hypotheses: ['菜单改价或活动结束', '高峰期出餐变慢', '竞品补贴'],
|
|
|
next_checks: ['对比改价/活动时间', '查看出餐时长与超时单'],
|
|
|
});
|
|
|
@@ -772,8 +772,27 @@ const REPORT_AI_PROMPT = [
|
|
|
'zeroOrder 非空时必须按原因归类(未开业/闭店/无曝光/无进店/无转化各多少店次)并点明哪类需要运营介入;',
|
|
|
'没有上期数据时明确说明不可做趋势判断;不输出「赋能」「抓手」等套话。',
|
|
|
'术语必须与指标名一致:revenue 是「营业额」(不是收入/结算收入/利润),avgTicket 是「客单价」,conversion 是「进店转化率」;日期一律写成 YYYY-MM-DD。',
|
|
|
+ 'evidence 必须写成「中文指标名:数值」(如「营业额:14926.92」「零单店次:11」),禁止出现 zeroOrder、revenue、top_shop 等内部字段名。',
|
|
|
].join('\n');
|
|
|
|
|
|
+const EVIDENCE_CN = {
|
|
|
+ revenue: '营业额', orders: '有效订单', avgTicket: '客单价', exposure: '曝光人数',
|
|
|
+ conversion: '进店转化率', rating: '店铺评分', zeroOrder: '零单店次',
|
|
|
+ previous: '上期值', prev: '上期值', top_shop: '头部门店', shop_id: '门店ID',
|
|
|
+ visit: '进店人数', buyers: '下单人数', expense: '支出', subsidy: '补贴',
|
|
|
+};
|
|
|
+
|
|
|
+/** 把模型偶发的内部字段名(zeroOrder/revenue 等)翻成中文,普通用户才看得懂 */
|
|
|
+function humanizeText(value) {
|
|
|
+ return String(value == null ? '' : value)
|
|
|
+ .replace(/\b(zeroOrder|revenue|orders|avgTicket|exposure|conversion|rating|previous|prev|top_shop|shop_id|visit|buyers|expense|subsidy)\b(:?)/g,
|
|
|
+ (match, key, colon) => EVIDENCE_CN[key] + (colon || ''));
|
|
|
+}
|
|
|
+
|
|
|
+function humanizeEvidence(list) {
|
|
|
+ return (list || []).map(humanizeText);
|
|
|
+}
|
|
|
+
|
|
|
/** 把模型返回的叙述字段覆盖到规则版报告上;结构不合法就返回 null(保留规则版) */
|
|
|
function overlayAiReport(base, ai, meta = {}) {
|
|
|
if (!ai || typeof ai !== 'object') return null;
|
|
|
@@ -790,9 +809,9 @@ function overlayAiReport(base, ai, meta = {}) {
|
|
|
.slice(0, 6)
|
|
|
.map((f) => ({
|
|
|
severity: ['positive', 'neutral', 'warning', 'critical'].includes(f.severity) ? f.severity : 'neutral',
|
|
|
- finding: String(f.finding).slice(0, 120),
|
|
|
- evidence: Array.isArray(f.evidence) ? f.evidence.slice(0, 5).map(String) : [],
|
|
|
- interpretation: f.interpretation ? String(f.interpretation).slice(0, 200) : '',
|
|
|
+ finding: humanizeText(f.finding).slice(0, 120),
|
|
|
+ evidence: humanizeEvidence(f.evidence).slice(0, 5),
|
|
|
+ interpretation: f.interpretation ? humanizeText(f.interpretation).slice(0, 200) : '',
|
|
|
confidence: ['high', 'medium', 'low'].includes(f.confidence) ? f.confidence : 'medium',
|
|
|
}));
|
|
|
if (findings.length) {
|
|
|
@@ -816,8 +835,8 @@ function overlayAiReport(base, ai, meta = {}) {
|
|
|
report.alerts = ai.alerts.slice(0, 5).map((a) => ({
|
|
|
severity: ['P0', 'P1', 'P2'].includes(a.severity) ? a.severity : 'P2',
|
|
|
store_id: a.store_id || null,
|
|
|
- title: String(a.title || '').slice(0, 80),
|
|
|
- evidence: Array.isArray(a.evidence) ? a.evidence.slice(0, 6).map(String) : [],
|
|
|
+ title: humanizeText(a.title || '').slice(0, 80),
|
|
|
+ evidence: humanizeEvidence(a.evidence).slice(0, 6),
|
|
|
hypotheses: Array.isArray(a.hypotheses) ? a.hypotheses.slice(0, 4).map(String) : [],
|
|
|
next_checks: Array.isArray(a.next_checks) ? a.next_checks.slice(0, 4).map(String) : [],
|
|
|
}));
|
|
|
@@ -1137,27 +1156,33 @@ function register(app, ctx) {
|
|
|
const configured = chatCfg.chatApiKey && chatCfg.chatApiKey !== 'your-api-key-here';
|
|
|
|
|
|
if (ai && configured) {
|
|
|
- try {
|
|
|
- const text = await chatCompletion({
|
|
|
- apiBase: chatCfg.chatApiBase,
|
|
|
- apiKey: chatCfg.chatApiKey,
|
|
|
- model: chatCfg.chatModel,
|
|
|
- temperature: 0.3,
|
|
|
- timeoutMs: 60000,
|
|
|
- messages: [
|
|
|
- { role: 'system', content: REPORT_AI_PROMPT },
|
|
|
- { role: 'user', content: JSON.stringify(insightContext(data)) },
|
|
|
- ],
|
|
|
- });
|
|
|
- const merged = overlayAiReport(base, extractJson(text), { model: chatCfg.chatModel });
|
|
|
- if (merged) {
|
|
|
- payload = merged;
|
|
|
- source = 'llm';
|
|
|
- } else {
|
|
|
- aiError = '模型返回结构不合法,已退回规则版';
|
|
|
+ let aiOk = false;
|
|
|
+ for (let attempt = 0; attempt < 2 && !aiOk; attempt += 1) {
|
|
|
+ try {
|
|
|
+ const text = await chatCompletion({
|
|
|
+ apiBase: chatCfg.chatApiBase,
|
|
|
+ apiKey: chatCfg.chatApiKey,
|
|
|
+ model: chatCfg.chatModel,
|
|
|
+ temperature: 0.3,
|
|
|
+ timeoutMs: 60000,
|
|
|
+ messages: [
|
|
|
+ { role: 'system', content: REPORT_AI_PROMPT },
|
|
|
+ { role: 'user', content: JSON.stringify(insightContext(data)) },
|
|
|
+ ],
|
|
|
+ });
|
|
|
+ const merged = overlayAiReport(base, extractJson(text), { model: chatCfg.chatModel });
|
|
|
+ if (merged) {
|
|
|
+ payload = merged;
|
|
|
+ source = 'llm';
|
|
|
+ aiOk = true;
|
|
|
+ aiError = '';
|
|
|
+ } else {
|
|
|
+ aiError = '模型返回结构不合法,已退回规则版';
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ aiError = e.message || String(e);
|
|
|
+ if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 800));
|
|
|
}
|
|
|
- } catch (e) {
|
|
|
- aiError = e.message || String(e);
|
|
|
}
|
|
|
} else if (ai && !configured) {
|
|
|
aiError = '未配置对话模型,当前为规则版';
|