feat(asset-ai): simplify count answers and refine mobile conversation layout
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 18:35:31 +08:00
parent fd514177c6
commit 5be6240bbf
13 changed files with 251 additions and 58 deletions
@@ -257,3 +257,15 @@ test("metric references keep one unit when the model appends it in prose", async
await generatePresentation(value,"说明库存",signal());
assert.equal(value.presentation?.blocks[0].text,"库存2 台,其中待交车0台。");
});
test("verified single count bypasses generated report and external request", async (t) => {
configure(t);
t.mock.method(globalThis, "fetch", async () => { throw new Error("count must not call presentation model"); });
const r = result({plan:plan({category:"abnormal",groupBy:"none"}),outcome:"empty",rows:[],groups:[],metrics:[{label:"异动车辆",value:0,unit:"台"}]});
await generatePresentation(r,"当前有多少异动车辆?",new AbortController().signal);
assert.equal(r.conciseCount,true);
assert.equal(r.outcome,"complete");
assert.equal(r.presentation,undefined);
assert.equal(r.presentationWarning,undefined);
assert.match(r.summary,/异动车辆为 0 台/);
});
@@ -6,6 +6,7 @@ import type {
ResultPresentation,
} from "../../../shared/assets/assistant.js";
import { validatePlan } from "./model.js";
import { simplifyCountAnswer } from "./presentation.js";
type Dataset = "groups" | "rows" | "daily" | "comparison";
type Field = { field: string; label: string; type: "number" | "string" | "boolean" };
@@ -158,6 +159,8 @@ function fallback(target: AssistantResult, warning: string) {
}
/** Generate presentation metadata only; query data and authorization are unchanged. */
export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise<void> {
if (signal.aborted) throw signal.reason || new Error("已取消");
if (simplifyCountAnswer(result, question)) return;
if (process.env.ASSET_AI_EXTERNAL_ENABLED === "0") return;
if (signal.aborted) throw signal.reason || new Error("已取消");
const targets = targetsFor(result).filter(target => target.result.plan.responseMode !== "vehicle-status");
@@ -158,3 +158,11 @@ test("presentation receives authorized aggregates while vehicle rows stay in the
);
assert.equal(events.filter((e) => e.type === "step").length, 4);
});
test("inventory subsets do not inherit parent inventory metric cards", () => {
for (const category of ["abnormal", "pending", "available", "awaiting"] as const) {
const r = assetResult(vehicles, {kind:"assets", category, groupBy:"none", view:"chart", filters:{}});
assert.equal(r.metrics.length, 1, category);
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
}
});
+4 -4
View File
@@ -335,7 +335,7 @@ export function assetResult(
missingDueDate: "未填写预计完成时间",
}[plan.exception || "overdue"];
let summary = rows.length
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${abnormal || pending ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${["all", "inventory"].includes(plan.category) && (abnormal || pending) ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
: `${scope}的${CATEGORY_LABELS[plan.category]}为 0 台,暂无符合条件车辆。`;
let inventoryBreakdown: AssistantResult["inventoryBreakdown"];
if (plan.category === "all") summary = `${scope}共有资产 ${rows.length.toLocaleString()} 台,其中运营 ${operating.toLocaleString()} 台、库存 ${inventory.toLocaleString()} 台,运营率 ${operationRate}%。`;
@@ -405,7 +405,7 @@ export function assetResult(
unit: "%",
},
]
: [
: plan.category === "inventory" ? [
{
label: "其中可运营",
value: selected.filter((v) =>
@@ -420,9 +420,9 @@ export function assetResult(
).length,
unit: "台",
},
]),
] : []),
...(plan.analysis === "briefing" ? [{ label: "其中可运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["3", "可运营"].includes(v.operationStatus || "")).length, unit: "台" }, { label: "其中待运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["4", "待运营"].includes(v.operationStatus || "")).length, unit: "台" }] : []),
...(!["all", "operating"].includes(plan.category) || plan.analysis === "briefing"
...(plan.category === "inventory" || plan.analysis === "briefing"
? [
{ label: "其中异动", value: abnormal, unit: "台" },
{ label: "其中待交车", value: pending, unit: "台" },
@@ -23,3 +23,30 @@ test("opening inventory from a briefing leaves briefing mode and keeps inherited
const next=applyAction(briefing,{type:"category",category:"inventory"},result(briefing));
assert.equal(next.category,"inventory");assert.equal(next.analysis,"distribution");assert.deepEqual(next.filters,briefing.filters);
});
test("simple counts answer directly, including verified zero, while preserving scope", async () => {
const { simplifyCountAnswer } = await import("./presentation.js");
for (const count of [0, 7]) {
const r = result({...plan, category:"abnormal", groupBy:"none", view:"chart"});
r.outcome = count ? "complete" : "empty";
r.metrics = [{label:"异动车辆",value:count,unit:"台"},{label:"其中待交车",value:0,unit:"台"}];
r.scope = resultScope(r.plan,"测试公司");
assert.equal(simplifyCountAnswer(r,"当前有多少异动车辆?"),true);
assert.match(r.summary,new RegExp(`异动车辆为 ${count} 台`));
assert.match(r.summary,/测试公司.*4.5T.*嘉兴市/);
assert.equal(r.metrics.length,1);
assert.equal(r.outcome,"complete");
assert.deepEqual(r.suggestions,[]);
}
});
test("counts never collapse analysis requests, missing metrics or incomplete results", async () => {
const { simplifyCountAnswer } = await import("./presentation.js");
const make = () => ({...result({...plan,category:"abnormal",groupBy:"none",view:"chart"}), outcome:"complete" as const,metrics:[{label:"异动车辆",value:7,unit:"台"}]});
for (const question of ["各区域有多少异动车辆", "异动和待交车分别多少", "异动有多少,占比多少", "有多少异动车辆,查看明细", "分析异动车辆数量变化"]) {
assert.equal(simplifyCountAnswer(make(),question),false,question);
}
for (const override of [{outcome:"partial" as const},{warning:"源数据未接入"},{metrics:[]},{plan:{...make().plan,view:"list" as const}},{plan:{...make().plan,groupBy:"city" as const}}]) {
assert.equal(simplifyCountAnswer({...make(),...override},"当前有多少异动车辆?"),false);
}
});
@@ -1,5 +1,31 @@
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult, type QueryPlan } from "../../../shared/assets/assistant.js";
/** Only simplify verified, single-scope count queries; never infer zero from missing data. */
export function simplifyCountAnswer(result: AssistantResult, question: string): boolean {
const p = result.plan;
const metric = result.metrics.find(m => m.label === CATEGORY_LABELS[p.category] && m.unit === "台");
if (result.sections?.length || p.kind !== "assets" || p.responseMode ||
p.groupBy !== "none" || p.secondaryGroupBy || p.view !== "chart" ||
(p.analysis && p.analysis !== "distribution") || p.comparePrevious || p.topN ||
result.warning || result.unanswered?.length || result.gap ||
!["complete", "empty"].includes(result.outcome || "") ||
!metric || !Number.isFinite(metric.value) || metric.value < 0 ||
!/(多少|几台|几辆|数量|总数)/.test(question) ||
/(分别|各|分布|占比|比例|运营率|对比|比较|趋势|变化|原因|为什么|分析|明细|清单|列表|表格|图表|排行|最多|最少|以及|和|与|、)/.test(question)) return false;
const scope = result.scope || resultScope(p, null);
const filters = scope.filters.slice(1);
result.conciseCount = true;
result.summary = `${scope.company}${filters.length ? `,${filters.join(",")}` : ""},当前${CATEGORY_LABELS[p.category]}为 ${metric.value.toLocaleString()} 台。`;
result.metrics = [metric];
result.suggestions = [];
// A successful zero count is an answer, not a query failure or missing dataset.
result.outcome = "complete";
delete result.presentation;
delete result.presentationStatus;
delete result.presentationWarning;
return true;
}
export function resultScope(plan: QueryPlan, subject: string | null): NonNullable<AssistantResult["scope"]> {
const filters = [
plan.kind === "flow" ? "交退换车业务" : CATEGORY_LABELS[plan.category],