|
|
@@ -10,6 +10,7 @@
|
|
|
const { spawn, spawnSync } = require('child_process');
|
|
|
const net = require('net');
|
|
|
const http = require('http');
|
|
|
+const fs = require('fs');
|
|
|
const os = require('os');
|
|
|
const path = require('path');
|
|
|
|
|
|
@@ -113,8 +114,14 @@ function enc(s) { return encodeURIComponent(s); }
|
|
|
|
|
|
async function main() {
|
|
|
const port = await pickPort();
|
|
|
+ // 主服务隔离:临时湖 + 临时配置(aitable 关闭)——smoke 绝不触碰本机数据湖、config.json 与真实云端凭据
|
|
|
+ const tmpMain = fs.mkdtempSync(path.join(os.tmpdir(), 'genpic-smoke-main-'));
|
|
|
+ const tmpMainLake = path.join(tmpMain, 'lake');
|
|
|
+ fs.mkdirSync(tmpMainLake, { recursive: true });
|
|
|
+ const tmpMainConfig = path.join(tmpMain, 'config.json');
|
|
|
+ fs.writeFileSync(tmpMainConfig, JSON.stringify({ aitable: { enabled: false } }));
|
|
|
const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
|
|
|
- env: { ...process.env, PORT: String(port), HOST: '127.0.0.1' },
|
|
|
+ env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig },
|
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
|
});
|
|
|
let bootLog = '';
|
|
|
@@ -339,6 +346,19 @@ async function main() {
|
|
|
expect(sheets.detail === '门店明细' && sheets.chain === '连锁汇总' && sheets.company === '公司汇总' && fingerprint.split('|').length === 3,
|
|
|
JSON.stringify(sheets));
|
|
|
});
|
|
|
+ await check('detectLayerConflict → 门店全零且汇总有数才拦;新店型全零/未营业/无门店行放行', async () => {
|
|
|
+ const mk = (scope, extra = {}) => ({ scope, shopId: scope === 'shop' ? 's1' : '', chainId: scope === 'company' ? '-1' : 'c1', validOrderCnt: 0, incomeAmt: 0, ...extra });
|
|
|
+ // 已知 bug 形态:门店行全零、连锁/公司有数 → 拦
|
|
|
+ const bug = [mk('shop'), mk('chain', { validOrderCnt: 138, incomeAmt: 2900 }), mk('company', { validOrderCnt: 138, incomeAmt: 2900 })];
|
|
|
+ expect(aitableLib.detectLayerConflict(bug) && aitableLib.detectLayerConflict(bug).shopRows === 1, '门店全零而汇总有数未检出');
|
|
|
+ // 新店型:大面积全零但部分门店有数 → 放行(>80% 全零也正常)
|
|
|
+ const newShops = [mk('shop'), mk('shop'), mk('shop', { validOrderCnt: 98, incomeAmt: 1400 }), mk('chain', { validOrderCnt: 138 }), mk('company')];
|
|
|
+ expect(aitableLib.detectLayerConflict(newShops) === null, '部分门店有数被误拦');
|
|
|
+ // 全新未营业:门店与汇总都没数 → 放行
|
|
|
+ expect(aitableLib.detectLayerConflict([mk('shop'), mk('chain'), mk('company')]) === null, '未营业主体被误拦');
|
|
|
+ // 无门店行 → 放行
|
|
|
+ expect(aitableLib.detectLayerConflict([mk('chain', { validOrderCnt: 5 })]) === null, '无门店行被误拦');
|
|
|
+ });
|
|
|
|
|
|
// ---- 问数两段式:结果摘要与兜底话术(纯函数,不调对话模型) ----
|
|
|
const nlQueryLib = require('../nl-query.js');
|
|
|
@@ -423,6 +443,39 @@ async function main() {
|
|
|
const bad = prov.checkProvenance('本期营业额 99999 元,环比 +25%。', steps);
|
|
|
expect(bad.untraced.length === 1 && bad.untraced[0] === 99999, `编造数字未被标记:${JSON.stringify(bad)}`);
|
|
|
});
|
|
|
+ await check('指标层 → 清单注入 prompt;未知指标给出可用清单', async () => {
|
|
|
+ const metricsLib = require('../server/agent/metrics.js');
|
|
|
+ const agentPrompt = require('../server/agent/prompt.js');
|
|
|
+ const base = {
|
|
|
+ svc: naposServiceLib, entityName: '测试主体', shops: [], dateRange: { from: '20260101', to: '20260201' },
|
|
|
+ today: '20260201', toolsText: '- t:测试工具', skillsText: '', metricsText: metricsLib.manifestText(),
|
|
|
+ };
|
|
|
+ const p = agentPrompt.buildSystemPrompt(base);
|
|
|
+ expect(p.includes('指标聚合口径') && p.includes('gross_mov7') && p.includes('移动平均'), '派生指标清单未注入');
|
|
|
+ const tool = require('../server/agent/tools/metric.js');
|
|
|
+ let listed = false;
|
|
|
+ try { tool.metricSanitize({ metric: 'nope' }, { shops: [], dateRange: base.dateRange }); } catch (e) { listed = /可用指标/.test(e.message); }
|
|
|
+ expect(listed, '未知指标未给可用清单');
|
|
|
+ });
|
|
|
+ await check('Schema linking → 门店 ≤40 内嵌清单、多了改为按需查询', async () => {
|
|
|
+ const agentPrompt = require('../server/agent/prompt.js');
|
|
|
+ const mk = (n) => Array.from({ length: n }, (_, i) => ({ shopId: String(i), shopName: `测试门店${i}` }));
|
|
|
+ const base = {
|
|
|
+ svc: naposServiceLib, entityName: '测试主体', dateRange: { from: '20260101', to: '20260201' },
|
|
|
+ today: '20260201', toolsText: '- t', skillsText: '', metricsText: '',
|
|
|
+ };
|
|
|
+ const small = agentPrompt.buildSystemPrompt({ ...base, shops: mk(3) });
|
|
|
+ expect(small.includes('测试门店2'), '小主体未内嵌门店清单');
|
|
|
+ const big = agentPrompt.buildSystemPrompt({ ...base, shops: mk(60) });
|
|
|
+ expect(big.includes('门店较多') && !big.includes('测试门店2'), '大主体未改为按需查询');
|
|
|
+ });
|
|
|
+ await check('模型路由 → chatModelAgent 缺省回落主模型、JSON mode 默认关', async () => {
|
|
|
+ const { makeChatCfg } = require('../server/context.js');
|
|
|
+ const cfg1 = makeChatCfg({ config: { chatModel: 'm1', chatModelAgent: 'm2', chatJsonMode: true, apiBase: 'http://x', apiKey: 'k' } });
|
|
|
+ expect(cfg1.chatModel === 'm1' && cfg1.chatModelAgent === 'm2' && cfg1.chatJsonMode === true, JSON.stringify(cfg1));
|
|
|
+ const cfg2 = makeChatCfg({ config: { chatModel: 'm1', apiBase: 'http://x', apiKey: 'k' } });
|
|
|
+ expect(cfg2.chatModelAgent === 'm1' && cfg2.chatJsonMode === false, JSON.stringify(cfg2));
|
|
|
+ });
|
|
|
|
|
|
// ---- 安全加固回归(纯函数,不落盘、不打外部 API) ----
|
|
|
const { createContext } = require('../server/context.js');
|
|
|
@@ -679,6 +732,28 @@ async function main() {
|
|
|
const run2 = await waitRunDone(r2.json().run.runId, 'smokeask');
|
|
|
expect(run2.status === 'done' && run2.mode === 'agent' && run2.autoPicked === true, `分析问未分流深度:mode=${run2.mode}`);
|
|
|
});
|
|
|
+ await check('晨报:确定性生成(环比头条 + verdicts)并可读取', async () => {
|
|
|
+ const b = await request(aport, 'POST', '/api/agent/digests/build', { entity: 'smokeask' });
|
|
|
+ expect(b.status === 200 && b.json().digest, `status=${b.status}`);
|
|
|
+ const d = b.json().digest;
|
|
|
+ expect(d.headline.includes('10,500'), `头条数字异常:${d.headline}`);
|
|
|
+ expect((d.verdicts || []).length >= 5 && (d.sections || []).length === 3, `晨报构成异常:sections=${(d.sections || []).length}`);
|
|
|
+ const list = await request(aport, 'GET', `/api/agent/digests?entity=${enc('smokeask')}`);
|
|
|
+ expect(list.json().digests.length === 1 && list.json().digests[0].id === d.id, '晨报列表读取异常');
|
|
|
+ });
|
|
|
+ await check('反馈闭环:采纳入语料、纠错入日志', async () => {
|
|
|
+ const up = await request(aport, 'POST', '/api/agent/feedback', { entity: 'smokeask', runId: askRunId, kind: 'up' });
|
|
|
+ expect(up.status === 200 && up.json().run.feedback.kind === 'up', `status=${up.status}`);
|
|
|
+ expect(fs.existsSync(path.join(tmpAgent, 'smokeask', 'corpus.json')), '采纳未入打法语料');
|
|
|
+ const down = await request(aport, 'POST', '/api/agent/feedback', { entity: 'smokeask', runId: askRunId, kind: 'down', note: '数字不对' });
|
|
|
+ expect(down.status === 200 && down.json().run.feedback.note === '数字不对', '纠错未落 run');
|
|
|
+ expect(fs.existsSync(path.join(tmpAgent, 'smokeask', 'feedback.json')), '纠错未入反馈日志');
|
|
|
+ });
|
|
|
+ await check('深度分析报告端点 → 自包含 HTML(体检/异常章节)', async () => {
|
|
|
+ const r = await request(aport, 'GET', `/api/agent/report-deep?entity=${enc('smokeask')}&from=20260924&to=20261007`);
|
|
|
+ expect(r.status === 200 && /text\/html/.test(r.headers['content-type'] || ''), `status=${r.status}`);
|
|
|
+ expect(r.text.includes('数据体检') && r.text.includes('统计异常') && r.text.includes('归因'), '深度报告章节缺失');
|
|
|
+ });
|
|
|
await check('智能体 工具直测(隔离子进程)→ cross_check/peer_benchmark/raw_inspect', async () => {
|
|
|
const toolProbe = [
|
|
|
`const ROOT = ${JSON.stringify(ROOT)};`,
|
|
|
@@ -705,6 +780,17 @@ async function main() {
|
|
|
'const cloudTool = require(ROOT + "/server/agent/tools/cloud.js");',
|
|
|
'cloudTool.cloudHandler(healthy, cloudTool.cloudSanitize({}, healthy)).then((c3) => {',
|
|
|
' out.cloud = { available: c3.data.available, hasVerdict: Array.isArray(c3.data.verdicts) && c3.data.verdicts.length === 1 };',
|
|
|
+ ' const replay = require(ROOT + "/server/agent/replay.js");',
|
|
|
+ ' const cfile = require("os").tmpdir() + "/genpic-replay-probe-" + process.pid + ".json";',
|
|
|
+ ' replay.record(cfile, { question: "最近7天营业额和单量趋势", steps: [{ tool: "query_report", args: { mode: "date" } }, { tool: "data_quality", args: {} }] });',
|
|
|
+ ' replay.record(cfile, { question: "上月营业额Top5门店", steps: [{ tool: "query_report", args: { mode: "shop", limit: 5 } }] });',
|
|
|
+ ' const plan = replay.findPlan(cfile, "最近7天营业额和单量的趋势");',
|
|
|
+ ' out.replay = {',
|
|
|
+ ' hit: !!plan && plan.steps.length === 1 && plan.steps[0].tool === "query_report",',
|
|
|
+ ' few: replay.fewShot(cfile, "最近7天营业额怎么样").length,',
|
|
|
+ ' miss: !!replay.findPlan(cfile, "帮我做个数据体检"),',
|
|
|
+ ' };',
|
|
|
+ ' require("fs").unlinkSync(cfile);',
|
|
|
' console.log(JSON.stringify(out));',
|
|
|
'});',
|
|
|
].join('\n');
|
|
|
@@ -725,6 +811,8 @@ async function main() {
|
|
|
`跨主体对比异常:${JSON.stringify(out.entity)}`);
|
|
|
expect(out.cloud.available === false && out.cloud.hasVerdict === true,
|
|
|
`云端未启用未优雅降级:${JSON.stringify(out.cloud)}`);
|
|
|
+ expect(out.replay.hit === true && out.replay.few >= 1 && out.replay.miss === false,
|
|
|
+ `打法缓存异常:${JSON.stringify(out.replay)}`);
|
|
|
});
|
|
|
await check('智能体 空问题 → 400 ASK_EMPTY', async () => {
|
|
|
const r = await request(aport, 'POST', '/api/agent/runs', { entity: 'smokeask', question: ' ' });
|