瀏覽代碼

feat: 经营解读与经营报告合并分析内核

- 抽出统一 getReportPayload:同一份 Report JSON + 10 分钟缓存,报告页与解读共用
- /api/ops/report?ai=1 调用对话模型生成叙述字段(摘要/分节/告警/行动计划),结构不合法或调用失败自动退回规则版并带 aiError
- /api/ops/insights 退化为摘要适配器:返回 findings 要点 + 前 3 条告警 + 来源/模型/区间,不再单独调模型
- 驾驶舱解读卡片标注「AI 生成 / 规则生成」、显示兜底原因,并提供「查看完整报告 →」带同区间跳转
- 报告页「重新生成」触发 AI;支持 URL 传入 from/to
- 测试更新:无模型时返回规则版摘要(47/47、113/113)
liangzhao 2 周之前
父節點
當前提交
6eaa7cede4
共有 5 個文件被更改,包括 196 次插入 和 63 次删除
  1. 1 0
      README.md
  2. 11 3
      public/cockpit.js
  3. 13 2
      public/report.js
  4. 5 2
      scripts/test-ops-import.js
  5. 166 56
      server/routes/ops.js

+ 1 - 0
README.md

@@ -53,6 +53,7 @@
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
+- **解读与报告同源**:驾驶舱的「经营解读」是经营报告 JSON 的摘要视图(同一份生成结果、同一缓存),卡片上可一键跳到同区间的完整报告;报告页「重新生成」会调用 AI 补齐文字,模型不可用时自动退回规则版并标注。
 - **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
 - **经营报告**:按统一 Report JSON 渲染「图文报告 / 交互报告」两版,支持选择日期区间、重新生成、浏览器打印导出 PDF(A4 打印样式已适配)。报告含管理层摘要(带证据)、核心指标、经营/门店/流量/费用四节图表、风险与行动计划、数据质量与方法论。
 - **字段与店铺筛选**:字段下拉可勾选平台导出里的任意列(默认 4 个基础指标),勾几个就显示几列;店铺下拉可多选门店,只看选中的店;选择保存在浏览器本地。

+ 11 - 3
public/cockpit.js

@@ -308,7 +308,9 @@
       });
       if (!res.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + res.status));
       if (data && data.enabled === false) {
-        body.innerHTML = '未配置对话模型。请到「系统设置 → API 设置」选择对话模型并填写 Key,然后回到这里重新生成。';
+        body.innerHTML = data.reason === 'chat-not-configured'
+          ? '未配置对话模型,当前只能显示规则版摘要。请到「系统设置 → API 设置」配置后重新生成。'
+          : '经营解读暂不可用。';
         return;
       }
       if (data && data.note) {
@@ -321,9 +323,15 @@
         return;
       }
       state.insightPoints = points;
-      body.innerHTML = `<ul class="ckpt-ai-list">${points.map((point) => `<li>${esc(point)}</li>`).join('')}</ul>`;
+      const rangeFrom = data.range && data.range.from ? String(data.range.from).replace(/-/g, '') : '';
+      const rangeTo = data.range && data.range.to ? String(data.range.to).replace(/-/g, '') : '';
+      const foot = [
+        data.aiError ? `<span class="ckpt-ai-note">${esc(data.aiError)}</span>` : '',
+        rangeFrom && rangeTo ? `<a class="ckpt-ai-link" href="/?view=report&from=${rangeFrom}&to=${rangeTo}">查看完整报告 →</a>` : '',
+      ].filter(Boolean).join('');
+      body.innerHTML = `<ul class="ckpt-ai-list">${points.map((point) => `<li>${esc(point)}</li>`).join('')}</ul>${foot ? `<div class="ckpt-ai-foot">${foot}</div>` : ''}`;
       const time = data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
-      $('ckptAiMeta').textContent = [data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · ');
+      $('ckptAiMeta').textContent = [data.source === 'llm' ? 'AI 生成' : '规则生成', data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · ');
       $('ckptAiCopy').hidden = false;
     } catch (e) {
       body.innerHTML = `<span class="ckpt-ai-error">生成失败:${esc(e.message || e)}</span>`;

+ 13 - 2
public/report.js

@@ -235,7 +235,7 @@
       renderSections(report),
       renderAlerts(report),
       renderQuality(report),
-      `<footer class="rp-footer">本报告由系统按统一口径自动生成,数值均来自后端计算,模型仅参与文字表述。</footer>`,
+      `<footer class="rp-footer">${report.ai && report.ai.source === 'llm' ? `文字由 AI 生成(${esc(report.ai.model || '')})` : '当前为规则版文字'};数值均由后端按统一口径计算。${report.aiError ? ' ' + esc(report.aiError) : ''}</footer>`,
     ].join('');
     drawCharts(report);
   }
@@ -338,6 +338,7 @@
       const params = new URLSearchParams();
       if (state.from) params.set('from', state.from);
       if (state.to) params.set('to', state.to);
+      if (force) params.set('ai', '1');
       const { res, body } = await api('/api/ops/report?' + params.toString());
       if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
       if (body.empty) {
@@ -354,6 +355,17 @@
   }
 
   async function initDates() {
+    const search = new URLSearchParams(location.search);
+    const qFrom = String(search.get('from') || '').replace(/\D/g, '');
+    const qTo = String(search.get('to') || '').replace(/\D/g, '');
+    const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
+    if (/^\d{8}$/.test(qFrom) && /^\d{8}$/.test(qTo)) {
+      state.from = qFrom;
+      state.to = qTo;
+      $('rpFrom').value = toInput(qFrom);
+      $('rpTo').value = toInput(qTo);
+      return;
+    }
     const { body } = await api('/api/ops/summary');
     const coverage = body && body.coverage;
     if (!coverage) return;
@@ -362,7 +374,6 @@
     const from = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
     state.to = to;
     state.from = from < coverage.from ? coverage.from : from;
-    const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
     $('rpFrom').value = toInput(state.from);
     $('rpTo').value = toInput(state.to);
   }

+ 5 - 2
scripts/test-ops-import.js

@@ -443,14 +443,17 @@ async function main() {
       expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`);
     });
 
-    await check('E2E AI 解读:未配置模型时明确返回', async () => {
+    await check('E2E 经营解读:无模型时退回规则版摘要', async () => {
       const r = await fetch(base + '/api/ops/insights', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({}),
       }).then(readJson);
       expect(r.status === 200, `status ${r.status}`);
-      expect(r.body.enabled === false && r.body.reason === 'chat-not-configured', JSON.stringify(r.body));
+      expect(r.body.enabled === true, JSON.stringify(r.body).slice(0, 120));
+      expect(r.body.source === 'rules', `source ${r.body.source}`);
+      expect(Array.isArray(r.body.points) && r.body.points.length > 0, 'points 为空');
+      expect(!!r.body.aiError, '应提示模型未配置');
     });
 
     await check('E2E 导入记录只含元数据', async () => {

+ 166 - 56
server/routes/ops.js

@@ -9,7 +9,7 @@ const opsImport = require('../../ops-import.js');
 const { createOpsStore } = require('../../ops-store.js');
 const { loadSchools } = require('../../schools-store.js');
 const { makeChatCfg } = require('../context.js');
-const { chatCompletion } = require('../agent/llm.js');
+const { chatCompletion, extractJson } = require('../agent/llm.js');
 
 const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
 const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
@@ -759,6 +759,85 @@ function buildReport(data, meta = {}) {
   };
 }
 
+const REPORT_AI_PROMPT = [
+  '你是连锁餐饮公司的经营分析师。用户会给一段已经计算好的经营数据 JSON(含 kpis/trend/ranking/bottom/funnel/structure/zeroOrder)。',
+  '数值全部由后端计算,你不得修改、杜撰或重算;只做解释、诊断与建议。',
+  '只输出一个 JSON 对象,不要代码块、不要前后缀,结构如下:',
+  '{"overall_assessment":"一段客观判断",',
+  ' "key_findings":[{"severity":"positive|neutral|warning|critical","finding":"结论","evidence":["指标:数值"],"interpretation":"解释","confidence":"high|medium|low"}],',
+  ' "sections":[{"id":"overview|stores|funnel|finance","summary":"本节结论","paragraphs":["事实与解释"]}],',
+  ' "alerts":[{"severity":"P0|P1|P2","title":"告警标题","evidence":["数据证据"],"hypotheses":["待验证原因"],"next_checks":["核查步骤"]}],',
+  ' "action_plan":[{"priority":"P0|P1|P2","action":"具体行动","owner_role":"负责人角色","time_horizon":"时间窗","success_metric":"可衡量指标"}]}',
+  '要求:3~5 条 findings、2~4 条 alerts、1~4 条 action;金额保留两位小数,比率用百分比;',
+  'zeroOrder 非空时必须按原因归类(未开业/闭店/无曝光/无进店/无转化各多少店次)并点明哪类需要运营介入;',
+  '没有上期数据时明确说明不可做趋势判断;不输出「赋能」「抓手」等套话。',
+].join('\n');
+
+/** 把模型返回的叙述字段覆盖到规则版报告上;结构不合法就返回 null(保留规则版) */
+function overlayAiReport(base, ai, meta = {}) {
+  if (!ai || typeof ai !== 'object') return null;
+  const report = JSON.parse(JSON.stringify(base));
+  let touched = false;
+
+  if (typeof ai.overall_assessment === 'string' && ai.overall_assessment.trim()) {
+    report.executive_summary.overall_assessment = ai.overall_assessment.trim();
+    touched = true;
+  }
+  if (Array.isArray(ai.key_findings) && ai.key_findings.length) {
+    const findings = ai.key_findings
+      .filter((f) => f && f.finding)
+      .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) : '',
+        confidence: ['high', 'medium', 'low'].includes(f.confidence) ? f.confidence : 'medium',
+      }));
+    if (findings.length) {
+      report.executive_summary.key_findings = findings;
+      touched = true;
+    }
+  }
+  if (Array.isArray(ai.sections)) {
+    const byId = new Map(report.sections.map((sec) => [sec.id, sec]));
+    for (const sec of ai.sections) {
+      const target = sec && byId.get(sec.id);
+      if (!target) continue;
+      if (sec.summary) target.summary = String(sec.summary).slice(0, 200);
+      if (Array.isArray(sec.paragraphs) && sec.paragraphs.length) {
+        target.paragraphs = sec.paragraphs.slice(0, 4).map((t) => String(t).slice(0, 400));
+      }
+      touched = true;
+    }
+  }
+  if (Array.isArray(ai.alerts) && ai.alerts.length) {
+    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) : [],
+      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) : [],
+    }));
+    touched = true;
+  }
+  if (Array.isArray(ai.action_plan) && ai.action_plan.length) {
+    report.action_plan = ai.action_plan.slice(0, 4).map((p) => ({
+      priority: ['P0', 'P1', 'P2'].includes(p.priority) ? p.priority : 'P2',
+      action: String(p.action || '').slice(0, 160),
+      owner_role: String(p.owner_role || '').slice(0, 40),
+      time_horizon: String(p.time_horizon || '').slice(0, 40),
+      success_metric: String(p.success_metric || '').slice(0, 80),
+      related_alerts: Array.isArray(p.related_alerts) ? p.related_alerts : [],
+    }));
+    touched = true;
+  }
+  if (!touched) return null;
+  report.ai = { source: 'llm', model: meta.model || '', generatedAt: new Date().toISOString() };
+  return report;
+}
+
 const REASON_LABEL = { 'not-open': '未开业', closed: '当日闭店', 'no-traffic': '无曝光', 'no-visit': '无进店', 'no-conversion': '有进店无下单', unknown: '数据不足' };
 
 function formatYmd(ymd) {
@@ -1028,15 +1107,69 @@ function register(app, ctx) {
     }
   });
 
-  const insightCache = new Map();
-  const INSIGHT_TTL_MS = 10 * 60 * 1000;
+  // ---------- 统一报告生成(经营解读与经营报告共用一份 JSON) ----------
+  const REPORT_CACHE_TTL_MS = 10 * 60 * 1000;
+  const reportCache = new Map();
 
-  app.post('/api/ops/insights', async ({ req, res }) => {
-    if (!(await requireHealthy(store, res))) return;
+  async function getReportPayload(params, { ai = false, force = false } = {}) {
+    const key = JSON.stringify(params);
+    const hit = reportCache.get(key);
+    const fresh = hit && Date.now() - hit.at < REPORT_CACHE_TTL_MS;
+    if (!force && fresh && (!ai || hit.source === 'llm')) {
+      return { ...hit.payload, cached: true };
+    }
+
+    const data = await buildDashboard(store, params);
+    if (!data.kpis) return { empty: true };
+
+    const uploads = await store.listUploads().catch(() => []);
+    const base = buildReport(data, {
+      organization: (params.schools && params.schools[0]) || '',
+      sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
+    });
+    base.ai = { source: 'rules', model: '', generatedAt: new Date().toISOString() };
+
+    let payload = base;
+    let source = 'rules';
+    let aiError = '';
     const chatCfg = makeChatCfg(ctx);
-    if (!chatCfg.chatApiKey || chatCfg.chatApiKey === 'your-api-key-here') {
-      return sendJson(res, 200, { enabled: false, reason: 'chat-not-configured' });
+    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 = '模型返回结构不合法,已退回规则版';
+        }
+      } catch (e) {
+        aiError = e.message || String(e);
+      }
+    } else if (ai && !configured) {
+      aiError = '未配置对话模型,当前为规则版';
     }
+    if (aiError) payload.aiError = aiError;
+
+    reportCache.set(key, { at: Date.now(), source, payload });
+    return { ...payload, cached: false };
+  }
+
+  // 经营解读 = 报告 JSON 的摘要视图
+  app.post('/api/ops/insights', async ({ req, res }) => {
+    if (!(await requireHealthy(store, res))) return;
     const body = await readBody(req);
     const params = {
       from: parseYmd(body.from) || undefined,
@@ -1044,64 +1177,41 @@ function register(app, ctx) {
       schools: splitList(body.schools),
       shops: splitList(body.shops),
     };
-    const cacheKey = JSON.stringify(params);
-    const cached = insightCache.get(cacheKey);
-    if (!body.refresh && cached && Date.now() - cached.at < INSIGHT_TTL_MS) {
-      return sendJson(res, 200, { enabled: true, ...cached.value, cached: true });
-    }
-
-    let data;
-    try {
-      data = await buildDashboard(store, params);
-    } catch (e) {
-      return sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
-    }
-    if (!data.kpis) {
-      return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
-    }
-
-    const context = insightContext(data);
-    let text;
     try {
-      text = await chatCompletion({
-        apiBase: chatCfg.chatApiBase,
-        apiKey: chatCfg.chatApiKey,
-        model: chatCfg.chatModel,
-        temperature: 0.3,
-        timeoutMs: 45000,
-        messages: [
-          { role: 'system', content: INSIGHT_SYSTEM_PROMPT },
-          { role: 'user', content: JSON.stringify(context) },
-        ],
+      const report = await getReportPayload(params, { ai: true, force: !!body.refresh });
+      if (report.empty || !report.executive_summary) {
+        return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
+      }
+      const findings = report.executive_summary.key_findings || [];
+      const points = findings.slice(0, 5).map((f) => f.finding);
+      const alerts = (report.alerts || []).slice(0, 3).map((a) => ({ severity: a.severity, title: a.title }));
+      sendJson(res, 200, {
+        enabled: true,
+        points,
+        alerts,
+        source: (report.ai && report.ai.source) || 'rules',
+        model: (report.ai && report.ai.model) || '',
+        generatedAt: (report.ai && report.ai.generatedAt) || new Date().toISOString(),
+        aiError: report.aiError || '',
+        range: { from: report.meta.period_start, to: report.meta.period_end },
+        cached: !!report.cached,
       });
     } catch (e) {
-      return sendJson(res, 502, { enabled: true, error: '模型调用失败', detail: e.message || String(e) });
+      sendJson(res, 502, { enabled: true, error: '生成失败', detail: e.message || String(e) });
     }
-
-    const value = {
-      points: parseInsightPoints(text),
-      model: chatCfg.chatModel,
-      generatedAt: new Date().toISOString(),
-      digest: context,
-    };
-    insightCache.set(cacheKey, { at: Date.now(), value });
-    sendJson(res, 200, { enabled: true, ...value });
   });
 
   app.get('/api/ops/report', async ({ res, query }) => {
     if (!(await requireHealthy(store, res))) return;
-    const from = parseYmd(query.from);
-    const to = parseYmd(query.to);
-    const schools = splitList(query.schools);
-    const shops = splitList(query.shops);
+    const params = {
+      from: parseYmd(query.from) || undefined,
+      to: parseYmd(query.to) || undefined,
+      schools: splitList(query.schools),
+      shops: splitList(query.shops),
+    };
     try {
-      const data = await buildDashboard(store, { from: from || undefined, to: to || undefined, schools, shops });
-      if (!data.kpis) return sendJson(res, 200, { empty: true });
-      const uploads = await store.listUploads().catch(() => []);
-      const report = buildReport(data, {
-        organization: schools.length === 1 ? schools[0] : (schools[0] || ''),
-        sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
-      });
+      const report = await getReportPayload(params, { ai: query.ai === '1', force: query.refresh === '1' });
+      if (report.empty) return sendJson(res, 200, { empty: true });
       sendJson(res, 200, { report });
     } catch (e) {
       sendJson(res, 503, { error: '报告生成失败', detail: e.message || String(e) });