2
0

2 Ревизии 30ffbae4a2 ... 6d4d7c29b8

Автор SHA1 Съобщение Дата
  KenZ1117 6d4d7c29b8 feat: 智能问数升级为数据智能体(独立页面 + harness/loop) преди 2 седмици
  KenZ1117 7339f1bbf7 fix: 采集链路加固与体验修复 преди 2 седмици
променени са 47 файла, в които са добавени 3941 реда и са изтрити 512 реда
  1. 294 0
      docs/智能问数智能体方案.md
  2. 9 1
      napos-service.js
  3. 42 6
      napos/cdp.js
  4. 53 15
      napos/kit.js
  5. 17 3
      napos/ws.js
  6. 3 55
      nl-query.js
  7. 12 5
      public/analysis-templates.js
  8. 64 50
      public/app.js
  9. 550 0
      public/ask.js
  10. BIN
      public/assets/logo.png
  11. 40 6
      public/crawl.js
  12. 53 24
      public/gen.js
  13. 4 1
      public/history.js
  14. 71 46
      public/index.html
  15. 34 200
      public/reports.js
  16. 128 72
      public/style.css
  17. 9 0
      public/tasks.js
  18. 12 6
      report-html.js
  19. 23 1
      reports-store.js
  20. 8 1
      scripts/diagnose-napos.js
  21. 357 2
      scripts/smoke.js
  22. 15 4
      server.js
  23. 56 0
      server/agent/guard.js
  24. 177 0
      server/agent/index.js
  25. 121 0
      server/agent/llm.js
  26. 239 0
      server/agent/loop.js
  27. 27 0
      server/agent/mock-model.js
  28. 61 0
      server/agent/prompt.js
  29. 83 0
      server/agent/provenance.js
  30. 47 0
      server/agent/skills.js
  31. 101 0
      server/agent/tools/cloud.js
  32. 179 0
      server/agent/tools/compare.js
  33. 106 0
      server/agent/tools/cross.js
  34. 78 0
      server/agent/tools/entity.js
  35. 234 0
      server/agent/tools/lake.js
  36. 145 0
      server/agent/tools/peer.js
  37. 100 0
      server/agent/tools/registry.js
  38. 33 0
      server/agent/tools/report.js
  39. 152 0
      server/agent/trace.js
  40. 16 2
      server/context.js
  41. 36 4
      server/middleware.js
  42. 91 0
      server/routes/agent.js
  43. 1 0
      server/routes/index.js
  44. 5 6
      server/routes/reports.js
  45. 1 0
      server/routes/static.js
  46. 30 1
      server/services/aitable.js
  47. 24 1
      server/services/generation.js

+ 294 - 0
docs/智能问数智能体方案.md

@@ -0,0 +1,294 @@
+# 智能问数 · 智能体化方案(harness + loop)
+
+> 目标:移除经营分析页右下角问数机器人弹窗,新增独立「智能问数」页面;问数能力从
+> 「单发 NL2Query」升级为类 Codex / WorkBuddy 的数据智能体 —— 带 harness(编排契约)
+> + loop(工具循环)架构,专注数据处理与分析:交叉比对、同比环比验证、数据体检、
+> 同行对标。基于现有「本地数据湖 + 钉钉 AI 表格」双层存储寻最优解,参考 ai-fde
+> 的副驾(copilot-delegation)与 agent runtime 模式。
+
+---
+
+## 一、审计结论
+
+### 1.1 现状链路
+
+| 层 | 位置 | 现状 |
+| --- | --- | --- |
+| 前端 | `public/reports.js:1292-1470` + `public/index.html:768+` | 右下角 FAB 弹窗,仅 reports/analysis 视图显示;localStorage 按主体存 20 条;ECharts 渲染 +「应用到报表」跳转数据中台 |
+| 路由 | `server/routes/reports.js:22` | `POST /api/reports/ask` 单发同步接口 |
+| 问数 | `nl-query.js`(334 行) | 两段式:①语义层 prompt → 模型输出查询 JSON → `sanitizeQuery` 钳制 → 本地湖执行;②Top≤10 行 digest 回传模型写一句结论,失败降级模板句 |
+| 语义层 | `napos-service.js` `REPORT_COLUMNS` | 19 列注册表:key/label/同义词/口径说明/预留列标记 |
+| 数据湖 | `reports-store.js` | JSON 按月分片,行键 `date\|scope\|chainId\|shopId`;scope=company/chain/shop/summary;**全字段落库**(每行 raw 含环比差值 `_diff`、同行均值/排名 `_mall_avg(_rank)`、上期对比 `_last_period`,约 60+ 字段) |
+| 云端 | `server/services/aitable.js` | 钉钉 AI 表格三表(门店明细/连锁汇总/公司汇总),pushLake/pullLake 水位同步 |
+| 模型 | `server/context.js makeChatCfg` | OpenAI 兼容 /v1/chat/completions,deepseek-v4.1-flash(默认)/ glm-5.3-flash,`stream:false`,文本内嵌 JSON(`extractJson`),**未用原生 function calling** |
+
+### 1.2 现状局限(升级动因)
+
+1. **单发无循环**:一次规划定死查询,答不了「先看整体再下钻到异常店再看异常日」的复合分析;追问只靠 history 里拼接的文字摘要。
+2. **无验证层**:结论句直接采信一次查询结果。shop 行全零而 chain 行正常的已知 bug(挂起中)就是例证 —— 没有任何环节做层间一致性核对;比率指标若被简单算术平均(而非加权)也不会被发现。
+3. **无工具抽象**:取数逻辑写死在 nl-query 里,湖里最值钱的数据(raw 的 `_diff/_mall_avg/_last_period`、syncs 同步日志、bench 同行对标、跨主体 crossData)全都触达不到。
+4. **无过程可见性**:用户只能看到最终一句话 + 图,模型选了什么区间/门店/指标,只有「应用到报表」后反向可见;出了错无法定位。
+5. **无护栏**:无轮次/调用/时长预算,无循环侦测,无取消。
+6. **无审计**:对话只在浏览器 localStorage,服务端零留痕,换电脑即失。
+
+### 1.3 必须保住的既有资产(方案全部复用)
+
+- `summarizeRows` 的**加权口径**:进店率按曝光加权、下单率按进店加权 —— 这是防「算术平均比率」错误的关键,agent 一切聚合必须走它,不允许模型自己算。
+- `sanitizeQuery` 的钳制哲学:日期钳在湖内区间(T-1 结算)、指标白名单、limit 上限。
+- 语义层注册表 `REPORT_COLUMNS` + 同义词表。
+- 全字段落库的 raw 字段:环比/上期/同行排名的**源侧真值**,验证层直接比对,不用重算。
+- `/api/reports/bench`(同行对标)、`ensureCrossData`(跨主体对比)、`report-html.js`(HTML 周报)、`aggregateReport/queryReport`。
+- `syncState/syncSnapshot` 的任务状态机模式、`syncs.json` 的留痕模式 —— agent 运行态照此办理。
+- 零依赖纪律:`package.json` 仅图像编解码两个依赖,LLM 调用手写 HTTP。**智能体全程延续零依赖**。
+
+### 1.4 从 ai-fde 借的模式(映射)
+
+| ai-fde | 本方案 |
+| --- | --- |
+| `copilot-delegation.ts` 双模式:direct(点名工具直调,成本 ~1/20)vs agent(完整 harness) | 「快问」保留 nl-query 单发;「深度分析」走 loop。双模式并存,同一页面切换 |
+| `runtime.ts` 主循环 + 循环侦测(回注重复提醒而非硬杀) | loop.js 同款:相同 tool+args 重复 → 注入提醒 → 再犯才强制收敛 |
+| `harness-modes`(auto/plan/research/…,模式定温度/预算/确认闸门) | 收敛为两模式:快问(低成本)/ 深度分析(loop),温度 0.2 |
+| `tool-registry` 清单求交防幻觉工具名 + tool-guard 风险级 | tools/registry.js 同款:模型输出工具名先与清单求交,再过参数 sanitize |
+| `harness-trace` 审计事件(tool.completed outcome / agent.error,成败都留痕)+ `turn-assessment` 零 token 规则打分 | trace.js 事件流 + 落盘;「数字溯源 post-check」沿用零 token 规则哲学 |
+| 委派永不抛出、失败折叠进结构化结果 + EventLog | run 状态机失败折叠为 `{status:'failed', error}`,前端可读 |
+
+---
+
+## 二、目标与非目标
+
+**目标**
+1. 独立「智能问数」页(经营分析分组第 4 项),会话式、过程可见、可取消、可回放。
+2. harness + loop 智能体:规划 → 调工具取数 → 校验 → 结论,多步复合分析。
+3. 验证层为一等公民:同比环比验证、层间交叉比对、恒等式校验、数据体检、同行对标,产出结构化 verdict 卡。
+4. 内置 tools + skills 双层:tools 是可执行取数/校验动作;skills 是注入 prompt 的分析打法(playbook)。
+5. 双层存储用满:本地湖为计算层(快、全字段),钉钉 AI 表格为档案层(跨机共享)—— 增加湖↔云比对工具。
+
+**非目标**
+- 不引入任何 npm 依赖、不换框架、不上向量库/SQL。
+- 不做通用代码执行沙箱(模型永不直接算数,聚合只走本地确定性函数)。
+- 不动生成/采集/任务中心等既有模块。
+- 本期不做流式打字机(沿用轮询;见 §9 开放问题)。
+
+---
+
+## 三、总体架构
+
+```text
+┌─ 前端 public/ask.js + index.html(view=ask) ─────────────────────────┐
+│ 会话列表 │ 对话流:用户消息 / 工具步骤折叠条 / verdict 卡 / 图表表格卡 │
+│          │ 底部 composer:模式(快问/深度) · 发送 · 停止              │
+└──────────────┬──────────────────────────────────────────────────────┘
+               │ POST /api/agent/runs → {runId};GET /api/agent/runs/:id 轮询
+┌─ server/agent ─▼────────────────────────────────────────────────────┐
+│ index.js   运行注册表(syncState 模式:running/steps/cancel)        │
+│ loop.js    while 循环:prompt→解析→护栏→执行→观察→(提醒/收敛)        │
+│ llm.js     chatCompletion(自 nl-query 抽出共用)+ JSON 解析与修复   │
+│ guard.js   工具清单求交、参数 sanitize、预算(轮次/调用/时长)、循环侦测│
+│ trace.js   事件流 + runs 落盘 + 数字溯源 post-check(零 token 规则)  │
+│ tools/     registry + lake_overview/query_report/compare_periods/   │
+│            make_chart/cross_check/data_quality/peer_benchmark/…     │
+│ skills/    周期对比 · 数据体检 · 同行对标 · 下钻归因(prompt playbook)│
+└──────────────┬──────────────────────────────────────────────────────┘
+               │
+    ┌──────────▼──────────┐   ┌──────────────────────────────┐
+    │ 本地数据湖 reports-  │   │ 钉钉 AI 表格 aitable.js       │
+    │ store(计算缓存层,  │   │ (在线主档案/共享层,三表)    │
+    │ raw 全字段落库)     │   │  P3: aitable_verify 湖↔云比对 │
+    └─────────────────────┘   └──────────────────────────────┘
+```
+
+---
+
+## 四、Harness + Loop 设计
+
+### 4.1 回合协议(文本 JSON,非原生 function calling)
+
+沿用 `extractJson` 已被验证的路子(两个 flash 模型、temperature 0 下稳定),结构化为单一动作对象:
+
+```json
+{"thought": "先看整体趋势,再定位异常门店",
+ "action": {"tool": "query_report", "args": {"mode": "date", "from": "20261001", "to": "20261007", "metrics": ["grossAmt","validOrderCnt"]}}}
+```
+
+结束回合:
+
+```json
+{"thought": "数据齐了,二店环比下滑主因是曝光下降",
+ "final": {"answer": "markdown 文本",
+           "chart": {"type": "line", "x": "date", "y": ["grossAmt"]},
+           "applied": {…同现有 applied 形状,供「应用到报表」…},
+           "verdicts": ["v1", "v3"], "sources": ["t1", "t2", "t4"]}}
+```
+
+- 解析失败:带错误信息回注重模型修复一次,再失败即终止该轮(折叠为可读失败)。
+- 工具名与注册表清单求交,未注册名 → 回注「工具不存在,可用清单:…」修正一次(ai-fde 防幻觉同款)。
+
+### 4.2 主循环(loop.js)
+
+```text
+准备:系统提示(角色 + 语义层 + 工具清单 + skills 打法 + 输出契约 + 今天/湖区间)
+     + 会话历史(压缩后)+ skills 注入
+while (true):
+  1 调 LLM(快问直连路径除外)
+  2 解析动作 → guard 校验(清单求交 / sanitize / 预算)
+  3 执行工具 → 观察(rows 裁剪 Top≤20 + summary;全量留在 artifacts)
+  4 观察 appended;循环侦测:同 tool+规范化args 连续重复 → 注入提醒;第三次强制 finish
+  5 final 或触发预算上限(maxTurns/maxToolCalls/超时)→ 强制 finish 或折叠失败
+收尾:trace 落盘、数字溯源 post-check、状态机置 done/failed/cancelled
+```
+
+### 4.3 护栏与预算(guard.js,常量起步零配置)
+
+- maxTurns=10、maxToolCalls=16、单次 LLM 45s、单 run 墙钟 120s(照抄 nl-query 超时量级)。
+- 循环侦测用「提醒注入」,不硬杀(ai-fde runtime 注释里的 Codex/Claude Code 同款思路)。
+- 取消:run 状态机带 `cancelRequested`,工具间隙检查(照抄 `requestSyncCancel`)。
+- 工具只读:本期全部工具均为只读(湖查询/校验/图表规格),无写操作 —— 不需要 ai-fde 的风险级审批链,guard 简化为参数钳制。
+
+### 4.4 双模式(ai-fde direct/agent 同构)
+
+- **快问**:直接走现有 `nl-query.ask`(2 次 LLM 调用),回答快、成本 1/3。适合「昨天营业额多少」「上周 Top5」。
+- **深度分析**:走 loop(3~10 次调用)。适合「环比为什么跌」「哪些店数据有问题」「对标同行谁掉队了」。
+- P1 前端显式 toggle,默认深度分析;P3 加零成本规则启发自动分流(问题长度 + 指标词命中 + 是否含「为什么/对比/变化」类词)。
+
+### 4.5 运行态与留痕(trace.js)
+
+- 运行注册表:`server/agent/index.js` 持有 `Map<runId, state>`,state 形状仿 `syncSnapshot`(status/steps/error/startedAt/finishedAt)。
+- 接口:
+  - `POST /api/agent/runs`(body: entity, question, mode, sessionId)→ `{runId}`,立即返回
+  - `GET /api/agent/runs?entity=` 会话与历史 run 列表
+  - `GET /api/agent/runs/:id` → 进度 + 事件数组(前端 1s 轮询,模式与任务中心一致)
+  - `POST /api/agent/runs/:id/cancel`
+  - `GET /api/agent/sessions?entity=`
+- 落盘:`data/agent/<entityKey>/runs/<runId>.json`(事件数组:llm.call/tool.completed{outcome,durationMs}/verdict/final),`sessions.json` 索引 cap 30。**成败都留痕**(ai-fde EventLog 哲学:失败无留痕时问题不会有任何信号)。
+- 历史压缩(compaction):新回合只携带旧回合的「final 结论 + 关键数字 + 用过的区间/门店」,旧观察全部丢弃 —— 防上下文随轮次膨胀(ai-fde compaction 的简化版)。
+
+---
+
+## 五、工具集设计(tools/)
+
+| 工具 | 职责 | 复用/说明 |
+| --- | --- | --- |
+| `lake_overview` | 主体/门店清单、湖内日期范围、各月行数、最近同步时间与成败 | 入口必调:先知道有什么数据再问;多机场景提示「先云端拉取」 |
+| `query_report` | 明细/按店/按日查询 + 排序 + TopN + 区间合计 | 内核 = `queryReport`/`aggregateReport`/`summarizeRows`(加权口径);参数走 `sanitizeQuery` 同源钳制 |
+| `compare_periods` | 环比/同比:本期 vs 基期(等长平移,支持任意偏移) | 门店集合对齐(开/闭店列出)、缺失日对齐说明、比率加权 delta |
+| `cross_check` | **层间一致性**:每日 sum(shop)≈chain 行、sum(chain)≈company 行;**恒等式**:收入+支出=营业额、营业额/订单≈客单价;**日期连续性** | 容差可配;直接命中「shop 全零」这类已踩的坑(见 §8) |
+| `data_quality` | 体检:全零行、缺失日、预留列 null、syncs 最近失败/告警 | 同步步骤里已有 `nonZero===0 → warn` 的先例,升级为工具 |
+| `raw_inspect` | 取某行 raw 的指定字段(`_diff/_last_period/_mall_avg/_rank`) | 源侧真值交叉验证:本地算的环比 vs 取数接口自带的环比差值 |
+| `peer_benchmark` | 同行对标:门店×天 自身值/同行均值/排名,跨期排名变化 | 内聚 `/api/reports/bench` 逻辑为服务函数供两端复用 |
+| `make_chart` | ~~独立工具~~ P1 实施调整:图表规格随 final 输出、由 `guard.clampChart` 按语义层白名单钳制;图表数据取自本运行最后一次查询产物(rows/curByDate/curByShop),模型不传数据只传规格 | 复用 sanitizeQuery 的 chart 钳制思路 |
+| `aitable_verify`(P3) | 湖↔云:行数比对 + 按行键抽样值 diff | 只读拉取(记录接口无限流),复用 aitable.js pullLake 的读路径 |
+| `cross_entity`(P3) | 跨主体(跨校)本期/基期对比 | 内聚 ensureCrossData 逻辑 |
+
+工具出参统一带 `digest`(给模型的压缩观察)与 `data`(给前端的完整产物)两路。
+
+## 六、技能集设计(skills/,prompt playbook,非工具)
+
+| skill | 打法(注入的步骤约束) | 阶段 |
+| --- | --- | --- |
+| 周期对比分析 | overview → 本期+基期 query → compare_periods → cross_check 抽查 → 图表 → 结论必答「变化量/变化率/主因店」 | P2 |
+| 数据体检 | data_quality 全量 + cross_check 全量 → 出体检报告(分级问题清单 + 指引:如全零行建议跑 scripts/diagnose-napos.js) | P2 |
+| 同行对标 | peer_benchmark → 排名恶化店下钻逐日 → raw_inspect 看同行均值是否整体下移 | P3 |
+| 下钻归因 | TopN → 锁定异常店逐日 → 定位拐点日 → 区分自身问题 vs 大盘问题(`_diff`/`_mall_avg`) | P3 |
+
+skills 触发:用户点页面快捷 chip 或模型在规划回合自选(最终回答的 `final.verdicts/sources` 契约不变)。
+
+---
+
+## 七、验证层(本方案的差异化核心)
+
+### 7.1 verdict 体系
+
+每个校验产出结构化判定:
+
+```json
+{"id":"v1","kind":"identity|cross-layer|freshness|period|peer",
+ "target":"2026-10 全部门店","expect":"sum(shop)=chain 行","actual":"差 0.4%",
+ "status":"pass|warn|fail","note":"…"}
+```
+
+前端渲染为 verdict 卡(绿/黄/红),结论文本必须引用 verdict id —— 「数据可不可信」和「结论是什么」并排呈现。
+
+### 7.2 数字溯源 post-check(零 token,仿 turn-assessment 哲学)
+
+final 返回后,本地规则校验:从 answer 抽取数值(金额/单量/百分比),逐个在 `sources` 对应工具观察的数值集合中做容差匹配;匹配不到的标注 `⚠ 未溯源` 展示(不阻断,但可见)。防编造的底线,纯字符串/数值匹配,零额外模型成本。
+
+### 7.3 同比环比验证方法论(compare_periods 内置,模型无权偏离)
+
+1. 基期 = 等长平移(环比)或上年同期(同比,湖内无数据时如实说明)。
+2. 门店集合对齐:两期都在线的店才算可比集,开/闭店单列。
+3. 比率指标一律加权(曝光/进店为权),禁止算术平均 —— 与 `summarizeRows` 同源实现。
+4. 缺失日显式列出(复用趋势图表 `dataQualityNote` 的空缺标注逻辑)。
+5. 交叉验证:本地计算的 delta 与该行 `raw._diff`(源侧环比差值)比对,超容差 → warn。
+
+---
+
+## 八、与已知数据问题的关系
+
+「shop 行全零、chain 行正常」的挂起 bug 是本方案的活教材:`cross_check` 的层间一致性会把这类问题在**每次问数时自动暴露**为 fail verdict,而不是等人工发现数字不对。配套两点:
+- 数据体检 skill 的报告对已知问题给出处置指引(诊断脚本 `scripts/diagnose-napos.js`)。
+- 体检 fail 时,涉及该数据的结论自动加「数据质量受影响」角标,避免用户把平台 bug 误读为经营事实。
+
+---
+
+## 九、前端页面(public/ask.js + index.html view=ask)—— 已按 FDE 副驾式布局实施
+
+- 导航:经营分析分组第 4 项「智能问数」(`data-view="ask"`),页面全高(100vh)。
+- 布局(2026-10-17 用户以 ai-fde「FDE 副驾」截图定版):
+  - **左会话栏**:品牌头(图标+标题+「+」新建)、会话搜索框(按标题过滤)、会话列表(标题 + 最近一问预览 + 悬停删除);
+  - **右舞台**:顶部「新对话/会话标题」+ 主体徽章 + 一行能力说明;空态为居中大标题「给问数一个目标」+ 说明 + 推荐 chips;
+  - **底部大输入卡**:textarea(回车发送 / Shift+Enter 换行 / 自动伸缩),控制行含主体下拉、深度分析/快问 seg、停止、圆形渐变发送钮。
+- 对话流元素:用户消息右气泡;工具步骤折叠条(工具名/参数摘要/耗时/结果行数,可展开看观察与原始参数);verdict 卡(P2);图表卡(ECharts,复用现有配色与格式化);「应用到报表」按钮(复用 `window.shanReportsApplyQuery` 跳数据中台明细 tab)。
+- 样式遵循既有约定:配色/字体沿用应用设计令牌(暖色 + 橙 accent),组件复用 seg-control/btn/panel 体系;help 小字克制(输入卡 placeholder 一行 + 空态说明一句)。
+- 移除:index.html 机器人 markup、reports.js 的 chat 区块(state/render/bindings 约 170 行)、app.js 视图可见性里的机器人逻辑、style.css 的 `.chatbot-*`。旧 localStorage 对话不迁移(价值低,新页服务端留痕)。
+
+---
+
+## 十、分阶段落地
+
+**P1 骨架(先移除、立骨架、跑通闭环)—— 2026-10-17 已完成,smoke 86/86**
+1. 移除机器人(§9 清单),`/api/reports/ask` 与 nl-query.js 保留(快问路径继续用);reports.js 以 `window.shanReportsApplyQuery` 承接「应用到报表」。
+2. `server/agent/`:llm.js(chatCompletion 自 nl-query 抽出共用)、loop.js、guard.js、trace.js、index.js + 4 个核心工具(lake_overview/query_report/compare_periods/data_quality);测试隔离环境变量 `GENPIC_LAKE_DIR`(reports-store)与 `GENPIC_AGENT_DIR`(trace)。
+3. 路由(§4.5 五条,挂 `server/routes/agent.js`);前端 `public/ask.js` + viewAsk 页面 + nav「智能问数」。
+4. smoke:`NAPOS_AGENT_MOCK=1` 确定性假模型在隔离湖上端到端跑通 loop(四工具序列/环比数字/体检判定/用量/会话索引),加解析/钳制/循环侦测纯函数断言。
+
+**P2 验证层 —— 2026-10-17 全部完成**
+- ✅ 工具齐装(9 个):lake_overview / query_report / compare_periods / data_quality / cross_check / raw_inspect / peer_benchmark / cross_entity(跨主体对比)/ aitable_verify(湖↔云只读比对,aitable 服务新增 snapshotForVerify,未启用时优雅降级为 warn verdict);
+- ✅ 内置技能:skills.js 四大技能(周期对比分析/数据体检/同行对标/下钻归因),打法常驻 prompt,composer「技能」下拉点名(run.skill),GET /api/agent/skills;
+- ✅ 编排时间线 UI:运行头「时间 + 状态·总耗时·N 个工具·自动分流·技能」+ 逐步骤(思考·Turn N 带耗时与 in/out tokens;工具带耗时/载荷字节/行数);
+- ✅ verdict 体系:校验类工具产出结构化判定(quality/identity/cross-layer/freshness/coverage/cloud),loop 归档到 run.verdicts(v1..vn),前端 verdict 卡(绿/黄/红左边条);
+- ✅ 数字溯源 post-check:provenance.js 零 token 规则(数值抽取剔除日期/TopN 噪声,容差匹配含 %/万/亿换算),未溯源数字前端黄条警示不阻断。
+
+**P3 —— 2026-10-17 完成**
+- ✅ 快问自动分流:mode=auto(前端默认档),服务端规则(>18 字或含分析类词 → 深度分析,否则快问),run.autoPicked 标记并在时间线头展示「自动→快问/深度分析」;
+- ✅ aitable_verify、cross_entity(见上);
+- ✅ HTML 分析报告:build_report 工具(复用 report-html.js 即时生成,零重复实现),final 携带 reportUrl,前端「查看分析报告」新窗口打开可打印导出 PDF;快问模式同样附带当期报告链接;
+- ✅ 会话管理:新建/搜索/删除/轮询续显(切页运行不中断,回来自动续接轮询)。
+
+**自检记录(2026-10-17,用户要求)**
+- ✅ 修复:问数页运行中切换会话/新建会话会让 composer 卡在「停止」态(busy 未复位);
+- ✅ 修复:切页回来运行中的运行不会恢复轮询(现 openSession 检测 running 运行并续接);
+- ✅ 修复:agent 路由重复实例化 aitable 服务(server.js 已有 ctx.aitable,改为复用);
+- ✅ 修复:reports.js 残留死变量 lastAskApply;
+- ✅ 确认:cloud 行键映射 scope 已转回本地枚举,湖↔云比对逻辑成立;无 console.log/debugger 残留;无 ask-thought 等死样式引用。
+
+预估:P1 约 6~8 个新文件 + 3 处移除;P2/P3 均为增量工具/技能注册,不动骨架。
+
+---
+
+## 十一、测试与回归
+
+- **纯函数单测进 smoke**:动作解析(含围栏/脏输出)、guard 钳制、compare_periods(fixture 行构造:比率加权、闭店对齐)、cross_check(合成全零/恒等式破坏 fixture 必须出 fail)、溯源 post-check。
+- **mock 模型端到端**:NAPOS_AGENT_MOCK 固化工具调用序列 → 断言 run 状态机、事件留痕、取消、预算强制收敛。
+- **红线不变**:smoke 绝不发真实 LLM 请求、不触碰 DELETE/启动类端点(沿用现有约定)。
+
+## 十二、风险与开放问题
+
+| 风险 | 对策 |
+| --- | --- |
+| flash 模型多轮 JSON 输出不稳 | temperature 0.2、单一 JSON 动作契约、失败回注修复一次、maxTurns 兜底、mock 锁行为进 smoke |
+| loop 成本高于单发(3~10 次调用) | 快问双模式并存 + 预算护栏 + 观察 digest 压缩(控制输入 token) |
+| 钉钉侧限流 | aitable_verify 只读、抽样比对,不批量拉全表 |
+| 多机湖不同步导致答旧数据 | lake_overview 展示最近同步时间,提示先「云端拉取」 |
+| 每次问数都暴露既有全零 bug 引发困惑 | 体检报告给处置指引 + 结论加数据质量角标(§8) |
+| 开放:是否要流式(SSE)体验 | P1 用轮询(与任务中心一致、smoke 友好);SSE 可后置为纯增强,接口形状不变 |
+| 开放:会话是否上云(钉钉)共享 | 本期仅本地落盘;跨机共享待 aitable 场景成熟后再议 |

+ 9 - 1
napos-service.js

@@ -387,6 +387,10 @@ async function callHistory(scopeKey, ctx, port = DEFAULT_CDP_PORT) {
   const r = await kit.statsHistory(o);
   if (r.risk) throw new Error('触发客户端风控(请稍后在客户端完成验证后重试)');
   if (r.authFail) throw new Error('登录态失效(ksid 过期),请在商家客户端重新登录');
+  // HTTP 层失败:pageFetch 拿不到业务数据,不检查会把失败伪装成 0 行落库
+  if (r.ok === false) throw new Error('接口请求失败:' + (r.error || `HTTP ${r.status || '?'}`));
+  // 形态守卫:有原始行但无任何 ds 形态行 = 接口/页面结构变化,拒绝静默落空
+  if (r.shapeWarning) throw new Error(`接口返回形态异常:${(r.rows || []).length} 行均无 ds 字段(接口/页面结构可能已变化),已拒绝把空数据落库`);
   if (r.code != null && r.code !== 0 && String(r.code) !== '0') {
     throw new Error('接口返回异常 code=' + r.code + (r.msg ? ' ' + r.msg : ''));
   }
@@ -486,7 +490,11 @@ function aggregateReport({ entity, from, to, group = 'shop', shops } = {}) {
 
 function csvEscape(v) {
   if (v == null) return '';
-  const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  let s = typeof v === 'object' ? JSON.stringify(v) : String(v);
+  // 防 Excel 公式注入:非数字值以 = + - @ 开头时前置单引号(负数是数字,不受影响)
+  if (/^[=+@\t\r]/.test(s) || (/^-/.test(s) && !Number.isFinite(Number(s)))) {
+    s = "'" + s;
+  }
   return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
 }
 

+ 42 - 6
napos/cdp.js

@@ -282,15 +282,32 @@ class CDP {
     this.pending = new Map();
     this.handlers = new Map();
     this.attached = new Set();
+    this.dead = false;
   }
 
   async open(timeout = 10000) {
     this.ws = new RawWebSocket(this.wsUrl);
     this.ws.on('message', (m) => this._onMessage(m));
+    // 必须接住 error/close:底层 socket 出错时 EventEmitter 无人监听 'error'
+    // 会直接崩掉整个进程;断连后 pending 也要立刻兑现而不是干等超时。
+    this.ws.on('error', (e) => this._onDead(e instanceof Error ? e : new Error(String(e || 'WebSocket 连接错误'))));
+    this.ws.on('close', () => this._onDead(new Error('WebSocket 连接已关闭')));
     await this.ws.connect(timeout);
     return this;
   }
 
+  /** 连接死亡:把所有在途请求立即以该错误兑现,后续 send 也会快速失败 */
+  _onDead(err) {
+    if (this.dead) return;
+    this.dead = true;
+    const waiting = [...this.pending.values()];
+    this.pending.clear();
+    for (const { reject, timer } of waiting) {
+      clearTimeout(timer);
+      reject(err);
+    }
+  }
+
   _onMessage(raw) {
     let msg;
     try { msg = JSON.parse(raw); } catch { return; }
@@ -310,6 +327,9 @@ class CDP {
 
   send(method, params = {}, sessionId) {
     return new Promise((resolve, reject) => {
+      if (this.dead || !this.ws || this.ws.readyState !== 1) {
+        return reject(new Error(`CDP 连接不可用: ${method}`));
+      }
       const id = ++this.seq;
       const payload = { id, method, params };
       if (sessionId) payload.sessionId = sessionId;
@@ -338,7 +358,10 @@ class CDP {
     this.attached.delete(sessionId);
   }
 
-  close() { try { this.ws && this.ws.close(); } catch { /* ignore */ } }
+  close() {
+    this._onDead(new Error('CDP 连接已关闭'));
+    try { this.ws && this.ws.close(); } catch { /* ignore */ }
+  }
 }
 
 /**
@@ -349,7 +372,12 @@ async function openAppTarget(port, app) {
   if (!app || !app.openUrl) return null;
   const p = await probe(port);
   if (!p.ok) return null;
-  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  const cdp = new CDP(p.version.webSocketDebuggerUrl);
+  try {
+    await cdp.open(12000);
+  } catch {
+    return null; // 连接没建起来,cdp.close() 里已无 socket 可关
+  }
   try {
     let res = null;
     for (const opts of [
@@ -471,8 +499,9 @@ async function tryOpenApp(port, app, fallbackTarget) {
     if (id) return true;
   }
   if (!app.openPath || !fallbackTarget) return false;
-  const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(12000);
+  const cdp = new CDP(p.version.webSocketDebuggerUrl);
   try {
+    await cdp.open(12000);
     const sessionId = await cdp.attachTo(fallbackTarget.id);
     const url = `https://melody.shop.ele.me${app.openPath}`;
     await cdp.send('Runtime.evaluate', {
@@ -514,9 +543,16 @@ async function withPageFor(port, urlOrApp, fn, { needExact = false, autoOpen = t
     if (!found || !found.target) {
       throw new Error('没有找到可用的页面 target(客户端可能还没加载完业务页面)');
     }
-    const cdp = await new CDP(p.version.webSocketDebuggerUrl).open(timeout);
-    if (!cdp) throw new Error('CDP 连接失败');
-    const sessionId = await cdp.attachTo(found.target.id);
+    const cdp = new CDP(p.version.webSocketDebuggerUrl);
+    let sessionId;
+    try {
+      await cdp.open(timeout);
+      sessionId = await cdp.attachTo(found.target.id);
+    } catch (e) {
+      // 建连/附加失败必须关掉半开连接,否则 socket 泄漏拖住进程退不出
+      cdp.close();
+      throw new Error(`CDP 连接失败(第 ${attempt} 次尝试):${(e && e.message) || e}`);
+    }
     const ctxMap = await collectContexts(cdp, sessionId, attempt === 1 ? 800 : 1500);
     lastFound = found;
     lastCtx = ctxMap;

+ 53 - 15
napos/kit.js

@@ -124,8 +124,12 @@ async function statsHistory(o = {}) {
     else if (Array.isArray(d.result)) rowsRaw = d.result;
     const daily = rowsRaw.filter((x) => x && x.ds && /^\d{8}$/.test(String(x.ds)));
     const all = rowsRaw.find((x) => x && x.ds === 'all') || null;
+    // 形态守卫:有原始行却一条 ds 形态都对不上 = 接口/页面结构变了。
+    // 不标记的话上层会把这批行当"合法空数据"静默落库(全零类问题同根因)。
+    const shapeWarning = rowsRaw.length > 0 && !daily.length && !all;
     return {
       ok: !!res.ok, status: res.status, risk: !!res.risk, authFail: !!res.authFail,
+      shapeWarning,
       code: d.code != null ? d.code : (d.success === false ? 'success:false' : undefined),
       msg: d.msg || d.message || undefined,
       contextOrigin: res.contextOrigin || null,
@@ -374,10 +378,13 @@ async function melody(o = {}) {
  * 订单履约巡检 —— 一次抓「当前」的异常/待办订单计数(全公司或指定门店)。
  * ⚠️ 这是**实时快照**,不是历史;要看趋势必须定时落库累积。
  *
- * @returns {Promise<{newOrder:number, urge:number, preparing:number, callDelivery:number,
- *   riderAccept:number, unprocessedBooking:number, bookingOrderIds:string[],
- *   exceptional:number, cancel:number, refund:number, fastRefund:number,
- *   noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object}>}
+ * @returns {Promise<{ok:boolean, newOrder:?number, urge:?number, preparing:?number, callDelivery:?number,
+ *   riderAccept:?number, unprocessedBooking:?number, bookingOrderIds:string[],
+ *   exceptional:?number, cancel:?number, refund:?number, fastRefund:?number,
+ *   noProcesNegotiate:*, timeOutNegotiate:*, needRemindException:boolean, raw:object,
+ *   risk:boolean, authFail:boolean}>}
+ *   任一子调用失败(HTTP/风控/鉴权/业务错误)时 ok=false 且各计数为 **null** ——
+ *   绝不返回 0:那会把"取数失败"伪装成"健康零负载"快照。
  */
 async function orderAlerts(o = {}) {
   const shopId = o.shopId == null ? -1 : o.shopId;
@@ -387,12 +394,16 @@ async function orderAlerts(o = {}) {
     melody({ ...P, service: 'PollingService', method: 'nonCoreOrders', path: '/fulfill/weborder/nonCoreOrders/', params: { shopId } }),
     melody({ ...P, service: 'PollingService', method: 'unprocessedOrders', path: '/fulfill/weborder/unprocessedOrders/', params: { shopId } }),
   ]);
-  const A = (ab.result && typeof ab.result === 'object') ? ab.result : {};
-  const N = (non.result && typeof non.result === 'object') ? non.result : {};
-  const U = (un.result && typeof un.result === 'object') ? un.result : {};
-  const n = (v) => Number(v) || 0;
-  const bookingIds = (N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
+  const subFail = (r) => !r || r.ok === false || !!r.risk || !!r.authFail || !!r.error;
+  const anyFail = subFail(ab) || subFail(non) || subFail(un);
+  const A = (ab && ab.result && typeof ab.result === 'object') ? ab.result : {};
+  const N = (non && non.result && typeof non.result === 'object') ? non.result : {};
+  const U = (un && un.result && typeof un.result === 'object') ? un.result : {};
+  // 失败时计数不可信 → 一律 null,让调用方显式处理"不可知",而不是当成 0
+  const n = (v) => (anyFail ? null : (Number(v) || 0));
+  const bookingIds = (!anyFail && N.bookingNotice && Array.isArray(N.bookingNotice.orderIds)) ? N.bookingNotice.orderIds : [];
   return {
+    ok: !anyFail,
     shopId,
     newOrder: n(U.newOrderCount),
     urge: n(N.urgeOrderCount),
@@ -405,11 +416,12 @@ async function orderAlerts(o = {}) {
     cancel: n(A.cancelOrderCount),
     refund: n(A.refundOrderCount),
     fastRefund: n(A.fastRefundOrderCount),
-    noProcesNegotiate: A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder),
-    timeOutNegotiate: A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder),
-    needRemindException: !!A.needRemindExceptionOrder,
+    noProcesNegotiate: anyFail ? null : (A.noProcesNegotiateOrder == null ? null : n(A.noProcesNegotiateOrder)),
+    timeOutNegotiate: anyFail ? null : (A.timeOutNegotiateOrder == null ? null : n(A.timeOutNegotiateOrder)),
+    needRemindException: !anyFail && !!A.needRemindExceptionOrder,
     raw: { abnormal: A, nonCore: N, unprocessed: U },
-    risk: ab.risk || non.risk || un.risk,
+    risk: !!(ab && ab.risk) || !!(non && non.risk) || !!(un && un.risk),
+    authFail: !!(ab && ab.authFail) || !!(non && non.authFail) || !!(un && un.authFail),
   };
 }
 
@@ -578,10 +590,14 @@ async function settleList(o = {}) {
   const maxPage = Number(o.maxPage || 60);
   const rows = [];
   let pageNo = 1, totalPage = 1, meta = null;
+  let last = null;      // 末次翻页响应:决定整体 ok(失败不得伪装成"成功 0 行")
+  let pages = 0;
   while (pageNo <= Math.min(totalPage, maxPage)) {
     const r = await agw('bill', '/gw/h5api/ele-shop-bill-query.ShopSummaryQueryFacade.queryChainSettleListByPage',
       [{ shopId: unit, pageNo, pageSize: size, startBillDate: msOf(from, 'start'), endBillDate: msOf(to, 'end'), chainId: unit, settleBizType: biz }],
       { port });
+    last = r;
+    pages++;
     const d = r.payload || {};
     if (Array.isArray(d.list)) rows.push(...d.list);
     totalPage = Number(d.totalPage || 1) || 1;
@@ -589,7 +605,17 @@ async function settleList(o = {}) {
     if (!Array.isArray(d.list) || d.list.length < size) break;
     pageNo++;
   }
-  return { ok: true, unitId: unit, biz, from, to, rows, meta, ensured: ens };
+  // 口径统一:末页 HTTP 失败 / 风控 / 鉴权失效 / 业务层没回 list(空区间成功时 list 也是数组,只是为空)
+  const ld = (last && last.payload) || null;
+  const failed = !last || !last.ok || !!last.risk || !!last.authFail || !ld || !Array.isArray(ld.list);
+  return {
+    ok: !failed,
+    risk: !!(last && last.risk), authFail: !!(last && last.authFail),
+    error: failed
+      ? `结算账单翻页异常(第 ${pages} 页):${!last ? '无响应' : last.authFail ? '鉴权失效' : last.risk ? '触发风控' : !last.ok ? `HTTP ${last.status || '?'}` : '响应缺少 list 字段(接口形态可能已变化)'}`
+      : null,
+    unitId: unit, biz, from, to, rows, meta, ensured: ens,
+  };
 }
 
 /** 结算账单区间汇总(按日聚合 → 收入/支出/结算额/天数/状态分布) */
@@ -640,16 +666,28 @@ async function growthTaskRate(o = {}) {
   const size = Number(o.size || 50), maxPage = Number(o.maxPage || 20);
   const rows = [];
   let page = 1, total = 1;
+  let last = null;
   while (page <= Math.min(total, maxPage)) {
     const r = await agw('growth', '/gw/h5api/ele-shop-growth-product.ChainBusinessTaskPageWebService.getBusinessTaskCompleteRateByPage',
       [{ chainId, page, size, data: { detailType: o.detailType || 'DETAIL_TYPE_SHOP', brandId: o.brandId == null ? -1 : o.brandId, useNew: true } }], { port });
+    last = r;
     const d = r.payload || {};
     if (Array.isArray(d.data)) rows.push(...d.data);
     total = Number(d.total || 0) ? Math.ceil(Number(d.total) / size) : 1;
     if (!Array.isArray(d.data) || d.data.length < size) break;
     page++;
   }
-  return { ok: true, rows };
+  // 口径统一:末页失败/形态异常不再报 ok:true(否则翻页失败会被当"完成率全空"误读)
+  const ld = (last && last.payload) || null;
+  const failed = !last || !last.ok || !!last.risk || !!last.authFail || !ld || !Array.isArray(ld.data);
+  return {
+    ok: !failed,
+    risk: !!(last && last.risk), authFail: !!(last && last.authFail),
+    error: failed
+      ? `生意任务完成率翻页异常:${!last ? '无响应' : last.authFail ? '鉴权失效' : last.risk ? '触发风控' : !last.ok ? `HTTP ${last.status || '?'}` : '响应缺少 data 字段(接口形态可能已变化)'}`
+      : null,
+    rows,
+  };
 }
 
 /** 成长中心品牌列表(brandId 用于周报 / 任务过滤) */

+ 17 - 3
napos/ws.js

@@ -82,12 +82,26 @@ class RawWebSocket extends EventEmitter {
         this.readyState = 1;
         socket.setNoDelay(true);
         socket.on('data', (d) => this._feed(d));
-        socket.on('close', () => { this.readyState = 3; this.emit('close'); });
-        socket.on('error', (e) => this.emit('error', e));
+        socket.on('close', () => {
+          const wasOpen = this.readyState === 1;
+          this.readyState = 3;
+          this.emit('close');
+          // 从未 emit 过 error 的连接断掉时,给上层一个明确的失败信号而不是静默
+          if (wasOpen) this.emit('error', new Error('WebSocket 连接已断开'));
+        });
+        socket.on('error', (e) => {
+          this.readyState = 3;
+          this.emit('error', e);
+        });
         if (head && head.length) this._feed(head);
         resolve(res.headers);
       });
-      req.on('response', (res) => fail(new Error(`握手被拒: HTTP ${res.statusCode}`)));
+      req.on('response', (res) => {
+        // 握手被拒:必须销毁请求释放底层 socket,否则每次探测失败都泄漏一条连接
+        res.resume();
+        req.destroy();
+        fail(new Error(`握手被拒: HTTP ${res.statusCode}`));
+      });
       req.on('error', fail);
       req.on('timeout', () => req.destroy(new Error('WebSocket 握手超时')));
       req.end();

+ 3 - 55
nl-query.js

@@ -15,8 +15,8 @@
  * 需要 OpenAI 兼容的对话模型(config.chatModel / chatApiKey / chatApiBase)。
  */
 
-const https = require('https');
-const http = require('http');
+// 对话通道与 JSON 提取已抽到 server/agent/llm.js(智能体主循环共用同一实现)
+const { chatCompletion, extractJson } = require('./server/agent/llm.js');
 
 const COLUMN_KEYS = new Set(['date', 'shopName', 'bizHours', 'abnormalCloseHours', 'validOrderCnt',
   'incomeAmt', 'grossAmt', 'goodsSaleAmt', 'expUv', 'orderUserCnt',
@@ -28,58 +28,6 @@ function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.g
 function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
 function normDay(v) { return /^\d{8}$/.test(String(v || '')) ? String(v) : ''; }
 
-// ---------- OpenAI 兼容对话调用 ----------
-
-function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000 }) {
-  return new Promise((resolve, reject) => {
-    let base;
-    try { base = new URL(apiBase); } catch (e) { return reject(new Error('对话模型地址不合法: ' + apiBase)); }
-    const body = JSON.stringify({ model, messages, temperature: 0, stream: false });
-    const options = {
-      hostname: base.hostname,
-      port: base.port || 443,
-      path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
-      method: 'POST',
-      headers: {
-        Authorization: `Bearer ${apiKey}`,
-        'Content-Type': 'application/json; charset=utf-8',
-        'Content-Length': Buffer.byteLength(body),
-      },
-      timeout: timeoutMs,
-    };
-    const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
-      const chunks = [];
-      res.on('data', (c) => chunks.push(c));
-      res.on('end', () => {
-        const raw = Buffer.concat(chunks).toString('utf8');
-        let json = null;
-        try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
-        if (res.statusCode !== 200) {
-          const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
-          return reject(new Error(`对话模型请求失败: ${msg}`));
-        }
-        const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
-        if (!content) return reject(new Error('对话模型返回为空'));
-        resolve(content);
-      });
-    });
-    req.on('error', reject);
-    req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
-    req.write(body);
-    req.end();
-  });
-}
-
-/** 从模型输出中稳健地提取第一个 JSON 对象 */
-function extractJson(text) {
-  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
-  const candidate = fenced ? fenced[1] : text;
-  const start = candidate.indexOf('{');
-  const end = candidate.lastIndexOf('}');
-  if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
-  return JSON.parse(candidate.slice(start, end + 1));
-}
-
 // ---------- Prompt 构造 ----------
 
 function semanticModelText() {
@@ -331,4 +279,4 @@ async function ping(cfg) {
   return String(reply).trim().slice(0, 40);
 }
 
-module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange, buildResultDigest, fallbackReply };
+module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, executeQuery, lakeDateRange, buildResultDigest, fallbackReply };

+ 12 - 5
public/analysis-templates.js

@@ -28,6 +28,13 @@
 
     const WEEKDAY_CN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
 
+    // 账号名/门店名来自外部平台数据,进 innerHTML(表格与 tooltip 默认按 HTML 渲染)前必须转义
+    function esc(value) {
+      const div = document.createElement('div');
+      div.textContent = String(value ?? '');
+      return div.innerHTML;
+    }
+
     // ---------- 模板一:综合总览(原有四图,保持默认行为) ----------
 
     function renderOverviewTpl(grid, data) {
@@ -428,7 +435,7 @@
             trigger: 'axis',
             formatter: (ps) => {
               const idx = ps[0].dataIndex;
-              return `<b>${sorted[idx].name}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
+              return `<b>${esc(sorted[idx].name)}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
             },
           },
           grid: { left: 10, right: 40, top: 30, bottom: 64, containLabel: true },
@@ -467,7 +474,7 @@
           tooltip: {
             formatter: (p) => {
               const s = p.data.meta;
-              return `<b>${s.name}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
+              return `<b>${esc(s.name)}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
             },
           },
           grid: { left: 64, right: 30, top: 30, bottom: 46 },
@@ -881,7 +888,7 @@
       wrap.innerHTML = `<table class="ana-table">
         <thead><tr><th>账号</th><th>本期</th><th>上期</th><th>环比</th><th>日均</th><th>有效订单</th><th>客单价</th><th>档口数</th></tr></thead>
         <tbody>${stats.map((e) => `<tr>
-          <td>${e.name}</td>
+          <td>${esc(e.name)}</td>
           <td>${fmtByCol(col, e.cur)}</td>
           <td>${fmtByCol(col, e.prev)}</td>
           <td>${deltaBadge(e.cur, e.prev)}</td>
@@ -1042,8 +1049,8 @@
           <tr><td>本店(${isPct ? '加权均值' : '合计'})</td><td>${fmtByCol(col, ownTotal)}</td></tr>
           <tr><td>商圈同行均值</td><td>${hasAvg ? fmtByCol(col, avgTotal) : '—'}</td></tr>
           <tr><td>超越同行</td><td>${hasAvg && avgTotal > 0 ? `<span class="delta ${ownTotal >= avgTotal ? 'up' : 'down'}">${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%</span>` : '—'}</td></tr>
-          <tr><td>最强档口</td><td>${ranked.length ? `${ranked[0][0]}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
-          <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${ranked[ranked.length - 1][0]}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
+          <tr><td>最强档口</td><td>${ranked.length ? `${esc(ranked[0][0])}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
+          <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${esc(ranked[ranked.length - 1][0])}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
         </tbody></table>`;
       tbl.cell.appendChild(wrap);
     }

+ 64 - 50
public/app.js

@@ -1,21 +1,27 @@
 'use strict';
 
-// 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入一次口令,
+// 局域网访问口令:服务端开启 accessToken 时,API 返回 401 会引导输入口令,
 // 写入 cookie 后所有请求(fetch / 图片 / CSV 链接)自动携带,无需逐处改代码。
+// 并发 401 共享同一次弹窗;用户取消只结束本轮,后续 401 仍可再次询问。
 (function () {
-  let tokenAsked = false;
   const origFetch = window.fetch.bind(window);
+  let tokenPromise = null;
   window.fetch = async function (input, init) {
     const res = await origFetch(input, init);
     const url = typeof input === 'string' ? input : (input && input.url) || String(input || '');
-    if (res.status === 401 && /\/(api|files)\b/.test(url) && !tokenAsked) {
-      tokenAsked = true;
-      const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
-      if (token) {
-        document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
-        tokenAsked = false;
-        return origFetch(input, init);
+    if (res.status === 401 && /\/(api|files)\b/.test(url)) {
+      if (!tokenPromise) {
+        tokenPromise = (async () => {
+          const token = window.prompt('此服务已开启访问口令(accessToken),请输入:');
+          if (token) {
+            document.cookie = 'accessToken=' + encodeURIComponent(token) + '; path=/; max-age=31536000; SameSite=Lax';
+          }
+          tokenPromise = null; // 本轮结束;取消后下次 401 仍会询问
+          return token || '';
+        })();
       }
+      const token = await tokenPromise;
+      if (token) return origFetch(input, init);
     }
     return res;
   };
@@ -30,10 +36,12 @@
     reports: document.getElementById('viewReports'),
     lake: document.getElementById('viewLake'),
     analysis: document.getElementById('viewAnalysis'),
+    ask: document.getElementById('viewAsk'),
     settings: document.getElementById('viewSettings'),
     logs: document.getElementById('viewLogs'),
   };
   const navItems = document.querySelectorAll('.nav-item[data-view]');
+  let settingsBound = false;
   let settingsLoaded = false;
   let logTimer = null;
   let lastLogSignature = '';
@@ -271,45 +279,50 @@
   window.shanAitableAction = aitableAction;
 
   async function initSettings() {
-    if (settingsLoaded) return;
-    bindSettingsTabs();
-    if (new URLSearchParams(location.search).get('tab') === 'chat') {
-      document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
-    }
-    document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings);
-    document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings);
-    document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings);
-    document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings);
-    document.getElementById('btnTestImage').addEventListener('click', testImageApi);
-    document.getElementById('btnTestChat').addEventListener('click', testChatModel);
-    document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
-    const bindHelpPopover = (btnId, popId) => {
-      const btn = document.getElementById(btnId);
-      const pop = document.getElementById(popId);
-      btn.addEventListener('click', (event) => {
-        event.stopPropagation();
-        pop.hidden = !pop.hidden;
-        btn.classList.toggle('active', !pop.hidden);
-        btn.setAttribute('aria-expanded', String(!pop.hidden));
-      });
-      document.addEventListener('click', (event) => {
-        if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
-          pop.hidden = true;
-          btn.classList.remove('active');
-          btn.setAttribute('aria-expanded', 'false');
-        }
+    // 事件绑定与数据加载分离:加载失败重进设置页时不会重复 addEventListener
+    if (!settingsBound) {
+      settingsBound = true;
+      bindSettingsTabs();
+      if (new URLSearchParams(location.search).get('tab') === 'chat') {
+        document.querySelector('#settingsApiTabs button[data-tab="chat"]')?.click();
+      }
+      document.getElementById('btnSaveApi').addEventListener('click', saveApiSettings);
+      document.getElementById('btnSaveCrawl').addEventListener('click', saveCrawlSettings);
+      document.getElementById('btnSaveSecurity').addEventListener('click', saveSecuritySettings);
+      document.getElementById('btnSaveAitable').addEventListener('click', saveAitableSettings);
+      document.getElementById('btnTestImage').addEventListener('click', testImageApi);
+      document.getElementById('btnTestChat').addEventListener('click', testChatModel);
+      document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
+      const bindHelpPopover = (btnId, popId) => {
+        const btn = document.getElementById(btnId);
+        const pop = document.getElementById(popId);
+        btn.addEventListener('click', (event) => {
+          event.stopPropagation();
+          pop.hidden = !pop.hidden;
+          btn.classList.toggle('active', !pop.hidden);
+          btn.setAttribute('aria-expanded', String(!pop.hidden));
+        });
+        document.addEventListener('click', (event) => {
+          if (!pop.hidden && !event.target.closest('.conn-help-wrap')) {
+            pop.hidden = true;
+            btn.classList.remove('active');
+            btn.setAttribute('aria-expanded', 'false');
+          }
+        });
+      };
+      bindHelpPopover('btnSecurityHelp', 'securityHelpPopover');
+      bindHelpPopover('btnAitableHelp', 'aitableHelpPopover');
+      document.getElementById('btnClearLog').addEventListener('click', () => {
+        clearLogs().catch(() => {});
       });
-    };
-    bindHelpPopover('btnSecurityHelp', 'securityHelpPopover');
-    bindHelpPopover('btnAitableHelp', 'aitableHelpPopover');
-    document.getElementById('btnClearLog').addEventListener('click', () => {
-      clearLogs().catch(() => {});
-    });
-    try {
-      await loadSettings();
-      settingsLoaded = true;
-    } catch (error) {
-      setPanelStatus('apiSaveStatus', error.message, false);
+    }
+    if (!settingsLoaded) {
+      try {
+        await loadSettings();
+        settingsLoaded = true;
+      } catch (error) {
+        setPanelStatus('apiSaveStatus', error.message, false);
+      }
     }
   }
 
@@ -341,7 +354,7 @@
     const response = await fetch('/api/logs');
     if (!response.ok) return;
     const data = await response.json();
-    const logs = (data.logs || []).sort((a, b) => b.seq - a.seq);
+    const logs = (data.logs || []).sort((a, b) => (b.seq || 0) - (a.seq || 0));
     const signature = logs.map((log) => `${log.seq}:${log.level}:${log.message}`).join('|');
     if (signature === lastLogSignature) return;
     lastLogSignature = signature;
@@ -370,18 +383,19 @@
     }
     navItems.forEach((n) => n.classList.toggle('active', n.dataset.view === name));
     if (name === 'crawl' && window.shanCrawlInit) window.shanCrawlInit();
+    if (window.shanCrawlSetActive) window.shanCrawlSetActive(name === 'crawl'); // 离开采集页暂停状态轮询
     if (name === 'gen' && window.shanGenInit) window.shanGenInit();
     if (name === 'tasks' && window.shanTasksInit) window.shanTasksInit();
+    if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表
     if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
+    if (name === 'ask' && window.shanAskInit) window.shanAskInit();
     if (name === 'settings') initSettings();
     // 系统日志:仅日志页激活时轮询
     if (name === 'logs') startLogPolling();
     else stopLogPolling();
-    // 智能问数机器人:数据采集(问数入口语义在报表浏览,浏览已挪中台但采集页仍是分析组入口)、数据中台、趋势图表显示
-    if (window.shanSetAnalysisVisible) window.shanSetAnalysisVisible(name === 'reports' || name === 'lake' || name === 'analysis');
   }
 
   navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));

+ 550 - 0
public/ask.js

@@ -0,0 +1,550 @@
+'use strict';
+
+/**
+ * 智能问数 · 独立页面(副驾式布局:左会话栏 + 右舞台 + 底部大输入卡)
+ * ----------------------------------------------------------------------------
+ * 发起运行(POST /api/agent/runs)→ 轮询进度 → 渲染思考 / 工具步骤 / 结论 / 图表。
+ * 运行异步执行在服务端,本页只做编排与展示;切换页面运行继续,回来自动续显。
+ * 「应用到报表」经 window.shanReportsApplyQuery 落到数据中台明细 tab(reports.js 提供)。
+ */
+
+(function () {
+  const ACCENT = '#ff6b35';
+  const BLUE = '#2563eb';
+  const STATUS_WORD = { running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
+
+  const state = {
+    inited: false,
+    entities: [],
+    entity: null,
+    sessions: [],
+    sessionId: null,
+    search: '',
+    mode: 'auto',
+    skill: '',
+    skills: [],
+    busy: false,
+    pollTimer: null,
+    activeRunId: null,
+    charts: [],
+    columns: [],
+  };
+
+  const $ = (id) => document.getElementById(id);
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value ?? '');
+    return div.innerHTML;
+  }
+
+  async function jsonFetch(url, opts) {
+    const r = await fetch(url, opts);
+    const data = await r.json().catch(() => ({}));
+    if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
+    return data;
+  }
+
+  function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
+
+  function fmtDuration(ms) { return ms == null || ms === '' ? '' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : `${ms}ms`); }
+  function fmtBytes(n) { return n == null ? '' : (n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : `${n} B`); }
+  function fmtTokens(n) { return n == null ? '—' : (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
+  function fmtClock(iso) {
+    const d = iso ? new Date(iso) : null;
+    return d ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` : '';
+  }
+
+  function entityName(key) {
+    const e = state.entities.find((x) => x.key === key);
+    return e ? (e.name || e.key) : (key || '');
+  }
+
+  // ---------- 初始化 ----------
+
+  window.shanAskInit = async function () {
+    if (!state.inited) {
+      state.inited = true;
+      bindEvents();
+    }
+    if (!state.columns.length) {
+      try {
+        const data = await jsonFetch('/api/reports/columns');
+        state.columns = data.columns || [];
+      } catch { state.columns = []; }
+    }
+    await loadSkills();
+    await loadEntities();
+    await loadSessions();
+    await openLatestSession();
+  };
+
+  async function loadSkills() {
+    const sel = $('askSkill');
+    try {
+      const data = await jsonFetch('/api/agent/skills');
+      state.skills = data.skills || [];
+    } catch { state.skills = []; }
+    sel.innerHTML = '<option value="">技能:自动</option>';
+    for (const s of state.skills) {
+      const opt = document.createElement('option');
+      opt.value = s.key;
+      opt.textContent = `技能:${s.name}`;
+      opt.title = s.desc || '';
+      sel.appendChild(opt);
+    }
+    sel.value = state.skill || '';
+  }
+
+  async function loadEntities() {
+    try {
+      const data = await jsonFetch('/api/reports/entities');
+      state.entities = data.entities || [];
+    } catch { state.entities = []; }
+    const sel = $('askEntity');
+    sel.innerHTML = '';
+    if (!state.entities.length) {
+      const opt = document.createElement('option');
+      opt.value = '';
+      opt.textContent = '暂无主体,请先同步数据';
+      sel.appendChild(opt);
+      sel.disabled = true;
+      state.entity = null;
+      renderStageHead();
+      return;
+    }
+    sel.disabled = false;
+    for (const e of state.entities) {
+      const opt = document.createElement('option');
+      opt.value = e.key;
+      opt.textContent = e.name || e.key;
+      sel.appendChild(opt);
+    }
+    if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
+      state.entity = state.entities[0].key;
+    }
+    sel.value = state.entity;
+    renderStageHead();
+  }
+
+  // ---------- 左侧会话栏 ----------
+
+  async function loadSessions() {
+    if (!state.entity) { state.sessions = []; renderSessions(); return; }
+    try {
+      const data = await jsonFetch(`/api/agent/sessions?entity=${encodeURIComponent(state.entity)}`);
+      state.sessions = data.sessions || [];
+    } catch { state.sessions = []; }
+    renderSessions();
+  }
+
+  function renderSessions() {
+    const wrap = $('askSessions');
+    if (!state.entity) { wrap.innerHTML = '<div class="ask-sessions-empty">暂无主体</div>'; return; }
+    const kw = state.search.trim().toLowerCase();
+    const list = state.sessions.filter((s) => !kw || String(s.title || '').toLowerCase().includes(kw));
+    if (!list.length) {
+      wrap.innerHTML = `<div class="ask-sessions-empty">${kw ? '无匹配会话' : '暂无会话'}</div>`;
+      return;
+    }
+    wrap.innerHTML = '';
+    for (const s of list) {
+      const item = document.createElement('div');
+      item.className = 'ask-session-item' + (s.sessionId === state.sessionId ? ' active' : '');
+      item.innerHTML = `
+        <span class="ask-session-title"></span>
+        <span class="ask-session-preview"></span>
+        <button type="button" class="ask-session-del" title="删除会话">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/></svg>
+        </button>`;
+      item.querySelector('.ask-session-title').textContent = s.title || '新会话';
+      const preview = s.lastQuestion && s.lastQuestion !== s.title ? s.lastQuestion : (s.lastQuestion || '');
+      item.querySelector('.ask-session-preview').textContent = preview;
+      item.addEventListener('click', () => openSession(s.sessionId));
+      item.querySelector('.ask-session-del').addEventListener('click', (event) => {
+        event.stopPropagation();
+        removeSession(s.sessionId);
+      });
+      wrap.appendChild(item);
+    }
+  }
+
+  async function removeSession(sessionId) {
+    try {
+      await jsonFetch(`/api/agent/sessions/${encodeURIComponent(sessionId)}?entity=${encodeURIComponent(state.entity)}`, { method: 'DELETE' });
+    } catch { /* 删除失败不打扰 */ }
+    if (sessionId === state.sessionId) await newSession();
+    else await loadSessions();
+  }
+
+  // ---------- 会话与舞台 ----------
+
+  function renderStageHead() {
+    const session = state.sessions.find((s) => s.sessionId === state.sessionId);
+    $('askStageTitle').textContent = state.sessionId && session ? (session.title || '新对话') : '新对话';
+    $('askStageBadge').textContent = state.entity ? entityName(state.entity) : '未选择主体';
+  }
+
+  async function openLatestSession() {
+    await loadSessions();
+    if (state.sessions.length) await openSession(state.sessions[0].sessionId);
+    else await openSession(null);
+  }
+
+  async function openSession(sessionId) {
+    stopPoll();
+    state.sessionId = sessionId;
+    state.activeRunId = null;
+    if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
+    renderStageHead();
+    renderSessions();
+    const stream = $('askStream');
+    [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
+    if (!sessionId || !state.entity) { showHero(true); return; }
+    showHero(false);
+    try {
+      const data = await jsonFetch(`/api/agent/runs?entity=${encodeURIComponent(state.entity)}&sessionId=${encodeURIComponent(sessionId)}`);
+      const runs = (data.runs || []).slice(0, 10);
+      let runningRun = null;
+      for (const r of runs.reverse()) {
+        try {
+          const detail = await jsonFetch(`/api/agent/runs/${encodeURIComponent(r.runId)}?entity=${encodeURIComponent(state.entity)}`);
+          appendRun(detail.run);
+          if (detail.run.status === 'running') runningRun = detail.run;
+        } catch { /* 单条历史加载失败跳过 */ }
+      }
+      // 回到页面时若有运行仍在进行(切页不中断),续接轮询
+      if (runningRun) {
+        state.activeRunId = runningRun.runId;
+        state.busy = true;
+        setComposerBusy(true);
+        startPoll(runningRun.runId);
+      }
+    } catch { /* 历史加载失败不阻塞新提问 */ }
+  }
+
+  async function newSession() {
+    stopPoll();
+    state.sessionId = null;
+    state.activeRunId = null;
+    if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
+    const stream = $('askStream');
+    [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
+    renderStageHead();
+    renderSessions();
+    showHero(true);
+    $('askInput').focus();
+  }
+
+  function showHero(show) {
+    const hero = $('askEmpty');
+    if (hero) hero.hidden = !show;
+  }
+
+  // ---------- 提问 ----------
+
+  async function ask(text) {
+    const q = String(text || $('askInput').value || '').trim();
+    if (!q || state.busy || !state.entity) return;
+    state.busy = true;
+    $('askInput').value = '';
+    autoresize();
+    setComposerBusy(true);
+    showHero(false);
+    try {
+      const data = await jsonFetch('/api/agent/runs', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          entity: state.entity, question: q, mode: state.mode,
+          skill: state.skill || undefined, sessionId: state.sessionId || undefined,
+        }),
+      });
+      state.sessionId = data.run.sessionId;
+      state.activeRunId = data.run.runId;
+      renderStageHead();
+      appendRun(data.run);
+      loadSessions(); // 侧栏出现新会话/新预览
+      startPoll(data.run.runId);
+    } catch (e) {
+      state.busy = false;
+      setComposerBusy(false);
+      appendLocalError(q, e.message);
+    }
+  }
+
+  async function cancel() {
+    if (!state.activeRunId) return;
+    try {
+      await jsonFetch(`/api/agent/runs/${encodeURIComponent(state.activeRunId)}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+    } catch { /* 取消失败不打扰 */ }
+  }
+
+  function startPoll(runId) {
+    stopPoll();
+    state.pollTimer = setInterval(async () => {
+      if (!$('viewAsk').classList.contains('active')) return; // 不在问数页时暂停拉取
+      try {
+        const data = await jsonFetch(`/api/agent/runs/${encodeURIComponent(runId)}?entity=${encodeURIComponent(state.entity)}`);
+        const runEl = document.getElementById(`ask-run-${runId}`);
+        if (runEl) renderRunInto(runEl, data.run);
+        if (data.run.status !== 'running') {
+          stopPoll();
+          state.busy = false;
+          setComposerBusy(false);
+          loadSessions();
+        }
+      } catch { /* 单次轮询失败下轮再试 */ }
+    }, 1000);
+  }
+
+  function stopPoll() {
+    if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
+  }
+
+  function setComposerBusy(busy) {
+    $('btnAskSend').hidden = busy;
+    $('btnAskStop').hidden = !busy;
+  }
+
+  function autoresize() {
+    const el = $('askInput');
+    el.style.height = 'auto';
+    el.style.height = Math.min(el.scrollHeight, 120) + 'px';
+  }
+
+  // ---------- 渲染 ----------
+
+  function appendLocalError(q, message) {
+    const stream = $('askStream');
+    const wrap = document.createElement('div');
+    wrap.className = 'ask-run';
+    wrap.innerHTML = `
+      <div class="ask-q">${esc(q)}</div>
+      <div class="ask-card ask-card-error">${esc(message)}</div>`;
+    stream.appendChild(wrap);
+    stream.scrollTop = stream.scrollHeight;
+  }
+
+  function appendRun(rec) {
+    const stream = $('askStream');
+    const wrap = document.createElement('div');
+    wrap.className = 'ask-run';
+    wrap.id = `ask-run-${rec.runId}`;
+    stream.appendChild(wrap);
+    renderRunInto(wrap, rec);
+    stream.scrollTop = stream.scrollHeight;
+  }
+
+  /** 把一次运行渲染进容器(轮询时整卡重绘,数据量小无压力)。 */
+  function renderRunInto(wrap, rec) {
+    const TOOL_LABEL = {
+      lake_overview: '湖概览', query_report: '查询报表', compare_periods: '同环比对比',
+      data_quality: '数据体检', cross_check: '层间核对', raw_inspect: '源侧字段', peer_benchmark: '同行对标',
+      cross_entity: '跨主体对比', aitable_verify: '湖云比对',
+    };
+    const VERDICT_WORD = { ok: '通过', warn: '警告', fail: '不一致' };
+    const skillName = rec.skill ? ((state.skills.find((s) => s.key === rec.skill) || {}).name || rec.skill) : '';
+
+    // 头部:时间 + 「状态 · 总耗时 · N 个工具」
+    const totalMs = rec.finishedAt && rec.startedAt ? new Date(rec.finishedAt) - new Date(rec.startedAt) : null;
+    const toolCount = (rec.steps || []).filter((s) => s.type === 'tool').length;
+    const dotCls = rec.status === 'running' ? 'running' : (rec.status === 'failed' ? 'failed' : 'done');
+    const head = [];
+    head.push(`<div class="ask-meta">问数 · ${esc(fmtClock(rec.startedAt))}</div>`);
+    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} 个工具`
+      + `${rec.autoPicked ? ` · 自动→${rec.mode === 'quick' ? '快问' : '深度分析'}` : ''}`
+      + `${skillName ? ` · 技能「${esc(skillName)}」` : ''}</span></div>`);
+
+    // 步骤时间线:思考 · Turn N(耗时/tokens)、工具(耗时/载荷/行数)
+    const steps = [];
+    for (const step of rec.steps || []) {
+      if (step.type === 'thought') {
+        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-meta">${bits.filter(Boolean).join(' · ')}</div>
+          ${step.text ? `<div class="ask-step-body">${esc(step.text)}</div>` : ''}
+        </div>`);
+      } else if (step.type === 'tool') {
+        const label = TOOL_LABEL[step.tool] || step.tool;
+        const bits = [fmtDuration(step.ms), fmtBytes(step.bytes)];
+        if (step.rows != null) bits.push(`${step.rows} 行`);
+        steps.push(`<div class="ask-step">
+          <div class="ask-step-head"><span class="ask-dot ${step.ok ? 'done' : 'failed'}"></span><b>${esc(label)}</b></div>
+          <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
+          <details class="ask-tool"><summary>参数与结果</summary>
+            <pre class="ask-pre">参数:${esc(JSON.stringify(step.args || {}))}
+
+${esc(step.summary || step.error || '')}</pre>
+          </details>
+        </div>`);
+      }
+    }
+
+    const parts = [];
+    parts.push(`<div class="ask-q">${esc(rec.question)}</div>`);
+    parts.push('<div class="ask-card">');
+    parts.push(head.join(''));
+    if (steps.length) parts.push(`<div class="ask-steps">${steps.join('')}</div>`);
+    // verdict 卡:数据体检/层间核对/恒等式/完整性等校验结论
+    const verdicts = rec.verdicts || [];
+    if (verdicts.length) {
+      parts.push(`<div class="ask-verdicts">${verdicts.map((v) => `
+        <div class="ask-verdict ${v.status}">
+          <span class="ask-verdict-status">${VERDICT_WORD[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>`);
+    }
+    // 数字溯源警示(不阻断,仅提示)
+    const prov = rec.provenance;
+    if (prov && prov.untracedCount > 0) {
+      parts.push(`<div class="ask-provenance">⚠ ${prov.untracedCount}/${prov.total} 个数字未能在工具结果中溯源(可能为换算或表述),请谨慎采信:${esc(prov.untraced.join('、'))}</div>`);
+    }
+    if (rec.status === 'running') parts.push('<div class="ask-typing"><span></span><span></span><span></span></div>');
+    if (rec.status === 'failed') parts.push(`<div class="ask-error">${esc(rec.error || '运行失败')}</div>`);
+    if (rec.status === 'cancelled') parts.push('<div class="ask-note">已取消</div>');
+
+    const result = rec.result || null;
+    if (result && result.answer) {
+      parts.push(`<div class="ask-answer">${esc(result.answer)}</div>`);
+      const chart = result.chart;
+      if (chart && chart.type && chart.type !== 'none') {
+        parts.push(`<div class="ask-chart" data-run="${esc(rec.runId)}"></div>`);
+        if (chart.title) parts.push(`<div class="ask-chart-title">${esc(chart.title)}</div>`);
+      }
+      if (result.appliedFrom) {
+        parts.push('<div class="ask-actions"><button type="button" class="btn btn-ghost btn-small" data-apply="' + esc(rec.runId) + '">应用到报表</button></div>');
+      }
+      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>`);
+      }
+    }
+    parts.push('</div>');
+
+    wrap.innerHTML = parts.join('');
+
+    // 图表:从产物中找与 chart 维度匹配的行(最后一条优先)
+    if (result && result.chart && result.chart.type && result.chart.type !== 'none') {
+      const wrapEl = wrap.querySelector('.ask-chart');
+      const rows = findChartRows(rec, result.chart);
+      if (wrapEl && rows && rows.length) renderChart(wrapEl, result.chart, rows);
+      else if (wrapEl) wrapEl.parentNode.removeChild(wrapEl);
+    }
+    const applyBtn = wrap.querySelector('[data-apply]');
+    if (applyBtn) {
+      applyBtn.addEventListener('click', () => {
+        const applied = rec.result && rec.result.appliedFrom;
+        if (applied && window.shanReportsApplyQuery) window.shanReportsApplyQuery(applied);
+      });
+    }
+    const stream = $('askStream');
+    stream.scrollTop = stream.scrollHeight;
+  }
+
+  function findChartRows(rec, chart) {
+    if (rec.result && Array.isArray(rec.result.chartRows) && rec.result.chartRows.length) {
+      return filterRowsByDimension(rec.result.chartRows, chart);
+    }
+    const steps = (rec.steps || []).filter((s) => s.type === 'tool' && s.ok && s.data);
+    for (const step of steps.reverse()) {
+      const d = step.data;
+      const candidates = [d.rows, chart.x === 'date' ? d.curByDate : d.curByShop];
+      for (const rows of candidates) {
+        if (Array.isArray(rows) && rows.length) {
+          const filtered = filterRowsByDimension(rows, chart);
+          if (filtered.length) return filtered;
+        }
+      }
+    }
+    return null;
+  }
+
+  function filterRowsByDimension(rows, chart) {
+    const key = chart.x === 'date' ? 'date' : 'shopName';
+    const has = rows.filter((r) => r && r[key] != null && chart.y.some((m) => r[m] != null));
+    if (chart.x === 'date') has.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
+    return has;
+  }
+
+  function renderChart(wrapEl, chart, rows) {
+    const prev = echarts.getInstanceByDom(wrapEl);
+    if (prev) prev.dispose();
+    const instance = echarts.init(wrapEl);
+    state.charts.push(instance);
+    const meta = new Map(state.columns.map((c) => [c.key, c]));
+    const colOf = (key) => meta.get(key) || { label: key, type: 'num' };
+    instance.setOption({
+      tooltip: { trigger: 'axis' },
+      grid: { left: 56, right: 16, top: 20, bottom: chart.type === 'bar' && chart.x !== 'date' ? 52 : 24 },
+      xAxis: {
+        type: 'category',
+        data: rows.map((r) => (chart.x === 'date' ? ymdToDash(r.date) : (r.shopName || r.shopId || ''))),
+        axisLabel: { color: '#6b6560', interval: 0, rotate: chart.type === 'bar' && chart.x !== 'date' ? 30 : 0, width: 80, overflow: 'truncate' },
+        axisLine: { lineStyle: { color: '#e8e4df' } },
+      },
+      yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
+      series: (chart.y || []).map((key, yi) => {
+        const c = colOf(key);
+        return {
+          name: c.label || key,
+          type: chart.type === 'line' ? 'line' : 'bar',
+          smooth: true, barMaxWidth: 26,
+          data: rows.map((r) => {
+            const n = Number(r[key]);
+            if (!Number.isFinite(n)) return 0;
+            return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
+          }),
+          itemStyle: { color: yi === 0 ? ACCENT : BLUE },
+        };
+      }),
+    });
+    setTimeout(() => instance.resize(), 40);
+  }
+
+  // ---------- 事件 ----------
+
+  function bindEvents() {
+    $('askEntity').addEventListener('change', () => {
+      state.entity = $('askEntity').value || null;
+      state.sessionId = null;
+      state.sessions = [];
+      renderStageHead();
+      openLatestSession();
+    });
+    $('askMode').addEventListener('click', (event) => {
+      const btn = event.target.closest('button[data-mode]');
+      if (!btn) return;
+      state.mode = ['auto', 'agent', 'quick'].includes(btn.dataset.mode) ? btn.dataset.mode : 'auto';
+      $('askMode').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
+    });
+    $('askSkill').addEventListener('change', () => {
+      state.skill = $('askSkill').value || '';
+    });
+    $('btnAskNewSession').addEventListener('click', newSession);
+    $('askSearch').addEventListener('input', () => {
+      state.search = $('askSearch').value;
+      renderSessions();
+    });
+    $('btnAskSend').addEventListener('click', () => ask());
+    $('btnAskStop').addEventListener('click', cancel);
+    const input = $('askInput');
+    input.addEventListener('keydown', (event) => {
+      if (event.key === 'Enter' && !event.shiftKey) {
+        event.preventDefault();
+        ask();
+      }
+    });
+    input.addEventListener('input', autoresize);
+    $('askChips').addEventListener('click', (event) => {
+      const chip = event.target.closest('[data-q]');
+      if (chip) ask(chip.dataset.q);
+    });
+    window.addEventListener('resize', () => {
+      state.charts.forEach((c) => { try { c.resize(); } catch { /* ignore */ } });
+    });
+  }
+})();

BIN
public/assets/logo.png


+ 40 - 6
public/crawl.js

@@ -8,6 +8,7 @@ const state = {
 };
 
 let pollTimer = null;
+let pollFailStreak = 0;
 let crawlRoot = '';
 let initialized = false;
 
@@ -30,8 +31,20 @@ function jsonFetch(url, opts) {
   );
 }
 
+// 店铺名/目录名来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
+function esc(value) {
+  const div = document.createElement('div');
+  div.textContent = String(value ?? '');
+  return div.innerHTML;
+}
+
 function renderShopTable() {
   const body = document.getElementById('shopTableBody');
+  // 表格每 1.5s 随轮询全量重建,重建前记住用户已手动取消勾选的店铺,避免勾选被重置
+  const uncheckedBefore = new Set();
+  body.querySelectorAll('input[type=checkbox]').forEach((cb) => {
+    if (!cb.checked) uncheckedBefore.add(cb.dataset.shopId);
+  });
   body.innerHTML = '';
   document.getElementById('shopTableEmpty').style.display = state.shops.length ? 'none' : 'block';
   const countEl = document.getElementById('shopCount');
@@ -40,10 +53,10 @@ function renderShopTable() {
     const tr = document.createElement('tr');
     const tdCb = document.createElement('td');
     const cb = document.createElement('input');
-    cb.type = 'checkbox'; cb.checked = true; cb.dataset.shopId = String(shop.shopId);
+    cb.type = 'checkbox'; cb.checked = !uncheckedBefore.has(String(shop.shopId)); cb.dataset.shopId = String(shop.shopId);
     tdCb.appendChild(cb); tr.appendChild(tdCb);
     const tdName = document.createElement('td');
-    tdName.innerHTML = `<div class="shop-name">${shop.shopName}</div>`;
+    tdName.innerHTML = `<div class="shop-name">${esc(shop.shopName)}</div>`;
     tr.appendChild(tdName);
     const tdId = document.createElement('td');
     tdId.textContent = shop.shopId;
@@ -58,7 +71,7 @@ function renderShopTable() {
     const tdFolder = document.createElement('td');
     tdFolder.className = 'col-folder';
     tdFolder.innerHTML = folderName
-      ? `<span class="badge badge-ok">${folderName}</span>`
+      ? `<span class="badge badge-ok">${esc(folderName)}</span>`
       : '<span class="badge">—</span>';
     tr.appendChild(tdFolder);
     tr.dataset.shopId = String(shop.shopId);
@@ -74,7 +87,7 @@ function statusBadgeHtml(st) {
     failed: '失败', stopped: '已停止', interrupted: '已中断',
   }[st.state] || st.state;
   const cls = { done: 'badge-ok', failed: 'badge-fail', running: 'badge-warn' }[st.state] || 'badge-info';
-  return `<span class="badge ${cls}">${label}</span>`;
+  return `<span class="badge ${cls}">${esc(label)}</span>`;
 }
 
 const ICON_PLAY = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
@@ -123,7 +136,7 @@ function renderCrawlState(cs) {
     row.className = 'progress-row';
     const pct = s.total > 0 ? Math.round((s.downloaded + s.skipped) / s.total * 100) : 0;
     row.innerHTML = `
-      <div class="left"><span class="badge badge-info">采集</span><span class="name">${s.shopName}</span><span class="meta">· 菜品图</span></div>
+      <div class="left"><span class="badge badge-info">采集</span><span class="name">${esc(s.shopName)}</span><span class="meta">· 菜品图</span></div>
       <div class="right">
         <div class="progress-bar"><span style="width:${pct}%;"></span></div>
         <span>${s.downloaded + s.skipped} / ${s.total || 0}</span>
@@ -137,7 +150,14 @@ function renderCrawlState(cs) {
 
 function startPolling() {
   if (pollTimer) clearInterval(pollTimer);
-  const tick = () => jsonFetch('/api/crawl/status').then(renderCrawlState).catch(() => {});
+  const tick = () => jsonFetch('/api/crawl/status').then((cs) => {
+    pollFailStreak = 0;
+    renderCrawlState(cs);
+  }).catch(() => {
+    // 静默重试,但连续失败要给一次提示(服务可能重启),之后仍自动恢复
+    pollFailStreak++;
+    if (pollFailStreak === 3) showToast('抓取状态轮询失败(服务可能已重启),恢复后自动继续', 'error');
+  });
   tick();
   pollTimer = setInterval(tick, 1500);
 }
@@ -219,4 +239,18 @@ window.shanCrawlInit = function () {
     jsonFetch('/api/crawl/stop', { method: 'POST' }).then(() => showToast('已发送停止请求'));
   });
 };
+
+// 视图感知:离开采集页暂停状态轮询,回页时按服务端实际状态决定是否恢复
+//(app.js switchView 调用;shanCrawlInit 只在首次进入时绑事件,不会重复拉状态)
+window.shanCrawlSetActive = function (active) {
+  if (active) {
+    jsonFetch('/api/crawl/status').then((cs) => {
+      renderCrawlState(cs);
+      if (cs.status === 'running') startPolling();
+    }).catch(() => {});
+  } else if (pollTimer) {
+    clearInterval(pollTimer);
+    pollTimer = null;
+  }
+};
 })();

+ 53 - 24
public/gen.js

@@ -757,13 +757,17 @@ function buildShopCard(shop) {
         const value = panel.prompt.value.trim();
         if (value && panel.prompt.dataset.inherit !== '1') prompts.dishes[shop.dishes[index].file] = value;
       });
-      const response = await fetch('/api/save-config', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }),
-      });
-      const data = await response.json().catch(() => ({}));
-      if (!response.ok) return showToast(data.error || '保存失败', 'error');
-      showToast(`已保存「${shopNameInput.value}」配置`);
+      try {
+        const response = await fetch('/api/save-config', {
+          method: 'POST', headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ root: state.root, folderName, shopName: shopNameInput.value, prompts }),
+        });
+        const data = await response.json().catch(() => ({}));
+        if (!response.ok) return showToast(data.error || '保存失败', 'error');
+        showToast(`已保存「${shopNameInput.value}」配置`);
+      } catch (error) {
+        showToast('保存失败:' + error.message, 'error');
+      }
     });
     refreshWorkflow();
     sharedPrompt.addEventListener('input', () => {
@@ -785,21 +789,28 @@ function buildShopCard(shop) {
 }
 
 async function openShopFolder(folderName) {
-  const response = await fetch('/api/open-folder', {
-    method: 'POST', headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({ root: state.root, folderName }),
-  });
-  const data = await response.json().catch(() => ({}));
-  if (!response.ok) showToast(data.error || '打开目录失败', 'error');
+  try {
+    const response = await fetch('/api/open-folder', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ root: state.root, folderName }),
+    });
+    const data = await response.json().catch(() => ({}));
+    if (!response.ok) showToast(data.error || '打开目录失败', 'error');
+  } catch (error) {
+    showToast('打开目录失败:' + error.message, 'error');
+  }
 }
 
 function trackJob(jobId, container, kind, done, context) {
   activeJobs.set(jobId, { state: 'queued', cost: 0 });
   updateStats();
+  const label = (BRAND_META[kind] && BRAND_META[kind].label) || '作品';
+  let pollErrors = 0; // 连续失败上限:服务端任务丢失/重启后不再无限轮询
   const tick = async () => {
     try {
       const response = await fetch(`/api/job/${jobId}`);
       const job = await response.json();
+      pollErrors = 0;
       activeJobs.set(jobId, { state: job.state, cost: job.cost || 0 });
       renderResult(container, { kind, ...job }, context);
       if (job.state === 'queued' || job.state === 'running') {
@@ -808,10 +819,20 @@ function trackJob(jobId, container, kind, done, context) {
       }
       activeJobs.delete(jobId);
       updateStats();
-      if (job.state === 'success') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成完成`);
-      else if (job.state === 'failed') showToast(`${job.dishName || BRAND_META[kind]?.label || '作品'}生成失败:${job.error}`, 'error');
+      if (job.state === 'success') showToast(`${job.dishName || label}生成完成`);
+      else if (job.state === 'failed') showToast(`${job.dishName || label}生成失败:${job.error}`, 'error');
       if (done) done(job);
-    } catch { setTimeout(tick, 4000); }
+    } catch {
+      pollErrors++;
+      if (pollErrors >= 5) {
+        activeJobs.delete(jobId);
+        updateStats();
+        renderResult(container, { kind, id: jobId, state: 'failed', error: '任务状态查询失败(服务可能已重启),请重新提交' }, context);
+        showToast(`${label}任务跟踪失败,已停止轮询`, 'error');
+        return;
+      }
+      setTimeout(tick, 4000);
+    }
   };
   tick();
 }
@@ -909,6 +930,8 @@ function handleScanDropdownClick(event) {
 
 async function scan() {
   if (!state.root) return;
+  if (state.scanning) return; // 重入保护:刷新按钮与初始化两个入口可能并发
+  state.scanning = true;
   const edits = captureCurrentEdits();
   const selected = Array.from(state.scanSelection).filter((value) => value !== '__all__');
   const scanAll = !selected.length;
@@ -934,6 +957,7 @@ async function scan() {
     updateScanOptions(); renderShops();
     showToast(scanAll ? `扫描完成,找到 ${data.shops.length} 家店铺` : `扫描完成,已更新 ${data.shops.length} 家门店`);
   } catch (error) { showToast(`扫描失败:${error.message}`, 'error'); }
+  state.scanning = false;
   button.disabled = false;
   document.getElementById('scanIcon').innerHTML = ICON_PLAY;
   document.getElementById('scanText').textContent = '扫描店铺';
@@ -956,13 +980,18 @@ window.shanGenInit = async function () {
       state.model = settings.model || '';
     } catch {}
   }
-  const response = await fetch('/api/default-root');
-  const data = await response.json();
-  const changed = !state.root || state.root !== data.root;
-  state.root = data.root;
-  state.expandedShops = readStoredSet('expanded');
-  state.scanSelection = readStoredSet('scan-selection');
-  if (!state.scanSelection.size) state.scanSelection.add('__all__');
-  if (changed) scan();
+  try {
+    const response = await fetch('/api/default-root');
+    if (!response.ok) throw new Error(`HTTP ${response.status}`);
+    const data = await response.json();
+    const changed = !state.root || state.root !== data.root;
+    state.root = data.root;
+    state.expandedShops = readStoredSet('expanded');
+    state.scanSelection = readStoredSet('scan-selection');
+    if (!state.scanSelection.size) state.scanSelection.add('__all__');
+    if (changed) scan();
+  } catch (error) {
+    showToast('初始化失败:' + error.message, 'error');
+  }
 };
 })();

+ 4 - 1
public/history.js

@@ -136,6 +136,7 @@
     render();
     try {
       const rootResponse = await fetch('/api/default-root');
+      if (!rootResponse.ok) throw new Error(`默认目录获取失败 HTTP ${rootResponse.status}`);
       const rootData = await rootResponse.json();
       state.root = rootData.root;
       const response = await fetch(`/api/creations?root=${encodeURIComponent(state.root)}&limit=600`);
@@ -156,7 +157,9 @@
     document.getElementById('btnRefreshHistory').addEventListener('click', loadHistory);
     document.getElementById('historySearch').addEventListener('input', (event) => {
       state.keyword = event.target.value;
-      render();
+      // 每次击键全量重建最多 600 张卡,防抖后再渲染
+      clearTimeout(state.searchTimer);
+      state.searchTimer = setTimeout(render, 300);
     });
     document.getElementById('historyFilters').addEventListener('click', (event) => {
       const button = event.target.closest('[data-filter]');

+ 71 - 46
public/index.html

@@ -3,7 +3,7 @@
 <head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
-<title>智慧运营中台</title>
+<title>炘和数智智慧平台</title>
 <link rel="icon" href="/assets/logo.png">
 <link rel="stylesheet" href="/style.css">
 </head>
@@ -11,9 +11,9 @@
 
 <!-- Splash Screen -->
 <div class="splash" id="splash">
-  <img src="/assets/logo.png" alt="智慧运营中台" class="splash-logo">
-  <div class="splash-name">智慧运营中台</div>
-  <div class="splash-sub">淘宝闪购专业版</div>
+  <img src="/assets/logo.png" alt="炘和数智智慧平台" class="splash-logo">
+  <div class="splash-name">炘和数智智慧平台</div>
+  <div class="splash-sub">让餐饮经营可视化</div>
   <div class="splash-bar"><span></span></div>
   <div class="splash-powered">
     <span>powered by</span>
@@ -27,8 +27,8 @@
     <div class="sidebar-brand">
       <div class="sidebar-logo"><img src="/assets/logo.png" alt="门"></div>
       <div class="sidebar-titles">
-        <div class="t1">智慧运营中台</div>
-        <div class="t2">淘宝闪购专业版</div>
+        <div class="t1">炘和数智智慧平台</div>
+        <div class="t2">让餐饮经营可视化</div>
       </div>
     </div>
 
@@ -41,14 +41,15 @@
     <div class="nav-section-label">经营分析</div>
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
     <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
-    <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span></button>
+    <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span><span class="nav-beta">Beta</span></button>
+    <button class="nav-item" data-view="ask"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><path d="M9.5 13.5h.01M14.5 13.5h.01"/><path d="M9.5 16.2h5"/></svg></span><span>智能问数</span><span class="nav-beta">Beta</span></button>
 
     <div class="nav-section-label">设置</div>
     <button class="nav-item" data-view="settings"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10 3.05V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.55.91H21a2 2 0 1 1 0 4h-.05a1.7 1.7 0 0 0-1.55 1z"/></svg></span><span>系统设置</span></button>
     <button class="nav-item" data-view="logs"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H8l-4 4Z"/><path d="M8 9h8M8 12h5"/></svg></span><span>系统日志</span></button>
 
     <div class="sidebar-bottom">
-      <span>v1.0.0</span>
+      <span>淘宝闪购 · v1.0.0</span>
       <img src="/assets/powered-by.png" alt="Hyreal IDE" class="sidebar-powered">
     </div>
   </aside>
@@ -486,7 +487,7 @@
         <div class="page-header">
           <div>
             <h1>趋势图表</h1>
-            <div class="sub">经营 / 运营双视角内置模板,按模板看趋势与归因;有疑问点右下角问数助手</div>
+            <div class="sub">经营 / 运营双视角内置模板,按模板看趋势与归因;深入分析去「智能问数」页</div>
           </div>
           <div class="toolbar">
             <button class="btn btn-ghost btn-small" id="btnExportHtml">
@@ -542,6 +543,66 @@
       </main>
     </div>
 
+    <!-- ==================== 智能问数 ==================== -->
+    <div id="viewAsk" class="view">
+      <main class="content content-ask">
+        <div class="ask-shell">
+          <aside class="ask-side">
+            <div class="ask-side-head">
+              <span class="ask-brand">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><path d="M9.5 13.5h.01M14.5 13.5h.01"/><path d="M9.5 16.2h5"/></svg>
+                智能问数
+              </span>
+              <button type="button" class="ask-new-btn" id="btnAskNewSession" title="新建会话">+</button>
+            </div>
+            <div class="ask-search">
+              <input type="text" id="askSearch" placeholder="搜索会话" autocomplete="off">
+            </div>
+            <div class="ask-side-list" id="askSessions"></div>
+          </aside>
+
+          <section class="ask-stage">
+            <div class="ask-stage-head">
+              <span class="ask-stage-title" id="askStageTitle">新对话</span>
+              <span class="ask-stage-badge" id="askStageBadge"></span>
+            </div>
+            <div class="ask-stage-sub">智能体自动取数、做同环比对比与数据体检;结论均来自本地数据湖,过程与数据质量判定全程可见</div>
+            <div class="ask-stream" id="askStream">
+              <div class="ask-hero" id="askEmpty">
+                <div class="ask-hero-title">给问数一个目标</div>
+                <div class="ask-hero-sub">智能体会自动规划、调用取数与校验工具(湖概览 / 报表查询 / 同环比对比 / 数据体检),逐步给出带真实数字的结论;切换页面后运行仍会继续,可随时回来查看。</div>
+                <div class="ask-chips" id="askChips">
+                  <button type="button" data-q="最近7天营业额和单量趋势">最近7天营业额和单量趋势</button>
+                  <button type="button" data-q="最近30天比上一个30天营业额变化多少,主要受哪些门店影响">环比变化与影响门店</button>
+                  <button type="button" data-q="做一次数据体检,看看数据质量有没有问题">数据体检</button>
+                  <button type="button" data-q="上月营业额Top5门店">上月营业额Top5门店</button>
+                </div>
+              </div>
+            </div>
+            <div class="ask-composer">
+              <textarea id="askInput" rows="1" placeholder="描述你的数据问题,或从「技能」里点名打法;回车发送,Shift + Enter 换行" autocomplete="off"></textarea>
+              <div class="ask-composer-bar">
+                <select id="askEntity" class="ask-ent-select" title="数据主体"></select>
+                <div class="seg-control" id="askMode">
+                  <button type="button" data-mode="auto" class="active">自动</button>
+                  <button type="button" data-mode="agent">深度分析</button>
+                  <button type="button" data-mode="quick">快问</button>
+                </div>
+                <select id="askSkill" class="ask-ent-select" title="内置技能(打法模板,可选)">
+                  <option value="">技能:自动</option>
+                </select>
+                <span class="ask-composer-spacer"></span>
+                <button type="button" class="btn btn-ghost btn-small" id="btnAskStop" hidden>停止</button>
+                <button type="button" class="ask-send-btn" id="btnAskSend" title="发送">
+                  <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 11l18-8-8 18-2.5-7.5z"/></svg>
+                </button>
+              </div>
+            </div>
+          </section>
+        </div>
+      </main>
+    </div>
+
     <!-- ==================== 系统设置 ==================== -->
     <div id="viewSettings" class="view">
       <main class="content">
@@ -765,43 +826,6 @@
 
 <div class="toast-wrap" id="toast-wrap"></div>
 
-<!-- 智能问数 · 机器人(仅经营分析相关页面显示) -->
-<div class="chatbot-fab" id="chatbotFab" title="智能问数" hidden>
-  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><circle cx="9" cy="13" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="13" r="1.1" fill="currentColor" stroke="none"/><path d="M9.5 16.2h5"/></svg>
-</div>
-<div class="chatbot-panel" id="chatbotPanel" hidden>
-  <div class="chatbot-head">
-    <span class="chatbot-title">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><circle cx="9" cy="13" r="1.1" fill="currentColor" stroke="none"/><circle cx="15" cy="13" r="1.1" fill="currentColor" stroke="none"/><path d="M9.5 16.2h5"/></svg>
-      智能问数
-    </span>
-    <span class="chatbot-sub">支持上下文追问</span>
-    <button type="button" class="chatbot-icon-btn" id="btnChatClear" title="清空对话">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/></svg>
-    </button>
-    <button type="button" class="chatbot-icon-btn" id="btnChatClose" title="收起">
-      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
-    </button>
-  </div>
-  <div class="chatbot-body" id="chatbotBody">
-    <div class="chatbot-msg bot">
-      <div class="chatbot-bubble">你好,我是经营数据助手。可以直接问,也支持追问 —— 比如先问「上月营业额Top5门店」,再问「那二店的单量呢」。</div>
-    </div>
-    <div class="chatbot-chips" id="chatbotChips">
-      <button type="button" data-q="上月营业额Top5门店">上月营业额Top5门店</button>
-      <button type="button" data-q="最近7天进店转化率趋势">最近7天进店转化率趋势</button>
-      <button type="button" data-q="昨天营业额和订单总量">昨天营业额和订单总量</button>
-      <button type="button" data-q="哪几家门店曝光高但下单少">哪几家门店曝光高但下单少</button>
-    </div>
-  </div>
-  <div class="chatbot-input-row">
-    <input type="text" id="chatbotInput" placeholder="输入问题,回车发送…" autocomplete="off">
-    <button type="button" class="btn btn-primary btn-small" id="btnChatSend" title="发送">
-      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 11l18-8-8 18-2.5-7.5z"/></svg>
-    </button>
-  </div>
-</div>
-
 <!-- 添加店铺弹窗 -->
 <div class="modal-overlay" id="modalAddShop" hidden>
   <div class="modal">
@@ -935,6 +959,7 @@
 <script src="/analysis-templates.js"></script>
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
+<script src="/ask.js"></script>
 <script src="/app.js"></script>
 </body>
 </html>

+ 34 - 200
public/reports.js

@@ -4,7 +4,7 @@
  * 数据采集 + 趋势图表 · 前端逻辑
  * 数据采集:多主体 tab / 同步 / 筛选 / BI 表格 / CSV 导出
  * 趋势图表:内置分析模板(经营/运营双视角,可切换)+ 环比指标条 + 周报导出
- * 智能问数:右下角机器人(仅经营分析页面显示),弹窗对话,支持上下文追问
+ * 智能问数已升级为独立页面(public/ask.js + server/agent/),此处仅保留「应用到报表」落点
  */
 (function () {
 
@@ -48,6 +48,12 @@
     const n = Number(v);
     return Number.isFinite(n) ? (n * 100).toFixed(2) + '%' : String(v);
   }
+  // 店铺名/账号名/raw 原始字段均来自外部平台数据,凡进 innerHTML 的文本必须先过 esc()
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value ?? '');
+    return div.innerHTML;
+  }
   function fmtCell(col, v) {
     if (col.type === 'pct') return fmtPct(v);
     if (col.type === 'money') return fmtMoney(v);
@@ -119,13 +125,11 @@
     analysisShopFilter: new Set(),
     analysisPreset: '30',
     comparePrev: true,
-    chat: { open: false, busy: false, messages: [] },
   };
 
   let chartMetric = LS.get('shanReports.chartMetric', 'grossAmt');
   const chartInstances = {};
   let anaCache = null;
-  let lastAskApply = null;
 
   const $ = (id) => document.getElementById(id);
 
@@ -506,14 +510,14 @@
     thead.innerHTML = '<tr>' + cols.map((c) => {
       const active = state.sortKey === c.key;
       const arrow = active ? (state.sortDir === 'asc' ? ' ↑' : ' ↓') : '';
-      return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${c.key}">${c.label}${arrow}</th>`;
+      return `<th class="col-${c.type}${['money', 'int', 'pct', 'num'].includes(c.type) ? ' col-num' : ''}" data-key="${esc(c.key)}">${esc(c.label)}${arrow}</th>`;
     }).join('') + '</tr>';
     tbody.innerHTML = sortedRows().map((r) => '<tr>' + cols.map((c) => {
       const isNum = ['money', 'int', 'pct', 'num'].includes(c.type);
       const v = cellValue(r, c.key);
       const display = (v != null && typeof v === 'object') ? JSON.stringify(v) : v;
       const cls = `col-${c.type}${isNum ? ' col-num' : ''}${c.type === 'pct' ? ' ' + rateClass(c.key, v) : ''}`;
-      return `<td class="${cls}">${fmtCell(c, display)}</td>`;
+      return `<td class="${cls}">${esc(fmtCell(c, display))}</td>`;
     }).join('') + '</tr>').join('');
   }
 
@@ -717,7 +721,7 @@
       const label = document.createElement('label');
       label.className = 'colcfg-item';
       const checked = state.visible.has(c.key) ? 'checked' : '';
-      label.innerHTML = `<input type="checkbox" value="${c.key}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
+      label.innerHTML = `<input type="checkbox" value="${esc(c.key)}" ${checked}><span></span>${c.reserved ? '<em class="colcfg-reserved">预留</em>' : ''}`;
       label.querySelector('span').textContent = c.label;
       list.appendChild(label);
     }
@@ -748,7 +752,7 @@
       const cls = s.ok ? 'ok' : 'fail';
       const detail = s.ok
         ? `+${s.added}/~${s.updated}` + (s.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
-        : (s.error || '失败').slice(0, 60);
+        : esc((s.error || '失败').slice(0, 60));
       return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
     }).join('');
     const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
@@ -760,9 +764,11 @@
 
   function pollSyncStatus() {
     if (state.syncPollTimer) clearInterval(state.syncPollTimer);
+    let pollErrors = 0; // 连续失败计数:服务端持续不可达时停止轮询,避免永久空转
     state.syncPollTimer = setInterval(async () => {
       try {
         const st = await jsonFetch('/api/reports/sync/status');
+        pollErrors = 0;
         renderSyncProgress(st);
         if (!st.running) {
           clearInterval(state.syncPollTimer);
@@ -771,7 +777,14 @@
           else showToast(st.cancelled ? '同步已取消' : `同步完成:新增 ${st.added},更新 ${st.updated}`);
           refreshConn().then(loadReport);
         }
-      } catch { /* 轮询失败忽略 */ }
+      } catch {
+        pollErrors++;
+        if (pollErrors >= 5) {
+          clearInterval(state.syncPollTimer);
+          state.syncPollTimer = null;
+          showToast('同步状态轮询失败,已停止;可用「刷新」手动恢复', 'error');
+        }
+      }
     }, 900);
   }
 
@@ -1289,128 +1302,12 @@
 
   const TEMPLATES = window.shanAnalysisTemplates ? window.shanAnalysisTemplates(buildTemplateKit()) : [];
 
-  // ---------- 智能问数 · 机器人(仅经营分析页面) ----------
-
-  // 对话历史按主体持久化到 localStorage,刷新/重开后可继续追问
-  const CHAT_LS_KEY = 'shanReports.chat.v1';
-  const chatStoreKey = () => state.entity || '_';
-
-  function loadChatForEntity() {
-    const all = LS.get(CHAT_LS_KEY, {});
-    state.chat.messages = Array.isArray(all[chatStoreKey()]) ? all[chatStoreKey()] : [];
-    if (state.chat.open) renderChatMessages();
-  }
-
-  function saveChatForEntity() {
-    const key = chatStoreKey();
-    const all = LS.get(CHAT_LS_KEY, {});
-    const trimmed = state.chat.messages.filter((m) => !m.pending).slice(-20);
-    const attempt = (payload) => {
-      try { localStorage.setItem(CHAT_LS_KEY, JSON.stringify(payload)); return true; } catch { return false; }
-    };
-    if (attempt({ ...all, [key]: trimmed })) return;
-    // 图表行数据可能撑爆配额 → 退化为只存文字与图表配置
-    const slim = { ...all, [key]: trimmed.map((m) => ({ ...m, rows: undefined })) };
-    if (!(attempt(slim) || attempt({ [key]: slim[key].slice(-10) }))) { /* 配额不足则放弃持久化 */ }
-  }
-
-  function chatVisibleFor(view) { return view === 'reports' || view === 'analysis'; }
-
-  function setChatOpen(open) {
-    state.chat.open = open;
-    $('chatbotPanel').hidden = !open;
-    $('chatbotFab').classList.toggle('active', open);
-    if (open) {
-      loadChatForEntity();
-      $('chatbotFab').hidden = false;
-      setTimeout(() => $('chatbotInput').focus(), 60);
-      scrollChatBottom();
-    }
-  }
-
-  function scrollChatBottom() {
-    const body = $('chatbotBody');
-    body.scrollTop = body.scrollHeight;
-  }
-
-  function renderChatMessages() {
-    const body = $('chatbotBody');
-    [...body.querySelectorAll('.chatbot-msg:not(.static)')].forEach((n) => n.remove());
-    body.querySelectorAll('.chatbot-chips').forEach((n) => { n.hidden = state.chat.messages.length > 0; });
-    for (let i = 0; i < state.chat.messages.length; i++) {
-      const m = state.chat.messages[i];
-      const div = document.createElement('div');
-      div.className = 'chatbot-msg ' + (m.role === 'user' ? 'user' : 'bot');
-      if (m.pending) {
-        div.innerHTML = '<div class="chatbot-bubble typing"><span></span><span></span><span></span></div>';
-      } else {
-        const bubble = document.createElement('div');
-        bubble.className = 'chatbot-bubble';
-        bubble.textContent = m.text || '';
-        div.appendChild(bubble);
-        if (m.chart && m.chart.type !== 'none' && m.rows && m.rows.length) {
-          const chartWrap = document.createElement('div');
-          chartWrap.className = 'chatbot-chart';
-          chartWrap.dataset.idx = String(i);
-          div.appendChild(chartWrap);
-          const actions = document.createElement('div');
-          actions.className = 'chatbot-msg-actions';
-          const applyBtn = document.createElement('button');
-          applyBtn.type = 'button';
-          applyBtn.className = 'btn btn-ghost btn-small';
-          applyBtn.textContent = '应用到报表';
-          applyBtn.addEventListener('click', () => applyMsgQuery(i));
-          actions.appendChild(applyBtn);
-          div.appendChild(actions);
-        }
-      }
-      body.appendChild(div);
-    }
-    scrollChatBottom();
-    // 渲染消息内图表
-    state.chat.messages.forEach((m, i) => {
-      if (m.pending || !m.chart || m.chart.type === 'none' || !m.rows || !m.rows.length) return;
-      const wrap = body.querySelector(`.chatbot-chart[data-idx="${i}"]`);
-      if (!wrap) return;
-      const col = (key) => (state.columns.find((c) => c.key === key) || { label: key, type: 'num' });
-      const isDate = m.chart.x === 'date';
-      const rows = m.rows.slice();
-      if (isDate) rows.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
-      const chart = echarts.init(wrap);
-      chart.setOption({
-        tooltip: { trigger: 'axis' },
-        grid: { left: 56, right: 16, top: 20, bottom: m.chart.type === 'bar' && !isDate ? 52 : 24 },
-        xAxis: {
-          type: 'category',
-          data: rows.map((r) => (isDate ? ymdToDash(r.date) : (r.shopName || r.shopId))),
-          axisLabel: { color: '#6b6560', interval: 0, rotate: m.chart.type === 'bar' && !isDate ? 30 : 0, width: 80, overflow: 'truncate' },
-          axisLine: { lineStyle: { color: '#e8e4df' } },
-        },
-        yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
-        series: (m.chart.y || []).map((key, yi) => {
-          const c = col(key);
-          return {
-            name: c.label || key,
-            type: m.chart.type === 'line' ? 'line' : 'bar',
-            smooth: true, barMaxWidth: 26,
-            data: rows.map((r) => {
-              const v = r[key];
-              if (v == null || v === '') return 0;
-              const n = Number(v);
-              return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
-            }),
-            itemStyle: { color: yi === 0 ? '#ff6b35' : '#2563eb' },
-          };
-        }),
-      });
-      setTimeout(() => chart.resize(), 40);
-    });
-  }
+  // ---------- 智能问数 ·「应用到报表」(独立问数页经 window.shanReportsApplyQuery 调用) ----------
 
-  function applyMsgQuery(idx) {
-    const m = state.chat.messages[idx];
-    const a = m && m.applied;
+  /** 把问数查询条件落到报表筛选,并跳转数据中台明细 tab(原机器人 applyMsgQuery 语义) */
+  window.shanReportsApplyQuery = async function (a) {
     if (!a) return;
+    if (!state.inited) await window.shanReportsInit();
     state.mode = ['detail', 'shop', 'date'].includes(a.mode) ? a.mode : 'shop';
     $('reportViewModes').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b.dataset.mode === state.mode));
     state.preset = 'custom';
@@ -1426,49 +1323,7 @@
     if (window.shanLakeShowDetail) window.shanLakeShowDetail();
     const nav = document.querySelector('.nav-item[data-view="lake"]');
     if (nav) nav.click();
-  }
-
-  async function sendChat(text) {
-    const q = String(text || '').trim();
-    if (!q || state.chat.busy) return;
-    state.chat.busy = true;
-    state.chat.messages.push({ role: 'user', text: q });
-    state.chat.messages.push({ role: 'assistant', pending: true });
-    renderChatMessages();
-    $('chatbotInput').value = '';
-    try {
-      const history = state.chat.messages
-        .filter((m) => !m.pending && (m.role === 'user' || (m.role === 'assistant' && m.text)))
-        .slice(0, -1)
-        .slice(-8)
-        .map((m) => ({ role: m.role, content: m.role === 'assistant' ? `${m.text}${m.applied ? `(已查询:mode=${m.applied.mode} ${m.applied.from}~${m.applied.to} 指标 ${(m.applied.metrics || []).join(',') || 'grossAmt'}${m.applied.shopIds && m.applied.shopIds.length ? ' 门店 ' + m.applied.shopIds.join(',') : ''})` : ''}` : m.text }));
-      const result = await jsonFetch('/api/reports/ask', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ entity: state.entity, q, history }),
-      });
-      const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
-      const msg = {
-        role: 'assistant',
-        text: result.reply || '(模型未给出文字回答)',
-        chart: result.chart,
-        rows: result.rows,
-        applied: result.applied,
-        chartTitle: result.chartTitle,
-      };
-      if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, msg);
-      else state.chat.messages.push(msg);
-      saveChatForEntity();
-    } catch (e) {
-      const pendingIdx = state.chat.messages.findIndex((m) => m.pending);
-      const errMsg = { role: 'assistant', text: '出错了:' + e.message, error: true };
-      if (pendingIdx >= 0) state.chat.messages.splice(pendingIdx, 1, errMsg);
-      else state.chat.messages.push(errMsg);
-    } finally {
-      state.chat.busy = false;
-      renderChatMessages();
-    }
-  }
+  };
 
   // ---------- 事件绑定 ----------
 
@@ -1501,7 +1356,12 @@
       if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
       $('syncProgressWrap').hidden = true;
     });
-    $('btnCancelSync').addEventListener('click', () => $('modalReportSync').hidden = true);
+    $('btnCancelSync').addEventListener('click', () => {
+      $('modalReportSync').hidden = true;
+      // 与 btnCloseSync 同款:取消路径也要停掉同步状态轮询,否则后台空转
+      if (state.syncPollTimer) { clearInterval(state.syncPollTimer); state.syncPollTimer = null; }
+      $('syncProgressWrap').hidden = true;
+    });
     $('btnStartSync').addEventListener('click', startSync);
     $('btnSyncCancel').addEventListener('click', async () => {
       try { await jsonFetch('/api/reports/sync/cancel', { method: 'POST' }); showToast('已请求取消,等待当前步骤结束…'); } catch { /* ignore */ }
@@ -1622,25 +1482,6 @@
     });
     $('anaComparePrev').addEventListener('change', renderCharts);
 
-    // 智能问数机器人
-    $('chatbotFab').addEventListener('click', () => setChatOpen(!state.chat.open));
-    $('btnChatClose').addEventListener('click', () => setChatOpen(false));
-    $('btnChatClear').addEventListener('click', () => {
-      state.chat.messages = [];
-      const all = LS.get(CHAT_LS_KEY, {});
-      delete all[state.entity || '_'];
-      LS.set(CHAT_LS_KEY, all);
-      renderChatMessages();
-    });
-    $('btnChatSend').addEventListener('click', () => sendChat($('chatbotInput').value));
-    $('chatbotInput').addEventListener('keydown', (event) => {
-      if (event.key === 'Enter') sendChat($('chatbotInput').value);
-    });
-    $('chatbotChips').addEventListener('click', (event) => {
-      const chip = event.target.closest('[data-q]');
-      if (chip) sendChat(chip.dataset.q);
-    });
-
     window.addEventListener('resize', () => {
       Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
     });
@@ -1693,8 +1534,8 @@
             : (pushed ? `<span class="badge badge-ok">已推 ${lastPush.pushedRows || 0} 行</span>` : '<span class="badge badge-info">未推送</span>');
           return `<tr>
             <td>${ts}</td>
-            <td>${(r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')} ~ ${(r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')}</td>
-            <td>${SCOPE_LABEL[r.scope] || r.scope || '–'}</td>
+            <td>${esc((r.from || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))} ~ ${esc((r.to || '–').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'))}</td>
+            <td>${esc(SCOPE_LABEL[r.scope] || r.scope || '–')}</td>
             <td class="num">${r.added ?? 0}</td>
             <td class="num">${r.updated ?? 0}</td>
             <td class="num">${r.failed ?? 0}</td>
@@ -1786,11 +1627,4 @@
     Object.values(chartInstances).forEach((c) => { if (c) c.resize(); });
   };
 
-  window.shanSetAnalysisVisible = function (visible) {
-    const fab = $('chatbotFab');
-    if (!fab) return;
-    fab.hidden = !visible;
-    if (!visible) setChatOpen(false);
-  };
-
 })();

+ 128 - 72
public/style.css

@@ -78,11 +78,14 @@ button, [role="button"], .btn { min-height: var(--tap-target-row-min); min-width
 .sidebar-titles .t2 { font-size: 11px; color: rgba(255,255,255,0.55); margin-top: 2px; }
 .nav-section-label { font-size: 11px; color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 0.06em; padding: 12px 10px 6px; }
 .nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; color: var(--sidebar-text); cursor: pointer; font-size: 13px; margin-bottom: 4px; transition: background 0.15s, color 0.15s; min-height: 36px; border: none; background: transparent; width: 100%; text-align: left; }
+.nav-beta { margin-left: auto; font-size: 9px; font-weight: 700; letter-spacing: .04em; line-height: 1; padding: 2px 6px; border-radius: var(--radius-pill); color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.09); border: 1px solid rgba(255,255,255,0.14); }
+.nav-item.active .nav-beta { color: #ffd9c7; background: rgba(255, 107, 53, 0.18); border-color: rgba(255, 107, 53, 0.35); }
 .nav-item:hover { background: var(--sidebar-hover-bg); color: #f0ede8; }
 .nav-item.active { background: var(--sidebar-active-bg); color: var(--accent); font-weight: 600; }
 .nav-item .ico { width: 16px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.9; }
 .nav-item .ico svg { width: 16px; height: 16px; display: block; }
-.sidebar-bottom { margin-top: auto; padding: 12px 10px; font-size: 11px; color: rgba(255,255,255,0.4); border-top: 1px solid rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: space-between; }
+.sidebar-bottom { margin-top: auto; padding: 12px 10px; font-size: 11px; color: rgba(255,255,255,0.4); border-top: 1px solid rgba(255,255,255,0.08); display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.sidebar-bottom span { white-space: nowrap; }
 .sidebar-powered { height: 14px; opacity: 0.4; }
 
 .main-wrap { flex: 1; min-width: 0; height: 100vh; overflow-y: auto; }
@@ -950,82 +953,135 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .diag-step-meta { font-size: var(--font-size-small); color: var(--text-3); }
 .diag-conclusion { background: var(--warn-bg); color: var(--warn); border-radius: var(--radius-md); padding: 8px 10px; font-size: var(--font-size-body); line-height: 1.5; margin-top: 8px; }
 
-/* ── 智能问数 · 机器人 FAB + 对话弹窗 ───────────────────── */
-.chatbot-fab {
-  position: fixed; right: 26px; bottom: 48px; z-index: 90;
-  width: 54px; height: 54px; border-radius: 50%; border: none; cursor: pointer;
-  background: linear-gradient(135deg, var(--accent) 0%, #ff8a5c 100%);
-  color: #fff; display: flex; align-items: center; justify-content: center;
-  box-shadow: 0 10px 26px rgba(255, 107, 53, .4);
-  transition: transform var(--duration-base) var(--ease-out), box-shadow var(--duration-base) var(--ease-out);
-}
-.chatbot-fab svg { width: 26px; height: 26px; }
-.chatbot-fab:hover { transform: translateY(-2px) scale(1.04); box-shadow: 0 14px 32px rgba(255, 107, 53, .48); }
-.chatbot-fab.active { box-shadow: 0 0 0 3px var(--focus-ring), 0 10px 26px rgba(255, 107, 53, .4); }
-.chatbot-fab[hidden] { display: none; }
-
-.chatbot-panel {
-  position: fixed; right: 26px; bottom: 114px; z-index: 95;
-  width: min(400px, calc(100vw - 40px)); height: min(600px, calc(100vh - 140px));
-  display: flex; flex-direction: column; overflow: hidden;
-  background: var(--card-bg); border: 1px solid var(--border); border-radius: 16px;
-  box-shadow: 0 24px 60px rgba(31, 27, 24, .22);
-}
-.chatbot-panel[hidden] { display: none; }
-.chatbot-head {
-  display: flex; align-items: center; gap: 8px; padding: 12px 14px;
-  border-bottom: 1px solid var(--border-light); background: linear-gradient(135deg, #fff 0%, #faf8f5 100%);
-}
-.chatbot-title { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: var(--font-size-h2); color: var(--text); }
-.chatbot-title svg { width: 18px; height: 18px; color: var(--accent); }
-.chatbot-sub { flex: 1; font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.chatbot-icon-btn {
-  width: 26px; height: 26px; min-width: 0; min-height: 0; padding: 0; border: 0; border-radius: 7px;
-  background: transparent; color: var(--text-2); cursor: pointer;
-  display: inline-flex; align-items: center; justify-content: center;
-}
-.chatbot-icon-btn:hover { background: #f1efec; color: var(--text); }
-.chatbot-icon-btn svg { width: 15px; height: 15px; }
-
-.chatbot-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; background: #faf9f7; }
-.chatbot-msg { display: flex; }
-.chatbot-msg.user { justify-content: flex-end; }
-.chatbot-msg.bot { justify-content: flex-start; }
-.chatbot-bubble {
-  max-width: 86%; padding: 9px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.55;
-  background: #fff; border: 1px solid var(--border-light); color: var(--text); word-break: break-word;
-}
-.chatbot-msg.user .chatbot-bubble { background: var(--accent); border-color: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
-.chatbot-msg.bot .chatbot-bubble { border-bottom-left-radius: 4px; }
-.chatbot-msg .chatbot-bubble.error { background: var(--fail-bg); border-color: transparent; color: var(--fail); }
-.chatbot-bubble.typing { display: inline-flex; gap: 4px; align-items: center; padding: 12px 14px; }
-.chatbot-bubble.typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); animation: chatblink 1.2s infinite; }
-.chatbot-bubble.typing span:nth-child(2) { animation-delay: .2s; }
-.chatbot-bubble.typing span:nth-child(3) { animation-delay: .4s; }
-@keyframes chatblink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
-.chatbot-chart { width: 100%; height: 220px; margin-top: 8px; border: 1px solid var(--border-light); border-radius: 10px; background: #fff; }
-.chatbot-msg-actions { margin-top: 6px; }
-.chatbot-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 4px; }
-.chatbot-chips button {
+/* ── 智能问数 · 独立页面(副驾式:左会话栏 + 右舞台 + 底部大输入卡) ── */
+#viewAsk.active { display: block; height: 100vh; }
+#viewAsk > main.content-ask { height: 100%; display: flex; flex-direction: column; padding: 14px 18px 0; min-height: 0; }
+.ask-shell { flex: 1; min-height: 0; display: flex; gap: 14px; padding-bottom: 14px; }
+
+/* 左侧会话栏 */
+.ask-side { width: 244px; flex: 0 0 244px; display: flex; flex-direction: column; min-height: 0; background: #fff; border: 1px solid var(--border-light); border-radius: 14px; padding: 12px 10px 10px; }
+.ask-side-head { display: flex; align-items: center; justify-content: space-between; padding: 0 4px 10px; }
+.ask-brand { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: 14.5px; color: var(--text); }
+.ask-brand svg { width: 16px; height: 16px; color: var(--accent); }
+.ask-new-btn { width: 26px; height: 26px; border-radius: 8px; border: 1px solid var(--border-light); background: #fff; color: var(--text-2); font-size: 16px; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
+.ask-new-btn:hover { color: var(--accent); border-color: var(--accent); }
+.ask-search { padding: 0 2px 10px; }
+.ask-search input { width: 100%; box-sizing: border-box; min-height: 32px; padding: 6px 12px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: #faf9f7; font-size: 12px; font-family: inherit; color: var(--text); outline: none; transition: border-color .15s, box-shadow .15s, background .15s; }
+.ask-search input::placeholder { color: var(--text-3); }
+.ask-search input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); background: #fff; }
+.ask-side-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
+.ask-sessions-empty { color: var(--text-3); font-size: var(--font-size-small); text-align: center; padding: 18px 0; }
+.ask-session-item { position: relative; display: flex; flex-direction: column; gap: 2px; padding: 8px 30px 8px 10px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; }
+.ask-session-item:hover { background: #f5f3f0; }
+.ask-session-item.active { background: #fff7f3; border-color: #ffd9c7; }
+.ask-session-title { font-size: 12.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ask-session-preview { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ask-session-del { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); width: 22px; height: 22px; border: none; border-radius: 6px; background: transparent; color: var(--text-3); cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0; }
+.ask-session-del svg { width: 13px; height: 13px; }
+.ask-session-item:hover .ask-session-del { display: inline-flex; }
+.ask-session-del:hover { background: #fdecec; color: var(--fail); }
+
+/* 右侧舞台 */
+.ask-stage { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #fff; border: 1px solid var(--border-light); border-radius: 14px; }
+.ask-stage-head { display: flex; align-items: center; gap: 10px; padding: 14px 18px 2px; }
+.ask-stage-title { font-size: 15px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ask-stage-badge { flex: 0 0 auto; font-size: 11px; color: var(--text-2); background: #f5f3f0; border-radius: var(--radius-pill); padding: 2px 10px; }
+.ask-stage-sub { padding: 2px 18px 12px; font-size: 11.5px; color: var(--text-3); }
+.ask-stream { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; background: #faf9f7; border-radius: 0 0 14px 14px; }
+
+/* 居中空态(给问数一个目标) */
+.ask-hero { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 8px; max-width: 540px; text-align: center; padding: 40px 16px; }
+.ask-hero-title { font-size: 22px; font-weight: 700; color: var(--text); }
+.ask-hero-sub { font-size: 12.5px; color: var(--text-3); line-height: 1.7; }
+.ask-chips { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 12px; }
+.ask-chips button {
   border: 1px solid var(--border); background: #fff; color: var(--text-2);
   font-size: 11px; padding: 4px 10px; border-radius: var(--radius-pill); cursor: pointer;
 }
-.chatbot-chips button:hover { border-color: var(--accent); color: var(--accent); }
-
-.chatbot-input-row { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--border-light); background: #fff; }
-.chatbot-input-row input {
-  flex: 1; min-width: 0; min-height: 36px;
-  padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius-pill);
-  background: #faf9f7; color: var(--text); font-size: 12.5px; font-family: inherit;
-  outline: none; transition: border-color .15s, box-shadow .15s, background .15s;
+.ask-chips button:hover { border-color: var(--accent); color: var(--accent); }
+
+/* 对话流 */
+.ask-run { display: flex; flex-direction: column; gap: 6px; }
+.ask-q {
+  align-self: flex-end; max-width: 86%; padding: 8px 12px; border-radius: 12px; border-bottom-right-radius: 4px;
+  background: var(--accent); color: #fff; font-size: 12.5px; line-height: 1.55; word-break: break-word;
+}
+.ask-card {
+  align-self: stretch; display: flex; flex-direction: column; gap: 8px;
+  background: #fff; border: 1px solid var(--border-light); border-radius: 12px; border-bottom-left-radius: 4px;
+  padding: 12px 14px; font-size: 12.5px; line-height: 1.6; color: var(--text); word-break: break-word;
+}
+.ask-card-error { background: var(--fail-bg); border-color: transparent; color: var(--fail); }
+/* 编排时间线(Worked · N 个工具 + 逐步骤:思考·Turn N / 工具调用) */
+.ask-meta { font-size: 10.5px; color: var(--text-3); letter-spacing: .05em; margin-bottom: 2px; }
+.ask-worked { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 650; color: var(--text); }
+.ask-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); display: inline-block; flex: 0 0 auto; }
+.ask-dot.running { background: var(--accent); animation: askpulse 1.1s ease-in-out infinite; }
+.ask-dot.failed { background: var(--fail); }
+@keyframes askpulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.82); } }
+.ask-steps { display: flex; flex-direction: column; margin-top: 10px; }
+.ask-step { position: relative; margin: 0 0 10px 3px; padding-left: 18px; }
+.ask-step::before { content: ''; position: absolute; left: 3.5px; top: 10px; bottom: -8px; width: 1px; background: var(--border-light); }
+.ask-step:last-child::before { display: none; }
+.ask-step .ask-dot { position: absolute; left: 0; top: 4px; }
+.ask-step-head { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text); }
+.ask-step-head b { font-weight: 650; }
+.ask-step-meta { font-size: 11px; color: var(--text-3); margin-top: 2px; }
+.ask-step-body { margin-top: 4px; color: var(--text-2); font-size: 12px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
+.ask-tool { margin-top: 4px; }
+.ask-tool summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; padding: 2px 0; color: var(--text-2); font-size: 11px; }
+.ask-tool summary::-webkit-details-marker { display: none; }
+.ask-tool summary::before { content: '▸'; font-size: 10px; color: var(--text-3); }
+.ask-tool[open] summary::before { content: '▾'; }
+
+/* verdict 卡(数据体检/层间核对/恒等式等校验结论) */
+.ask-verdicts { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
+.ask-verdict {
+  display: flex; align-items: baseline; gap: 8px; padding: 6px 10px;
+  border: 1px solid var(--border-light); border-left-width: 3px; border-radius: 8px;
+  background: #faf9f7; font-size: 11.5px; line-height: 1.5;
 }
-.chatbot-input-row input::placeholder { color: var(--text-3); }
-.chatbot-input-row input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); background: #fff; }
-.chatbot-input-row .btn {
-  flex: 0 0 auto; width: 36px; min-width: 36px; height: 36px; padding: 0;
-  border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
+.ask-verdict.ok { border-left-color: var(--ok); }
+.ask-verdict.warn { border-left-color: var(--warn, #d97706); }
+.ask-verdict.fail { border-left-color: var(--fail); background: var(--fail-bg, #fdf1f0); }
+.ask-verdict-status { flex: 0 0 auto; font-weight: 700; }
+.ask-verdict.ok .ask-verdict-status { color: var(--ok); }
+.ask-verdict.warn .ask-verdict-status { color: var(--warn, #d97706); }
+.ask-verdict.fail .ask-verdict-status { color: var(--fail); }
+.ask-verdict-check { flex: 0 0 auto; font-weight: 600; color: var(--text); }
+.ask-verdict-detail { color: var(--text-3); min-width: 0; }
+
+/* 数字溯源警示(不阻断) */
+.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-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);
+  white-space: pre-wrap; word-break: break-word; max-height: 260px; overflow-y: auto; font-family: inherit;
 }
-.chatbot-input-row .btn svg { width: 16px; height: 16px; }
+.ask-typing { display: inline-flex; gap: 4px; align-items: center; padding: 4px 0; }
+.ask-typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); animation: askblink 1.2s infinite; }
+.ask-typing span:nth-child(2) { animation-delay: .2s; }
+.ask-typing span:nth-child(3) { animation-delay: .4s; }
+@keyframes askblink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }
+.ask-error { background: var(--fail-bg); color: var(--fail); border-radius: 8px; padding: 8px 10px; }
+.ask-note { color: var(--text-3); font-size: 11.5px; }
+.ask-answer { white-space: pre-wrap; }
+.ask-chart { width: 100%; height: 240px; margin-top: 4px; border: 1px solid var(--border-light); border-radius: 10px; background: #fff; }
+.ask-chart-title { font-size: 11px; color: var(--text-3); text-align: center; margin-top: 4px; }
+.ask-actions { margin-top: 4px; }
+
+/* 底部大输入卡 */
+.ask-composer { margin: 0 20px 16px; padding: 10px 12px 8px; background: #fff; border: 1px solid var(--border); border-radius: 16px; box-shadow: 0 8px 24px rgba(31, 27, 24, .06); transition: border-color .15s, box-shadow .15s; }
+.ask-composer:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus-ring); }
+.ask-composer textarea { width: 100%; box-sizing: border-box; border: none; outline: none; resize: none; background: transparent; font-family: inherit; font-size: 12.5px; line-height: 1.6; color: var(--text); min-height: 22px; max-height: 120px; padding: 2px 4px; }
+.ask-composer textarea::placeholder { color: var(--text-3); }
+.ask-composer-bar { display: flex; align-items: center; gap: 8px; padding-top: 8px; }
+.ask-ent-select { min-height: 28px; max-width: 190px; padding: 0 8px; border: 1px solid var(--border-light); border-radius: var(--radius-pill); background: #faf9f7; font-size: 11.5px; color: var(--text-2); font-family: inherit; }
+.ask-composer-spacer { flex: 1; }
+.ask-send-btn { width: 34px; height: 34px; border: none; border-radius: 50%; cursor: pointer; background: linear-gradient(135deg, var(--accent) 0%, #ff8a5c 100%); color: #fff; display: inline-flex; align-items: center; justify-content: center; box-shadow: 0 6px 16px rgba(255, 107, 53, .35); }
+.ask-send-btn svg { width: 16px; height: 16px; }
+.ask-send-btn:disabled { opacity: .5; cursor: not-allowed; }
+@media (max-width: 900px) { .ask-side { display: none; } }
 
 /* 图标按钮(视觉生成 · 保存弹层) */
 .btn.icon-btn { width: 34px; min-width: 34px; padding: 0; justify-content: center; }

+ 9 - 0
public/tasks.js

@@ -198,6 +198,7 @@
     try {
       if (!state.root) {
         const rootResponse = await fetch('/api/default-root');
+        if (!rootResponse.ok) throw new Error(`默认目录获取失败 HTTP ${rootResponse.status}`);
         const rootData = await rootResponse.json();
         state.root = rootData.root;
       }
@@ -240,4 +241,12 @@
       }, 3000);
     }
   };
+
+  // 离开任务页时清掉轮询(app.js switchView 调用),回页时 shanTasksInit 会重建
+  window.shanTasksSetActive = function (active) {
+    if (!active && state.timer) {
+      clearInterval(state.timer);
+      state.timer = null;
+    }
+  };
 })();

+ 12 - 6
report-html.js

@@ -19,6 +19,12 @@ function esc(v) {
     .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
 }
 
+// JSON 内插进 <script> 时 JSON.stringify 不转义 "/",
+// 店名等外部数据含 "</script>" 会提前闭合脚本标签,必须把 "<" 转成 \u003c
+function jsonForScript(v) {
+  return JSON.stringify(v).replace(/</g, '\\u003c');
+}
+
 function fmtMoney(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
 function fmtInt(v) { return v == null ? '—' : Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); }
 function fmtPct(v) { return v == null || v === '' ? '—' : (Number(v) * 100).toFixed(2) + '%'; }
@@ -113,14 +119,14 @@ function buildReportHtml({ entity, from, to }) {
     tooltip: { trigger: 'axis' },
     legend: { data: ['营业额(元)', '有效订单'] },
     grid: { left: 60, right: 60, top: 40, bottom: 30 },
-    xAxis: { type: 'category', data: ${JSON.stringify(trendDates)}, ...AX },
+    xAxis: { type: 'category', data: ${jsonForScript(trendDates)}, ...AX },
     yAxis: [
       { type: 'value', name: '营业额', ...AX },
       { type: 'value', name: '订单', ...AX }
     ],
     series: [
-      { name: '营业额(元)', type: 'line', smooth: true, data: ${JSON.stringify(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
-      { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${JSON.stringify(trendOrders)}, itemStyle: { color: '#2563eb' } }
+      { name: '营业额(元)', type: 'line', smooth: true, data: ${jsonForScript(trendGross)}, itemStyle: { color: '#ff6b35' }, areaStyle: { color: 'rgba(255,107,53,.08)' } },
+      { name: '有效订单', type: 'line', smooth: true, yAxisIndex: 1, data: ${jsonForScript(trendOrders)}, itemStyle: { color: '#2563eb' } }
     ]
   });
   var rank = echarts.init(document.getElementById('rank'));
@@ -128,8 +134,8 @@ function buildReportHtml({ entity, from, to }) {
     tooltip: { trigger: 'axis' },
     grid: { left: 10, right: 30, top: 10, bottom: 30, containLabel: true },
     xAxis: { type: 'value', ...AX },
-    yAxis: { type: 'category', data: ${JSON.stringify(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
-    series: [{ type: 'bar', data: ${JSON.stringify(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
+    yAxis: { type: 'category', data: ${jsonForScript(topNames)}, axisLabel: { color: '#2b2a28', width: 150, overflow: 'truncate' }, axisLine: { lineStyle: { color: '#e8e4df' } } },
+    series: [{ type: 'bar', data: ${jsonForScript(topGross)}, itemStyle: { color: '#ff6b35', borderRadius: [0, 6, 6, 0] } }]
   });
   window.addEventListener('resize', function () { trend.resize(); rank.resize(); });
 </script>
@@ -137,4 +143,4 @@ function buildReportHtml({ entity, from, to }) {
 </html>`;
 }
 
-module.exports = { buildReportHtml };
+module.exports = { buildReportHtml, jsonForScript };

+ 23 - 1
reports-store.js

@@ -21,7 +21,10 @@
 const fs = require('fs');
 const path = require('path');
 
-const LAKE_DIR = path.join(__dirname, 'data', 'reports');
+// 湖根目录:GENPIC_LAKE_DIR 环境变量可覆盖(smoke 测试隔离用,生产不受影响)
+const LAKE_DIR = process.env.GENPIC_LAKE_DIR
+  ? path.resolve(process.env.GENPIC_LAKE_DIR)
+  : path.join(__dirname, 'data', 'reports');
 
 // ---------- 通用 ----------
 
@@ -128,10 +131,25 @@ function sortRows(a, b) {
     : (a.date < b.date ? -1 : 1);
 }
 
+// 分片解析缓存:数据中台/明细/湖全景每个请求都会 loadRows,mtime+size 未变的
+// 文件直接复用上次解析结果,省掉重复 JSON.parse。写路径(upsert/save/delete)
+// 改动文件后 mtime 变化,缓存自然失效;缓存行对象为共享引用,调用方只读不改。
+const shardCache = new Map(); // 绝对路径 -> { mtimeMs, size, rows }
+
 function readShardRows(file, into) {
+  let stat = null;
+  try { stat = fs.statSync(file); } catch { shardCache.delete(file); return; }
+  const hit = shardCache.get(file);
+  if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
+    for (const r of hit.rows) into.set(rowKey(r), r);
+    return;
+  }
   const data = readJsonSafe(file, null);
   if (data && Array.isArray(data.rows)) {
     for (const r of data.rows) if (r && r.date) into.set(rowKey(r), r);
+    shardCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, rows: data.rows });
+  } else {
+    shardCache.delete(file);
   }
 }
 
@@ -244,6 +262,10 @@ function datesInLake(entityKey) {
 
 function deleteEntityData(entityKey) {
   const dir = path.join(LAKE_DIR, sanitizeKey(entityKey));
+  // 先摘掉该主体的分片缓存,避免重建同名主体时短暂引用已删除的旧行对象
+  for (const f of [...shardCache.keys()]) {
+    if (f.startsWith(dir + path.sep)) shardCache.delete(f);
+  }
   fs.rmSync(dir, { recursive: true, force: true });
 }
 

+ 8 - 1
scripts/diagnose-napos.js

@@ -56,6 +56,7 @@ async function runCase(tag, params) {
     out.msg = r.msg || null;
     out.risk = r.risk || false;
     out.authFail = r.authFail || false;
+    out.shapeWarning = r.shapeWarning || false;
     out.ok = r.ok;
   } catch (e) {
     out.error = e.message || String(e);
@@ -99,7 +100,13 @@ async function runCase(tag, params) {
   report.shopCount = shops.length;
 
   const testDate = /^\d{8}$/.test(process.argv[2] || '') ? process.argv[2] : ymdShift(todayYmd(), -1);
-  const chainId = (report.chains[0] || {}).chainId || '96059480';
+  // 不做硬编码 chainId 兜底:对着陌生主体跑对照实验只会产出误导性报告
+  if (!report.chains.length) {
+    report.conclusion = '组织树没有连锁信息,无法构造连锁对照实验。请确认该主体确有连锁结构后重跑。';
+    finish(report);
+    return;
+  }
+  const chainId = report.chains[0].chainId;
   const picked = [];
   if (/^\d+$/.test(process.argv[3] || '')) {
     const one = shops.find((s) => s.shopId === process.argv[3]);

+ 357 - 2
scripts/smoke.js

@@ -7,7 +7,7 @@
 // /api/image/test、/api/chat/test,也不在数据湖有主体时调用 /api/reports/ask。
 // 用法:node scripts/smoke.js
 
-const { spawn } = require('child_process');
+const { spawn, spawnSync } = require('child_process');
 const net = require('net');
 const http = require('http');
 const os = require('os');
@@ -203,6 +203,14 @@ async function main() {
       const r = await request(port, 'POST', '/api/crawl/stop');
       expect(r.status === 200 && r.json().ok === true, `status=${r.status}`);
     });
+    await check('Origin 防护:跨站写请求 → 403', async () => {
+      const r = await request(port, 'POST', '/api/crawl/stop', undefined, '127.0.0.1', { Origin: 'http://evil.example' });
+      expect(r.status === 403, `status=${r.status}`);
+    });
+    await check('Origin 防护:同源写请求放行', async () => {
+      const r = await request(port, 'POST', '/api/crawl/stop', undefined, '127.0.0.1', { Origin: `http://127.0.0.1:${port}` });
+      expect(r.status === 200, `status=${r.status}`);
+    });
 
     // ---- 生成/任务/历史域 ----
     await check('GET /api/tasks', async () => {
@@ -361,8 +369,89 @@ async function main() {
       expect(t.includes('2026-09-18') && t.includes('营业额'), t);
     });
 
+    // ---- 问数智能体:动作解析/图表钳制/循环侦测(纯函数,不调对话模型) ----
+    const agentLlm = require('../server/agent/llm.js');
+    const agentGuard = require('../server/agent/guard.js');
+    const naposServiceLib = require('../napos-service.js');
+    await check('parseAgentAction → 工具动作/终答/围栏解析', async () => {
+      const a = agentLlm.parseAgentAction('{"thought":"查","action":{"tool":"query_report","args":{"mode":"shop"}}}');
+      expect(a.type === 'tool' && a.tool === 'query_report' && a.args.mode === 'shop', JSON.stringify(a));
+      const f = agentLlm.parseAgentAction('前言```json\n{"thought":"完","final":{"answer":"结论"}}\n```后记');
+      expect(f.type === 'final' && f.final.answer === '结论', JSON.stringify(f));
+    });
+    await check('parseAgentAction → 非法输出全部给出可读拒绝', async () => {
+      const bads = ['不是json', '{"thought":"只有thought"}', '{"final":{"answer":"  "}}', '{"final":"字符串"}'];
+      for (const bad of bads) {
+        let rejected = false;
+        try { agentLlm.parseAgentAction(bad); } catch { rejected = true; }
+        expect(rejected, `未拒绝非法输出:${bad}`);
+      }
+    });
+    await check('clampChart → 指标白名单与非法类型兜底', async () => {
+      expect(agentGuard.clampChart({ type: 'pie', x: 'date', y: ['grossAmt'] }, naposServiceLib) === null, 'pie 应被拒绝');
+      const c = agentGuard.clampChart({ type: 'line', x: 'date', y: ['grossAmt', 'hacked', 'validOrderCnt'] }, naposServiceLib);
+      expect(c && c.type === 'line' && c.x === 'date' && c.y.length === 2 && !c.y.includes('hacked'), JSON.stringify(c));
+    });
+    await check('LoopDetector → 重复先提醒、三犯强制收敛、换参重置', async () => {
+      const d = new agentGuard.LoopDetector();
+      expect(d.record('t', { a: 1 }) === 'ok', '首次应为 ok');
+      expect(d.record('t', { a: 1 }) === 'repeat', '第二次应为 repeat');
+      expect(d.record('t', { a: 1 }) === 'loop', '第三次应为 loop');
+      expect(d.record('t', { a: 2 }) === 'ok', '换参数应重置');
+    });
+    await check('技能 → 清单注入 prompt,点名技能有专属指令', async () => {
+      const skillsLib = require('../server/agent/skills.js');
+      const agentPrompt = require('../server/agent/prompt.js');
+      const base = {
+        svc: naposServiceLib, entityName: '测试主体', shops: [], dateRange: { from: '20260101', to: '20260201' },
+        today: '20260201', toolsText: '- t:测试工具',
+      };
+      const p1 = agentPrompt.buildSystemPrompt({ ...base, skillsText: skillsLib.manifestText() });
+      expect(p1.includes('内置技能') && p1.includes('周期对比分析') && p1.includes('数据体检'), '技能清单未注入');
+      const p2 = agentPrompt.buildSystemPrompt({ ...base, skillsText: skillsLib.manifestText(), pointedSkill: skillsLib.get('data-checkup') });
+      expect(p2.includes('点名技能【数据体检】') && p2.includes('严格按其打法'), '点名技能指令缺失');
+    });
+    await check('数字溯源 → 换算形态可溯源,无中生有被标记', async () => {
+      const prov = require('../server/agent/provenance.js');
+      const steps = [{
+        type: 'tool', ok: true,
+        summary: '本期营业额 10500 元,环比 25%。',
+        data: { deltas: [{ cur: 10500, deltaPct: 0.25 }], curByDate: [{ date: '20261001', grossAmt: 1500 }] },
+      }];
+      const good = prov.checkProvenance('本期营业额 10500 元,环比 +25%;2026-10-01 当天 1,500 元。', steps);
+      expect(good.total > 0 && good.untraced.length === 0, `应全部溯源:${JSON.stringify(good)}`);
+      const bad = prov.checkProvenance('本期营业额 99999 元,环比 +25%。', steps);
+      expect(bad.untraced.length === 1 && bad.untraced[0] === 99999, `编造数字未被标记:${JSON.stringify(bad)}`);
+    });
+
+    // ---- 安全加固回归(纯函数,不落盘、不打外部 API) ----
+    const { createContext } = require('../server/context.js');
+    const { createGenerationService } = require('../server/services/generation.js');
+    await check('buildImagesAndPrompt → 参考图越界被钳制', async () => {
+      const gen = createGenerationService(createContext());
+      const out = gen.buildImagesAndPrompt(os.tmpdir(), '测试提示词', [
+        '../../config.json',       // 相对路径越界
+        '/etc/passwd',             // 绝对路径注入
+        'sub/../ok.png',           // 归一化后仍在目录内的合法形态(文件不存在也会被读取失败跳过)
+      ], {});
+      expect(out.images.length === 0, `越界参考图未被拦截:images=${out.images.length}`);
+    });
+    await check('jsonForScript → </script> 不再原样进入脚本体', async () => {
+      const { jsonForScript } = require('../report-html.js');
+      const out = jsonForScript(['一食堂</script><img src=x onerror=alert(1)>']);
+      expect(!out.includes('</script>') && out.includes('\\u003c'), out);
+    });
+    await check('mergeDefaults → 嵌套默认值不被 config.json 局部对象清掉', async () => {
+      const { mergeDefaults, CONFIG_DEFAULTS } = require('../server/context.js');
+      const merged = mergeDefaults(CONFIG_DEFAULTS, { aitable: { enabled: true, appSecret: 'sec' } });
+      expect(merged.aitable.enabled === true && merged.aitable.appSecret === 'sec', '保存值未生效');
+      expect(merged.aitable.appKey === CONFIG_DEFAULTS.aitable.appKey && !!merged.aitable.baseId && !!merged.aitable.operatorId,
+        `组织级默认被清掉:appKey=${merged.aitable.appKey}`);
+      expect(merged.autoSync.time === CONFIG_DEFAULTS.autoSync.time, 'autoSync 默认被清掉');
+    });
+
     // ---- 静态与路由行为 ----
-    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/analysis-templates.js', '/lake.js', '/style.css', '/vendor/echarts.min.js']) {
+    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/analysis-templates.js', '/lake.js', '/ask.js', '/style.css', '/vendor/echarts.min.js']) {
       await check(`静态 ${p}`, async () => {
         const r = await request(port, 'GET', p);
         expect(r.status === 200, `status=${r.status}`);
@@ -387,6 +476,272 @@ async function main() {
       expect(r.status === 405, `status=${r.status}`);
     });
 
+    // ---- 问数智能体:主服务上只测只读会话接口(绝不在有主体/真模型时发起运行) ----
+    await check('GET /api/agent/sessions(无主体 → 400;有主体 → 200)', async () => {
+      const ent = await request(port, 'GET', '/api/reports/entities');
+      const r = await request(port, 'GET', '/api/agent/sessions');
+      if (ent.json().entities.length > 0) {
+        expect(r.status === 200 && Array.isArray(r.json().sessions), `status=${r.status}`);
+      } else {
+        expect(r.status === 400 && r.json().code === 'ASK_NO_ENTITY', `status=${r.status} code=${r.json().code}`);
+      }
+    });
+
+    // ---- 问数智能体端到端(隔离湖 + NAPOS_AGENT_MOCK 确定性假模型,零网络零真实数据) ----
+    {
+      const fs = require('fs');
+      const tmpLake = fs.mkdtempSync(path.join(os.tmpdir(), 'genpic-lake-'));
+      const tmpAgent = fs.mkdtempSync(path.join(os.tmpdir(), 'genpic-agent-'));
+      const mkRow = (date, scope, ids, m) => ({
+        date, scope,
+        shopId: ids.shopId || '', shopName: ids.shopName || '',
+        chainId: ids.chainId || '', chainName: ids.chainName || '',
+        bizHours: null, abnormalCloseHours: null, goodsSaleAmt: null,
+        syncedAt: new Date().toISOString(),
+        validOrderCnt: m.orders || 0, incomeAmt: m.income || 0, grossAmt: m.gross || 0,
+        expenseAmt: m.expense || 0, expUv: m.uv || 0, orderUserCnt: m.buyers || 0,
+        clkExpRate: m.clk || 0, ordClkRate: 0, perActualPay: 0,
+        actOrdCnt: 0, actSubsidyAmt: 0, shopRoi: 0, rebuyRate30d: 0,
+        // 全字段落库模拟:同行均值/排名与环比差值(peer_benchmark / raw_inspect 用)
+        raw: {
+          gross_amt: m.gross || 0, valid_order_cnt: m.orders || 0,
+          gross_amt_mall_avg: m.peerAvg != null ? m.peerAvg : 800,
+          gross_amt_mall_avg_rank: m.rank != null ? m.rank : 2,
+          valid_order_cnt_diff: m.orders || 0,
+        },
+      });
+      // 健康主体:0924~0930(缺 0927)+ 1001~1007,两店;本期日营业额 1500 / 上期 1400(环比 +25%)
+      const prevDays = ['20260924', '20260925', '20260926', '20260928', '20260929', '20260930'];
+      const curDays = ['20261001', '20261002', '20261003', '20261004', '20261005', '20261006', '20261007'];
+      const healthyRows = [];
+      for (const d of prevDays) {
+        healthyRows.push(mkRow(d, 'shop', { shopId: '101', shopName: '一店', chainId: '9', chainName: '第一连锁' }, { gross: 900, income: 720, expense: 180, orders: 90, uv: 1800, buyers: 100, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'shop', { shopId: '102', shopName: '二店', chainId: '9', chainName: '第一连锁' }, { gross: 500, income: 400, expense: 100, orders: 40, uv: 900, buyers: 45, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'chain', { chainId: '9', chainName: '第一连锁' }, { gross: 1400, income: 1120, expense: 280, orders: 130, uv: 2700, buyers: 145, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'company', { chainId: '-1' }, { gross: 1400, income: 1120, expense: 280, orders: 130, uv: 2700, buyers: 145, clk: 0.08 }));
+      }
+      for (const d of curDays) {
+        healthyRows.push(mkRow(d, 'shop', { shopId: '101', shopName: '一店', chainId: '9', chainName: '第一连锁' }, { gross: 1000, income: 800, expense: 200, orders: 100, uv: 2000, buyers: 110, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'shop', { shopId: '102', shopName: '二店', chainId: '9', chainName: '第一连锁' }, { gross: 500, income: 400, expense: 100, orders: 40, uv: 900, buyers: 45, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'chain', { chainId: '9', chainName: '第一连锁' }, { gross: 1500, income: 1200, expense: 300, orders: 140, uv: 2900, buyers: 155, clk: 0.08 }));
+        healthyRows.push(mkRow(d, 'company', { chainId: '-1' }, { gross: 1500, income: 1200, expense: 300, orders: 140, uv: 2900, buyers: 155, clk: 0.08 }));
+      }
+      // 全零主体:门店行全零而连锁/公司有数(已知 bug 形态,体检必须 fail)
+      const badRows = [];
+      for (const d of ['20261001', '20261002', '20261003']) {
+        badRows.push(mkRow(d, 'shop', { shopId: '201', shopName: '全零店', chainId: '9', chainName: '第一连锁' }, {}));
+        badRows.push(mkRow(d, 'chain', { chainId: '9', chainName: '第一连锁' }, { gross: 1000, income: 800, expense: 200, orders: 100 }));
+        badRows.push(mkRow(d, 'company', { chainId: '-1' }, { gross: 1000, income: 800, expense: 200, orders: 100 }));
+      }
+      const nowIso = new Date().toISOString();
+      fs.writeFileSync(path.join(tmpLake, 'entities.json'), JSON.stringify({ entities: [
+        { key: 'smokeask', firstSeen: nowIso, lastSeen: nowIso, merchantId: 'smokeask', name: '冒烟主体A', chainCount: 1, shopCount: 2,
+          chains: [{ chainId: '9', chainName: '第一连锁' }],
+          shops: [
+            { shopId: '101', shopName: '一店', chainId: '9', chainName: '第一连锁' },
+            { shopId: '102', shopName: '二店', chainId: '9', chainName: '第一连锁' },
+          ] },
+        { key: 'smokebad', firstSeen: nowIso, lastSeen: nowIso, merchantId: 'smokebad', name: '冒烟主体B', chainCount: 1, shopCount: 1,
+          chains: [{ chainId: '9', chainName: '第一连锁' }],
+          shops: [{ shopId: '201', shopName: '全零店', chainId: '9', chainName: '第一连锁' }] },
+      ] }));
+      fs.mkdirSync(path.join(tmpLake, 'smokeask'), { recursive: true });
+      fs.writeFileSync(path.join(tmpLake, 'smokeask', 'daily-202609.json'), JSON.stringify({ version: 2, updated: nowIso, rows: healthyRows.filter((r) => r.date < '20261000') }));
+      fs.writeFileSync(path.join(tmpLake, 'smokeask', 'daily-202610.json'), JSON.stringify({ version: 2, updated: nowIso, rows: healthyRows.filter((r) => r.date >= '20261000') }));
+      fs.mkdirSync(path.join(tmpLake, 'smokebad'), { recursive: true });
+      fs.writeFileSync(path.join(tmpLake, 'smokebad', 'daily-202610.json'), JSON.stringify({ version: 2, updated: nowIso, rows: badRows }));
+
+      const aport = await pickPort();
+      const achild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
+        env: {
+          ...process.env, PORT: String(aport), HOST: '127.0.0.1',
+          GENPIC_LAKE_DIR: tmpLake, GENPIC_AGENT_DIR: tmpAgent, NAPOS_AGENT_MOCK: '1',
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      });
+      let alog = '';
+      achild.stdout.on('data', (c) => { alog += c; });
+      achild.stderr.on('data', (c) => { alog += c; });
+      try {
+        await waitForHealth(aport);
+        const waitRunDone = async (runId, entity, timeoutMs = 10000) => {
+          const start = Date.now();
+          for (;;) {
+            const r = await request(aport, 'GET', `/api/agent/runs/${enc(runId)}?entity=${enc(entity)}`);
+            const run = r.json().run;
+            if (run && run.status !== 'running') return run;
+            if (Date.now() - start > timeoutMs) throw new Error(`运行未在超时内结束 status=${run && run.status}`);
+            await new Promise((res) => setTimeout(res, 200));
+          }
+        };
+        const stepByTool = (run, tool) => (run.steps || []).find((s) => s.type === 'tool' && s.tool === tool);
+        let askSessionId = null;
+        let askRunId = null;
+        const fetchAskRun = async () => {
+          const r = await request(aport, 'GET', `/api/agent/runs/${enc(askRunId)}?entity=${enc('smokeask')}`);
+          return r.json().run;
+        };
+
+        await check('智能体 mock 运行 → 四工具全成 + final + 用量', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '最近营业额趋势如何?', mode: 'agent' });
+          expect(r.status === 200 && r.json().run && r.json().run.runId, `status=${r.status}`);
+          const run = await waitRunDone(r.json().run.runId, 'smokeask');
+          expect(run.status === 'done', `status=${run.status} error=${run.error}`);
+          const tools = (run.steps || []).filter((s) => s.type === 'tool');
+          expect(JSON.stringify(tools.map((s) => s.tool)) === JSON.stringify(['lake_overview', 'query_report', 'compare_periods', 'data_quality', 'build_report']),
+            `工具序列异常:${tools.map((s) => s.tool).join(',')}`);
+          expect(tools.every((s) => s.ok), '存在失败的工具步骤');
+          expect(run.usage.llmCalls === 6 && run.usage.toolCalls === 5, `用量异常 llm=${run.usage.llmCalls} tool=${run.usage.toolCalls}`);
+          expect(run.result && run.result.answer.includes('mock'), JSON.stringify(run.result));
+          expect(run.result.chart && run.result.chart.type === 'line', 'final 缺图表规格');
+          expect(run.result.reportUrl && run.result.reportUrl.includes('/api/reports/report-html'), `缺报告链接:${run.result.reportUrl}`);
+          askSessionId = run.sessionId;
+          askRunId = run.runId;
+        });
+        await check('智能体 mock → compare_periods 环比数字正确(门店对齐 + +25%)', async () => {
+          const run = await fetchAskRun();
+          const cmp = stepByTool(run, 'compare_periods');
+          expect(cmp && cmp.ok && cmp.data, 'compare_periods 步骤缺失');
+          const gross = cmp.data.deltas.find((d) => d.key === 'grossAmt');
+          expect(gross && gross.cur === 10500 && gross.prev === 8400, `环比数字异常 ${JSON.stringify(gross)}`);
+          expect(Math.abs(gross.deltaPct - 0.25) < 1e-9, `变化率异常 ${gross.deltaPct}`);
+          expect(cmp.data.comparableShopCount === 2, `可比门店数异常 ${cmp.data.comparableShopCount}`);
+        });
+        await check('智能体 mock → query_report 按日聚合 13 天', async () => {
+          const run = await fetchAskRun();
+          const q = stepByTool(run, 'query_report');
+          expect(q && q.ok && q.data && q.data.rows.length === 13, `按日行数异常 ${q && q.data && q.data.rows.length}`);
+        });
+        await check('智能体 mock → data_quality 健康主体无 fail 且报缺 1 天', async () => {
+          const run = await fetchAskRun();
+          const dq = stepByTool(run, 'data_quality');
+          expect(dq && dq.ok, 'data_quality 步骤缺失');
+          expect(dq.summary.includes('缺 1/14'), `缺日未识别:${dq.summary}`);
+          expect(!dq.summary.includes('[fail]'), `健康主体误报 fail:${dq.summary}`);
+        });
+        await check('智能体 mock → 全零主体体检必须 fail', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokebad', question: '体检一下', mode: 'agent' });
+          expect(r.status === 200, `status=${r.status}`);
+          const run = await waitRunDone(r.json().run.runId, 'smokebad');
+          expect(run.status === 'done', `status=${run.status} error=${run.error}`);
+          const dq = stepByTool(run, 'data_quality');
+          expect(dq && dq.summary.includes('[fail]') && dq.summary.includes('全零'), dq.summary);
+        });
+        await check('智能体 mock 快问 → 直接终答(同一会话追问)', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '昨天营业额', mode: 'quick', sessionId: askSessionId });
+          expect(r.status === 200, `status=${r.status}`);
+          const run = await waitRunDone(r.json().run.runId, 'smokeask');
+          expect(run.status === 'done' && run.mode === 'quick' && run.result.answer.includes('快问'), JSON.stringify(run.result));
+          expect(run.sessionId === askSessionId, '快问未落入同一会话');
+        });
+        await check('智能体 会话索引 → 标题=首问、运行入册、带最近一问', async () => {
+          const s = await request(aport, 'GET', `/api/agent/sessions?entity=${enc('smokeask')}`);
+          const sessions = s.json().sessions;
+          expect(sessions.length === 1 && sessions[0].title === '最近营业额趋势如何?' && sessions[0].runCount === 2,
+            JSON.stringify(sessions));
+          expect(sessions[0].lastQuestion === '昨天营业额', `lastQuestion 异常:${sessions[0].lastQuestion}`);
+          const runs = await request(aport, 'GET', `/api/agent/runs?entity=${enc('smokeask')}&sessionId=${enc(sessions[0].sessionId)}`);
+          expect(runs.json().runs.length === 2, '会话运行列表数量异常');
+        });
+        await check('智能体 删除会话 → 索引与运行文件清零、再删 404', async () => {
+          const s = await request(aport, 'GET', `/api/agent/sessions?entity=${enc('smokeask')}`);
+          const sid = s.json().sessions[0].sessionId;
+          const del = await request(aport, 'DELETE', `/api/agent/sessions/${enc(sid)}?entity=${enc('smokeask')}`);
+          expect(del.status === 200 && del.json().ok === true, `status=${del.status}`);
+          const after = await request(aport, 'GET', `/api/agent/sessions?entity=${enc('smokeask')}`);
+          expect(after.json().sessions.length === 0, '删除后索引未清空');
+          const del2 = await request(aport, 'DELETE', `/api/agent/sessions/${enc(sid)}?entity=${enc('smokeask')}`);
+          expect(del2.status === 404, `重复删除应 404,实际 ${del2.status}`);
+        });
+        await check('GET /api/agent/skills → 四个内置技能', async () => {
+          const r = await request(aport, 'GET', '/api/agent/skills');
+          const skills = r.json().skills || [];
+          expect(r.status === 200 && skills.length === 4 && skills.every((s) => s.key && s.name), JSON.stringify(skills));
+        });
+        await check('智能体 技能点名 → run.skill 落档且运行完成', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '对比本期与上期', mode: 'agent', skill: 'period-compare' });
+          expect(r.status === 200, `status=${r.status}`);
+          const run = await waitRunDone(r.json().run.runId, 'smokeask');
+          expect(run.status === 'done' && run.skill === 'period-compare', `skill=${run.skill} status=${run.status} error=${run.error}`);
+        });
+        await check('智能体 verdicts 归档 → 校验工具结论入运行记录', async () => {
+          const run = await fetchAskRun();
+          const vs = run.verdicts || [];
+          expect(vs.length >= 5, `verdicts 过少:${vs.length}`);
+          expect(vs.some((v) => v.kind === 'identity') && vs.some((v) => v.kind === 'quality'), `缺 identity/quality verdict:${JSON.stringify(vs.map((v) => v.kind))}`);
+          expect(vs.every((v) => v.id && ['ok', 'warn', 'fail'].includes(v.status)), `verdict 形状异常:${JSON.stringify(vs[0])}`);
+        });
+        await check('智能体 自动分流 → 短问走快问、分析问走深度', async () => {
+          const r1 = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '昨天营业额多少', mode: 'auto' });
+          const run1 = await waitRunDone(r1.json().run.runId, 'smokeask');
+          expect(run1.status === 'done' && run1.mode === 'quick' && run1.autoPicked === true, `短问未分流快问:mode=${run1.mode}`);
+          const r2 = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '最近30天比上一个30天营业额变化多少,主要受哪些门店影响', mode: 'auto' });
+          const run2 = await waitRunDone(r2.json().run.runId, 'smokeask');
+          expect(run2.status === 'done' && run2.mode === 'agent' && run2.autoPicked === true, `分析问未分流深度:mode=${run2.mode}`);
+        });
+        await check('智能体 工具直测(隔离子进程)→ cross_check/peer_benchmark/raw_inspect', async () => {
+          const toolProbe = [
+            `const ROOT = ${JSON.stringify(ROOT)};`,
+            'const svc = require(ROOT + "/napos-service.js");',
+            'const nlq = require(ROOT + "/nl-query.js");',
+            'const cross = require(ROOT + "/server/agent/tools/cross.js");',
+            'const peer = require(ROOT + "/server/agent/tools/peer.js");',
+            'function ctxOf(entity) { const meta = svc.store.getEntity(entity); return { svc, entityKey: entity, meta, shops: meta.shops || [], dateRange: nlq.lakeDateRange(entity, svc) }; }',
+            'const out = {};',
+            'const healthy = ctxOf("smokeask");',
+            'const c1 = cross.crossHandler(healthy, cross.crossSanitize({}, healthy));',
+            'out.healthy = { checked: c1.data.checked, mismatch: c1.data.mismatchCount, severe: c1.data.severe };',
+            'const bad = ctxOf("smokebad");',
+            'const c2 = cross.crossHandler(bad, cross.crossSanitize({}, bad));',
+            'out.bad = { mismatch: c2.data.mismatchCount, severe: c2.data.severe };',
+            'const b1 = peer.benchHandler(healthy, peer.benchSanitize({ field: "gross_amt" }, healthy));',
+            'const bs = b1.data.shops || [];',
+            'out.bench = { shops: bs.length, firstPeer: bs[0] && bs[0].peerAvg, firstRank: bs[0] && bs[0].rankAvg };',
+            'const r1 = peer.rawHandler(healthy, peer.rawSanitize({ date: "20261001", shops: ["一店"] }, healthy));',
+            'out.raw = { found: r1.data.found, matched: (r1.data.matched || []).length, hasRaw: r1.data.hasRaw };',
+            'const entityTool = require(ROOT + "/server/agent/tools/entity.js");',
+            'const e1 = entityTool.entityHandler(healthy, entityTool.entitySanitize({}, healthy));',
+            'out.entity = { entities: (e1.data.entities || []).length, first: (e1.data.entities || [])[0] && (e1.data.entities || [])[0].name, skipped: (e1.data.skipped || []).length };',
+            '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 };',
+            '  console.log(JSON.stringify(out));',
+            '});',
+          ].join('\n');
+          const probe = spawnSync(process.execPath, ['-e', toolProbe], {
+            encoding: 'utf8', timeout: 20000,
+            env: { ...process.env, GENPIC_LAKE_DIR: tmpLake },
+          });
+          expect(probe.status === 0, `探针失败:${(probe.stderr || probe.stdout || '').slice(-300)}`);
+          const out = JSON.parse(probe.stdout.trim().split('\n').pop());
+          expect(out.healthy.checked > 0 && out.healthy.mismatch === 0 && out.healthy.severe === false,
+            `健康主体层间核对异常:${JSON.stringify(out.healthy)}`);
+          expect(out.bad.severe === true && out.bad.mismatch > 0, `全零主体未检出层间不一致:${JSON.stringify(out.bad)}`);
+          expect(out.bench.shops === 2 && out.bench.firstPeer === 800 && out.bench.firstRank === 2,
+            `同行对标异常:${JSON.stringify(out.bench)}`);
+          expect(out.raw.found === true && out.raw.hasRaw === true && out.raw.matched >= 3,
+            `raw 检视异常:${JSON.stringify(out.raw)}`);
+          expect(out.entity.entities === 2 && out.entity.first === '冒烟主体A' && out.entity.skipped === 0,
+            `跨主体对比异常:${JSON.stringify(out.entity)}`);
+          expect(out.cloud.available === false && out.cloud.hasVerdict === true,
+            `云端未启用未优雅降级:${JSON.stringify(out.cloud)}`);
+        });
+        await check('智能体 空问题 → 400 ASK_EMPTY', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: '   ' });
+          expect(r.status === 400 && r.json().code === 'ASK_EMPTY', `status=${r.status} code=${r.json().code}`);
+        });
+        await check('智能体 取消不存在的运行 → 404', async () => {
+          const r = await request(aport, 'POST', '/api/agent/runs/no-such-run/cancel', {});
+          expect(r.status === 404, `status=${r.status}`);
+        });
+      } catch (e) {
+        results.push({ name: '(智能体隔离测试执行中断)', ok: false, error: e.message + (alog ? ` | 服务日志:${alog.slice(-300)}` : '') });
+      } finally {
+        try { achild.kill('SIGKILL'); } catch { /* ignore */ }
+        try { fs.rmSync(tmpLake, { recursive: true, force: true }); fs.rmSync(tmpAgent, { recursive: true, force: true }); } catch { /* ignore */ }
+      }
+    }
+
     // ---- 优雅停机(SIGTERM 后应自行退出) ----
     await check('SIGTERM 优雅停机', async () => {
       process.kill(child.pid, 'SIGTERM');

+ 15 - 4
server.js

@@ -10,7 +10,7 @@ const { execFile } = require('child_process');
 
 const { createContext } = require('./server/context.js');
 const { createRouter } = require('./server/router.js');
-const { makeTokenGuard } = require('./server/middleware.js');
+const { makeTokenGuard, makeOriginGuard } = require('./server/middleware.js');
 const { sendJson } = require('./server/util.js');
 const { createCrawlService } = require('./server/services/crawl.js');
 const { createGenerationService } = require('./server/services/generation.js');
@@ -31,6 +31,8 @@ registerRoutes(app, ctx);
 
 // 局域网访问口令:config.accessToken(或环境变量 ACCESS_TOKEN)非空时启用
 const tokenGuard = makeTokenGuard(ctx.config);
+// 本地 CSRF 防护:带 Origin 的写请求必须同源(浏览器跨站 no-cors POST 被拒)
+const originGuard = makeOriginGuard();
 
 const server = http.createServer((req, res) => {
   const startedAt = Date.now();
@@ -40,6 +42,9 @@ const server = http.createServer((req, res) => {
       console.log(`[http] ${req.method} ${req.url} -> ${res.statusCode} ${ms}ms`);
     }
   });
+  if (!originGuard(req)) {
+    return sendJson(res, 403, { error: '已拒绝跨站写请求(Origin 与服务地址不符)' });
+  }
   try {
     const parsed = new URL(req.url, `http://${req.headers.host}`);
     if (!tokenGuard(req, parsed.pathname, parsed.searchParams)) {
@@ -153,9 +158,15 @@ server.listen(LISTEN_PORT, HOST, () => {
   const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${LISTEN_PORT}`;
   ctx.logs.pushSystemLog(`服务已启动:${url}`, 'success');
   console.log(`智慧运营中台已启动: ${url}`);
-  // "start" is a cmd.exe builtin (not a standalone executable), so it must be run via cmd /c;
-  // the empty "" argument is the window-title placeholder start expects before the URL.
-  execFile('cmd', ['/c', 'start', '', url], () => {});
+  // 启动后自动打开浏览器(按平台选命令;失败静默,不影响服务)
+  try {
+    if (process.platform === 'darwin') execFile('open', [url], () => {});
+    else if (process.platform === 'win32') {
+      // "start" is a cmd.exe builtin (not a standalone executable), so it must be run
+      // via cmd /c; the empty "" argument is the window-title placeholder start expects.
+      execFile('cmd', ['/c', 'start', '', url], () => {});
+    } else execFile('xdg-open', [url], () => {});
+  } catch { /* ignore */ }
 });
 
 // ---------- 优雅停机:Ctrl+C / kill 时先落盘任务与采集状态,再退出 ----------

+ 56 - 0
server/agent/guard.js

@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 护栏
+ * ----------------------------------------------------------------------------
+ * 预算常量(轮次/工具调用/修复次数/墙钟,零配置起步)、图表规格钳制
+ * (复用语义层白名单,防模型捏造指标 key)、循环侦测(重复调用先提醒后强制收敛,
+ * 对齐 ai-fde runtime「回注重复提醒而非硬杀」的 Codex/Claude Code 同款思路)。
+ */
+
+function clampChart(raw, svc) {
+  if (!raw || typeof raw !== 'object') return null;
+  const validKeys = new Set((svc.REPORT_COLUMNS || []).map((c) => c.key));
+  const type = ['bar', 'line'].includes(raw.type) ? raw.type : null;
+  if (!type) return null;
+  const x = raw.x === 'date' ? 'date' : 'shopName';
+  const y = (Array.isArray(raw.y) ? raw.y : []).map(String).filter((k) => validKeys.has(k)).slice(0, 2);
+  return {
+    type,
+    x,
+    y: y.length ? y : ['grossAmt'],
+    title: String(raw.title || '').slice(0, 60),
+  };
+}
+
+const BUDGET = {
+  maxTurns: 10,        // LLM 调用次数上限(含 final)
+  maxToolCalls: 16,    // 工具执行次数上限
+  maxRepairs: 2,       // 解析失败/非法动作的连续回注修复次数
+  llmTimeoutMs: 45000, // 单次对话模型超时(与 nl-query 同量级)
+  runTimeoutMs: 120000, // 单次运行墙钟
+};
+
+/** 循环侦测:规范化(tool + 排序后的参数)做指纹;同调用第 2 次提醒、第 3 次强制收敛 */
+class LoopDetector {
+  constructor() { this.lastKey = null; this.repeatCount = 0; }
+
+  static fingerprint(tool, args) {
+    const norm = {};
+    for (const k of Object.keys(args || {}).sort()) norm[k] = args[k];
+    return tool + '|' + JSON.stringify(norm);
+  }
+
+  record(tool, args) {
+    const key = LoopDetector.fingerprint(tool, args);
+    if (key === this.lastKey) {
+      this.repeatCount += 1;
+      return this.repeatCount >= 2 ? 'loop' : 'repeat';
+    }
+    this.lastKey = key;
+    this.repeatCount = 0;
+    return 'ok';
+  }
+}
+
+module.exports = { BUDGET, clampChart, LoopDetector };

+ 177 - 0
server/agent/index.js

@@ -0,0 +1,177 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 入口与运行注册表
+ * ----------------------------------------------------------------------------
+ * startRun 立即返回运行记录(前端轮询),循环异步执行;成败都折叠进 rec,
+ * 绝不向上抛(对齐 ai-fde 委派网关「永不抛出」的契约)。
+ * 双模式:quick = 复用 nl-query 两段式(省预算);agent = harness 循环(默认)。
+ * 运行态在内存(仿 syncState),终态落盘 data/agent/(trace.js)。
+ */
+
+const trace = require('./trace.js');
+const loop = require('./loop.js');
+const skills = require('./skills.js');
+const { mockReply } = require('./mock-model.js');
+const { pickRow } = require('./tools/lake.js');
+const nlQuery = require('../../nl-query.js');
+
+const runs = new Map(); // runId → rec
+const MAX_CACHED = 60;
+
+function isMock() { return process.env.NAPOS_AGENT_MOCK === '1'; }
+
+/** 快问自动分流:短问句且不含分析类词 → 快问(nl-query 两段式),否则深度分析 */
+function pickMode(q) {
+  if (String(q || '').length > 18) return 'agent';
+  if (/(为什么|怎么|为何|对比|环比|同比|变化|体检|对标|排名|归因|分析|趋势|影响|跨|哪家|哪些|问题)/.test(String(q || ''))) return 'agent';
+  return 'quick';
+}
+
+function assertChatCfg(cfg) {
+  for (const [k, label] of [['chatApiBase', '对话模型地址'], ['chatApiKey', '对话模型 Key'], ['chatModel', '对话模型']]) {
+    if (!cfg[k]) {
+      const e = new Error(`对话模型未配置(缺少${label}):请到「系统设置 → API 设置 → 对话模型」选择模型后点「保存设置」`);
+      e.code = 'ASK_NO_MODEL';
+      throw e;
+    }
+  }
+}
+
+function trimCache() {
+  if (runs.size <= MAX_CACHED) return;
+  for (const [id, rec] of runs) {
+    if (rec.status !== 'running') runs.delete(id);
+    if (runs.size <= MAX_CACHED) break;
+  }
+}
+
+/**
+ * 发起一次运行并立即返回记录。
+ * @param {object} o { entity, question, mode: 'agent'|'quick', sessionId, cfg, svc }
+ */
+function startRun(o) {
+  const { cfg, svc } = o;
+  const q = String(o.question || '').trim();
+  if (!q) { const e = new Error('请输入问题'); e.code = 'ASK_EMPTY'; throw e; }
+  if (!isMock()) assertChatCfg(cfg);
+  const entity = trace.sanitizeKey(o.entity);
+  if (!svc.store.getEntity(entity)) {
+    const e = new Error('数据主体不存在,请刷新页面重试');
+    e.code = 'ASK_NO_ENTITY';
+    throw e;
+  }
+  const rec = trace.newRunRecord({
+    entity,
+    entityName: (svc.store.getEntity(entity) || {}).name,
+    question: q,
+    mode: o.mode,
+    sessionId: o.sessionId ? trace.sanitizeKey(o.sessionId) : undefined,
+  });
+  const skill = skills.get(o.skill);
+  if (skill) rec.skill = skill.key;
+  // 自动分流:mode='auto'(前端默认档)按规则选快问/深度分析
+  if (o.mode === 'auto') {
+    rec.mode = pickMode(q);
+    rec.autoPicked = true;
+  }
+  runs.set(rec.runId, rec);
+  trimCache();
+  trace.upsertSessionRun(entity, {
+    sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: 'running', at: rec.startedAt,
+  });
+
+  // 异步执行:路由已返回,前端轮询 GET /api/agent/runs/:id
+  (async () => {
+    try {
+      if (isMock()) {
+        // mock 模式:quick/agent 都走脚本化假模型(快问=直接 final);auto 已折算进 rec.mode
+        rec.result = rec.mode === 'quick'
+          ? { answer: '(mock 模型)快问链路正常。', chart: null, appliedFrom: null, thought: '' }
+          : await loop.runAgent({ rec, svc, cfg, mock: { next: (r) => mockReply(r) } });
+      } else if (rec.mode === 'quick') {
+        const history = trace.loadRecentConclusions(entity, rec.sessionId);
+        const result = await nlQuery.ask({ q, entity, cfg, svc, history });
+        rec.result = {
+          answer: result.reply,
+          chart: result.chart && result.chart.type !== 'none' ? result.chart : null,
+          appliedFrom: result.applied || null,
+          thought: '',
+          // 快问同样可一键打开当期 HTML 分析报告(区间取本次查询钳制后的区间)
+          reportUrl: result.applied && result.applied.from && result.applied.to
+            ? `/api/reports/report-html?${new URLSearchParams({ entity, from: result.applied.from, to: result.applied.to }).toString()}`
+            : null,
+          // 图表数据源(快问不走工具步骤,行随结果携带;去 raw 控体积)
+          chartRows: (result.rows || []).slice(0, 200).map(pickRow),
+        };
+      } else {
+        rec.history = trace.loadRecentConclusions(entity, rec.sessionId);
+        rec.result = await loop.runAgent({ rec, svc, cfg, mock: null, cloud: o.cloud || null });
+      }
+      rec.steps.push({ type: 'final', answer: rec.result.answer });
+      rec.status = 'done';
+    } catch (e) {
+      if (e && e.code === 'AGENT_CANCELLED') rec.status = 'cancelled';
+      else {
+        rec.status = 'failed';
+        rec.error = String((e && e.message) || e).slice(0, 500);
+      }
+    } finally {
+      rec.finishedAt = new Date().toISOString();
+      rec.cancelRequested = false;
+      trace.pushEvent(rec, 'run.end', { status: rec.status, error: rec.error });
+      trace.persistRun(rec);
+      trace.upsertSessionRun(entity, {
+        sessionId: rec.sessionId, runId: rec.runId, question: q, mode: rec.mode, status: rec.status, at: rec.finishedAt,
+      });
+    }
+  })();
+
+  return rec;
+}
+
+function cancelRun(runId) {
+  const rec = runs.get(String(runId || ''));
+  if (rec && rec.status === 'running') rec.cancelRequested = true;
+  return rec || null;
+}
+
+/** 内存优先,落盘兜底(服务重启后仍可回放历史运行) */
+function getRun(entity, runId) {
+  const key = trace.sanitizeKey(entity);
+  const id = String(runId || '').replace(/[^a-zA-Z0-9_-]/g, '');
+  const rec = runs.get(id);
+  if (rec && rec.entity === key) return rec;
+  const saved = trace.loadRun(key, id);
+  return saved && saved.entity === key ? saved : null;
+}
+
+function listRuns(entity, sessionId, limit = 20) {
+  const key = trace.sanitizeKey(entity);
+  const session = trace.loadSessions(key).find((s) => s.sessionId === sessionId);
+  return session ? session.runs.slice(0, limit) : [];
+}
+
+function listSessions(entity) {
+  const key = trace.sanitizeKey(entity);
+  return trace.loadSessions(key).map((s) => {
+    const last = (s.runs || [])[0] || {};
+    return {
+      sessionId: s.sessionId,
+      title: s.title,
+      createdAt: s.createdAt,
+      updatedAt: s.updatedAt,
+      runCount: (s.runs || []).length,
+      lastQuestion: last.question || '',
+      lastAt: last.at || s.updatedAt || s.createdAt || null,
+    };
+  });
+}
+
+function deleteSession(entity, sessionId) {
+  const key = trace.sanitizeKey(entity);
+  const id = String(sessionId || '').replace(/[^a-zA-Z0-9_-]/g, '');
+  return trace.deleteSession(key, id);
+}
+
+module.exports = { startRun, cancelRun, getRun, listRuns, listSessions, deleteSession };

+ 121 - 0
server/agent/llm.js

@@ -0,0 +1,121 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 对话模型通道
+ * ----------------------------------------------------------------------------
+ * OpenAI 兼容 /v1/chat/completions 手写 HTTP(零依赖纪律,与 nl-query 同款)。
+ * chatCompletion / extractJson 自 nl-query.js 抽出共用(nl-query 改为 require 本模块)。
+ *
+ * parseAgentAction 是 harness 的输入闸门:模型每回合只允许输出一个 JSON 对象,
+ * 二选一 —— 调工具 {"thought","action":{tool,args}} 或收敛 {"thought","final":{answer,…}}。
+ * 解析失败抛带可读 reason 的错误,供主循环回注模型自修复。
+ */
+
+const https = require('https');
+const http = require('http');
+
+function chatCompletion({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
+  return chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs, temperature }).then((out) => out.content);
+}
+
+/**
+ * 同 chatCompletion,但返回 { content, usage }(usage 来自 OpenAI 兼容响应的
+ * usage 字段:prompt_tokens / completion_tokens;网关不给时为 null)。
+ * 智能体主循环用它记录每回合的 token 用量(编排时间线展示)。
+ */
+function chatCompletionDetailed({ apiBase, apiKey, model, messages, timeoutMs = 45000, temperature = 0 }) {
+  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 options = {
+      hostname: base.hostname,
+      port: base.port || 443,
+      path: (base.pathname.replace(/\/+$/, '') || '') + '/v1/chat/completions',
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${apiKey}`,
+        'Content-Type': 'application/json; charset=utf-8',
+        'Content-Length': Buffer.byteLength(body),
+      },
+      timeout: timeoutMs,
+    };
+    const req = (base.protocol === 'http:' ? http : https).request(options, (res) => {
+      const chunks = [];
+      res.on('data', (c) => chunks.push(c));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        let json = null;
+        try { json = JSON.parse(raw); } catch { /* 保留 raw */ }
+        if (res.statusCode !== 200) {
+          const msg = (json && json.error && json.error.message) || raw.slice(0, 200) || `HTTP ${res.statusCode}`;
+          return reject(new Error(`对话模型请求失败: ${msg}`));
+        }
+        const content = json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content;
+        if (!content) return reject(new Error('对话模型返回为空'));
+        const u = (json && json.usage) || null;
+        resolve({
+          content,
+          usage: u ? {
+            promptTokens: Number(u.prompt_tokens) || null,
+            completionTokens: Number(u.completion_tokens) || null,
+          } : null,
+        });
+      });
+    });
+    req.on('error', reject);
+    req.on('timeout', () => req.destroy(new Error('对话模型请求超时')));
+    req.write(body);
+    req.end();
+  });
+}
+
+/** 从模型输出中稳健地提取第一个 JSON 对象(支持 ```json 围栏与前后杂讯) */
+function extractJson(text) {
+  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
+  const candidate = fenced ? fenced[1] : text;
+  const start = candidate.indexOf('{');
+  const end = candidate.lastIndexOf('}');
+  if (start === -1 || end <= start) throw new Error('模型未返回 JSON');
+  return JSON.parse(candidate.slice(start, end + 1));
+}
+
+/**
+ * 解析一回合模型输出为结构化动作。
+ * 返回 { type:'tool', thought, tool, args } 或 { type:'final', thought, final }。
+ * chart / applied 只收编形状,指标白名单等业务钳制在 guard / 工具层做。
+ */
+function parseAgentAction(text) {
+  let obj;
+  try {
+    obj = extractJson(text);
+  } catch (e) {
+    throw new Error('输出不是可解析的 JSON 对象:' + (e.message || e));
+  }
+  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('输出不是 JSON 对象');
+  const thought = String(obj.thought || '').slice(0, 500);
+  if (obj.final && typeof obj.final === 'object' && !Array.isArray(obj.final)) {
+    const answer = String(obj.final.answer || '').trim();
+    if (!answer) throw new Error('final.answer 不能为空');
+    const final = { answer: answer.slice(0, 4000) };
+    if (obj.final.chart && typeof obj.final.chart === 'object') {
+      final.chart = {
+        type: obj.final.chart.type,
+        x: obj.final.chart.x,
+        y: Array.isArray(obj.final.chart.y) ? obj.final.chart.y : [],
+        title: obj.final.chart.title,
+      };
+    }
+    return { type: 'final', thought, final };
+  }
+  if (obj.action && typeof obj.action === 'object' && !Array.isArray(obj.action)) {
+    const tool = String(obj.action.tool || '').trim();
+    if (!tool) throw new Error('action.tool 不能为空');
+    const rawArgs = obj.action.args;
+    const args = (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) ? rawArgs : {};
+    return { type: 'tool', thought, tool: tool.slice(0, 64), args };
+  }
+  throw new Error('输出缺少 action 或 final:每回合必须二选一');
+}
+
+module.exports = { chatCompletion, chatCompletionDetailed, extractJson, parseAgentAction };

+ 239 - 0
server/agent/loop.js

@@ -0,0 +1,239 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 主循环(harness 核心)
+ * ----------------------------------------------------------------------------
+ * 每回合:LLM → parseAgentAction → 护栏(求交/钳制/预算/循环侦测)→ 工具执行
+ * → 观察回注 → …,直到模型给出 final 或触发预算强制收敛。
+ * 消息形状保持严格 user/assistant 交替;工具观察以 [工具结果] 文本回注(digest
+ * 已在工具内裁剪)。取消在回合间隙检查(进行中的 HTTP 调用不可中断,属已知边界)。
+ */
+
+const llm = require('./llm.js');
+const guard = require('./guard.js');
+const prompt = require('./prompt.js');
+const registry = require('./tools/registry.js');
+const skills = require('./skills.js');
+const provenance = require('./provenance.js');
+const trace = require('./trace.js');
+const nlQuery = require('../../nl-query.js');
+
+const z2 = (n) => String(n).padStart(2, '0');
+
+function cancelledError() {
+  const e = new Error('已取消');
+  e.code = 'AGENT_CANCELLED';
+  return e;
+}
+
+/** 「应用到报表」载荷:取最后一次成功查询的钳制后参数,前端据此落报表筛选 */
+function appliedFrom(tool, args) {
+  if (!args) return null;
+  if (tool === 'query_report') {
+    return { mode: args.mode, from: args.from, to: args.to, shopIds: args.shopIds || [], metrics: args.metrics };
+  }
+  if (tool === 'compare_periods') {
+    return { mode: 'shop', from: args.from, to: args.to, shopIds: args.shops || [], metrics: args.metrics };
+  }
+  return null;
+}
+
+function fallbackAnswer(rec, reason) {
+  const lastOk = [...rec.steps].reverse().find((s) => s.type === 'tool' && s.ok);
+  if (lastOk && lastOk.summary) {
+    return `(${reason},未生成完整结论)最后一步取数结果:\n\n${String(lastOk.summary).slice(0, 800)}`;
+  }
+  return `(${reason})本次未能完成分析,请重试或换一个更具体的问题。`;
+}
+
+/**
+ * 运行一次智能体回合循环。
+ * @param {object} o { rec, svc, cfg: {chatApiBase,chatApiKey,chatModel}, mock: {next(rec)}|null }
+ * @returns {object} { answer, chart, appliedFrom, thought }
+ */
+async function runAgent(o) {
+  const { rec, svc, cfg, mock } = o;
+  const meta = svc.store.getEntity(rec.entity) || {};
+  const shops = meta.shops || [];
+  const dateRange = nlQuery.lakeDateRange(rec.entity, svc);
+  const ctx = { svc, entityKey: rec.entity, meta, shops, dateRange, cloud: o.cloud || null };
+  const today = (() => {
+    const d = new Date();
+    return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
+  })();
+
+  const messages = [{
+    role: 'system',
+    content: prompt.buildSystemPrompt({
+      svc, entityName: meta.name || rec.entity, shops, dateRange, today,
+      toolsText: registry.manifestText(),
+      skillsText: skills.manifestText(),
+      pointedSkill: rec.skill ? skills.get(rec.skill) : null,
+    }),
+  }];
+  for (const h of rec.history || []) messages.push(h);
+  messages.push({ role: 'user', content: rec.question });
+
+  const startedAt = Date.now();
+  let repairs = 0;
+  let repeatWarned = false;
+  let lastApplied = null;
+  let lastReportUrl = null;
+  const detector = new guard.LoopDetector();
+
+  const callModel = async (msgs) => {
+    rec.usage.llmCalls += 1;
+    trace.pushEvent(rec, 'llm.call', { turns: msgs.length, mock: !!mock });
+    const t0 = Date.now();
+    if (mock) {
+      return { reply: mock.next(rec), usage: null, ms: Date.now() - t0 };
+    }
+    const out = await llm.chatCompletionDetailed({
+      apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel,
+      messages: msgs, timeoutMs: guard.BUDGET.llmTimeoutMs, temperature: 0.2,
+    });
+    return { reply: out.content, usage: out.usage, ms: Date.now() - t0 };
+  };
+
+  const finishWith = (parsed) => {
+    // 数字溯源 post-check(零 token 规则):回答中的数值必须能在工具结果里找到出处
+    const prov = provenance.checkProvenance(parsed.final.answer, rec.steps);
+    rec.provenance = { total: prov.total, untracedCount: prov.untraced.length, untraced: prov.untraced, ratio: prov.ratio };
+    return {
+      answer: parsed.final.answer,
+      chart: guard.clampChart(parsed.final.chart, svc),
+      appliedFrom: lastApplied,
+      reportUrl: lastReportUrl,
+      thought: parsed.thought || '',
+    };
+  };
+
+  const forceFinal = async (reason) => {
+    messages.push({ role: 'user', content: `${reason}:请立即基于以上已有信息输出 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, reason), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
+  };
+
+  while (true) {
+    if (rec.cancelRequested) throw cancelledError();
+
+    // 预算(轮次/工具次数/墙钟)触发强制收敛:给模型最后一次输出 final 的机会
+    if (rec.usage.llmCalls >= guard.BUDGET.maxTurns
+      || rec.usage.toolCalls >= guard.BUDGET.maxToolCalls
+      || Date.now() - startedAt >= guard.BUDGET.runTimeoutMs) {
+      return forceFinal('已达预算上限');
+    }
+
+    let reply;
+    let turnMeta = null;
+    try {
+      turnMeta = await callModel(messages);
+      reply = turnMeta.reply;
+    } catch (e) {
+      throw new Error('对话模型调用失败:' + (e.message || e));
+    }
+
+    let parsed;
+    try {
+      parsed = llm.parseAgentAction(reply);
+      repairs = 0;
+    } catch (e) {
+      if (repairs >= guard.BUDGET.maxRepairs) throw new Error('模型连续输出无法解析:' + (e.message || e));
+      repairs += 1;
+      trace.pushEvent(rec, 'notice', { text: '解析失败,回注修复' });
+      messages.push({ role: 'assistant', content: String(reply).slice(0, 2000) });
+      messages.push({ role: 'user', content: `你的输出无法解析(${e.message})。请重新输出:只输出一个 JSON 对象,action 或 final 二选一。` });
+      continue;
+    }
+
+    rec.steps.push({
+      type: 'thought',
+      turn: rec.usage.llmCalls,
+      text: parsed.thought,
+      ms: turnMeta.ms,
+      tokensIn: turnMeta.usage ? turnMeta.usage.promptTokens : null,
+      tokensOut: turnMeta.usage ? turnMeta.usage.completionTokens : null,
+    });
+    messages.push({ role: 'assistant', content: String(reply).slice(0, 4000) });
+
+    if (parsed.type === 'final') {
+      return finishWith(parsed);
+    }
+
+    // 工具名与清单求交(防幻觉工具名)
+    const tool = registry.get(parsed.tool);
+    if (!tool) {
+      if (repairs >= guard.BUDGET.maxRepairs) throw new Error(`模型连续引用不存在的工具(${parsed.tool})`);
+      repairs += 1;
+      trace.pushEvent(rec, 'notice', { text: `未知工具 ${parsed.tool},回注清单` });
+      messages.push({
+        role: 'user',
+        content: `工具「${parsed.tool}」不存在。可用工具:\n${registry.manifestText()}\n请重新输出动作 JSON。`,
+      });
+      continue;
+    }
+
+    // 参数钳制(sanitize 抛错 = 参数非法,回注让模型修正)
+    let args;
+    try {
+      args = tool.sanitize(parsed.args, ctx);
+    } catch (e) {
+      if (repairs >= guard.BUDGET.maxRepairs) throw new Error(`工具参数连续非法(${parsed.tool}):${e.message || e}`);
+      repairs += 1;
+      trace.pushEvent(rec, 'notice', { text: `${parsed.tool} 参数非法,回注修复` });
+      messages.push({ role: 'user', content: `工具「${parsed.tool}」参数非法:${e.message || e}。请修正后重新输出动作 JSON。` });
+      continue;
+    }
+
+    // 循环侦测:完全相同的调用先提醒,再犯强制收敛
+    const verdict = detector.record(parsed.tool, args);
+    if (verdict === 'loop') {
+      if (repeatWarned) {
+        trace.pushEvent(rec, 'notice', { text: '重复调用循环,强制收敛' });
+        return { answer: fallbackAnswer(rec, '检测到重复调用'), chart: null, appliedFrom: lastApplied, reportUrl: lastReportUrl, thought: '' };
+      }
+      repeatWarned = true;
+      messages.push({ role: 'user', content: '提醒:你已连续多次发出完全相同的调用。若信息已足够请立即输出 final;确需重试请更换参数。' });
+      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 });
+    }
+  }
+}
+
+module.exports = { runAgent };

+ 27 - 0
server/agent/mock-model.js

@@ -0,0 +1,27 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 确定性假模型(NAPOS_AGENT_MOCK=1 时启用)
+ * ----------------------------------------------------------------------------
+ * 按已完成的工具步骤输出脚本化动作,零网络跑通 loop→guard→工具→final 全链路,
+ * 供 smoke 回归锁定行为(smoke 红线:绝不发真实 LLM 请求)。
+ */
+
+function mockReply(rec) {
+  const done = new Set(rec.steps.filter((s) => s.type === 'tool' && s.ok).map((s) => s.tool));
+  const act = (tool, args, thought) => JSON.stringify({ thought, action: { tool, args } });
+  if (!done.has('lake_overview')) return act('lake_overview', {}, '(mock)先摸底湖内有什么数据');
+  if (!done.has('query_report')) return act('query_report', { mode: 'date', metrics: ['grossAmt', 'validOrderCnt'] }, '(mock)看近期按日趋势');
+  if (!done.has('compare_periods')) return act('compare_periods', {}, '(mock)做环比对比');
+  if (!done.has('data_quality')) return act('data_quality', {}, '(mock)做数据体检');
+  if (!done.has('build_report')) return act('build_report', {}, '(mock)生成分析报告入口');
+  return JSON.stringify({
+    thought: '(mock)信息已足够',
+    final: {
+      answer: '(mock 模型)已完成 lake_overview → query_report → compare_periods → data_quality → build_report 五步演练,链路正常。',
+      chart: { type: 'line', x: 'date', y: ['grossAmt'], title: 'mock 趋势' },
+    },
+  });
+}
+
+module.exports = { mockReply };

+ 61 - 0
server/agent/prompt.js

@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 系统提示装配
+ * ----------------------------------------------------------------------------
+ * 角色 + 数据边界(今天/T-1/湖内区间)+ 语义层(napos-service 列注册表)
+ * + 工具清单(registry 提供)+ 数据纪律 + 输出契约(JSON 动作协议)。
+ * 与 nl-query 的两段式 prompt 同源不同用:这里是循环式多步取数的合约。
+ */
+
+function semanticModelText(svc) {
+  return (svc.REPORT_COLUMNS || [])
+    .filter((c) => c.key !== 'date' && c.key !== 'shopName' && c.key !== 'account')
+    .map((c) => {
+      const syn = (c.synonyms || []).join('/');
+      const desc = c.description || '';
+      return `- ${c.key} | ${c.label} | 同义词:${syn} | ${desc}${c.reserved ? ' | ⚠️预留列,当前无数据,不要用于查询' : ''}`;
+    }).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');
+  const pointed = pointedSkill ? `\n本次用户点名技能【${pointedSkill.name}】:请严格按其打法组织工具调用与结论。` : '';
+  return `你是「淘宝闪购门店经营数据」的数据分析智能体。围绕用户的问题多步取数与校验后作答:先弄清有什么数据,再取数,必要时交叉验证,最后给带真实数字的中文结论。
+
+## 今天与数据边界
+今天 ${today}(YYYYMMDD)。湖内可用区间 ${dateRange.from} ~ ${dateRange.to}(区间外无数据;当天未结算,查询截止日默认取昨天)。
+当前主体:${entityName}。
+
+## 指标语义(key | 名称 | 同义词 | 口径)
+${semanticModelText(svc)}
+
+## 门店清单(shopId|shopName|连锁)
+${shopLines || '(暂无门店清单,shops 留空)'}
+
+## 可用工具(每次只调一个)
+${toolsText}
+
+## 内置技能(打法参考;用户可能点名,你判断适用时也应遵循)
+${skillsText}${pointed}
+
+## 数据纪律(违反即错)
+- 一切数字必须来自工具返回,禁止心算合计/平均/编造;同比环比一律用 compare_periods,不要自己拿两次查询相减。
+- 比率指标(进店转化率/下单转化率/复购率)工具已按曝光或进店人数加权,不要对比率再做算术平均。
+- 营业额 = 收入 + 支出(恒等式);预留列(营业时长/异常关店时长/商品销售额)当前无数据,被问到时如实说明。
+- 数据可能存在质量问题(如门店明细全零):data_quality 的结论要如实转述,相关数字标注「数据质量存疑」。
+- 回答里每个关键数字都注明出处(如「据 query_report」「据 compare_periods」)。
+
+## 输出契约(每回合只输出一个 JSON 对象,禁止输出 JSON 之外的文字)
+调用工具:
+{"thought":"一句话说明这步做什么","action":{"tool":"工具名","args":{…}}}
+给出最终回答:
+{"thought":"一句话总结依据","final":{"answer":"markdown 中文回答,直接回应问题","chart":{"type":"bar或line","x":"shopName或date","y":["指标key"],"title":"图表标题"},"applied":null}}
+规则:
+- 每回合二选一:要么 action 要么 final;觉得信息够了立即 final,不要为调工具而调工具。
+- chart 可省略(type:"none" 同效)。图表数据取自你最近一次查询产物:x=date 需先按日(mode=date)查询,x=shopName 需先按门店(mode=shop)查询。
+- 「应用到报表」由系统根据你最后一次查询自动附带,无需在 final 里提供。`;
+}
+
+module.exports = { buildSystemPrompt, semanticModelText };

+ 83 - 0
server/agent/provenance.js

@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 数字溯源 post-check
+ * ----------------------------------------------------------------------------
+ * final 返回后的零 token 规则校验(turn-assessment 同哲学):从回答文本抽取数值,
+ * 逐一在本次运行工具结果的数值集合中做容差匹配;匹配不到的记为「未溯源」并随
+ * 运行留痕展示(警示不阻断)。换算形态自动覆盖:百分比显示值(8.21 vs 0.0821)、
+ * 万元/亿元换算(4.2万 vs 42000)。日期、TopN、题干序号等模式先行剔除降噪。
+ */
+
+function stripNoise(text) {
+  return String(text || '')
+    .replace(/\d{4}-\d{1,2}-\d{1,2}/g, ' ')   // 2026-10-01
+    .replace(/\b\d{8}\b/g, ' ')                // 20261001
+    .replace(/top\s*\d+/gi, ' ')               // Top5
+    .replace(/turn\s*\d+/gi, ' ')              // Turn 2
+    .replace(/v\d+/gi, ' ');                   // verdict 引用 v3
+}
+
+function extractNumbers(text) {
+  const out = [];
+  const re = /-?\d+(?:,\d{3})*(?:\.\d+)?/g;
+  const cleaned = stripNoise(text);
+  let m;
+  while ((m = re.exec(cleaned))) {
+    const n = Number(m[0].replace(/,/g, ''));
+    if (Number.isFinite(n)) out.push(n);
+  }
+  return out;
+}
+
+/** 递归收集工具产物里的数值(深度/广度有界,防大产物拖慢) */
+function collectFromData(data, into, depth = 0) {
+  if (data == null || typeof data !== 'object' || depth > 4) return;
+  if (Array.isArray(data)) {
+    for (const item of data.slice(0, 60)) collectFromData(item, into, depth + 1);
+    return;
+  }
+  for (const v of Object.values(data)) {
+    if (typeof v === 'number' && Number.isFinite(v)) into.push(v);
+    else if (v && typeof v === 'object') collectFromData(v, into, depth + 1);
+  }
+}
+
+function collectCandidates(steps) {
+  const cands = [];
+  for (const step of steps || []) {
+    if (step.type !== 'tool' || !step.ok) continue;
+    collectFromData(step.data, cands);
+    for (const n of extractNumbers(step.summary || '')) cands.push(n);
+  }
+  return cands;
+}
+
+function matches(cands, n) {
+  return cands.some((c) => {
+    if (Math.abs(c - n) <= Math.max(Math.abs(c) * 0.005, 0.01)) return true;
+    // 展示换算:百分比(×100)、万元(×1e4)、亿元(×1e8),以及反向
+    for (const k of [100, 1e4, 1e8]) {
+      if (Math.abs(c - n * k) <= Math.max(Math.abs(c) * 0.005, 0.01)) return true;
+      if (Math.abs(c * k - n) <= Math.max(Math.abs(n) * 0.005, 0.01)) return true;
+    }
+    return false;
+  });
+}
+
+/**
+ * @returns {{total:number, untraced:number[], ratio:number}}
+ */
+function checkProvenance(answer, steps) {
+  const nums = extractNumbers(answer);
+  if (!nums.length) return { total: 0, untraced: [], ratio: 1 };
+  const cands = collectCandidates(steps);
+  if (!cands.length) return { total: nums.length, untraced: nums.slice(0, 12), ratio: 0 };
+  const untraced = [];
+  for (const n of nums) {
+    if (!matches(cands, n)) untraced.push(n);
+  }
+  return { total: nums.length, untraced: untraced.slice(0, 12), ratio: nums.length ? 1 - untraced.length / nums.length : 1 };
+}
+
+module.exports = { extractNumbers, collectCandidates, checkProvenance };

+ 47 - 0
server/agent/skills.js

@@ -0,0 +1,47 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 内置技能(skills)
+ * ----------------------------------------------------------------------------
+ * 技能 = 注入系统提示的分析打法(playbook),不是可执行工具;工具是动作,技能是
+ * 「何时调哪些工具、按什么顺序、结论必答什么」的打法约束。用户可在问数页点名技能
+ * (rec.skill),模型也会在适用时自行遵循(全部技能的打法都常驻提示词,体积小)。
+ */
+
+const SKILLS = [
+  {
+    key: 'period-compare',
+    name: '周期对比分析',
+    desc: '本期 vs 基期全流程对比,归因到门店',
+    playbook: '①lake_overview 摸底确认区间可用;②compare_periods 取本期 vs 基期加权 delta(不要自己相减);③对异常指标用 cross_check 抽查层间一致性;④结论必答「变化量 / 变化率 / 主因门店」,配趋势或对比图(x=date 需先按日查询)。',
+  },
+  {
+    key: 'data-checkup',
+    name: '数据体检',
+    desc: '全面体检数据质量并分级给结论',
+    playbook: '①lake_overview 摸底;②data_quality 全区间体检;③cross_check 全区间层间一致性核对;④结论按 fail/warn/info 分级列出问题、影响范围与处置建议(如门店全零行建议排查取数参数或数据权限),涉及质量问题的数字标注「数据质量存疑」。',
+  },
+  {
+    key: 'peer-benchmark',
+    name: '同行对标',
+    desc: '门店自身 vs 同行均值/排名',
+    playbook: '①lake_overview 摸底;②peer_benchmark 取目标字段的同行均值与排名;③对排名恶化门店用 query_report(mode=date) 下钻逐日;④raw_inspect 看同行均值是否整体下移(区分自身问题 vs 大盘问题);⑤结论:排名变化 + 原因归类(自身/大盘/数据质量)。',
+  },
+  {
+    key: 'drill-down',
+    name: '下钻归因',
+    desc: 'TopN → 异常门店 → 拐点日期归因',
+    playbook: '①query_report(mode=shop, 按目标指标排序) 取 TopN;②锁定异常门店用 query_report(mode=detail) 看逐日;③定位拐点日期;④raw_inspect 查该店该日 raw(环比差值/同行均值),判断自身波动还是大盘波动;⑤结论给归因链路「指标 → 门店 → 日期 → 原因」。',
+  },
+];
+
+function get(key) {
+  return SKILLS.find((s) => s.key === key) || null;
+}
+
+/** 常驻提示词的技能清单(打法全文,体积小) */
+function manifestText() {
+  return SKILLS.map((s) => `- ${s.name}(${s.key}):${s.desc}。打法:${s.playbook}`).join('\n');
+}
+
+module.exports = { SKILLS, get, manifestText };

+ 101 - 0
server/agent/tools/cloud.js

@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 湖↔云比对工具
+ * ----------------------------------------------------------------------------
+ * aitable_verify:本地数据湖 vs 钉钉 AI 表格(在线主档案)只读比对。
+ * 行数按粒度对比 + 共同行键的营业额抽样值比对,只读不写(记录接口无建列限流,
+ * 但仍控制在整表拉一次)。云端未启用/配置不齐时优雅降级为说明性结论。
+ * ctx.cloud 由路由层装配(aitable 服务实例);未装配时同样优雅降级。
+ */
+
+const { ymdDash } = require('./lake.js');
+
+function cloudSanitize(args, ctx) {
+  const a = args || {};
+  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, sample: Math.min(Number(a.sample) > 0 ? Number(a.sample) : 5, 10) };
+}
+
+function rowKey(row) {
+  return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`;
+}
+
+async function cloudHandler(ctx, args) {
+  const { svc, entityKey, cloud } = ctx;
+  const { from, to, sample } = args;
+  if (!cloud) {
+    return {
+      summary: '湖↔云比对不可用:云端服务未装配(仅云端协同模式提供)。',
+      data: {
+        available: false,
+        verdicts: [{ kind: 'cloud', check: '湖↔云比对', status: 'warn', detail: '云端服务未装配' }],
+      },
+    };
+  }
+  let snapshot;
+  try {
+    snapshot = await cloud.snapshotForVerify(entityKey);
+  } catch (e) {
+    return {
+      summary: `湖↔云比对不可用:${e.message || e}(云端未启用或配置不齐时属预期;如需比对请先到系统设置开启并测试连通)。`,
+      data: { available: false, reason: String(e.message || e).slice(0, 200) },
+      verdicts: [{ kind: 'cloud', check: '湖↔云比对', status: 'warn', detail: `云端不可用:${String(e.message || e).slice(0, 80)}` }],
+    };
+  }
+  const inRange = (r) => String(r.date || '') >= from && String(r.date || '') <= to;
+  const lines = [`湖↔云比对(区间 ${ymdDash(from)} ~ ${ymdDash(to)},业务键 日期|维度|连锁|门店):`];
+  const perScope = [];
+  let totalDiff = 0;
+  const samples = [];
+  for (const scope of ['shop', 'chain', 'company']) {
+    const localRows = svc.store.queryRows(entityKey, { from, to, scope }).filter(inRange);
+    const cloudRows = (snapshot[scope] || []).filter(inRange);
+    const localMap = new Map(localRows.map((r) => [rowKey(r), r]));
+    const cloudMap = new Map(cloudRows.map((r) => [rowKey(r), r]));
+    let onlyLocal = 0;
+    let onlyCloud = 0;
+    let valueDiff = 0;
+    const shared = [];
+    for (const [k, r] of localMap) {
+      if (!cloudMap.has(k)) { onlyLocal += 1; continue; }
+      shared.push([k, r, cloudMap.get(k)]);
+    }
+    for (const k of cloudMap.keys()) if (!localMap.has(k)) onlyCloud += 1;
+    for (const [k, lr, cr] of shared) {
+      const lv = Number(lr.grossAmt) || 0;
+      const cv = Number(cr.grossAmt) || 0;
+      if (Math.abs(lv - cv) > Math.max(lv * 0.005, 1)) {
+        valueDiff += 1;
+        if (samples.length < sample) samples.push({ scope, key: k, local: Math.round(lv * 100) / 100, cloud: Math.round(cv * 100) / 100 });
+      }
+    }
+    perScope.push({ scope, local: localRows.length, cloud: cloudRows.length, onlyLocal, onlyCloud, valueDiff, shared: shared.length });
+    totalDiff += onlyLocal + onlyCloud + valueDiff;
+  }
+  const label = { shop: '门店明细', chain: '连锁汇总', company: '公司汇总' };
+  for (const s of perScope) {
+    lines.push(`  ${label[s.scope]}:本地 ${s.local} 行 / 云端 ${s.cloud} 行(仅本地 ${s.onlyLocal}、仅云端 ${s.onlyCloud}、抽样值不一致 ${s.valueDiff})`);
+  }
+  if (samples.length) {
+    lines.push(`抽样值差异示例:${samples.map((s) => `${label[s.scope]} ${s.key} 本地 ${s.local} vs 云端 ${s.cloud}`).join(';')}`);
+  }
+  const ok = totalDiff === 0;
+  if (!ok) lines.push('湖与云端存在差异:通常由「上次同步/推送之后本地又有新数据」导致,可到数据采集页推送或从中台拉取后再问。');
+  else lines.push('湖与云端一致。');
+  return {
+    summary: lines.join('\n'),
+    data: {
+      available: true, from, to, perScope, samples,
+      verdicts: [{
+        kind: 'cloud', check: `湖↔云一致性(${ymdDash(from)} ~ ${ymdDash(to)})`,
+        status: ok ? 'ok' : 'warn',
+        detail: ok ? '三表行数与抽样值一致' : `差异 ${totalDiff} 处(行数或抽样值)`,
+      }],
+    },
+  };
+}
+
+module.exports = { cloudSanitize, cloudHandler };

+ 179 - 0
server/agent/tools/compare.js

@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 同比环比对比工具
+ * ----------------------------------------------------------------------------
+ * compare_periods:本期 vs 基期(默认等长紧邻平移 = 环比;gap 支持隔段对比)。
+ * 方法论固定在实现里,模型无权偏离:
+ *   · 门店集合对齐 —— 两期都在线的店才进可比集,开/闭店单列(防开闭店污染 delta)
+ *   · 比率指标用 summarizeRows 的加权口径(曝光/进店加权),绝不算术平均
+ *   · 缺失日显式列出;恒等式(收入+支出=营业额)逐期校验
+ */
+
+const nlQuery = require('../../../nl-query.js');
+const { pickRow, eachDay, ymdDash } = require('./lake.js');
+
+const ADDITIVE = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
+
+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 clampDay(v, lo, hi) {
+  const s = /^\d{8}$/.test(String(v || '')) ? String(v) : '';
+  if (!s) return '';
+  return s < lo ? lo : (s > hi ? hi : s);
+}
+
+const VALID_METRICS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
+  'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d'];
+
+function compareSanitize(args, ctx) {
+  const { shops, dateRange } = ctx;
+  const a = args || {};
+  const ms = (Array.isArray(a.metrics) ? a.metrics : []).map(String).filter((k) => VALID_METRICS.includes(k)).slice(0, 4);
+  const finalMetrics = ms.length ? ms : ['grossAmt', 'validOrderCnt'];
+
+  // 本期区间:缺省取湖内最后 7 个有数日(稀疏数据下比「自然日倒退 7 天」更贴近直觉)
+  const dates = ctx.svc.store.datesInLake(ctx.entityKey);
+  let from = clampDay(a.from, dateRange.from, dateRange.to);
+  let to = clampDay(a.to, dateRange.from, dateRange.to);
+  if (!from || !to || from > to) {
+    if (!dates.length) return { metrics: finalMetrics, from: dateRange.from, to: dateRange.to, prevFrom: dateRange.from, prevTo: dateRange.to, shops: null, empty: true };
+    to = dates[dates.length - 1];
+    from = dates[Math.max(0, dates.length - 7)];
+  }
+  const gap = Number(a.gap) > 0 ? Math.floor(Number(a.gap)) : 0;
+  const len = Math.max(1, Number(
+    (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 - gap);
+  const prevFrom = dayShift(prevTo, -(len - 1));
+
+  let shopIds = null;
+  const rawShops = Array.isArray(a.shops) ? a.shops.filter(Boolean) : [];
+  if (rawShops.length) {
+    shopIds = nlQuery.matchShopsByName(shops, rawShops);
+    if (!shopIds) throw new Error(`门店名未匹配到任何门店:${rawShops.join('、')}(可用 lake_overview 查看门店清单)`);
+  }
+  return { metrics: finalMetrics, from, to, prevFrom, prevTo, shops: shopIds, empty: false };
+}
+
+function windowStats(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 shopsPresent = new Map();
+  const dates = new Set();
+  for (const r of rows) {
+    shopsPresent.set(String(r.shopId), (shopsPresent.get(String(r.shopId)) || 0) + 1);
+    dates.add(r.date);
+  }
+  return { rows, summary: svc.summarizeRows(rows), shopsPresent, dates: [...dates].sort() };
+}
+
+function identityCheck(summary) {
+  const g = Number(summary.grossAmt) || 0;
+  const lhs = (Number(summary.incomeAmt) || 0) + (Number(summary.expenseAmt) || 0);
+  const bad = Math.abs(lhs - g) > Math.max(g * 0.005, 1);
+  return { ok: !bad, lhs: Math.round(lhs * 100) / 100, gross: Math.round(g * 100) / 100 };
+}
+
+function bucketBy(ctx, rows, group, metrics) {
+  // group: 'date' | 'shopId';给图表用的轻量逐桶聚合(加权口径与 summarizeRows 一致)
+  const { svc } = ctx;
+  const map = new Map();
+  for (const r of rows) {
+    const k = group === 'date' ? r.date : String(r.shopId);
+    if (!map.has(k)) map.set(k, []);
+    map.get(k).push(r);
+  }
+  return [...map.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, list]) => {
+    const s = svc.summarizeRows(list);
+    const out = group === 'date' ? { date: k } : { shopId: k, shopName: (list[0] || {}).shopName || k };
+    for (const m of metrics) out[m] = s[m];
+    return out;
+  });
+}
+
+function compareHandler(ctx, args) {
+  const { svc, entityKey, meta } = ctx;
+  const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
+  const label = (k) => (reg.get(k) || {}).label || k;
+  if (args.empty) {
+    return { summary: '湖内暂无数据,无法对比。请先完成同步。', data: { empty: true } };
+  }
+  const { metrics, from, to, prevFrom, prevTo, shops } = args;
+  const shopNameOf = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
+
+  const cur = windowStats(ctx, from, to, shops);
+  const prev = windowStats(ctx, prevFrom, prevTo, shops);
+  // 可比集:两期都出现过的门店
+  const comparable = [...cur.shopsPresent.keys()].filter((id) => prev.shopsPresent.has(id));
+  const curCmp = comparable.length ? windowStats(ctx, from, to, comparable) : cur;
+  const prevCmp = comparable.length ? windowStats(ctx, prevFrom, prevTo, comparable) : prev;
+
+  const deltas = metrics.map((k) => {
+    const c = Number(curCmp.summary[k]) || 0;
+    const p = Number(prevCmp.summary[k]) || 0;
+    const type = (reg.get(k) || {}).type || 'num';
+    const deltaPct = (type === 'pct')
+      ? (p > 0 ? (c - p) / p : null) // 比率的「变化」用相对差表述
+      : (p !== 0 ? (c - p) / Math.abs(p) : null);
+    return { key: k, label: label(k), type, cur: c, prev: p, delta: Math.round((c - p) * 100) / 100, deltaPct };
+  });
+
+  const opened = [...cur.shopsPresent.keys()].filter((id) => !prev.shopsPresent.has(id));
+  const closed = [...prev.shopsPresent.keys()].filter((id) => !cur.shopsPresent.has(id));
+  const expected = eachDay(from, to);
+  const missingDays = expected.filter((d) => !cur.dates.includes(d));
+  const idCur = identityCheck(curCmp.summary);
+  const idPrev = identityCheck(prevCmp.summary);
+
+  const lines = [];
+  lines.push(`对比口径:本期 ${ymdDash(from)} ~ ${ymdDash(to)} vs 上期 ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)}(等长平移),可比门店 ${comparable.length} 家${shops ? '(已限定指定门店)' : ''}。`);
+  for (const d of deltas) {
+    const chg = d.deltaPct == null ? '上期为 0/无数据,无法算变化率'
+      : `${d.deltaPct >= 0 ? '+' : ''}${(d.deltaPct * 100).toFixed(1)}%`;
+    const fmt = (v) => d.type === 'pct' ? `${(v * 100).toFixed(2)}%` : String(Math.round(v * 100) / 100);
+    lines.push(`${d.label}:本期 ${fmt(d.cur)} vs 上期 ${fmt(d.prev)}(${chg})`);
+  }
+  if (opened.length) lines.push(`本期新出现门店(未进可比集):${opened.map((id) => shopNameOf.get(id) || id).join('、')}`);
+  if (closed.length) lines.push(`上期有数本期无数门店(未进可比集):${closed.map((id) => shopNameOf.get(id) || id).join('、')}`);
+  if (missingDays.length) lines.push(`本期缺数 ${missingDays.length} 天:${missingDays.slice(0, 8).map(ymdDash).join('、')}${missingDays.length > 8 ? ' 等' : ''}`);
+  if (!idCur.ok || !idPrev.ok) lines.push(`⚠ 恒等式(收入+支出=营业额)不满足:本期 ${idCur.lhs} vs ${idCur.gross},上期 ${idPrev.lhs} vs ${idPrev.gross},数据质量存疑`);
+
+  return {
+    summary: lines.join('\n'),
+    data: {
+      cur: { from, to, summary: curCmp.summary },
+      prev: { from: prevFrom, to: prevTo, summary: prevCmp.summary },
+      comparableShopCount: comparable.length,
+      opened: opened.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
+      closed: closed.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
+      missingDays,
+      deltas,
+      identity: { cur: idCur, prev: idPrev },
+      curByDate: bucketBy(ctx, curCmp.rows, 'date', metrics),
+      curByShop: bucketBy(ctx, curCmp.rows, 'shopId', metrics).sort((a, b) => (Number(b.grossAmt) || 0) - (Number(a.grossAmt) || 0)).slice(0, 50),
+      verdicts: [
+        { kind: 'identity', check: '恒等式 收入+支出=营业额(本期)', status: idCur.ok ? 'ok' : 'fail', detail: `本期 ${idCur.lhs} vs ${idCur.gross}` },
+        { kind: 'identity', check: '恒等式 收入+支出=营业额(上期)', status: idPrev.ok ? 'ok' : 'fail', detail: `上期 ${idPrev.lhs} vs ${idPrev.gross}` },
+        ...(missingDays.length ? [{
+          kind: 'freshness', check: '本期数据完整性',
+          status: missingDays.length / expected.length > 0.3 ? 'warn' : 'ok',
+          detail: `缺 ${missingDays.length}/${expected.length} 天(${missingDays.slice(0, 3).map(ymdDash).join('、')}${missingDays.length > 3 ? ' 等' : ''})`,
+        }] : []),
+        { kind: 'coverage', check: '门店可比集', status: opened.length || closed.length ? 'warn' : 'ok', detail: `可比 ${comparable.length} 家${opened.length ? `,新出现 ${opened.length} 家` : ''}${closed.length ? `,缺数 ${closed.length} 家` : ''}` },
+      ],
+    },
+  };
+}
+
+module.exports = { compareSanitize, compareHandler, windowStats, identityCheck, bucketBy, ADDITIVE };

+ 106 - 0
server/agent/tools/cross.js

@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 层间一致性核对工具
+ * ----------------------------------------------------------------------------
+ * cross_check:按日核对 门店合计 vs 连锁合计 vs 公司行(营业额 + 有效订单)。
+ * 从 data_quality 中独立成工具,供模型对指定区间单独抽查(如环比异常时先排除
+ * 数据质量问题)。容差与判定同 data_quality:相对差 >0.5% 记不一致、>20% 判严重。
+ */
+
+const { eachDay, ymdDash } = require('./lake.js');
+
+const CROSS_TOL = 0.005;
+const CROSS_FAIL = 0.2;
+
+function crossSanitize(args, ctx) {
+  const a = args || {};
+  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,
+  };
+}
+
+function sumBy(rows, key) {
+  const m = new Map();
+  for (const r of rows) m.set(r.date, (m.get(r.date) || 0) + (Number(r[key]) || 0));
+  return m;
+}
+
+function relDiff(a, b) {
+  return Math.abs(a - b) / Math.max(Math.abs(b), 1);
+}
+
+function crossHandler(ctx, args) {
+  const { svc, entityKey } = ctx;
+  const store = svc.store;
+  const { from, to } = args;
+  const inRange = (r) => r.date >= from && r.date <= to;
+  const shopRows = store.queryRows(entityKey, { from, to, scope: 'shop' }).filter(inRange);
+  const chainRows = store.queryRows(entityKey, { from, to, scope: 'chain' }).filter(inRange);
+  const companyRows = store.queryRows(entityKey, { from, to, scope: 'company' }).filter(inRange);
+
+  if (!shopRows.length && !chainRows.length) {
+    return { summary: `区间 ${ymdDash(from)} ~ ${ymdDash(to)} 无可核对的行(门店/连锁层均无数据)。`, data: { from, to, checked: 0 } };
+  }
+
+  const metrics = [
+    { key: 'grossAmt', label: '营业额' },
+    { key: 'validOrderCnt', label: '有效订单' },
+  ];
+  // 层×指标 各建一张按日求和表(日期字符串不做数值运算)
+  const sums = {};
+  for (const m of metrics) {
+    sums[m.key] = { shop: sumBy(shopRows, m.key), chain: sumBy(chainRows, m.key), company: sumBy(companyRows, m.key) };
+  }
+  const days = eachDay(from, to).filter((d) => sums.grossAmt.shop.has(d) || sums.grossAmt.chain.has(d));
+  const mismatches = [];
+  let checked = 0;
+  for (const d of days) {
+    for (const m of metrics) {
+      const shop = sums[m.key].shop.get(d);
+      const chain = sums[m.key].chain.get(d);
+      const company = sums[m.key].company.get(d);
+      // 同层求和口径:门店合计对比连锁合计;连锁合计对比公司行(任一侧缺失跳过该对比)
+      const pairs = [];
+      if (shop !== undefined && chain !== undefined) pairs.push(['门店合计 vs 连锁', shop, chain]);
+      if (chain !== undefined && company !== undefined) pairs.push(['连锁合计 vs 公司', chain, company]);
+      for (const [kind, a, b] of pairs) {
+        checked += 1;
+        const diff = relDiff(a, b);
+        if (diff > CROSS_TOL) mismatches.push({ date: d, metric: m.label, kind, a: Math.round(a * 100) / 100, b: Math.round(b * 100) / 100, diffPct: +(diff * 100).toFixed(2) });
+      }
+    }
+  }
+  mismatches.sort((x, y) => y.diffPct - x.diffPct);
+  const severe = mismatches.some((m) => m.diffPct / 100 > CROSS_FAIL);
+  const lines = [];
+  if (!mismatches.length) {
+    lines.push(`层间一致性通过:抽查 ${days.length} 天 × 2 指标(共 ${checked} 组对比),门店合计/连锁/公司层间一致(容差 ${(CROSS_TOL * 100).toFixed(1)}%)。`);
+  } else {
+    lines.push(`层间一致性${severe ? '严重不一致' : '存在不一致'}:${mismatches.length} 组对比超容差(共核对 ${checked} 组),最差 ${ymdDash(mismatches[0].date)} ${mismatches[0].kind} ${mismatches[0].metric}:${mismatches[0].a} vs ${mismatches[0].b}(差 ${mismatches[0].diffPct}%)。`);
+    for (const m of mismatches.slice(0, 8)) {
+      lines.push(`  ${ymdDash(m.date)} ${m.metric} ${m.kind}:${m.a} vs ${m.b}(差 ${m.diffPct}%)`);
+    }
+    if (mismatches.length > 8) lines.push(`  …其余 ${mismatches.length - 8} 组略`);
+    lines.push('层间不一致通常意味着某一粒度取数异常(如门店行全零、部分门店缺数),相关结论应标注「数据质量存疑」。');
+  }
+  return {
+    summary: lines.join('\n'),
+    data: {
+      from, to, checked, mismatchCount: mismatches.length, severe, mismatches: mismatches.slice(0, 20),
+      verdicts: [{
+        kind: 'cross-layer', check: `层间一致性 · 门店/连锁/公司(${ymdDash(from)} ~ ${ymdDash(to)})`,
+        status: mismatches.length === 0 ? 'ok' : (severe ? 'fail' : 'warn'),
+        detail: mismatches.length === 0
+          ? `抽查 ${days.length} 天 × 2 指标共 ${checked} 组对比全部一致`
+          : `${mismatches.length} 组超容差,最差 ${ymdDash(mismatches[0].date)} ${mismatches[0].metric} 差 ${mismatches[0].diffPct}%`,
+      }],
+    },
+  };
+}
+
+module.exports = { crossSanitize, crossHandler, CROSS_TOL, CROSS_FAIL };

+ 78 - 0
server/agent/tools/entity.js

@@ -0,0 +1,78 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 跨主体对比工具
+ * ----------------------------------------------------------------------------
+ * cross_entity:湖内全部主体(多账号/多食堂)本期 vs 基期核心指标对比,
+ * 供「跨校对比」「哪家餐厅更好」类问题。窗口锚定当前主体的湖内区间,
+ * 无本期数据的主体自动滤除并说明。
+ */
+
+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 entitySanitize(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));
+  return { from, to, prevFrom, prevTo, empty: false };
+}
+
+function windowTotals(svc, key, from, to) {
+  const rows = svc.store.queryRows(key, { from, to, scope: 'shop' });
+  const s = svc.summarizeRows(rows);
+  return { gross: s.grossAmt, orders: s.validOrderCnt, days: s.dayCount, hasData: rows.length > 0 };
+}
+
+function entityHandler(ctx, args) {
+  const { svc } = ctx;
+  if (args.empty) return { summary: '当前主体湖内暂无数据,无法跨主体对比。', data: { empty: true } };
+  const { from, to, prevFrom, prevTo } = args;
+  const entities = svc.store.listEntities();
+  const out = [];
+  const skipped = [];
+  for (const e of entities) {
+    const cur = windowTotals(svc, e.key, from, to);
+    if (!cur.hasData) { skipped.push(e.name || e.key); continue; }
+    const prev = windowTotals(svc, e.key, prevFrom, prevTo);
+    out.push({
+      key: e.key,
+      name: e.name || e.key,
+      shopCount: e.shopCount || 0,
+      cur,
+      prev,
+      grossDeltaPct: prev.gross > 0 ? +(((cur.gross - prev.gross) / prev.gross) * 100).toFixed(1) : null,
+    });
+  }
+  out.sort((a, b) => b.cur.gross - a.cur.gross);
+  const lines = [`跨主体对比(本期 ${ymdDash(from)} ~ ${ymdDash(to)} vs 上期 ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)},按本期营业额降序):`];
+  for (const e of out) {
+    const delta = e.grossDeltaPct == null ? '上期无数据' : `环比 ${e.grossDeltaPct >= 0 ? '+' : ''}${e.grossDeltaPct}%`;
+    lines.push(`  ${e.name}(${e.shopCount} 店):营业额 ${Math.round(e.cur.gross)} / 有效订单 ${Math.round(e.cur.orders)}(${delta})`);
+  }
+  if (skipped.length) lines.push(`本期无数据未纳入:${skipped.join('、')}`);
+  if (!out.length) lines.push('湖内没有任何主体在本期有数据。');
+  return { summary: lines.join('\n'), data: { from, to, prevFrom, prevTo, entities: out, skipped } };
+}
+
+module.exports = { entitySanitize, entityHandler };

+ 234 - 0
server/agent/tools/lake.js

@@ -0,0 +1,234 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 湖内取数与体检工具
+ * ----------------------------------------------------------------------------
+ * lake_overview  摸底:主体/门店/区间/行数/最近同步(循环第一步的事实基础)
+ * query_report   查询:明细/按店/按日,内核复用 nl-query 的 sanitize+execute(口径同报表页)
+ * data_quality   体检:全零行 / 层间一致性 / 恒等式 / 日期连续性 / 预留列 / 最近同步
+ *
+ * handler(ctx, args) → { summary(给模型的观察文本,纯文本无嵌套), data(给前端的产物,行数已裁剪) }
+ * ctx = { svc: napos-service, entityKey, meta, shops, dateRange }
+ */
+
+const nlQuery = require('../../../nl-query.js');
+
+const z2 = (n) => String(n).padStart(2, '0');
+function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
+function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
+function spanDays(from, to) {
+  return 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;
+}
+function eachDay(from, to) {
+  const out = [];
+  const d = new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)));
+  const end = to;
+  for (;;) {
+    const ymd = `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
+    if (ymd > end) break;
+    out.push(ymd);
+    d.setDate(d.getDate() + 1);
+  }
+  return out;
+}
+function ymdDash(v) { return String(v || '').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'); }
+
+/** 列注册表快捷访问:label / type */
+function colMap(svc) {
+  return new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
+}
+
+/** 行瘦身:raw 原始字段不进产物(60+ 字段 × 200 行太大,模型与前端都不需要) */
+function pickRow(r) {
+  const out = {};
+  for (const k of Object.keys(r)) if (k !== 'raw') out[k] = r[k];
+  return out;
+}
+
+// ---------- lake_overview ----------
+
+function overviewSanitize(args) { return {}; }
+
+function overviewHandler(ctx) {
+  const { svc, entityKey, meta, dateRange } = ctx;
+  const store = svc.store;
+  const rows = store.loadRows(entityKey);
+  const scopes = {};
+  for (const r of rows) scopes[r.scope] = (scopes[r.scope] || 0) + 1;
+  const dates = store.datesInLake(entityKey);
+  const syncs = store.loadSyncLogs(entityKey);
+  const last = syncs[0] || null;
+  const shops = meta.shops || [];
+  const lines = [];
+  lines.push(`主体「${meta.name || entityKey}」:${shops.length} 家门店、${(meta.chains || []).length} 个连锁。`);
+  if (dates.length) {
+    lines.push(`湖内区间 ${ymdDash(dateRange.from)} ~ ${ymdDash(dateRange.to)}(${dates.length} 天有数据)。`);
+  } else {
+    lines.push('湖内暂无数据行:请先到「数据采集」完成一次同步,或确认已从云端拉取。');
+  }
+  lines.push(`行数:门店明细 ${scopes.shop || 0} / 连锁 ${scopes.chain || 0} / 公司 ${scopes.company || 0} / 区间汇总 ${scopes.summary || 0}。`);
+  if (last) {
+    lines.push(`最近同步 ${last.at}:${ymdDash(last.from || '')} ~ ${ymdDash(last.to || '')},新增 ${last.added ?? 0} 更新 ${last.updated ?? 0} 失败 ${last.failed ?? 0}${last.cancelled ? '(被取消)' : ''}。`);
+  } else {
+    lines.push('暂无同步记录。');
+  }
+  return {
+    summary: lines.join('\n'),
+    data: {
+      entity: { key: entityKey, name: meta.name || entityKey },
+      chains: meta.chains || [],
+      shops: shops.slice(0, 300),
+      dateRange,
+      datesCount: dates.length,
+      scopes,
+      lastSync: last ? { at: last.at, from: last.from, to: last.to, added: last.added, updated: last.updated, failed: last.failed, cancelled: !!last.cancelled } : null,
+    },
+  };
+}
+
+// ---------- query_report ----------
+
+function querySanitize(args, ctx) {
+  const { shops, dateRange } = ctx;
+  return nlQuery.sanitizeQuery(args || {}, shops, dateRange);
+}
+
+function queryHandler(ctx, query) {
+  const { svc, entityKey } = ctx;
+  const result = nlQuery.executeQuery(entityKey, query, svc);
+  const rows = (result.rows || []).map(pickRow);
+  return {
+    summary: nlQuery.buildResultDigest(query, { columns: result.columns, rows: result.rows, summary: result.summary }),
+    data: {
+      applied: result.applied,
+      columns: (result.columns || []).map((c) => ({ key: c.key, label: c.label, type: c.type })),
+      summary: result.summary,
+      totalRows: rows.length,
+      rows: rows.slice(0, 200),
+    },
+  };
+}
+
+// ---------- data_quality ----------
+
+const IDENTITY_TOL = 0.005;   // 恒等式容差:收入+支出 与 营业额 相对差 0.5%
+const CROSS_TOL = 0.005;      // 层间一致性容差:门店合计 vs 连锁/公司 相对差 0.5%
+const CROSS_FAIL = 0.2;       // 层间相对差超过 20% 判 fail(典型:全零行)
+
+function qualitySanitize(args, ctx) {
+  const a = args || {};
+  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 };
+}
+
+function qualityHandler(ctx, args) {
+  const { svc, entityKey, dateRange } = ctx;
+  const store = svc.store;
+  const { from, to } = args;
+  const inRange = (r) => r.date >= from && r.date <= to;
+  const shopRows = store.queryRows(entityKey, { from, to, scope: 'shop' });
+  const chainRows = store.queryRows(entityKey, { from, to, scope: 'chain' });
+  const companyRows = store.queryRows(entityKey, { from, to, scope: 'company' });
+  const findings = [];
+  const note = (level, check, detail) => findings.push({ level, check, detail });
+
+  // 1) 门店明细全零行(已知 bug 形态:shop 全零而 chain 有数)
+  const zeroRows = shopRows.filter((r) => !(Number(r.validOrderCnt) > 0) && !(Number(r.grossAmt) > 0));
+  if (shopRows.length) {
+    const pct = zeroRows.length / shopRows.length;
+    const byShop = new Map();
+    for (const r of zeroRows) byShop.set(r.shopName || r.shopId, (byShop.get(r.shopName || r.shopId) || 0) + 1);
+    const top = [...byShop.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
+      .map(([name, n]) => `${name}×${n}`).join('、');
+    const level = pct >= 0.3 ? 'fail' : (pct >= 0.05 ? 'warn' : 'ok');
+    note(level, '门店明细全零行', zeroRows.length
+      ? `${zeroRows.length}/${shopRows.length} 行营业额与单量全零(${(pct * 100).toFixed(1)}%),主要:${top}。疑似取数参数或数据权限异常`
+      : '未发现全零行');
+  }
+
+  // 2) 层间一致性:sum(门店) vs sum(连锁) vs 公司行(按日比对营业额)
+  const sumBy = (rows) => {
+    const m = new Map();
+    for (const r of rows) m.set(r.date, (m.get(r.date) || 0) + (Number(r.grossAmt) || 0));
+    return m;
+  };
+  const shopSum = sumBy(shopRows);
+  const chainSum = sumBy(chainRows);
+  const companySum = sumBy(companyRows);
+  const mismatches = [];
+  const relDiff = (a, b) => Math.abs(a - b) / Math.max(Math.abs(b), 1);
+  for (const [d, sv] of shopSum) {
+    const cv = chainSum.get(d);
+    if (cv !== undefined && relDiff(sv, cv) > CROSS_TOL) mismatches.push({ date: d, kind: '门店合计≠连锁', sv, cv });
+  }
+  for (const [d, cv] of chainSum) {
+    const pv = companySum.get(d);
+    if (pv !== undefined && relDiff(cv, pv) > CROSS_TOL) mismatches.push({ date: d, kind: '连锁合计≠公司', sv: cv, cv: pv });
+  }
+  if (shopSum.size || chainSum.size) {
+    if (mismatches.length) {
+      const worst = mismatches.slice().sort((a, b) => relDiff(b.sv, b.cv) - relDiff(a.sv, a.cv))[0];
+      const level = mismatches.some((m) => relDiff(m.sv, m.cv) > CROSS_FAIL) ? 'fail' : 'warn';
+      note(level, '层间一致性(营业额)', `${mismatches.length} 天层间对不齐,最差 ${ymdDash(worst.date)} ${worst.kind}:${Math.round(worst.sv)} vs ${Math.round(worst.cv)}。`);
+    } else {
+      note('ok', '层间一致性(营业额)', `抽查 ${Math.min(shopSum.size, chainSum.size)} 天门店/连锁/公司层间一致(容差 ${(CROSS_TOL * 100).toFixed(1)}%)。`);
+    }
+  }
+
+  // 3) 恒等式:收入 + 支出 = 营业额
+  const identityBad = shopRows.filter((r) => {
+    const g = Number(r.grossAmt) || 0;
+    const lhs = (Number(r.incomeAmt) || 0) + (Number(r.expenseAmt) || 0);
+    return Math.abs(lhs - g) > Math.max(g * IDENTITY_TOL, 1);
+  });
+  note(identityBad.length ? 'warn' : 'ok', '恒等式 收入+支出=营业额',
+    identityBad.length ? `${identityBad.length} 行不满足(示例:${ymdDash(identityBad[0].date)} ${identityBad[0].shopName || ''})` : '全部满足');
+
+  // 4) 日期连续性
+  const dates = [...new Set(shopRows.map((r) => r.date))].sort();
+  if (dates.length) {
+    const expected = eachDay(dates[0], dates[dates.length - 1]);
+    const missing = expected.filter((d) => !dates.includes(d));
+    const pct = missing.length / expected.length;
+    note(pct >= 0.3 ? 'warn' : 'info', '日期连续性',
+      missing.length ? `区间内缺 ${missing.length}/${expected.length} 天(${pct >= 0.3 ? '偏多,' : ''}示例:${missing.slice(0, 5).map(ymdDash).join('、')})。食堂休假/寒暑假属正常缺日`
+        : `区间 ${ymdDash(dates[0])} ~ ${ymdDash(dates[dates.length - 1])} 逐日连续`);
+  }
+
+  // 5) 预留列(恒定无数据,属预期而非故障)
+  note('info', '预留列', '营业时长/异常关店时长/商品销售额当前无数据源,恒为空(口径见语义层)');
+
+  // 6) 最近同步
+  const last = (store.loadSyncLogs(entityKey)[0]) || null;
+  if (!last) note('info', '最近同步', '无同步记录');
+  else {
+    const failed = last.failed || 0;
+    const warned = (last.steps || []).filter((s) => s && s.warn).length;
+    note(failed > 0 ? 'warn' : (warned > 0 ? 'warn' : 'ok'), '最近同步',
+      `${last.at} ${failed ? `失败 ${failed} 步` : ''}${warned ? `告警 ${warned} 步(含全零)` : ''}${!failed && !warned ? '正常完成' : ''}`);
+  }
+
+  const lines = findings.map((f) => `[${f.level}] ${f.check}:${f.detail}`);
+  const rangeLine = `体检范围 ${ymdDash(from)} ~ ${ymdDash(to)}(湖内 ${ymdDash(dateRange.from)} ~ ${ymdDash(dateRange.to)}):`;
+  return {
+    summary: rangeLine + '\n' + lines.join('\n'),
+    data: {
+      from, to, findings,
+      verdicts: findings.map((f) => ({
+        kind: 'quality', check: f.check,
+        status: f.level === 'fail' ? 'fail' : (f.level === 'warn' ? 'warn' : 'ok'),
+        detail: f.detail,
+      })),
+    },
+  };
+}
+
+module.exports = {
+  overviewSanitize, overviewHandler,
+  querySanitize, queryHandler,
+  qualitySanitize, qualityHandler,
+  pickRow, colMap, spanDays, eachDay, ymdDash, daysAgoYmd,
+};

+ 145 - 0
server/agent/tools/peer.js

@@ -0,0 +1,145 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 源侧字段与同行对标工具
+ * ----------------------------------------------------------------------------
+ * raw_inspect    取某行 raw 原始字段(环比差值/上期对比/同行均值排名,全字段落库),
+ *                用于源侧交叉验证 —— 本地算的环比 vs 取数接口自带的环比差值
+ * peer_benchmark 同行对标:门店×区间 自身值 vs 同行均值 vs 排名(字段白名单 =
+ *                FIELD_MAP 键,蛇形命名与 historyOverview 一致)
+ *
+ * raw 字段命名以取数接口原样为准(已知后缀:<field>_mall_avg / <field>_mall_avg_rank,
+ * 环比与上期对比后缀以实际返回为准)——工具按「包含匹配」取字段,不猜精确名。
+ */
+
+const { pickRow, ymdDash } = require('./lake.js');
+
+// ---------- raw_inspect ----------
+
+function rawSanitize(args, ctx) {
+  const a = args || {};
+  const date = /^\d{8}$/.test(String(a.date || '')) ? String(a.date) : '';
+  if (!date) throw new Error('缺少 date(YYYYMMDD)');
+  const { shops, dateRange } = ctx;
+  if (date < dateRange.from || date > dateRange.to) throw new Error(`date 超出湖内区间(${dateRange.from} ~ ${dateRange.to})`);
+  let shopId = a.shopId != null ? String(a.shopId) : '';
+  if (!shopId && Array.isArray(a.shops) && a.shops.length) {
+    const nlQuery = require('../../../nl-query.js');
+    const hit = nlQuery.matchShopsByName(shops, a.shops);
+    if (!hit) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
+    shopId = String(hit[0]);
+  }
+  if (!shopId) {
+    const first = (ctx.meta.shops || [])[0];
+    if (!first) throw new Error('主体无门店清单');
+    shopId = String(first.shopId);
+  }
+  const keys = (Array.isArray(a.keys) ? a.keys : []).map(String).slice(0, 6);
+  return { date, shopId, keys };
+}
+
+function rawHandler(ctx, args) {
+  const { svc, entityKey, meta } = ctx;
+  const { date, shopId, keys } = args;
+  const row = svc.store.queryRows(entityKey, { from: date, to: date, scope: 'shop', shopId })[0];
+  if (!row) return { summary: `无该行:${ymdDash(date)} 门店 ${shopId}(当天可能缺数)。`, data: { date, shopId, found: false } };
+  const shopName = row.shopName || (meta.shops || []).find((s) => String(s.shopId) === shopId)?.shopName || shopId;
+  if (!row.raw || typeof row.raw !== 'object') {
+    return { summary: `该行没有 raw 原始字段(旧版数据未经全字段落库,重新同步该区间即可补全)。`, data: { date, shopId, shopName, found: true, hasRaw: false } };
+  }
+  const wants = keys.length ? keys : ['gross_amt', 'valid_order_cnt'];
+  const matched = Object.keys(row.raw).filter((k) => wants.some((w) => k.includes(w))).slice(0, 40);
+  if (!matched.length) {
+    return { summary: `raw 中未找到包含 ${wants.join('/')} 的字段(该行 raw 共 ${Object.keys(row.raw).length} 个字段)。`, data: { date, shopId, shopName, found: true, hasRaw: true, matched: [] } };
+  }
+  const picked = {};
+  for (const k of matched) picked[k] = row.raw[k];
+  const lines = [`${ymdDash(date)} 门店「${shopName}」的 raw 源侧字段(共匹配 ${matched.length} 个,字段名为取数接口原样命名):`];
+  for (const k of matched) lines.push(`  ${k} = ${row.raw[k] === '' || row.raw[k] == null ? '(空)' : row.raw[k]}`);
+  return { summary: lines.join('\n'), data: { date, shopId, shopName, found: true, hasRaw: true, matched, values: picked, row: pickRow(row) } };
+}
+
+// ---------- peer_benchmark ----------
+
+const BENCH_KEYS = [
+  'valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv', 'order_user_cnt',
+  'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt', 'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d',
+];
+
+function benchSanitize(args, ctx) {
+  const a = args || {};
+  const field = String(a.field || 'gross_amt');
+  if (!BENCH_KEYS.includes(field)) {
+    throw new Error(`不支持的对标字段:${field}(可用:${BENCH_KEYS.join('、')})`);
+  }
+  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 { field, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, shopIds };
+}
+
+function benchHandler(ctx, args) {
+  const { svc, entityKey } = ctx;
+  const { field, from, to, shopIds } = args;
+  const camel = svc.FIELD_MAP[field];
+  if (!camel) return { summary: `字段映射缺失:${field}`, data: { field } };
+  const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
+  const isPct = (reg.get(camel) || {}).type === 'pct';
+  const label = (reg.get(camel) || {}).label || field;
+  const avgKey = `${field}_mall_avg`;
+  const rankKey = `${field}_mall_avg_rank`;
+
+  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)));
+  }
+  if (!rows.length) return { summary: `区间 ${ymdDash(from)} ~ ${ymdDash(to)} 无门店明细行。`, data: { field, from, to, shops: [] } };
+
+  // 门店聚合:加性指标简单均值;率指标按曝光(clk_exp_rate)或进店人数(ord_clk_rate)加权
+  const byShop = new Map();
+  for (const r of rows) {
+    const id = String(r.shopId);
+    if (!byShop.has(id)) byShop.set(id, { shopId: id, shopName: r.shopName || id, days: 0, vW: 0, aW: 0, w: 0, ranks: [] });
+    const agg = byShop.get(id);
+    const v = Number(r[camel]) || 0;
+    const avg = r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null;
+    const rank = r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null;
+    const weight = isPct ? (field === 'clk_exp_rate' ? (Number(r.expUv) || 0) : (Number(r.orderUserCnt) || 0)) : 1;
+    agg.days += 1;
+    if (weight > 0) { agg.vW += v * weight; agg.aW += (avg || 0) * weight; agg.w += weight; }
+    if (rank != null) agg.ranks.push(rank);
+  }
+
+  const shops = [...byShop.values()].map((agg) => ({
+    shopId: agg.shopId,
+    shopName: agg.shopName,
+    days: agg.days,
+    own: agg.w > 0 ? Math.round((agg.vW / agg.w) * 100) / 100 : null,
+    peerAvg: agg.w > 0 ? Math.round((agg.aW / agg.w) * 100) / 100 : null,
+    rankAvg: agg.ranks.length ? Math.round((agg.ranks.reduce((s, x) => s + x, 0) / agg.ranks.length) * 10) / 10 : null,
+    rankBest: agg.ranks.length ? Math.min(...agg.ranks) : null,
+    rankWorst: agg.ranks.length ? Math.max(...agg.ranks) : null,
+    hasPeer: agg.w > 0 && agg.aW > 0,
+  })).sort((a, b) => (a.rankAvg ?? 999) - (b.rankAvg ?? 999));
+
+  const lines = [`${label} 同行对标(${ymdDash(from)} ~ ${ymdDash(to)},自身值与同行均值均为${isPct ? '加权平均' : '日均'};排名越小越好):`];
+  for (const s of shops) {
+    if (!s.hasPeer) {
+      lines.push(`  ${s.shopName}:无同行数据(raw 缺失,重新同步该区间可补全)`);
+      continue;
+    }
+    const vs = s.own != null && s.peerAvg ? `自身 ${s.own} vs 同行均值 ${s.peerAvg}` : '均值缺失';
+    const rankTxt = s.rankAvg != null ? `平均排名 ${s.rankAvg}(最好 ${s.rankBest} / 最差 ${s.rankWorst})` : '无排名';
+    lines.push(`  ${s.shopName}:${vs};${rankTxt}`);
+  }
+  return { summary: lines.join('\n'), data: { field, from, to, isPct, shops } };
+}
+
+module.exports = { rawSanitize, rawHandler, benchSanitize, benchHandler, BENCH_KEYS };

+ 100 - 0
server/agent/tools/registry.js

@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 工具注册表
+ * ----------------------------------------------------------------------------
+ * 清单(manifestText,注入系统提示)与派发(get)单一事实源。
+ * 模型给出的工具名先在此求交(未注册即拒绝并回注可用清单,防幻觉工具名),
+ * 参数再经各工具 sanitize 钳制。本期全部工具只读(湖查询/校验),无写操作。
+ */
+
+const lake = require('./lake.js');
+const compare = require('./compare.js');
+const cross = require('./cross.js');
+const peer = require('./peer.js');
+const entity = require('./entity.js');
+const cloud = require('./cloud.js');
+const report = require('./report.js');
+
+const TOOLS = [
+  {
+    name: 'lake_overview',
+    desc: '摸底当前主体:门店/连锁清单、湖内可用日期区间、各粒度行数、最近一次同步结果。分析类问题的第一步,先看有什么数据再取数。',
+    argsDoc: '{}(无参数)',
+    sanitize: lake.overviewSanitize,
+    handler: lake.overviewHandler,
+  },
+  {
+    name: 'query_report',
+    desc: '查经营数据。mode=detail 日期×门店明细 / mode=shop 按门店汇总(排行/TopN)/ mode=date 按日期汇总(趋势)。区间自动钳制在湖内(截止默认昨天)。',
+    argsDoc: '{"mode":"shop","from":"YYYYMMDD","to":"YYYYMMDD","shops":["门店名,可部分匹配"],"metrics":["grossAmt","validOrderCnt"],"sort":"grossAmt","sortDir":"desc","limit":10}(from/to/metrics 缺省=近30天/营业额)',
+    sanitize: lake.querySanitize,
+    handler: lake.queryHandler,
+  },
+  {
+    name: 'compare_periods',
+    desc: '同比/环比对比:本期 vs 等长基期。自动做门店集合对齐(开/闭店单列)、比率加权、缺失日清单、收入+支出=营业额恒等式校验。凡问「变化/对比/为什么涨跌」一律用它,不要自己相减。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD","gap":0,"metrics":["grossAmt","validOrderCnt"],"shops":["门店名"]}(缺省=湖内最后7天 vs 紧邻前7天;gap=两期间隔天数,同比可用 gap 留空+自定 from/to 跨年)',
+    sanitize: compare.compareSanitize,
+    handler: compare.compareHandler,
+  },
+  {
+    name: 'data_quality',
+    desc: '数据体检:门店明细全零行、层间一致性(门店合计 vs 连锁 vs 公司)、恒等式、日期连续性、预留列状态、最近同步结果。回答前对数据可信度存疑时调用。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=全湖区间)',
+    sanitize: lake.qualitySanitize,
+    handler: lake.qualityHandler,
+  },
+  {
+    name: 'cross_check',
+    desc: '层间一致性核对(可单独抽查指定区间):按日核对 门店合计 vs 连锁合计 vs 公司行(营业额+有效订单),>0.5% 容差记不一致。环比/对比结论出来前建议抽查一次。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=全湖区间)',
+    sanitize: cross.crossSanitize,
+    handler: cross.crossHandler,
+  },
+  {
+    name: 'raw_inspect',
+    desc: '查某行 raw 原始字段(取数接口自带:环比差值/上期对比/同行均值排名),用于源侧交叉验证——本地算的数字 vs 接口自带的数字。字段按「包含匹配」取,命名以接口原样为准。',
+    argsDoc: '{"date":"YYYYMMDD","shops":["门店名"],"keys":["gross_amt"]}(keys 缺省匹配营业额与单量相关字段)',
+    sanitize: peer.rawSanitize,
+    handler: peer.rawHandler,
+  },
+  {
+    name: 'peer_benchmark',
+    desc: '同行对标:门店自身值 vs 同行均值 vs 排名(来自全字段落库的 raw *_mall_avg/_rank)。凡问「对标/排名/同行」一律用它。',
+    argsDoc: '{"field":"gross_amt","from":"YYYYMMDD","to":"YYYYMMDD","shops":["门店名"]}(field 蛇形命名,可用:valid_order_cnt/gross_amt/income_amt/expense_amt/exp_uv/order_user_cnt/clk_exp_rate/ord_clk_rate/per_actual_pay 等)',
+    sanitize: peer.benchSanitize,
+    handler: peer.benchHandler,
+  },
+  {
+    name: 'cross_entity',
+    desc: '跨主体(跨校/多账号)对比:湖内全部主体本期 vs 基期的营业额与有效订单。凡问「几家店/哪个食堂/跨校」类问题用它。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=当前主体湖内最后 7 天)',
+    sanitize: entity.entitySanitize,
+    handler: entity.entityHandler,
+  },
+  {
+    name: 'aitable_verify',
+    desc: '湖↔云只读比对:本地数据湖 vs 钉钉 AI 表格三表(行数 + 营业额抽样值)。凡结论涉及「云端数据是否同步/两边数据是否一致」时用它;云端未启用时优雅降级。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=全湖区间)',
+    sanitize: cloud.cloudSanitize,
+    handler: cloud.cloudHandler,
+  },
+  {
+    name: 'build_report',
+    desc: '生成经营分析 HTML 报告(KPI 概览/逐日趋势/门店明细,可打印导出 PDF)。用户要「报告/周报/导出」时调用,回答中提示点击「查看分析报告」。',
+    argsDoc: '{"from":"YYYYMMDD","to":"YYYYMMDD"}(缺省=全湖区间)',
+    sanitize: report.reportSanitize,
+    handler: report.reportHandler,
+  },
+];
+
+function get(name) {
+  return TOOLS.find((t) => t.name === name) || null;
+}
+
+function manifestText() {
+  return TOOLS.map((t) => `- ${t.name}:${t.desc}\n  参数:${t.argsDoc}`).join('\n');
+}
+
+module.exports = { TOOLS, get, manifestText };

+ 33 - 0
server/agent/tools/report.js

@@ -0,0 +1,33 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · HTML 分析报告工具
+ * ----------------------------------------------------------------------------
+ * build_report:为指定区间生成经营分析 HTML 报告的入口。报告本体由既有的
+ * report-html.js / GET /api/reports/report-html 即时生成(零重复实现),工具
+ * 只负责钳制区间、组装链接并说明报告内容;前端以「查看分析报告」按钮呈现。
+ */
+
+const { ymdDash } = require('./lake.js');
+
+function reportSanitize(args, ctx) {
+  const a = args || {};
+  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 };
+}
+
+function reportHandler(ctx, args) {
+  const { entityKey, dateRange } = ctx;
+  const { from, to } = args;
+  const params = new URLSearchParams({ entity: entityKey, from, to });
+  const url = `/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 },
+  };
+}
+
+module.exports = { reportSanitize, reportHandler };

+ 152 - 0
server/agent/trace.js

@@ -0,0 +1,152 @@
+'use strict';
+
+/**
+ * 智能问数智能体 · 运行留痕与会话索引
+ * ----------------------------------------------------------------------------
+ * 文件布局(data/agent/,GENPIC_AGENT_DIR 环境变量可覆盖——smoke 隔离用):
+ *   <entityKey>/runs/<runId>.json   单次运行全量(步骤/事件/结论,成败都留痕)
+ *   <entityKey>/sessions.json       会话索引(标题=首问,runs 摘要,双上限防膨胀)
+ * 内存中的 run 注册表在 index.js;这里只管磁盘侧。历史结论回灌(压缩:
+ * 只带问答文字,不带工具观察)供多轮追问。
+ */
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const AGENT_DIR = process.env.GENPIC_AGENT_DIR
+  ? path.resolve(process.env.GENPIC_AGENT_DIR)
+  : path.join(__dirname, '..', '..', 'data', 'agent');
+
+const MAX_SESSIONS = 30;
+const RUNS_PER_SESSION = 30;
+
+function readJsonSafe(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
+}
+
+function writeJsonAtomic(file, obj) {
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  const temp = file + '.tmp';
+  fs.writeFileSync(temp, JSON.stringify(obj), 'utf8');
+  fs.renameSync(temp, file);
+}
+
+function sanitizeKey(v) {
+  return String(v || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
+}
+
+function newId(prefix) {
+  return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
+}
+
+// ---------- 运行记录 ----------
+
+function newRunRecord({ entity, entityName, question, mode, sessionId }) {
+  return {
+    runId: newId('run'),
+    entity: sanitizeKey(entity),
+    entityName: entityName || entity,
+    sessionId: sessionId || newId('s'),
+    mode: mode === 'quick' ? 'quick' : 'agent',
+    question: String(question || '').slice(0, 500),
+    status: 'running',
+    cancelRequested: false,
+    steps: [],
+    events: [],
+    verdicts: [],
+    provenance: null,
+    error: null,
+    usage: { llmCalls: 0, toolCalls: 0 },
+    result: null,
+    startedAt: new Date().toISOString(),
+    finishedAt: null,
+  };
+}
+
+function pushEvent(rec, type, payload = {}) {
+  rec.events.push({ t: new Date().toISOString(), type, ...payload });
+  if (rec.events.length > 400) rec.events.splice(0, rec.events.length - 400);
+}
+
+function runFile(entityKey, runId) {
+  return path.join(AGENT_DIR, sanitizeKey(entityKey), 'runs', `${String(runId).replace(/[^a-zA-Z0-9_-]/g, '')}.json`);
+}
+
+function persistRun(rec) {
+  try { writeJsonAtomic(runFile(rec.entity, rec.runId), rec); } catch { /* 留痕失败不影响主流程 */ }
+}
+
+function loadRun(entityKey, runId) {
+  return readJsonSafe(runFile(entityKey, runId), null);
+}
+
+// ---------- 会话索引 ----------
+
+function sessionsFile(entityKey) {
+  return path.join(AGENT_DIR, sanitizeKey(entityKey), 'sessions.json');
+}
+
+function loadSessions(entityKey) {
+  return readJsonSafe(sessionsFile(entityKey), { sessions: [] }).sessions;
+}
+
+function saveSessions(entityKey, sessions) {
+  writeJsonAtomic(sessionsFile(entityKey), { sessions });
+}
+
+/** 把一次运行登记进会话索引(会话不存在则以该问题为标题创建;双上限裁剪) */
+function upsertSessionRun(entityKey, { sessionId, runId, question, mode, status, at }) {
+  const sessions = loadSessions(entityKey);
+  let session = sessions.find((s) => s.sessionId === sessionId);
+  if (!session) {
+    session = { sessionId, title: String(question || '新会话').slice(0, 40), createdAt: at, runs: [] };
+    sessions.unshift(session);
+  }
+  if (!Array.isArray(session.runs)) session.runs = [];
+  session.updatedAt = at;
+  const entry = { runId, question: String(question || '').slice(0, 200), mode, status, at };
+  const idx = session.runs.findIndex((r) => r.runId === runId);
+  if (idx >= 0) session.runs[idx] = entry;
+  else session.runs.unshift(entry);
+  if (session.runs.length > RUNS_PER_SESSION) session.runs.length = RUNS_PER_SESSION;
+  saveSessions(entityKey, sessions.slice(0, MAX_SESSIONS));
+}
+
+/** 删除会话:摘除索引并清理该会话的全部运行文件 */
+function deleteSession(entityKey, sessionId) {
+  const sessions = loadSessions(entityKey);
+  const idx = sessions.findIndex((s) => s.sessionId === sessionId);
+  if (idx < 0) return false;
+  const [gone] = sessions.splice(idx, 1);
+  saveSessions(entityKey, sessions);
+  for (const r of gone.runs || []) {
+    try { fs.unlinkSync(runFile(entityKey, r.runId)); } catch { /* 已不存在 */ }
+  }
+  return true;
+}
+
+/**
+ * 压缩历史:取该会话最近若干次成功运行的「问 / 答」文字回灌为新运行的上文。
+ * 工具观察一律不带(防上下文随轮次膨胀),模型需要的数据重新取。
+ */
+function loadRecentConclusions(entityKey, sessionId, limit = 4) {
+  const session = loadSessions(entityKey).find((s) => s.sessionId === sessionId);
+  if (!session) return [];
+  const done = session.runs.filter((r) => r.status === 'done').slice(0, limit);
+  const out = [];
+  for (const r of done.reverse()) {
+    const rec = loadRun(entityKey, r.runId);
+    if (!rec) continue;
+    const answer = rec.result && rec.result.answer;
+    if (!answer) continue;
+    out.push({ role: 'user', content: rec.question });
+    out.push({ role: 'assistant', content: String(answer).slice(0, 1500) });
+  }
+  return out;
+}
+
+module.exports = {
+  AGENT_DIR, newRunRecord, pushEvent, persistRun, loadRun,
+  loadSessions, upsertSessionRun, deleteSession, loadRecentConclusions, newId, sanitizeKey,
+};

+ 16 - 2
server/context.js

@@ -54,17 +54,30 @@ function writeConfigFile(cfg) {
   fs.renameSync(temp, CONFIG_PATH);
 }
 
+// 嵌套默认值按键合并(一层足够):config.json 只写 aitable:{enabled:true} 时,
+// 组织级默认的 appKey/baseId/operatorId 不应被浅合并整块清掉
+function mergeDefaults(defaults, saved) {
+  const out = { ...defaults };
+  for (const [k, v] of Object.entries(saved || {})) {
+    out[k] = (v && typeof v === 'object' && !Array.isArray(v)
+      && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k]))
+      ? { ...out[k], ...v }
+      : v;
+  }
+  return out;
+}
+
 // 缺失/损坏时自动用模板(或内置默认)在本机生成新的 config.json —— 分发包开箱即用
 function loadConfig() {
   try {
-    return { ...CONFIG_DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
+    return mergeDefaults(CONFIG_DEFAULTS, JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')));
   } catch (e) {
     if (e.code !== 'ENOENT') {
       console.warn(`config.json 解析失败(${e.message || e}),已改用默认配置;保存设置后会重建该文件。`);
     }
     let seeded = {};
     try { seeded = JSON.parse(fs.readFileSync(CONFIG_EXAMPLE_PATH, 'utf8')); } catch { /* 无模板则用内置默认 */ }
-    const cfg = { ...CONFIG_DEFAULTS, ...seeded };
+    const cfg = mergeDefaults(CONFIG_DEFAULTS, seeded);
     try {
       writeConfigFile(cfg);
       console.log('已生成本机默认 config.json —— 请在「系统设置」中填写你自己的 API Key。');
@@ -299,4 +312,5 @@ module.exports = {
   NO_1K_SUPPORT,
   DEFAULT_CHAT_MODEL,
   makeChatCfg,
+  mergeDefaults,
 };

+ 36 - 4
server/middleware.js

@@ -7,6 +7,7 @@
 //   · 其余 → 500
 // 响应体形状与拆分前一致:{ error: message, code?: errCode }
 
+const crypto = require('crypto');
 const { sendJson } = require('./util.js');
 
 const ASK_CLIENT_CODES = new Set(['ASK_NO_MODEL', 'ASK_EMPTY', 'ASK_NO_ENTITY']);
@@ -60,6 +61,17 @@ function isLoopbackRemote(req) {
   return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
 }
 
+// 常量时间比较:LAN 场景防时序侧信道逐字节猜口令
+function safeEqual(a, b) {
+  const ba = Buffer.from(String(a));
+  const bb = Buffer.from(String(b));
+  if (ba.length !== bb.length) {
+    crypto.timingSafeEqual(ba, ba); // 长度不等也耗一遍比较,不泄漏长度信息
+    return false;
+  }
+  return crypto.timingSafeEqual(ba, bb);
+}
+
 function makeTokenGuard(config) {
   return function tokenGuard(req, pathname, searchParams) {
     const expected = config.accessToken;
@@ -67,11 +79,31 @@ function makeTokenGuard(config) {
     if (pathname === '/healthz' || pathname === '/readyz') return true;
     if (!pathname.startsWith('/api/') && !pathname.startsWith('/files')) return true;
     if (isLoopbackRemote(req)) return true;
-    if (searchParams.get('token') === expected) return true;
-    if (req.headers['x-access-token'] === expected) return true;
-    if (parseCookie(req.headers.cookie).accessToken === expected) return true;
+    if (safeEqual(searchParams.get('token') || '', expected)) return true;
+    if (safeEqual(req.headers['x-access-token'] || '', expected)) return true;
+    if (safeEqual(parseCookie(req.headers.cookie).accessToken || '', expected)) return true;
     return false;
   };
 }
 
-module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard };
+// ---------- 本地 CSRF 防护 ----------
+// 回环请求在口令守卫里全放行,意味着用户浏览器里任意网页都能向 127.0.0.1 打
+// no-cors POST(不带自定义头也能被 readBody 解析)。浏览器发起的跨站写请求必带
+// Origin;本站同源请求的 Origin 与 Host 一致;非浏览器客户端(curl/脚本)不带
+// Origin → 放行,仍受口令守卫约束。
+
+function makeOriginGuard() {
+  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
+  return function originGuard(req) {
+    if (SAFE_METHODS.has(req.method)) return true;
+    const origin = req.headers.origin;
+    if (!origin) return true;
+    try {
+      return new URL(origin).host === (req.headers.host || '');
+    } catch {
+      return false;
+    }
+  };
+}
+
+module.exports = { ASK_CLIENT_CODES, errorStatus, sendError, errorBoundary, makeTokenGuard, makeOriginGuard };

+ 91 - 0
server/routes/agent.js

@@ -0,0 +1,91 @@
+'use strict';
+
+// 智能问数智能体路由:运行生命周期(异步执行 + 轮询)与会话索引
+//   POST /api/agent/runs            发起运行(agent/quick 双模式),立即返回 run 记录
+//   GET  /api/agent/runs            会话内运行摘要列表(?entity&sessionId)
+//   GET  /api/agent/runs/:id        运行详情(含步骤/产物;运行中为实时进度)
+//   POST /api/agent/runs/:id/cancel 请求取消(回合间隙生效)
+//   GET  /api/agent/sessions        会话列表(?entity)
+
+const { sendJson, readBody } = require('../util.js');
+const { ASK_CLIENT_CODES } = require('../middleware.js');
+const { makeChatCfg } = require('../context.js');
+const naposService = require('../../napos-service.js');
+const aitableFactory = require('../services/aitable.js');
+const agent = require('../agent/index.js');
+const agentSkills = require('../agent/skills.js');
+
+function requireEntity(query, body) {
+  const entity = (body && body.entity) || query.entity
+    || (naposService.store.listEntities()[0] || {}).key;
+  if (!entity) {
+    const e = new Error('暂无数据主体,请先完成一次同步');
+    e.code = 'ASK_NO_ENTITY';
+    throw e;
+  }
+  return entity;
+}
+
+function register(app, ctx) {
+  // 云端协同(湖↔云比对工具用):复用 server.js 装配的服务实例(无则兜底自建)
+  const aitableSvc = ctx.aitable || aitableFactory.createAitableService(ctx);
+  app.post('/api/agent/runs', async ({ req, res }) => {
+    const body = await readBody(req);
+    const entity = requireEntity({}, body);
+    // 参数类错误(空问题/未配模型/无主体)→ 400;其余交给错误边界
+    try {
+      const rec = agent.startRun({
+        entity,
+        question: body.question,
+        mode: ['quick', 'agent', 'auto'].includes(body.mode) ? body.mode : 'auto',
+        sessionId: body.sessionId,
+        skill: body.skill,
+        cloud: aitableSvc,
+        cfg: makeChatCfg(ctx),
+        svc: naposService,
+      });
+      sendJson(res, 200, { run: rec });
+    } catch (e) {
+      if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502;
+      throw e;
+    }
+  });
+
+  app.get('/api/agent/runs', async ({ res, query }) => {
+    const entity = requireEntity(query);
+    if (!query.sessionId) return sendJson(res, 400, { error: '缺少 sessionId 参数' });
+    sendJson(res, 200, { entity, runs: agent.listRuns(entity, query.sessionId) });
+  });
+
+  app.get('/api/agent/runs/:id', async ({ res, query, params }) => {
+    const entity = requireEntity(query);
+    const rec = agent.getRun(entity, params.id);
+    if (!rec) return sendJson(res, 404, { error: '运行记录不存在' });
+    sendJson(res, 200, { run: rec });
+  });
+
+  app.post('/api/agent/runs/:id/cancel', async ({ req, res, params }) => {
+    await readBody(req).catch(() => ({}));
+    const rec = agent.cancelRun(params.id);
+    if (!rec) return sendJson(res, 404, { error: '运行记录不存在' });
+    sendJson(res, 200, { run: rec });
+  });
+
+  app.get('/api/agent/sessions', async ({ res, query }) => {
+    const entity = requireEntity(query);
+    sendJson(res, 200, { entity, sessions: agent.listSessions(entity) });
+  });
+
+  app.del('/api/agent/sessions/:id', async ({ res, query, params }) => {
+    const entity = requireEntity(query);
+    const ok = agent.deleteSession(entity, params.id);
+    if (!ok) return sendJson(res, 404, { error: '会话不存在' });
+    sendJson(res, 200, { ok: true });
+  });
+
+  app.get('/api/agent/skills', async ({ res }) => {
+    sendJson(res, 200, { skills: agentSkills.SKILLS.map(({ key, name, desc }) => ({ key, name, desc })) });
+  });
+}
+
+module.exports = { register };

+ 1 - 0
server/routes/index.js

@@ -10,6 +10,7 @@ function registerRoutes(app, ctx) {
   require('./generate.js').register(app, ctx);
   require('./history.js').register(app, ctx);
   require('./reports.js').register(app, ctx);
+  require('./agent.js').register(app, ctx);
   require('./static.js').register(app, ctx);
 }
 

+ 5 - 6
server/routes/reports.js

@@ -264,18 +264,17 @@ function register(app, ctx) {
     }
     const columns = naposService.REPORT_COLUMNS;
     const csv = naposService.rowsToCsv(columns, rows);
+    // 文件名只用消毒过的主体 key 与合法日期,避免畸形参数把响应头打出 500
+    const safeEntity = String(query.entity || 'all').replace(/[^\w-]/g, '') || 'all';
+    const safeFrom = /^\d{8}$/.test(query.from || '') ? query.from : '';
+    const safeTo = /^\d{8}$/.test(query.to || '') ? query.to : '';
     res.writeHead(200, {
       'Content-Type': 'text/csv; charset=utf-8',
-      'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
+      'Content-Disposition': `attachment; filename="report-${safeEntity}-${safeFrom}-${safeTo}.csv"`,
     });
     return res.end(csv);
   });
 
-  app.get('/api/reports/syncs', async ({ res, query }) => {
-    const key = naposService.store.sanitizeKey(query.entity);
-    sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
-  });
-
   app.del('/api/reports/data', async ({ res, query }) => {
     if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
     naposService.store.deleteEntityData(query.entity);

+ 1 - 0
server/routes/static.js

@@ -16,6 +16,7 @@ const STATIC_MAP = {
   '/analysis-templates.js': ['analysis-templates.js', 'application/javascript; charset=utf-8'],
   '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
   '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
+  '/ask.js': ['ask.js', 'application/javascript; charset=utf-8'],
   '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],
 };

+ 30 - 1
server/services/aitable.js

@@ -250,6 +250,8 @@ function createAitableService(ctx) {
 
   // ---------- 推送:本地湖 → AI 表格(三表) ----------
 
+  let pushing = false; // 单飞锁:手动推送与自动同步的 fire-and-forget 推送不得并发
+
   const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
 
   function readPushState() {
@@ -317,6 +319,16 @@ function createAitableService(ctx) {
   /** 推送一个主体的数据到三张表。force=true 时忽略水位推全量、并跳过冲突/全零保护。
    *  水位按「主体 + 三表名指纹」记录:表名变化(如单表→三表迁移)自动降级为全量重推。 */
   async function pushLake(entity, opts = {}) {
+    if (pushing) throw err('已有推送在进行中,请稍后再试', 'AITABLE_PUSH_BUSY');
+    pushing = true;
+    try {
+      return await pushLakeLocked(entity, opts);
+    } finally {
+      pushing = false;
+    }
+  }
+
+  async function pushLakeLocked(entity, opts = {}) {
     const cfg = requireConfig();
     const naposService = require('../../napos-service.js');
     const key = naposService.store.sanitizeKey(entity);
@@ -452,8 +464,25 @@ function createAitableService(ctx) {
     return all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark).length;
   }
 
+  /** 湖↔云只读比对快照(问数智能体 aitable_verify 工具用):
+   *  三表记录按当前主体账号名过滤后整表拉回,不做任何写入。 */
+  async function snapshotForVerify(entity) {
+    const cfg = requireConfig();
+    const naposService = require('../../napos-service.js');
+    const { sheets } = resolveSheets(cfg);
+    const name = (naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity;
+    const out = {};
+    for (const [scope, sheetName] of [['shop', sheets.detail], ['chain', sheets.chain], ['company', sheets.company]]) {
+      const records = await listAllRecords(cfg, sheetName);
+      out[scope] = records
+        .map((rec) => recordToRow(rec, name))
+        .filter((r) => String(r.account || '') === name);
+    }
+    return out;
+  }
+
   logs.pushSystemLog('钉钉 AI 表格同步服务已就绪(三表:门店明细/连锁汇总/公司汇总)', 'info');
-  return { pushLake, pullLake, testConnection, lastPushInfo, pendingCount };
+  return { pushLake, pullLake, testConnection, lastPushInfo, pendingCount, snapshotForVerify };
 }
 
 module.exports = {

+ 24 - 1
server/services/generation.js

@@ -127,8 +127,11 @@ function createGenerationService(ctx) {
     const { addLogo, logoPosition, logoMode, addSticker, stickerPosition, stickerMode } = opts || {};
     const images = [];
     for (const f of (refDishFiles || []).slice(0, 3)) {
+      // 参考图名来自请求体,必须钳在店铺目录内,否则可借 data URI 读任意本地文件
+      const refPath = path.resolve(shopDir, String(f || ''));
+      if (!refPath.startsWith(shopDir + path.sep)) continue;
       try {
-        images.push(readAsDataUri(path.join(shopDir, f)));
+        images.push(readAsDataUri(refPath));
       } catch {
         // skip unreadable reference image
       }
@@ -447,6 +450,24 @@ function createGenerationService(ctx) {
     runJob(job);
   }
 
+  // jobs Map 只进不出:终态任务保留最近 MAX_JOBS 条,更旧的连同 tasks.json 记录一起清掉
+  //(参考图 data URI 已在终态释放,这里防的是长期运行下任务条目本身无限累积)
+  function pruneFinishedJobs() {
+    const MAX_JOBS = 200;
+    if (jobs.size <= MAX_JOBS) return;
+    const finished = [...jobs.values()]
+      .filter((j) => j.state === 'success' || j.state === 'failed')
+      .sort((a, b) => (a.finishedAt || 0) - (b.finishedAt || 0));
+    const drop = finished.slice(0, jobs.size - MAX_JOBS);
+    for (const j of drop) {
+      jobs.delete(j.id);
+      persistedTasks.delete(j.id);
+    }
+    if (drop.length) {
+      try { writeTaskStore(); } catch { /* ignore */ }
+    }
+  }
+
   function runJob(job) {
     activeCount++;
     job.state = 'running';
@@ -461,8 +482,10 @@ function createGenerationService(ctx) {
         const jobTitle = job.dishName ? `${job.shopName} / ${job.dishName}` : `${job.shopName} / ${job.kind}`;
         if (job.state === 'success') logs.pushSystemLog(`生成完成:${jobTitle}`, 'success');
         else if (job.state === 'failed') logs.pushSystemLog(`生成失败:${jobTitle},${job.error || '未知错误'}`, 'error');
+        job.images = []; // 终态后参考图 data URI(可达数 MB/张)不再需要,立即释放
         job.finishedAt = Date.now();
         persistJob(job, { urgent: true });
+        pruneFinishedJobs();
         activeCount--;
         const nextId = pendingQueue.shift();
         if (nextId) {