fix(asset-ai): refine mobile viewport and operating query scope
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 18:49:26 +08:00
parent 5be6240bbf
commit 6de77adb47
11 changed files with 145 additions and 23 deletions
@@ -770,3 +770,28 @@ test("references to ranked results receive the selected result's aggregate evide
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);
});
@@ -269,3 +269,15 @@ test("verified single count bypasses generated report and external request", asy
assert.equal(r.presentationWarning,undefined);
assert.match(r.summary,/异动车辆为 0 台/);
});
test("operating scope rejects redundant counts and constant rates from generated charts", async (t) => {
mock(t, { layout: "stack", results: [{ id: "main", blocks: [
{ id: "departments", kind: "chart", dataset: "groups", chart: "bar", series: [
{ field: "count", label: "运营车辆" }, { field: "operating", label: "重复数量" }, { field: "rate", label: "运营率" },
] },
], interactions: [] }] });
const target = result({ plan: plan({ category: "operating", groupBy: "department" }), metrics: [{ label: "运营车辆", value: 2, unit: "台" }] });
await generatePresentation(target, "各部门车辆运营情况", new AbortController().signal);
assert.deepEqual(target.presentation?.blocks[0].series, [{ field: "count", label: "运营车辆" }]);
});
@@ -17,9 +17,14 @@ const SCHEMA: Record<Dataset, Field[]> = {
daily: [field("date", "日期"), field("delivered", "交车", "number"), field("returned", "还车", "number"), field("replaced", "其中替换交车", "number"), field("total", "交还车合计", "number")],
comparison: [field("label", "指标"), field("current", "本期", "number"), field("previous", "上期", "number"), field("delta", "变化量", "number"), field("rate", "变化率", "number")],
};
function schemaFor(result: AssistantResult): Record<Dataset, Field[]> {
if (result.plan.category !== "operating") return SCHEMA;
return { ...SCHEMA, groups: SCHEMA.groups.filter(entry => ["name", "count", "share"].includes(entry.field)).map(entry => entry.field === "count" ? { ...entry, label: "运营车辆" } : entry) };
}
const SYSTEM = `你负责根据已计算的真实结果设计展示结构与分析路径。你不是再次查询数据,也不执行代码。根据用户本次问题,自主选择结果标题、信息块顺序、图形、表格列、需要的下钻或追问,不套固定模板。
返回JSON:{"layout":"tabs"|"stack","results":[{"id":"输入中对应结果id","title":"可选标题","blocks":[{"id":"唯一块id","kind":"summary"|"narrative"|"metrics"|"chart"|"table","title":"可选标题","text":"叙述模板","metrics":["真实指标label"],"dataset":"groups"|"rows"|"daily"|"comparison","chart":"bar"|"donut"|"line","series":[{"field":"真实数值字段","label":"展示名"}],"columns":[{"field":"真实字段","label":"展示名"}],"interactionId":"可选交互id"}],"interactions":[{"id":"交互id","label":"操作文字","kind":"group"|"point"|"followup","plan":QueryPlan}]}]}。
每个结果最多8块、6个交互。单纯问总数可只用metrics;结构分布可用donut或bar;时序可用line;管理者简报可先结论再证据,明细问题可以直接table并自主选列。顺序由问题决定,避免每题固定“指标→图→表”。layout选择便于比较的stack或可切换的tabs。
运营车辆范围只展示运营车辆数量及分布,不重复展示相同数量,不补库存或恒为100%的运营率。
用户指定的展示顺序、指标数量、列和点击目标优先:要求两个指标时只放指定两个,不额外补五个指标;未要求图表重复展示时优先用最少必要信息块。donut只用于可加总数量的构成,不用各组运营率组成一个圆。解释中引用指标已包含单位,不要重复追加同一单位。
只选择已有dataset与schema中的字段、metrics中的真实label。没有行明细值,仅提供rowCount和rows列schema,你仍可决定明细表的列,渲染时由内部系统读取原有rows,绝不能在模型回复里补造明细。无数据的图不要生成,零指标可直接用metrics。
summary没有text时使用内部已计算summary;narrative的数值一律写{{metric:完整真实label}},由后端替换为真实数值与单位。不要写裸数字、虚构金额、伪造趋势或确认原因;不能计算已有输入之外的新指标。未知label不能引用。可用不带数字的文字描述观察方向,建议与事实分开。图表series只能选择数字字段,不输出任意JavaScript、SQL、HTML或表达式。
@@ -125,7 +130,7 @@ function validatePresentation(raw: unknown, result: AssistantResult): { presenta
if (!Object.hasOwn(SCHEMA, String(item.dataset))) continue;
const dataset = item.dataset as Dataset;
block.dataset = dataset;
const allowed = new Map(SCHEMA[dataset].map((entry) => [entry.field, entry]));
const allowed = new Map(schemaFor(result)[dataset].map((entry) => [entry.field, entry]));
if (block.kind === "chart") {
if (!["bar", "donut", "line"].includes(String(item.chart)) || !data[dataset].length) continue;
block.chart = item.chart as PresentationBlock["chart"];
@@ -190,7 +195,7 @@ export async function generatePresentation(result: AssistantResult, question: st
summary: target.summary.slice(0, 1600), metrics: target.metrics.slice(0, 30),
groups: target.groups.slice(0, 100), daily: target.daily?.slice(0, 93), comparison: target.comparison?.slice(0, 20),
rowCount: target.rows.length, selectedVehicleCount: target.plan.vehicleIds?.length,
datasets: Object.fromEntries(Object.entries(SCHEMA).map(([name, fields]) => [name, { fields, count: datasets(target)[name as Dataset].length }])),
datasets: Object.fromEntries(Object.entries(schemaFor(target)).map(([name, fields]) => [name, { fields, count: datasets(target)[name as Dataset].length }])),
})),
}) },
],
@@ -50,6 +50,20 @@ test("permissions and company filters apply before aggregation and detail genera
["0"],
);
});
test("Shanghai counts include districts and preserve permission and company scope", () => {
const input = vehicles.map((v, index) => ({ ...v, province: "上海市", city: ["松江区", "嘉定区", "奉贤区", "上海市"][index] }));
const query = validatePlan({ ...plan, category: "all", groupBy: "city", filters: { city: ["上海市"] } });
const all = assetResult(input, query);
assert.equal(all.metrics[0].value, 4);
assert.equal(all.rows.length, 4);
assert.equal(all.groups.length, 1);
assert.equal(all.groups[0].count, 4);
const scoped = assetResult(visibleVehicles(input, user, "甲公司"), query);
assert.equal(scoped.metrics[0].value, 2);
assert.deepEqual(scoped.rows.map(v => v.id).sort(), ["0", "3"]);
assert.ok(scoped.rows.every(v => v.city === "上海市"));
});
test("inventory includes abnormal and pending; group totals reconcile with detail", () => {
const result = assetResult(vehicles, validatePlan(plan));
assert.equal(result.rows.length, 3);
+1 -1
View File
@@ -393,7 +393,7 @@ export function assetResult(
groups: sorted,
metrics: [
{ label: CATEGORY_LABELS[plan.category], value: rows.length, unit: "台" },
...(["all", "operating"].includes(plan.category)
...(plan.category === "all"
? [
{ label: "运营车辆", value: operating, unit: "台" },
...(plan.category === "all" ? [{label: "库存车辆", value: inventory, unit: "台"}] : []),
+5 -3
View File
@@ -315,8 +315,9 @@ function supportedFacts(message: string) {
const historical = /库存.*(?:上个?月|去年|前年|历史|月末|月底|增加)|(?:上个?月|月底|月末|去年|历史|上季度).*库存/.test(message);
return !historical && /(?:当前|现在|看看|查询|统计).{0,8}(?:库存|资产)|(?:库存|资产).{0,6}(?:多少|数量|分布)/.test(message);
}
const STATE_PATTERN = /待交车|待运营|可运营|异动车辆|异动|运营车辆|在运营|库存/g;
const STATE_PATTERN = /待交车|待运营|可运营|异动车辆|异动|车辆的运营情况|车辆运营情况|运营车辆|在运营|库存/g;
const STATE_CATEGORIES: Record<string, QueryPlan["category"]> = {
车辆运营情况: "operating", 车辆的运营情况: "operating",
待交车: "pending", 待运营: "awaiting", 可运营: "available", 异动车辆: "abnormal", 异动: "abnormal", 运营车辆: "operating", 在运营: "operating", 库存: "inventory",
};
function positiveStates(message: string) {
@@ -387,7 +388,8 @@ export function normalizeBusinessIntent(message: string, decision: ConversationP
const distinct = [...new Set(states.filter((s) => s.category !== "inventory").map((s) => s.category))];
const inventoryComposition = /(?:待交车|异动).{0,8}(?:占|比例|占比).{0,8}库存|库存.{0,18}(?:待交车|异动).{0,8}(?:占比|比例)/.test(message)
|| (scope.category === "inventory" && states.some(state => state.category === "inventory") && /指标|分别|各有/.test(message) && !/(?:只看|仅看|我问的是)[“”\s]*(?:待交车|异动)/.test(message));
const target = distinct.length === 1 && !inventoryComposition ? distinct[0] : undefined;
const operatingOverview = /运营率|使用率|运力利用率|全部资产|所有车辆/.test(message);
const target = distinct.length === 1 && !inventoryComposition && !(distinct[0] === "operating" && operatingOverview) ? distinct[0] : undefined;
if (inventoryComposition) {
result.plan = { ...scope, kind: "assets", category: "inventory", analysis: "distribution" };
delete result.plan.exception;
@@ -530,7 +532,7 @@ const SYSTEM = `你是车辆资产分析助手的会话规划器。理解用户
同一库存查询已返回库存数、待交车数、异动数以及“待交车占库存”“异动占库存”指标,所以“库存多少/其中待交车占比”合为一个inventory query;不要为同一个范围重复查询数量和占比,不创建ratio DSL。库存为0时占比不可计算。经营简报已有总量、运营、库存、运营率及库存集中度,用一个briefing即可;经营简报加最近7天流转对比才需要两项。历史库存、成本、损失不可用,放unanswered,不伪装成当前库存query。
resultContext.analyses提供上一回答各分项的id/title/plan/summary/metrics,可以据此理解“第二项/刚才的待交车/那张趋势”。若分项id同时出现在candidates中,可以作为baseResultId继续该分项;baseResultId只能使用当前candidates提供的服务端结果id,不能新造id。
中文字段映射必须严格遵守:区域/地区=region,城市=city,车型=type(例如4.5T、18T),车型细分/普货与冷链=vehicleClass(例如4.5T普货、4.5T冷链,与首页对应车型项核对),型号=model(例如厂商产品型号),部门=department,运营状态=status。不要把中文“车型”翻译成model。
中文分类映射:全部资产/所有车辆=all;总库存/全部库存/闲置车辆/闲着的车/在库车辆=inventory;明确“可运营”=available;明确“待运营”=awaiting;在运营/运营车辆=operating;待交车=pending;异动车辆=abnormal。“全部”只表示范围重置,不会把明确“库存”改成all。
中文分类映射:全部资产/所有车辆=all;总库存/全部库存/闲置车辆/闲着的车/在库车辆=inventory;明确“可运营”=available;明确“待运营”=awaiting;在运营/运营车辆/车辆运营情况=operating;“各部门车辆运营情况”表示category=operating、groupBy=department,只统计租赁和自营车辆,不附带全部资产、库存或运营率;明确询问运营率或全部资产概览时才使用all保留分母。待交车=pending;异动车辆=abnormal。“全部”只表示范围重置,不会把明确“库存”改成all。
完整 QueryPlan 字段:kind=assets|flow;category=all|inventory|available|awaiting|operating|pending|abnormal;groupBy=region|city|type|vehicleClass|model|department|status|none;view=chart|table|list;filters={维度:[精确值]};可选secondaryGroupBy(不同于groupBy的维度)、analysis=distribution|compare|exceptions|briefing、excludeFilters={维度:[精确值]}、topN(1-100)、sortBy=count|rate|abnormal|pending、sortOrder=asc|desc、exception=overdue|dueSoon|missingDueDate、vehicleIds=[上下文给出的车辆id]。
flow还必须提供start/end(YYYY-MM-DD),category=all、groupBy=none,不设secondaryGroupBy或exception,comparePrevious=true表示上一等长周期对比;日期跨度最多93天,替换计入交车但可单列。资产查询只支持当前实时台账,没有历史库存快照,不可拿交退换车数量替代历史库存。
关键状态:待交车=pending,待运营=awaiting,二者独立,后端都已支持!不能因为待交车为0就替换成待运营,不能声称不支持待交车。查询数值必须通过query,不可answer凭history作答。“不是待运营,我问待交车”应按pending重新查并撤回错误口径;忽略被否定的状态,但“待交车与待运营对比”同时保留这两项意图,不能只查其中一种。如单问待交车,即使上轮待运营也清除导致混淆的status筛选。
+11
View File
@@ -72,6 +72,17 @@ test('preserves display, inventory and macro region mappings', () => {
assert.equal(resolveCity(null, '[上海市]'), '上海市');
});
test('municipality districts resolve to the city used by filters and grouping', () => {
for (const [province, district] of [['上海', '松江区'], ['北京', '朝阳区'], ['天津', '滨海新区'], ['重庆', '渝北区']]) {
assert.equal(resolveCity(district, province), `${province}市`);
assert.equal(resolveCity(`[${district}]`, `[${province}市]`), `${province}市`);
assert.equal(resolveCity(province, null), `${province}市`);
}
assert.equal(resolveCity('杭州市', '浙江省'), '杭州市');
assert.equal(resolveCity('朝阳区', null), '朝阳区');
assert.equal(resolveCity(null, null), '其他');
});
test('keeps vehicle status precedence and ownership aliases', () => {
assert.equal(mapStatus('2', '4'), 'Pending');
assert.equal(mapStatus('1', '14'), 'Abnormal');
+5 -1
View File
@@ -98,8 +98,12 @@ export function mapOwnership(rentStatusLabel: string | null): string {
export function resolveCity(city: string | null, province: string | null): string {
const cityName = (city || '').replace(/[\[\]]/g, '').trim();
if (cityName) return cityName;
const provinceName = (province || '').replace(/[\[\]]/g, '').trim();
// Location feeds put municipality districts (e.g. 松江区) in city.
// Aggregate them under the municipality, consistently with city filters.
const municipality = [provinceName, cityName].find(name => /^(北京|上海|天津|重庆)市?$/.test(name));
if (municipality) return municipality.replace(/市?$/, '市');
if (cityName) return cityName;
return provinceName || '其他';
}