Merge mobile AI and operating query fixes into main
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -92,18 +92,47 @@ export default function AssetAssistant({
|
||||
const page = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const viewport = window.visualViewport;
|
||||
const mobile = window.matchMedia('(max-width: 767px)');
|
||||
let frame = 0;
|
||||
let unlock: (() => void) | undefined;
|
||||
const updateViewport = () => {
|
||||
frame = 0;
|
||||
if (!mobile.matches || (viewport && viewport.scale !== 1)) return;
|
||||
page.current?.style.setProperty('--aq-viewport-height', `${viewport?.height ?? window.innerHeight}px`);
|
||||
page.current?.style.setProperty('--aq-viewport-top', `${viewport?.offsetTop ?? 0}px`);
|
||||
};
|
||||
updateViewport();
|
||||
viewport?.addEventListener('resize', updateViewport);
|
||||
viewport?.addEventListener('scroll', updateViewport);
|
||||
window.addEventListener('resize', updateViewport);
|
||||
const scheduleViewport = () => {
|
||||
if (!frame) frame = requestAnimationFrame(updateViewport);
|
||||
};
|
||||
const syncMobile = () => {
|
||||
unlock?.();
|
||||
unlock = undefined;
|
||||
if (mobile.matches) {
|
||||
const scrollY = window.scrollY;
|
||||
const body = document.body;
|
||||
const previous = body.style.top;
|
||||
body.style.top = `${-scrollY}px`;
|
||||
body.classList.add('aq-mobile-open');
|
||||
unlock = () => {
|
||||
body.classList.remove('aq-mobile-open');
|
||||
body.style.top = previous;
|
||||
window.scrollTo({ top: scrollY, behavior: 'instant' });
|
||||
};
|
||||
}
|
||||
updateViewport();
|
||||
};
|
||||
syncMobile();
|
||||
mobile.addEventListener('change', syncMobile);
|
||||
viewport?.addEventListener('resize', scheduleViewport);
|
||||
viewport?.addEventListener('scroll', scheduleViewport);
|
||||
window.addEventListener('resize', scheduleViewport);
|
||||
return () => {
|
||||
viewport?.removeEventListener('resize', updateViewport);
|
||||
viewport?.removeEventListener('scroll', updateViewport);
|
||||
window.removeEventListener('resize', updateViewport);
|
||||
cancelAnimationFrame(frame);
|
||||
mobile.removeEventListener('change', syncMobile);
|
||||
viewport?.removeEventListener('resize', scheduleViewport);
|
||||
viewport?.removeEventListener('scroll', scheduleViewport);
|
||||
window.removeEventListener('resize', scheduleViewport);
|
||||
unlock?.();
|
||||
};
|
||||
}, []);
|
||||
const [theme, setTheme] = useState(appearance);
|
||||
@@ -214,7 +243,7 @@ export default function AssetAssistant({
|
||||
setActiveResult(undefined);
|
||||
sessionRef.current = undefined;
|
||||
setIndependent(true);
|
||||
composer.current?.focus();
|
||||
composer.current?.focus({ preventScroll: true });
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -360,7 +389,7 @@ export default function AssetAssistant({
|
||||
setTurns([]);
|
||||
setExpandedTurns(new Set());
|
||||
setDraft("");
|
||||
composer.current?.focus();
|
||||
composer.current?.focus({ preventScroll: true });
|
||||
};
|
||||
useEffect(() => {
|
||||
reset();
|
||||
@@ -390,7 +419,7 @@ export default function AssetAssistant({
|
||||
className="aq-nav-active"
|
||||
onClick={() => {
|
||||
scroller.current?.scrollTo({ top: 0 });
|
||||
composer.current?.focus();
|
||||
composer.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
|
||||
@@ -799,9 +799,7 @@ function ResultViewContent({
|
||||
"分组",
|
||||
"车辆数",
|
||||
"占比",
|
||||
"运营率",
|
||||
"异动",
|
||||
"待交车",
|
||||
...(result.plan.category === "operating" ? [] : ["运营率", "异动", "待交车"]),
|
||||
"下钻",
|
||||
].map((h) => (
|
||||
<th key={h}>{h}</th>
|
||||
@@ -814,9 +812,11 @@ function ResultViewContent({
|
||||
<td>{g.name}</td>
|
||||
<td>{g.count}</td>
|
||||
<td>{g.share}%</td>
|
||||
<td>{g.rate}%</td>
|
||||
<td>{g.abnormal}</td>
|
||||
<td>{g.pending}</td>
|
||||
{result.plan.category !== "operating" && <>
|
||||
<td>{g.rate}%</td>
|
||||
<td>{g.abnormal}</td>
|
||||
<td>{g.pending}</td>
|
||||
</>}
|
||||
<td>
|
||||
<button
|
||||
disabled={
|
||||
|
||||
@@ -2098,3 +2098,23 @@
|
||||
.aq-page .aq-shimmer { background: none; color: var(--aq-muted); }
|
||||
.aq-page .aq-trace-collapse { transition: none; }
|
||||
}
|
||||
|
||||
/* Keep the document stationary while the keyboard resizes the conversation. */
|
||||
@media (max-width: 767px) {
|
||||
body.aq-mobile-open { position: fixed; width: 100%; overflow: hidden; overscroll-behavior: none; }
|
||||
.aq-page .aq-workspace { min-height: 0; }
|
||||
.aq-page .aq-header { min-height: 48px; height: 48px; padding: 2px 10px; }
|
||||
.aq-page .aq-scope { padding: 6px 12px; }
|
||||
.aq-page .aq-conversation { min-height: 0; overscroll-behavior-y: contain; }
|
||||
.aq-page .aq-composer-area { padding: 6px 12px max(6px, env(safe-area-inset-bottom)); }
|
||||
.aq-page .aq-composer { position: relative; border-radius: 14px; }
|
||||
.aq-page .aq-composer textarea { min-height: 44px; padding: 10px 56px 8px 12px; line-height: 1.5; }
|
||||
.aq-page .aq-composer-bottom { padding: 0 10px 6px; min-height: 22px; }
|
||||
.aq-page .aq-composer-bottom > div:last-child { position: absolute; right: 4px; bottom: 4px; }
|
||||
.aq-page .aq-composer .aq-send { width: 44px; height: 44px; }
|
||||
.aq-page .aq-composer-foot { padding-top: 4px; }
|
||||
.aq-page .aq-context { flex-wrap: nowrap; overflow-x: auto; margin-bottom: 5px; padding-bottom: 2px; }
|
||||
.aq-page .aq-context > * { flex-shrink: 0; }
|
||||
.aq-page .aq-latest-answer { min-height: 32px; margin-bottom: 2px; }
|
||||
.aq-page:has(.aq-composer textarea:focus) .aq-composer-foot { display: none; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "台"}] : []),
|
||||
|
||||
@@ -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筛选。
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 || '其他';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user