feat(asset-ai): add contextual analysis and unmet-demand feedback

This commit is contained in:
shishengliang
2026-09-23 16:01:10 +08:00
parent 0a32392215
commit 7e9760f5db
23 changed files with 5160 additions and 835 deletions
@@ -0,0 +1,215 @@
import type {
QueryPlan,
AssistantAction,
Dimension,
AssistantResult,
} from "../../../shared/assets/assistant.js";
import { DIMENSIONS } from "../../../shared/assets/assistant.js";
import type { Vehicle } from "../../types.js";
import { vehicleRepository } from "../vehicles/repository.js";
import { listDateRange } from "../vehicles/utils.js";
import { assetResult, filterVehicles, toRow, validatePlan } from "./model.js";
export async function execute(
vehicles: Vehicle[],
plan: QueryPlan,
): Promise<AssistantResult> {
let result = assetResult(vehicles, plan);
if (plan.kind === "flow") {
const selected = filterVehicles(vehicles, plan);
const byId = new Map(selected.map((v) => [String(v.id), v]));
const events = (
await vehicleRepository.getFlowDetailRows(plan.start!, plan.end!)
).filter((e) => byId.has(String(e.truck_id)));
const daily = listDateRange(plan.start!, plan.end!).map((date) => ({
date,
delivered: 0,
returned: 0,
replaced: 0,
total: 0,
}));
const byDate = new Map(daily.map((d) => [d.date, d]));
for (const e of events) {
const d = byDate.get(e.stat_date);
if (!d) continue;
d[e.type]++;
if (e.type === "replaced") d.delivered++;
d.total++;
}
result = {
...result,
title: "交退换车趋势",
source: "运维签章时间 · 业务车次 · 替换交车已计入交车",
daily,
rows: events.map((e) => ({
...toRow(byId.get(String(e.truck_id))!),
id: String(e.id),
date: e.stat_date,
event: e.type_label,
})),
groups: daily.map((d) => ({
name: d.date,
count: d.total,
operating: 0,
rate: 0,
})),
metrics: [
{
label: "交车(含替换)",
value: daily.reduce((n, d) => n + d.delivered, 0),
unit: "车次",
},
{
label: "还车",
value: daily.reduce((n, d) => n + d.returned, 0),
unit: "车次",
},
{
label: "其中替换交车",
value: daily.reduce((n, d) => n + d.replaced, 0),
unit: "车次",
},
],
summary: `${plan.start} 至 ${plan.end} 共 ${events.length} 笔流转业务;交车已包含替换交车,不能将两者相加。`,
};
}
if (plan.kind === "flow") {
result.insights = [
"交车包含替换交车,不能将二者相加。流转范围按当前账号可见车辆筛选。",
];
result.exceptions = [];
}
if (plan.kind === "flow" && plan.comparePrevious) {
const span = Date.parse(plan.end!) - Date.parse(plan.start!) + 86400000;
const start = new Date(Date.parse(plan.start!) - span)
.toISOString()
.slice(0, 10);
const end = new Date(Date.parse(plan.start!) - 86400000)
.toISOString()
.slice(0, 10);
const previous = await execute(vehicles, {
...plan,
start,
end,
comparePrevious: false,
});
result.comparisonPeriod = { start, end };
result.comparison = result.metrics.map((m, i) => ({
label: m.label,
current: m.value,
previous: previous.metrics[i].value,
delta: m.value - previous.metrics[i].value,
rate: previous.metrics[i].value
? Math.round(
((m.value - previous.metrics[i].value) /
previous.metrics[i].value) *
1000,
) / 10
: null,
}));
result.insights!.push(
`对比上一等长周期 ${start} 至 ${end};基期为0时不计算增长率。`,
);
}
return result;
}
export function applyAction(
plan: QueryPlan,
action: AssistantAction,
result: AssistantResult,
): QueryPlan {
const next = structuredClone(plan);
switch (action.type) {
case "clear-filter":
if (!DIMENSIONS.includes(action.dimension))
throw new Error("筛选维度无效。");
delete next.filters[action.dimension];
if (next.excludeFilters) delete next.excludeFilters[action.dimension];
break;
case "reset-filters":
next.filters = {};
delete next.excludeFilters;
delete next.vehicleIds;
delete next.exception;
delete next.topN;
break;
case "sort":
next.sortBy = action.sortBy;
next.sortOrder = action.sortOrder;
next.topN = action.topN;
break;
case "restore":
break;
case "exception":
next.kind = "assets";
next.category = "abnormal";
next.exception = action.exception;
next.view = "list";
delete next.start;
delete next.end;
delete next.comparePrevious;
break;
case "view":
next.view = action.view;
break;
case "category":
next.kind = "assets";
next.category = action.category;
delete next.start;
delete next.end;
delete next.comparePrevious;
delete next.exception;
break;
case "analysis":
next.analysis = action.analysis;
break;
case "group":
if (next.kind === "flow")
throw new Error("交退换趋势暂按日统计,不能切换车辆分组。");
next.groupBy = action.groupBy;
next.secondaryGroupBy = action.secondaryGroupBy;
break;
case "compare-period":
if (next.kind !== "flow")
throw new Error("当前库存没有历史快照,不能进行跨期比较。");
next.comparePrevious = true;
break;
case "day":
if (
next.kind !== "flow" ||
!result.daily?.some((d) => d.date === action.date)
)
throw new Error("日期不在当前结果内。");
next.start = action.date;
next.end = action.date;
next.view = "list";
next.comparePrevious = false;
break;
case "drill": {
if (
next.kind === "flow" ||
!action.keys ||
typeof action.keys !== "object" ||
Array.isArray(action.keys)
)
throw new Error("下钻条件无效。");
const entries = Object.entries(action.keys);
const group = result.groups.find(
(g) =>
entries.length > 0 &&
Object.keys(g.keys || {}).length === entries.length &&
entries.every(([k, v]) => g.keys?.[k as Dimension] === v),
);
if (!group) throw new Error("分组已变化,请刷新后重试。");
for (const [k, v] of entries) next.filters[k as Dimension] = [v];
next.groupBy = DIMENSIONS.find((d) => !next.filters[d]) || "none";
delete next.secondaryGroupBy;
delete next.topN;
next.view = next.groupBy === "none" ? "list" : "chart";
break;
}
default:
throw new Error("分析操作不受支持。");
}
return validatePlan(next);
}