Ver Fonte

fix: 报告证据字段改用中文,去掉 zeroOrder 等内部字段名

- 规则版 evidence 全部中文化(营业额/上期营业额/进店转化率/零单店次/头部门店)
- AI 返回的 evidence 与正文做兜底翻译:zeroOrder→零单店次、revenue→营业额、top_shop→头部门店等
- 提示词明确要求 evidence 写成「中文指标名:数值」,禁止内部字段名
- KPI 单位 CNY 显示为「元」
- 零单告警证据日期改为 YYYY-MM-DD
- AI 调用失败自动重试一次(修复重试条件误用 payload.ai 导致不重试的问题)
liangzhao há 2 semanas atrás
pai
commit
acc2711648
2 ficheiros alterados com 58 adições e 33 exclusões
  1. 1 1
      public/report.js
  2. 57 32
      server/routes/ops.js

+ 1 - 1
public/report.js

@@ -152,7 +152,7 @@
         <div class="rp-kpis">
           ${(report.kpis || []).map((kpi) => `
             <div class="rp-kpi" title="${esc(kpi.definition)}">
-              <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit)}</span></div>
+              <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit === 'CNY' ? '元' : kpi.unit)}</span></div>
               <div class="rp-kpi-value">${kpiText(kpi)}</div>
               <div class="rp-kpi-foot">${deltaText(kpi)}<span class="rp-kpi-hint">较上期</span></div>
             </div>`).join('')}

+ 57 - 32
server/routes/ops.js

@@ -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 = '未配置对话模型,当前为规则版';