800 lines
28 KiB
TypeScript
800 lines
28 KiB
TypeScript
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";
|
|
|
|
// End-to-end route tests use synthetic business data and mock every model call.
|
|
// They validate integration contracts, not the real model's language accuracy.
|
|
let sequence = 0;
|
|
const plan = (overrides: Partial<QueryPlan> = {}): QueryPlan => ({
|
|
kind: "assets",
|
|
category: "inventory",
|
|
groupBy: "city",
|
|
view: "table",
|
|
filters: {},
|
|
analysis: "distribution",
|
|
...overrides,
|
|
});
|
|
type Proposal = {
|
|
kind?: "query" | "answer" | "clarify";
|
|
plan: QueryPlan;
|
|
reply?: string;
|
|
reason?: GapReason;
|
|
suggestions?: string[];
|
|
baseResultId?: string;
|
|
};
|
|
|
|
function setup(t: TestContext) {
|
|
const user: AuthUser = {
|
|
userId: `experience-${++sequence}`,
|
|
userName: "体验验收",
|
|
loginName: "experience",
|
|
depCode: "experience-department",
|
|
depName: "测试部门",
|
|
permissionLevel: "personal",
|
|
roles: [],
|
|
};
|
|
const rows = [
|
|
{ id: "jx-stock", city: "嘉兴市", status: "Inventory", type: "4.5T" },
|
|
{ id: "jx-abnormal", city: "嘉兴市", status: "Abnormal", type: "18T" },
|
|
{ id: "hz-pending", city: "杭州市", status: "Pending", type: "18T" },
|
|
{ id: "hz-stock", city: "杭州市", status: "Inventory", type: "4.5T" },
|
|
{
|
|
id: "gz-stock",
|
|
city: "广州市",
|
|
province: "广东省",
|
|
status: "Inventory",
|
|
type: "4.5T",
|
|
},
|
|
{
|
|
id: "jx-running",
|
|
city: "嘉兴市",
|
|
status: "Operating",
|
|
type: "4.5T",
|
|
operationStatus: "1",
|
|
},
|
|
{
|
|
// Inventory is public; use another owner's operating vehicle to test restricted data.
|
|
id: "not-authorized",
|
|
city: "嘉兴市",
|
|
status: "Operating",
|
|
operationStatus: "1",
|
|
type: "18T",
|
|
managerId: "another-user",
|
|
},
|
|
].map(
|
|
(v) =>
|
|
({
|
|
province: "浙江省",
|
|
model: "测试型号",
|
|
subjectOrg: "测试公司",
|
|
plateNumber: `测试车-${v.id}`,
|
|
managerId: user.userId,
|
|
departmentName: user.depName,
|
|
operationStatus: "3",
|
|
...v,
|
|
...(v.status === "Abnormal"
|
|
? {
|
|
abnormalMove: {
|
|
type: "维修",
|
|
targetCompletionTime: "2020-01-01 12:00:00",
|
|
actualCompletionTime: null,
|
|
},
|
|
}
|
|
: {}),
|
|
}) as Vehicle,
|
|
);
|
|
t.mock.method(vehicleRepository, "getVehicles", async () => rows);
|
|
t.mock.method(vehicleRepository, "getFlowDetailRows", async () => []);
|
|
const recorded: { demand: string; reason: GapReason }[] = [];
|
|
t.mock.method(
|
|
gapStore,
|
|
"record",
|
|
async (_user: string, demand: string, reason: GapReason) => {
|
|
recorded.push({ demand, reason });
|
|
},
|
|
);
|
|
const originalKey = process.env.DEEPSEEK_API_KEY;
|
|
const originalEnabled = process.env.ASSET_AI_EXTERNAL_ENABLED;
|
|
process.env.DEEPSEEK_API_KEY = "synthetic-test-key";
|
|
process.env.ASSET_AI_EXTERNAL_ENABLED = "1";
|
|
t.after(() => {
|
|
if (originalKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
else process.env.DEEPSEEK_API_KEY = originalKey;
|
|
if (originalEnabled === undefined)
|
|
delete process.env.ASSET_AI_EXTERNAL_ENABLED;
|
|
else process.env.ASSET_AI_EXTERNAL_ENABLED = originalEnabled;
|
|
});
|
|
// These scenarios assert planning behavior. A minimal valid presentation must
|
|
// not consume the queued planning response for the next user turn.
|
|
const requests: string[] = [];
|
|
const presentationRequests: string[] = [];
|
|
const responses: (Proposal | Error | 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(
|
|
responses.length,
|
|
"Unexpected model request: all responses must be explicit",
|
|
);
|
|
const response = responses.shift()!;
|
|
if (response instanceof Error) throw response;
|
|
const content =
|
|
typeof response === "string"
|
|
? response
|
|
: JSON.stringify({
|
|
...response,
|
|
kind:
|
|
response.kind ||
|
|
(response.plan.kind === "clarify" ? "clarify" : "query"),
|
|
reply: response.reply || response.plan.clarification,
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
choices: [
|
|
{
|
|
message: {
|
|
content,
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, 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("/assistant", router);
|
|
async function ask(input: Partial<AssistantRequest>) {
|
|
const response = await app.request("/assistant/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 failure = events.find((event) => event.type === "error");
|
|
assert.equal(failure, undefined, failure?.message);
|
|
const result = events.find((event) => event.type === "result")?.result;
|
|
assert.ok(result, "Expected completed assistant result");
|
|
return result as AssistantResult;
|
|
}
|
|
return { ask, responses, requests, presentationRequests, recorded, rows };
|
|
}
|
|
const based = (result: AssistantResult) => ({
|
|
sessionId: result.sessionId,
|
|
baseResultId: result.resultId,
|
|
});
|
|
const ids = (result: AssistantResult) =>
|
|
result.rows.map((row) => row.id).sort();
|
|
|
|
test("natural questions reach the model and its business filters are really executed", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
const message = "帮我瞅瞅嘉兴现在还有几台闲着的车,做成城市统计就行";
|
|
responses.push({ plan: plan({ filters: { city: ["嘉兴市"] } }) });
|
|
const result = await ask({ message });
|
|
assert.equal(
|
|
requests.length,
|
|
1,
|
|
"Natural wording must not be rejected by a closed vocabulary",
|
|
);
|
|
assert.ok(requests[0].includes(message));
|
|
assert.equal(result.plan.category, "inventory");
|
|
assert.deepEqual(result.plan.filters, { city: ["嘉兴市"] });
|
|
assert.deepEqual(ids(result), ["jx-abnormal", "jx-stock"]);
|
|
assert.equal(result.metrics[0].value, 2);
|
|
assert.equal(result.gap, undefined);
|
|
});
|
|
|
|
test("five-turn analysis sends conversation context and supports correction, detail and reset", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
const firstQuestion = "先帮我看一下华东的库存,后面我们再逐个城市研究";
|
|
responses.push({ plan: plan({ filters: { region: ["华东"] } }) });
|
|
const root = await ask({ message: firstQuestion });
|
|
assert.equal(root.rows.length, 4);
|
|
responses.push({
|
|
plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }),
|
|
});
|
|
const city = await ask({ ...based(root), message: "其中嘉兴呢?" });
|
|
assert.equal(city.rows.length, 2);
|
|
responses.push({
|
|
plan: plan({ filters: { region: ["华东"], city: ["杭州市"] } }),
|
|
});
|
|
const corrected = await ask({
|
|
...based(city),
|
|
message: "不对,我想看杭州,不是嘉兴",
|
|
});
|
|
assert.deepEqual(ids(corrected), ["hz-pending", "hz-stock"]);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "pending",
|
|
view: "list",
|
|
filters: { region: ["华东"], city: ["杭州市"] },
|
|
}),
|
|
});
|
|
const detail = await ask({
|
|
...based(corrected),
|
|
message: "这里面待交车的展开看看",
|
|
});
|
|
assert.deepEqual(ids(detail), ["hz-pending"]);
|
|
responses.push({ plan: plan() });
|
|
const reset = await ask({ ...based(detail), message: "重新看全部库存" });
|
|
assert.equal(reset.rows.length, 5);
|
|
assert.deepEqual(reset.plan.filters, {});
|
|
const fourthBody = JSON.stringify(JSON.parse(requests[3]));
|
|
assert.ok(
|
|
fourthBody.includes(firstQuestion),
|
|
"Recent conversation must reach the model",
|
|
);
|
|
assert.ok(fourthBody.includes("不对,我想看杭州,不是嘉兴"));
|
|
assert.ok(fourthBody.includes("杭州市"));
|
|
});
|
|
|
|
test("clarification preserves the selected analysis for the answer that follows", async (t) => {
|
|
const { ask, responses, requests, recorded } = setup(t);
|
|
const scoped = plan({ filters: { city: ["嘉兴市"] } });
|
|
responses.push({ plan: scoped });
|
|
const root = await ask({ message: "看看嘉兴库存" });
|
|
const clarification = "你说的到期,是异动预计完成时间,还是合同到期?";
|
|
responses.push({
|
|
plan: { ...scoped, kind: "clarify", clarification },
|
|
reply: clarification,
|
|
suggestions: ["异动预计完成时间"],
|
|
});
|
|
const question = await ask({ ...based(root), message: "其中到期的有哪些?" });
|
|
assert.equal(question.plan.kind, "clarify");
|
|
assert.equal(
|
|
recorded.length,
|
|
0,
|
|
"Normal clarification is not an unmet capability",
|
|
);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "abnormal",
|
|
exception: "overdue",
|
|
view: "list",
|
|
filters: scoped.filters,
|
|
}),
|
|
});
|
|
const answer = await ask({
|
|
...based(question),
|
|
message: "我指的是异动预计完成时间,已经超过的",
|
|
});
|
|
assert.deepEqual(answer.plan.filters, scoped.filters);
|
|
assert.deepEqual(ids(answer), ["jx-abnormal"]);
|
|
assert.equal(answer.rows[0].abnormalType, "维修");
|
|
assert.equal(answer.rows[0].targetCompletionTime, "2020-01-01 12:00:00");
|
|
assert.ok(
|
|
requests.at(-1)?.includes(clarification),
|
|
"A clarification must appear in conversational context",
|
|
);
|
|
});
|
|
|
|
test("help and valid zero-match answers do not pollute the unmet-demand inbox", async (t) => {
|
|
const { ask, responses, recorded } = setup(t);
|
|
responses.push({
|
|
kind: "answer",
|
|
plan: plan({
|
|
kind: "clarify",
|
|
clarification:
|
|
"可以分析库存、运营、异动和交退换车,也可以继续下钻到车辆明细。",
|
|
}),
|
|
reply: "可以分析库存、运营、异动和交退换车,也可以继续下钻到车辆明细。",
|
|
suggestions: ["各区域库存有多少", "看看异动情况"],
|
|
});
|
|
const help = await ask({ message: "你能帮我分析些什么?" });
|
|
assert.equal(help.gap, undefined);
|
|
assert.equal(recorded.length, 0);
|
|
responses.push({
|
|
plan: plan({ category: "pending", filters: { city: ["嘉兴市"] } }),
|
|
});
|
|
const empty = await ask({ ...based(help), message: "嘉兴有待交车吗?" });
|
|
assert.equal(empty.plan.kind, "assets");
|
|
assert.equal(empty.rows.length, 0);
|
|
assert.equal(empty.metrics[0].value, 0);
|
|
assert.equal(empty.gap, undefined);
|
|
assert.equal(recorded.length, 0);
|
|
});
|
|
|
|
test("unsupported business requests retain useful demand wording and are recorded", async (t) => {
|
|
const { ask, responses, recorded } = setup(t);
|
|
const message = "能按每台车的租赁收入减掉维修成本算毛利吗?";
|
|
responses.push({
|
|
plan: plan({
|
|
kind: "clarify",
|
|
clarification:
|
|
"目前缺少租赁收入和维修成本,不能可靠计算单车毛利。可以先查看车辆与异动分布。",
|
|
}),
|
|
reason: "unsupported",
|
|
suggestions: ["查看异动车辆"],
|
|
});
|
|
const result = await ask({ message });
|
|
assert.equal(result.plan.kind, "clarify");
|
|
assert.equal(result.gap?.reason, "unsupported");
|
|
assert.equal(result.gap.recorded, true);
|
|
assert.equal(recorded.length, 1);
|
|
assert.match(recorded[0].demand, /毛利/);
|
|
assert.match(recorded[0].demand, /租赁|收入/);
|
|
});
|
|
|
|
test("a model-selected historical result branches from that result rather than the latest city", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
responses.push({ plan: plan({ filters: { region: ["华东"] } }) });
|
|
const root = await ask({ message: "看华东库存" });
|
|
responses.push({
|
|
plan: plan({ filters: { region: ["华东"], city: ["嘉兴市"] } }),
|
|
});
|
|
const city = await ask({ ...based(root), message: "只看嘉兴" });
|
|
responses.push({
|
|
plan: plan({ groupBy: "type", filters: { region: ["华东"] } }),
|
|
baseResultId: root.resultId,
|
|
});
|
|
const branch = await ask({
|
|
...based(city),
|
|
message: "回到第一张图,按车型拆开",
|
|
});
|
|
assert.ok(
|
|
requests.at(-1)?.includes(root.resultId!),
|
|
"Model must receive selectable historical result identifiers",
|
|
);
|
|
assert.deepEqual(branch.plan.filters, { region: ["华东"] });
|
|
assert.equal(branch.rows.length, 4);
|
|
assert.equal(branch.plan.groupBy, "type");
|
|
assert.equal(branch.breadcrumbs?.at(-2)?.resultId, root.resultId);
|
|
assert.ok(
|
|
!branch.breadcrumbs?.some((item) => item.resultId === city.resultId),
|
|
);
|
|
});
|
|
|
|
test("model outage and malformed output use an honest scoped fallback", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
for (const failedResponse of [
|
|
new Error("Synthetic model outage"),
|
|
"{not valid JSON",
|
|
]) {
|
|
responses.push(failedResponse);
|
|
const result = await ask({ message: "统计嘉兴市库存" });
|
|
assert.deepEqual(result.plan.filters, { city: ["嘉兴市"] });
|
|
assert.deepEqual(ids(result), ["jx-abnormal", "jx-stock"]);
|
|
assert.match(result.warning || "", /规则|降级|暂不可用|连接|模型/);
|
|
assert.ok(!JSON.stringify(result).includes("Synthetic model outage"));
|
|
}
|
|
});
|
|
|
|
test("ranking, exclusions and their removal change results without distorting totals", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
responses.push({
|
|
plan: plan({ topN: 1, sortBy: "count", sortOrder: "asc" }),
|
|
});
|
|
const ranked = await ask({ message: "库存最少的那个城市是哪一个?" });
|
|
assert.equal(ranked.groups.length, 1);
|
|
assert.equal(ranked.groups[0].name, "广州市");
|
|
assert.equal(ranked.groups[0].count, 1);
|
|
assert.equal(
|
|
ranked.metrics[0].value,
|
|
5,
|
|
"Top N must not replace the full-scope total",
|
|
);
|
|
assert.equal(ranked.groups[0].share, 20);
|
|
responses.push({
|
|
plan: plan({
|
|
filters: { region: ["华东"] },
|
|
excludeFilters: { city: ["杭州市"] },
|
|
}),
|
|
});
|
|
const excluded = await ask({
|
|
...based(ranked),
|
|
message: "改看华东库存,杭州先不要",
|
|
});
|
|
assert.deepEqual(ids(excluded), ["jx-abnormal", "jx-stock"]);
|
|
assert.equal(excluded.plan.topN, undefined);
|
|
responses.push({ plan: plan({ filters: { region: ["华东"] } }) });
|
|
const restored = await ask({
|
|
...based(excluded),
|
|
message: "还是把杭州加回来吧",
|
|
});
|
|
assert.equal(restored.rows.length, 4);
|
|
assert.equal(restored.plan.excludeFilters, undefined);
|
|
});
|
|
|
|
test("a specific vehicle can be selected without broadening the user's authorized data", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "all",
|
|
groupBy: "none",
|
|
view: "list",
|
|
vehicleIds: ["jx-abnormal"],
|
|
}),
|
|
});
|
|
const result = await ask({
|
|
message: "帮我看看测试车-jx-abnormal现在的维修情况和预计结束时间",
|
|
});
|
|
assert.deepEqual(ids(result), ["jx-abnormal"]);
|
|
assert.equal(result.rows[0].abnormalType, "维修");
|
|
assert.equal(result.rows[0].targetCompletionTime, "2020-01-01 12:00:00");
|
|
assert.ok(!ids(result).includes("not-authorized"));
|
|
});
|
|
|
|
test("clearing a filter and resetting the scope are deterministic and do not need another planning call", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
responses.push({
|
|
plan: plan({
|
|
filters: { region: ["华东"], city: ["嘉兴市"] },
|
|
excludeFilters: { city: ["杭州市"] },
|
|
}),
|
|
});
|
|
const root = await ask({ message: "看华东嘉兴库存" });
|
|
assert.equal(root.rows.length, 2);
|
|
const cleared = await ask({
|
|
...based(root),
|
|
action: { type: "clear-filter", dimension: "city" },
|
|
});
|
|
assert.deepEqual(cleared.plan.filters, { region: ["华东"] });
|
|
assert.equal(cleared.plan.excludeFilters?.city, undefined);
|
|
assert.equal(cleared.rows.length, 4);
|
|
const ranked = await ask({
|
|
...based(cleared),
|
|
action: { type: "sort", sortBy: "count", sortOrder: "desc", topN: 1 },
|
|
});
|
|
assert.equal(ranked.groups.length, 1);
|
|
const reset = await ask({
|
|
...based(ranked),
|
|
action: { type: "reset-filters" },
|
|
});
|
|
assert.deepEqual(reset.plan.filters, {});
|
|
assert.equal(reset.plan.excludeFilters, undefined);
|
|
assert.equal(reset.plan.topN, undefined);
|
|
assert.equal(reset.rows.length, 5);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test("upcoming and missing due-date analysis selects actual abnormal records", async (t) => {
|
|
const { ask, responses, rows } = setup(t);
|
|
const abnormal = rows.find((row) => row.id === "jx-abnormal")!;
|
|
const future = new Date(Date.now() + 2 * 86400000).toISOString();
|
|
rows.push(
|
|
{
|
|
...abnormal,
|
|
id: "due-soon",
|
|
abnormalMove: { ...abnormal.abnormalMove!, targetCompletionTime: future },
|
|
},
|
|
{
|
|
...abnormal,
|
|
id: "already-completed",
|
|
abnormalMove: {
|
|
...abnormal.abnormalMove!,
|
|
targetCompletionTime: future,
|
|
actualCompletionTime: new Date().toISOString(),
|
|
},
|
|
},
|
|
{
|
|
...abnormal,
|
|
id: "far-future",
|
|
abnormalMove: {
|
|
...abnormal.abnormalMove!,
|
|
targetCompletionTime: new Date(
|
|
Date.now() + 30 * 86400000,
|
|
).toISOString(),
|
|
},
|
|
},
|
|
{
|
|
...abnormal,
|
|
id: "missing-due-date",
|
|
abnormalMove: { ...abnormal.abnormalMove!, targetCompletionTime: null },
|
|
},
|
|
);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "abnormal",
|
|
view: "list",
|
|
exception: "dueSoon",
|
|
filters: { city: ["嘉兴市"] },
|
|
}),
|
|
});
|
|
const upcoming = await ask({ message: "嘉兴的异动哪些未来七天就该结束了?" });
|
|
assert.deepEqual(ids(upcoming), ["due-soon"]);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "abnormal",
|
|
view: "list",
|
|
exception: "missingDueDate",
|
|
filters: { city: ["嘉兴市"] },
|
|
}),
|
|
});
|
|
const missing = await ask({
|
|
...based(upcoming),
|
|
message: "那些连预计结束时间都没填的呢?",
|
|
});
|
|
assert.deepEqual(ids(missing), ["missing-due-date"]);
|
|
assert.equal(missing.rows[0].targetCompletionTime, undefined);
|
|
const reset = await ask({
|
|
...based(missing),
|
|
action: { type: "reset-filters" },
|
|
});
|
|
assert.equal(reset.plan.exception, undefined);
|
|
assert.deepEqual(reset.plan.filters, {});
|
|
assert.equal(
|
|
reset.plan.category,
|
|
"abnormal",
|
|
"Reset filters retains the selected business category",
|
|
);
|
|
});
|
|
|
|
test("model outage must not answer Chinese historical-stock questions with current inventory", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
for (const message of [
|
|
"2020年8月31日嘉兴库存有多少?",
|
|
"去年年底的库存是多少?",
|
|
]) {
|
|
responses.push(new Error("Synthetic model outage"));
|
|
const result = await ask({ message });
|
|
assert.equal(result.plan.kind, "clarify", message);
|
|
assert.equal(
|
|
result.rows.length,
|
|
0,
|
|
"Current vehicles are not historical inventory evidence",
|
|
);
|
|
assert.equal(result.gap?.reason, "unsupported");
|
|
assert.match(result.summary, /历史|快照|尚未|暂不/);
|
|
}
|
|
});
|
|
|
|
test("historical flow period survives display changes and day drill but clears on current-assets switch", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
t.mock.method(
|
|
vehicleRepository,
|
|
"getFlowDetailRows",
|
|
async (start: string, end: string) =>
|
|
[
|
|
{
|
|
id: "flow-a",
|
|
truck_id: "jx-stock",
|
|
stat_date: "2020-08-10",
|
|
type: "delivered",
|
|
type_label: "交车",
|
|
},
|
|
{
|
|
id: "flow-b",
|
|
truck_id: "jx-abnormal",
|
|
stat_date: "2020-08-11",
|
|
type: "replaced",
|
|
type_label: "替换",
|
|
},
|
|
{
|
|
id: "flow-c",
|
|
truck_id: "hz-stock",
|
|
stat_date: "2020-08-11",
|
|
type: "returned",
|
|
type_label: "还车",
|
|
},
|
|
{
|
|
id: "flow-d",
|
|
truck_id: "not-authorized",
|
|
stat_date: "2020-08-11",
|
|
type: "delivered",
|
|
type_label: "交车",
|
|
},
|
|
].filter((event) => event.stat_date >= start && event.stat_date <= end),
|
|
);
|
|
responses.push({
|
|
plan: plan({
|
|
kind: "flow",
|
|
category: "all",
|
|
groupBy: "none",
|
|
start: "2020-08-10",
|
|
end: "2020-08-12",
|
|
comparePrevious: true,
|
|
filters: { city: ["嘉兴市"] },
|
|
}),
|
|
});
|
|
const flow = await ask({
|
|
message: "嘉兴2020年8月10日至12日的交退换,和上一等长周期对比",
|
|
});
|
|
assert.deepEqual(ids(flow), ["flow-a", "flow-b"]);
|
|
assert.deepEqual(flow.comparisonPeriod, {
|
|
start: "2020-08-07",
|
|
end: "2020-08-09",
|
|
});
|
|
const table = await ask({
|
|
...based(flow),
|
|
action: { type: "view", view: "table" },
|
|
});
|
|
assert.equal(table.plan.start, "2020-08-10");
|
|
assert.equal(table.plan.end, "2020-08-12");
|
|
assert.equal(table.plan.comparePrevious, true);
|
|
const day = await ask({
|
|
...based(table),
|
|
action: { type: "day", date: "2020-08-11" },
|
|
});
|
|
assert.equal(day.plan.start, "2020-08-11");
|
|
assert.equal(day.plan.end, "2020-08-11");
|
|
assert.equal(day.plan.comparePrevious, false);
|
|
assert.deepEqual(ids(day), ["flow-b"]);
|
|
assert.equal(
|
|
day.metrics[0].value,
|
|
1,
|
|
"Replacement delivery is included exactly once",
|
|
);
|
|
assert.equal(day.metrics[2].value, 1);
|
|
const current = await ask({
|
|
...based(day),
|
|
action: { type: "category", category: "inventory" },
|
|
});
|
|
assert.equal(current.plan.kind, "assets");
|
|
assert.equal(current.plan.start, undefined);
|
|
assert.equal(current.plan.end, undefined);
|
|
assert.equal(current.plan.comparePrevious, undefined);
|
|
assert.equal(current.comparison, undefined);
|
|
assert.deepEqual(current.plan.filters, { city: ["嘉兴市"] });
|
|
assert.deepEqual(ids(current), ["jx-abnormal", "jx-stock"]);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test("visible interpretation includes exception scope and clears it after a category change", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
responses.push({
|
|
plan: plan({
|
|
category: "abnormal",
|
|
exception: "overdue",
|
|
filters: { region: ["华东"] },
|
|
excludeFilters: { city: ["杭州市"] },
|
|
}),
|
|
});
|
|
const overdue = await ask({
|
|
message: "华东除了杭州,已经超过预计完成时间的异动车有哪些?",
|
|
});
|
|
assert.match(overdue.interpretation || "", /华东/);
|
|
assert.match(overdue.interpretation || "", /排除城市:杭州市/);
|
|
assert.match(overdue.interpretation || "", /超期|逾期|超过|已超/);
|
|
const pending = await ask({
|
|
...based(overdue),
|
|
action: { type: "category", category: "pending" },
|
|
});
|
|
assert.equal(pending.plan.exception, undefined);
|
|
assert.doesNotMatch(pending.interpretation || "", /超期|逾期|超过|已超/);
|
|
assert.equal(
|
|
pending.gap,
|
|
undefined,
|
|
"A valid empty subset is not a demand gap",
|
|
);
|
|
});
|
|
|
|
test("unmet follow-up demands keep enough prior scope to be actionable outside the chat", async (t) => {
|
|
const { ask, responses, recorded } = setup(t);
|
|
responses.push({ plan: plan({ filters: { city: ["嘉兴市"] } }) });
|
|
const current = await ask({ message: "嘉兴库存按车型拆开" });
|
|
responses.push({
|
|
kind: "clarify",
|
|
plan: plan({
|
|
kind: "clarify",
|
|
clarification: "目前没有历史库存快照,暂时无法计算去年同期。",
|
|
}),
|
|
reason: "unsupported",
|
|
});
|
|
const missing = await ask({ ...based(current), message: "去年同期呢?" });
|
|
assert.equal(missing.gap?.recorded, true);
|
|
assert.equal(recorded.length, 1);
|
|
assert.match(recorded[0].demand, /去年同期/);
|
|
assert.match(recorded[0].demand, /嘉兴/);
|
|
assert.match(recorded[0].demand, /库存/);
|
|
});
|
|
|
|
test("clarification between a result and an unsupported follow-up keeps business context in the demand inbox", async (t) => {
|
|
const { ask, responses, recorded } = setup(t);
|
|
responses.push({ plan: plan({ filters: { city: ["嘉兴市"] } }) });
|
|
const current = await ask({ message: "嘉兴库存有多少?" });
|
|
responses.push({
|
|
kind: "clarify",
|
|
plan: plan({ kind: "clarify", clarification: "你想对比哪个时间段?" }),
|
|
});
|
|
const clarification = await ask({
|
|
...based(current),
|
|
message: "跟以前比一下",
|
|
});
|
|
responses.push({
|
|
kind: "clarify",
|
|
plan: plan({
|
|
kind: "clarify",
|
|
clarification: "缺少历史库存快照,暂时无法计算去年同期。",
|
|
}),
|
|
reason: "unsupported",
|
|
});
|
|
await ask({ ...based(clarification), message: "去年同期呢?" });
|
|
assert.equal(recorded.length, 1);
|
|
assert.match(recorded[0].demand, /去年同期/);
|
|
assert.match(recorded[0].demand, /嘉兴/);
|
|
assert.match(recorded[0].demand, /库存/);
|
|
});
|
|
|
|
test("references to ranked results receive the selected result's aggregate evidence", async (t) => {
|
|
const { ask, responses, requests } = setup(t);
|
|
responses.push({
|
|
plan: plan({
|
|
groupBy: "region",
|
|
topN: 2,
|
|
sortBy: "count",
|
|
sortOrder: "desc",
|
|
}),
|
|
});
|
|
const ranked = await ask({ message: "哪些区域库存最多?" });
|
|
assert.equal(ranked.groups[0].name, "华东");
|
|
responses.push({
|
|
plan: plan({ filters: { region: ["华东"] }, groupBy: "city" }),
|
|
});
|
|
const detail = await ask({
|
|
...based(ranked),
|
|
message: "数量最多的那个区域,再按城市拆开",
|
|
});
|
|
const request = JSON.parse(requests.at(-1)!);
|
|
const context = JSON.parse(
|
|
[...request.messages]
|
|
.reverse()
|
|
.find((message: { role: string }) => message.role === "user").content,
|
|
);
|
|
assert.ok(
|
|
context.resultContext,
|
|
"Ranking references need the actual result, not just its query plan",
|
|
);
|
|
assert.ok(
|
|
context.resultContext.groups.some(
|
|
(group: { name: string; count: number }) =>
|
|
group.name === "华东" && group.count === 4,
|
|
),
|
|
);
|
|
assert.deepEqual(detail.plan.filters, { region: ["华东"] });
|
|
assert.equal(detail.rows.length, 4);
|
|
});
|
|
|
|
|
|
test("department operating situation repairs an all-assets proposal and preserves scope on drill", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
for (const message of ["各部门车辆运营情况", "各部门车辆的运营情况"]) {
|
|
responses.push({ plan: plan({ category: "all", groupBy: "department" }) });
|
|
const result = await ask({ message });
|
|
assert.equal(result.plan.category, "operating");
|
|
assert.equal(result.plan.groupBy, "department");
|
|
assert.deepEqual(result.rows.map(row => row.id), ["jx-running"]);
|
|
assert.deepEqual(result.metrics, [{ label: "运营车辆", value: 1, unit: "台" }]);
|
|
const detail = await ask({ ...based(result), action: { type: "drill", keys: result.groups[0].keys! } });
|
|
assert.equal(detail.plan.category, "operating");
|
|
assert.deepEqual(detail.rows.map(row => row.id), ["jx-running"]);
|
|
}
|
|
});
|
|
|
|
test("department operating rate retains all assets as its denominator", async (t) => {
|
|
const { ask, responses } = setup(t);
|
|
responses.push({ plan: plan({ category: "all", groupBy: "department" }) });
|
|
const result = await ask({ message: "各部门车辆运营情况和运营率" });
|
|
assert.equal(result.plan.category, "all");
|
|
assert.equal(result.rows.length, 6);
|
|
assert.equal(result.metrics.find(metric => metric.label === "运营率")?.value, 16.7);
|
|
});
|