Эх сурвалжийг харах

feat: 问数智能体 P0 收尾 + P1 + P2(晨报/打法缓存/指标层/归因/异常/深度报告)

P0 收尾:
- 晨报主动闭环:digest.js 确定性产出(体检+层间核对+环比,零模型),
  autoSync 后自动生成,问数页顶部横幅(钉钉推送按用户决定暂不做)
- 打法缓存与 few-shot:replay.js 字符二元组 Dice 相似度,成功运行
  (全溯源+工具全成)沉淀查询序列,相似问 ≥0.82 直接重跑(数据按当前湖
  重算)+ 单次收敛;0.6~0.82 注入 few-shot;仅真实模型模式启用

P1:
- 指标层:metrics.js(合计/加权/比值口径 + gross_mov7 等派生指标)+
  metric_query 工具(移动平均窗口前置、缺数按实算),模型引用指标 key
- Schema linking:门店 ≤40 内嵌清单,超过改按需查询(省 token 聚焦)
- 模型路由与结构化解码:chatModelAgent(深度分析专用)/chatJsonMode
  (response_format=json_object,默认关),设置页对话模型 tab 两控件
- 反馈闭环:答案卡 采纳(打法入语料)/纠错(feedback.json 供评测提炼)

P2:
- detect_anomalies:逐日 leave-one-out z-score 异常检测(σ 下限防误报,
  寒暑假基线失真提示),verdict 新增 anomaly 类
- attribution:LMDI 归因(曝光/进店率/客单价三效应分解,可比口径,
  单店恒等式严格、多店输出混合残差)
- 深度报告:build_report deep 旗 → report-deep 端点(自包含 HTML 八章节:
  体检/环比/归因/异常/对标/逐日/排行/verdict 汇总)
- 治理:run 级 token 用量累计与时间线成本展示

回归:eval 24/24(口径金标准)+ smoke 100/100。物化聚合与多主体授权
按既定触发条件暂缓(见 docs/问数迭代路线.md)
KenZ1117 2 долоо хоног өмнө
parent
commit
a9d7471445

+ 18 - 14
docs/问数迭代路线.md

@@ -16,24 +16,28 @@
 | 项 | 内容 | 落点 | 状态 |
 | --- | --- | --- | --- |
 | 评测金标准集 | 真实业务问句 → 期望工具/参数/关键数字的口径断言;改 prompt/工具/模型后跑一遍即知退化 | `npm run eval`(= scripts/eval-ask.js,隔离湖直跑工具链,零网络)。已覆盖 15 例:TopN/按日趋势/加权比率/门店部分匹配/环比可比集/显式区间与缺数实算/体检/层间核对/同行对标/源侧字段/跨主体/报告钳制/参数防呆 + 真机问句清单 | ✅ 2026-10-17(上线即抓到 query_report shops/shopNames 参数键不一致的真 bug) |
-| 主动式分析闭环 | autoSync 后自动 体检+层间核对+环比 → 钉钉群机器人晨报卡片(verdict 天然带可信度标签) | 复用 autoSync 调度 + agent 工具 + aitable 凭据 | ⬜ |
-| 动态 few-shot + 问答复用 | 人工追问收敛的问答对沉淀为「已验证语料」;相似问命中直接复用查询参数(秒回零成本),未命中注入 few-shot | sessions.json 扩展 + 相似度匹配(先编辑距离/关键词,够用) | ⬜ |
+| 主动式分析闭环 | autoSync 后自动 体检+层间核对+环比 → 晨报落盘(钉钉群机器人推送按用户决定暂不做,通道后续按需接) | `server/agent/digest.js`(确定性口径零模型)+ autoSync 挂点 + `GET/POST /api/agent/digests` + 问数页顶部晨报横幅(头条/展开详情/verdict 卡/报告链接) | ✅ 2026-10-17 |
+| 动态 few-shot + 问答复用 | 已验证问答沉淀为打法语料(数字全溯源 + 工具全成才入库);相似问命中直接重跑查询序列(缓存打法不缓存数字,数据按当前湖重算),只保留一次结论收敛调用;次相似问注入 few-shot 参考 | `server/agent/replay.js`(字符二元组 Dice 相似度)+ corpus.json(cap 50)+ loop 复用路径 + prompt 注入 | ✅ 2026-10-17 |
 
-## P1(答题质量与成本)
+## P1(答题质量与成本)—— 2026-10-17 全部完成
 
-| 项 | 内容 | 落点 |
-| --- | --- | --- |
-| 指标层升级 | 列注册表 → 指标对象(维度×聚合×时间粒度×口径),支持基于基础指标动态派生(同店环比/7日移动平均/周内节律归一),模型引用指标 key 而非自组口径 | REPORT_COLUMNS 旁新建 metrics 注册表,query/compare 工具参数引用 |
-| Schema linking | 门店清单不再 300 行常驻 prompt:按问题检索 top-k 门店 + 别名表(俗称/简称);指标说明按相关性裁剪 | prompt.js + 新工具 resolve_shops |
-| 模型路由与结构化解码 | 快问 flash / 深度分析可配更强模型;网关支持 JSON mode 时动作协议走结构化输出;流式打字机 | llm.js + context.js 模型配置 |
-| 反馈闭环 | 答案卡「采纳/纠错」,纠错进入评测集与 few-shot 池(数据飞轮) | ask.js + sessions.json |
+| 项 | 内容 | 落点 | 状态 |
+| --- | --- | --- | --- |
+| 指标层升级 | `server/agent/metrics.js`:基础指标(合计/加权/比值口径声明)+ 派生指标(gross_mov7/orders_mov7/uv_mov7);新工具 `metric_query` 按指标 key 查逐日序列(移动平均窗口前置取数、缺数按实算);prompt 常驻「指标聚合口径」段,模型引用 key 不自组口径 | tools/metric.js + registry(工具总数 11) | ✅(eval 4 例:mov7 手算断言、加权日值、未知指标给清单) |
+| Schema linking | 门店 ≤40 家内嵌清单;超过则 prompt 只留「门店较多,先调 lake_overview/部分匹配」,按需查询 | prompt.js buildSystemPrompt | ✅(smoke:3 家内嵌 / 60 家改按需) |
+| 模型路由与结构化解码 | config 新增 `chatModelAgent`(深度分析专用模型,缺省回落 chatModel)与 `chatJsonMode`(response_format=json_object,默认关);设置页对话模型 tab 两控件;快问始终走 chatModel | context.js makeChatCfg + llm.js jsonMode + loop 模型路由 + 设置 UI | ✅(smoke:路由回落断言;流式仍延后) |
+| 反馈闭环 | 答案卡「👍 有帮助 / ✍️ 纠错」:采纳 → 打法强制入语料(即使溯源不完美);纠错(可带说明)→ feedback.json 反馈日志(cap 100)供金标准评测人工提炼 | POST /api/agent/feedback + ask.js 按钮 | ✅(smoke:采纳入 corpus、纠错入日志) |
 
-## P2(平台化,按需)
+## P2(平台化,按需)—— 2026-10-17 完成(两项按既定条件暂缓)
 
-- 统计异常检测:移动平均 ±kσ、季节修正寒暑假基线,verdict 新增「统计异常」类;LMDI 归因从趋势模板抽成 agent 工具
-- 深度报告 agent:周报自动生成(plan → 并行子查询 → 汇总 HTML/钉钉文档)
-- 物化聚合:按月/店/日预聚合物;换查询引擎沿用既定触发条件(单主体 >50MB / 卡顿 / 跨主体 SQL)
-- 治理:多用户按主体授权、run 成本展示与预算联动(tokens 已捕获)
+| 项 | 内容 | 落点 | 状态 |
+| --- | --- | --- | --- |
+| 统计异常检测 | `detect_anomalies` 工具:加性指标逐日 leave-one-out z-score(σ 下限防全同值误报,|z|≥2 警告/≥3 不一致),寒暑假窗口基线失真提示;verdict 新增 anomaly 类 | tools/anomaly.js + eval(尖峰 fixture z=20 判 fail、健康序列零误报) | ✅ |
+| LMDI 归因工具化 | `attribution` 工具:营业额变化分解为 曝光/进店率/客单价 三效应(对数平均权重),可比口径;单店恒等式严格成立、多店输出混合残差 | tools/attribution.js + eval(单店 曝光效应=1600 全额、残差≈0) | ✅ |
+| 深度报告 | `build_report` deep 旗 → `GET /api/agent/report-deep`(`server/agent/deep-report.js` 确定性自包含 HTML:体检/环比/LMDI 归因/异常/对标/逐日/排行/verdict 汇总 八章节) | deep-report.js + 路由 + 工具 | ✅ |
+| 治理·成本展示 | run 级 token 用量累计(loop 累加 usage),编排时间线头部展示「X k in / Y out」 | loop.js + ask.js | ✅ |
+| 物化聚合 | 按既定触发条件(单主体 >50MB/查询卡顿/跨主体 SQL)再换引擎;reports-store 已有分片解析缓存(mtime 键控) | — | ⏸ 触发条件未到 |
+| 多主体授权 | 单机 + 访问口令形态下无多租户边界,暂不适用;SaaS 化时随语义层一起做 | — | ⏸ 形态不适用 |
 
 ## 不追的方向
 

+ 4 - 0
public/app.js

@@ -77,6 +77,8 @@
       : '登录 melody.shop.ele.me 后从 DevTools 复制';
     document.getElementById('settingsChatModel').value = settings.chatModel || 'deepseek-v4.1-flash';
     document.getElementById('settingsChatApiBase').value = settings.chatApiBase || '';
+    document.getElementById('settingsChatModelAgent').value = settings.chatModelAgent || '';
+    document.getElementById('settingsChatJsonMode').checked = !!settings.chatJsonMode;
     const chatKeyInput = document.getElementById('settingsChatApiKey');
     chatKeyInput.value = settings.chatApiKey || '';
     chatKeyInput.placeholder = settings.chatKeyFallback
@@ -133,6 +135,8 @@
       apiKey: val('settingsApiKey'),
       model: document.getElementById('settingsModel').value,
       chatModel: document.getElementById('settingsChatModel').value,
+      chatModelAgent: document.getElementById('settingsChatModelAgent').value,
+      chatJsonMode: document.getElementById('settingsChatJsonMode').checked,
       ...(hasNewSecret('settingsChatApiKey') ? { chatApiKey: val('settingsChatApiKey') } : {}),
     }), 'btnSaveApi', 'apiSaveStatus');
   }

+ 79 - 2
public/ask.js

@@ -77,8 +77,49 @@
     await loadEntities();
     await loadSessions();
     await openLatestSession();
+    await loadDigest();
   };
 
+  /** 晨报横幅:最近一次确定性体检+环比结论(每日自动同步后生成,可手动重建) */
+  async function loadDigest() {
+    const wrap = $('askDigest');
+    wrap.hidden = true;
+    wrap.innerHTML = '';
+    if (!state.entity) return;
+    let latest = null;
+    try {
+      const data = await jsonFetch(`/api/agent/digests?entity=${encodeURIComponent(state.entity)}`);
+      latest = (data.digests || [])[0] || null;
+    } catch { return; }
+    if (!latest) return;
+    const at = new Date(latest.at);
+    const stamp = `${at.getMonth() + 1}/${at.getDate()} ${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
+    const dotCls = latest.status === 'fail' ? 'failed' : (latest.status === 'warn' ? 'running' : 'done');
+    wrap.hidden = false;
+    wrap.innerHTML = `
+      <div class="ask-digest-bar" role="button" tabindex="0">
+        <span class="ask-dot ${dotCls}"></span>
+        <span class="ask-digest-label">晨报 · ${esc(stamp)}</span>
+        <span class="ask-digest-headline">${esc(latest.headline)}</span>
+        <span class="ask-digest-toggle">详情 ▾</span>
+      </div>
+      <div class="ask-digest-body" hidden>
+        <pre class="ask-pre">${esc((latest.sections || []).map((s) => `[${s.ok ? 'ok' : '失败'}] ${s.summary}`).join('\n\n'))}</pre>
+        ${(latest.verdicts || []).length ? `<div class="ask-verdicts">${latest.verdicts.map((v) => `
+          <div class="ask-verdict ${v.status}">
+            <span class="ask-verdict-status">${{ ok: '通过', warn: '警告', fail: '不一致' }[v.status] || esc(v.status)}</span>
+            <span class="ask-verdict-check">${esc(v.check)}</span>
+            <span class="ask-verdict-detail">${esc(v.detail || '')}</span>
+          </div>`).join('')}</div>` : ''}
+        ${latest.reportUrl ? `<div class="ask-actions"><a class="btn btn-ghost btn-small" href="${esc(latest.reportUrl)}" target="_blank" rel="noopener">查看分析报告</a></div>` : ''}
+      </div>`;
+    wrap.querySelector('.ask-digest-bar').addEventListener('click', () => {
+      const body = wrap.querySelector('.ask-digest-body');
+      body.hidden = !body.hidden;
+      wrap.querySelector('.ask-digest-toggle').textContent = body.hidden ? '详情 ▾' : '收起 ▴';
+    });
+  }
+
   async function loadSkills() {
     const sel = $('askSkill');
     try {
@@ -341,7 +382,8 @@
     const TOOL_LABEL = {
       lake_overview: '湖概览', query_report: '查询报表', compare_periods: '同环比对比',
       data_quality: '数据体检', cross_check: '层间核对', raw_inspect: '源侧字段', peer_benchmark: '同行对标',
-      cross_entity: '跨主体对比', aitable_verify: '湖云比对',
+      cross_entity: '跨主体对比', aitable_verify: '湖云比对', build_report: '分析报告', metric_query: '指标查询',
+      detect_anomalies: '异常检测', attribution: '归因分析',
     };
     const VERDICT_WORD = { ok: '通过', warn: '警告', fail: '不一致' };
     const skillName = rec.skill ? ((state.skills.find((s) => s.key === rec.skill) || {}).name || rec.skill) : '';
@@ -352,9 +394,14 @@
     const dotCls = rec.status === 'running' ? 'running' : (rec.status === 'failed' ? 'failed' : 'done');
     const head = [];
     head.push(`<div class="ask-meta">问数 · ${esc(fmtClock(rec.startedAt))}</div>`);
+    const usage = rec.usage || {};
+    const costBits = [];
+    if (usage.tokensIn) costBits.push(`${fmtTokens(usage.tokensIn)} in / ${fmtTokens(usage.tokensOut)} out`);
     head.push(`<div class="ask-worked"><span class="ask-dot ${dotCls}"></span><span>${STATUS_WORD[rec.status] || esc(rec.status)}`
       + `${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`
+      + (costBits.length ? ` · ${costBits.join(' · ')}` : '')
       + `${rec.autoPicked ? ` · 自动→${rec.mode === 'quick' ? '快问' : '深度分析'}` : ''}`
+      + `${rec.replayFrom ? ' · 复用打法' : ''}`
       + `${skillName ? ` · 技能「${esc(skillName)}」` : ''}</span></div>`);
 
     // 步骤时间线:思考 · Turn N(耗时/tokens)、工具(耗时/载荷/行数)
@@ -364,7 +411,7 @@
         const bits = [fmtDuration(step.ms)];
         if (step.tokensIn != null) bits.push(`${fmtTokens(step.tokensIn)} in / ${fmtTokens(step.tokensOut)} out`);
         steps.push(`<div class="ask-step">
-          <div class="ask-step-head"><span class="ask-dot done"></span><b>思考 · Turn ${step.turn || ''}</b></div>
+          <div class="ask-step-head"><span class="ask-dot done"></span><b>思考${step.turn ? ` · Turn ${step.turn}` : ''}</b></div>
           <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
           ${step.text ? `<div class="ask-step-body">${esc(step.text)}</div>` : ''}
         </div>`);
@@ -422,6 +469,16 @@ ${esc(step.summary || step.error || '')}</pre>
       if (result.reportUrl) {
         parts.push(`<div class="ask-actions"><a class="btn btn-ghost btn-small" href="${esc(result.reportUrl)}" target="_blank" rel="noopener">查看分析报告</a></div>`);
       }
+      // 反馈闭环:采纳 → 打法入语料;纠错 → 反馈日志供评测提炼
+      if (rec.status === 'done') {
+        const fb = rec.feedback;
+        if (fb && fb.kind) {
+          parts.push(`<div class="ask-note">已反馈${fb.kind === 'up' ? '有帮助' : '纠错'}${fb.note ? ':' + esc(fb.note) : ''}</div>`);
+        } else {
+          parts.push(`<div class="ask-actions ask-fb"><button type="button" class="btn btn-ghost btn-small" data-fb="up" data-run="${esc(rec.runId)}">👍 有帮助</button>`
+            + `<button type="button" class="btn btn-ghost btn-small" data-fb="down" data-run="${esc(rec.runId)}">✍️ 纠错</button></div>`);
+        }
+      }
     }
     parts.push('</div>');
 
@@ -441,6 +498,25 @@ ${esc(step.summary || step.error || '')}</pre>
         if (applied && window.shanReportsApplyQuery) window.shanReportsApplyQuery(applied);
       });
     }
+    wrap.querySelectorAll('[data-fb]').forEach((btn) => {
+      btn.addEventListener('click', async () => {
+        const kind = btn.dataset.fb;
+        let note = '';
+        if (kind === 'down') {
+          note = window.prompt('请描述哪里不对(会进入改进清单,可留空)');
+          if (note === null) return;
+        }
+        try {
+          const data = await jsonFetch('/api/agent/feedback', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ entity: state.entity, runId: rec.runId, kind, note }),
+          });
+          Object.assign(rec, { feedback: data.run.feedback });
+          renderRunInto(wrap, rec);
+        } catch { /* 反馈失败不打扰 */ }
+      });
+    });
     const stream = $('askStream');
     stream.scrollTop = stream.scrollHeight;
   }
@@ -513,6 +589,7 @@ ${esc(step.summary || step.error || '')}</pre>
       state.sessionId = null;
       state.sessions = [];
       renderStageHead();
+      loadDigest();
       openLatestSession();
     });
     $('askMode').addEventListener('click', (event) => {

+ 18 - 0
public/index.html

@@ -567,6 +567,7 @@
               <span class="ask-stage-badge" id="askStageBadge"></span>
             </div>
             <div class="ask-stage-sub">智能体自动取数、做同环比对比与数据体检;结论均来自本地数据湖,过程与数据质量判定全程可见</div>
+            <div id="askDigest" class="ask-digest" hidden></div>
             <div class="ask-stream" id="askStream">
               <div class="ask-hero" id="askEmpty">
                 <div class="ask-hero-title">给问数一个目标</div>
@@ -673,6 +674,23 @@
                       <option value="deepseek-v4.1-flash">deepseek-v4.1-flash</option>
                       <option value="glm-5.3-flash">glm-5.3-flash</option>
                     </select>
+                    <div class="help">用于快问与问数结论;深度分析缺省也用它</div>
+                  </div>
+                  <div class="field">
+                    <label>深度分析模型(可选)</label>
+                    <select id="settingsChatModelAgent">
+                      <option value="">跟随对话模型</option>
+                      <option value="deepseek-v4.1-flash">deepseek-v4.1-flash</option>
+                      <option value="glm-5.3-flash">glm-5.3-flash</option>
+                    </select>
+                    <div class="help">问数「深度分析」多步规划用的模型,缺省跟随上方对话模型</div>
+                  </div>
+                  <div class="field">
+                    <label>动作 JSON mode</label>
+                    <label style="display:flex;align-items:center;gap:6px;margin:0;">
+                      <input type="checkbox" id="settingsChatJsonMode" />
+                      <span>启用 response_format=json_object(网关支持时更稳,报错请关闭)</span>
+                    </label>
                   </div>
                   <div class="settings-links">
                     <button type="button" class="settings-link settings-link-btn" id="btnTestChat">

+ 12 - 0
public/style.css

@@ -1034,6 +1034,16 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ask-tool summary::before { content: '▸'; font-size: 10px; color: var(--text-3); }
 .ask-tool[open] summary::before { content: '▾'; }
 
+/* 晨报横幅(每日自动同步后的确定性体检+环比结论) */
+.ask-digest { border-bottom: 1px solid var(--border-light); background: #fffaf5; }
+.ask-digest[hidden] { display: none; }
+.ask-digest-bar { display: flex; align-items: center; gap: 8px; padding: 8px 18px; cursor: pointer; }
+.ask-digest-bar:hover { background: #fff5ec; }
+.ask-digest-label { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--accent); }
+.ask-digest-headline { flex: 1; min-width: 0; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ask-digest-toggle { flex: 0 0 auto; font-size: 11px; color: var(--text-3); }
+.ask-digest-body { padding: 4px 18px 12px; display: flex; flex-direction: column; gap: 8px; }
+
 /* verdict 卡(数据体检/层间核对/恒等式等校验结论) */
 .ask-verdicts { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
 .ask-verdict {
@@ -1053,6 +1063,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 
 /* 数字溯源警示(不阻断) */
 .ask-provenance { margin-top: 8px; padding: 6px 10px; border-radius: 8px; background: #fff8e6; color: #9a6b00; font-size: 11.5px; line-height: 1.6; word-break: break-word; }
+.ask-fb { gap: 6px; }
+.ask-fb .btn { padding: 2px 8px; font-size: 11px; }
 .ask-pre {
   margin: 4px 0 0; padding: 8px 10px; background: #faf9f7; border: 1px solid var(--border-light);
   border-radius: 8px; font-size: 11.5px; line-height: 1.6; color: var(--text-2);

+ 66 - 4
scripts/eval-ask.js

@@ -101,10 +101,16 @@ function buildFixture(lakeDir) {
   const byMonth = (list) => JSON.stringify({ version: 2, updated: now, rows: list });
   fs.writeFileSync(path.join(lakeDir, 'eval', 'daily-202609.json'), byMonth(rows.filter((r) => r.date < '20261000')));
   fs.writeFileSync(path.join(lakeDir, 'eval', 'daily-202610.json'), byMonth(rows.filter((r) => r.date >= '20261000')));
-  // 对照主体:本期每天 100 元(跨主体对比第二名)
-  fs.mkdirSync(path.join(lakeDir, 'evalb'), { recursive: true });
-  const bRows = curDays.map((d) => mkRow(d, 'shop', { shopId: '201', shopName: '样本店', chainId: '8', chainName: '第二餐厅' }, { gross: 100, income: 80, expense: 20, orders: 10, uv: 300, clk: 0.03 }));
-  fs.writeFileSync(path.join(lakeDir, 'evalb', 'daily-202610.json'), byMonth(bRows));
+  // 异常检测主体:6 天 1000 + 尾日 3000(统计尖峰,LOO z ≈ 6.2)
+  fs.mkdirSync(path.join(lakeDir, 'evalc'), { recursive: true });
+  const cRows = curDays.map((d) => mkRow(d, 'shop', { shopId: '301', shopName: '爆量店', chainId: '8', chainName: '第二餐厅' }, { gross: d === '20261007' ? 3000 : 1000, income: d === '20261007' ? 2400 : 800, expense: d === '20261007' ? 600 : 200, orders: 100, uv: 2000, clk: 0.08 }));
+  fs.writeFileSync(path.join(lakeDir, 'evalc', 'daily-202610.json'), byMonth(cRows));
+  // entities.json 追加 evalc
+  const entities = JSON.parse(fs.readFileSync(path.join(lakeDir, 'entities.json'), 'utf8'));
+  entities.entities.push({ key: 'evalc', firstSeen: now, lastSeen: now, merchantId: 'evalc', name: '异常主体', chainCount: 1, shopCount: 1,
+    chains: [{ chainId: '8', chainName: '第二餐厅' }],
+    shops: [{ shopId: '301', shopName: '爆量店', chainId: '8', chainName: '第二餐厅' }] });
+  fs.writeFileSync(path.join(lakeDir, 'entities.json'), JSON.stringify(entities));
 }
 
 // ---------- 评测执行 ----------
@@ -197,6 +203,32 @@ function main() {
     expect(gross.cur === 3700 && gross.prev === 1400, `环比数字 ${JSON.stringify(gross)}`);
   });
 
+  // ---- 指标层(派生指标与聚合口径) ----
+  check('指标层:基础指标逐日序列(按门店行聚合)', () => {
+    const out = run('metric_query', { metric: 'grossAmt', from: '20261001', to: '20261003' });
+    const v = Object.fromEntries(out.data.series.map((s) => [s.date, s.value]));
+    expect(v['20261001'] === 1500 && v['20261003'] === 1500, JSON.stringify(out.data.series));
+  });
+  check('指标层:7 日移动平均(窗口前置 + 缺数按实算)', () => {
+    const out = run('metric_query', { metric: 'gross_mov7', from: '20261001', to: '20261007' });
+    const v = Object.fromEntries(out.data.series.map((s) => [s.date, s.value]));
+    // 1007:mean(1001..1007) = (1500×3 + 1800×4)/7 ≈ 1671.43
+    expect(near(v['20261007'], 11700 / 7, 0.01), `1007 mov7 ${v['20261007']}`);
+    // 1001:窗口 0925(缺)/0926(1400)/0927(900)/0928(1400)/0929(1400)/0930(1400)/1001(1500) → 8000/6 ≈ 1333.33
+    expect(near(v['20261001'], 8000 / 6, 0.01), `1001 mov7 ${v['20261001']}`);
+  });
+  check('指标层:加权比率日值(进店率按当日曝光加权)', () => {
+    const out = run('metric_query', { metric: 'clkExpRate', from: '20261001', to: '20261001' });
+    const v = out.data.series[0].value;
+    // 1001:一 (0.10×2000) + 二 (0.02×1000) = 220 / 3000 ≈ 0.0733(三食堂 1004 才开业)
+    expect(near(v, 220 / 3000, 0.0001), `加权日值 ${v}`);
+  });
+  check('指标层:未知指标被拒绝并列出可用清单', () => {
+    let rejected = false;
+    try { run('metric_query', { metric: 'no_such' }); } catch (e) { rejected = /可用指标/.test(e.message); }
+    expect(rejected, '未知指标未拒绝或未给清单');
+  });
+
   // ---- 质量与校验 ----
   check('数据体检:缺 1/14 天为 info、无全零误报', () => {
     const out = run('data_quality', {});
@@ -238,6 +270,36 @@ function main() {
     expect(out.data.url.includes('entity=eval') && out.data.url.includes('from=20260924') && out.data.url.includes('to=20261007'),
       out.data.url);
   });
+  check('分析报告:deep 旗切换到深度报告端点', () => {
+    const out = run('build_report', { deep: true });
+    expect(out.data.url.includes('/api/agent/report-deep'), out.data.url);
+  });
+
+  // ---- 统计异常检测与 LMDI 归因 ----
+  check('异常检测:尖峰日 LOO z 判 fail,正常日不误报', () => {
+    const ctxC = { svc, entityKey: 'evalc', meta: svc.store.getEntity('evalc'), shops: svc.store.getEntity('evalc').shops || [], dateRange: nlQuery.lakeDateRange('evalc', svc) };
+    const out = registry.get('detect_anomalies').handler(ctxC, registry.get('detect_anomalies').sanitize({ metric: 'grossAmt', from: '20261001', to: '20261007' }, ctxC));
+    expect(out.data.anomalies.length === 1 && out.data.anomalies[0].date === '20261007', JSON.stringify(out.data.anomalies));
+    // LOO 基线 = 其余 6 天(均值 1000、σ 下限 100)→ z = 2000/100 = 20
+    expect(out.data.anomalies[0].z >= 10, `z ${out.data.anomalies[0].z}`);
+    expect(out.data.verdicts[0].status === 'fail' && out.data.verdicts[0].kind === 'anomaly', JSON.stringify(out.data.verdicts));
+  });
+  check('异常检测:健康序列不误报', () => {
+    const out = run('detect_anomalies', { metric: 'grossAmt', from: '20261001', to: '20261007' });
+    expect(out.data.anomalies.length === 0 && out.data.verdicts[0].status === 'ok', JSON.stringify(out.data.anomalies));
+  });
+  check('LMDI 归因:单店且仅曝光变化时 曝光效应=全部变化(残差≈0)', () => {
+    const out = run('attribution', { from: '20261001', to: '20261007', shops: ['一食堂'] });
+    const uv = out.data.factors.find((f) => f.key === 'expUv');
+    const clk = out.data.factors.find((f) => f.key === 'clkExpRate');
+    expect(near(uv.contribution, 1600, 0.5), `曝光效应 ${uv.contribution}`);
+    expect(Math.abs(clk.contribution) < 0.01, `进店率效应应为 0:${clk.contribution}`);
+    expect(Math.abs(out.data.residual) < 1, `残差 ${out.data.residual}`);
+  });
+  check('LMDI 归因:多店聚合输出混合残差说明', () => {
+    const out = run('attribution', {});
+    expect(out.data.residual != null && out.data.factors.length === 3, JSON.stringify(out.data.factors));
+  });
 
   // ---- 参数防呆 ----
   check('防呆:不存在的门店/非法字段/缺日期必须拒绝', () => {

+ 68 - 0
scripts/smoke.js

@@ -423,6 +423,39 @@ async function main() {
       const bad = prov.checkProvenance('本期营业额 99999 元,环比 +25%。', steps);
       expect(bad.untraced.length === 1 && bad.untraced[0] === 99999, `编造数字未被标记:${JSON.stringify(bad)}`);
     });
+    await check('指标层 → 清单注入 prompt;未知指标给出可用清单', async () => {
+      const metricsLib = require('../server/agent/metrics.js');
+      const agentPrompt = require('../server/agent/prompt.js');
+      const base = {
+        svc: naposServiceLib, entityName: '测试主体', shops: [], dateRange: { from: '20260101', to: '20260201' },
+        today: '20260201', toolsText: '- t:测试工具', skillsText: '', metricsText: metricsLib.manifestText(),
+      };
+      const p = agentPrompt.buildSystemPrompt(base);
+      expect(p.includes('指标聚合口径') && p.includes('gross_mov7') && p.includes('移动平均'), '派生指标清单未注入');
+      const tool = require('../server/agent/tools/metric.js');
+      let listed = false;
+      try { tool.metricSanitize({ metric: 'nope' }, { shops: [], dateRange: base.dateRange }); } catch (e) { listed = /可用指标/.test(e.message); }
+      expect(listed, '未知指标未给可用清单');
+    });
+    await check('Schema linking → 门店 ≤40 内嵌清单、多了改为按需查询', async () => {
+      const agentPrompt = require('../server/agent/prompt.js');
+      const mk = (n) => Array.from({ length: n }, (_, i) => ({ shopId: String(i), shopName: `测试门店${i}` }));
+      const base = {
+        svc: naposServiceLib, entityName: '测试主体', dateRange: { from: '20260101', to: '20260201' },
+        today: '20260201', toolsText: '- t', skillsText: '', metricsText: '',
+      };
+      const small = agentPrompt.buildSystemPrompt({ ...base, shops: mk(3) });
+      expect(small.includes('测试门店2'), '小主体未内嵌门店清单');
+      const big = agentPrompt.buildSystemPrompt({ ...base, shops: mk(60) });
+      expect(big.includes('门店较多') && !big.includes('测试门店2'), '大主体未改为按需查询');
+    });
+    await check('模型路由 → chatModelAgent 缺省回落主模型、JSON mode 默认关', async () => {
+      const { makeChatCfg } = require('../server/context.js');
+      const cfg1 = makeChatCfg({ config: { chatModel: 'm1', chatModelAgent: 'm2', chatJsonMode: true, apiBase: 'http://x', apiKey: 'k' } });
+      expect(cfg1.chatModel === 'm1' && cfg1.chatModelAgent === 'm2' && cfg1.chatJsonMode === true, JSON.stringify(cfg1));
+      const cfg2 = makeChatCfg({ config: { chatModel: 'm1', apiBase: 'http://x', apiKey: 'k' } });
+      expect(cfg2.chatModelAgent === 'm1' && cfg2.chatJsonMode === false, JSON.stringify(cfg2));
+    });
 
     // ---- 安全加固回归(纯函数,不落盘、不打外部 API) ----
     const { createContext } = require('../server/context.js');
@@ -679,6 +712,28 @@ async function main() {
           const run2 = await waitRunDone(r2.json().run.runId, 'smokeask');
           expect(run2.status === 'done' && run2.mode === 'agent' && run2.autoPicked === true, `分析问未分流深度:mode=${run2.mode}`);
         });
+        await check('晨报:确定性生成(环比头条 + verdicts)并可读取', async () => {
+          const b = await request(aport, 'POST', '/api/agent/digests/build', { entity: 'smokeask' });
+          expect(b.status === 200 && b.json().digest, `status=${b.status}`);
+          const d = b.json().digest;
+          expect(d.headline.includes('10,500'), `头条数字异常:${d.headline}`);
+          expect((d.verdicts || []).length >= 5 && (d.sections || []).length === 3, `晨报构成异常:sections=${(d.sections || []).length}`);
+          const list = await request(aport, 'GET', `/api/agent/digests?entity=${enc('smokeask')}`);
+          expect(list.json().digests.length === 1 && list.json().digests[0].id === d.id, '晨报列表读取异常');
+        });
+        await check('反馈闭环:采纳入语料、纠错入日志', async () => {
+          const up = await request(aport, 'POST', '/api/agent/feedback', { entity: 'smokeask', runId: askRunId, kind: 'up' });
+          expect(up.status === 200 && up.json().run.feedback.kind === 'up', `status=${up.status}`);
+          expect(fs.existsSync(path.join(tmpAgent, 'smokeask', 'corpus.json')), '采纳未入打法语料');
+          const down = await request(aport, 'POST', '/api/agent/feedback', { entity: 'smokeask', runId: askRunId, kind: 'down', note: '数字不对' });
+          expect(down.status === 200 && down.json().run.feedback.note === '数字不对', '纠错未落 run');
+          expect(fs.existsSync(path.join(tmpAgent, 'smokeask', 'feedback.json')), '纠错未入反馈日志');
+        });
+        await check('深度分析报告端点 → 自包含 HTML(体检/异常章节)', async () => {
+          const r = await request(aport, 'GET', `/api/agent/report-deep?entity=${enc('smokeask')}&from=20260924&to=20261007`);
+          expect(r.status === 200 && /text\/html/.test(r.headers['content-type'] || ''), `status=${r.status}`);
+          expect(r.text.includes('数据体检') && r.text.includes('统计异常') && r.text.includes('归因'), '深度报告章节缺失');
+        });
         await check('智能体 工具直测(隔离子进程)→ cross_check/peer_benchmark/raw_inspect', async () => {
           const toolProbe = [
             `const ROOT = ${JSON.stringify(ROOT)};`,
@@ -705,6 +760,17 @@ async function main() {
             'const cloudTool = require(ROOT + "/server/agent/tools/cloud.js");',
             'cloudTool.cloudHandler(healthy, cloudTool.cloudSanitize({}, healthy)).then((c3) => {',
             '  out.cloud = { available: c3.data.available, hasVerdict: Array.isArray(c3.data.verdicts) && c3.data.verdicts.length === 1 };',
+            '  const replay = require(ROOT + "/server/agent/replay.js");',
+            '  const cfile = require("os").tmpdir() + "/genpic-replay-probe-" + process.pid + ".json";',
+            '  replay.record(cfile, { question: "最近7天营业额和单量趋势", steps: [{ tool: "query_report", args: { mode: "date" } }, { tool: "data_quality", args: {} }] });',
+            '  replay.record(cfile, { question: "上月营业额Top5门店", steps: [{ tool: "query_report", args: { mode: "shop", limit: 5 } }] });',
+            '  const plan = replay.findPlan(cfile, "最近7天营业额和单量的趋势");',
+            '  out.replay = {',
+            '    hit: !!plan && plan.steps.length === 1 && plan.steps[0].tool === "query_report",',
+            '    few: replay.fewShot(cfile, "最近7天营业额怎么样").length,',
+            '    miss: !!replay.findPlan(cfile, "帮我做个数据体检"),',
+            '  };',
+            '  require("fs").unlinkSync(cfile);',
             '  console.log(JSON.stringify(out));',
             '});',
           ].join('\n');
@@ -725,6 +791,8 @@ async function main() {
             `跨主体对比异常:${JSON.stringify(out.entity)}`);
           expect(out.cloud.available === false && out.cloud.hasVerdict === true,
             `云端未启用未优雅降级:${JSON.stringify(out.cloud)}`);
+          expect(out.replay.hit === true && out.replay.few >= 1 && out.replay.miss === false,
+            `打法缓存异常:${JSON.stringify(out.replay)}`);
         });
         await check('智能体 空问题 → 400 ASK_EMPTY', async () => {
           const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '   ' });

+ 115 - 0
server/agent/deep-report.js

@@ -0,0 +1,115 @@
+'use strict';
+
+/**
+ * 智能问数 · 深度分析报告(确定性生成,零模型)
+ * ----------------------------------------------------------------------------
+ * GET /api/agent/report-deep?entity&from&to → 自包含 HTML:
+ * KPI 概览 / 逐日序列 / 门店排行 / 环比归因 / 数据体检 verdicts / 统计异常 / 同行对标。
+ * 全部章节复用问数工具的确定性口径(同源不打折),build_report 工具的 deep 旗指向这里。
+ */
+
+const nlQuery = require('../../nl-query.js');
+const lake = require('./tools/lake.js');
+const cross = require('./tools/cross.js');
+const compare = require('./tools/compare.js');
+const anomaly = require('./tools/anomaly.js');
+const attribution = require('./tools/attribution.js');
+const peer = require('./tools/peer.js');
+
+function esc(v) {
+  return String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+function buildDeepReportHtml({ entity, from, to, svc }) {
+  const key = svc.store.sanitizeKey(entity);
+  const meta = svc.store.getEntity(key) || {};
+  const dateRange = nlQuery.lakeDateRange(key, svc);
+  const f = from && /^\d{8}$/.test(String(from)) ? String(from) : dateRange.from;
+  const t = to && /^\d{8}$/.test(String(to)) ? String(to) : dateRange.to;
+  const ctx = { svc, entityKey: key, meta, shops: meta.shops || [], dateRange };
+
+  const safe = (fn) => { try { return fn(); } catch (e) { return { summary: String(e.message || e), data: null }; } };
+  const quality = safe(() => lake.qualityHandler(ctx, lake.qualitySanitize({ from: f, to: t }, ctx)));
+  const crossr = safe(() => cross.crossHandler(ctx, cross.crossSanitize({ from: f, to: t }, ctx)));
+  const cmp = safe(() => compare.compareHandler(ctx, compare.compareSanitize({ from: f, to: t }, ctx)));
+  const an = safe(() => anomaly.anomalyHandler(ctx, anomaly.anomalySanitize({ metric: 'grossAmt', from: f, to: t }, ctx)));
+  const attr = safe(() => attribution.attributionHandler(ctx, attribution.attributionSanitize({ from: f, to: t }, ctx)));
+  const bench = safe(() => peer.benchHandler(ctx, peer.benchSanitize({ field: 'gross_amt', from: f, to: t }, ctx)));
+  const trend = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'date' }));
+  const byShop = safe(() => svc.aggregateReport({ entity: key, from: f, to: t, group: 'shop' }));
+
+  const statusColor = { ok: '#16a34a', warn: '#d97706', fail: '#dc2626' };
+  const verdictRows = (verdicts) => (verdicts || []).map((v) => `
+    <tr><td><span style="color:${statusColor[v.status] || '#334155'};font-weight:700">${esc(v.status === 'ok' ? '通过' : v.status === 'warn' ? '警告' : '不一致')}</span></td>
+    <td>${esc(v.check)}</td><td>${esc(v.detail || '')}</td></tr>`).join('');
+
+  const dayRows = ((trend && trend.rows) || []).map((r) => `
+    <tr><td>${esc(String(r.date).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))}</td>
+    <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
+    <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
+    <td class="num">${Math.round(r.expUv || 0).toLocaleString('zh-CN')}</td>
+    <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
+
+  const shopRows = ((byShop && byShop.rows) || []).slice(0, 20).map((r, i) => `
+    <tr><td>${i + 1}</td><td>${esc(r.shopName || r.shopId)}</td>
+    <td class="num">${Math.round(r.grossAmt || 0).toLocaleString('zh-CN')}</td>
+    <td class="num">${Math.round(r.validOrderCnt || 0).toLocaleString('zh-CN')}</td>
+    <td class="num">${((r.clkExpRate || 0) * 100).toFixed(2)}%</td></tr>`).join('');
+
+  const cmpRows = ((cmp.data && cmp.data.deltas) || []).map((d) => `
+    <tr><td>${esc(d.label)}</td><td class="num">${Math.round(d.cur * 100) / 100}</td><td class="num">${Math.round(d.prev * 100) / 100}</td>
+    <td class="num">${d.deltaPct == null ? '—' : (d.deltaPct >= 0 ? '+' : '') + (d.deltaPct * 100).toFixed(1) + '%'}</td></tr>`).join('');
+
+  const attrLines = attr && attr.summary ? attr.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>归因不可用</li>';
+  const anomalyLines = an && an.summary ? an.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
+  const benchLines = bench && bench.summary ? bench.summary.split('\n').slice(1).map((l) => `<li>${esc(l)}</li>`).join('') : '<li>不可用</li>';
+  const qualityList = (quality.data && quality.data.findings || []).map((fd) => `<li>[${esc(fd.level)}] ${esc(fd.check)}:${esc(fd.detail)}</li>`).join('');
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>深度分析报告 · ${esc(meta.name || key)} ${f}-${t}</title>
+<style>
+  body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f8fafc; color: #0f172a; margin: 0; padding: 24px; font-size: 14px; line-height: 1.65; }
+  .wrap { max-width: 980px; margin: 0 auto; }
+  h1 { font-size: 22px; margin: 0 0 4px; } h2 { font-size: 17px; margin: 28px 0 10px; padding-bottom: 6px; border-bottom: 2px solid #e2e8f0; }
+  .meta { color: #64748b; font-size: 12px; margin-bottom: 20px; }
+  section { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px 20px; margin-bottom: 14px; }
+  table { width: 100%; border-collapse: collapse; font-size: 13px; }
+  th { text-align: left; background: #f1f5f9; padding: 7px 10px; border-bottom: 2px solid #e2e8f0; }
+  td { padding: 6px 10px; border-bottom: 1px solid #e2e8f0; }
+  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+  ul { margin: 6px 0; padding-left: 20px; } li { margin: 3px 0; color: #334155; }
+  .warn { color: #d97706; } @media print { body { background: #fff; padding: 0; } section { break-inside: avoid; } }
+</style></head><body><div class="wrap">
+<h1>深度分析报告 · ${esc(meta.name || key)}</h1>
+<div class="meta">区间 ${f}-${t}(生成于 ${new Date().toLocaleString('zh-CN')})· 确定性口径生成,数字与问数工具同源</div>
+
+<section><h2 style="margin-top:0">一、数据体检</h2><ul>${qualityList || '<li>不可用</li>'}</ul></section>
+
+<section><h2 style="margin-top:0">二、核心指标与环比</h2>
+<table><tr><th>指标</th><th class="num">本期</th><th class="num">上期</th><th class="num">变化</th></tr>${cmpRows || '<tr><td colspan="4">不可用</td></tr>'}</table></section>
+
+<section><h2 style="margin-top:0">三、营业额归因(LMDI)</h2><ul>${attrLines}</ul></section>
+
+<section><h2 style="margin-top:0">四、统计异常检测</h2><ul>${anomalyLines}</ul></section>
+
+<section><h2 style="margin-top:0">五、同行对标(营业额)</h2><ul>${benchLines}</ul></section>
+
+<section><h2 style="margin-top:0">六、逐日明细</h2>
+<table><tr><th>日期</th><th class="num">营业额</th><th class="num">有效订单</th><th class="num">曝光</th><th class="num">进店率</th></tr>${dayRows || '<tr><td colspan="5">无数据</td></tr>'}</table></section>
+
+<section><h2 style="margin-top:0">七、门店排行(Top 20)</h2>
+<table><tr><th>#</th><th>门店</th><th class="num">营业额</th><th class="num">有效订单</th><th class="num">进店率</th></tr>${shopRows || '<tr><td colspan="5">无数据</td></tr>'}</table></section>
+
+<section><h2 style="margin-top:0">八、校验结论汇总</h2>
+<table><tr><th>状态</th><th>校验</th><th>说明</th></tr>
+${verdictRows((quality.data && quality.data.verdicts) || [])}
+${verdictRows((crossr.data && crossr.data.verdicts) || [])}
+${verdictRows((cmp.data && cmp.data.verdicts) || [])}
+${verdictRows((an.data && an.data.verdicts) || [])}
+</table></section>
+
+</div></body></html>`;
+}
+
+module.exports = { buildDeepReportHtml };

+ 100 - 0
server/agent/digest.js

@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * 智能问数 · 晨报(确定性产出,零模型)
+ * ----------------------------------------------------------------------------
+ * 每日自动同步完成后,对当天实体确定性跑一遍 数据体检 + 层间核对 + 环比对比,
+ * 组装成带可信度标签的晨报落盘(data/agent/<entity>/digests.json,cap 30),
+ * 问数页顶部横幅展示。无钉钉推送——推送通道后续按需接。
+ * 确定性口径与问数工具同源(直接复用工具 handler),数字不许两套算法。
+ */
+
+const fs = require('fs');
+const path = require('path');
+const trace = require('./trace.js');
+const nlQuery = require('../../nl-query.js');
+const lake = require('./tools/lake.js');
+const cross = require('./tools/cross.js');
+const compare = require('./tools/compare.js');
+
+function digestsFile(entityKey) {
+  return path.join(trace.AGENT_DIR, trace.sanitizeKey(entityKey), 'digests.json');
+}
+
+function loadDigests(entityKey) {
+  try { return JSON.parse(fs.readFileSync(digestsFile(entityKey), 'utf8')).digests || []; }
+  catch { return []; }
+}
+
+function saveDigests(entityKey, digests) {
+  const file = digestsFile(entityKey);
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  const temp = `${file}.${process.pid}.tmp`;
+  fs.writeFileSync(temp, JSON.stringify({ digests }), 'utf8');
+  fs.renameSync(temp, file);
+}
+
+function buildAndSaveDigest(entity, svc) {
+  const key = trace.sanitizeKey(entity);
+  const meta = svc.store.getEntity(key) || {};
+  const dateRange = nlQuery.lakeDateRange(key, svc);
+  const ctx = { svc, entityKey: key, meta, shops: meta.shops || [], dateRange };
+
+  const sections = [];
+  let verdicts = [];
+  const runSection = (name, fn) => {
+    try {
+      const out = fn();
+      sections.push({ tool: name, ok: true, summary: String(out.summary || '').slice(0, 1200) });
+      if (out.data && Array.isArray(out.data.verdicts)) {
+        verdicts = verdicts.concat(out.data.verdicts.map((v) => ({ ...v, tool: name })));
+      }
+      return out;
+    } catch (e) {
+      sections.push({ tool: name, ok: false, summary: String(e.message || e).slice(0, 300) });
+      return null;
+    }
+  };
+
+  const quality = runSection('data_quality', () => lake.qualityHandler(ctx, lake.qualitySanitize({}, ctx)));
+  runSection('cross_check', () => cross.crossHandler(ctx, cross.crossSanitize({}, ctx)));
+  const cmp = runSection('compare_periods', () => compare.compareHandler(ctx, compare.compareSanitize({}, ctx)));
+
+  // 头条:环比一句话 + 最差校验结论
+  let headline;
+  const worst = verdicts.reduce((acc, v) => (v.status === 'fail' ? 'fail' : (v.status === 'warn' && acc !== 'fail' ? 'warn' : acc)), 'ok');
+  if (!dateRange || svc.store.datesInLake(key).length === 0) {
+    headline = '湖内暂无数据,待同步后再出晨报';
+  } else if (cmp && cmp.data && !cmp.data.empty) {
+    const gross = (cmp.data.deltas || []).find((d) => d.key === 'grossAmt');
+    if (gross) {
+      const pct = gross.deltaPct == null ? '' : `,环比 ${gross.deltaPct >= 0 ? '+' : ''}${(gross.deltaPct * 100).toFixed(1)}%`;
+      headline = `近 7 天营业额 ${Math.round(gross.cur).toLocaleString('zh-CN')} 元${pct}(可比口径)`;
+    } else {
+      headline = '近 7 天经营数据已就绪';
+    }
+  } else {
+    headline = '经营数据已就绪';
+  }
+  if (worst === 'fail') headline = `⚠ ${headline};数据体检发现问题,详见下方校验`;
+  else if (worst === 'warn') headline = `${headline};部分校验有警告`;
+
+  const digest = {
+    id: trace.newId('dg'),
+    entity: key,
+    entityName: meta.name || key,
+    at: new Date().toISOString(),
+    status: worst,
+    headline,
+    sections,
+    verdicts,
+    reportUrl: `/api/reports/report-html?${new URLSearchParams({ entity: key, from: dateRange.from, to: dateRange.to }).toString()}`,
+  };
+  const digests = loadDigests(key);
+  digests.unshift(digest);
+  if (digests.length > 30) digests.length = 30;
+  saveDigests(key, digests);
+  return digest;
+}
+
+module.exports = { buildAndSaveDigest, loadDigests };

+ 55 - 2
server/agent/index.js

@@ -9,9 +9,13 @@
  * 运行态在内存(仿 syncState),终态落盘 data/agent/(trace.js)。
  */
 
+'use strict';
+
+const path = require('path');
 const trace = require('./trace.js');
 const loop = require('./loop.js');
 const skills = require('./skills.js');
+const replay = require('./replay.js');
 const { mockReply } = require('./mock-model.js');
 const { pickRow } = require('./tools/lake.js');
 const nlQuery = require('../../nl-query.js');
@@ -21,6 +25,8 @@ const MAX_CACHED = 60;
 
 function isMock() { return process.env.NAPOS_AGENT_MOCK === '1'; }
 
+const corpusFile = (entity) => path.join(trace.AGENT_DIR, trace.sanitizeKey(entity), 'corpus.json');
+
 /** 快问自动分流:短问句且不含分析类词 → 快问(nl-query 两段式),否则深度分析 */
 function pickMode(q) {
   if (String(q || '').length > 18) return 'agent';
@@ -106,7 +112,23 @@ function startRun(o) {
         };
       } else {
         rec.history = trace.loadRecentConclusions(entity, rec.sessionId);
-        rec.result = await loop.runAgent({ rec, svc, cfg, mock: null, cloud: o.cloud || null });
+        // 打法复用与 few-shot:真实模型模式下,从已验证语料命中相似问
+        let plan = null;
+        let fewShotsText = '';
+        if (!o.skill && !isMock()) {
+          try {
+            plan = replay.findPlan(corpusFile(entity), q);
+            if (!plan) {
+              const shots = replay.fewShot(corpusFile(entity), q);
+              if (shots.length) {
+                fewShotsText = shots.map((s) => `- 问「${s.question}」→ ${s.steps.map((st) => st.tool).join(' → ')}`).join('\n');
+                rec.fewShots = shots.map((s) => s.question);
+              }
+            }
+          } catch { /* 语料读写失败不影响正常问答 */ }
+        }
+        if (plan) rec.replayFrom = plan.question;
+        rec.result = await loop.runAgent({ rec, svc, cfg, mock: null, cloud: o.cloud || null, plan, fewShotsText });
       }
       rec.steps.push({ type: 'final', answer: rec.result.answer });
       rec.status = 'done';
@@ -120,6 +142,16 @@ function startRun(o) {
       rec.finishedAt = new Date().toISOString();
       rec.cancelRequested = false;
       trace.pushEvent(rec, 'run.end', { status: rec.status, error: rec.error });
+      // 成功且数字全部溯源的运行沉淀为已验证打法(相似问复用 / few-shot 语料)
+      if (rec.status === 'done' && rec.mode === 'agent' && !isMock()
+        && rec.provenance && rec.provenance.untracedCount === 0) {
+        const steps = rec.steps
+          .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool))
+          .map((s) => ({ tool: s.tool, args: s.args }));
+        if (steps.length && steps.length <= 8) {
+          try { replay.record(corpusFile(entity), { question: q, steps }); } catch { /* 语料失败不影响主流程 */ }
+        }
+      }
       trace.persistRun(rec);
       trace.upsertSessionRun(entity, {
         sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: rec.status, at: rec.finishedAt,
@@ -174,4 +206,25 @@ function deleteSession(entity, sessionId) {
   return trace.deleteSession(key, id);
 }
 
-module.exports = { startRun, cancelRun, getRun, listRuns, listSessions, deleteSession };
+/** 答案反馈:采纳 → 打法强制入语料;纠错 → 反馈日志(供评测提炼) */
+function feedback({ entity, runId, kind, note }) {
+  const rec = getRun(entity, runId);
+  if (!rec) return null;
+  rec.feedback = { kind: kind === 'up' ? 'up' : 'down', note: String(note || '').slice(0, 300), at: new Date().toISOString() };
+  trace.persistRun(rec);
+  if (rec.feedback.kind === 'up') {
+    const steps = rec.steps
+      .filter((s) => s.type === 'tool' && s.ok && replay.ALLOW_TOOLS.has(s.tool))
+      .map((s) => ({ tool: s.tool, args: s.args }));
+    if (steps.length) {
+      try { replay.record(corpusFile(rec.entity), { question: rec.question, steps }); } catch { /* ignore */ }
+    }
+  } else {
+    trace.appendFeedbackLog(rec.entity, {
+      at: rec.feedback.at, runId: rec.runId, question: rec.question, answer: (rec.result || {}).answer || '', note: rec.feedback.note,
+    });
+  }
+  return rec;
+}
+
+module.exports = { startRun, cancelRun, getRun, listRuns, listSessions, deleteSession, feedback };

+ 7 - 4
server/agent/llm.js

@@ -23,11 +23,14 @@ function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000, t
  * usage 字段:prompt_tokens / completion_tokens;网关不给时为 null)。
  * 智能体主循环用它记录每回合的 token 用量(编排时间线展示)。
  */
-function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
+function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0, jsonMode = false }) {
   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, stream: false });
+    const body = { model, messages, temperature, stream: false };
+    // 结构化解码:网关支持 JSON mode 时动作输出更稳(配置开关 chatJsonMode,默认关)
+    if (jsonMode) body.response_format = { type: 'json_object' };
+    const payload = JSON.stringify(body);
     const options = {
       hostname: base.hostname,
       port: base.port || 443,
@@ -36,7 +39,7 @@ function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs =
       headers: {
         Authorization: `Bearer ${apiKey}`,
         'Content-Type': 'application/json; charset=utf-8',
-        'Content-Length': Buffer.byteLength(body),
+        'Content-Length': Buffer.byteLength(payload),
       },
       timeout: timeoutMs,
     };
@@ -65,7 +68,7 @@ function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs =
     });
     req.on('error', reject);
     req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
-    req.write(body);
+    req.write(payload);
     req.end();
   });
 }

+ 72 - 32
server/agent/loop.js

@@ -14,6 +14,7 @@ const guard = require('./guard.js');
 const prompt = require('./prompt.js');
 const registry = require('./tools/registry.js');
 const skills = require('./skills.js');
+const metrics = require('./metrics.js');
 const provenance = require('./provenance.js');
 const trace = require('./trace.js');
 const nlQuery = require('../../nl-query.js');
@@ -68,7 +69,9 @@ async function runAgent(o) {
       svc, entityName: meta.name || rec.entity, shops, dateRange, today,
       toolsText: registry.manifestText(),
       skillsText: skills.manifestText(),
+      metricsText: metrics.manifestText(),
       pointedSkill: rec.skill ? skills.get(rec.skill) : null,
+      fewShotsText: o.fewShotsText || '',
     }),
   }];
   for (const h of rec.history || []) messages.push(h);
@@ -88,10 +91,18 @@ async function runAgent(o) {
     if (mock) {
       return { reply: mock.next(rec), usage: null, ms: Date.now() - t0 };
     }
+    // 模型路由:深度分析优先用专用模型(chatModelAgent),缺省回落主对话模型
     const out = await llm.chatCompletionDetailed({
-      apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel,
+      apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey,
+      model: cfg.chatModelAgent || cfg.chatModel,
       messages: msgs, timeoutMs: guard.BUDGET.llmTimeoutMs, temperature: 0.2,
+      jsonMode: !!cfg.chatJsonMode,
     });
+    // 治理:run 级 token 用量累计(成本展示)
+    if (out.usage) {
+      rec.usage.tokensIn = (rec.usage.tokensIn || 0) + (out.usage.promptTokens || 0);
+      rec.usage.tokensOut = (rec.usage.tokensOut || 0) + (out.usage.completionTokens || 0);
+    }
     return { reply: out.content, usage: out.usage, ms: Date.now() - t0 };
   };
 
@@ -108,6 +119,38 @@ async function runAgent(o) {
     };
   };
 
+  /** 工具执行(主循环与复用打法共用):留痕/verdict 归档/应用与报告链接捕获 */
+  const executeTool = async (toolName, tool, args) => {
+    rec.usage.toolCalls += 1;
+    const step = { type: 'tool', tool: toolName, args, ok: false, ms: 0 };
+    rec.steps.push(step);
+    trace.pushEvent(rec, 'tool.start', { tool: toolName });
+    const t0 = Date.now();
+    try {
+      const out = await tool.handler(ctx, args);
+      step.ok = true;
+      step.summary = String(out.summary || '').slice(0, 4000);
+      step.data = out.data || null;
+      if (out.data && out.data.totalRows != null) step.rows = out.data.totalRows;
+      step.bytes = JSON.stringify(out.data || null).length;
+      // 校验类工具的 verdict 归档(quality/identity/cross-layer/freshness/coverage/cloud)
+      if (out.data && Array.isArray(out.data.verdicts)) {
+        for (const v of out.data.verdicts) {
+          rec.verdicts.push({ id: `v${rec.verdicts.length + 1}`, tool: toolName, ...v });
+        }
+      }
+      const applied = appliedFrom(toolName, args);
+      if (applied) lastApplied = applied;
+      if (toolName === 'build_report' && out.data && out.data.url) lastReportUrl = out.data.url;
+    } catch (e) {
+      step.error = String(e.message || e).slice(0, 500);
+    } finally {
+      step.ms = Date.now() - t0;
+      trace.pushEvent(rec, 'tool.done', { tool: toolName, ok: step.ok, ms: step.ms, error: step.error });
+    }
+    return step;
+  };
+
   const forceFinal = async (reason) => {
     messages.push({ role: 'user', content: `${reason}:请立即基于以上已有信息输出 final(JSON),不要再调用工具。` });
     try {
@@ -118,6 +161,28 @@ async function runAgent(o) {
     return { answer: fallbackAnswer(rec, reason), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
   };
 
+  // 打法复用:命中已验证查询序列 → 直接重跑(缓存的是打法,数据按当前湖重算)→ 单次收敛
+  if (o.plan && Array.isArray(o.plan.steps) && o.plan.steps.length) {
+    rec.replayFrom = o.plan.question;
+    rec.steps.push({ type: 'thought', text: `命中已验证打法「${o.plan.question}」,直接复用其查询序列(数据按当前湖重算)`, ms: 0 });
+    for (const ps of o.plan.steps) {
+      if (rec.cancelRequested) throw cancelledError();
+      const tool = registry.get(ps.tool);
+      if (!tool) continue;
+      let args;
+      try { args = tool.sanitize(ps.args || {}, ctx); } catch { continue; } // 口径变更等导致的参数失配:跳过该步
+      const step = await executeTool(ps.tool, tool, args);
+      messages.push({ role: 'user', content: `[工具结果] ${ps.tool}\n${step.summary || step.error || ''}` });
+    }
+    messages.push({ role: 'user', content: '以上为按既有打法重跑的最新数据结果,请立即基于其输出 final(JSON),不要再调用工具。' });
+    try {
+      const turn = await callModel(messages);
+      const parsed = llm.parseAgentAction(turn.reply);
+      if (parsed.type === 'final') return finishWith(parsed);
+    } catch { /* 收敛失败走兜底 */ }
+    return { answer: fallbackAnswer(rec, '复用打法收敛失败'), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
+  }
+
   while (true) {
     if (rec.cancelRequested) throw cancelledError();
 
@@ -201,37 +266,12 @@ async function runAgent(o) {
       continue;
     }
 
-    rec.usage.toolCalls += 1;
-    const step = { type: 'tool', tool: parsed.tool, args, ok: false, ms: 0 };
-    rec.steps.push(step);
-    trace.pushEvent(rec, 'tool.start', { tool: parsed.tool });
-    const t0 = Date.now();
-    try {
-      const out = await tool.handler(ctx, args);
-      step.ok = true;
-      step.summary = String(out.summary || '').slice(0, 4000);
-      step.data = out.data || null;
-      if (out.data && out.data.totalRows != null) step.rows = out.data.totalRows;
-      step.bytes = JSON.stringify(out.data || null).length;
-      // 校验类工具的 verdict 归档(quality/identity/cross-layer/freshness/coverage/peer)
-      if (out.data && Array.isArray(out.data.verdicts)) {
-        for (const v of out.data.verdicts) {
-          rec.verdicts.push({ id: `v${rec.verdicts.length + 1}`, tool: parsed.tool, ...v });
-        }
-      }
-      const applied = appliedFrom(parsed.tool, args);
-      if (applied) lastApplied = applied;
-      if (parsed.tool === 'build_report' && out.data && out.data.url) lastReportUrl = out.data.url;
-      messages.push({ role: 'user', content: `[工具结果] ${parsed.tool}\n${step.summary}` });
-      if (verdict === 'repeat') {
-        messages.push({ role: 'user', content: '提醒:这与上一次调用完全相同。若信息已足够请输出 final;确需重试请更换参数。' });
-      }
-    } catch (e) {
-      step.error = String(e.message || e).slice(0, 500);
-      messages.push({ role: 'user', content: `[工具失败] ${parsed.tool}:${step.error}。请修正参数重试或换路径。` });
-    } finally {
-      step.ms = Date.now() - t0;
-      trace.pushEvent(rec, 'tool.done', { tool: parsed.tool, ok: step.ok, ms: step.ms, error: step.error });
+    const step = await executeTool(parsed.tool, tool, args);
+    messages.push(step.ok
+      ? { role: 'user', content: `[工具结果] ${parsed.tool}\n${step.summary}` }
+      : { role: 'user', content: `[工具失败] ${parsed.tool}:${step.error}。请修正参数重试或换路径。` });
+    if (step.ok && verdict === 'repeat') {
+      messages.push({ role: 'user', content: '提醒:这与上一次调用完全相同。若信息已足够请输出 final;确需重试请更换参数。' });
     }
   }
 }

+ 73 - 0
server/agent/metrics.js

@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * 智能问数 · 指标层
+ * ----------------------------------------------------------------------------
+ * 把「指标 = 维度 × 聚合 × 时间粒度 × 口径」从列注册表升级为一等对象:
+ *   · 基础指标:合计 / 加权(比率按曝光或进店人数加权)/ 比值(客单价)
+ *   · 派生指标:基于基础指标动态计算(7 日移动平均等),模型引用指标 key,
+ *     不自己组合口径——flash 模型引用比自算的出错率低一个量级。
+ * 与 REPORT_COLUMNS 的关系:列注册表服务表格语义(label/同义词/类型),
+ * 本注册表服务问数聚合语义(怎么算),两者键保持一致。
+ */
+
+const BASE = {
+  grossAmt: { name: '营业额', unit: 'money', agg: 'sum', synonyms: ['流水', 'GMV', '销售额'] },
+  validOrderCnt: { name: '有效订单', unit: 'int', agg: 'sum', synonyms: ['单量', '订单量'] },
+  incomeAmt: { name: '收入', unit: 'money', agg: 'sum', synonyms: ['到账'] },
+  expenseAmt: { name: '支出', unit: 'money', agg: 'sum', synonyms: ['费用'] },
+  expUv: { name: '曝光人数', unit: 'int', agg: 'sum', synonyms: ['曝光'] },
+  orderUserCnt: { name: '下单人数', unit: 'int', agg: 'sum', synonyms: ['购买人数'] },
+  actOrdCnt: { name: '活动订单', unit: 'int', agg: 'sum' },
+  actSubsidyAmt: { name: '活动补贴', unit: 'money', agg: 'sum' },
+  clkExpRate: { name: '进店转化率', unit: 'pct', agg: 'weighted', weightBy: 'expUv' },
+  ordClkRate: { name: '下单转化率', unit: 'pct', agg: 'weighted', weightBy: 'orderUserCnt' },
+  perActualPay: { name: '客单价', unit: 'money', agg: 'ratio', num: 'grossAmt', den: 'validOrderCnt' },
+};
+
+/** 派生指标:基于基础指标按窗口动态计算(按天粒度) */
+const DERIVED = {
+  gross_mov7: { name: '营业额7日移动平均', unit: 'money', base: 'grossAmt', window: 7 },
+  orders_mov7: { name: '有效订单7日移动平均', unit: 'int', base: 'validOrderCnt', window: 7 },
+  uv_mov7: { name: '曝光人数7日移动平均', unit: 'int', base: 'expUv', window: 7 },
+};
+
+const ALL = { ...BASE, ...DERIVED };
+
+function get(key) { return ALL[key] || null; }
+
+/** 常驻提示词:聚合口径一句话说清(含派生指标) */
+function manifestText() {
+  const lines = Object.entries(ALL).map(([key, m]) => {
+    const how = m.agg === 'sum' ? '合计'
+      : m.agg === 'weighted' ? `加权平均(按${(BASE[m.weightBy] || {}).name || m.weightBy}加权)`
+      : m.agg === 'ratio' ? `比值(${(BASE[m.num] || {}).name || m.num} / ${(BASE[m.den] || {}).name || m.den})`
+      : `${m.window} 日移动平均(基于${(BASE[m.base] || {}).name || m.base})`;
+    return `- ${key}|${m.name}|${how}`;
+  });
+  return lines.join('\n');
+}
+
+/** 单日聚合:把当天门店行聚成一个数(口径与 summarizeRows 同源——比率加权) */
+function dayValue(key, rows) {
+  const m = ALL[key];
+  if (!m || !rows.length) return null;
+  if (m.agg === 'weighted') {
+    let wv = 0;
+    let w = 0;
+    for (const r of rows) {
+      const weight = Number(r[m.weightBy]) || 0;
+      wv += (Number(r[key]) || 0) * weight;
+      w += weight;
+    }
+    return w > 0 ? +(wv / w).toFixed(4) : null;
+  }
+  if (m.agg === 'ratio') {
+    const num = rows.reduce((s, r) => s + (Number(r[m.num]) || 0), 0);
+    const den = rows.reduce((s, r) => s + (Number(r[m.den]) || 0), 0);
+    return den > 0 ? +(num / den).toFixed(2) : null;
+  }
+  return Math.round(rows.reduce((s, r) => s + (Number(r[key]) || 0), 0) * 100) / 100;
+}
+
+module.exports = { BASE, DERIVED, ALL, get, manifestText, dayValue };

+ 11 - 5
server/agent/prompt.js

@@ -18,10 +18,13 @@ function semanticModelText(svc) {
     }).join('\n');
 }
 
-function buildSystemPrompt({ svc, entityName, shops, dateRange, today, toolsText, skillsText, pointedSkill }) {
-  const shopLines = (shops || []).slice(0, 300)
-    .map((s) => `${s.shopId}|${s.shopName}${s.chainName ? '|' + s.chainName : ''}`).join('\n');
+function buildSystemPrompt({ svc, entityName, shops, dateRange, today, toolsText, skillsText, pointedSkill, fewShotsText, metricsText }) {
+  // Schema linking:门店少(≤40)直接内嵌清单;多则让模型按需查(省 token 且聚焦)
+  const shopSection = shops.length > 0 && shops.length <= 40
+    ? (shops.slice(0, 40).map((s) => `${s.shopId}|${s.shopName}${s.chainName ? '|' + s.chainName : ''}`).join('\n'))
+    : `门店较多(共 ${(shops || []).length} 家):先调 lake_overview 查看门店清单;门店名参数支持部分匹配(如「二食堂」可只写「二」)。`;
   const pointed = pointedSkill ? `\n本次用户点名技能【${pointedSkill.name}】:请严格按其打法组织工具调用与结论。` : '';
+  const fewShots = fewShotsText ? `\n## 相似问题参考(已验证的查询打法,可借鉴其工具组合与区间口径)\n${fewShotsText}\n` : '';
   return `你是「淘宝闪购门店经营数据」的数据分析智能体。围绕用户的问题多步取数与校验后作答:先弄清有什么数据,再取数,必要时交叉验证,最后给带真实数字的中文结论。
 
 ## 今天与数据边界
@@ -31,14 +34,17 @@ function buildSystemPrompt({ svc, entityName, shops, dateRange, today, toolsText
 ## 指标语义(key | 名称 | 同义词 | 口径)
 ${semanticModelText(svc)}
 
+## 指标聚合口径(引用指标 key,禁止自组口径或自己平均)
+${metricsText}
+
 ## 门店清单(shopId|shopName|连锁)
-${shopLines || '(暂无门店清单,shops 留空)'}
+${shopSection}
 
 ## 可用工具(每次只调一个)
 ${toolsText}
 
 ## 内置技能(打法参考;用户可能点名,你判断适用时也应遵循)
-${skillsText}${pointed}
+${skillsText}${fewShots}${pointed}
 
 ## 数据纪律(违反即错)
 - 一切数字必须来自工具返回,禁止心算合计/平均/编造;同比环比一律用 compare_periods,不要自己拿两次查询相减。

+ 99 - 0
server/agent/replay.js

@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * 智能问数 · 已验证打法缓存(replay)与 few-shot 检索
+ * ----------------------------------------------------------------------------
+ * 业界实证对查询准确率/成本最有效的两件事:
+ *   · 打法缓存 —— 成功运行(数字全部溯源、工具全成)的查询序列沉淀为语料;
+ *     相似问命中后**直接重跑既有查询序列**(缓存的是打法不是数字,数据按当前湖重算),
+ *     省掉多轮规划,只保留一次结论收敛调用。
+ *   · few-shot 检索 —— 未达复用阈值但相近的问题,把语料里的打法作为参考注入 prompt。
+ * 相似度:字符二元组 Dice 系数(零依赖,问句短文本足够)。
+ * 语料文件:data/agent/<entity>/corpus.json(cap 50,同题去重保留最新)。
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+/** 打法缓存只收只读取数类工具(不含体检类——体检结果不构成「打法」) */
+const ALLOW_TOOLS = new Set(['query_report', 'compare_periods', 'peer_benchmark', 'cross_entity', 'build_report']);
+const MAX_ITEMS = 50;
+
+function normalize(q) {
+  return String(q || '').toLowerCase().replace(/[\s,。?!、:;"'()()?!,.:;~~—\-_/\\]/g, '');
+}
+
+function bigrams(s) {
+  const out = new Set();
+  for (let i = 0; i < s.length - 1; i++) out.add(s.slice(i, i + 2));
+  if (s.length === 1) out.add(s);
+  return out;
+}
+
+/** 字符二元组 Dice 系数:0(完全不同)~ 1(相同) */
+function similarity(a, b) {
+  const x = normalize(a);
+  const y = normalize(b);
+  if (!x || !y) return 0;
+  if (x === y) return 1;
+  const bx = bigrams(x);
+  const by = bigrams(y);
+  let inter = 0;
+  for (const g of bx) if (by.has(g)) inter += 1;
+  return (2 * inter) / (bx.size + by.size);
+}
+
+function loadCorpus(file) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')).items || []; }
+  catch { return []; }
+}
+
+function saveCorpus(file, items) {
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  const temp = `${file}.${process.pid}.tmp`;
+  fs.writeFileSync(temp, JSON.stringify({ items }), 'utf8');
+  fs.renameSync(temp, file);
+}
+
+/**
+ * 沉淀一条已验证打法(同题去重保留最新)。
+ * steps = 运行里成功且在白名单内的 {tool, args}(args 已是钳制后的参数)。
+ */
+function record(file, { question, steps }) {
+  const q = String(question || '').trim();
+  const cleanSteps = (Array.isArray(steps) ? steps : [])
+    .filter((s) => s && ALLOW_TOOLS.has(s.tool) && s.args && typeof s.args === 'object')
+    .map((s) => ({ tool: s.tool, args: s.args }));
+  if (!q || !cleanSteps.length) return false;
+  const items = loadCorpus(file);
+  const norm = normalize(q);
+  const filtered = items.filter((it) => it.norm !== norm);
+  filtered.unshift({ norm, question: q, steps: cleanSteps, at: new Date().toISOString() });
+  saveCorpus(file, filtered.slice(0, MAX_ITEMS));
+  return true;
+}
+
+/** 相似问命中:返回可重跑的打法(含原问题),不达阈值返回 null */
+function findPlan(file, question, threshold = 0.82) {
+  const items = loadCorpus(file);
+  let best = null;
+  let bestSim = 0;
+  for (const it of items) {
+    const sim = similarity(question, it.question);
+    if (sim > bestSim) { bestSim = sim; best = it; }
+  }
+  if (!best || bestSim < threshold) return null;
+  return { question: best.question, steps: best.steps, similarity: +bestSim.toFixed(3) };
+}
+
+/** few-shot 检索:相近问题的打法参考(不含自身完全命中——那走 findPlan) */
+function fewShot(file, question, k = 2, min = 0.6) {
+  return loadCorpus(file)
+    .map((it) => ({ it, sim: similarity(question, it.question) }))
+    .filter((x) => x.sim >= min && x.sim < 0.999)
+    .sort((a, b) => b.sim - a.sim)
+    .slice(0, k)
+    .map((x) => ({ question: x.it.question, steps: x.it.steps, similarity: +x.sim.toFixed(3) }));
+}
+
+module.exports = { ALLOW_TOOLS, normalize, similarity, record, findPlan, fewShot, MAX_ITEMS };

+ 109 - 0
server/agent/tools/anomaly.js

@@ -0,0 +1,109 @@
+'use strict';
+
+/**
+ * 智能问数 · 统计异常检测工具
+ * ----------------------------------------------------------------------------
+ * detect_anomalies:对加性指标的逐日序列做 leave-one-out z-score 异常检测。
+ * 方法论(模型无权偏离):
+ *   · 每天的 z = (当天值 - 其余天均值) / 其余天标准差(LOO,防异常值自己抬高 σ)
+ *   · σ 下限 = 均值的 10%(防全同值序列除零误报);样本 <5 天不检测
+ *   · |z| ≥ 3 判 fail(严重异常),≥ 2 判 warn
+ *   · 区间含寒暑假窗口(0108-0225 / 0701-0831)时附加 info 说明基线可能失真
+ */
+
+const metrics = require('../metrics.js');
+const { eachDay, ymdDash } = require('./lake.js');
+
+const VACATIONS = [
+  { name: '寒假', from: '0108', to: '0225' },
+  { name: '暑假', from: '0701', to: '0831' },
+];
+
+function std(values) {
+  if (values.length < 2) return 0;
+  const mean = values.reduce((s, x) => s + x, 0) / values.length;
+  return Math.sqrt(values.reduce((s, x) => s + (x - mean) ** 2, 0) / (values.length - 1));
+}
+
+function mean(values) {
+  return values.length ? values.reduce((s, x) => s + x, 0) / values.length : 0;
+}
+
+function anomalySanitize(args, ctx) {
+  const a = args || {};
+  const metric = ['grossAmt', 'validOrderCnt', 'incomeAmt', 'expUv', 'orderUserCnt'].includes(a.metric) ? a.metric : 'grossAmt';
+  const { dateRange } = ctx;
+  const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
+  const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
+  const k = Number(a.k) > 0 ? Number(a.k) : 2;
+  return { metric, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, k };
+}
+
+function anomalyHandler(ctx, args) {
+  const { svc, entityKey } = ctx;
+  const { metric, from, to, k } = args;
+  const def = metrics.get(metric) || {};
+  const rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
+  const byDate = new Map();
+  for (const r of rows) {
+    if (!byDate.has(r.date)) byDate.set(r.date, []);
+    byDate.get(r.date).push(r);
+  }
+  const days = eachDay(from, to).filter((d) => byDate.has(d));
+  if (days.length < 5) {
+    return { summary: `异常检测需要至少 5 天数据(当前 ${days.length} 天),已跳过。`, data: { metric, from, to, anomalies: [], verdicts: [] } };
+  }
+  const series = days.map((d) => ({ date: d, value: metrics.dayValue(metric, byDate.get(d)) })).filter((s) => s.value != null);
+
+  const anomalies = [];
+  for (const s of series) {
+    const others = series.filter((x) => x.date !== s.date).map((x) => x.value);
+    const baseMean = mean(others);
+    const sigma = Math.max(std(others), Math.abs(baseMean) * 0.1, 1); // σ 下限防全同值误报
+    const z = (s.value - baseMean) / sigma;
+    if (Math.abs(z) >= k) {
+      anomalies.push({
+        date: s.date, value: s.value, baseMean: Math.round(baseMean * 100) / 100,
+        z: +z.toFixed(2), severity: Math.abs(z) >= 3 ? 'fail' : 'warn',
+      });
+    }
+  }
+  anomalies.sort((a, b) => Math.abs(b.z) - Math.abs(a.z));
+
+  const lines = [`${def.name || metric} 异常检测(${ymdDash(from)} ~ ${ymdDash(to)},leave-one-out z-score,阈值 |z|≥${k}):`];
+  if (!anomalies.length) {
+    lines.push(`未发现统计异常(${series.length} 天均在基线 ±${k}σ 内)。`);
+  } else {
+    for (const a of anomalies.slice(0, 10)) {
+      lines.push(`  [${a.severity}] ${ymdDash(a.date)}:${a.value}(基线 ${a.baseMean},z=${a.z})`);
+    }
+    if (anomalies.length > 10) lines.push(`  …其余 ${anomalies.length - 10} 天略`);
+    lines.push('异常日建议结合活动/调价/天气等业务事件解读,再下结论。');
+  }
+  const mmdd = (v) => v.slice(4);
+  const vacations = VACATIONS.filter((v) => {
+    const f = mmdd(from);
+    const t = mmdd(to);
+    return (f >= v.from && f <= v.to) || (t >= v.from && t <= v.to) || (f < v.from && t > v.to);
+  });
+  if (vacations.length) {
+    lines.push(`注意:区间含${vacations.map((v) => v.name).join('/')}窗口,基线可能失真,异常判定请谨慎采信。`);
+  }
+
+  const status = anomalies.some((a) => a.severity === 'fail') ? 'fail' : (anomalies.length ? 'warn' : 'ok');
+  return {
+    summary: lines.join('\n'),
+    data: {
+      metric, from, to, series, anomalies,
+      verdicts: [{
+        kind: 'anomaly', check: `统计异常 · ${def.name || metric}`,
+        status,
+        detail: anomalies.length
+          ? `${anomalies.length} 天偏离基线(最差 ${ymdDash(anomalies[0].date)} z=${anomalies[0].z})`
+          : `${series.length} 天均在基线 ±${k}σ 内`,
+      }],
+    },
+  };
+}
+
+module.exports = { anomalySanitize, anomalyHandler };

+ 112 - 0
server/agent/tools/attribution.js

@@ -0,0 +1,112 @@
+'use strict';
+
+/**
+ * 智能问数 · LMDI 归因工具
+ * ----------------------------------------------------------------------------
+ * attribution:把营业额变化分解为 曝光效应 × 进店率效应 × 客单价效应
+ * (对数平均迪氏指数法,V = expUv × clkExpRate × perActualPay)。
+ * 仅在可比门店集(两期都在线)上计算;比率取加权口径。
+ * 单店场景恒等式严格成立(残差≈0);多店聚合存在混合残差,如实输出。
+ */
+
+const nlQuery = require('../../../nl-query.js');
+const { ymdDash } = require('./lake.js');
+
+const z2 = (n) => String(n).padStart(2, '0');
+function dayShift(ymd, delta) {
+  const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  d.setDate(d.getDate() + delta);
+  return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
+}
+
+function attributionSanitize(args, ctx) {
+  const a = args || {};
+  const { dateRange } = ctx;
+  const dates = ctx.svc.store.datesInLake(ctx.entityKey);
+  let from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : '';
+  let to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : '';
+  from = from && from >= dateRange.from && from <= dateRange.to ? from : '';
+  to = to && to >= dateRange.from && to <= dateRange.to ? to : '';
+  if (!from || !to || from > to) {
+    if (!dates.length) return { empty: true };
+    to = dates[dates.length - 1];
+    from = dates[Math.max(0, dates.length - 7)];
+  }
+  const len = Math.max(1, Math.round(
+    (new Date(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)))
+      - new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)))) / 86400000) + 1);
+  const prevTo = dayShift(from, -1);
+  const prevFrom = dayShift(prevTo, -(len - 1));
+  let shopIds = null;
+  if (Array.isArray(a.shops) && a.shops.length) {
+    shopIds = nlQuery.matchShopsByName(ctx.shops, a.shops);
+    if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
+  }
+  return { from, to, prevFrom, prevTo, shopIds, empty: false };
+}
+
+function windowSummary(ctx, from, to, shopIds) {
+  const { svc, entityKey } = ctx;
+  let rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
+  if (shopIds) {
+    const wanted = new Set(shopIds.map(String));
+    rows = rows.filter((r) => wanted.has(String(r.shopId)));
+  }
+  const s = svc.summarizeRows(rows);
+  return { rows: rows.length, expUv: s.expUv, clk: s.clkExpRate, pay: s.perActualPay, gross: s.grossAmt };
+}
+
+function attributionHandler(ctx, args) {
+  const { svc } = ctx;
+  if (args.empty) return { summary: '湖内暂无数据,无法归因。', data: { empty: true } };
+  const { from, to, prevFrom, prevTo, shopIds } = args;
+  const cur = windowSummary(ctx, from, to, shopIds);
+  const prev = windowSummary(ctx, prevFrom, prevTo, shopIds);
+  if (!cur.rows || !prev.rows) {
+    return { summary: `本期或上期无可比数据(本期 ${cur.rows} 行 / 上期 ${prev.rows} 行),无法归因。`, data: { from, to, prevFrom, prevTo } };
+  }
+  const v0 = prev.gross;
+  const v1 = cur.gross;
+  const delta = v1 - v0;
+  const factors = [
+    { key: 'expUv', name: '曝光效应', cur: cur.expUv, prev: prev.expUv },
+    { key: 'clkExpRate', name: '进店率效应', cur: cur.clk, prev: prev.clk },
+    { key: 'perActualPay', name: '客单价效应', cur: cur.pay, prev: prev.pay },
+  ];
+  let lines;
+  let outFactors = [];
+  let residual = null;
+  if (v0 <= 0 || v1 <= 0 || delta === 0 || !v0 || !v1) {
+    lines = [`营业额 ${Math.round(v0)} → ${Math.round(v1)}:基期为 0 或无变化,LMDI 分解不适用。`];
+  } else {
+    const L = delta / (Math.log(v1) - Math.log(v0)); // 对数平均权重
+    let sum = 0;
+    for (const f of factors) {
+      let contribution = null;
+      let note = '';
+      if (!f.cur || !f.prev || f.cur <= 0 || f.prev <= 0) note = '(某期为 0,无法对数分解)';
+      else {
+        contribution = +(L * Math.log(f.cur / f.prev)).toFixed(2);
+        sum += contribution;
+      }
+      outFactors.push({ key: f.key, name: f.name, cur: f.cur, prev: f.prev, contribution, note });
+    }
+    residual = +(delta - sum).toFixed(2);
+    lines = [`营业额归因(LMDI,${ymdDash(from)} ~ ${ymdDash(to)} vs ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)},可比口径):总变化 ${Math.round(delta)} 元,分解为——`];
+    for (const f of outFactors) {
+      const share = f.contribution != null && delta !== 0 ? `(占 ${(f.contribution / delta * 100).toFixed(0)}%)` : '';
+      lines.push(`  ${f.name}:${f.contribution == null ? note : `${f.contribution >= 0 ? '+' : ''}${f.contribution} 元${share}`}`);
+    }
+    if (Math.abs(residual) > Math.abs(delta) * 0.02 + 1) {
+      lines.push(`  混合残差 ${residual} 元(多店聚合与店间结构变化的交叉项,非某单一因素)。`);
+    }
+    const top = outFactors.filter((f) => f.contribution != null).sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))[0];
+    if (top) lines.push(`主因:${top.name}(${top.contribution >= 0 ? '拉动' : '拖累'} ${Math.abs(top.contribution)} 元)。结论请结合业务事件解读。`);
+  }
+  return {
+    summary: lines.join('\n'),
+    data: { from, to, prevFrom, prevTo, cur, prev, factors: outFactors, residual, delta },
+  };
+}
+
+module.exports = { attributionSanitize, attributionHandler };

+ 103 - 0
server/agent/tools/metric.js

@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * 智能问数 · 指标查询工具
+ * ----------------------------------------------------------------------------
+ * metric_query:按指标 key 查逐日序列(支持派生指标如 7 日移动平均)。
+ * 模型引用指标 key,聚合口径全部由指标层决定——比率加权/比值/移动平均都不许自算。
+ * 移动平均窗口取「当日及前 N-1 天内已有数据的天」(缺数日按实算,不插值)。
+ */
+
+const metrics = require('../metrics.js');
+const { eachDay, ymdDash } = require('./lake.js');
+
+const z2 = (n) => String(n).padStart(2, '0');
+function dayShift(ymd, delta) {
+  const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  d.setDate(d.getDate() + delta);
+  return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
+}
+
+function metricSanitize(args, ctx) {
+  const a = args || {};
+  const m = metrics.get(String(a.metric || ''));
+  if (!m) {
+    throw new Error(`未知指标:${a.metric}。可用指标:\n${metrics.manifestText()}`);
+  }
+  const { shops, dateRange } = ctx;
+  const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
+  const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
+  let shopIds = null;
+  if (Array.isArray(a.shops) && a.shops.length) {
+    const nlQuery = require('../../../nl-query.js');
+    shopIds = nlQuery.matchShopsByName(shops, a.shops);
+    if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
+  }
+  return {
+    metric: String(a.metric),
+    from: from < dateRange.from ? dateRange.from : from,
+    to: to > dateRange.to ? dateRange.to : to,
+    shopIds,
+    window: m.window || 1,
+  };
+}
+
+function metricHandler(ctx, args) {
+  const { svc, entityKey } = ctx;
+  const { metric, from, to, shopIds, window } = args;
+  const def = metrics.get(metric);
+  const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
+  const unitFmt = { money: (v) => `${Math.round(v).toLocaleString('zh-CN')}元`, pct: (v) => `${(v * 100).toFixed(2)}%`, int: (v) => String(Math.round(v)) };
+
+  // 派生移动平均需要窗口前置数据:多取 window-1 天
+  const leadFrom = window > 1 ? dayShift(from, -(window - 1)) : from;
+  let rows = svc.store.queryRows(entityKey, { from: leadFrom, to, scope: 'shop' });
+  if (shopIds) {
+    const wanted = new Set(shopIds.map(String));
+    rows = rows.filter((r) => wanted.has(String(r.shopId)));
+  }
+  const byDate = new Map();
+  for (const r of rows) {
+    if (!byDate.has(r.date)) byDate.set(r.date, []);
+    byDate.get(r.date).push(r);
+  }
+  const dayVal = (d) => metrics.dayValue(def.base || metric, byDate.get(d) || []);
+
+  const days = eachDay(from, to);
+  const series = [];
+  for (const d of days) {
+    let value = null;
+    if (window > 1) {
+      const win = [];
+      for (let i = 0; i < window; i++) {
+        const v = dayVal(dayShift(d, -i));
+        if (v != null) win.push(v);
+      }
+      value = win.length ? +(win.reduce((s, x) => s + x, 0) / win.length).toFixed(2) : null;
+    } else {
+      value = dayVal(d);
+    }
+    series.push({ date: d, value });
+  }
+
+  const values = series.map((s) => s.value).filter((v) => v != null);
+  const fmt = unitFmt[def.unit] || ((v) => String(v));
+  const lines = [`${def.name}(${ymdDash(from)} ~ ${ymdDash(to)}${window > 1 ? `,${window} 日移动平均,缺数日按实算` : ''}):`];
+  for (const s of series) lines.push(`  ${ymdDash(s.date)}:${s.value == null ? '无数据' : fmt(s.value)}`);
+  if (values.length) {
+    const avg = values.reduce((s, x) => s + x, 0) / values.length;
+    lines.push(`区间:均值 ${fmt(+avg.toFixed(2))},最高 ${fmt(Math.max(...values))},最低 ${fmt(Math.min(...values))}。`);
+  } else {
+    lines.push('区间内无数据。');
+  }
+  return {
+    summary: lines.join('\n'),
+    data: {
+      metric, name: def.name, unit: def.unit, from, to, window,
+      series, totalRows: series.length,
+      stats: values.length ? { avg: +(values.reduce((s, x) => s + x, 0) / values.length).toFixed(2), max: Math.max(...values), min: Math.min(...values) } : null,
+    },
+  };
+}
+
+module.exports = { metricSanitize, metricHandler };

+ 27 - 2
server/agent/tools/registry.js

@@ -15,6 +15,10 @@ const peer = require('./peer.js');
 const entity = require('./entity.js');
 const cloud = require('./cloud.js');
 const report = require('./report.js');
+const metric = require('./metric.js');
+const metrics = require('../metrics.js');
+const anomaly = require('./anomaly.js');
+const attribution = require('./attribution.js');
 
 const TOOLS = [
   {
@@ -80,10 +84,31 @@ const TOOLS = [
     sanitize: cloud.cloudSanitize,
     handler: cloud.cloudHandler,
   },
+  {
+    name: 'metric_query',
+    desc: '按指标查逐日序列(含派生指标如 7 日移动平均)。聚合口径由指标层决定:比率加权、客单价比值、移动平均都不许自己算。要「移动平均/平滑/去噪」视图时用它。',
+    argsDoc: '{"metric":"gross_mov7","from":"YYYYMMDD","to":"YYYYMMDD","shops":["门店名"]}(metric 可用:grossAmt/validOrderCnt/incomeAmt/expenseAmt/expUv/orderUserCnt/clkExpRate/ordClkRate/perActualPay/gross_mov7/orders_mov7/uv_mov7)',
+    sanitize: metric.metricSanitize,
+    handler: metric.metricHandler,
+  },
+  {
+    name: 'detect_anomalies',
+    desc: '统计异常检测:加性指标逐日序列的 leave-one-out z-score(σ 下限防误报),|z|≥2 警告、≥3 不一致;区间含寒暑假会提示基线失真。凡问「哪天不对劲/异常波动」用它。',
+    argsDoc: '{"metric":"grossAmt","from":"YYYYMMDD","to":"YYYYMMDD","k":2}(metric 可用 grossAmt/validOrderCnt/incomeAmt/expUv/orderUserCnt)',
+    sanitize: anomaly.anomalySanitize,
+    handler: anomaly.anomalyHandler,
+  },
+  {
+    name: 'attribution',
+    desc: '营业额归因(LMDI):把环比变化分解为 曝光效应/进店率效应/客单价效应,给出各因素贡献金额与主因。凡问「为什么涨跌/谁拉动谁拖累」在 compare_periods 之后用它。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD","shops":["门店名"]}(缺省=湖内最后 7 天;单店口径恒等式严格成立)',
+    sanitize: attribution.attributionSanitize,
+    handler: attribution.attributionHandler,
+  },
   {
     name: 'build_report',
-    desc: '生成经营分析 HTML 报告(KPI 概览/逐日趋势/门店明细,可打印导出 PDF)。用户要「报告/周报/导出」时调用,回答中提示点击「查看分析报告」。',
-    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=全湖区间)',
+    desc: '生成经营分析 HTML 报告。deep=false 基础版(KPI/趋势/明细);deep=true 深度版(另含数据体检/统计异常/归因/同行对标章节)。用户要「报告/周报/导出」时调用,回答中提示点击「查看分析报告」。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD","deep":false}(缺省=全湖区间)',
     sanitize: report.reportSanitize,
     handler: report.reportHandler,
   },

+ 9 - 5
server/agent/tools/report.js

@@ -15,18 +15,22 @@ function reportSanitize(args, ctx) {
   const { dateRange } = ctx;
   const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
   const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
-  return { from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to };
+  return {
+    from: from < dateRange.from ? dateRange.from : from,
+    to: to > dateRange.to ? dateRange.to : to,
+    deep: !!a.deep,
+  };
 }
 
 function reportHandler(ctx, args) {
   const { entityKey, dateRange } = ctx;
-  const { from, to } = args;
+  const { from, to, deep } = args;
   const params = new URLSearchParams({ entity: entityKey, from, to });
-  const url = `/api/reports/report-html?${params.toString()}`;
+  const url = deep ? `/api/agent/report-deep?${params.toString()}` : `/api/reports/report-html?${params.toString()}`;
   const wholeLake = from === dateRange.from && to === dateRange.to;
   return {
-    summary: `已就绪经营分析报告:${ymdDash(from)} ~ ${ymdDash(to)}${wholeLake ? '(全湖区间)' : ''},包含 KPI 概览、逐日趋势与门店明细(HTML,浏览器新窗口打开,可打印导出 PDF)。回答中提示用户点击「查看分析报告」查看。`,
-    data: { url, from, to },
+    summary: `已就绪${deep ? '深度分析报告(含数据体检/统计异常/归因/同行对标章节)' : '经营分析报告'}:${ymdDash(from)} ~ ${ymdDash(to)}${wholeLake ? '(全湖区间)' : ''}(HTML,浏览器新窗口打开,可打印导出 PDF)。回答中提示用户点击「查看分析报告」查看。`,
+    data: { url, from, to, deep },
   };
 }
 

+ 14 - 1
server/agent/trace.js

@@ -146,7 +146,20 @@ function loadRecentConclusions(entityKey, sessionId, limit = 4) {
   return out;
 }
 
+/** 纠错反馈日志(问数答案「纠错」入口):cap 100,供金标准评测人工提炼 */
+function feedbackFile(entityKey) {
+  return path.join(AGENT_DIR, sanitizeKey(entityKey), 'feedback.json');
+}
+
+function appendFeedbackLog(entityKey, entry) {
+  let items = [];
+  try { items = JSON.parse(fs.readFileSync(feedbackFile(entityKey), 'utf8')).items || []; } catch { /* 首条 */ }
+  items.unshift({ ...entry });
+  if (items.length > 100) items.length = 100;
+  writeJsonAtomic(feedbackFile(entityKey), { items });
+}
+
 module.exports = {
   AGENT_DIR, newRunRecord, pushEvent, persistRun, loadRun,
-  loadSessions, upsertSessionRun, deleteSession, loadRecentConclusions, newId, sanitizeKey,
+  loadSessions, upsertSessionRun, deleteSession, loadRecentConclusions, appendFeedbackLog, newId, sanitizeKey,
 };

+ 7 - 0
server/context.js

@@ -26,6 +26,10 @@ const CONFIG_DEFAULTS = {
   chatModel: '',
   chatApiBase: '',
   chatApiKey: '',
+  // 深度分析模型(问数智能体专用,缺省回落 chatModel;快问始终用 chatModel)
+  chatModelAgent: '',
+  // 问数动作协议走 JSON mode(response_format=json_object):网关支持时解析更稳,默认关
+  chatJsonMode: false,
   accessToken: '',
   // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印)
   httpLog: false,
@@ -301,6 +305,9 @@ function makeChatCfg(ctx) {
     chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
     chatApiKey: ctx.config.chatApiKey || ctx.config.apiKey,
     chatModel: ctx.config.chatModel || DEFAULT_CHAT_MODEL,
+    // 智能体深度分析模型(可选):缺省回落主对话模型
+    chatModelAgent: ctx.config.chatModelAgent || ctx.config.chatModel || DEFAULT_CHAT_MODEL,
+    chatJsonMode: !!ctx.config.chatJsonMode,
   };
 }
 

+ 36 - 0
server/routes/agent.js

@@ -14,6 +14,8 @@ const naposService = require('../../napos-service.js');
 const aitableFactory = require('../services/aitable.js');
 const agent = require('../agent/index.js');
 const agentSkills = require('../agent/skills.js');
+const digest = require('../agent/digest.js');
+const deepReport = require('../agent/deep-report.js');
 
 function requireEntity(query, body) {
   const entity = (body && body.entity) || query.entity
@@ -86,6 +88,40 @@ function register(app, ctx) {
   app.get('/api/agent/skills', async ({ res }) => {
     sendJson(res, 200, { skills: agentSkills.SKILLS.map(({ key, name, desc }) => ({ key, name, desc })) });
   });
+
+  app.get('/api/agent/digests', async ({ res, query }) => {
+    const entity = requireEntity(query);
+    sendJson(res, 200, { entity, digests: digest.loadDigests(entity) });
+  });
+
+  // 手动重建晨报(每日自动同步后也会自动生成)
+  app.post('/api/agent/digests/build', async ({ req, res }) => {
+    const body = await readBody(req);
+    const entity = requireEntity({}, body);
+    const d = digest.buildAndSaveDigest(entity, naposService);
+    sendJson(res, 200, { digest: d });
+  });
+
+  // 答案反馈:采纳(打法入语料)/ 纠错(进反馈日志供评测提炼)
+  app.post('/api/agent/feedback', async ({ req, res }) => {
+    const body = await readBody(req);
+    const entity = requireEntity({}, body);
+    const rec = agent.feedback({
+      entity, runId: body.runId,
+      kind: body.kind === 'up' ? 'up' : 'down',
+      note: body.note,
+    });
+    if (!rec) return sendJson(res, 404, { error: '运行记录不存在' });
+    sendJson(res, 200, { run: rec });
+  });
+
+  // 深度分析报告(确定性生成:体检/环比/归因/异常/对标/明细,自包含 HTML)
+  app.get('/api/agent/report-deep', async ({ res, query }) => {
+    const entity = requireEntity(query);
+    const html = deepReport.buildDeepReportHtml({ entity, from: query.from, to: query.to, svc: naposService });
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+    return res.end(html);
+  });
 }
 
 module.exports = { register };

+ 4 - 0
server/routes/settings.js

@@ -28,6 +28,8 @@ function register(app, ctx) {
       chatApiBase: ctx.config.chatApiBase || ctx.config.apiBase,
       chatApiKey: ctx.config.chatApiKey ? ctx.config.chatApiKey.slice(0, 8) + '••••••••' : '',
       chatKeyFallback: !ctx.config.chatApiKey && !!ctx.config.apiKey,
+      chatModelAgent: ctx.config.chatModelAgent || '',
+      chatJsonMode: !!ctx.config.chatJsonMode,
       autoSyncEnabled: !!(ctx.config.autoSync && ctx.config.autoSync.enabled),
       autoSyncTime: (ctx.config.autoSync && ctx.config.autoSync.time) || '06:00',
       accessTokenSet: !!ctx.config.accessToken,
@@ -73,6 +75,8 @@ function register(app, ctx) {
     if (body.chatModel !== undefined) config.chatModel = String(body.chatModel).trim();
     if (body.chatApiBase !== undefined) config.chatApiBase = String(body.chatApiBase).trim();
     if (body.chatApiKey !== undefined && !String(body.chatApiKey).includes('••••')) config.chatApiKey = String(body.chatApiKey).trim();
+    if (body.chatModelAgent !== undefined) config.chatModelAgent = String(body.chatModelAgent).trim();
+    if (body.chatJsonMode !== undefined) config.chatJsonMode = !!body.chatJsonMode;
     if (body.autoSyncEnabled !== undefined || body.autoSyncTime !== undefined) {
       if (!config.autoSync || typeof config.autoSync !== 'object') config.autoSync = { enabled: false, time: '06:00' };
       if (body.autoSyncEnabled !== undefined) config.autoSync.enabled = !!body.autoSyncEnabled;

+ 8 - 0
server/services/auto-sync.js

@@ -103,6 +103,14 @@ function createAutoSyncService(ctx) {
             logs.pushSystemLog(`推送云端失败:${e.message || e}`, 'error');
           });
         }
+        // 同步完成 → 生成当日晨报(确定性口径:体检 + 层间核对 + 环比,零模型;
+        // 结果落问数页顶部横幅展示,钉钉推送通道后续按需接入)
+        if (snap.entity) {
+          const { buildAndSaveDigest } = require('../agent/digest.js');
+          buildAndSaveDigest(snap.entity.key, naposService)
+            .then((d) => logs.pushSystemLog(`晨报已生成:${d.headline}`, d.status === 'fail' ? 'warn' : 'info'))
+            .catch((e) => logs.pushSystemLog(`晨报生成失败:${e.message || e}`, 'warn'));
+        }
       }
     } catch (e) {
       logs.pushSystemLog(`自动同步失败:${e.message || e}`, 'error');