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

481 lines
15 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 from "node:test";
import { Hono } from "hono";
import router from "./index.js";
import { vehicleRepository } from "../vehicles/repository.js";
import { gapStore } from "./gaps.js";
import { projectQuestion, externalContext } from "./privacy.js";
import { initialPlan, localPlan, modelPlan } from "./planner.js";
import { assetResult, validatePlan } from "./model.js";
import { execute, applyAction } from "./execute.js";
import { acquire, getSession, addNode, getNode } from "./session.js";
import type { Vehicle } from "../../types.js";
import type { AuthUser } from "../../auth/types.js";
import type {
AssistantResult,
AssistantRequest,
GapReason,
} from "../../../shared/assets/assistant.js";
process.env.ASSET_AI_EXTERNAL_ENABLED = "0";
const user: AuthUser = {
userId: "analysis",
userName: "测试",
loginName: "test",
depCode: "D",
depName: "测试部门",
permissionLevel: "personal",
roles: [],
};
const vehicles = [
{ id: "1", status: "Inventory", city: "嘉兴市", type: "4.5T" },
{ id: "2", status: "Abnormal", city: "嘉兴市", type: "18T" },
{ id: "3", status: "Pending", city: "杭州市", type: "18T" },
{
id: "4",
status: "Inventory",
city: "广州市",
type: "4.5T",
province: "广东省",
},
{
id: "5",
status: "Operating",
city: "嘉兴市",
type: "4.5T",
operationStatus: "1",
},
{
id: "6",
status: "Inventory",
city: "嘉兴市",
type: "18T",
managerId: "other",
},
].map(
(v) =>
({
province: "浙江省",
model: "保密型号甲",
subjectOrg: "保密公司甲",
plateNumber: `测试车牌${v.id}`,
managerId: user.userId,
departmentName: "保密部门甲",
operationStatus: "3",
...v,
...(v.status === "Abnormal"
? {
abnormalMove: {
type: "维修",
targetCompletionTime: "2020-01-01 12:00:00",
actualCompletionTime: null,
},
}
: {}),
}) as Vehicle,
);
function appFor(u: AuthUser = user) {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => {
c.set("user", u);
await next();
});
app.route("/ai", router);
return app;
}
async function ask(
app: ReturnType<typeof appFor>,
input: Partial<AssistantRequest>,
) {
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((s) => s.startsWith("data:"))
.map((s) => JSON.parse(s.slice(5)));
const error = events.find((e) => e.type === "error");
assert.equal(error, undefined, error?.message);
return events.find((e) => e.type === "result").result as AssistantResult;
}
const based = (r: AssistantResult) => ({
sessionId: r.sessionId,
baseResultId: r.resultId,
});
test("natural question and relevant catalog remain available for semantic planning", () => {
const question = "只看嘉兴市的库存,再解释一下有哪些值得关注";
const input = projectQuestion(question, vehicles);
const context = externalContext(input);
assert.equal(context.message, question);
assert.ok(context.catalog.city?.includes("嘉兴市"));
assert.equal(
projectQuestion("查询天狼星客户的利润", vehicles).demand,
"查询天狼星客户的利润",
);
assert.ok(
!projectQuestion("sk-testsecret123456", vehicles).demand.includes(
"testsecret",
),
);
});
test("multi-level clicks and follow-ups preserve exact scope, branching and reconcile groups", async (t) => {
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
t.mock.method(gapStore, "record", async () => {});
const app = appFor();
const root = await ask(app, {
message: "各区域的库存车辆有多少?按数量排序。",
});
assert.equal(root.rows.length, 5);
assert.equal(root.plan.category, "inventory");
const region = await ask(app, {
...based(root),
action: { type: "drill", keys: { region: "华东" } },
});
assert.deepEqual(region.plan.filters, { region: ["华东"] });
assert.equal(region.plan.groupBy, "city");
assert.equal(region.rows.length, 4);
const city = await ask(app, {
...based(region),
action: { type: "drill", keys: { city: "嘉兴市" } },
});
assert.deepEqual(city.plan.filters, { region: ["华东"], city: ["嘉兴市"] });
assert.equal(city.rows.length, 3);
const types = await ask(app, { ...based(city), message: "再按车型拆分" });
assert.deepEqual(types.plan.filters, city.plan.filters);
assert.equal(types.plan.groupBy, "type");
const subsets = await ask(app, {
...based(types),
message: "这些车里面有多少异动和待交车",
});
assert.equal(subsets.plan.category, "inventory");
assert.deepEqual(subsets.plan.filters, city.plan.filters);
assert.equal(subsets.metrics.find((m) => m.label === "其中异动")?.value, 1);
assert.equal(subsets.metrics.find((m) => m.label === "其中待交车")?.value, 0);
const detail = await ask(app, {
...based(subsets),
message: "显示异动车辆明细",
});
assert.equal(detail.rows.length, 1);
assert.equal(detail.rows[0].abnormalType, "维修");
assert.equal(detail.rows[0].targetCompletionTime, "2020-01-01 12:00:00");
const branch = await ask(app, {
...based(root),
action: { type: "restore" },
});
assert.deepEqual(branch.plan.filters, {});
assert.equal(branch.rows.length, 5);
assert.equal(detail.breadcrumbs?.length, 6);
for (const r of [root, region, city, types, subsets, detail])
assert.equal(
r.groups.reduce((n, g) => n + g.count, 0),
r.rows.length,
);
});
test("cross dimensions, exact values, anomalies and comparisons are computed internally", () => {
const plan = localPlan(
projectQuestion("库存按区域和车型交叉统计", vehicles),
).plan;
assert.equal(plan.secondaryGroupBy, "type");
const result = assetResult(vehicles, plan);
assert.equal(
result.groups.reduce((n, g) => n + g.count, 0),
5,
);
assert.equal(
result.exceptions?.find((e) => e.label === "异动已超过预计完成时间")?.count,
1,
);
assert.equal(
assetResult(vehicles, { ...plan, filters: { city: ["嘉兴"] } }).rows.length,
0,
);
const comparison = assetResult(vehicles, { ...plan, analysis: "compare" });
assert.ok(comparison.insights?.some((s) => s.includes("不代表历史增长")));
assert.throws(() =>
applyAction(
plan,
{ type: "drill", keys: { region: "未授权地区" } },
result,
),
);
assert.throws(() => validatePlan({ ...plan, filters: { sql: ["select"] } }));
});
test("same-duration flow comparisons include replacements once and handle zero baseline", async (t) => {
t.mock.method(
vehicleRepository,
"getFlowDetailRows",
async (start: string) =>
start === "2026-09-10"
? [
{
id: "a",
truck_id: "1",
stat_date: "2026-09-10",
type: "replaced",
type_label: "替换",
},
{
id: "b",
truck_id: "2",
stat_date: "2026-09-10",
type: "returned",
type_label: "还车",
},
{
id: "c",
truck_id: "unauthorized",
stat_date: "2026-09-10",
type: "delivered",
type_label: "交车",
},
]
: [],
);
const result = await execute(vehicles, {
...initialPlan(),
kind: "flow",
groupBy: "none",
start: "2026-09-10",
end: "2026-09-12",
comparePrevious: true,
});
assert.equal(result.rows.length, 2);
assert.equal(result.metrics[0].value, 1);
assert.deepEqual(result.comparisonPeriod, {
start: "2026-09-07",
end: "2026-09-09",
});
assert.equal(result.comparison?.[0].rate, null);
const follow = localPlan(
projectQuestion("改成表格", vehicles, result.plan),
result.plan,
).plan;
assert.equal(follow.start, "2026-09-10");
assert.equal(follow.comparePrevious, true);
});
test("sessions bind user, company and permission fingerprint; exports reapply current visibility", async (t) => {
let current = vehicles;
t.mock.method(vehicleRepository, "getVehicles", async () => current);
const app = appFor({ ...user, userId: "owner", permissionLevel: "full" });
const result = await ask(app, { message: "统计库存" });
const other = appFor({ ...user, userId: "intruder" });
for (const target of [other, app]) {
const r = await target.request("/ai/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...based(result),
subject: target === app ? "另一家公司" : null,
message: "显示明细",
}),
});
assert.equal(r.status, 409);
}
current = vehicles.slice(0, 1);
const exported = await app.request("/ai/export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject: null,
}),
});
assert.equal(exported.status, 200);
assert.ok(!(await exported.text()).includes("测试车牌2"));
const s = getSession(user, null),
n = addNode(s, { plan: initialPlan(), label: "root", demand: "库存" });
assert.throws(() =>
getSession({ ...user, permissionLevel: "full" }, null, s.id),
);
assert.equal(getNode(s, n.id)?.id, n.id);
});
test("unsupported demands preserve intent and local mode records feedback once", async (t) => {
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
const recorded: { demand: string; reason: GapReason }[] = [];
t.mock.method(
gapStore,
"record",
async (_u: string, demand: string, reason: GapReason) => {
recorded.push({ demand, reason });
},
);
const fetch = t.mock.method(globalThis, "fetch", async () => {
throw new Error("No network expected");
});
const app = appFor({ ...user, userId: "gaps", permissionLevel: "full" });
for (const [message, reason] of [
["预测库存", "unsupported"],
["统计天狼星的利润", "unsupported"],
] as const) {
const r = await ask(app, { message });
assert.equal(r.gap?.reason, reason);
assert.equal(r.gap.recorded, true);
assert.equal(r.plan.kind, "clarify");
}
assert.equal(fetch.mock.callCount(), 0);
assert.equal(recorded.length, 2);
assert.ok(recorded.some((r) => r.demand.includes("天狼星")));
const result = await ask(app, { message: "库存统计" });
const feedback = () =>
app.request("/ai/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: result.sessionId,
resultId: result.resultId,
subject: null,
}),
});
assert.equal((await feedback()).status, 200);
assert.equal((await feedback()).status, 200);
assert.equal(recorded.filter((r) => r.reason === "unsatisfied").length, 1);
assert.equal((await app.request("/ai/gaps?scope=all")).status, 403);
assert.equal(
(
await app.request("/ai/gaps/1", {
method: "PATCH",
body: JSON.stringify({ status: "resolved" }),
headers: { "Content-Type": "application/json" },
})
).status,
403,
);
});
test("storage failure is visible and must never claim a requirement was saved", async (t) => {
t.mock.method(vehicleRepository, "getVehicles", async () => vehicles);
t.mock.method(gapStore, "record", async () => {
throw new Error("sensitive database diagnostic");
});
const result = await ask(appFor({ ...user, userId: "failure" }), {
message: "预测库存",
});
assert.equal(result.gap?.recorded, false);
assert.ok(!JSON.stringify(result).includes("sensitive database"));
});
test("model can choose a full plan using natural question and contextual catalog", async (t) => {
const previous = { ...initialPlan(), filters: { city: ["嘉兴市"] } };
const safe = projectQuestion("改为杭州市库存", vehicles, previous);
let body = "";
t.mock.method(
globalThis,
"fetch",
async (_url: unknown, init?: RequestInit) => {
body = String(init?.body);
return new Response(
JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({
kind: "query",
plan: {
...initialPlan(),
category: "inventory",
filters: { city: ["杭州市"] },
},
}),
},
},
],
}),
);
},
);
const plan = await modelPlan(
safe,
previous,
initialPlan(),
new AbortController().signal,
);
assert.deepEqual(plan.filters.city, ["杭州市"]);
assert.ok(body.includes("改为杭州市库存"));
assert.ok(body.includes("嘉兴市"));
});
test("only concurrent duplicate work is limited, ordinary long analysis is not quota-blocked", () => {
const release = acquire("rate-test");
assert.ok(release);
assert.equal(acquire("rate-test"), null);
release();
for (let i = 1; i < 20; i++) {
const r = acquire("rate-test");
assert.ok(r);
r();
}
const next = acquire("rate-test");
assert.ok(next);
next();
});
test("overdue action returns only unfinished, overdue abnormal vehicles and retains scope", () => {
const plan = {
...initialPlan(),
category: "inventory" as const,
filters: { region: ["华东"] },
};
const next = applyAction(
plan,
{ type: "exception", exception: "overdue" },
assetResult(vehicles, plan),
);
assert.equal(next.exception, "overdue");
assert.equal(next.view, "list");
const result = assetResult(vehicles, next);
assert.deepEqual(
result.rows.map((r) => r.id),
["2"],
);
assert.deepEqual(next.filters, plan.filters);
const cleared = applyAction(
next,
{ type: "category", category: "pending" },
result,
);
assert.equal(cleared.exception, undefined);
});
test("malformed partial planner output cannot become an executable query", async (t) => {
const safe = projectQuestion("统计库存", vehicles);
t.mock.method(
globalThis,
"fetch",
async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({
groupBy: "region",
filters: { region: ["secret"] },
url: "https://invalid.example",
}),
},
},
],
}),
),
);
await assert.rejects(() =>
modelPlan(safe, undefined, initialPlan(), new AbortController().signal),
);
});