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

246 lines
18 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, QueryPlan, PresentationBlock, PresentationInteraction } from "../../../shared/assets/assistant.js";
const company = "生成式验收公司";
const plan = (overrides: Partial<QueryPlan> = {}): QueryPlan => ({ kind: "assets", category: "inventory", groupBy: "region", view: "chart", filters: { department: ["部门甲"] }, analysis: "distribution", ...overrides });
const based = (result: AssistantResult) => ({ sessionId: result.sessionId, baseResultId: result.resultId });
const ids = (result: AssistantResult) => result.rows.map((row) => row.id).sort();
const layout = (blocks: PresentationBlock[], interactions: PresentationInteraction[] = [], id = "main") => ({ id, title: "模型设计的分析视图", blocks, interactions });
const simple = (id = "main") => layout([{ id: "summary", kind: "summary" }], [], id);
let serial = 0;
function fixture(t: TestContext) {
const user: AuthUser = { userId: `generative-${++serial}`, userName: "展示验收", loginName: "generative", depCode: "D", depName: "部门甲", permissionLevel: "personal", roles: [] };
const vehicles = [
{ id: "east-45", type: "4.5T", city: "嘉兴市", status: "Inventory", operationStatus: "3" },
{ id: "east-18", type: "18T", city: "杭州市", status: "Inventory", operationStatus: "3" },
{ id: "east-abnormal", type: "4.5T", city: "嘉兴市", status: "Abnormal", operationStatus: "3", abnormalMove: { type: "维修", targetCompletionTime: "2020-01-01 12:00:00", actualCompletionTime: null } },
{ id: "east-pending", type: "18T", city: "嘉兴市", status: "Pending", operationStatus: "4" },
{ id: "south-45", type: "4.5T", city: "广州市", province: "广东省", status: "Inventory", operationStatus: "3" },
{ id: "other-department", type: "4.5T", city: "嘉兴市", departmentName: "部门乙", status: "Inventory", operationStatus: "3" },
{ id: "other-company", type: "4.5T", city: "嘉兴市", subjectOrg: "其他公司", status: "Inventory", operationStatus: "3" },
].map((v) => ({ province: "浙江省", model: "合成车型", subjectOrg: company, departmentName: "部门甲", managerId: user.userId, plateNumber: `合成-${v.id}`, vin: `SYNTHETIC-${v.id}`, ...v }) as Vehicle);
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
t.mock.method(vehicleRepository, "getFlowDetailRows", async (start: string, end: string) => [
{ id: "day-17", truck_id: "east-45", stat_date: "2026-09-17", type: "delivered", type_label: "交车" },
{ id: "day-18", truck_id: "south-45", stat_date: "2026-09-18", type: "returned", type_label: "还车" },
].filter((row) => row.stat_date >= start && row.stat_date <= end));
t.mock.method(gapStore, "record", async () => {});
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 = "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 planning: Record<string, unknown>[] = [];
const presentations: (Record<string, unknown> | Error)[] = [];
const planningCalls: Record<string, unknown>[] = [];
const presentationCalls: Record<string, unknown>[] = [];
t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => {
const request = JSON.parse(String(init?.body));
const context = JSON.parse(request.messages.find((message: { role: string }) => message.role === "user").content);
const isPresentation = context.stage === "presentation";
(isPresentation ? presentationCalls : planningCalls).push(context);
const queue = isPresentation ? presentations : planning;
assert.ok(queue.length, `Unexpected ${isPresentation ? "presentation" : "planning"} request`);
const content = queue.shift()!;
if (content instanceof Error) throw content;
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) }, 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 raw(input: Partial<AssistantRequest>) {
const response = await app.request("/ai/ask", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject: company, ...input }) });
const text = await response.text();
if (response.status !== 200) return { status: response.status, events: [], text };
const events = text.split("\n").filter((line) => line.startsWith("data:")).map((line) => JSON.parse(line.slice(5)));
return { status: response.status, events, text };
}
async function ask(input: Partial<AssistantRequest>) {
const response = await raw(input);
assert.equal(response.status, 200, response.text);
const error = response.events.find((event) => event.type === "error");
assert.equal(error, undefined, error?.message);
const result = response.events.find((event) => event.type === "result")?.result as AssistantResult;
assert.ok(result);
return result;
}
return { planning, presentations, planningCalls, presentationCalls, ask, raw };
}
test("the same regional group follows two model-designed paths rather than a fixed dimension order", async (t) => {
const { planning, presentations, planningCalls, presentationCalls, ask } = fixture(t);
planning.push({ kind: "query", plan: plan() });
presentations.push({ layout: "stack", results: [layout([
{ id: "types-chart", kind: "chart", dataset: "groups", chart: "bar", series: [{ field: "count", label: "库存数量" }], interactionId: "by-type" },
{ id: "abnormal-chart", kind: "chart", dataset: "groups", chart: "donut", series: [{ field: "count", label: "库存数量" }], interactionId: "abnormal-list" },
], [
{ id: "by-type", label: "按车型拆分这个区域", kind: "group", plan: plan({ groupBy: "type", filters: {} }) },
{ id: "abnormal-list", label: "直接查看这个区域的异动清单", kind: "group", plan: plan({ category: "abnormal", groupBy: "none", view: "list", filters: {} }) },
])] });
const root = await ask({ message: "部门甲的库存按区域看,并提供车型和异动两个分析方向。" });
assert.equal(root.presentationStatus, "generated");
assert.equal(root.presentation?.blocks.length, 2);
presentations.push({ layout: "stack", results: [simple()] });
const types = await ask({ ...based(root), action: { type: "explore", interactionId: "by-type", keys: { region: "华东" } } });
assert.equal(types.plan.groupBy, "type");
assert.deepEqual(types.plan.filters, { department: ["部门甲"], region: ["华东"] });
assert.equal(types.rows.length, 4);
assert.equal(types.presentationStatus, "generated");
presentations.push({ layout: "stack", results: [layout([{ id: "detail", kind: "table", dataset: "rows", columns: [{ field: "abnormalType", label: "异动类型" }, { field: "plate", label: "车辆" }] }])] });
const abnormal = await ask({ ...based(root), action: { type: "explore", interactionId: "abnormal-list", keys: { region: "华东" } } });
assert.equal(abnormal.plan.category, "abnormal");
assert.equal(abnormal.plan.groupBy, "none");
assert.equal(abnormal.plan.view, "list");
assert.deepEqual(abnormal.plan.filters, types.plan.filters);
assert.deepEqual(ids(abnormal), ["east-abnormal"]);
assert.equal(abnormal.rows[0].abnormalType, "维修");
assert.equal(planningCalls.length, 1, "Clicks execute stored model decisions without a new planning pass");
assert.equal(presentationCalls.length, 3, "Each changed dataset gets one new display design");
});
test("presentation preserves real metric references and model-selected column order", async (t) => {
const { planning, presentations, ask, presentationCalls } = fixture(t);
planning.push({ kind: "query", plan: plan() });
presentations.push({ layout: "stack", results: [layout([
{ id: "metrics", kind: "metrics", metrics: ["其中待交车", "库存车辆"] },
{ id: "table", kind: "table", dataset: "groups", columns: [{ field: "count", label: "车辆数量" }, { field: "name", label: "区域名称" }] },
])] });
const result = await ask({ message: "展示部门甲库存指标和统计表,表格把数量放在区域前面。" });
assert.equal(result.presentationStatus, "generated");
assert.deepEqual(result.presentation?.blocks[0].metrics, ["其中待交车", "库存车辆"]);
assert.deepEqual(result.presentation?.blocks[1].columns, [{ field: "count", label: "车辆数量" }, { field: "name", label: "区域名称" }]);
assert.equal(result.metrics.find((metric) => metric.label === "库存车辆")?.value, 5);
assert.equal(result.metrics.find((metric) => metric.label === "其中待交车")?.value, 1);
assert.match(JSON.stringify(presentationCalls[0]), /库存车辆/);
});
test("unknown interaction identifiers and invented group keys cannot execute or generate a result", async (t) => {
const { planning, presentations, ask, raw, planningCalls, presentationCalls } = fixture(t);
planning.push({ kind: "query", plan: plan() });
presentations.push({ layout: "stack", results: [layout([{ id: "regions", kind: "chart", dataset: "groups", chart: "bar", series: [{ field: "count", label: "库存" }], interactionId: "drill" }], [{ id: "drill", label: "按车型查看", kind: "group", plan: plan({ groupBy: "type" }) }])] });
const root = await ask({ message: "看看部门甲各区域库存" });
for (const action of [
{ type: "explore" as const, interactionId: "not-issued", keys: { region: "华东" } },
{ type: "explore" as const, interactionId: "drill", keys: { region: "不存在的地区" } },
{ type: "explore" as const, interactionId: "drill", keys: { region: "华东", city: "嘉兴市" } },
]) {
const failed = await raw({ ...based(root), action });
assert.ok(failed.status >= 400 || failed.events.some((event) => event.type === "error"));
assert.ok(!failed.events.some((event) => event.type === "result"));
}
assert.equal(planningCalls.length, 1);
assert.equal(presentationCalls.length, 1);
});
test("multiple sections share one layout call and cannot use another section's interaction", async (t) => {
const { planning, presentations, ask, raw, planningCalls, presentationCalls } = fixture(t);
const stockPlan = plan(), pendingPlan = plan({ category: "pending", view: "list" });
planning.push({ kind: "query", plan: stockPlan, queries: [
{ id: "stock", title: "库存区域分布", question: "部门甲库存按区域", plan: stockPlan, display: "chart" },
{ id: "pending", title: "待交车清单", question: "部门甲待交车清单", plan: pendingPlan, display: "list" },
] });
presentations.push({ layout: "tabs", results: [
layout([{ id: "stock-chart", kind: "chart", dataset: "groups", chart: "donut", series: [{ field: "count", label: "库存数量" }], interactionId: "stock-only" }], [{ id: "stock-only", label: "车型细分", kind: "group", plan: plan({ groupBy: "type" }) }], "stock"),
layout([{ id: "pending-table", kind: "table", dataset: "rows", columns: [{ field: "plate", label: "车牌" }, { field: "type", label: "车型" }] }], [], "pending"),
] });
const root = await ask({ message: "库存分布和待交车清单分开展示,使用页签。" });
assert.equal(root.layout, "tabs");
assert.equal(root.sections?.length, 2);
assert.ok(root.sections!.every((section) => section.result?.presentationStatus === "generated"));
assert.equal(root.sections![0].result?.presentation?.blocks[0].chart, "donut");
assert.equal(root.sections![1].result?.presentation?.blocks[0].dataset, "rows");
assert.equal(planningCalls.length, 1);
assert.equal(presentationCalls.length, 1);
const wrongSection = root.sections!.find((section) => section.id === "pending")!.result!;
const rejected = await raw({ ...based(wrongSection), action: { type: "explore", interactionId: "stock-only", keys: { region: "华东" } } });
assert.ok(rejected.status >= 400 || rejected.events.some((event) => event.type === "error"));
assert.equal(presentationCalls.length, 1);
});
test("a failed display generation preserves computed data and explicitly marks fallback", async (t) => {
const { planning, presentations, ask, planningCalls, presentationCalls } = fixture(t);
planning.push({ kind: "query", plan: plan() });
presentations.push(new Error("Synthetic rendering model failure"));
const result = await ask({ message: "部门甲库存按区域展示" });
assert.equal(result.rows.length, 5);
assert.equal(result.metrics.find((metric) => metric.label === "库存车辆")?.value, 5);
assert.equal(result.presentationStatus, "fallback");
assert.ok(result.presentationWarning);
assert.notEqual(result.outcome, "unsupported");
assert.equal(result.gap?.reason, "generation");
assert.equal(result.gap.recorded, true);
assert.equal(planningCalls.length, 1);
assert.equal(presentationCalls.length, 1);
assert.doesNotMatch(JSON.stringify(result), /Synthetic rendering model failure/);
});
test("a model-designed daily point action fixes the selected date and regenerates its layout", async (t) => {
const { planning, presentations, ask, planningCalls, presentationCalls } = fixture(t);
const flowPlan = plan({ kind: "flow", category: "all", groupBy: "none", start: "2026-09-17", end: "2026-09-18" });
planning.push({ kind: "query", plan: flowPlan });
presentations.push({ layout: "stack", results: [layout([{ id: "flow", kind: "chart", dataset: "daily", chart: "line", series: [{ field: "delivered", label: "交车" }, { field: "returned", label: "还车" }], interactionId: "day-events" }], [{ id: "day-events", label: "查看该日业务明细", kind: "point", plan: { ...flowPlan, view: "list" } }])] });
const root = await ask({ message: "部门甲2026年9月17日至18日交退换趋势" });
presentations.push({ layout: "stack", results: [layout([{ id: "events", kind: "table", dataset: "rows", columns: [{ field: "date", label: "日期" }, { field: "event", label: "业务" }, { field: "plate", label: "车辆" }] }])] });
const day = await ask({ ...based(root), action: { type: "explore", interactionId: "day-events", date: "2026-09-17" } });
assert.equal(day.plan.start, "2026-09-17");
assert.equal(day.plan.end, "2026-09-17");
assert.deepEqual(day.plan.filters, flowPlan.filters);
assert.deepEqual(ids(day), ["day-17"]);
assert.equal(day.presentationStatus, "generated");
assert.equal(planningCalls.length, 1);
assert.equal(presentationCalls.length, 2);
});
test("narrative metric placeholders resolve to computed values and fabricated numeric prose is omitted", async (t) => {
const { planning, presentations, ask } = fixture(t);
planning.push({ kind: "query", plan: plan() });
presentations.push({ layout: "stack", results: [layout([
{ id: "grounded", kind: "narrative", text: "当前库存{{metric:库存车辆}},其中待交车{{metric:其中待交车}}。" },
{ id: "fabricated", kind: "narrative", text: "库存已经达到9999台。" },
])] });
const result = await ask({ message: "部门甲库存给我一段可核对的数字解读" });
assert.equal(result.presentationStatus, "generated");
assert.equal(result.presentation?.blocks.find((block) => block.id === "grounded")?.text, "当前库存5台,其中待交车1台。");
assert.ok(!result.presentation?.blocks.some((block) => block.id === "fabricated"));
assert.ok(result.presentationWarning);
assert.doesNotMatch(JSON.stringify(result.presentation), /9999/);
});
test("one section's missing presentation keeps all data and the sibling's generated layout", async (t) => {
const { planning, presentations, ask, presentationCalls } = fixture(t);
planning.push({ kind: "query", plan: plan(), queries: [
{ id: "stock", title: "库存区域分布", question: "部门甲库存按区域", plan: plan(), display: "chart" },
{ id: "pending", title: "待交车清单", question: "部门甲待交车清单", plan: plan({ category: "pending", view: "list" }), display: "list" },
] });
presentations.push({ layout: "stack", results: [simple("stock")] });
const result = await ask({ message: "分别展示部门甲库存分布和待交车清单" });
const stock = result.sections!.find((section) => section.id === "stock")!.result!;
const pending = result.sections!.find((section) => section.id === "pending")!.result!;
assert.equal(stock.presentationStatus, "generated");
assert.equal(stock.rows.length, 5);
assert.equal(pending.presentationStatus, "fallback");
assert.deepEqual(ids(pending), ["east-pending"]);
assert.ok(pending.presentationWarning);
assert.equal(result.outcome, "partial");
assert.equal(result.gap?.reason, "generation");
assert.ok(result.unanswered?.some((item) => /待交车/.test(item.question) && /展示|生成/.test(item.reason)));
assert.equal(presentationCalls.length, 1);
});