import { beforeEach } from 'node:test'; import { questionLog } from './question-log.js'; beforeEach(t => { if (!('mock' in t)) throw new Error('Expected test context'); t.mock.method(questionLog, 'start', async () => 'synthetic-question'); t.mock.method(questionLog, 'finish', async () => {}); }); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; import router from "./index.js"; import { vehicleRepository } from "../vehicles/repository.js"; import { gapStore } from "./gaps.js"; import { addNode, getSession } from "./session.js"; import type { AuthUser } from "../../auth/types.js"; import type { Vehicle } from "../../types.js"; import type { AssistantRequest, AssistantResult, GapReason, QueryPlan } from "../../../shared/assets/assistant.js"; // Exact questions from the 2026-09-23 user-supplied boss-perspective report. // All data below is synthetic; model responses and persistence are mocked. export const BOSS_QUESTIONS = [ "我是公司负责人。请用当前数据给我一份资产经营简报:资产总数、运营和库存各多少、运营率多少;哪个车型库存最多,最值得我优先关注?请给数字依据、数据截至时间和统计口径。", "现在总共有多少台车?运营多少、库存多少,整体运营率是多少?", "按车型看库存,按库存数量从多到少排序,告诉我库存最多的车型占总库存多少。", "这些4.5T库存主要在哪些区域?只看可运营的,按数量降序。", "换个问题:最近7天交车、还车分别多少?其中替换交车多少?比前7天是改善还是变差?请写明起止日期和统计口径。", "全部车辆,不沿用之前4.5T或库存筛选。查询2026年9月17日至9月23日交车、还车、替换交车数量,按天列出。", "当前库存比上个月月底增加了多少?这些闲置车每个月损失多少钱?没有相应数据请直接说明,不要估算。", "现在全部公司的待交车有多少?列出待交车清单。", "查询待交车数量", "我问的是“待交车”,不是“待运营”。请区分这两个业务状态,重新查询待交车数量;如果不支持,请明确说明,不能用待运营数量替代。", ] as const; const query = (overrides: Partial = {}): QueryPlan => ({ kind: "assets", category: "all", groupBy: "type", view: "table", filters: {}, analysis: "distribution", ...overrides, }); let sequence = 0; function harness(t: TestContext, mode: "model" | "local" = "model") { const user: AuthUser = { userId: `boss-${++sequence}`, userName: "老板体验验收", loginName: "boss-test", depCode: "D", depName: "合成部门", permissionLevel: "personal", roles: [] }; const inventory = [ { id: "plain-east", type: "4.5T", model: "4.5吨普货", province: "浙江省", city: "嘉兴市", operationStatus: "3" }, { id: "cold-east", type: "4.5T", model: "4.5吨冷链车", province: "浙江省", city: "嘉兴市", operationStatus: "3" }, { id: "cold-south", type: "4.5T", model: "4.5吨冷链车", province: "广东省", city: "广州市", operationStatus: "4" }, { id: "cold-northwest", type: "4.5T", model: "4.5吨冷链车", province: "新疆", city: "乌鲁木齐市", operationStatus: "4" }, { id: "18-awaiting", type: "18T", model: "18吨厢货", province: "浙江省", city: "嘉兴市", operationStatus: "4" }, { id: "49-available", type: "49T", model: "49吨牵引", province: "广东省", city: "广州市", operationStatus: "3" }, ]; const vehicles = [ ...inventory.map((v) => ({ ...v, status: "Inventory" })), ...Array.from({ length: 4 }, (_, i) => ({ id: `operating-${i}`, type: "18T", model: "18吨厢货", province: "浙江省", city: "嘉兴市", operationStatus: "1", status: "Operating" })), ].map((v) => ({ ...v, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, subjectOrg: "合成公司", departmentName: "合成部门", managerId: user.userId }) as Vehicle); t.mock.method(vehicleRepository, "getVehicles", async () => vehicles); const flowCalls: { start: string; end: string }[] = []; t.mock.method(vehicleRepository, "getFlowDetailRows", async (start: string, end: string) => { flowCalls.push({ start, end }); return [ { id: `${start}-delivered`, truck_id: "49-available", stat_date: start, type: "delivered", type_label: "交车" }, { id: `${start}-replaced`, truck_id: "operating-0", stat_date: start, type: "replaced", type_label: "替换" }, { id: `${start}-returned`, truck_id: "cold-south", stat_date: end, type: "returned", type_label: "还车" }, ]; }); const gaps: { demand: string; reason: GapReason }[] = []; t.mock.method(gapStore, "record", async (_user: string, demand: string, reason: GapReason) => { gaps.push({ demand, reason }); }); const oldKey = process.env.DEEPSEEK_API_KEY, oldEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED; process.env.DEEPSEEK_API_KEY = "synthetic-test-key"; process.env.ASSET_AI_EXTERNAL_ENABLED = mode === "model" ? "1" : "0"; t.after(() => { if (oldKey === undefined) delete process.env.DEEPSEEK_API_KEY; else process.env.DEEPSEEK_API_KEY = oldKey; if (oldEnabled === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = oldEnabled; }); const proposals: Record[] = []; const presentationCalls: string[] = []; // Count planning separately and return a minimal valid display for each result. const calls: string[] = []; t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => { assert.equal(mode, "model", "Local acceptance must not call any external endpoint"); const body = JSON.parse(String(init?.body)); const context = JSON.parse(body.messages.find((message: { role: string }) => message.role === "user").content); if (context.stage === "presentation") { presentationCalls.push(String(init?.body)); const presentation = { layout: "stack", results: context.results.map((result: { id: string }) => ({ id: result.id, blocks: [{ id: "summary", kind: "summary" }], interactions: [] })) }; return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(presentation) }, finish_reason: "stop" }] })); } calls.push(String(init?.body)); assert.ok(proposals.length, "Every model response must be explicitly supplied"); return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(proposals.shift()) }, finish_reason: "stop" }] }), { headers: { "Content-Type": "application/json" } }); }); const app = new Hono<{ Variables: { user: AuthUser } }>(); app.use("*", async (c, next) => { c.set("user", user); await next(); }); app.route("/ai", router); async function ask(input: Partial) { const response = await app.request("/ai/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject: null, ...input }) }); assert.equal(response.status, 200); const events = (await response.text()).split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5))); const error = events.find((event) => event.type === "error"); assert.equal(error, undefined, error?.message); const result = events.find((event) => event.type === "result")?.result as AssistantResult; assert.ok(result, "Every query must return a result or explicit capability response"); return result; } return { user, vehicles, ask, proposals, calls, presentationCalls, gaps, flowCalls }; } const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId }); function expectCoreMetrics(result: AssistantResult) { assert.equal(result.metrics.find((m) => /全部资产|资产总数|总资产/.test(m.label))?.value, 10); assert.equal(result.metrics.find((m) => m.label === "运营车辆")?.value, 4); assert.equal(result.metrics.find((m) => /库存/.test(m.label))?.value, 6); assert.equal(result.metrics.find((m) => m.label === "运营率")?.value, 40); } test("Q01 briefing answers supported facts and inventory concentration using computed data", async (t) => { const { ask, proposals } = harness(t); proposals.push({ kind: "query", plan: query({ analysis: "briefing" }), reply: "资产766台、运营475台、库存291台。" }); const result = await ask({ message: BOSS_QUESTIONS[0] }); expectCoreMetrics(result); assert.ok(result.inventoryBreakdown); const largest = result.inventoryBreakdown.groups[0]; assert.equal(largest.name, "4.5T"); assert.equal(largest.count, 4); assert.equal(largest.share, 66.7); assert.doesNotMatch(result.summary, /766|475|291/, "Model-provided numbers must not replace real calculations"); assert.equal(result.scope?.sourceUpdatedAt, null); assert.ok(result.scope?.definitions.some((definition) => /库存/.test(definition))); assert.ok(result.fetchedAt); }); test("Q08/Q09 use pending even when the model incorrectly proposes awaiting or unsupported", async (t) => { const { ask, proposals, gaps } = harness(t); for (const [question, proposal] of [ [BOSS_QUESTIONS[7], { kind: "query", plan: query({ category: "awaiting", view: "list" }) }], [BOSS_QUESTIONS[8], { kind: "clarify", reply: "无法单独查询待交车,目前只能统计待运营。", reason: "unsupported" }], ] as const) { proposals.push(proposal); const result = await ask({ message: question }); assert.equal(result.plan.kind, "assets"); assert.equal(result.plan.category, "pending"); assert.equal(result.metrics[0].value, 0); assert.equal(result.rows.length, 0); assert.equal(result.outcome, "empty"); assert.equal(result.gap, undefined); assert.doesNotMatch(result.summary, /待运营库存共\s*3|不能.*待交|无法.*待交/); } assert.equal(gaps.length, 0); }); test("Q10 explicitly retracts an earlier wrong awaiting answer and queries pending", async (t) => { const { ask, proposals, user } = harness(t); const session = getSession(user, null); const wrong = addNode(session, { plan: query({ category: "awaiting" }), label: "待运营库存 · 车型", demand: BOSS_QUESTIONS[8], question: BOSS_QUESTIONS[8], answer: "待运营库存共3台。", }); proposals.push({ kind: "clarify", reply: "目前只能统计待运营,不能单独查询待交车。", reason: "unsupported" }); const result = await ask({ sessionId: session.id, baseResultId: wrong.id, message: BOSS_QUESTIONS[9] }); assert.equal(result.plan.category, "pending"); assert.equal(result.metrics[0].value, 0); assert.equal(result.correction?.resultId, wrong.id); assert.match(result.correction?.message || "", /待运营/); assert.match(result.correction?.message || "", /不能|不应|不适用|撤回|纠正/); assert.equal(result.gap, undefined); }); test("correcting a wrong pending plan removes conflicting awaiting filters and shows real deliveries", async (t) => { const { ask, proposals, vehicles } = harness(t); vehicles.push({ ...vehicles[0], id: "real-pending", status: "Pending", operationStatus: "3", pendingDelivery: { contractNo: "SYNTHETIC-CONTRACT", customerName: "合成客户" } }); proposals.push({ kind: "query", plan: query({ category: "awaiting", filters: { status: ["待运营"] }, exception: "overdue", view: "list" }) }); const result = await ask({ message: BOSS_QUESTIONS[7] }); assert.equal(result.plan.category, "pending"); assert.equal(result.plan.filters.status, undefined); assert.equal(result.plan.exception, undefined); assert.equal(result.metrics[0].value, 1); assert.deepEqual(result.rows.map((row) => row.id), ["real-pending"]); assert.equal(result.outcome, "complete"); }); test("a pending category with a hidden awaiting constraint cannot turn a real delivery into a false zero", async (t) => { const { ask, proposals, vehicles } = harness(t); vehicles.push({ ...vehicles[0], id: "pending-with-available-operation", status: "Pending", operationStatus: "3" }); proposals.push({ kind: "query", plan: query({ category: "pending", filters: { status: ["待运营"] }, view: "list" }) }); const result = await ask({ message: BOSS_QUESTIONS[8] }); assert.equal(result.plan.filters.status, undefined); assert.equal(result.metrics[0].value, 1); assert.deepEqual(result.rows.map((row) => row.id), ["pending-with-available-operation"]); }); test("mixed supported and unsupported requests produce partial answers instead of a blanket refusal", async (t) => { const { ask, proposals, gaps } = harness(t); proposals.push({ kind: "query", plan: query({ analysis: "briefing" }), unanswered: [{ question: "每月闲置损失金额", reason: "未接入成本与收入数据,无法可靠计算。" }] }); const result = await ask({ message: "给我资产经营简报,并告诉我每月闲置损失金额;没有数据的部分请说明。" }); expectCoreMetrics(result); assert.equal(result.outcome, "partial"); assert.ok(result.unanswered?.some((item) => /损失/.test(item.question))); assert.ok(result.unanswered?.some((item) => /成本|收入/.test(item.reason))); assert.ok(gaps.some((gap) => /闲置损失/.test(gap.demand))); }); test("the complete ten-question report works through the deterministic local fallback", async (t) => { const { ask, calls, flowCalls } = harness(t, "local"); const answers: AssistantResult[] = []; for (const message of BOSS_QUESTIONS) { const last = answers.at(-1); answers.push(await ask({ ...(last ? based(last) : {}), message })); } expectCoreMetrics(answers[0]); assert.ok(answers[0].inventoryBreakdown); expectCoreMetrics(answers[1]); assert.equal(answers[2].plan.category, "inventory"); assert.equal(answers[2].groups[0].name, "4.5T"); assert.equal(answers[2].groups[0].share, 66.7); assert.equal(answers[3].plan.category, "available"); assert.deepEqual(answers[3].plan.filters.type, ["4.5T"]); assert.equal(answers[3].rows.length, 2); assert.equal(answers[3].plan.groupBy, "region"); const recent = answers[4]; assert.equal(recent.plan.kind, "flow"); assert.deepEqual(recent.plan.filters, {}); assert.equal(recent.plan.category, "all"); assert.equal(Date.parse(recent.plan.end!) - Date.parse(recent.plan.start!), 6 * 86400000); assert.equal(recent.plan.comparePrevious, true); assert.equal(recent.metrics[0].value, 2); assert.equal(recent.metrics[1].value, 1); assert.equal(recent.metrics[2].value, 1); assert.ok(recent.comparisonPeriod); assert.equal(Date.parse(recent.comparisonPeriod.end), Date.parse(recent.plan.start!) - 86400000); assert.doesNotMatch(recent.summary, /运营车辆净增\s*1|运营净增\s*1/); assert.ok(recent.unanswered?.some((item) => /改善|变差/.test(item.question)) || recent.insights?.some((item) => /改善|变差|业务目标|判断/.test(item))); assert.equal(answers[5].plan.start, "2026-09-17"); assert.equal(answers[5].plan.end, "2026-09-23"); assert.deepEqual(answers[5].plan.filters, {}); assert.equal(answers[5].rows.length, 3); assert.equal(answers[6].outcome, "unsupported"); assert.equal(answers[6].rows.length, 0); for (const pending of answers.slice(7)) { assert.equal(pending.plan.category, "pending"); assert.equal(pending.metrics[0].value, 0); assert.equal(pending.outcome, "empty"); } assert.ok(flowCalls.length >= 3); assert.equal(calls.length, 0); }); test("result scope exposes company, permission, applied filters, grouping and distinct source time", async (t) => { const { ask, proposals } = harness(t); proposals.push({ kind: "query", plan: query({ category: "available", groupBy: "region", filters: { type: ["4.5T"] } }) }); const result = await ask({ subject: "合成公司", message: BOSS_QUESTIONS[3] }); assert.equal(result.scope?.company, "合成公司"); assert.ok(result.scope?.permission); assert.ok(result.scope?.filters.some((filter) => /4\.5T/.test(filter))); assert.ok(result.scope?.filters.some((filter) => /可运营/.test(filter))); assert.match(result.scope?.grouping || "", /区域/); assert.match(result.scope?.period || "", /当前|实时/); assert.equal(result.scope?.sourceUpdatedAt, null); assert.ok(result.scope?.definitions.some((definition) => /4\.5T/.test(definition) && /普货/.test(definition) && /冷链/.test(definition))); }); test("4.5T tonnage results can split into ordinary freight and cold-chain inventory", async (t) => { const { ask, proposals } = harness(t); proposals.push({ kind: "query", plan: query({ category: "inventory", groupBy: "type" }) }); const total = await ask({ message: BOSS_QUESTIONS[2] }); proposals.push({ kind: "query", plan: query({ category: "inventory", groupBy: "vehicleClass", filters: { type: ["4.5T"] } }) }); const split = await ask({ ...based(total), message: "把4.5T进一步拆成普货和冷链" }); assert.equal(split.rows.length, 4); assert.equal(split.groups.find((group) => /普货/.test(group.name))?.count, 1); assert.equal(split.groups.find((group) => /冷链/.test(group.name))?.count, 3); assert.equal(split.groups.reduce((sum, group) => sum + group.count, 0), total.groups.find((group) => group.name === "4.5T")?.count); });