Files
ln-bi/src/server/routes/asset-assistant/composition.test.ts
T

297 lines
20 KiB
TypeScript

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 type { AuthUser } from "../../auth/types.js";
import type { Vehicle } from "../../types.js";
import type { AssistantRequest, AssistantResult, GapReason, QueryPlan } from "../../../shared/assets/assistant.js";
const company = "合成公司A";
const plan = (overrides: Partial<QueryPlan> = {}): QueryPlan => ({
kind: "assets", category: "inventory", groupBy: "city", view: "table", filters: { region: ["华东"] }, analysis: "distribution", ...overrides,
});
const part = (id: string, title: string, queryPlan: QueryPlan, display = "table") => ({ id, title, question: title, plan: queryPlan, display });
let serial = 0;
function fixture(t: TestContext, permissionLevel: AuthUser["permissionLevel"] = "personal") {
const user: AuthUser = { userId: `composition-${++serial}`, userName: "组合验收", loginName: "composition", depCode: "D", depName: "合成部门", permissionLevel, roles: [] };
const vehicles = [
{ id: "jx-inventory", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" },
{ id: "hz-inventory", type: "18T", city: "杭州市", status: "Inventory", operationStatus: "3" },
{ id: "jx-pending", type: "4.5T", city: "嘉兴市", status: "Pending", operationStatus: "4" },
{ id: "jx-operating", type: "18T", city: "嘉兴市", status: "Operating", operationStatus: "1" },
{ id: "south-inventory", type: "49T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" },
{ id: "other-company", type: "4.5T", city: "嘉兴市", subjectOrg: "合成公司B", status: "Inventory", operationStatus: "3" },
{ id: "other-owner", type: "4.5T", city: "嘉兴市", managerId: "another-user", departmentName: "其他部门", status: "Inventory", operationStatus: "3" },
].map((v) => ({ province: "浙江省", model: "合成车型", subjectOrg: company, departmentName: "合成部门", managerId: user.userId, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, ...v }) as Vehicle);
const getVehicles = t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
let flowFailure = false;
const getFlow = t.mock.method(vehicleRepository, "getFlowDetailRows", async (start: string) => {
if (flowFailure) throw new Error("Synthetic internal database error; must not become user output");
return [
{ id: "event-valid", truck_id: "jx-inventory", stat_date: start, type: "delivered", type_label: "交车" },
{ id: "event-other-company", truck_id: "other-company", stat_date: start, type: "delivered", type_label: "交车" },
{ id: "event-other-owner", truck_id: "other-owner", stat_date: start, type: "delivered", 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-model-key";
process.env.ASSET_AI_EXTERNAL_ENABLED = "1";
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<string, unknown>[] = [];
const presentationRequests: string[] = [];
// Existing assertions count planning calls; presentation is an independent stage.
const requests: string[] = [];
t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => {
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") {
presentationRequests.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" }] }));
}
requests.push(String(init?.body));
assert.ok(proposals.length, "Unexpected external request: every response is supplied by this test");
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<AssistantRequest>) {
const response = await app.request("/ai/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject: company, ...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, "A composed response must produce a result even if one section fails");
return result;
}
return { app, ask, proposals, requests, presentationRequests, gaps, getVehicles, getFlow, vehicles, failFlow: () => { flowFailure = true; } };
}
const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId });
const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort();
const inventory = () => part("inventory", "华东库存按城市分布", plan(), "chart");
const flow = () => part("flow", "华东本期交退换趋势", plan({ kind: "flow", category: "all", groupBy: "none", start: "2026-09-17", end: "2026-09-23" }), "chart");
test("one model decision executes four independent analyses in the same authorized company scope", async (t) => {
const { ask, proposals, requests, presentationRequests, getVehicles } = fixture(t);
const sections = [
part("overview", "华东全部资产概况", plan({ category: "all" }), "metrics"),
inventory(),
part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list"),
part("operating", "华东运营车辆按车型统计", plan({ category: "operating", groupBy: "type" }), "table"),
];
proposals.push({ kind: "query", plan: sections[0].plan, queries: sections });
const result = await ask({ message: "分别展示华东资产概况、库存分布、待交车清单和运营车型统计。" });
assert.equal(result.sections?.length, 4);
assert.ok(result.resultId);
const counts = [5, 4, 1, 1];
const childIds: string[] = [];
for (const [index, section] of result.sections!.entries()) {
assert.equal(section.id, sections[index].id);
assert.equal(section.display, sections[index].display);
assert.equal(section.error, undefined);
assert.ok(section.result);
assert.equal(section.result.rows.length, counts[index]);
assert.equal(section.result.scope?.company, company);
assert.deepEqual(section.result.plan.filters, { region: ["华东"] });
assert.equal(section.result.sessionId, result.sessionId);
assert.ok(section.result.resultId);
childIds.push(section.result.resultId!);
assert.ok(ids(section.result).every((id) => !["other-company", "south-inventory"].includes(id)));
}
assert.equal(new Set([result.resultId, ...childIds]).size, 5, "Root and each analysis need distinct result identities");
assert.equal(result.outcome, "complete");
assert.equal(requests.length, 1);
assert.equal(presentationRequests.length, 1, "One display generation call serves all four analyses");
assert.equal(getVehicles.mock.callCount(), 1, "All analyses must share a single authorized vehicle snapshot");
});
for (const failedFirst of [true, false]) {
test(`a ${failedFirst ? "primary" : "secondary"} flow failure preserves the successful asset analysis`, async (t) => {
const { ask, proposals, gaps, failFlow, requests } = fixture(t);
failFlow();
const queries = failedFirst ? [flow(), inventory()] : [inventory(), flow()];
proposals.push({ kind: "query", plan: queries[0].plan, queries });
const result = await ask({ message: "华东库存和本期交退换分别展示,哪个暂不可用就保留其余结果。" });
assert.equal(result.sections?.length, 2);
const asset = result.sections!.find((section) => section.id === "inventory")!;
const failed = result.sections!.find((section) => section.id === "flow")!;
assert.equal(asset.result?.rows.length, 4);
assert.ok(asset.result?.resultId);
assert.ok(failed.error);
assert.equal(failed.result, undefined);
assert.equal(result.outcome, "partial");
assert.ok(result.unanswered?.some((item) => /交退换|流转/.test(item.question)));
assert.ok(gaps.some((gap) => /交退换|流转/.test(gap.demand)));
assert.doesNotMatch(JSON.stringify(result), /Synthetic internal database/);
assert.equal(requests.length, 1);
});
}
test("drilling and following up a child preserves that child's category and filters", async (t) => {
const { ask, proposals, requests, presentationRequests } = fixture(t);
proposals.push({ kind: "query", plan: plan({ category: "all" }), queries: [part("overview", "华东全部资产", plan({ category: "all" }), "metrics"), inventory()] });
const composition = await ask({ message: "华东资产概览和库存分布分开给我。" });
const child = composition.sections!.find((section) => section.id === "inventory")!.result!;
const drill = await ask({ ...based(child), action: { type: "drill", keys: { city: "嘉兴市" } } });
assert.equal(drill.sections, undefined, "A local block action must return a normal result for in-place replacement");
assert.equal(drill.plan.category, "inventory");
assert.deepEqual(drill.plan.filters, { region: ["华东"], city: ["嘉兴市"] });
assert.deepEqual(ids(drill), ["jx-inventory", "jx-pending", "other-owner"]);
assert.equal(requests.length, 1, "Click-based drilling should not trigger another planning request");
assert.equal(presentationRequests.length, 2, "The original result and clicked result each get one display generation pass");
proposals.push({ kind: "query", plan: plan({ category: "pending", view: "list", filters: { region: ["华东"], city: ["嘉兴市"] } }) });
const follow = await ask({ ...based(drill), message: "这里面待交车的单独列出来" });
assert.equal(follow.sections, undefined);
assert.deepEqual(ids(follow), ["jx-pending"]);
assert.deepEqual(follow.plan.filters, drill.plan.filters);
assert.equal(follow.breadcrumbs?.at(-2)?.resultId, drill.resultId);
});
test("the parent conversation includes all successful analyses for cross-section references", async (t) => {
const { ask, proposals, requests } = fixture(t);
proposals.push({ kind: "query", plan: plan(), queries: [inventory(), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list")] });
const root = await ask({ message: "华东库存分布和待交车清单分开显示。" });
const pending = root.sections!.find((section) => section.id === "pending")!.result!;
proposals.push({ kind: "query", plan: plan({ category: "pending", groupBy: "type" }), baseResultId: pending.resultId });
const next = await ask({ ...based(root), message: "继续第二项的待交车,按车型拆开" });
const request = JSON.parse(requests.at(-1)!);
const context = JSON.parse([...request.messages].reverse().find((message: { role: string }) => message.role === "user").content);
const analyses = context.analyses || context.resultContext?.analyses;
assert.ok(Array.isArray(analyses), "The model needs every analysis, not only the root's first plan");
assert.equal(analyses.length, 2);
assert.ok(analyses.some((analysis: { plan: QueryPlan; metrics: { value: number }[] }) => analysis.plan.category === "inventory" && analysis.metrics[0].value === 4));
assert.ok(analyses.some((analysis: { id: string; plan: QueryPlan; metrics: { value: number }[] }) => analysis.id === pending.resultId && analysis.plan.category === "pending" && analysis.metrics[0].value === 1));
assert.equal(next.plan.category, "pending");
assert.deepEqual(ids(next), ["jx-pending"]);
assert.equal(next.breadcrumbs?.at(-2)?.resultId, pending.resultId);
});
test("an unknown display or invalid child plan cannot discard the healthy sibling", async (t) => {
const { ask, proposals, requests, gaps } = fixture(t);
proposals.push({ kind: "query", plan: plan(), queries: [
inventory(),
part("unknown-display", "待交车清单", plan({ category: "pending", view: "list" }), "javascript:unsupported-renderer"),
{ ...part("invalid-plan", "未能识别的业务查询", plan()), plan: { ...plan(), category: "does-not-exist" } },
] });
const result = await ask({ message: "库存分布、待交清单和另外一项查询分别显示。" });
const healthy = result.sections?.find((section) => section.id === "inventory")?.result || result;
assert.equal(healthy.rows.length, 4);
assert.equal(healthy.plan.category, "inventory");
const display = result.sections?.find((section) => section.id === "unknown-display");
if (display) {
assert.ok(["metrics", "chart", "table", "list"].includes(display.display));
assert.ok(display.error || display.result?.plan.category === "pending");
} else {
assert.ok(result.unanswered?.some((item) => item.question === "待交车清单"), "Discarded presentation must have an explicit unanswered item");
}
const invalid = result.sections?.find((section) => section.id === "invalid-plan");
assert.ok(invalid?.error || result.unanswered?.some((item) => item.question === "未能识别的业务查询"), "Invalid query must be individually explained, either as a failed section or an unanswered item");
assert.equal(result.outcome, "partial");
assert.ok(result.unanswered?.length);
assert.ok(gaps.some((gap) => /未能识别的业务查询/.test(gap.demand)));
assert.equal(requests.length, 1, "An invalid section must not cause a blanket second planning pass");
});
test("a simple query keeps the legacy result shape and uses one planning call", async (t) => {
const { ask, proposals, requests, presentationRequests, getVehicles, getFlow } = fixture(t);
proposals.push({ kind: "query", plan: plan() });
const result = await ask({ message: "华东库存按城市统计" });
assert.equal(result.sections, undefined);
assert.equal(result.rows.length, 4);
assert.equal(result.metrics[0].value, 4);
assert.ok(result.resultId);
assert.equal(requests.length, 1);
assert.equal(presentationRequests.length, 1);
assert.equal(getVehicles.mock.callCount(), 1);
assert.equal(getFlow.mock.callCount(), 0);
});
test("inventory subset percentages use the full inventory denominator even when the chart shows Top 1", async (t) => {
const { ask, proposals, vehicles } = fixture(t);
vehicles.find((vehicle) => vehicle.id === "hz-inventory")!.status = "Abnormal";
const stock = plan({ topN: 1 });
proposals.push({ kind: "query", plan: stock, queries: [part("stock-share", "华东库存分布与子集占比", stock, "chart"), part("pending", "华东待交车清单", plan({ category: "pending", view: "list" }), "list")] });
const result = await ask({ message: "华东库存最多的城市和待交车清单分别展示,同时给待交车与异动占库存比例。" });
const stockResult = result.sections!.find((section) => section.id === "stock-share")!.result!;
assert.equal(stockResult.rows.length, 4);
assert.equal(stockResult.groups.length, 1);
assert.equal(stockResult.groups[0].count, 3);
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.value, 25);
assert.equal(stockResult.metrics.find((metric) => metric.label === "异动占库存")?.value, 25);
assert.equal(stockResult.metrics.find((metric) => metric.label === "待交车占库存")?.unit, "%");
});
test("an empty inventory denominator has no invented percentage and does not become a query failure", async (t) => {
const { ask, proposals, gaps } = fixture(t);
const noMatch = plan({ filters: { city: ["北京市"] } });
proposals.push({ kind: "query", plan: noMatch, queries: [part("empty-inventory", "北京库存子集占比", noMatch, "metrics"), part("empty-pending", "北京待交车清单", { ...noMatch, category: "pending", view: "list" }, "list")] });
const result = await ask({ message: "北京库存占比和待交车列表分别展示。" });
const stockResult = result.sections!.find((section) => section.id === "empty-inventory")!.result!;
assert.equal(stockResult.metrics[0].value, 0);
assert.ok(stockResult.metrics.every((metric) => !/占库存/.test(metric.label)));
assert.match(stockResult.summary, /分母.*0|没有|未找到/);
assert.equal(result.outcome, "empty");
assert.equal(result.gap, undefined);
assert.equal(gaps.length, 0);
});
test("explicitly changing the parent region for composed analyses does not restore an obsolete city filter", async (t) => {
const { ask, proposals } = fixture(t);
proposals.push({ kind: "query", plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }) });
const previous = await ask({ message: "只看华东嘉兴库存" });
assert.equal(previous.rows.length, 3);
const southernInventory = plan({ filters: { region: ["华南"] } });
const southernOperating = plan({ category: "operating", filters: { region: ["华南"] } });
proposals.push({ kind: "query", plan: southernInventory, queries: [
part("southern-inventory", "改看华南库存分布", southernInventory, "chart"),
part("southern-operating", "改看华南运营概况", southernOperating, "metrics"),
] });
const changed = await ask({ ...based(previous), message: "改看华南,库存分布和运营概况分开展示。" });
const inventoryResult = changed.sections!.find((section) => section.id === "southern-inventory")!.result!;
assert.deepEqual(inventoryResult.plan.filters, { region: ["华南"] });
assert.deepEqual(ids(inventoryResult), ["south-inventory"]);
assert.ok(changed.sections!.every((section) => !section.result?.plan.filters.city));
});
for (const permissionLevel of ["personal", "department"] as const) {
test(`${permissionLevel} can analyze public stock but flow, comparisons, actions and exports retain original scope`, async (t) => {
const { app, ask, proposals } = fixture(t, permissionLevel);
const compared = { ...flow(), plan: { ...flow().plan, comparePrevious: true } };
proposals.push({ kind: "query", plan: plan(), queries: [inventory(), compared] });
const result = await ask({ message: "库存和交退换趋势分别展示,并对比上期" });
const stock = result.sections!.find(section => section.id === "inventory")!.result!;
const business = result.sections!.find(section => section.id === "flow")!.result!;
assert.ok(ids(stock).includes("other-owner"), "Other departments' stock is public");
assert.deepEqual(ids(business), ["event-valid"], "Public stock does not expose other owners' historical events");
assert.equal(business.comparison![0].current, 1);
assert.equal(business.comparison![0].previous, 1);
const day = await ask({ ...based(business), action: { type: "day", date: business.plan.start! } });
assert.deepEqual(ids(day), ["event-valid"]);
const exported = await app.request("/ai/export", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: business.sessionId, resultId: business.resultId, subject: company }),
});
assert.equal(exported.status, 200);
const csv = await exported.text();
assert.match(csv, /合成-jx-inventory/);
assert.doesNotMatch(csv, /合成-other-owner|合成-other-company/);
});
}