diff --git a/docs/asset-ai-vehicle-answer-plan.md b/docs/asset-ai-vehicle-answer-plan.md new file mode 100644 index 0000000..020e108 --- /dev/null +++ b/docs/asset-ai-vehicle-answer-plan.md @@ -0,0 +1,60 @@ +# 单车状态问答优化与验收计划 + +## 问题与目标 + +用户询问“沪A57850F 这辆车现在是什么状态”,现有结果展示单车总量、运营率,并出现把数量描述为状态的回答。本轮让回答先满足单车事实查询,再按需要展示证据。截图仅作为问题证据,不作为当前车辆数据源。 + +预期示例(以实际查询值为准):沪A57850F 当前运营状态为「租赁」,不属于库存。随后补充城市、部门、车型及必要异动信息。不得把台账归属城市说成实时定位,也不得把未标记异动推断为车辆无故障。 + +## 实施范围 + +1. 区分单车事实问题与总量、占比、分布、趋势问题。单车状态查询优先返回状态,不因结果仅一行便把所有统计请求转成状态回答。 +2. 自然语言中的事实由授权查询结果提供,绑定可校验字段;缺失字段如实处理。保留权限校验和既有查询边界。 +3. 单车状态不展示无关总量、运营率和车队下钻建议。模型展示失败时仍有准确、简洁的默认答案。 +4. 连续追问保持目标车辆;明确切回全部资产时解除单车范围。保留原有统计能力。 + +## 分工 + +- 主代理:范围与计划、集成检查、验收结果汇总。 +- 开发子代理:查询/结果/展示实现、必要回归测试。 +- 验收子代理:独立场景审查、执行验证、问题复核。 + +## 验收矩阵 + +| 场景 | 通过条件 | +| --- | --- | +| 指定租赁车辆状态 | 首先给出真实租赁状态,不出现“1台状态”或默认运营率 | +| 库存、异动、待交车车辆 | 使用各自真实字段,不混淆待运营与待交车 | +| 字段缺失 | 不虚构状态、归属、异常原因 | +| 无匹配或无权限 | 如实说明当前可见范围未找到,不泄露不可见车辆 | +| 多车结果 | 不擅自选首车作为唯一答案 | +| 明确询问运营率/数量 | 保留统计结果,即使筛选后仅一辆车 | +| 单车连续追问 | 沿用正确目标,并再次基于真实查询作答 | +| 回到全部资产 | 清除单车限制,恢复统计 | +| 模型失败 | 默认答案仍基于真实字段,保留必要失败提示 | +| 既有分布与复合分析 | 无统计口径、权限和结果渲染回归 | + +## 验证证据要求 + +记录相关测试、TypeScript 检查和构建的实际执行结果。模拟模型测试、真实模型调用、浏览器验证分别标注,未执行项不得写成通过。本轮不包含生产部署。 + +## 实现决定 + +单车事实查询采用 `responseMode=vehicle-status`。服务端基于当前授权查询的真实字段直接生成状态结论及台账资料,跳过展示模型。无需扩大明细外发范围,也不依赖展示模型正确理解“状态”与“数量”的区别。 + +单车显式统计仍进入统计流程,但明确指定的车辆身份需要约束最终查询,避免模型遗漏车辆筛选后误答全量。否定、重置与复合问题须按各自范围处理,不能简单将出现的所有车牌都当成正向筛选。 + +开发前基线:`npm test`,285 项全部通过。 + +## 最终验收结果(2026-09-23) + +- 独立验收子代理执行 `npm test`:301/301 通过,较基线新增 16 项(独立路由验收 13 项、开发回归 3 项)。 +- `npm run lint`、`npm run build`、`git diff --check` 均退出成功;构建保留已有的大于 500KB 分块提示。 +- 独立路由验收确认租赁、异动、待交车、未知车辆、显式统计、继承、否定、重置及复合问题。测试使用合成车辆和模拟模型,通过实际 Hono 路由执行。 +- 验收中发现并修复:模型漏掉单车身份后误答全量;未知车辆查询扩大范围;状态和统计子项在旧去重逻辑中被合并。 +- 复合用例分别核对同车状态与 100% 运营率,以及单车状态与合成全公司 50% 运营率。数字仅为测试样本,不代表真实业务数据。 +- 未执行真实 DeepSeek、真实业务数据库或浏览器视觉验收;单车状态路径直接绑定后台事实,不需要展示模型调用。 + +已知边界:排除指定车辆后需要选择超过 100 台车辆时返回澄清;当前实现不新增批量排除协议。只回答当前台账事实,不推断实时位置与故障原因。本轮修改保留在工作区,未提交、推送或部署。 + +开发大脑结束复盘:执行 DB-C-003;本轮证据为项目内修复与本地测试,尚不足以形成跨任务可复用知识记录,无可沉淀候选,未触发知识仓写入。 diff --git a/src/integration/vehicle-answer.acceptance.test.ts b/src/integration/vehicle-answer.acceptance.test.ts new file mode 100644 index 0000000..bc2919c --- /dev/null +++ b/src/integration/vehicle-answer.acceptance.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import { Hono } from "hono"; +import router from "../server/routes/asset-assistant/index.js"; +import { vehicleRepository } from "../server/routes/vehicles/repository.js"; +import { gapStore } from "../server/routes/asset-assistant/gaps.js"; +import type { AuthUser } from "../server/auth/types.js"; +import type { Vehicle } from "../server/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); +}); diff --git a/src/modules/assets/assistant/assistant.css b/src/modules/assets/assistant/assistant.css index a05f1a9..7a92c45 100644 --- a/src/modules/assets/assistant/assistant.css +++ b/src/modules/assets/assistant/assistant.css @@ -419,6 +419,7 @@ } .aq-question { display: flex; + flex-direction: row-reverse; gap: 13px; align-items: flex-start; margin: 0 0 25px; @@ -435,9 +436,14 @@ flex-shrink: 0; } .aq-question p { + min-width: 0; + max-width: min(80%, 640px); + margin: 0; line-height: 1.8; font-size: 15px; - padding-top: 1px; + padding: 8px 12px; + border-radius: 14px 4px 14px 14px; + background: var(--aq-inset); font-weight: 500; white-space: pre-wrap; overflow-wrap: anywhere; diff --git a/src/modules/assets/assistant/result-presentation.test.ts b/src/modules/assets/assistant/result-presentation.test.ts index b772ed9..ae48151 100644 --- a/src/modules/assets/assistant/result-presentation.test.ts +++ b/src/modules/assets/assistant/result-presentation.test.ts @@ -217,3 +217,9 @@ test("analysis path collapses display-only steps and preserves the latest node o ); assert.equal(path.at(-2)?.label, "库存 · 华东"); }); + +test("vehicle status navigation identifies a vehicle answer instead of a total", () => { + const path = queryPath({ kind: "assets", category: "all", groupBy: "none", view: "list", filters: {}, vehicleIds: ["v1"], responseMode: "vehicle-status" }); + assert.ok(path.includes("车辆状态")); + assert.ok(!path.includes("总量")); +}); diff --git a/src/modules/assets/assistant/result-presentation.ts b/src/modules/assets/assistant/result-presentation.ts index 93cf7c9..229f6bb 100644 --- a/src/modules/assets/assistant/result-presentation.ts +++ b/src/modules/assets/assistant/result-presentation.ts @@ -39,7 +39,7 @@ export function queryPath(plan: QueryPlan): string[] { ] : []), plan.groupBy === "none" && !plan.secondaryGroupBy - ? "总量" + ? plan.responseMode === "vehicle-status" ? "车辆状态" : "总量" : `按${[GROUP_LABELS[plan.groupBy], plan.secondaryGroupBy && GROUP_LABELS[plan.secondaryGroupBy]].filter(Boolean).join(" × ")}统计`, ]; } diff --git a/src/server/routes/asset-assistant/execute.ts b/src/server/routes/asset-assistant/execute.ts index 2a85e46..d4153cd 100644 --- a/src/server/routes/asset-assistant/execute.ts +++ b/src/server/routes/asset-assistant/execute.ts @@ -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": diff --git a/src/server/routes/asset-assistant/generative.ts b/src/server/routes/asset-assistant/generative.ts index 7f858aa..ae9ba7f 100644 --- a/src/server/routes/asset-assistant/generative.ts +++ b/src/server/routes/asset-assistant/generative.ts @@ -160,7 +160,7 @@ function fallback(target: AssistantResult, warning: string) { export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise { 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); diff --git a/src/server/routes/asset-assistant/model.ts b/src/server/routes/asset-assistant/model.ts index 014c13d..63d3595 100644 --- a/src/server/routes/asset-assistant/model.ts +++ b/src/server/routes/asset-assistant/model.ts @@ -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(); rows.forEach((row, index) => { const dims = [plan.groupBy, plan.secondaryGroupBy].filter( diff --git a/src/server/routes/asset-assistant/planner.ts b/src/server/routes/asset-assistant/planner.ts index bdeb3d7..5208466 100644 --- a/src/server/routes/asset-assistant/planner.ts +++ b/src/server/routes/asset-assistant/planner.ts @@ -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 { 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 { + 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(/(? !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( diff --git a/src/server/routes/asset-assistant/presentation.ts b/src/server/routes/asset-assistant/presentation.ts index 9a3ba3f..3978c27 100644 --- a/src/server/routes/asset-assistant/presentation.ts +++ b/src/server/routes/asset-assistant/presentation.ts @@ -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") { diff --git a/src/server/routes/asset-assistant/vehicle-status.test.ts b/src/server/routes/asset-assistant/vehicle-status.test.ts new file mode 100644 index 0000000..86e62c0 --- /dev/null +++ b/src/server/routes/asset-assistant/vehicle-status.test.ts @@ -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); +}); diff --git a/src/server/routes/asset-assistant/vehicle-status.ts b/src/server/routes/asset-assistant/vehicle-status.ts new file mode 100644 index 0000000..96fa8d2 --- /dev/null +++ b/src/server/routes/asset-assistant/vehicle-status.ts @@ -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(/(? 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: [] }, + }; +} diff --git a/src/shared/assets/assistant.ts b/src/shared/assets/assistant.ts index b68f218..c8c182a 100644 --- a/src/shared/assets/assistant.ts +++ b/src/shared/assets/assistant.ts @@ -34,6 +34,7 @@ export type QueryPlan = { sortBy?: "count" | "rate" | "abnormal" | "pending"; sortOrder?: "asc" | "desc"; vehicleIds?: string[]; + responseMode?: "vehicle-status"; excludeFilters?: Partial>; clarification?: string; };