eval-ask.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. #!/usr/bin/env node
  2. 'use strict';
  3. /**
  4. * 智能问数 · 金标准评测(口径回归)
  5. * ----------------------------------------------------------------------------
  6. * 在隔离数据湖上直跑工具链(sanitize → handler),对真实业务问句断言
  7. * 「期望工具 + 期望关键数字」。专测口径:加权比率、可比门店集、缺失日、
  8. * 层间一致性、同行对标、跨主体对比与参数防呆。零网络、零真实模型。
  9. *
  10. * 与 smoke 的分工:smoke 管「端点行为没坏」,本脚本管「数字算得对不对」——
  11. * 改 prompt / 工具 / 口径 / 换模型前后都应跑一遍:node scripts/eval-ask.js
  12. *
  13. * 末尾附「真机金标准问句清单」:接真实模型后人工抽查的多轮与表述变体。
  14. */
  15. const fs = require('fs');
  16. const os = require('os');
  17. const path = require('path');
  18. const ROOT = path.resolve(__dirname, '..');
  19. // ---------- 隔离湖夹具(数字为断言而设计) ----------
  20. // 主体 eval「金标准主体」:一食堂(101)/二食堂(102)@第一餐厅 + 三食堂(103)@第二餐厅
  21. // 本期 20261001-1007(7 天全齐);上期 0924-0930 中 0925 全体缺数、二食堂 0927 缺数;
  22. // 三食堂仅本期 1004-1007 有数(模拟新开店,测可比集剔除)。
  23. // 关键期望值:compare 可比集(一+二) 10500 vs 7900(+32.9%);加权进店率 0.0709
  24. // (算术平均会是 0.0567,故意设计两套算法可区分)。
  25. function mkRow(date, scope, ids, m) {
  26. return {
  27. date, scope,
  28. shopId: ids.shopId || '', shopName: ids.shopName || '',
  29. chainId: ids.chainId || '', chainName: ids.chainName || '',
  30. bizHours: null, abnormalCloseHours: null, goodsSaleAmt: null,
  31. syncedAt: new Date().toISOString(),
  32. validOrderCnt: m.orders || 0, incomeAmt: m.income || 0, grossAmt: m.gross || 0,
  33. expenseAmt: m.expense || 0, expUv: m.uv || 0, orderUserCnt: m.buyers || 0,
  34. clkExpRate: m.clk || 0, ordClkRate: 0, perActualPay: 0,
  35. actOrdCnt: 0, actSubsidyAmt: 0, shopRoi: 0, rebuyRate30d: 0,
  36. raw: {
  37. gross_amt: m.gross || 0, valid_order_cnt: m.orders || 0,
  38. gross_amt_mall_avg: 800,
  39. gross_amt_mall_avg_rank: m.rank != null ? m.rank : 9,
  40. valid_order_cnt_diff: m.orders || 0,
  41. },
  42. };
  43. }
  44. function buildFixture(lakeDir) {
  45. const now = new Date().toISOString();
  46. const rows = [];
  47. const put = (date, scope, ids, m) => rows.push(mkRow(date, scope, ids, m));
  48. const A = { shopId: '101', shopName: '一食堂', chainId: '9', chainName: '第一餐厅' };
  49. const B = { shopId: '102', shopName: '二食堂', chainId: '9', chainName: '第一餐厅' };
  50. const C = { shopId: '103', shopName: '三食堂', chainId: '8', chainName: '第二餐厅' };
  51. const curDays = ['20261001', '20261002', '20261003', '20261004', '20261005', '20261006', '20261007'];
  52. const prevDays = ['20260924', '20260926', '20260927', '20260928', '20260929', '20260930'];
  53. const Acur = { gross: 1000, income: 800, expense: 200, orders: 100, uv: 2000, clk: 0.10, rank: 1 };
  54. const Aprev = { gross: 900, income: 720, expense: 180, orders: 90, uv: 1800, clk: 0.10, rank: 1 };
  55. const Bcur = { gross: 500, income: 400, expense: 100, orders: 40, uv: 1000, clk: 0.02, rank: 4 };
  56. const Bprev = { gross: 500, income: 400, expense: 100, orders: 40, uv: 1000, clk: 0.02, rank: 4 };
  57. const Cm = { gross: 300, income: 240, expense: 60, orders: 30, uv: 600, clk: 0.05, rank: 6 };
  58. for (const d of curDays) {
  59. put(d, 'shop', A, Acur);
  60. put(d, 'shop', B, Bcur);
  61. put(d, 'chain', { chainId: '9', chainName: '第一餐厅' }, { gross: 1500, income: 1200, expense: 300, orders: 140, uv: 3000, clk: 0.08 });
  62. if (d >= '20261004') {
  63. put(d, 'shop', C, Cm);
  64. put(d, 'chain', { chainId: '8', chainName: '第二餐厅' }, { gross: 300, income: 240, expense: 60, orders: 30, uv: 600, buyers: 30, clk: 0.05 });
  65. }
  66. const c = { gross: d >= '20261004' ? 1800 : 1500, income: d >= '20261004' ? 1440 : 1200, expense: d >= '20261004' ? 360 : 300, orders: d >= '20261004' ? 170 : 140, uv: d >= '20261004' ? 3600 : 3000, clk: 0.08 };
  67. put(d, 'company', { chainId: '-1' }, c);
  68. }
  69. for (const d of prevDays) {
  70. put(d, 'shop', A, Aprev);
  71. if (d !== '20260927') {
  72. put(d, 'shop', B, Bprev);
  73. // 连锁/公司行 = 门店合计(一 900 + 二 500)
  74. put(d, 'chain', { chainId: '9', chainName: '第一餐厅' }, { gross: 1400, income: 1120, expense: 280, orders: 130, uv: 2800, clk: 0.08 });
  75. put(d, 'company', { chainId: '-1' }, { gross: 1400, income: 1120, expense: 280, orders: 130, uv: 2800, clk: 0.08 });
  76. } else {
  77. // 二食堂缺数日:连锁/公司行 = 仅一食堂
  78. put(d, 'chain', { chainId: '9', chainName: '第一餐厅' }, { gross: 900, income: 720, expense: 180, orders: 90, uv: 1800, clk: 0.10 });
  79. put(d, 'company', { chainId: '-1' }, { gross: 900, income: 720, expense: 180, orders: 90, uv: 1800, clk: 0.10 });
  80. }
  81. }
  82. fs.writeFileSync(path.join(lakeDir, 'entities.json'), JSON.stringify({ entities: [
  83. { key: 'eval', firstSeen: now, lastSeen: now, merchantId: 'eval', name: '金标准主体', chainCount: 2, shopCount: 3,
  84. chains: [{ chainId: '9', chainName: '第一餐厅' }, { chainId: '8', chainName: '第二餐厅' }],
  85. shops: [
  86. { shopId: '101', shopName: '一食堂', chainId: '9', chainName: '第一餐厅' },
  87. { shopId: '102', shopName: '二食堂', chainId: '9', chainName: '第一餐厅' },
  88. { shopId: '103', shopName: '三食堂', chainId: '8', chainName: '第二餐厅' },
  89. ] },
  90. { key: 'evalb', firstSeen: now, lastSeen: now, merchantId: 'evalb', name: '对照主体', chainCount: 1, shopCount: 1,
  91. chains: [{ chainId: '8', chainName: '第二餐厅' }],
  92. shops: [{ shopId: '201', shopName: '样本店', chainId: '8', chainName: '第二餐厅' }] },
  93. ] }));
  94. fs.mkdirSync(path.join(lakeDir, 'eval'), { recursive: true });
  95. const byMonth = (list) => JSON.stringify({ version: 2, updated: now, rows: list });
  96. fs.writeFileSync(path.join(lakeDir, 'eval', 'daily-202609.json'), byMonth(rows.filter((r) => r.date < '20261000')));
  97. fs.writeFileSync(path.join(lakeDir, 'eval', 'daily-202610.json'), byMonth(rows.filter((r) => r.date >= '20261000')));
  98. // 异常检测主体:6 天 1000 + 尾日 3000(统计尖峰,LOO z ≈ 6.2)
  99. fs.mkdirSync(path.join(lakeDir, 'evalc'), { recursive: true });
  100. const cRows = curDays.map((d) => mkRow(d, 'shop', { shopId: '301', shopName: '爆量店', chainId: '8', chainName: '第二餐厅' }, { gross: d === '20261007' ? 3000 : 1000, income: d === '20261007' ? 2400 : 800, expense: d === '20261007' ? 600 : 200, orders: 100, uv: 2000, clk: 0.08 }));
  101. fs.writeFileSync(path.join(lakeDir, 'evalc', 'daily-202610.json'), byMonth(cRows));
  102. // entities.json 追加 evalc
  103. const entities = JSON.parse(fs.readFileSync(path.join(lakeDir, 'entities.json'), 'utf8'));
  104. entities.entities.push({ key: 'evalc', firstSeen: now, lastSeen: now, merchantId: 'evalc', name: '异常主体', chainCount: 1, shopCount: 1,
  105. chains: [{ chainId: '8', chainName: '第二餐厅' }],
  106. shops: [{ shopId: '301', shopName: '爆量店', chainId: '8', chainName: '第二餐厅' }] });
  107. fs.writeFileSync(path.join(lakeDir, 'entities.json'), JSON.stringify(entities));
  108. }
  109. // ---------- 评测执行 ----------
  110. const results = [];
  111. function check(name, fn) {
  112. try { fn(); results.push({ name, ok: true }); }
  113. catch (e) { results.push({ name, ok: false, error: e.message }); }
  114. }
  115. function expect(cond, msg) { if (!cond) throw new Error(msg); }
  116. function near(a, b, tol) { return Math.abs(a - b) <= (tol || 1e-6); }
  117. function main() {
  118. const lakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'genpic-eval-'));
  119. process.env.GENPIC_LAKE_DIR = lakeDir;
  120. buildFixture(lakeDir);
  121. // env 先行,require 在后:确保湖指向隔离目录
  122. const svc = require(path.join(ROOT, 'napos-service.js'));
  123. const nlQuery = require(path.join(ROOT, 'nl-query.js'));
  124. const registry = require(path.join(ROOT, 'server/agent/tools/registry.js'));
  125. const meta = svc.store.getEntity('eval');
  126. const ctx = { svc, entityKey: 'eval', meta, shops: meta.shops || [], dateRange: nlQuery.lakeDateRange('eval', svc) };
  127. const run = (toolName, args) => {
  128. const tool = registry.get(toolName);
  129. expect(tool, `工具未注册:${toolName}`);
  130. return tool.handler(ctx, tool.sanitize(args || {}, ctx));
  131. };
  132. const runRaw = (toolName, args) => {
  133. const tool = registry.get(toolName);
  134. return tool.sanitize(args || {}, ctx);
  135. };
  136. // ---- 查询口径 ----
  137. check('TopN 排行:一食堂 7000 > 二食堂 3500(二食堂上期缺数不影响本期)', () => {
  138. const out = run('query_report', { mode: 'shop', from: '20261001', to: '20261007', sort: 'grossAmt', limit: 2 });
  139. expect(out.data.rows.length === 2, `行数 ${out.data.rows.length}`);
  140. expect(out.data.rows[0].shopName === '一食堂' && out.data.rows[0].grossAmt === 7000, JSON.stringify(out.data.rows[0]));
  141. expect(out.data.rows[1].shopName === '二食堂' && out.data.rows[1].grossAmt === 3500, JSON.stringify(out.data.rows[1]));
  142. });
  143. check('按日趋势:7 天、缺数日不含三食堂时合计正确', () => {
  144. const out = run('query_report', { mode: 'date', from: '20261001', to: '20261007', metrics: ['grossAmt', 'validOrderCnt'] });
  145. expect(out.data.rows.length === 7, `行数 ${out.data.rows.length}`);
  146. const d3 = out.data.rows.find((r) => r.date === '20261003');
  147. const d5 = out.data.rows.find((r) => r.date === '20261005');
  148. expect(d3.grossAmt === 1500 && d5.grossAmt === 1800, `1003=${d3.grossAmt} 1005=${d5.grossAmt}`);
  149. expect(out.data.summary.grossAmt === 11700 && out.data.summary.validOrderCnt === 1100, JSON.stringify(out.data.summary));
  150. });
  151. check('加权比率:进店率按曝光加权 0.0709(算术平均 0.0567 是错的)', () => {
  152. const out = run('query_report', { mode: 'shop', from: '20261001', to: '20261007', metrics: ['clkExpRate'] });
  153. const w = out.data.summary.clkExpRate;
  154. expect(near(w, 0.0709, 1e-6), `加权进店率 ${w}(期望 0.0709)`);
  155. expect(Math.abs(w - 0.0567) > 0.001, '与算术平均不可区分,加权实现可疑');
  156. });
  157. check('门店部分匹配:问「二」只出二食堂', () => {
  158. const out = run('query_report', { mode: 'shop', from: '20261001', to: '20261007', shops: ['二'] });
  159. expect(out.data.rows.length === 1 && out.data.rows[0].shopName === '二食堂' && out.data.rows[0].grossAmt === 3500,
  160. JSON.stringify(out.data.rows));
  161. });
  162. // ---- 同比环比口径 ----
  163. check('环比:可比集剔除新开店,10500 vs 7900(+32.9%)', () => {
  164. const out = run('compare_periods', {});
  165. const gross = out.data.deltas.find((d) => d.key === 'grossAmt');
  166. expect(gross.cur === 10500 && gross.prev === 7900, `环比数字 ${JSON.stringify(gross)}`);
  167. expect(near(gross.deltaPct, 0.3291, 0.0005), `变化率 ${gross.deltaPct}`);
  168. expect(out.data.comparableShopCount === 2, `可比门店 ${out.data.comparableShopCount}`);
  169. expect(out.data.opened.length === 1 && out.data.opened[0].shopName === '三食堂', '新开店未单列');
  170. expect(out.data.missingDays.length === 0, '本期不应缺日');
  171. });
  172. check('环比 verdict:恒等式通过 + 可比集警告(有新开店)', () => {
  173. const out = run('compare_periods', {});
  174. const vs = out.data.verdicts || [];
  175. expect(vs.filter((v) => v.kind === 'identity' && v.status === 'ok').length === 2, '恒等式 verdict 异常');
  176. const cov = vs.find((v) => v.kind === 'coverage');
  177. expect(cov && cov.status === 'warn' && /新出现/.test(cov.detail), `coverage verdict 异常:${JSON.stringify(cov)}`);
  178. });
  179. check('显式区间环比:等长平移取上期,缺数按实算', () => {
  180. const out = run('compare_periods', { from: '20261001', to: '20261003' });
  181. const gross = out.data.deltas.find((d) => d.key === 'grossAmt');
  182. // 基期 = 0928-0930(三天全齐):一 2700 + 二 1500 = 4200;本期 = 一 3000 + 二 1500 = 4500
  183. expect(gross.cur === 4500 && gross.prev === 4200, `环比数字 ${JSON.stringify(gross)}`);
  184. expect(near(gross.deltaPct, 0.0714, 0.0005), `变化率 ${gross.deltaPct}`);
  185. });
  186. check('显式区间环比:上期缺数日按实算不虚构(0925 全体缺数、0927 二食堂缺数)', () => {
  187. const out = run('compare_periods', { from: '20260926', to: '20260928' });
  188. const gross = out.data.deltas.find((d) => d.key === 'grossAmt');
  189. // 本期窗口 0926-0928:1400 + 900 + 1400 = 3700;基期 0923(无)/0924(1400)/0925(无) = 1400
  190. expect(gross.cur === 3700 && gross.prev === 1400, `环比数字 ${JSON.stringify(gross)}`);
  191. });
  192. // ---- 指标层(派生指标与聚合口径) ----
  193. check('指标层:基础指标逐日序列(按门店行聚合)', () => {
  194. const out = run('metric_query', { metric: 'grossAmt', from: '20261001', to: '20261003' });
  195. const v = Object.fromEntries(out.data.series.map((s) => [s.date, s.value]));
  196. expect(v['20261001'] === 1500 && v['20261003'] === 1500, JSON.stringify(out.data.series));
  197. });
  198. check('指标层:7 日移动平均(窗口前置 + 缺数按实算)', () => {
  199. const out = run('metric_query', { metric: 'gross_mov7', from: '20261001', to: '20261007' });
  200. const v = Object.fromEntries(out.data.series.map((s) => [s.date, s.value]));
  201. // 1007:mean(1001..1007) = (1500×3 + 1800×4)/7 ≈ 1671.43
  202. expect(near(v['20261007'], 11700 / 7, 0.01), `1007 mov7 ${v['20261007']}`);
  203. // 1001:窗口 0925(缺)/0926(1400)/0927(900)/0928(1400)/0929(1400)/0930(1400)/1001(1500) → 8000/6 ≈ 1333.33
  204. expect(near(v['20261001'], 8000 / 6, 0.01), `1001 mov7 ${v['20261001']}`);
  205. });
  206. check('指标层:加权比率日值(进店率按当日曝光加权)', () => {
  207. const out = run('metric_query', { metric: 'clkExpRate', from: '20261001', to: '20261001' });
  208. const v = out.data.series[0].value;
  209. // 1001:一 (0.10×2000) + 二 (0.02×1000) = 220 / 3000 ≈ 0.0733(三食堂 1004 才开业)
  210. expect(near(v, 220 / 3000, 0.0001), `加权日值 ${v}`);
  211. });
  212. check('指标层:未知指标被拒绝并列出可用清单', () => {
  213. let rejected = false;
  214. try { run('metric_query', { metric: 'no_such' }); } catch (e) { rejected = /可用指标/.test(e.message); }
  215. expect(rejected, '未知指标未拒绝或未给清单');
  216. });
  217. // ---- 质量与校验 ----
  218. check('数据体检:缺 1/14 天为 info、无全零误报', () => {
  219. const out = run('data_quality', {});
  220. expect(out.summary.includes('缺 1/14'), `缺日未识别:${out.summary}`);
  221. expect(out.summary.includes('未发现全零行'), `全零误报:${out.summary}`);
  222. expect(!out.summary.includes('[fail]'), `健康湖误报 fail:${out.summary}`);
  223. expect((out.data.verdicts || []).length >= 5, 'verdicts 未产出');
  224. });
  225. check('层间核对:13 天 × 双指标全一致', () => {
  226. const out = run('cross_check', {});
  227. expect(out.data.checked === 52 && out.data.mismatchCount === 0 && out.data.severe === false,
  228. `checked=${out.data.checked} mismatch=${out.data.mismatchCount}`);
  229. });
  230. // ---- 同行对标与源侧 ----
  231. check('同行对标:排名升序、自身日均与同行均值正确', () => {
  232. const out = run('peer_benchmark', { field: 'gross_amt' });
  233. const shops = out.data.shops;
  234. expect(shops.length === 3, `门店数 ${shops.length}`);
  235. expect(shops[0].shopName === '一食堂' && shops[0].rankAvg === 1 && shops[0].peerAvg === 800, JSON.stringify(shops[0]));
  236. expect(near(shops[0].own, 953.85, 0.01), `自身日均 ${shops[0].own}(期望 (6×900+7×1000)/13=953.85)`);
  237. expect(shops[2].rankAvg === 6, `末位排名 ${shops[2].rankAvg}`);
  238. });
  239. check('源侧字段:raw_inspect 按包含匹配取到 mall 均值与排名', () => {
  240. const out = run('raw_inspect', { date: '20261001', shops: ['一食堂'], keys: ['gross_amt'] });
  241. expect(out.data.found && out.data.hasRaw && out.data.matched.length >= 3, JSON.stringify(out.data));
  242. expect(out.data.values.gross_amt_mall_avg === 800, `均值 ${JSON.stringify(out.data.values)}`);
  243. });
  244. // ---- 跨主体与报告 ----
  245. check('跨主体对比:两主体按本期营业额降序', () => {
  246. const out = run('cross_entity', {});
  247. expect(out.data.entities.length === 2, `主体数 ${out.data.entities.length}`);
  248. expect(out.data.entities[0].name === '金标准主体' && out.data.entities[0].cur.gross === 11700, JSON.stringify(out.data.entities[0]));
  249. expect(near(out.data.entities[0].grossDeltaPct, 48.1, 0.05), `环比 ${out.data.entities[0].grossDeltaPct}`);
  250. });
  251. check('分析报告:URL 组装且区间被钳制', () => {
  252. const out = run('build_report', { from: '20200101', to: '20991231' });
  253. expect(out.data.url.includes('entity=eval') && out.data.url.includes('from=20260924') && out.data.url.includes('to=20261007'),
  254. out.data.url);
  255. });
  256. check('分析报告:deep 旗切换到深度报告端点', () => {
  257. const out = run('build_report', { deep: true });
  258. expect(out.data.url.includes('/api/agent/report-deep'), out.data.url);
  259. });
  260. // ---- 统计异常检测与 LMDI 归因 ----
  261. check('异常检测:尖峰日 LOO z 判 fail,正常日不误报', () => {
  262. const ctxC = { svc, entityKey: 'evalc', meta: svc.store.getEntity('evalc'), shops: svc.store.getEntity('evalc').shops || [], dateRange: nlQuery.lakeDateRange('evalc', svc) };
  263. const out = registry.get('detect_anomalies').handler(ctxC, registry.get('detect_anomalies').sanitize({ metric: 'grossAmt', from: '20261001', to: '20261007' }, ctxC));
  264. expect(out.data.anomalies.length === 1 && out.data.anomalies[0].date === '20261007', JSON.stringify(out.data.anomalies));
  265. // LOO 基线 = 其余 6 天(均值 1000、σ 下限 100)→ z = 2000/100 = 20
  266. expect(out.data.anomalies[0].z >= 10, `z ${out.data.anomalies[0].z}`);
  267. expect(out.data.verdicts[0].status === 'fail' && out.data.verdicts[0].kind === 'anomaly', JSON.stringify(out.data.verdicts));
  268. });
  269. check('异常检测:健康序列不误报', () => {
  270. const out = run('detect_anomalies', { metric: 'grossAmt', from: '20261001', to: '20261007' });
  271. expect(out.data.anomalies.length === 0 && out.data.verdicts[0].status === 'ok', JSON.stringify(out.data.anomalies));
  272. });
  273. check('LMDI 归因:单店且仅曝光变化时 曝光效应=全部变化(残差≈0)', () => {
  274. const out = run('attribution', { from: '20261001', to: '20261007', shops: ['一食堂'] });
  275. const uv = out.data.factors.find((f) => f.key === 'expUv');
  276. const clk = out.data.factors.find((f) => f.key === 'clkExpRate');
  277. expect(near(uv.contribution, 1600, 0.5), `曝光效应 ${uv.contribution}`);
  278. expect(Math.abs(clk.contribution) < 0.01, `进店率效应应为 0:${clk.contribution}`);
  279. expect(Math.abs(out.data.residual) < 1, `残差 ${out.data.residual}`);
  280. });
  281. check('LMDI 归因:多店聚合输出混合残差说明', () => {
  282. const out = run('attribution', {});
  283. expect(out.data.residual != null && out.data.factors.length === 3, JSON.stringify(out.data.factors));
  284. });
  285. // ---- 参数防呆 ----
  286. check('防呆:不存在的门店/非法字段/缺日期必须拒绝', () => {
  287. for (const [tool, args] of [
  288. ['compare_periods', { shops: ['不存在的店'] }],
  289. ['peer_benchmark', { field: 'no_such_field' }],
  290. ['raw_inspect', {}],
  291. ]) {
  292. let rejected = false;
  293. try { runRaw(tool, args); } catch { rejected = true; }
  294. expect(rejected, `${tool} 未拒绝非法参数:${JSON.stringify(args)}`);
  295. }
  296. });
  297. // ---- 报告 ----
  298. const failed = results.filter((r) => !r.ok);
  299. console.log('\n========== 问数金标准评测 ==========');
  300. for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : ' ← ' + r.error}`);
  301. console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
  302. console.log('\n—— 真机金标准问句清单(接真实模型后人工抽查,覆盖表述变体与多轮) ——');
  303. console.log('1. 上月营业额Top5门店 → 查询排行,数字与湖一致');
  304. console.log('2. 最近7天营业额和单量趋势 → 按日折线,7 个点');
  305. console.log('3. 本期比上期营业额变化多少?主要受哪些门店影响 → 可比集口径 + 主因店');
  306. console.log('4. 二食堂最近进店转化率怎么样 → 加权比率(对照表格页数字)');
  307. console.log('5. 那一食堂呢(追问) → 上下文门店切换');
  308. console.log('6. 做一次数据体检,看看有没有问题 → 分级问题清单');
  309. console.log('7. 哪家店同行对标掉队了 → peer_benchmark + 排名变化');
  310. console.log('8. 几个食堂里哪家最近表现更好 → cross_entity');
  311. console.log('9. 云端和本地的数据一致吗 → aitable_verify');
  312. console.log('10. 给我出一份上周的经营报告 → build_report 链接可打开');
  313. try { fs.rmSync(lakeDir, { recursive: true, force: true }); } catch { /* ignore */ }
  314. if (failed.length) process.exit(1);
  315. }
  316. main();