fix(asset-ai): answer vehicle status from authorized vehicle facts
ci/woodpecker/push/woodpecker Pipeline was canceled

This commit is contained in:
shishengliang
2026-09-23 18:05:10 +08:00
parent d3a56b2cc1
commit d3fcec7dce
12 changed files with 317 additions and 5 deletions
@@ -123,6 +123,7 @@ export function applyAction(
result: AssistantResult,
): QueryPlan {
const next = structuredClone(plan);
if (!["restore", "view"].includes(action.type)) delete next.responseMode;
if (next.analysis === "briefing" && ["category", "group", "drill", "exception"].includes(action.type)) next.analysis = "distribution";
switch (action.type) {
case "clear-filter":
@@ -160,7 +160,7 @@ function fallback(target: AssistantResult, warning: string) {
export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise<void> {
if (process.env.ASSET_AI_EXTERNAL_ENABLED === "0") return;
if (signal.aborted) throw signal.reason || new Error("已取消");
const targets = targetsFor(result);
const targets = targetsFor(result).filter(target => target.result.plan.responseMode !== "vehicle-status");
if (!targets.length) return;
const markFallback = (warning: string) => {
for (const target of targets) fallback(target.result, warning);
@@ -10,6 +10,7 @@ import {
type AssistantResult,
type AssistantRow,
} from "../../../shared/assets/assistant.js";
import { vehicleStatusResult } from "./vehicle-status.js";
import { filterByPermission } from "../../auth/permissions.js";
import type { AuthUser } from "../../auth/types.js";
import type { Vehicle } from "../../types.js";
@@ -55,6 +56,7 @@ export function validatePlan(value: unknown): QueryPlan {
view: p.view as QueryPlan["view"],
filters,
};
if (p.responseMode === "vehicle-status" && plan.kind === "assets") plan.responseMode = "vehicle-status";
if (p.secondaryGroupBy != null) {
if (
!DIMENSIONS.includes(p.secondaryGroupBy as Dimension) ||
@@ -109,6 +111,7 @@ export function validatePlan(value: unknown): QueryPlan {
throw new Error("车辆选择无效。");
plan.vehicleIds = [...new Set(p.vehicleIds)] as string[];
}
if (plan.responseMode && !plan.vehicleIds?.length) delete plan.responseMode;
if (p.topN != null) {
if (!Number.isInteger(p.topN) || Number(p.topN) < 1 || Number(p.topN) > 100)
throw new Error("排名数量应为 1 至 100。");
@@ -249,6 +252,7 @@ export function assetResult(
): AssistantResult {
const selected = filterVehicles(vehicles, plan);
const rows = selected.map(toRow);
if (plan.kind === "assets" && plan.responseMode === "vehicle-status") return vehicleStatusResult(rows, plan);
const groups = new Map<string, AssistantGroup>();
rows.forEach((row, index) => {
const dims = [plan.groupBy, plan.secondaryGroupBy].filter(
+48 -2
View File
@@ -1,3 +1,4 @@
import { planVehicleStatus } from "./vehicle-status.js";
import type {
AssistantResult,
AnalysisQuery,
@@ -364,6 +365,7 @@ function assetScope(plan: QueryPlan): QueryPlan {
return next;
}
export function normalizeBusinessIntent(message: string, decision: ConversationPlan, previous?: QueryPlan): ConversationPlan {
if (decision.plan.responseMode && /统计|运营率|占比|多少|总数|分布|对比|全部|取消|重置|新问题/.test(message)) delete decision.plan.responseMode;
// Composite items have already been reconciled using their own questions.
if (decision.queries?.length) return decision;
let result = { ...decision, plan: structuredClone(decision.plan) };
@@ -714,13 +716,28 @@ function composeAnalysisQueries(
continue;
}
unanswered.push(...(decision.unanswered || []));
// Bind identity and answer intent before deduplication: status and statistics
// can share a query scope while requiring different answers.
const normalizeIdentity = (value: string) => value.toUpperCase().replace(/\s+/g, "");
const mentions = (value: string) => input.vehicles.filter(v => (v.plateNumber && normalizeIdentity(value).includes(normalizeIdentity(v.plateNumber))) || (v.vin && normalizeIdentity(value).includes(normalizeIdentity(v.vin))));
const explicit = mentions(question);
const overall = mentions(input.message);
if (/全公司|全部|整体|所有车辆|车队/.test(question)) {
delete decision.plan.vehicleIds;
delete decision.plan.responseMode;
} else if (!/除了|排除|不要看|不看/.test(question)) {
if (explicit.length) decision.plan.vehicleIds = explicit.map(v => String(v.id));
else if (overall.length === 1 && !/除了|排除|不要看|不看/.test(input.message)) decision.plan.vehicleIds = [String(overall[0].id)];
const status = planVehicleStatus(`这些车${question}`, input.vehicles, decision.plan);
if (status?.responseMode === "vehicle-status") decision.plan = status;
}
const fingerprint = JSON.stringify({ ...decision.plan, filters: Object.fromEntries(Object.entries(decision.plan.filters).sort(([a], [b]) => a.localeCompare(b)).map(([key, values]) => [key, [...values].sort()])) });
if (plans.has(fingerprint)) continue;
plans.add(fingerprint);
const preferredId = typeof item.id === "string" && /^[\w-]{1,64}$/.test(item.id) ? item.id : `analysis-${index + 1}`;
const id = ids.has(preferredId) ? `${preferredId}-${index + 1}` : preferredId;
ids.add(id);
queries.push({ id, title, question, plan: decision.plan, display });
queries.push({ id, title, question, plan: decision.plan, display: decision.plan.responseMode === "vehicle-status" ? "list" : display });
} catch {
unanswered.push({ question, reason: "该项查询方案暂时无法执行,已保留其他可以完成的分析。" });
}
@@ -914,10 +931,12 @@ async function requestPlan(
}, base);
}
export async function planConversation(
async function planConversationInternal(
input: ConversationInput,
): Promise<ConversationPlan> {
if (input.signal.aborted) throw input.signal.reason || new Error("已取消");
const statusPlan = planVehicleStatus(input.message, input.vehicles, input.previous);
if (statusPlan) return { plan: statusPlan, engine: "local" };
if (
!process.env.DEEPSEEK_API_KEY ||
process.env.ASSET_AI_EXTERNAL_ENABLED === "0"
@@ -940,6 +959,33 @@ export async function planConversation(
}
}
/** Explicit authorized identities constrain every query, including statistical questions. */
export async function planConversation(input: ConversationInput): Promise<ConversationPlan> {
input.signal.throwIfAborted();
const normalized = input.message.toUpperCase().replace(/\s+/g, "");
const matched = input.vehicles.filter(v => (v.plateNumber && normalized.includes(v.plateNumber.toUpperCase().replace(/\s+/g, ""))) || (v.vin && normalized.includes(v.vin.toUpperCase())));
const plates = normalized.match(/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{5,6}/g) || [];
const vins = normalized.match(/(?<![A-Z0-9])[A-HJ-NPR-Z0-9]{17}(?![A-Z0-9])/g) || [];
if (plates.some(plate => !matched.some(v => v.plateNumber?.toUpperCase().replace(/\s+/g, "") === plate)) || vins.some(vin => !matched.some(v => v.vin?.toUpperCase() === vin))) {
return { plan: { kind: "clarify", category: "all", groupBy: "none", view: "list", filters: {}, clarification: "当前账号可见范围内未找到所指定车辆,请核对车牌或 VIN,或确认访问权限。" }, engine: "local" };
}
const decision = await planConversationInternal(input);
if (!matched.length && /它|这辆|该车|这台|这些车/.test(input.message) && !/全部|取消|重置|新问题/.test(input.message) && input.previous?.vehicleIds?.length && !decision.queries?.length && decision.plan.kind !== "clarify") decision.plan.vehicleIds = [...input.previous.vehicleIds];
if (matched.length && !decision.queries?.length && decision.plan.kind !== "clarify") {
if (/回到全部|查看全部|取消.*筛选|重置|新问题/.test(input.message)) {
delete decision.plan.vehicleIds;
delete decision.plan.responseMode;
} else if (/除了|排除|不要看|不看/.test(input.message)) {
const excluded = new Set(matched.map(v => String(v.id)));
const remaining = input.vehicles.filter(v => !excluded.has(String(v.id))).map(v => String(v.id));
if (remaining.length > 100) return { plan: { kind: "clarify", category: "all", groupBy: "none", view: "list", filters: {}, clarification: "当前排除车辆后的范围超过车辆选择上限,请先限定城市或部门后再查询。" }, engine: decision.engine };
decision.plan.vehicleIds = remaining;
delete decision.plan.responseMode;
} else decision.plan.vehicleIds = [...new Set(matched.map(v => String(v.id)))];
}
return decision;
}
// Legacy integration uses the same model-first planner, with no local override of
// category, filters, dates or presentation selected by the model.
export async function modelPlan(
@@ -8,7 +8,7 @@ export function resultScope(plan: QueryPlan, subject: string | null): NonNullabl
...(plan.vehicleIds ? [`指定 ${plan.vehicleIds.length} 台车辆`] : []),
...(plan.exception ? [{ overdue: "异动已超预计完成时间", dueSoon: "异动未来7天到期", missingDueDate: "异动未填写预计完成时间" }[plan.exception]] : []),
];
const definitions = plan.kind === "flow" ? [
const definitions = plan.responseMode === "vehicle-status" ? ["状态来自当前资产台账,不代表车辆实时位置、行驶或故障状态。", "运营状态、库存归属、异动及待交车分别展示,不互相替代。"] : plan.kind === "flow" ? [
"按运维签章日期统计业务车次,替换交车已计入交车,不能重复相加。",
"按当前账号可见车辆及当前筛选条件查询流转记录;车次差额不代表净增车辆,增减本身不能证明经营改善。",
] : [
@@ -31,6 +31,7 @@ export function resultScope(plan: QueryPlan, subject: string | null): NonNullabl
export function followupSuggestions(result: AssistantResult): string[] {
const p = result.plan;
if (p.responseMode === "vehicle-status") return result.rows.length ? ["查看全部库存"] : ["取消所有筛选"];
if (p.kind === "clarify") return [...new Set(result.suggestions || [])].slice(0, 3);
const suggestions: string[] = [];
if (p.kind === "flow") {
@@ -0,0 +1,132 @@
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 { AssistantResult, AssistantRequest } from "../../../shared/assets/assistant.js";
import { copyResultText } from "../../../modules/assets/assistant/result-presentation.js";
let sequence = 0;
function setup(t: TestContext) {
const user = { userId: `independent-${++sequence}`, userName: "独立验收", loginName: "qa", depCode: "qa", depName: "业务三部", permissionLevel: "personal", roles: [] } as AuthUser;
const rows = [
{ id: "rental", plateNumber: "沪A57850F", status: "Operating", operationStatus: "1" },
{ id: "repair", plateNumber: "沪B12345", status: "Abnormal", operationStatus: "4", abnormalMove: { type: "维修", targetCompletionTime: "2026-12-30 12:00:00" } },
{ id: "hidden", plateNumber: "沪C99999", status: "Operating", operationStatus: "2", managerId: "someone-else" },
].map(v => ({ city: "西安市", province: "陕西省", model: "苏龙18T飞翼", type: "18T", subjectOrg: "验收公司", managerId: user.userId, departmentName: user.depName, ...v })) as Vehicle[];
t.mock.method(vehicleRepository, "getVehicles", async () => rows);
t.mock.method(gapStore, "record", async () => {});
const old = process.env.DEEPSEEK_API_KEY;
const enabled = process.env.ASSET_AI_EXTERNAL_ENABLED;
process.env.DEEPSEEK_API_KEY = "synthetic-acceptance";
process.env.ASSET_AI_EXTERNAL_ENABLED = "1";
t.after(() => { if (old === undefined) delete process.env.DEEPSEEK_API_KEY; else process.env.DEEPSEEK_API_KEY = old; if (enabled === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = enabled; });
let failed = false;
let queries: unknown[] | undefined;
const bodies: string[] = [];
t.mock.method(globalThis, "fetch", async (_url: unknown, init?: RequestInit) => {
bodies.push(String(init?.body));
if (failed) throw new Error("independent simulated outage");
const context = JSON.parse(JSON.parse(String(init?.body)).messages.find((m: {role:string}) => m.role === "user").content);
// Deliberately wrong fleet plan tests deterministic repair, rather than repeating expected implementation output.
const proposal = context.stage === "presentation" ? { layout: "stack", results: context.results.map((r: {id:string}) => ({ id: r.id, blocks: [{ id: "summary", kind: "summary" }], interactions: [] })) } : queries ? { kind: "query", queries } : { kind: "query", plan: { kind: "assets", category: "all", groupBy: "none", view: "chart", filters: {} } };
return new Response(JSON.stringify({ choices: [{ message: {content: JSON.stringify(proposal)}, finish_reason: "stop" }] }));
});
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use("*", async (c, next) => { c.set("user", user); await next(); });
app.route("/assistant", router);
return { rows, bodies, compound: (value: unknown[]) => { queries = value; }, outage: () => { failed = true; }, ask: async (message: string, base?: AssistantResult) => {
const input: AssistantRequest = { message, subject: null, sessionId: base?.sessionId, baseResultId: base?.resultId };
const response = await app.request("/assistant/ask", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(input) });
assert.equal(response.status, 200);
const events = (await response.text()).split("\n").filter(x => x.startsWith("data:")).map(x => JSON.parse(x.slice(5)));
assert.equal(events.find(x => x.type === "error"), undefined);
const result = events.find(x => x.type === "result")?.result as AssistantResult;
assert.ok(result); return result;
}};
}
test("independent route: rental answer overrides fleet model and copied answer stays relevant", async t => {
const s = setup(t); const r = await s.ask("沪A57850F 这辆车现在是什么状态。");
assert.deepEqual(r.rows.map(x => x.id), ["rental"]);
assert.match(r.summary, /租赁/); assert.match(r.summary, /不属于库存/);
assert.equal(r.metrics.length, 0); assert.equal(r.groups.length, 0);
assert.doesNotMatch(copyResultText(r, null), /运营率|1台状态/);
});
test("independent route: abnormal inventory status survives model outage", async t => {
const s = setup(t); s.outage(); const r = await s.ask("沪B12345 现在是什么状态?");
assert.match(r.summary, /待运营/); assert.match(r.summary, /库存/); assert.equal(r.rows[0].abnormalType, "维修"); assert.ok(r.presentation?.blocks.some(b => b.columns?.some(c => c.field === "abnormalType")));
assert.equal(r.metrics.length, 0);
});
test("independent route: invisible vehicle cannot become fleet answer or leak details", async t => {
const s = setup(t); const r = await s.ask("沪C99999 现在是什么状态?");
assert.equal(r.rows.length, 0); assert.doesNotMatch(r.summary, /自营|租赁/);
assert.ok(/未找到|未查询|无匹配|没有找到|未能完整匹配/.test(r.summary), r.summary);
assert.ok(s.bodies.every(x => !x.includes('"id":"hidden"')));
});
test("independent route: multi-vehicle status preserves both facts", async t => {
const s = setup(t); const r = await s.ask("沪A57850F 和沪B12345 现在各是什么状态?");
assert.deepEqual(r.rows.map(x => x.id).sort(), ["rental", "repair"]);
assert.match(r.summary, /租赁/); assert.match(r.summary, /待运营/);
assert.equal(r.metrics.length, 0);
});
test("independent route: followup retains vehicle and explicit all-assets clears it", async t => {
const s = setup(t); const first = await s.ask("沪A57850F 现在是什么状态?");
const next = await s.ask("它属于库存吗?", first);
assert.deepEqual(next.rows.map(x => x.id), ["rental"]); assert.match(next.summary, /不属于库存/);
const all = await s.ask("取消所有筛选,查看全部资产总数", next);
assert.deepEqual(all.rows.map(x => x.id).sort(), ["rental", "repair"]); assert.ok(all.metrics.length);
});
test("independent route: explicit single-vehicle operating rate remains a statistic", async t => {
const s = setup(t); const r = await s.ask("沪A57850F 这辆车的运营率是多少?");
assert.deepEqual(r.rows.map(x => x.id), ["rental"]);
assert.ok(r.metrics.some(x => x.label === "运营率" && x.value === 100));
});
test("independent route: unknown explicit vehicle statistic never broadens to fleet", async t => {
const s = setup(t); const r = await s.ask("沪D88888 这辆车的运营率是多少?");
assert.equal(r.rows.length, 0);
assert.equal(r.metrics.length, 0);
});
test("independent route: single-vehicle statistic followup retains scope", async t => {
const s = setup(t); const first = await s.ask("沪A57850F 现在是什么状态?");
const r = await s.ask("它的运营率是多少?", first);
assert.deepEqual(r.rows.map(x => x.id), ["rental"]);
assert.ok(r.metrics.some(x => x.label === "运营率" && x.value === 100));
});
test("independent route: explicit exclusion must not select the excluded vehicle", async t => {
const s = setup(t); const r = await s.ask("除了沪A57850F,其他车辆运营率是多少?");
assert.deepEqual(r.rows.map(x => x.id), ["repair"]);
});
test("independent route: mentioning old vehicle while resetting must not pin it", async t => {
const s = setup(t); const first = await s.ask("沪A57850F 现在是什么状态?");
const r = await s.ask("不要看沪A57850F,回到全部资产", first);
assert.deepEqual(r.rows.map(x => x.id).sort(), ["rental", "repair"]);
});
const compoundQuery = (id: string, question: string) => ({ id, title: question, question, plan: {kind: "assets", category: "all", groupBy: "none", view: "chart", filters: {}}, display: "metrics" });
test("independent route: compound single-vehicle state and rate bind the same vehicle", async t => {
const s = setup(t); s.compound([compoundQuery("status", "沪A57850F 当前状态"), compoundQuery("rate", "它的运营率是多少")]);
const r = await s.ask("沪A57850F 现在什么状态,它的运营率是多少?");
assert.equal(r.sections?.length, 2);
for (const item of r.sections!) assert.deepEqual(item.result?.rows.map(x => x.id), ["rental"]);
assert.match(r.sections![0].result!.summary, /租赁/);
assert.equal(r.sections![0].result!.metrics.length, 0);
assert.ok(r.sections![1].result!.metrics.some(x => x.label === "运营率" && x.value === 100));
});
test("independent route: compound vehicle state and company rate keep separate scopes", async t => {
const s = setup(t); s.compound([compoundQuery("status", "沪A57850F 当前状态"), compoundQuery("rate", "全公司运营率是多少")]);
const r = await s.ask("沪A57850F 现在什么状态,全公司运营率是多少?");
assert.deepEqual(r.sections![0].result!.rows.map(x => x.id), ["rental"]);
assert.match(r.sections![0].result!.summary, /租赁/);
assert.deepEqual(r.sections![1].result!.rows.map(x => x.id).sort(), ["rental", "repair"]);
assert.ok(r.sections![1].result!.metrics.some(x => x.label === "运营率" && x.value === 50));
});
test("independent route: pending delivery remains separate from awaiting operation", async t => {
const s = setup(t); s.rows[1].status = "Pending"; s.rows[1].operationStatus = "4";
const r = await s.ask("沪B12345 现在是什么状态?");
assert.match(r.summary, /待运营/); assert.match(r.summary, /标记为待交车/);
assert.equal(r.rows[0].pending, true); assert.equal(r.rows[0].abnormal, false);
assert.equal(r.metrics.length, 0);
});
@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import test from "node:test";
import { planVehicleStatus, vehicleStatusResult } from "./vehicle-status.js";
import { applyAction } from "./execute.js";
import type { Vehicle } from "../../types.js";
import type { AssistantRow } from "../../../shared/assets/assistant.js";
const vehicles = [{ id: "a", plateNumber: "沪A57850F", vin: "L123456789012345X" }] as Vehicle[];
test("natural vehicle questions resolve locally and unknown VIN never queries all assets", () => {
for (const question of ["沪A57850F现在怎么样", "沪A57850F在运营吗", "沪A57850F在哪个部门"]) {
assert.deepEqual(planVehicleStatus(question, vehicles)?.vehicleIds, ["a"]);
}
assert.equal(planVehicleStatus("L123456789012345Y现在什么状态", vehicles)?.kind, "clarify");
});
test("missing flags remain unknown and resetting removes status presentation", () => {
const plan = planVehicleStatus("沪A57850F状态", vehicles)!;
const result = vehicleStatusResult([{ id: "a", plate: "沪A57850F", status: "未标注" } as AssistantRow], plan);
assert.match(result.summary, /库存归属未标注/);
assert.match(result.summary, /异动标记未知/);
assert.match(result.summary, /待交车标记未知/);
const reset = applyAction(plan, { type: "reset-filters" }, result);
assert.equal(reset.responseMode, undefined);
assert.equal(reset.vehicleIds, undefined);
});
@@ -0,0 +1,38 @@
import type { AssistantResult, AssistantRow, QueryPlan } from "../../../shared/assets/assistant.js";
import type { Vehicle } from "../../types.js";
/** Resolve explicit vehicle identity against authorized rows, never model supplied identities. */
export function planVehicleStatus(message: string, vehicles: Vehicle[], previous?: QueryPlan): QueryPlan | undefined {
if (/除了|排除|不要看|不看|统计|数量|几台|几辆|运营率|占比|多少|总数|分布|对比|汇总|全部|取消|重置|新问题|历史|轨迹|位置|原因|为什么|成本|收入|交退换|趋势/.test(message)) return;
const normalized = message.toUpperCase().replace(/\s+/g, "");
const mentioned = vehicles.filter(v => (v.plateNumber && normalized.includes(v.plateNumber.toUpperCase().replace(/\s+/g, ""))) || (v.vin && normalized.includes(v.vin.toUpperCase())));
const plateLike = normalized.match(/[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼][A-Z][A-Z0-9]{5,6}/g) || [];
const vinLike = normalized.match(/(?<![A-Z0-9])[A-HJ-NPR-Z0-9]{17}(?![A-Z0-9])/g) || [];
const statusQuestion = /状态|情况|怎么样|在干嘛|运营|库存|异动|待交车|租赁|自营|资料|信息|明细|部门|车型|型号/.test(message);
if (!statusQuestion) return;
const inherited = !mentioned.length && !plateLike.length && !vinLike.length && /这辆|这台|该车|它|这些车|它们/.test(message) ? previous?.vehicleIds : undefined;
if (!mentioned.length && !plateLike.length && !vinLike.length && !inherited?.length) return;
// An unknown explicit plate must never broaden a query to all visible vehicles.
const ids = [...new Set(mentioned.map(v => String(v.id)))];
const unknown = plateLike.some(plate => !mentioned.some(v => v.plateNumber?.toUpperCase().replace(/\s+/g, "") === plate));
if (unknown || vinLike.some(vin => !mentioned.some(v => v.vin?.toUpperCase() === vin))) return { kind: "clarify", category: "all", groupBy: "none", view: "list", filters: {}, clarification: "当前账号可见范围内未能完整匹配所指定车辆,请核对车牌或 VIN,或确认访问权限。" };
return { kind: "assets", category: "all", groupBy: "none", view: "list", filters: {}, vehicleIds: inherited || ids, responseMode: "vehicle-status" };
}
/** All sentences bind to server-owned row fields; no model-written status claims. */
export function vehicleStatusResult(rows: AssistantRow[], plan: QueryPlan): AssistantResult {
const statements = rows.map(row => `${row.plate || "未登记车牌"} 当前运营状态为「${row.status || "未标注"}」,${row.inventory === undefined ? "库存归属未标注" : row.inventory ? "属于库存" : "不属于库存"}。${row.abnormal === undefined ? "异动标记未知" : row.abnormal ? "当前标记为异动" : "当前未标记异动"},${row.pending === undefined ? "待交车标记未知" : row.pending ? "标记为待交车" : "未标记待交车"}。`);
const summary = statements.join("\n") || "当前账号可见范围内未找到所选车辆,无法确认其状态。";
return {
plan, title: rows.length === 1 ? `${rows[0].plate} 当前状态` : "车辆当前状态", summary,
answerKind: "analysis", outcome: rows.length ? "complete" : "empty",
source: "资产台账 · 当前账号可见车辆", fetchedAt: new Date().toISOString(), rows, metrics: [], groups: [],
presentation: { blocks: [
{ id: "status", kind: "summary", text: summary },
...(rows.length ? [{ id: "details", kind: "table" as const, title: "台账资料", dataset: "rows" as const, columns: [
{ field: "plate", label: "车牌" }, { field: "model", label: "型号" }, { field: "city", label: "台账城市" }, { field: "department", label: "归属部门" },
...(rows.some(row => row.abnormal) ? [{ field: "abnormalType", label: "异动类型" }, { field: "targetCompletionTime", label: "预计完成时间" }] : []),
] }] : []),
], interactions: [] },
};
}