481 lines
15 KiB
TypeScript
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),
|
|
);
|
|
});
|