From 33c92d1352dd6ea1efb12d9ed1d5e9c8d00da656 Mon Sep 17 00:00:00 2001
From: minghanchen <17188325+minghanchen@user.noreply.gitee.com>
Date: Mon, 24 Aug 2026 14:15:53 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=90=8E=E7=AB=AF=E5=92=8C?=
=?UTF-8?q?=E5=89=8D=E7=AB=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/web-console/app.js | 195 +++++--
apps/web-console/console-v2.css | 18 +
apps/web-console/console-v2.js | 37 +-
apps/web-console/index.html | 6 +-
apps/web-console/service-worker.js | 8 +-
contracts/schemas/function-config.schema.json | 4 +
docs/CAN1控制指令白名单.md | 74 +++
domains/climate/config/capabilities.yaml | 2 +-
domains/seat/src/seat_domain/generator.py | 12 +
domains/seat/src/seat_domain/intent.py | 19 +
.../tests/integration/test_seat_service.py | 2 +-
domains/seat/tests/unit/test_intent.py | 15 +
.../application/conversation_state.py | 11 +-
.../application/domain_runtime.py | 3 +
.../application/native_can_intent.py | 414 ++++++++++----
.../application/native_can_relative.py | 1 +
.../application/native_can_runtime.py | 74 ++-
.../application/seat_service.py | 535 +++++++++++++++++-
.../infrastructure/can/mqtt_transport.py | 19 +-
src/cognitive_agent/server.py | 241 +++++++-
tests/e2e/test_seat_web_api.py | 144 ++++-
tests/unit/test_can1_semantic_whitelist.py | 351 ++++++++++++
tests/unit/test_ch1_default_targets.py | 38 ++
tests/unit/test_driver_seat_native_can.py | 10 +-
tests/unit/test_mqtt_can_transport.py | 133 ++++-
.../test_requested_frontend_enhancements.py | 4 +
tests/unit/test_two_stage_can_parsing.py | 192 +++++++
27 files changed, 2328 insertions(+), 234 deletions(-)
create mode 100644 docs/CAN1控制指令白名单.md
create mode 100644 tests/unit/test_can1_semantic_whitelist.py
create mode 100644 tests/unit/test_two_stage_can_parsing.py
diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 72e5c71..4a4f88c 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -7023,14 +7023,14 @@ function renderExecution(result) {
const operationResults = report.spec.results.filter((item) => item.ref !== "seat-controller-firmware");
const succeededOperations = operationResults.filter((item) => item.status === "succeeded");
const failedOperations = operationResults.filter((item) => item.status === "failed");
- const mqttPublishedOperations = operationResults.filter(
- (item) => item.telemetry?.delivery_status === "published",
+ const mqttSubmittedOperations = operationResults.filter(
+ (item) => ["queued", "published"].includes(item.telemetry?.delivery_status),
);
const physicallyConfirmedOperations = operationResults.filter(
(item) => item.telemetry?.physical_effect_confirmed === true,
);
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
- && mqttPublishedOperations.length > physicallyConfirmedOperations.length;
+ && mqttSubmittedOperations.length > physicallyConfirmedOperations.length;
const succeededDomains = [...new Set(succeededOperations.map((item) => item.domain))];
const failedDomains = [...new Set(failedOperations.map((item) => item.domain))];
let executionDetail = modeSave
@@ -7041,7 +7041,7 @@ function renderExecution(result) {
? `${executedDomains.join(" / ")} 已完成隔离 HIL 仿真,未访问物理 CAN`
: physical
? result.control_transport === "wireless_mqtt"
- ? `${executedDomains.join(" / ")} 已通过 MQTT 发布 ${mqttPublishedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}`
+ ? `${executedDomains.join(" / ")} 已向 MQTT 提交 ${mqttSubmittedOperations.length || succeededOperations.length} 个白名单控制步骤${mqttPublishedUnconfirmed ? ";车辆效果尚未确认,以状态话题回读为准" : `,并确认 ${physicallyConfirmedOperations.length || succeededOperations.length} 个车辆状态`}`
: selectedVehicleTransport()?.target_policy === "ch1_body_can_driver_only"
? `${executedDomains.join(" / ")} 已通过 CH1 下发,并以车端状态字段确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
: `${executedDomains.join(" / ")} 已下发真实 CAN,并以 IAR 固件实际反馈确认 ${operationResults.filter((item) => item.status === "succeeded").length} 个步骤`
@@ -7081,7 +7081,7 @@ function renderExecution(result) {
: presetSave ? "姿态预设已保存"
: simulatedHil ? "模拟台架执行成功"
: physical
- ? mqttPublishedUnconfirmed ? "MQTT 指令已发布" : "真实台架执行成功"
+ ? mqttPublishedUnconfirmed ? "MQTT 指令已提交" : "真实台架执行成功"
: "虚拟执行成功"
: partiallySucceeded ? (physical ? "真实台架部分执行成功" : "部分执行成功")
: simulatedHil ? "模拟故障已触发" : physical ? "真实台架执行失败" : "虚拟执行失败";
@@ -7091,7 +7091,7 @@ function renderExecution(result) {
summary.innerHTML = `
${executionTitle}${executionDetail}
`;
$("#resultNavMeta").textContent = reportSucceeded
? mqttPublishedUnconfirmed
- ? `${mqttPublishedOperations.length} 项已发布 · MQTT`
+ ? `${mqttSubmittedOperations.length} 项已提交 · MQTT`
: `${report.spec.results.length} 项完成 · ${simulatedHil ? "SIM-HIL" : physical ? "HIL" : "DRY-RUN"}`
: partiallySucceeded ? `${succeededOperations.length} 项成功 / ${failedOperations.length} 项失败 · 查看明细`
: "执行失败 · 查看回读";
@@ -7251,7 +7251,7 @@ function renderResult(result) {
const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback;
const mqttPublishedUnconfirmed = result.control_transport === "wireless_mqtt"
&& (result.execution_report?.spec?.results || []).some(
- (item) => item.telemetry?.delivery_status === "published"
+ (item) => ["queued", "published"].includes(item.telemetry?.delivery_status)
&& item.telemetry?.physical_effect_confirmed !== true,
);
if (feedbackSource === "simulated_hardware") {
@@ -7291,7 +7291,7 @@ function renderResult(result) {
: result.scene_config
? `${domains.length} 域场景闭环完成`
: mqttPublishedUnconfirmed
- ? `${domainLabels[domains[0]] || "车舱"}控制指令已发布`
+ ? `${domainLabels[domains[0]] || "车舱"}控制指令已提交`
: `${domainLabels[domains[0]] || "车舱"}单域闭环完成`
: "请求未执行";
const conversationLabel = result.conversation_id?.slice(-8) || "no-context";
@@ -7324,6 +7324,67 @@ function renderResult(result) {
window.dispatchEvent(new CustomEvent("cognitive-agent:result", { detail: result }));
}
+async function postControlPhase(path, body, executionMode, onEvent = null) {
+ const streaming = typeof onEvent === "function";
+ const response = await apiRequest(path, {
+ method: "POST",
+ timeoutMs: requestTimeoutForExecutionMode(executionMode),
+ headers: {
+ "Content-Type": "application/json",
+ ...(streaming ? { Accept: "application/x-ndjson" } : {}),
+ },
+ body: JSON.stringify(body),
+ });
+ if (response.status === 401) {
+ window.location.replace("/login");
+ return null;
+ }
+ if (streaming && response.ok && response.body?.getReader) {
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffered = "";
+ let resultPayload = null;
+ const handleLine = (line) => {
+ if (!line.trim()) return;
+ const phaseEvent = JSON.parse(line);
+ if (phaseEvent.type === "error") {
+ const requestError = new Error(
+ phaseEvent.message || phaseEvent.error || "流式控制请求失败",
+ );
+ requestError.code = phaseEvent.error;
+ requestError.retryAfter = Number(phaseEvent.retry_after_seconds || 0);
+ throw requestError;
+ }
+ if (phaseEvent.type === "result") {
+ resultPayload = phaseEvent.payload;
+ } else {
+ onEvent(phaseEvent);
+ }
+ };
+ while (true) {
+ const { value, done } = await reader.read();
+ buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
+ const lines = buffered.split("\n");
+ buffered = lines.pop() || "";
+ lines.forEach(handleLine);
+ if (done) break;
+ }
+ if (buffered.trim()) handleLine(buffered);
+ if (!resultPayload) throw new Error("流式控制请求未返回完整结果");
+ return resultPayload;
+ }
+ const payload = await response.json();
+ if (!response.ok) {
+ const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
+ requestError.code = payload.error;
+ requestError.retryAfter = Number(
+ payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
+ );
+ throw requestError;
+ }
+ return payload;
+}
+
async function submitRequest(event) {
event.preventDefault();
const utterance = $("#utterance").value.trim();
@@ -7360,39 +7421,89 @@ async function submitRequest(event) {
setRunStatus("processing", "PROCESSING");
$("#resultTitle").textContent = "正在执行车舱编排";
try {
- const response = await apiRequest("/api/requests", {
- method: "POST",
- timeoutMs: requestTimeoutForExecutionMode(state.executionMode),
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- utterance,
- client_type: "web_console",
- provider: state.provider,
- conversation_id: state.conversationId,
- vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
- execution_mode: state.executionMode,
- control_transport: state.controlTransport,
- ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
- ...(state.executionMode === "hardware" && !driverOnlyCh1
- ? { hardware_client_id: state.hardwareClientId }
- : {}),
- ...(state.executionMode === "simulated_hardware"
- ? { simulation_fault: $("#simulationFault").value }
- : {}),
- }),
- });
- const payload = await response.json();
- if (response.status === 401) {
- window.location.replace("/login");
- return;
- }
- if (!response.ok) {
- const requestError = new Error(payload.message || payload.error || `HTTP ${response.status}`);
- requestError.code = payload.error;
- requestError.retryAfter = Number(
- payload.retry_after_seconds || response.headers.get("Retry-After") || 0,
+ const requestBody = {
+ utterance,
+ client_type: "web_console",
+ provider: state.provider,
+ conversation_id: state.conversationId,
+ vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
+ execution_mode: state.executionMode,
+ control_transport: state.controlTransport,
+ ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}),
+ ...(state.executionMode === "hardware" && !driverOnlyCh1
+ ? { hardware_client_id: state.hardwareClientId }
+ : {}),
+ ...(state.executionMode === "simulated_hardware"
+ ? { simulation_fault: $("#simulationFault").value }
+ : {}),
+ };
+ let payload;
+ let supplementWarning = "";
+ const onControlPhaseEvent = (phaseEvent) => {
+ if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return;
+ window.dispatchEvent(new CustomEvent(
+ "cognitive-agent:validated-can-commands",
+ { detail: phaseEvent.payload },
+ ));
+ };
+ if (state.provider === "openai") {
+ const preview = await postControlPhase(
+ "/api/requests/preview",
+ requestBody,
+ state.executionMode,
+ onControlPhaseEvent,
);
- throw requestError;
+ if (!preview) return;
+ renderResult(preview);
+ completedResult = preview;
+ if (preview.supplement_required) {
+ setRunStatus("processing", "CLOUD SUPPLEMENT");
+ $("#resultTitle").textContent = "本地指令已处理,正在补充云端解析";
+ try {
+ payload = await postControlPhase(
+ `/api/requests/${preview.request_id}/supplement`,
+ {
+ client_type: "web_console",
+ provider: state.provider,
+ reasoning_effort: state.reasoningEffort,
+ ...(state.executionMode === "hardware" && !driverOnlyCh1
+ ? { hardware_client_id: state.hardwareClientId }
+ : {}),
+ },
+ state.executionMode,
+ onControlPhaseEvent,
+ );
+ if (!payload) return;
+ } catch (error) {
+ const localCommandCount = Object.values(preview.function_configs || {})
+ .reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0);
+ if (!localCommandCount) throw error;
+ payload = {
+ ...preview,
+ status: "partially_succeeded",
+ supplement_required: false,
+ cloud_supplement_error: {
+ code: error.code || "cloud_supplement_failed",
+ message: error.message,
+ },
+ decision_summary: [
+ ...(preview.decision_summary || []),
+ `云端补充失败:${error.message};本地规则命令已保留且不会撤销。`,
+ ],
+ };
+ supplementWarning = "本地规则命令已执行;云端补充失败,已保留本地结果";
+ }
+ } else {
+ payload = preview;
+ }
+ } else {
+ payload = await postControlPhase(
+ "/api/requests",
+ requestBody,
+ state.executionMode,
+ onControlPhaseEvent,
+ );
+ if (!payload) return;
}
renderResult(payload);
completedResult = payload;
@@ -7405,7 +7516,7 @@ async function submitRequest(event) {
setModeExecutionState(submittedModeProfileId, completed ? "succeeded" : "failed");
}
const simulated = payload.execution_report?.metadata?.labels?.seat_feedback === "simulated_hardware";
- showToast(completed
+ showToast(supplementWarning || (completed
? (payload.mode_profile
? `${payload.mode_profile.name}已创建并添加到功能模式`
: payload.status === "partially_succeeded"
@@ -7415,7 +7526,7 @@ async function submitRequest(event) {
: payload.execution_report?.spec?.mode === "hil"
? "真实台架执行与反馈确认已完成"
: "可执行动作的 dry-run 已完成")
- : simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行");
+ : simulated ? "模拟故障已触发,详情见执行结果" : "请求未执行"));
} catch (error) {
completedError = error;
if (submittedModeProfileId) setModeExecutionState(submittedModeProfileId, "failed");
diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css
index f9727d2..7127528 100644
--- a/apps/web-console/console-v2.css
+++ b/apps/web-console/console-v2.css
@@ -7201,6 +7201,24 @@ body.device-power-drawer-open {
text-align: center;
}
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source {
+ flex: 0 0 auto;
+ padding: 1px 5px;
+ color: #2366a1;
+ background: #e4f0fb;
+ border: 1px solid rgba(35, 102, 161, 0.14);
+ border-radius: 999px;
+ font-size: 7px;
+ font-weight: 850;
+ line-height: 1.5;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-source[data-parse-source="local_rule"] {
+ color: #08766f;
+ background: #d9f3ef;
+ border-color: rgba(8, 118, 111, 0.14);
+}
+
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel="CAN0"] {
background: #f7f4ec;
}
diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js
index 83af78d..4f43d1d 100644
--- a/apps/web-console/console-v2.js
+++ b/apps/web-console/console-v2.js
@@ -21,6 +21,7 @@
};
let reasoningPickerControl = null;
+ let validatedCommandsVisible = false;
function make(tag, className, html) {
const node = document.createElement(tag);
@@ -61,8 +62,13 @@
const target = normalizedCanTarget(operation.target);
const onOff = normalizedOnOff(parameters.state ?? parameters.power);
const hyphenated = (value) => String(value || "").replaceAll("_", "-");
+ const parseSource = String(operation.parse_source || "");
const command = (category, text, channel = "CAN1", frame = "") => ({
- channel, category, command: text, ...(frame ? { frame } : {}),
+ channel,
+ category,
+ command: text,
+ ...(frame ? { frame } : {}),
+ ...(parseSource ? { parseSource } : {}),
});
const hexByte = (value) => Number(value).toString(16).padStart(2, "0").toUpperCase();
const otaCan0Frame = (opcode, selector, argument) => {
@@ -271,7 +277,15 @@
if (item) commands.push(item);
});
});
- return [...new Map(commands.map((item) => [`${item.channel}:${item.command}`, item])).values()];
+ const unique = new Map();
+ commands.forEach((item) => {
+ const key = `${item.channel}:${item.command}`;
+ const previous = unique.get(key);
+ if (!previous || (previous.parseSource !== "local_rule" && item.parseSource === "local_rule")) {
+ unique.set(key, item);
+ }
+ });
+ return [...unique.values()];
}
function renderParsedCanCommands(result, { message = "" } = {}) {
@@ -285,7 +299,11 @@
if (commands.length) {
list.innerHTML = `${commands.map((item) => `
-
- ${escapeHtml(item.channel)}${escapeHtml(item.category)}
+
+ ${escapeHtml(item.channel)}
+ ${item.parseSource ? `${item.parseSource === "local_rule" ? "本地规则" : "云端模型"}` : ""}
+ ${escapeHtml(item.category)}
+
${escapeHtml(item.command)}
${item.frame ? `seat-ota · ${escapeHtml(item.frame)}` : ""}
`).join("")}
`;
@@ -1348,10 +1366,19 @@
});
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
$("#requestForm")?.addEventListener("submit", () => {
+ validatedCommandsVisible = false;
renderParsedCanCommands(null, { message: "正在解析自然语言并生成 CAN 控制指令…" });
});
+ window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
+ const result = event.detail || {};
+ validatedCommandsVisible = parsedCanCommands(result).length > 0;
+ renderParsedCanCommands(result, {
+ message: "已完成白名单校验,正在下发控制指令…",
+ });
+ });
window.addEventListener("cognitive-agent:result", (event) => {
const result = event.detail || {};
+ validatedCommandsVisible = parsedCanCommands(result).length > 0;
renderParsedCanCommands(result);
appendSyncLog({
key: result.request_id ? `result:${result.request_id}` : "",
@@ -1375,8 +1402,10 @@
});
renderSyncedDevices();
}
- if (event.detail?.error) {
+ if (event.detail?.error && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成 CAN 控制指令" });
+ }
+ if (event.detail?.error) {
appendSyncLog({ category: "task", title: "编排任务执行失败", detail: event.detail.error.message || "请求失败", status: "error" });
}
});
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index c2d0429..ed235ec 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -10,17 +10,17 @@
511工程-智能座舱
-
+
-
+
-
+
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index e22b3ff..3bee611 100644
--- a/apps/web-console/service-worker.js
+++ b/apps/web-console/service-worker.js
@@ -1,4 +1,4 @@
-const CACHE_VERSION = "cognitive-agent-pad-v202";
+const CACHE_VERSION = "cognitive-agent-pad-v204";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,11 +6,11 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-16",
"/network.js?v=android-pad-2",
- "/app.js?v=dual-seat-101",
+ "/app.js?v=dual-seat-103",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
- "/console-v2.css?v=sync-layout-40",
- "/console-v2.js?v=sync-layout-41",
+ "/console-v2.css?v=sync-layout-41",
+ "/console-v2.js?v=sync-layout-43",
"/self-extension.css?v=sync-layout-26",
"/self-extension.js?v=sync-layout-26",
"/assets/pets/self-extension-cat.png?v=3",
diff --git a/contracts/schemas/function-config.schema.json b/contracts/schemas/function-config.schema.json
index 64d0f99..b56b5dd 100755
--- a/contracts/schemas/function-config.schema.json
+++ b/contracts/schemas/function-config.schema.json
@@ -12,6 +12,10 @@
"capability": {"type": "string", "pattern": "^[a-z][a-z0-9_]*\\.[a-z][a-z0-9_]*$"},
"target": {"$ref": "common.schema.json#/$defs/target"},
"parameters": {"type": "object"},
+ "parse_source": {
+ "type": "string",
+ "enum": ["local_rule", "cloud_model", "cloud_supplement"]
+ },
"motion": {
"type": "object",
"additionalProperties": false,
diff --git a/docs/CAN1控制指令白名单.md b/docs/CAN1控制指令白名单.md
new file mode 100644
index 0000000..4b2e982
--- /dev/null
+++ b/docs/CAN1控制指令白名单.md
@@ -0,0 +1,74 @@
+# CAN1 控制指令白名单
+
+- 适用项目:`Cognitive_Agent`
+- 适用分支:`feature/N7Web`
+- 核对日期:2026-08-24
+- 验证层级:自然语言解析、精确指令解析和 CAN1 白名单编译通过;未在本次核对中发送真实 CAN 帧。
+
+“发送逻辑”列以当前执行器代码为准。有线模式按该顺序直接提交 CAN1 帧;无线 MQTT 模式将每个原始 CAN 帧分别封装为 1 条 `n7/n7-headunit/can/command` 消息(默认 QoS 1)。因此表中“3 帧,间隔 100 ms”在无线模式下表示后端连续发布 3 条 MQTT 消息,每条消息内部仍为 `repeat: 1`、`intervalMs: 0`。MQTT 客户端或 Broker 接受消息只代表已提交,车辆是否完成动作仍以状态话题的反馈帧为准。
+
+## 有效白名单
+
+| 类别 | 有效指令 | 参数约束 | 发送逻辑 |
+| --- | --- | --- | --- |
+| 后排门锁 | `door rear ` | 仅后排门锁 | `0x32B` 经典 CAN,发送 1 帧;反馈 ID `0x407` |
+| 车窗动作 | `window ` | 目标和动作必须使用列出的枚举值 | `0x431` CAN FD+BRS,连续 3 帧,相邻帧间隔 100 ms;反馈 ID `0x388` |
+| 单窗位置 | `window position <0-100>` | 仅支持单个车窗,位置为整数百分比 | 先等待最长 2500 ms 的有效 `0x388`;已在目标 ±3% 内则发送 0 帧。否则用 `0x431` CAN FD+BRS 发送 3 帧启动命令(间隔 100 ms),最长闭环监听 `0x388` 12 s;无论到位或超时,最后强制发送 3 帧停止命令(间隔 100 ms) |
+| 空调电源 | `ac ` | 整车 HVAC 电源 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
+| 空调风量 | `ac fan <1-7>` | 只接受 1–7 的整数 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
+| 出风模式 | `ac mode ` | 仅接受列出的 7 种模式 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
+| 空调温度 | `ac temp <18-32>` | 温度只接受 18–32 的整数 | `0x1DE` 经典 CAN;`driver`/`passenger` 各发 3 帧(间隔 100 ms),`rear` 发 1 帧;反馈 ID `0x404` |
+| 空调控制模式 | `ac control ` | 手动或自动 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
+| 空调循环 | `ac cycle ` | 内循环或外循环 | `0x1DE` 经典 CAN,连续 3 帧,间隔 100 ms;反馈 ID `0x404` |
+| 风口百分比 | `outlet percent <0-100> <0-100>` | 依次为左右、上下整数百分比 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
+| 风口标定坐标 | `outlet <区域> position <左右坐标> <上下坐标>` | 必须同时满足通用有符号范围和对应风口标定范围 | `0x4EF` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x2F5`/`0x37B` |
+| 主驾座椅通风/加热 | `seat driver ` | `front-left` 可作为主驾输入兼容别名,规范输出仍为 `driver` | `0x431` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
+| 主驾座椅按摩关闭 | `seat driver massage off` | 仅关闭动作 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
+| 主驾座椅按摩强度 | `seat driver massage strength ` | `1\|2\|3` 也可作为输入兼容别名,规范输出为单词枚举 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
+| 主驾座椅按摩模式 | `seat driver massage mode ` | 仅接受列出的 5 种模式 | `0x4C1` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x4FA` |
+| 主驾座椅水平调节 | `seat driver horizontal [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
+| 主驾座椅高度调节 | `seat driver height [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
+| 主驾座椅靠背调节 | `seat driver backrest [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
+| 主驾座椅坐盆调节 | `seat driver basin [duration-ms]` | `duration-ms` 为 100–2000;省略时默认 500 ms | `0x4C1` CAN FD+BRS;动作帧数为 `ceil(duration-ms/100)`,间隔 100 ms,最后一帧后额外保持 100 ms;随后强制发送 3 帧 STOP(间隔 100 ms);反馈 ID `0x494` |
+| 主驾座椅停止 | `seat driver stop` | `stop` 后不允许附加持续时间 | `0x4C1` CAN FD+BRS,直接连续发送 3 帧 STOP,间隔 100 ms;反馈 ID `0x494` |
+| 主驾座椅记忆 | `seat driver position <1-3>` | 需要先由操作员完成 PAD 只读学习 | `0x4C1` CAN FD+BRS;下发前最长等待 2500 ms 的新鲜 `0x494`,且四轴初始化均为真;然后将已学习的 `0x16 → 0x15` 交替序列发送 10 轮,共 20 帧,间隔 50 ms;发送后最长等待 1200 ms 的 `0x494` |
+| 主驾座椅记忆兼容别名 | `seat driver memory recall <1-3>` | 与 `seat driver position <1-3>` 使用同一已学习帧序列 | 发送逻辑与 `seat driver position <1-3>` 完全相同:`0x4C1` CAN FD+BRS,四轴初始化门禁通过后发送 20 帧 `0x16/0x15` 交替序列,间隔 50 ms |
+| 室内灯总开关 | `light indoor ` | 整车室内灯总开关 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
+| 全部阅读灯 | `light reading all ` | CAN1 仅验证 `all` 目标 | `0x4F5` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
+| 氛围灯开关 | `light ambient ` | 氛围灯总开关 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
+| 氛围灯颜色 | `light ambient color <1-127>` | 车型颜色索引,只接受整数 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
+| 氛围灯亮度 | `light ambient brightness <1-5>` | 只接受 1–5 的整数档位 | `0x3FC` CAN FD+BRS,连续 3 帧,间隔 100 ms;反馈 ID `0x42E` |
+| 遮阳帘动作 | `shade ` | 0/50/100 位置与已验证预设动作复用 | `0x431` 经典 CAN;`open/close/half` 先发 1 帧预设命令,最长监听 `0x41A` 15 s,在匹配目标位置但仍在运动时追加 1 帧 STOP 并确认停止;`stop` 发 1 帧 STOP 并等待停止反馈 |
+| 遮阳帘位置 | `shade position <0-100>` | 中间位置使用状态反馈闭环并自动停止 | 0/50/100 按上述预设逻辑发送。其他位置先等待最长 2500 ms 的新鲜 `0x41A`;已在目标 ±2% 内且停止则发 0 帧,已到位但仍运动则发 1 帧 STOP。否则用 `0x431` 经典 CAN 发 1 帧开/关启动命令,最长监听 15 s,并在 `finally` 中强制发 1 帧 STOP,再确认位置与停止状态 |
+
+## 风口标定坐标范围
+
+| 风口 | 左右范围 | 上下范围 |
+| --- | ---: | ---: |
+| `driver-left` | -25–40 | -40–30 |
+| `driver-right` | -40–30 | -40–30 |
+| `passenger-left` | -10–40 | -40–30 |
+| `passenger-right` | -40–5 | -40–30 |
+
+风口原始坐标语法层的通用范围为 -128–126,但只有同时落在上表对应风口实际标定范围内才能通过编译。
+
+## 能力层扩展与非白名单入口
+
+| 指令/能力 | 当前状态 | 发送逻辑 |
+| --- | --- | --- |
+| `ac compressor ` | `climate.set_ac` 在后端能力层可编译,但当前没有对应的直接精确文本解析入口 | 若由受信能力层调用:`0x1DE` 经典 CAN,3 帧,间隔 100 ms,反馈 ID `0x404`;但本项不是直接指令白名单入口 |
+| `door vehicle ` | 可解析,但当前 CH1 实车验证未观察到有效整车门锁动作,因此会被安全拒绝,不属于可下发白名单 | 安全门禁拒绝,发送 0 帧 |
+| `ac defrost ` | 存在旧解析入口,但当前 CAN1 运行时未实现;前除霜应使用 `ac mode defrost` | 编译门禁拒绝,发送 0 帧 |
+
+## CAN0 边界
+
+本文档只描述 CAN1 车身控制白名单。CAN0 只用于副驾座椅,其状态、轴位置、热舒适和动态模式控制必须依据 `seat-ota` 协议独立处理,不得使用本 CAN1 白名单生成或猜测 CAN0 帧。
+
+## 实现与回归测试
+
+- 精确命令和自然语言匹配:`src/cognitive_agent/application/native_can_intent.py`
+- 能力、参数及 CAN 帧编译门禁:`src/cognitive_agent/application/native_can_runtime.py`
+- 风口标定范围:`platforms/t2g_cabin_bench/calibration/body_can.yaml`
+- 完整白名单回归测试:`tests/unit/test_can1_semantic_whitelist.py`
+
+白名单回归测试展开覆盖 119 条有效命令,并验证越界、非整数、非法枚举和超出风口标定范围的指令不能通过白名单门禁。
diff --git a/domains/climate/config/capabilities.yaml b/domains/climate/config/capabilities.yaml
index 55089ec..77578fb 100755
--- a/domains/climate/config/capabilities.yaml
+++ b/domains/climate/config/capabilities.yaml
@@ -22,7 +22,7 @@ spec:
- name: climate.set_temperature
description: Set an absolute HVAC temperature from 18 to 32 degrees Celsius.
target_devices: [hvac]
- target_scopes: [driver, passenger, front_row, rear_row, rear_left, rear_right, vehicle]
+ target_scopes: [driver, passenger, front_row, rear, rear_row, rear_left, rear_right, vehicle]
parameters:
- name: temperature_c
type: number
diff --git a/domains/seat/src/seat_domain/generator.py b/domains/seat/src/seat_domain/generator.py
index b13320c..f4324b9 100755
--- a/domains/seat/src/seat_domain/generator.py
+++ b/domains/seat/src/seat_domain/generator.py
@@ -395,6 +395,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_STOP_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {},
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -412,6 +415,9 @@ def build_function_config(
"capability": DYNAMIC_MODE_CAPABILITY,
"target": TARGETS[action["target"]],
"parameters": {"mode_id": int(action["mode_id"])},
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": 5000,
"failure_policy": "abort",
}
@@ -437,6 +443,9 @@ def build_function_config(
"capability": MOTION_CAPABILITIES[action["axis"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": 8000,
"failure_policy": "abort",
}
@@ -460,6 +469,9 @@ def build_function_config(
"capability": CAPABILITIES[action["function"]],
"target": TARGETS[action["target"]],
"parameters": parameters,
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": 3000,
"failure_policy": "abort",
}
diff --git a/domains/seat/src/seat_domain/intent.py b/domains/seat/src/seat_domain/intent.py
index f75db38..b273d1e 100755
--- a/domains/seat/src/seat_domain/intent.py
+++ b/domains/seat/src/seat_domain/intent.py
@@ -52,6 +52,15 @@ STRONG_PATTERN = re.compile(r"很|太|非常|特别|难受")
AIRFLOW_CAUSE_PATTERN = re.compile(r"吹|风太大|风吹")
FIRST_PERSON_PATTERN = re.compile(r"我|自己")
VEHICLE_SCOPE_PATTERN = re.compile(r"车上|车内|车里|全车|整车|整个车")
+NON_SEAT_DEVICE_PATTERN = re.compile(
+ r"车窗|车门|门锁|空调|温度|风量|风速|出风模式|"
+ r"内循环|外循环|循环模式|风口|遮阳帘|天幕|阅读灯|"
+ r"室内灯|车内灯|氛围灯"
+)
+SEAT_CONTROL_PATTERN = re.compile(
+ r"座椅|坐椅|座位|靠背|椅背|坐盆|坐垫|滑轨|腿托|脚托|四轴|"
+ r"通风|加热|按摩|记忆|预设"
+)
PASSENGER_ONLY_QUESTION = "当前所有座椅能力仅控制副驾,不支持主驾座椅目标。"
UNSUPPORTED_SEAT_SCOPE_PATTERN = re.compile(
r"后排|第二排|二排|第三排|三排|左前座椅|左前座|左前|"
@@ -799,6 +808,16 @@ def parse_local_intent(
"""Parse common Chinese seat commands without making hardware assumptions."""
text = _normalize_text(utterance)
+ if NON_SEAT_DEVICE_PATTERN.search(text) and not SEAT_CONTROL_PATTERN.search(text):
+ # This parser owns CAN0 passenger-seat semantics only. Occupant words
+ # such as “主驾/副驾” in a window or climate clause are spatial
+ # qualifiers for that device and must not create a seat ambiguity.
+ return {
+ "actions": [],
+ "ambiguities": [],
+ "decision_summary": [],
+ "confidence": 0.0,
+ }
clauses = [part for part in re.split(r"[,,。;;!!??]", text) if part]
actions: list[dict[str, Any]] = []
motion_actions: list[dict[str, Any]] = []
diff --git a/domains/seat/tests/integration/test_seat_service.py b/domains/seat/tests/integration/test_seat_service.py
index 8e43229..7d47ce7 100755
--- a/domains/seat/tests/integration/test_seat_service.py
+++ b/domains/seat/tests/integration/test_seat_service.py
@@ -311,7 +311,7 @@ class SeatServiceIntegrationTests(unittest.TestCase):
]
self.assertEqual(
commands,
- ["seat driver memory recall 3", "ac temp driver 25", "ac fan 1"],
+ ["seat driver position 3", "ac temp driver 25", "ac fan 1"],
)
def test_model_climate_request_completes_domain_loop(self) -> None:
diff --git a/domains/seat/tests/unit/test_intent.py b/domains/seat/tests/unit/test_intent.py
index f6c2493..5e27594 100755
--- a/domains/seat/tests/unit/test_intent.py
+++ b/domains/seat/tests/unit/test_intent.py
@@ -67,6 +67,21 @@ class LocalIntentParserTests(unittest.TestCase):
self.assertEqual(result["actions"][0]["target"], "passenger")
self.assertEqual(result["ambiguities"], [])
+ def test_non_seat_spatial_qualifiers_do_not_enter_can0_seat_rules(self) -> None:
+ utterances = (
+ "打开主驾车窗",
+ "打开副驾车窗",
+ "把主驾和副驾温度都设为24度",
+ "风量调到2档",
+ "设置主驾出风模式为吹脸",
+ )
+ for utterance in utterances:
+ with self.subTest(utterance=utterance):
+ result = parse_local_intent(utterance)
+
+ self.assertEqual(result["actions"], [])
+ self.assertEqual(result["ambiguities"], [])
+
def test_parses_four_axis_zero_for_passenger(self) -> None:
result = parse_local_intent("把水平、坐盆、靠背、腿托四轴归零")
diff --git a/src/cognitive_agent/application/conversation_state.py b/src/cognitive_agent/application/conversation_state.py
index d726fc8..23427c6 100755
--- a/src/cognitive_agent/application/conversation_state.py
+++ b/src/cognitive_agent/application/conversation_state.py
@@ -567,14 +567,19 @@ class ConversationStateStore:
device = state["domain_devices"][domain]
if domain == "climate":
+ # The trusted CAN command vocabulary uses the concise token
+ # ``rear`` while the conversation state stores that same HVAC
+ # zone as ``rear_row``. Keep the public command stable and
+ # normalize only at the state boundary.
+ zone_target = "rear_row" if target == "rear" else target
if capability == "climate.set_power":
- device["zones"][target]["power"] = parameters["state"]
+ device["zones"][zone_target]["power"] = parameters["state"]
if target == "vehicle":
device["power"] = parameters["state"]
for zone in device["zones"].values():
zone["power"] = parameters["state"]
elif capability == "climate.set_temperature":
- device["zones"][target]["temperature_c"] = parameters[
+ device["zones"][zone_target]["temperature_c"] = parameters[
"temperature_c"
]
if target == "vehicle":
@@ -589,7 +594,7 @@ class ConversationStateStore:
elif capability == "climate.set_front_defrost":
device["front_defrost"] = parameters["state"]
elif capability == "climate.set_outlet_mode":
- device["zones"][target]["outlet_mode"] = parameters["mode"]
+ device["zones"][zone_target]["outlet_mode"] = parameters["mode"]
elif capability == "climate.set_control_mode":
device["control_mode"] = parameters["mode"]
device["feedback_source"] = feedback_source
diff --git a/src/cognitive_agent/application/domain_runtime.py b/src/cognitive_agent/application/domain_runtime.py
index ddbe416..6dfaebe 100755
--- a/src/cognitive_agent/application/domain_runtime.py
+++ b/src/cognitive_agent/application/domain_runtime.py
@@ -78,6 +78,9 @@ class ConfiguredDomainRuntime:
"zone": action["target"],
},
"parameters": dict(action["parameters"]),
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": 8000 if self.domain == "roof" else 4000,
"failure_policy": "abort",
}
diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py
index 2706d38..6e75dd2 100644
--- a/src/cognitive_agent/application/native_can_intent.py
+++ b/src/cognitive_agent/application/native_can_intent.py
@@ -25,6 +25,16 @@ WINDOW_TARGETS = {
"后左": "rear_left",
"右后": "rear_right",
"后右": "rear_right",
+ # Natural cabin language names the front windows by occupant more often
+ # than by left/right. These aliases belong only to the window domain:
+ # driver = left-front, passenger = right-front.
+ "主驾驶": "front_left",
+ "驾驶位": "front_left",
+ "司机位": "front_left",
+ "主驾": "front_left",
+ "副驾驶": "front_right",
+ "乘客位": "front_right",
+ "副驾": "front_right",
}
WINDOW_GROUP_MEMBERS = {
"all": ["front_left", "front_right", "rear_left", "rear_right"],
@@ -49,6 +59,7 @@ LIGHT_TARGETS = {
"左后": "rear_left",
"右后": "rear_right",
}
+CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+")
# Defaults are intentionally scoped to the driver console and verified CH1
# body-CAN vocabulary. A missing target must never become the legacy
@@ -83,7 +94,8 @@ CH1_DEFAULT_TARGETS = {
_EXPLICIT_TARGET_PATTERNS = {
"window": re.compile(
- r"(?:全部|所有|四个|全车|前排|后排|左前|前左|右前|前右|左后|后左|右后|后右)"
+ r"(?:全部|所有|四个|全车|前排|后排|左前|前左|右前|前右|左后|后左|右后|后右|"
+ r"主驾驶|驾驶位|司机位|主驾|副驾驶|乘客位|副驾)"
r"(?:的)?(?:车窗|窗)|window\s+(?:all|front|rear|front-left|front-right|rear-left|rear-right)",
re.IGNORECASE,
),
@@ -113,6 +125,88 @@ _EXPLICIT_TARGET_PATTERNS = {
}
+def _scoped_text(
+ compact: str,
+ subject_pattern: str,
+ continuation_pattern: str | None = None,
+) -> str:
+ """Keep action words inside clauses that name the controlled device."""
+
+ clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
+ subject_indexes = {
+ index
+ for index, clause in enumerate(clauses)
+ if re.search(subject_pattern, clause, re.IGNORECASE)
+ }
+ selected_indexes = set(subject_indexes)
+ if continuation_pattern:
+ selected_indexes.update(
+ index + 1
+ for index in subject_indexes
+ if index + 1 < len(clauses)
+ and re.search(
+ continuation_pattern,
+ clauses[index + 1],
+ re.IGNORECASE,
+ )
+ )
+ return ",".join(
+ clause
+ for index, clause in enumerate(clauses)
+ if index in selected_indexes
+ )
+
+
+def _window_targets(text: str) -> tuple[list[str], bool]:
+ """Resolve target aliases only when they are attached to a window noun."""
+
+ explicit: list[str] = []
+ broad: str | None = None
+ target_labels = "|".join(
+ re.escape(label)
+ for label in sorted(WINDOW_TARGETS, key=len, reverse=True)
+ )
+ for label, target in WINDOW_TARGETS.items():
+ attached = bool(
+ re.search(
+ rf"{re.escape(label)}"
+ rf"(?:(?:和|与|及|、|以及)(?:{target_labels}))*"
+ rf"(?:的)?(?:两个|两侧)?(?:车窗|窗)",
+ text,
+ )
+ )
+ if not attached:
+ continue
+ if target in WINDOW_GROUP_MEMBERS:
+ broad = broad or target
+ elif target not in explicit:
+ explicit.append(target)
+
+ exclusions = {
+ target
+ for label, target in WINDOW_TARGETS.items()
+ if target not in WINDOW_GROUP_MEMBERS
+ and re.search(
+ rf"{re.escape(label)}(?:车窗)?(?:除外|除去|不包括|不含)",
+ text,
+ )
+ }
+ if broad and exclusions:
+ return (
+ [
+ target
+ for target in WINDOW_GROUP_MEMBERS[broad]
+ if target not in exclusions
+ ],
+ True,
+ )
+ if explicit:
+ return ([target for target in explicit if target not in exclusions], True)
+ if broad:
+ return ([broad], True)
+ return (["front_left"], False)
+
+
def _capability_domain(capability: str) -> str:
return "interior_light" if capability.startswith("interior_light.") else capability.split(".", 1)[0]
@@ -156,6 +250,7 @@ def _action(
"operation": "set",
"reason": reason,
"execution_backend": "native_can",
+ "parse_source": "local_rule",
}
@@ -284,6 +379,8 @@ def _parse_exact_command(text: str) -> list[dict[str, Any]]:
}
if direction not in valid_directions[axis]:
return []
+ if direction == "stop" and duration_token is not None:
+ return []
duration = (
500
if direction == "stop"
@@ -381,17 +478,23 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
actions: list[dict[str, Any]] = []
memory = re.search(
- r"(?:记忆(?:位置|位)?|记忆座椅(?:位置)?|座椅记忆(?:位置)?|位置记忆)"
+ r"(?:"
+ r"(?:记忆(?:位置|位)?|预设(?:位置)?|记忆座椅(?:位置)?|"
+ r"座椅记忆(?:位置)?|位置记忆)"
r"(?:切换到|切换为|调整到|调到|唤醒|调用|恢复到|恢复为|设为|设置为)?"
- r"([1-3])(?:号|档|位)?",
+ r"(?P[1-3一二三])(?:号|档|位)?"
+ r"|(?P[1-3一二三])号?(?:记忆|预设)(?:位置|位)?"
+ r")",
compact,
)
if memory is not None:
+ slot_token = memory.group("after") or memory.group("before")
+ slot = {"一": 1, "二": 2, "三": 3}.get(slot_token, slot_token)
actions.append(_action(
"seat",
- "seat.recall_driver_memory_native",
+ "seat.set_driver_position_native",
"driver",
- {"slot": int(memory[1])},
+ {"slot": int(slot)},
(
"用户明确指定了 CH1 主驾座椅记忆位置。"
if explicit_driver
@@ -449,8 +552,10 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
strength = (
1
if re.search(r"弱|低|1档", compact)
+ else 2
+ if re.search(r"中等|中档|2档", compact)
else 3
- if re.search(r"强|高|3档", compact)
+ if re.search(r"强(?!度)|高|3档", compact)
else 2
)
massage = {"action": "strength", "strength": strength}
@@ -549,103 +654,149 @@ def _parse_chinese_driver_seat_commands(compact: str) -> list[dict[str, Any]]:
def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
compact = re.sub(r"\s+", "", text)
- actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(compact)
+ seat_text = _scoped_text(
+ compact,
+ r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|按摩|记忆|预设|"
+ r"通风|加热|前移|后移|向前移动|向后移动",
+ )
+ actions: list[dict[str, Any]] = _parse_chinese_driver_seat_commands(
+ seat_text
+ )
+
+ climate_text = _scoped_text(
+ compact,
+ r"空调|温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜|(?:AC|A/C)",
+ )
if (
- "空调" in compact
+ "空调" in climate_text
or re.search(r"(?:AC|A/C)", text, re.IGNORECASE)
- or re.search(r"温度|风量|风速|内循环|外循环|循环模式", compact)
+ or re.search(
+ r"温度|风量|风速|内循环|外循环|循环模式|吹面|吹脸|吹脚|除霜",
+ climate_text,
+ )
):
- if re.search(r"(?:打开|开启|启动)空调", compact):
+ if re.search(r"(?:打开|开启|启动)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "on"}, "用户要求打开空调。"))
- elif re.search(r"(?:关闭|关掉|关上)空调", compact):
+ elif re.search(r"(?:关闭|关掉|关上)空调", climate_text):
actions.append(_action("climate", "climate.set_power", "vehicle", {"state": "off"}, "用户要求关闭空调。"))
temperature = re.search(
- r"(?:(主驾|副驾|后排)(?:空调)?(?:温度)?|空调(?:温度)?|温度)(?:调到|设为|设置为|到)?(1[89]|2\d|3[0-2])(?:℃|度)?",
- compact,
+ r"(?:空调(?:温度)?|温度)(?:都)?"
+ r"(?:调整到|调整为|调到|调为|设为|设置为|调至|到|为)?"
+ r"(1[89]|2\d|3[0-2])(?:摄氏)?(?:℃|度)?(?![\d.])",
+ climate_text,
)
if temperature:
- target = {"主驾": "driver", "副驾": "passenger", "后排": "rear"}.get(temperature[1], "driver")
+ if (
+ re.search(r"全车|整车|前排|主副驾", climate_text)
+ or ("主驾" in climate_text and "副驾" in climate_text)
+ ):
+ temperature_targets = ("driver", "passenger")
+ elif re.search(r"副驾|副驾驶|乘客", climate_text):
+ temperature_targets = ("passenger",)
+ elif "后排" in climate_text:
+ temperature_targets = ("rear",)
+ else:
+ temperature_targets = ("driver",)
+ explicit_temperature_target = bool(
+ re.search(
+ r"主驾|副驾|副驾驶|乘客|后排|前排|全车|整车|主副驾",
+ climate_text,
+ )
+ )
reason = (
"用户指定了空调温区和温度。"
- if temperature[1]
+ if explicit_temperature_target
else "用户未指定空调温区,按驾驶台默认选择主驾温区。"
)
- actions.append(_action("climate", "climate.set_temperature", target, {"temperature_c": int(temperature[2])}, reason))
+ actions.extend(
+ _action(
+ "climate",
+ "climate.set_temperature",
+ target,
+ {"temperature_c": int(temperature[1])},
+ reason,
+ )
+ for target in temperature_targets
+ )
- fan = re.search(r"(?:风量|风速)(?:调到|设为|设置为|到)?([1-7])(?:档)?", compact)
+ fan = re.search(
+ r"(?:风量|风速)(?:调整到|调整为|调到|调为|设为|设置为|到)?"
+ r"([1-7])(?:档|级)?(?![\d.])",
+ climate_text,
+ )
if fan:
actions.append(_action("climate", "climate.set_fan_level", "vehicle", {"level": int(fan[1])}, "用户指定了空调风量。"))
- if re.search(r"自动(?:控制|模式)", compact):
+ if re.search(r"自动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "auto"}, "用户指定空调自动控制。"))
- elif re.search(r"手动(?:控制|模式)", compact):
+ elif re.search(r"手动(?:控制|模式)", climate_text):
actions.append(_action("climate", "climate.set_control_mode", "vehicle", {"mode": "manual"}, "用户指定空调手动控制。"))
- if re.search(r"内循环", compact):
+ if re.search(r"内循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "internal"}, "用户指定空调内循环。"))
- elif re.search(r"外循环", compact):
+ elif re.search(r"外循环", climate_text):
actions.append(_action("climate", "climate.set_cycle_mode", "vehicle", {"mode": "external"}, "用户指定空调外循环。"))
- outlet_modes = [
- ("吹面吹脚除霜", "face_feet_defrost"),
- ("吹面除霜", "face_defrost"),
- ("吹脚除霜", "feet_defrost"),
- ("吹面吹脚", "face_feet"),
- ("除霜", "defrost"),
- ("吹脚", "feet"),
- ("吹面", "face"),
- ]
- for label, mode in outlet_modes:
- if label in compact:
- actions.append(_action("climate", "climate.set_outlet_mode", "vehicle", {"mode": mode}, "用户指定了空调出风模式。"))
- break
-
- if "车窗" in compact:
- explicit_targets = []
- for label, target_name in WINDOW_TARGETS.items():
- if target_name in WINDOW_GROUP_MEMBERS or label not in compact:
- continue
- if target_name not in explicit_targets:
- explicit_targets.append(target_name)
- broad_target = next(
- (
- target_name
- for label, target_name in WINDOW_TARGETS.items()
- if target_name in WINDOW_GROUP_MEMBERS and label in compact
- ),
- None,
+ has_face = bool(re.search(r"吹面|吹脸", climate_text))
+ has_feet = "吹脚" in climate_text
+ has_defrost = "除霜" in climate_text
+ outlet_mode = (
+ "face_feet_defrost"
+ if has_face and has_feet and has_defrost
+ else "face_feet"
+ if has_face and has_feet
+ else "face_defrost"
+ if has_face and has_defrost
+ else "feet_defrost"
+ if has_feet and has_defrost
+ else "defrost"
+ if has_defrost
+ else "face"
+ if has_face
+ else "feet"
+ if has_feet
+ else None
)
- exclusions = {
- target_name
- for label, target_name in WINDOW_TARGETS.items()
- if target_name not in WINDOW_GROUP_MEMBERS
- and re.search(
- rf"{re.escape(label)}(?:车窗)?(?:除外|除去|不包括|不含)",
- compact,
+ if outlet_mode:
+ actions.append(
+ _action(
+ "climate",
+ "climate.set_outlet_mode",
+ "vehicle",
+ {"mode": outlet_mode},
+ "用户指定了空调出风模式。",
+ )
)
- }
- if broad_target and exclusions:
- targets = [
- target_name
- for target_name in WINDOW_GROUP_MEMBERS[broad_target]
- if target_name not in exclusions
- ]
- elif explicit_targets:
- targets = [target_name for target_name in explicit_targets if target_name not in exclusions]
- else:
- targets = [broad_target or "front_left"]
+
+ window_text = _scoped_text(
+ compact,
+ r"车窗",
+ r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
+ r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?(?:除外|除去|不包括|不含)$",
+ )
+ if window_text:
+ targets, explicit_window_target = _window_targets(window_text)
position = re.search(
- r"(?:位置|开度|调到|设为|设置为|开启到|打开到)(\d{1,3})%",
- compact,
+ r"(?:位置|开度|调整到|调到|设为|设置为|开启到|打开到|开到)(\d{1,3})%",
+ window_text,
+ )
+ half_position = bool(
+ re.search(
+ r"(?:开启|打开|开到|开度)?(?:一半|半开)",
+ window_text,
+ )
)
- half_position = bool(re.search(r"(?:开启|打开|开到|开度)?(?:一半|半开)", compact))
position_pct = int(position[1]) if position else 50 if half_position else None
- if position_pct is not None and all(target not in WINDOW_GROUP_MEMBERS for target in targets) and position_pct <= 100:
+ if (
+ position_pct is not None
+ and all(target not in WINDOW_GROUP_MEMBERS for target in targets)
+ and position_pct <= 100
+ ):
reason = (
"用户指定了单个车窗的绝对开度。"
- if explicit_targets or broad_target
+ if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
@@ -658,15 +809,15 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
pass
else:
motion = (
- "stop" if re.search(r"停止|停下|暂停", compact)
- else "close" if re.search(r"关闭|关上|升起|关窗", compact)
- else "open" if re.search(r"打开|开启|降下|开窗", compact)
+ "stop" if re.search(r"停止|停下|暂停", window_text)
+ else "close" if re.search(r"关闭|关上|升起|关窗", window_text)
+ else "open" if re.search(r"打开|开启|降下|开窗", window_text)
else None
)
if motion:
reason = (
"用户指定了车窗目标和动作。"
- if explicit_targets or broad_target
+ if explicit_window_target
else "用户未指定车窗目标,按驾驶台默认选择主驾左前窗。"
)
actions.extend(
@@ -674,17 +825,18 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
for target in targets
)
+ door_text = _scoped_text(compact, r"门锁|车门|后门|锁车|解锁车辆")
rear_lock_request = bool(
re.search(
- r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", compact
+ r"(?:后门|后排车门|后排门锁|二排车门|二排门锁)", door_text
)
)
if rear_lock_request:
state = (
"unlock"
- if re.search(r"解锁|开锁", compact)
+ if re.search(r"解锁|开锁", door_text)
else "lock"
- if re.search(r"上锁|落锁|锁上", compact)
+ if re.search(r"上锁|落锁|锁上", door_text)
else None
)
if state:
@@ -695,59 +847,123 @@ def _parse_chinese_commands(text: str) -> list[dict[str, Any]]:
r"整车(?:门锁|车门)|全车(?:门锁|车门)|(?:所有|全部)(?:车门|门锁)|"
r"(?:车门|门锁)(?:上锁|落锁|锁上|解锁|开锁)|"
r"(?:锁车|整车解锁|全车解锁|解锁车辆)",
- compact,
+ door_text,
)
)
if vehicle_lock_request:
state = (
"unlock"
- if re.search(r"解锁|开锁", compact)
+ if re.search(r"解锁|开锁", door_text)
else "lock"
- if re.search(r"上锁|落锁|锁上|锁车", compact)
+ if re.search(r"上锁|落锁|锁上|锁车", door_text)
else None
)
if state:
actions.append(_action("door", "door.set_vehicle_lock", "vehicle", {"state": state}, "用户指定了整车门锁状态。"))
- if re.search(r"遮阳帘|天幕帘", compact):
- position = re.search(r"(?:位置|开度|调到|设为)(\d{1,3})%", compact)
+ shade_text = _scoped_text(compact, r"遮阳帘|天幕帘")
+ if shade_text:
+ position = re.search(
+ r"(?:位置|开度|调整到|调到|设为|设置为|开到|打开(?:到)?|开启(?:到)?)(\d{1,3})%",
+ shade_text,
+ )
if position and int(position[1]) <= 100:
actions.append(_action("roof", "roof.set_shade_position", "vehicle", {"position_pct": int(position[1])}, "用户指定了遮阳帘开度。"))
else:
motion = (
- "stop" if re.search(r"停止|停下|暂停", compact)
- else "half" if re.search(r"半开|一半", compact)
- else "close" if re.search(r"关闭|关上", compact)
- else "open" if re.search(r"打开|开启", compact)
+ "stop" if re.search(r"停止|停下|暂停", shade_text)
+ else "half" if re.search(r"半开|一半", shade_text)
+ else "close" if re.search(r"关闭|关上", shade_text)
+ else "open" if re.search(r"打开|开启", shade_text)
else None
)
if motion:
actions.append(_action("roof", "roof.set_shade_motion", "vehicle", {"motion": motion}, "用户指定了遮阳帘动作。"))
+ indoor_text = _scoped_text(compact, r"室内灯|车内灯")
for label, capability in (("室内灯", "interior_light.set_indoor_power"), ("车内灯", "interior_light.set_indoor_power")):
- if label in compact:
- state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
+ if label in indoor_text:
+ state = "off" if re.search(r"关闭|关掉|关上", indoor_text) else "on" if re.search(r"打开|开启", indoor_text) else None
if state:
actions.append(_action("interior_light", capability, "vehicle", {"state": state}, "用户指定了车内灯状态。"))
break
- if "阅读灯" in compact:
- target = next((value for label, value in LIGHT_TARGETS.items() if label in compact), "all")
- state = "off" if re.search(r"关闭|关掉|关上", compact) else "on" if re.search(r"打开|开启", compact) else None
+ reading_text = _scoped_text(compact, r"阅读灯")
+ if reading_text:
+ target = next((value for label, value in LIGHT_TARGETS.items() if label in reading_text), "all")
+ state = "off" if re.search(r"关闭|关掉|关上", reading_text) else "on" if re.search(r"打开|开启", reading_text) else None
if state and target == "all":
actions.append(_action("interior_light", "interior_light.set_reading_power", target, {"state": state}, "用户指定了阅读灯区域和状态。"))
- if "氛围灯" in compact:
- color_match = re.search(r"颜色(?:索引|编号)?(\d{1,3})", compact)
- brightness_match = re.search(r"亮度(?:档|级)?([1-5])", compact)
+ ambient_text = _scoped_text(
+ compact,
+ r"氛围灯",
+ r"^(?:亮度|强度)(?:调整|调节|调|设置|设)?",
+ )
+ if ambient_text:
+ state = (
+ "off"
+ if re.search(r"关闭|关掉|熄灭", ambient_text)
+ else "on"
+ if re.search(r"打开|开启|点亮", ambient_text)
+ else None
+ )
+ if state:
+ actions.append(
+ _action(
+ "ambient_light",
+ "ambient_light.set_power",
+ "vehicle",
+ {"state": state},
+ "用户指定了氛围灯总开关状态。",
+ )
+ )
+ color_match = re.search(
+ r"(?:颜色编号|颜色索引|颜色|色号)"
+ r"(?:调整到|调整为|调到|调为|设置为|设为|为)?"
+ r"(\d{1,3})(?![\d.])",
+ ambient_text,
+ )
+ brightness_match = re.search(
+ r"(?:亮度|强度)(?:调整到|调整为|调到|调为|设置为|设为|为)?"
+ r"([1-5])(?:档|级)?(?![\d.])",
+ ambient_text,
+ )
if color_match and 1 <= int(color_match[1]) <= 127:
actions.append(_action("ambient_light", "ambient_light.set_color_index", "vehicle", {"color_index": int(color_match[1])}, "用户指定了已验证的氛围灯颜色索引。"))
if brightness_match:
actions.append(_action("ambient_light", "ambient_light.set_brightness_level", "vehicle", {"brightness_level": int(brightness_match[1])}, "用户指定了已验证的氛围灯亮度档位。"))
- outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in compact), None)
- outlet_position = re.search(r"(?:左右|水平)(\d{1,3})%?.*?(?:上下|垂直)(\d{1,3})%?", compact)
- if "风口" in compact and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
+ outlet_text = _scoped_text(compact, r"风口")
+ outlet_target = next((value for label, value in OUTLET_TARGETS.items() if f"{label}风口" in outlet_text), None)
+ calibrated_position = re.search(
+ r"(?:标定坐标|标定位置).*?(?:左右|水平)"
+ r"(-?\d{1,3})(?![\d.]).*?(?:上下|垂直)(-?\d{1,3})(?![\d.])",
+ outlet_text,
+ )
+ outlet_position = re.search(
+ r"(?:左右|水平)(\d{1,3})%?(?![\d.]).*?"
+ r"(?:上下|垂直)(\d{1,3})%?(?![\d.])",
+ outlet_text,
+ )
+ if (
+ outlet_target
+ and calibrated_position
+ and all(-128 <= int(value) <= 126 for value in calibrated_position.groups())
+ ):
+ actions.append(
+ _action(
+ "outlet",
+ "outlet.set_calibrated_position",
+ outlet_target,
+ {
+ "horizontal_value": int(calibrated_position[1]),
+ "vertical_value": int(calibrated_position[2]),
+ },
+ "用户指定了已标定风口的双轴原始坐标。",
+ )
+ )
+ elif outlet_text and outlet_position and all(int(value) <= 100 for value in outlet_position.groups()):
target = outlet_target or "driver_left"
reason = (
"用户指定了已标定风口的双轴百分比位置。"
diff --git a/src/cognitive_agent/application/native_can_relative.py b/src/cognitive_agent/application/native_can_relative.py
index b6e56bf..c553717 100644
--- a/src/cognitive_agent/application/native_can_relative.py
+++ b/src/cognitive_agent/application/native_can_relative.py
@@ -275,6 +275,7 @@ class NativeCanRelativeResolver:
"target": target,
"operation": "set",
"parameters": parameters,
+ "parse_source": "local_rule",
"reason": f"用户要求{label}{verb}{delta};基于当前状态 {current} 计算绝对目标 {target_value}{',已按允许范围收敛' if clamped else ''}。",
"relative_resolution": {
"direction": direction,
diff --git a/src/cognitive_agent/application/native_can_runtime.py b/src/cognitive_agent/application/native_can_runtime.py
index c0a9003..a09945f 100644
--- a/src/cognitive_agent/application/native_can_runtime.py
+++ b/src/cognitive_agent/application/native_can_runtime.py
@@ -183,6 +183,9 @@ class NativeCanRuntime:
"zone": target,
},
"parameters": dict(action.get("parameters", {})),
+ "parse_source": str(
+ action.get("parse_source", "local_rule")
+ ),
"timeout_ms": (
20_000
if capability_name
@@ -417,6 +420,8 @@ class NativeCanRuntime:
return f"seat driver massage strength {strength}"
return f"seat driver massage mode {parameters['mode']}"
if capability == "seat.set_driver_axis_native":
+ if parameters["direction"] == "stop":
+ return f"seat driver {parameters['axis']} stop"
return (
f"seat driver {parameters['axis']} {parameters['direction']} "
f"{parameters.get('duration_ms', 500)}"
@@ -1129,15 +1134,30 @@ class NativeCanExecutor:
"feedback ID and a target-value match contract"
)
special_handler = command["signals"].get("special_handler")
- if special_handler == "window_position":
+ feedback_required = self.feedback_required
+ if not feedback_required and special_handler in {
+ None,
+ "shade_preset",
+ }:
+ # Wireless MQTT uses the state topic as an asynchronous
+ # source of truth. Fixed frames and self-terminating
+ # shade presets must not hold the HTTP request open
+ # while waiting for the same state stream synchronously.
+ sent_frames = self._send_command(bus, command)
+ feedback = None
+ elif special_handler == "window_position":
sent_frames, feedback = self._execute_window_position(bus, command)
elif special_handler == "driver_seat_axis":
sent_frames, feedback = self._execute_driver_seat_axis(
- bus, command
+ bus,
+ command,
+ wait_for_feedback=feedback_required,
)
elif special_handler == "driver_seat_memory_recall":
sent_frames, feedback = self._execute_driver_seat_memory(
- bus, command
+ bus,
+ command,
+ wait_for_completion=feedback_required,
)
elif special_handler == "shade_preset":
sent_frames, feedback = self._execute_shade_preset(
@@ -1151,7 +1171,6 @@ class NativeCanExecutor:
sent_frames = self._send_command(bus, command)
feedback = self._wait_feedback(bus, command)
feedback_match = self._feedback_matches(command, feedback)
- feedback_required = self.feedback_required
verified = feedback is not None and feedback_match is True
delivered = bool(sent_frames)
succeeded = verified or (delivered and not feedback_required)
@@ -1167,17 +1186,26 @@ class NativeCanExecutor:
publication_start:
]
)
+ delivery_status = "submitted"
+ if not feedback_required and delivered:
+ delivery_status = (
+ "published"
+ if mqtt_messages
+ and all(
+ message.get("delivery_status") == "published"
+ for message in mqtt_messages
+ )
+ else "queued"
+ )
results.append(
{
"ref": operation["id"],
"domain": function_config["spec"]["domain"],
"status": "succeeded" if succeeded else "failed",
"message": (
- "MQTT 白名单控制帧已发布,并收到匹配的车辆状态反馈。"
- if not feedback_required and feedback_match is True
- else "MQTT 白名单控制帧已发布;已收到状态帧,但目标值尚未确认。"
- if not feedback_required and feedback is not None
- else "MQTT 白名单控制帧已发布;车辆状态反馈尚未确认。"
+ "MQTT 白名单控制帧已由 Broker 确认;车辆效果以状态话题回读为准。"
+ if not feedback_required and delivery_status == "published"
+ else "MQTT 白名单控制帧已进入客户端发布队列;车辆效果以状态话题回读为准。"
if not feedback_required
else "CAN 盒已提交车端控制帧并观察到匹配反馈。"
if feedback_match is True
@@ -1197,11 +1225,7 @@ class NativeCanExecutor:
"feedback_match": feedback_match,
"feedback_state": self._feedback_state(feedback),
"feedback_required": feedback_required,
- "delivery_status": (
- "published"
- if not feedback_required and delivered
- else "submitted"
- ),
+ "delivery_status": delivery_status,
"physical_effect_confirmed": verified,
"control_gate": (
"compiled_action_and_frame_whitelist"
@@ -1260,7 +1284,11 @@ class NativeCanExecutor:
return sent
def _execute_driver_seat_axis(
- self, bus: CanBus, command: dict[str, Any]
+ self,
+ bus: CanBus,
+ command: dict[str, Any],
+ *,
+ wait_for_feedback: bool = True,
) -> tuple[list[CanFrame], CanFrame | None]:
"""Send bounded motion and attempt all three stop frames in a finally path."""
@@ -1313,10 +1341,14 @@ class NativeCanExecutor:
f"{len(stop_errors)}/3 forced-stop frame(s) failed"
)
raise RuntimeError("; ".join(details))
- return sent, self._wait_feedback(bus, command)
+ return sent, self._wait_feedback(bus, command) if wait_for_feedback else None
def _execute_driver_seat_memory(
- self, bus: CanBus, command: dict[str, Any]
+ self,
+ bus: CanBus,
+ command: dict[str, Any],
+ *,
+ wait_for_completion: bool = True,
) -> tuple[list[CanFrame], CanFrame | None]:
"""Require fresh initialization, then replay the learned 16/15 sequence."""
@@ -1366,8 +1398,12 @@ class NativeCanExecutor:
)
if index + 1 < len(sequence):
time.sleep(int(special["period_ms"]) / 1_000)
- feedback = self._wait_for_id(
- bus, 0x494, int(special["post_status_timeout_ms"])
+ feedback = (
+ self._wait_for_id(
+ bus, 0x494, int(special["post_status_timeout_ms"])
+ )
+ if wait_for_completion
+ else None
)
return sent, feedback
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 5a8db50..68bc803 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -8,6 +8,7 @@ import re
import sys
import time
import uuid
+from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from datetime import UTC, datetime
@@ -177,7 +178,7 @@ UPDATE_MODE_PROFILE_PREFIX = re.compile(
UPDATE_MODE_PROFILE_BA = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:把|将)"
r"(?P[^,。,.!!\s]{1,64}?模式)"
- r"(?:的)?(?P.+)$"
+ r"的(?P.+)$"
)
RESTORE_MODE_PROFILE_PREFIX = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
@@ -1066,6 +1067,340 @@ class SeatDemoService:
return MqttOtaSeatExecutor(executor, resolved_bus_factory)
return executor
+ def preview_native_can(
+ self,
+ utterance: str,
+ provider: str = "auto",
+ conversation_id: str | None = None,
+ execution_mode: str = "dry_run",
+ approval_token: str | None = None,
+ vehicle_id: str | None = None,
+ simulation_fault: str = "normal",
+ *,
+ hardware_client_id: str | None = None,
+ hardware_access_bypass: bool = True,
+ reasoning_effort: str | None = None,
+ control_transport: str = WIRED_CAN_TRANSPORT,
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
+ ) -> dict[str, Any]:
+ """Validate and execute deterministic controls before hosted parsing."""
+
+ utterance = utterance.strip()
+ if not utterance:
+ raise ValueError("utterance must not be empty")
+ if provider not in {"auto", "openai", "local"}:
+ raise ValueError("provider must be auto, openai, or local")
+ conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
+ if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id):
+ raise ValueError(
+ "conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens"
+ )
+ vehicle_profile = self.vehicle_profiles.get(vehicle_id)
+ control_plane_request = any(
+ matcher(utterance) is not None
+ for matcher in (
+ self._parse_mode_profile_save_name,
+ self._parse_mode_profile_create_name,
+ self._parse_mode_profile_restore_name,
+ self._parse_mode_profile_update,
+ )
+ )
+ if control_plane_request:
+ result = self.process(
+ utterance,
+ provider=provider,
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ approval_token=approval_token,
+ vehicle_id=vehicle_profile.id,
+ simulation_fault=simulation_fault,
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass,
+ reasoning_effort=reasoning_effort,
+ control_transport=control_transport,
+ _on_commands_validated=on_commands_validated,
+ )
+ result.update(
+ {
+ "phase": "control_plane_complete",
+ "preview_request_id": result["request_id"],
+ "unresolved_clauses": [],
+ "supplement_required": False,
+ }
+ )
+ self._persist(result)
+ return result
+ context = self._context_for_vehicle(
+ self.conversations.snapshot(conversation_id),
+ vehicle_profile,
+ )
+ clause_results = self._local_clause_results(utterance, context)
+ unresolved_clauses = [
+ clause
+ for clause, _, status in clause_results
+ if status != "complete"
+ ]
+ started = time.perf_counter()
+
+ def publish_local_commands(validated: dict[str, Any]) -> None:
+ if on_commands_validated is None:
+ return
+ event = deepcopy(validated)
+ event.update(
+ {
+ "phase": "deterministic_commands_validated",
+ "provider_requested": provider,
+ "unresolved_clauses": unresolved_clauses,
+ "supplement_required": bool(
+ provider != "local" and unresolved_clauses
+ ),
+ }
+ )
+ on_commands_validated(event)
+
+ result = self.process(
+ utterance,
+ provider="local",
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ approval_token=approval_token,
+ vehicle_id=vehicle_profile.id,
+ simulation_fault=simulation_fault,
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass,
+ reasoning_effort=reasoning_effort,
+ control_transport=control_transport,
+ _local_preview=True,
+ _allow_empty_control_result=True,
+ _on_commands_validated=publish_local_commands,
+ )
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
+ supplement_required = bool(
+ provider != "local" and unresolved_clauses
+ )
+ result.update(
+ {
+ "phase": "deterministic_preview",
+ "provider_requested": provider,
+ "preview_request_id": result["request_id"],
+ "preview_elapsed_ms": elapsed_ms,
+ "unresolved_clauses": unresolved_clauses,
+ "supplement_required": supplement_required,
+ }
+ )
+ result["decision_summary"] = [
+ *result.get("decision_summary", []),
+ (
+ "本地规则阶段已完成白名单校验并先行处理 "
+ f"{sum(len((config.get('spec') or {}).get('operations', [])) for config in result.get('function_configs', {}).values())} 条 CAN 控制指令。"
+ ),
+ ]
+ self._persist(result)
+ manifest = {
+ "version": 1,
+ "preview_request_id": result["request_id"],
+ "utterance": utterance,
+ "provider": provider,
+ "conversation_id": conversation_id,
+ "execution_mode": execution_mode,
+ "vehicle_id": vehicle_profile.id,
+ "simulation_fault": simulation_fault,
+ "hardware_client_id": hardware_client_id,
+ "reasoning_effort": reasoning_effort,
+ "control_transport": control_transport,
+ "unresolved_clauses": unresolved_clauses,
+ "preview_control_signatures": result.get(
+ "parsed_control_signatures", []
+ ),
+ }
+ self._atomic_write(
+ self.artifact_root / result["request_id"] / "preview.json",
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ )
+ return result
+
+ def supplement_native_can(
+ self,
+ preview_request_id: str,
+ *,
+ provider: str | None = None,
+ approval_token: str | None = None,
+ hardware_client_id: str | None = None,
+ hardware_access_bypass: bool = True,
+ reasoning_effort: str | None = None,
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
+ ) -> dict[str, Any]:
+ """Parse only unresolved clauses and merge them with the local result."""
+
+ if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(preview_request_id):
+ raise ValueError("invalid preview request id")
+ preview_dir = self.artifact_root / preview_request_id
+ try:
+ manifest = json.loads(
+ (preview_dir / "preview.json").read_text(encoding="utf-8")
+ )
+ preview = json.loads(
+ (preview_dir / "result.json").read_text(encoding="utf-8")
+ )
+ except (OSError, json.JSONDecodeError) as exc:
+ raise ValueError("native CAN preview not found") from exc
+ if (
+ not isinstance(manifest, dict)
+ or manifest.get("version") != 1
+ or manifest.get("preview_request_id") != preview_request_id
+ or not isinstance(preview, dict)
+ or preview.get("request_id") != preview_request_id
+ ):
+ raise ValueError("invalid native CAN preview manifest")
+ selected_provider = str(manifest.get("provider") or "auto")
+ if provider is not None and provider != selected_provider:
+ raise ValueError("supplement provider does not match preview")
+ selected_effort = manifest.get("reasoning_effort")
+ if reasoning_effort is not None and reasoning_effort != selected_effort:
+ raise ValueError("supplement reasoning effort does not match preview")
+ expected_client_id = manifest.get("hardware_client_id")
+ if hardware_client_id != expected_client_id:
+ raise ValueError("supplement hardware client does not match preview")
+ unresolved_clauses = [
+ str(item).strip()
+ for item in manifest.get("unresolved_clauses", [])
+ if str(item).strip()
+ ]
+ if not unresolved_clauses:
+ raise ValueError("native CAN preview has no unresolved clauses")
+ excluded_signatures = {
+ tuple(item)
+ for item in manifest.get("preview_control_signatures", [])
+ if isinstance(item, list)
+ }
+
+ def publish_cloud_commands(validated: dict[str, Any]) -> None:
+ if on_commands_validated is None:
+ return
+ event = self._merge_native_can_phases(preview, validated)
+ event.update(
+ {
+ "phase": "supplement_commands_validated",
+ "preview_request_id": preview_request_id,
+ "supplement_of": preview_request_id,
+ "supplement_required": False,
+ }
+ )
+ on_commands_validated(event)
+
+ supplement = self.process(
+ ",".join(unresolved_clauses),
+ provider=selected_provider,
+ conversation_id=str(manifest["conversation_id"]),
+ execution_mode=str(manifest.get("execution_mode") or "dry_run"),
+ approval_token=approval_token,
+ vehicle_id=str(manifest["vehicle_id"]),
+ simulation_fault=str(manifest.get("simulation_fault") or "normal"),
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=hardware_access_bypass,
+ reasoning_effort=(
+ str(selected_effort) if selected_effort is not None else None
+ ),
+ control_transport=str(
+ manifest.get("control_transport") or WIRED_CAN_TRANSPORT
+ ),
+ _excluded_control_signatures=excluded_signatures,
+ _model_parse_source="cloud_supplement",
+ _allow_empty_control_result=True,
+ _on_commands_validated=publish_cloud_commands,
+ )
+ merged = self._merge_native_can_phases(preview, supplement)
+ merged.update(
+ {
+ "phase": "two_stage_complete",
+ "utterance": str(manifest["utterance"]),
+ "provider_requested": selected_provider,
+ "preview_request_id": preview_request_id,
+ "supplement_of": preview_request_id,
+ "supplement_request_id": supplement["request_id"],
+ "supplement_required": False,
+ "unresolved_clauses": unresolved_clauses,
+ }
+ )
+ self._persist(merged)
+ return merged
+
+ @staticmethod
+ def _merge_native_can_phases(
+ preview: dict[str, Any],
+ supplement: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Create one durable result without collapsing same-domain phases."""
+
+ merged = deepcopy(supplement)
+
+ def phase_documents(
+ local_documents: dict[str, Any] | None,
+ cloud_documents: dict[str, Any] | None,
+ ) -> dict[str, Any]:
+ return {
+ **{
+ f"local_{key}": deepcopy(value)
+ for key, value in (local_documents or {}).items()
+ },
+ **{
+ f"cloud_{key}": deepcopy(value)
+ for key, value in (cloud_documents or {}).items()
+ },
+ }
+
+ configs = phase_documents(
+ preview.get("function_configs"),
+ supplement.get("function_configs"),
+ )
+ plans = phase_documents(
+ preview.get("command_plans"),
+ supplement.get("command_plans"),
+ )
+ merged["function_configs"] = configs
+ merged["command_plans"] = plans
+ merged["function_config"] = next(iter(configs.values()), None)
+ merged["command_plan"] = next(iter(plans.values()), None)
+ merged["decision_summary"] = [
+ *preview.get("decision_summary", []),
+ *supplement.get("decision_summary", []),
+ ]
+ merged["trace_events"] = [
+ *preview.get("trace_events", []),
+ *supplement.get("trace_events", []),
+ ]
+ merged["validation"] = [
+ *preview.get("validation", []),
+ *supplement.get("validation", []),
+ ]
+ merged["parsed_control_signatures"] = [
+ *preview.get("parsed_control_signatures", []),
+ *supplement.get("parsed_control_signatures", []),
+ ]
+ merged["phase_execution_reports"] = {
+ "local_rule": deepcopy(preview.get("execution_report")),
+ "cloud_supplement": deepcopy(supplement.get("execution_report")),
+ }
+ if supplement.get("execution_report") is None:
+ merged["execution_report"] = deepcopy(preview.get("execution_report"))
+ successful = {"succeeded", "partially_succeeded", "no_action"}
+ preview_ok = preview.get("status") in successful
+ supplement_ok = supplement.get("status") in successful
+ has_any_commands = bool(configs and plans)
+ if preview_ok and supplement_ok:
+ merged["status"] = "succeeded" if has_any_commands else "no_action"
+ elif preview_ok and preview.get("function_configs"):
+ merged["status"] = "partially_succeeded"
+ merged["local_preview"] = {
+ "request_id": preview.get("request_id"),
+ "elapsed_ms": preview.get("preview_elapsed_ms"),
+ "command_count": sum(
+ len((config.get("spec") or {}).get("operations", []))
+ for config in preview.get("function_configs", {}).values()
+ ),
+ }
+ return merged
+
def process(
self,
utterance: str,
@@ -1080,6 +1415,11 @@ class SeatDemoService:
hardware_access_bypass: bool = True,
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
+ _local_preview: bool = False,
+ _excluded_control_signatures: set[tuple[Any, ...]] | None = None,
+ _model_parse_source: str = "cloud_model",
+ _allow_empty_control_result: bool = False,
+ _on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -1099,6 +1439,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
+ _local_preview,
+ _excluded_control_signatures,
+ _model_parse_source,
+ _allow_empty_control_result,
+ _on_commands_validated,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -1114,6 +1459,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
+ _local_preview,
+ _excluded_control_signatures,
+ _model_parse_source,
+ _allow_empty_control_result,
+ _on_commands_validated,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -1128,6 +1478,11 @@ class SeatDemoService:
simulation_fault,
reasoning_effort,
control_transport,
+ _local_preview,
+ _excluded_control_signatures,
+ _model_parse_source,
+ _allow_empty_control_result,
+ _on_commands_validated,
)
def _process_once(
@@ -1141,6 +1496,11 @@ class SeatDemoService:
simulation_fault: str = "normal",
reasoning_effort: str | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
+ local_preview: bool = False,
+ excluded_control_signatures: set[tuple[Any, ...]] | None = None,
+ model_parse_source: str = "cloud_model",
+ allow_empty_control_result: bool = False,
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -1298,6 +1658,8 @@ class SeatDemoService:
context,
vehicle_profile,
reasoning_effort=reasoning_effort,
+ local_preview=local_preview,
+ model_parse_source=model_parse_source,
)
if self._is_dynamic_mode_stop_request(utterance):
parsed = self._apply_dynamic_mode_stop_request(parsed, utterance)
@@ -1305,6 +1667,7 @@ class SeatDemoService:
parsed = self._apply_mode_profiles(
parsed, matched_modes, context, vehicle_profile
)
+ parsed = self._mark_control_parse_source(parsed, "local_rule")
relative_resolution = (
self.native_can_relative_resolver.resolve(utterance, context)
if self.native_can_relative_resolver.matches(utterance)
@@ -1323,6 +1686,15 @@ class SeatDemoService:
relative_resolution.claimed_controls if relative_resolution else None
),
)
+ if local_preview:
+ parsed["ambiguities"] = []
+ parsed["unresolved_spans"] = []
+ parsed["model_requests"] = []
+ if excluded_control_signatures:
+ parsed = self._filter_excluded_controls(
+ parsed,
+ excluded_control_signatures,
+ )
parsed = apply_language_safety_guards(parsed, utterance)
parsed = self._partition_native_actions(
parsed,
@@ -1335,6 +1707,47 @@ class SeatDemoService:
)
),
)
+ result["parsed_control_signatures"] = [
+ list(self._control_signature(action))
+ for action in [
+ *parsed.get("actions", []),
+ *parsed.get("domain_actions", []),
+ *parsed.get("native_can_actions", []),
+ ]
+ ]
+ has_control = any(
+ parsed.get(key)
+ for key in ("actions", "domain_actions", "native_can_actions")
+ )
+ if allow_empty_control_result and not has_control:
+ result["provider_used"] = provider_used
+ result["decision_summary"] = [
+ *parsed.get("decision_summary", []),
+ "当前阶段没有新增的已验证 CAN 控制指令。",
+ ]
+ result["deferred_requests"] = parsed.get("deferred_requests", [])
+ result["model_requests"] = parsed.get("model_requests", [])
+ result["intent_ir"] = build_intent_ir(
+ parsed,
+ utterance,
+ request_id,
+ provider_used,
+ self.semantic_capability_metadata,
+ )
+ result["validation"] = self.contracts.validate(result["intent_ir"])
+ result["status"] = "no_action"
+ self._trace(
+ traces,
+ request_id,
+ "intent_parse",
+ "succeeded",
+ "当前阶段未产生新的白名单控制动作。",
+ started,
+ {"provider": provider_used, "control_count": 0},
+ domain="orchestration",
+ )
+ self._persist(result)
+ return result
if hardware_request and not control_plane_request:
if (
control_transport == WIRELESS_MQTT_TRANSPORT
@@ -1505,6 +1918,7 @@ class SeatDemoService:
simulation_fault=simulation_fault,
vehicle_profile=vehicle_profile,
control_transport=control_transport,
+ on_commands_validated=on_commands_validated,
)
intent_ir = build_intent_ir(
@@ -1776,6 +2190,8 @@ class SeatDemoService:
self._persist(result)
return result
+ self._emit_validated_commands(result, on_commands_validated)
+
safety_started = time.perf_counter()
self._trace(
traces,
@@ -3395,6 +3811,21 @@ class SeatDemoService:
result["deferred_requests"] = deferred_requests
return result
+ @staticmethod
+ def _emit_validated_commands(
+ result: dict[str, Any],
+ callback: Callable[[dict[str, Any]], None] | None,
+ ) -> None:
+ """Expose fully compiled commands before physical execution begins."""
+
+ if callback is None:
+ return
+ event = deepcopy(result)
+ event["status"] = "processing"
+ event["phase"] = "commands_validated"
+ event["execution_report"] = None
+ callback(event)
+
def _process_orchestrated(
self,
utterance: str,
@@ -3411,6 +3842,7 @@ class SeatDemoService:
simulation_fault: str = "normal",
vehicle_profile: VehicleProfile | None = None,
control_transport: str = WIRED_CAN_TRANSPORT,
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Run isolated adapters under the selected physical-bus policy."""
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
@@ -3669,6 +4101,8 @@ class SeatDemoService:
self._persist(result)
return result
+ self._emit_validated_commands(result, on_commands_validated)
+
self._trace(
traces,
request_id,
@@ -3842,7 +4276,7 @@ class SeatDemoService:
"execute",
"succeeded" if execution_succeeded and not report_issues else "failed",
(
- "无线 MQTT 白名单控制帧已发布;车辆实际状态以状态话题回读为准。"
+ "无线 MQTT 白名单控制帧已提交;车辆实际状态以状态话题回读为准。"
if (
hardware_request
and control_transport == WIRELESS_MQTT_TRANSPORT
@@ -4385,16 +4819,29 @@ class SeatDemoService:
)
return merged
- def _interpret(
+ @staticmethod
+ def _mark_control_parse_source(
+ parsed: dict[str, Any],
+ source: str,
+ *,
+ overwrite: bool = False,
+ ) -> dict[str, Any]:
+ """Attach per-control provenance without changing semantic content."""
+
+ marked = deepcopy(parsed)
+ for key in ("actions", "domain_actions", "native_can_actions"):
+ for action in marked.get(key, []):
+ if overwrite or not action.get("parse_source"):
+ action["parse_source"] = source
+ return marked
+
+ def _local_clause_results(
self,
utterance: str,
- provider: str,
context: dict[str, Any],
- vehicle_profile: VehicleProfile | None = None,
- *,
- reasoning_effort: str | None = None,
- ) -> tuple[dict[str, Any], str, str | None]:
- vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
+ ) -> list[tuple[str, dict[str, Any], str]]:
+ """Parse each clause deterministically and preserve inherited seat scope."""
+
clause_results: list[tuple[str, dict[str, Any], str]] = []
def parse_clause(clause: str) -> dict[str, Any]:
@@ -4413,7 +4860,7 @@ class SeatDemoService:
relative.claimed_controls if relative else None
),
)
- return local
+ return self._mark_control_parse_source(local, "local_rule")
def has_seat_domain(parsed: dict[str, Any]) -> bool:
if parsed.get("actions") or "seat" in parsed.get("detected_domains", []):
@@ -4441,12 +4888,6 @@ class SeatDemoService:
if clause_has_seat and explicit_driver != explicit_passenger:
inherited_seat_scope = "driver" if explicit_driver else "passenger"
elif clause_has_seat and inherited_seat_scope:
- # Chinese compound controls commonly state the seat once and
- # then list axes or thermal functions after commas. Carry the
- # last explicit seat scope only into later seat clauses; this
- # prevents a passenger request from silently falling through
- # to the CH1 driver default while leaving climate/door/window
- # clauses untouched.
scope_label = (
"主驾座椅"
if inherited_seat_scope == "driver"
@@ -4457,10 +4898,59 @@ class SeatDemoService:
clause_results.append(
(clause, local, self._local_clause_status(local, clause))
)
+ return clause_results
+
+ @classmethod
+ def _trusted_local_preview(
+ cls,
+ clause_results: list[tuple[str, dict[str, Any], str]],
+ ) -> dict[str, Any]:
+ """Keep deterministic controls while deferring incomplete text to GPT."""
+
+ trusted = cls._merge_parsed_results(
+ [parsed for _, parsed, status in clause_results if status != "unparsed"]
+ )
+ # Ambiguities and unresolved spans belong to the supplement phase. A
+ # deterministic action still goes through the normal schema, compiler,
+ # whitelist and physical execution gates before it can be sent.
+ trusted["ambiguities"] = []
+ trusted["unresolved_spans"] = []
+ trusted["model_requests"] = []
+ return trusted
+
+ @classmethod
+ def _filter_excluded_controls(
+ cls,
+ parsed: dict[str, Any],
+ excluded: set[tuple[Any, ...]],
+ ) -> dict[str, Any]:
+ filtered = deepcopy(parsed)
+ for key in ("actions", "domain_actions", "native_can_actions"):
+ filtered[key] = [
+ action
+ for action in filtered.get(key, [])
+ if cls._control_signature(action) not in excluded
+ ]
+ return filtered
+
+ def _interpret(
+ self,
+ utterance: str,
+ provider: str,
+ context: dict[str, Any],
+ vehicle_profile: VehicleProfile | None = None,
+ *,
+ reasoning_effort: str | None = None,
+ local_preview: bool = False,
+ model_parse_source: str = "cloud_model",
+ ) -> tuple[dict[str, Any], str, str | None]:
+ vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
+ clause_results = self._local_clause_results(utterance, context)
trusted_local = self._merge_parsed_results(
[parsed for _, parsed, status in clause_results if status != "unparsed"]
)
+ preview_local = self._trusted_local_preview(clause_results)
all_local = self._merge_parsed_results(
[parsed for _, parsed, _ in clause_results]
)
@@ -4474,7 +4964,13 @@ class SeatDemoService:
safe_fallback = trusted_local if trusted_controls else all_local
if provider == "local" or not fallback_clauses:
return (
- all_local if provider == "local" else trusted_local,
+ (
+ preview_local
+ if provider == "local" and local_preview
+ else all_local
+ if provider == "local"
+ else trusted_local
+ ),
"local_rules",
None,
)
@@ -4505,6 +5001,11 @@ class SeatDemoService:
vehicle_profile,
utterance=fallback_utterance,
)
+ model = self._mark_control_parse_source(
+ model,
+ model_parse_source,
+ overwrite=True,
+ )
return (
self._merge_local_and_model_intent(trusted_local, model),
f"local_rules+openai:{self.openai.model}",
diff --git a/src/cognitive_agent/infrastructure/can/mqtt_transport.py b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
index a8f8b26..336f2f2 100644
--- a/src/cognitive_agent/infrastructure/can/mqtt_transport.py
+++ b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
@@ -411,6 +411,10 @@ def mqtt_control_payload(
"data": frame.data.hex(" ").upper(),
"repeat": 1,
"intervalMs": 0,
+ # Vehicle state is consumed asynchronously from can/state by the
+ # Cognitive Agent. Do not serialize Android's command worker on a
+ # per-command response wait as well.
+ "expectResponse": False,
"responseTimeoutMs": bounded_timeout_ms,
},
ensure_ascii=False,
@@ -788,14 +792,19 @@ class MqttCanTransport:
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"MQTT CAN publish failed with code {result.rc}")
- if hasattr(result, "wait_for_publish"):
- published = result.wait_for_publish(timeout=2.0)
- if published is False:
- raise CanGatewayError("MQTT CAN publish confirmation timed out")
+ # Paho already accepted the payload into its network-loop queue when
+ # publish() returns rc == MQTT_ERR_SUCCESS. Waiting here serializes
+ # every repeated CAN frame and previously added up to two seconds per
+ # frame. Observe an already-available acknowledgement without ever
+ # blocking the control pipeline.
+ is_published = getattr(result, "is_published", None)
+ broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": self.settings.control_topic,
"qos": self.settings.qos,
"retain": False,
+ "delivery_status": "published" if broker_confirmed else "queued",
+ "message_id": getattr(result, "mid", None),
"payload": json.loads(raw_payload),
}
@@ -876,7 +885,7 @@ class MqttCanBus:
@property
def published_messages(self) -> tuple[dict[str, Any], ...]:
- """Return this bus view's confirmed MQTT publications in send order."""
+ """Return this bus view's accepted MQTT publications in send order."""
return tuple(deepcopy(self._published_messages))
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index bd1cbd6..03805c7 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -362,7 +362,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
if path.startswith("/api/mode-profiles/"):
self._handle_mode_profile_post(path)
return
- if path != "/api/requests":
+ supplement_match = re.fullmatch(
+ r"/api/requests/(?Preq-[a-z0-9-]+)/supplement",
+ path,
+ )
+ if (
+ path not in {"/api/requests", "/api/requests/preview"}
+ and not supplement_match
+ ):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
return
@@ -397,40 +404,82 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
payload = self._read_json_body()
utterance = str(payload.get("utterance", ""))
source = str(payload.get("client_type", "head_unit"))
- self.vehicle_display_sync.update_task(
- status="running",
- utterance=utterance,
- source=source,
+ if utterance:
+ self.vehicle_display_sync.update_task(
+ status="running",
+ utterance=utterance,
+ source=source,
+ )
+ hardware_client_id = (
+ str(payload["hardware_client_id"])
+ if payload.get("hardware_client_id")
+ else None
)
- result = self.service.process(
- utterance,
- str(payload.get("provider", "auto")),
- str(payload["conversation_id"]) if payload.get("conversation_id") else None,
- str(payload.get("execution_mode", "dry_run")),
+ reasoning_effort = (
+ str(payload["reasoning_effort"])
+ if payload.get("reasoning_effort") is not None
+ else None
+ )
+ common_arguments = {
+ "utterance": utterance,
+ "provider": str(payload.get("provider", "auto")),
+ "conversation_id": (
+ str(payload["conversation_id"])
+ if payload.get("conversation_id")
+ else None
+ ),
+ "execution_mode": str(
+ payload.get("execution_mode", "dry_run")
+ ),
# Hardware authorization is a server deployment policy. The
- # browser no longer renders or transmits an approval secret.
- approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
- vehicle_id=(
+ # browser never receives or transmits this secret.
+ "approval_token": os.getenv(
+ "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
+ ),
+ "vehicle_id": (
str(payload["vehicle_id"])
if payload.get("vehicle_id")
else None
),
- simulation_fault=str(payload.get("simulation_fault", "normal")),
- hardware_client_id=(
- str(payload["hardware_client_id"])
- if payload.get("hardware_client_id")
- else None
+ "simulation_fault": str(
+ payload.get("simulation_fault", "normal")
),
- hardware_access_bypass=self._is_unproxied_loopback_request(),
- reasoning_effort=(
- str(payload["reasoning_effort"])
- if payload.get("reasoning_effort") is not None
- else None
- ),
- control_transport=str(
+ "hardware_client_id": hardware_client_id,
+ "hardware_access_bypass": self._is_unproxied_loopback_request(),
+ "reasoning_effort": reasoning_effort,
+ "control_transport": str(
payload.get("control_transport", "wired_can")
),
- )
+ }
+ if "application/x-ndjson" in self.headers.get("Accept", ""):
+ self._stream_control_request(
+ path,
+ supplement_match,
+ payload,
+ common_arguments,
+ source=source,
+ )
+ return
+ if path == "/api/requests/preview":
+ result = self.service.preview_native_can(**common_arguments)
+ elif supplement_match:
+ result = self.service.supplement_native_can(
+ supplement_match.group("request_id"),
+ provider=(
+ str(payload["provider"])
+ if payload.get("provider")
+ else None
+ ),
+ approval_token=os.getenv(
+ "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
+ ),
+ hardware_client_id=hardware_client_id,
+ hardware_access_bypass=self._is_unproxied_loopback_request(),
+ reasoning_effort=reasoning_effort,
+ )
+ else:
+ result = self.service.process(**common_arguments)
+ utterance = str(result.get("utterance") or utterance)
self.vehicle_display_sync.update_task(
status=str(result.get("status", "succeeded")),
utterance=utterance,
@@ -489,6 +538,146 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.INTERNAL_SERVER_ERROR,
)
+ def _stream_control_request(
+ self,
+ path: str,
+ supplement_match: re.Match[str] | None,
+ payload: dict[str, object],
+ common_arguments: dict[str, object],
+ *,
+ source: str,
+ ) -> None:
+ """Stream validated command plans before physical execution finishes."""
+
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("X-Accel-Buffering", "no")
+ self._send_security_headers()
+ self.end_headers()
+ stream_open = True
+
+ def emit(event: dict[str, object]) -> None:
+ nonlocal stream_open
+ if not stream_open:
+ return
+ content = (json.dumps(event, ensure_ascii=False) + "\n").encode(
+ "utf-8"
+ )
+ try:
+ self.wfile.write(content)
+ self.wfile.flush()
+ except (BrokenPipeError, ConnectionResetError):
+ # The submitted control request must continue even if the
+ # browser closes while the physical execution is in progress.
+ stream_open = False
+
+ def on_commands_validated(result: dict[str, object]) -> None:
+ emit({"type": "validated_commands", "payload": result})
+
+ utterance = str(common_arguments.get("utterance") or "")
+ try:
+ if path == "/api/requests/preview":
+ result = self.service.preview_native_can(
+ **common_arguments,
+ on_commands_validated=on_commands_validated,
+ )
+ elif supplement_match:
+ result = self.service.supplement_native_can(
+ supplement_match.group("request_id"),
+ provider=(
+ str(payload["provider"])
+ if payload.get("provider")
+ else None
+ ),
+ approval_token=os.getenv(
+ "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
+ ),
+ hardware_client_id=(
+ str(payload["hardware_client_id"])
+ if payload.get("hardware_client_id")
+ else None
+ ),
+ hardware_access_bypass=self._is_unproxied_loopback_request(),
+ reasoning_effort=(
+ str(payload["reasoning_effort"])
+ if payload.get("reasoning_effort") is not None
+ else None
+ ),
+ on_commands_validated=on_commands_validated,
+ )
+ else:
+ result = self.service.process(
+ **common_arguments,
+ _on_commands_validated=on_commands_validated,
+ )
+ utterance = str(result.get("utterance") or utterance)
+ self.vehicle_display_sync.update_task(
+ status=str(result.get("status", "succeeded")),
+ utterance=utterance,
+ source=source,
+ result=result,
+ )
+ emit({"type": "result", "payload": result})
+ except HardwareAccessRequiredError as exc:
+ self.vehicle_display_sync.update_task(
+ status="failed",
+ utterance=utterance,
+ source=source,
+ error={"code": "hardware_access_required", "message": str(exc)},
+ )
+ emit({
+ "type": "error",
+ "error": "hardware_access_required",
+ "message": str(exc),
+ "access_state": exc.state,
+ })
+ except HardwareBusyError as exc:
+ self.vehicle_display_sync.update_task(
+ status="failed",
+ utterance=utterance,
+ source=source,
+ error={"code": "hardware_busy", "message": str(exc)},
+ )
+ emit({
+ "type": "error",
+ "error": "hardware_busy",
+ "message": str(exc),
+ "retry_after_seconds": exc.retry_after_seconds,
+ })
+ except HardwareSafetyError as exc:
+ self.vehicle_display_sync.update_task(
+ status="failed",
+ utterance=utterance,
+ source=source,
+ error={"code": "hardware_safety_gate", "message": str(exc)},
+ )
+ emit({
+ "type": "error",
+ "error": "hardware_safety_gate",
+ "message": str(exc),
+ })
+ except (ValueError, json.JSONDecodeError) as exc:
+ emit({
+ "type": "error",
+ "error": "invalid_request",
+ "message": str(exc),
+ })
+ except OpenAIIntentError as exc:
+ self.log_error("OpenAI intent request failed: %s", exc)
+ emit({
+ "type": "error",
+ "error": "openai_request_failed",
+ "message": "GPT 意图解析失败,请检查 API Key、模型权限或网络状态。",
+ })
+ except Exception as exc: # noqa: BLE001 - stream a terminal event
+ self.log_error("streaming request failed: %s", exc)
+ emit({
+ "type": "error",
+ "error": "internal_error",
+ "message": "请求处理失败,请查看服务端日志。",
+ })
+
def do_DELETE(self) -> None: # noqa: N802 - stdlib handler API
parsed_url = urlparse(self.path)
path = unquote(parsed_url.path)
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index b073e08..e08bb3c 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -332,18 +332,18 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
- with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-101", timeout=3) as response:
+ with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-103", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
) as response:
gesture_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/console-v2.js?v=sync-layout-41", timeout=3
+ f"{self.base_url}/console-v2.js?v=sync-layout-43", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/console-v2.css?v=sync-layout-40", timeout=3
+ f"{self.base_url}/console-v2.css?v=sync-layout-41", timeout=3
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
@@ -378,12 +378,12 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
- self.assertIn('src="/app.js?v=dual-seat-101"', html)
+ self.assertIn('src="/app.js?v=dual-seat-103"', html)
self.assertIn('href="/styles.css?v=dual-seat-16"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
- self.assertIn('src="/console-v2.js?v=sync-layout-41"', html)
- self.assertIn('href="/console-v2.css?v=sync-layout-40"', html)
+ self.assertIn('src="/console-v2.js?v=sync-layout-43"', html)
+ self.assertIn('href="/console-v2.css?v=sync-layout-41"', html)
self.assertIn('', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
@@ -612,7 +612,7 @@ operations:
self.assertIn('client_type: "web_console"', app_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
- self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v202"', service_worker)
+ self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v204"', service_worker)
self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker)
self.assertIn(".then(() => self.skipWaiting())", service_worker)
self.assertNotIn("const cached = await caches.match(request)", service_worker)
@@ -852,7 +852,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/app.js?v=dual-seat-101", timeout=3
+ f"{self.base_url}/app.js?v=dual-seat-103", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -1314,6 +1314,134 @@ operations:
self.assertEqual(result["provider_used"], "local_rules_fallback")
self.assertEqual(result["execution_report"]["spec"]["mode"], "dry_run")
+ def test_two_stage_http_api_returns_local_commands_before_cloud_supplement(
+ self,
+ ) -> None:
+ original_api_key = self.service.openai.api_key
+ self.service.openai.api_key = "two-stage-http-test-key"
+ preview_request = urllib.request.Request(
+ f"{self.base_url}/api/requests/preview",
+ method="POST",
+ headers={"Content-Type": "application/json"},
+ data=json.dumps(
+ {
+ "utterance": "空调温度设置为24度,播放音乐",
+ "provider": "openai",
+ "conversation_id": "conv-e2e-two-stage-http",
+ "execution_mode": "dry_run",
+ "reasoning_effort": "medium",
+ "client_type": "web_console",
+ }
+ ).encode("utf-8"),
+ )
+ model_output = {
+ "detected_domains": ["ambient_light"],
+ "requests": [
+ {
+ "domain": "ambient_light",
+ "capability": "ambient_light.set_power",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "duration_ms": None,
+ "parameters": [{"name": "state", "value": "on"}],
+ "reason": "补充播放场景氛围灯。",
+ }
+ ],
+ "ambiguities": [],
+ "decision_summary": ["仅补充本地未解析片段。"],
+ "confidence": 0.9,
+ }
+ try:
+ with patch.object(
+ self.service.openai,
+ "interpret",
+ side_effect=AssertionError("preview must not invoke GPT"),
+ ):
+ with self.opener.open(preview_request, timeout=3) as response:
+ preview = json.loads(response.read().decode("utf-8"))
+ self.assertEqual(response.status, 201)
+ local_operation = next(
+ operation
+ for config in preview["function_configs"].values()
+ for operation in config["spec"]["operations"]
+ )
+ self.assertEqual(local_operation["parse_source"], "local_rule")
+ self.assertTrue(preview["supplement_required"])
+
+ supplement_request = urllib.request.Request(
+ f"{self.base_url}/api/requests/{preview['request_id']}/supplement",
+ method="POST",
+ headers={"Content-Type": "application/json"},
+ data=json.dumps(
+ {
+ "provider": "openai",
+ "reasoning_effort": "medium",
+ "client_type": "web_console",
+ }
+ ).encode("utf-8"),
+ )
+ with patch.object(
+ self.service.openai,
+ "interpret",
+ return_value=model_output,
+ ) as interpret:
+ with self.opener.open(supplement_request, timeout=3) as response:
+ result = json.loads(response.read().decode("utf-8"))
+ self.assertEqual(response.status, 201)
+ self.assertEqual(interpret.call_args.args[0], "播放音乐")
+ finally:
+ self.service.openai.api_key = original_api_key
+
+ self.assertEqual(result["phase"], "two_stage_complete")
+ sources = {
+ operation["capability"]: operation["parse_source"]
+ for config in result["function_configs"].values()
+ for operation in config["spec"]["operations"]
+ }
+ self.assertEqual(sources["climate.set_temperature"], "local_rule")
+ self.assertEqual(sources["ambient_light.set_power"], "cloud_supplement")
+
+ def test_preview_streams_validated_commands_before_terminal_result(self) -> None:
+ request = urllib.request.Request(
+ f"{self.base_url}/api/requests/preview",
+ method="POST",
+ headers={
+ "Content-Type": "application/json",
+ "Accept": "application/x-ndjson",
+ },
+ data=json.dumps(
+ {
+ "utterance": "空调24度、风量2档",
+ "provider": "local",
+ "conversation_id": "conv-e2e-command-stream",
+ "execution_mode": "dry_run",
+ "client_type": "web_console",
+ },
+ ensure_ascii=False,
+ ).encode("utf-8"),
+ )
+
+ with self.opener.open(request, timeout=3) as response:
+ events = [
+ json.loads(line)
+ for line in response.read().decode("utf-8").splitlines()
+ if line.strip()
+ ]
+
+ self.assertEqual(
+ response.headers.get_content_type(),
+ "application/x-ndjson",
+ )
+ self.assertEqual(
+ [event["type"] for event in events],
+ ["validated_commands", "result"],
+ )
+ validated = events[0]["payload"]
+ self.assertEqual(validated["status"], "processing")
+ self.assertIsNone(validated["execution_report"])
+ self.assertTrue(validated["command_plans"])
+ self.assertEqual(events[1]["payload"]["status"], "succeeded")
+
def test_frontend_suite_covers_every_physical_seat_capability(self) -> None:
suite_path = (
REPOSITORY_ROOT
diff --git a/tests/unit/test_can1_semantic_whitelist.py b/tests/unit/test_can1_semantic_whitelist.py
new file mode 100644
index 0000000..97d8052
--- /dev/null
+++ b/tests/unit/test_can1_semantic_whitelist.py
@@ -0,0 +1,351 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from cognitive_agent.application.native_can_intent import (
+ parse_exact_native_can_command,
+)
+from cognitive_agent.application.native_can_runtime import (
+ NativeCanCompileError,
+ NativeCanRuntime,
+)
+from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
+
+
+HARDWARE_DISABLED_ENV = {
+ "COGNITIVE_AGENT_CH1_HIL_ENABLED": "false",
+ "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false",
+ "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false",
+}
+
+
+class Can1SemanticWhitelistTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.temporary_directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary_directory.cleanup)
+ temporary_root = Path(self.temporary_directory.name)
+ memory_path = temporary_root / "driver-seat-memory.json"
+ memory_path.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "positions": {
+ str(slot): {
+ "payload_15": "15 00 00 00 00 00 00 00",
+ "payload_16": "16 00 00 00 00 00 00 00",
+ }
+ for slot in (1, 2, 3)
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ self.environment = patch.dict(
+ os.environ,
+ {
+ **HARDWARE_DISABLED_ENV,
+ "COGNITIVE_AGENT_DRIVER_SEAT_MEMORY_PATH": str(memory_path),
+ },
+ clear=False,
+ )
+ self.environment.start()
+ self.addCleanup(self.environment.stop)
+ self.runtime = NativeCanRuntime(REPOSITORY_ROOT)
+ self.service = SeatDemoService(REPOSITORY_ROOT)
+ self.service.artifact_root = temporary_root / "artifacts"
+
+ @staticmethod
+ def _valid_exact_commands() -> list[str]:
+ commands = [f"door rear {state}" for state in ("lock", "unlock")]
+ commands.extend(
+ f"window {target} {motion}"
+ for target in (
+ "all",
+ "front",
+ "rear",
+ "front-left",
+ "front-right",
+ "rear-left",
+ "rear-right",
+ )
+ for motion in ("open", "close", "stop")
+ )
+ commands.extend(
+ f"window {target} position {position}"
+ for target in (
+ "front-left",
+ "front-right",
+ "rear-left",
+ "rear-right",
+ )
+ for position in (0, 100)
+ )
+ commands.extend(("ac on", "ac off"))
+ commands.extend(f"ac fan {level}" for level in range(1, 8))
+ commands.extend(
+ f"ac mode {mode}"
+ for mode in (
+ "face",
+ "face-feet",
+ "feet",
+ "feet-defrost",
+ "defrost",
+ "face-feet-defrost",
+ "face-defrost",
+ )
+ )
+ commands.extend(
+ f"ac temp {target} {temperature}"
+ for target in ("driver", "passenger", "rear")
+ for temperature in (18, 32)
+ )
+ commands.extend(("ac control manual", "ac control auto"))
+ commands.extend(
+ f"outlet {target} percent {horizontal} {vertical}"
+ for target in (
+ "driver-left",
+ "driver-right",
+ "passenger-left",
+ "passenger-right",
+ )
+ for horizontal, vertical in ((0, 0), (100, 100))
+ )
+ commands.extend(
+ (
+ "outlet driver-left position -25 -40",
+ "outlet driver-right position 30 30",
+ "outlet passenger-left position -10 -40",
+ "outlet passenger-right position 5 30",
+ )
+ )
+ commands.extend(
+ f"seat driver {feature} {level}"
+ for feature in ("vent", "heat")
+ for level in ("off", "low", "high")
+ )
+ commands.append("seat driver massage off")
+ commands.extend(
+ f"seat driver massage strength {strength}"
+ for strength in ("weak", "medium", "strong")
+ )
+ commands.extend(
+ f"seat driver massage mode {mode}"
+ for mode in ("wave", "pulse", "stretch", "slack", "relax")
+ )
+ commands.extend(
+ (
+ "seat driver horizontal forward 100",
+ "seat driver horizontal backward 2000",
+ "seat driver horizontal stop",
+ "seat driver height up 100",
+ "seat driver height down 2000",
+ "seat driver height stop",
+ "seat driver backrest forward 100",
+ "seat driver backrest backward 2000",
+ "seat driver backrest stop",
+ "seat driver basin up 100",
+ "seat driver basin down 2000",
+ "seat driver basin stop",
+ )
+ )
+ commands.extend(f"seat driver position {slot}" for slot in (1, 2, 3))
+ commands.extend(
+ f"seat driver memory recall {slot}" for slot in (1, 2, 3)
+ )
+ commands.extend(f"light indoor {state}" for state in ("on", "off"))
+ commands.extend(
+ f"light reading all {state}" for state in ("on", "off")
+ )
+ commands.extend(f"light ambient {state}" for state in ("on", "off"))
+ commands.extend(f"light ambient color {value}" for value in (1, 127))
+ commands.extend(
+ f"light ambient brightness {value}" for value in (1, 5)
+ )
+ commands.extend(
+ f"shade {motion}" for motion in ("open", "close", "half", "stop")
+ )
+ commands.extend(
+ f"shade position {position}" for position in (0, 1, 50, 99, 100)
+ )
+ return commands
+
+ def _compile_exact(self, command: str) -> dict:
+ actions = parse_exact_native_can_command(command)
+ self.assertEqual(len(actions), 1, command)
+ action = actions[0]
+ config = self.runtime.build_function_config(
+ action["domain"],
+ actions,
+ "req-can-one-whitelist",
+ "local_rules",
+ )
+ return self.runtime.compile(config)
+
+ @staticmethod
+ def _debug_commands(result: dict) -> list[str]:
+ return [
+ command["signals"]["debug_command"]
+ for plan in result.get("command_plans", {}).values()
+ for command in plan["spec"]["commands"]
+ ]
+
+ def test_every_documented_exact_command_parses_and_compiles(self) -> None:
+ commands = self._valid_exact_commands()
+ self.assertEqual(len(commands), 119)
+
+ for command in commands:
+ with self.subTest(command=command):
+ plan = self._compile_exact(command)
+ self.assertEqual(
+ [
+ item["signals"]["debug_command"]
+ for item in plan["spec"]["commands"]
+ ],
+ [command],
+ )
+
+ def test_out_of_whitelist_exact_values_are_rejected(self) -> None:
+ commands = (
+ "door rear open",
+ "window vehicle open",
+ "window all position 50",
+ "window front-left position -1",
+ "window front-left position 101",
+ "ac fan 0",
+ "ac fan 8",
+ "ac mode auto",
+ "ac temp driver 17",
+ "ac temp driver 33",
+ "ac temp driver 24.5",
+ "ac control off",
+ "outlet driver-left percent -1 50",
+ "outlet driver-left percent 101 50",
+ "seat driver vent medium",
+ "seat driver horizontal forward 99",
+ "seat driver horizontal forward 2001",
+ "seat driver horizontal stop 500",
+ "seat driver position 0",
+ "seat driver position 4",
+ "light ambient color 0",
+ "light ambient color 128",
+ "light ambient brightness 0",
+ "light ambient brightness 6",
+ "shade position -1",
+ "shade position 101",
+ )
+ for command in commands:
+ with self.subTest(command=command):
+ self.assertEqual(parse_exact_native_can_command(command), [])
+
+ def test_calibrated_outlet_values_must_fit_the_selected_outlet(self) -> None:
+ action = parse_exact_native_can_command(
+ "outlet driver-left position -26 -40"
+ )[0]
+ config = self.runtime.build_function_config(
+ action["domain"],
+ [action],
+ "req-can-one-calibration",
+ "local_rules",
+ )
+
+ with self.assertRaisesRegex(
+ NativeCanCompileError, "horizontal_value exceeds the calibrated range"
+ ):
+ self.runtime.compile(config)
+
+ def test_representative_chinese_commands_cover_every_whitelist_family(self) -> None:
+ cases = (
+ ("后排车门上锁", ["door rear lock"]),
+ ("后排门锁解锁", ["door rear unlock"]),
+ ("打开全部车窗", ["window all open"]),
+ ("关闭前排车窗", ["window front close"]),
+ ("停止后排车窗", ["window rear stop"]),
+ ("把副驾车窗开到35%", ["window front-right position 35"]),
+ ("打开空调", ["ac on"]),
+ ("关闭空调", ["ac off"]),
+ ("风量调到7档", ["ac fan 7"]),
+ ("空调出风模式设为吹脸", ["ac mode face"]),
+ ("空调出风模式设为吹面和吹脚", ["ac mode face-feet"]),
+ ("空调出风模式设为吹脚", ["ac mode feet"]),
+ ("空调出风模式设为吹脚除霜", ["ac mode feet-defrost"]),
+ ("空调出风模式设为除霜", ["ac mode defrost"]),
+ ("空调出风模式设为吹脸和吹脚除霜", ["ac mode face-feet-defrost"]),
+ ("空调出风模式设为吹脸除霜", ["ac mode face-defrost"]),
+ ("主驾空调温度设为18度", ["ac temp driver 18"]),
+ ("副驾空调温度设为32度", ["ac temp passenger 32"]),
+ ("后排空调温度设为24度", ["ac temp rear 24"]),
+ ("空调设置为自动控制", ["ac control auto"]),
+ ("空调设置为手动控制", ["ac control manual"]),
+ ("主驾左风口水平25%上下75%", ["outlet driver-left percent 25 75"]),
+ ("副驾右风口左右25%垂直75%", ["outlet passenger-right percent 25 75"]),
+ ("主驾左风口标定坐标左右-25上下-40", ["outlet driver-left position -25 -40"]),
+ ("打开主驾座椅通风", ["seat driver vent low"]),
+ ("关闭主驾座椅加热", ["seat driver heat off"]),
+ ("主驾座椅加热高档", ["seat driver heat high"]),
+ ("关闭主驾座椅按摩", ["seat driver massage off"]),
+ ("主驾座椅按摩强度调为中等", ["seat driver massage strength medium"]),
+ ("主驾座椅按摩模式设为放松", ["seat driver massage mode relax"]),
+ ("主驾座椅向前移动500毫秒", ["seat driver horizontal forward 500"]),
+ ("主驾座椅高度向下调500毫秒", ["seat driver height down 500"]),
+ ("主驾座椅靠背后仰800毫秒", ["seat driver backrest backward 800"]),
+ ("把主驾座椅坐盆抬高300毫秒", ["seat driver basin up 300"]),
+ ("调用主驾座椅记忆位置2", ["seat driver position 2"]),
+ ("打开室内灯", ["light indoor on"]),
+ ("关闭全部阅读灯", ["light reading all off"]),
+ ("打开氛围灯", ["light ambient on"]),
+ ("关闭氛围灯", ["light ambient off"]),
+ ("把氛围灯颜色编号调到70", ["light ambient color 70"]),
+ ("氛围灯亮度调整为3档", ["light ambient brightness 3"]),
+ ("打开遮阳帘", ["shade open"]),
+ ("关闭遮阳帘", ["shade close"]),
+ ("遮阳帘半开", ["shade half"]),
+ ("停止遮阳帘", ["shade stop"]),
+ ("遮阳帘开到20%", ["shade position 20"]),
+ ("遮阳帘打开30%", ["shade position 30"]),
+ )
+ for index, (utterance, expected) in enumerate(cases):
+ with self.subTest(utterance=utterance):
+ result = self.service.process(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-can-one-natural-{index}",
+ execution_mode="dry_run",
+ )
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(self._debug_commands(result), expected)
+
+ rear_context = self.service.conversations.snapshot(
+ "conv-can-one-natural-18"
+ )
+ self.assertEqual(
+ rear_context["domain_devices"]["climate"]["zones"]["rear_row"][
+ "temperature_c"
+ ],
+ 24,
+ )
+
+ def test_fractional_values_are_not_silently_truncated(self) -> None:
+ utterances = (
+ "空调温度设为24.5度",
+ "风量设为2.5档",
+ "氛围灯颜色编号调到70.5",
+ "氛围灯亮度调到3.5档",
+ )
+ for index, utterance in enumerate(utterances):
+ with self.subTest(utterance=utterance):
+ result = self.service.process(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-can-one-fraction-{index}",
+ execution_mode="dry_run",
+ )
+ self.assertEqual(self._debug_commands(result), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_ch1_default_targets.py b/tests/unit/test_ch1_default_targets.py
index 3e4a49e..22b2a2d 100644
--- a/tests/unit/test_ch1_default_targets.py
+++ b/tests/unit/test_ch1_default_targets.py
@@ -154,6 +154,44 @@ class Ch1DefaultTargetTests(unittest.TestCase):
"front_right",
)
+ def test_driver_and_passenger_window_aliases_map_to_the_correct_side(self) -> None:
+ cases = (
+ ("打开主驾车窗", "front_left", "window.set_motion", {"motion": "open"}),
+ ("打开主驾驶的车窗", "front_left", "window.set_motion", {"motion": "open"}),
+ ("打开副驾车窗", "front_right", "window.set_motion", {"motion": "open"}),
+ ("打开副驾驶的车窗", "front_right", "window.set_motion", {"motion": "open"}),
+ ("把副驾车窗开到35%", "front_right", "window.set_position", {"position_pct": 35}),
+ )
+ for index, (utterance, target, capability, parameters) in enumerate(cases):
+ with self.subTest(utterance=utterance):
+ result = self.service.process(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-window-occupant-alias-{index}",
+ execution_mode="dry_run",
+ )
+
+ self.assertEqual(result["status"], "succeeded")
+ self.assertEqual(set(result["function_configs"]), {"native_window"})
+ operation = result["function_configs"]["native_window"]["spec"][
+ "operations"
+ ][0]
+ self.assertEqual(operation["target"]["zone"], target)
+ self.assertEqual(operation["capability"], capability)
+ self.assertEqual(operation["parameters"], parameters)
+
+ def test_window_target_does_not_leak_from_a_later_seat_subject(self) -> None:
+ parsed = merge_native_can_intent({}, "打开副驾车窗和主驾座椅通风")
+ windows = [
+ action
+ for action in parsed["native_can_actions"]
+ if action["capability"] == "window.set_motion"
+ ]
+
+ self.assertEqual(len(windows), 1)
+ self.assertEqual(windows[0]["target"], "front_right")
+ self.assertEqual(windows[0]["parameters"], {"motion": "open"})
+
def test_group_or_non_driver_seat_scope_is_not_treated_as_unscoped(self) -> None:
for index, utterance in enumerate(
("打开前排座椅加热", "打开后排座椅加热", "打开所有座椅加热")
diff --git a/tests/unit/test_driver_seat_native_can.py b/tests/unit/test_driver_seat_native_can.py
index 4ec28a7..b783f40 100644
--- a/tests/unit/test_driver_seat_native_can.py
+++ b/tests/unit/test_driver_seat_native_can.py
@@ -211,9 +211,17 @@ class DriverSeatNativeCanTests(unittest.TestCase):
{"axis": "height", "direction": "up", "duration_ms": 500},
),
"主驾座椅调整到记忆位置3": (
- "seat.recall_driver_memory_native",
+ "seat.set_driver_position_native",
{"slot": 3},
),
+ "调用主驾座椅记忆位置2": (
+ "seat.set_driver_position_native",
+ {"slot": 2},
+ ),
+ "恢复主驾座椅到二号记忆位置": (
+ "seat.set_driver_position_native",
+ {"slot": 2},
+ ),
}
for utterance, expected in cases.items():
with self.subTest(utterance=utterance):
diff --git a/tests/unit/test_mqtt_can_transport.py b/tests/unit/test_mqtt_can_transport.py
index 5a96b68..3774d33 100644
--- a/tests/unit/test_mqtt_can_transport.py
+++ b/tests/unit/test_mqtt_can_transport.py
@@ -27,9 +27,13 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
class _PublishResult:
rc = 0
+ mid = 17
+
+ def is_published(self) -> bool:
+ return True
def wait_for_publish(self, timeout: float) -> bool:
- return timeout > 0
+ raise AssertionError("control publish must never block for acknowledgement")
class _FakeMqttClient:
@@ -571,11 +575,14 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertEqual(payload["data"], "10 00 00 00 00 00 00 00")
self.assertEqual(payload["repeat"], 1)
self.assertEqual(payload["intervalMs"], 0)
+ self.assertFalse(payload["expectResponse"])
self.assertEqual(payload["responseTimeoutMs"], 100)
publication = bus.published_messages[0]
self.assertEqual(publication["topic"], topic)
self.assertEqual(publication["qos"], qos)
self.assertEqual(publication["retain"], retain)
+ self.assertEqual(publication["delivery_status"], "published")
+ self.assertEqual(publication["message_id"], 17)
self.assertEqual(publication["payload"], payload)
self.assertTrue(
{
@@ -606,6 +613,40 @@ class MqttCanTransportTests(unittest.TestCase):
self.assertEqual(len(self.client.published), 2)
self.assertEqual(len(bus.published_messages), 2)
+ def test_publish_returns_queued_without_waiting_for_broker_ack(self) -> None:
+ class QueuedPublishResult:
+ rc = 0
+ mid = 29
+
+ @staticmethod
+ def is_published() -> bool:
+ return False
+
+ @staticmethod
+ def wait_for_publish(timeout: float) -> bool:
+ raise AssertionError(
+ f"wait_for_publish({timeout}) must not be called"
+ )
+
+ with patch.object(
+ self.client,
+ "publish",
+ return_value=QueuedPublishResult(),
+ ):
+ publication = self.transport.publish(
+ CanFrame(
+ arbitration_id=0x431,
+ data=bytes.fromhex("10 00 00 00 00 00 00 00"),
+ channel=1,
+ is_fd=True,
+ bitrate_switch=True,
+ ),
+ command="shade position 20",
+ )
+
+ self.assertEqual(publication["delivery_status"], "queued")
+ self.assertEqual(publication["message_id"], 29)
+
def test_can1_whitelist_publishes_rear_lock_candidate_with_debug_command(self) -> None:
bus = self.transport.bus(channel=1)
bus.set_debug_command("door rear lock")
@@ -717,6 +758,96 @@ class MqttCanTransportTests(unittest.TestCase):
)
)
+ def test_wireless_fixed_controls_and_shade_preset_never_wait_for_state(
+ self,
+ ) -> None:
+ service = SeatDemoService(REPOSITORY_ROOT)
+ service._vehicle_mqtt_transport = self.transport
+ service.vehicle_mqtt_settings = SimpleNamespace(feedback_timeout_ms=1_500)
+ runtime = NativeCanRuntime(REPOSITORY_ROOT)
+ actions_by_domain = {
+ "climate": [
+ {
+ "domain": "climate",
+ "capability": "climate.set_temperature",
+ "target": "driver",
+ "operation": "set",
+ "parameters": {"temperature_c": 24},
+ },
+ {
+ "domain": "climate",
+ "capability": "climate.set_fan_level",
+ "target": "vehicle",
+ "operation": "set",
+ "parameters": {"level": 2},
+ },
+ ],
+ "roof": [
+ {
+ "domain": "roof",
+ "capability": "roof.set_shade_motion",
+ "target": "vehicle",
+ "operation": "set",
+ "parameters": {"motion": "open"},
+ }
+ ],
+ "ambient_light": [
+ {
+ "domain": "ambient_light",
+ "capability": "ambient_light.set_power",
+ "target": "vehicle",
+ "operation": "set",
+ "parameters": {"state": "on"},
+ }
+ ],
+ }
+ configs = {
+ f"native_{domain}": runtime.build_function_config(
+ domain,
+ actions,
+ f"req-wireless-no-wait-{domain}",
+ "local_rules",
+ )
+ for domain, actions in actions_by_domain.items()
+ }
+ plans = {key: runtime.compile(config) for key, config in configs.items()}
+
+ with (
+ patch.object(
+ self.transport,
+ "_receive_after",
+ side_effect=AssertionError(
+ "wireless fixed commands must not wait on can/state"
+ ),
+ ) as receive_after,
+ patch("cognitive_agent.application.native_can_runtime.time.sleep"),
+ ):
+ reports = service._execute_orchestrated_reports(
+ configs,
+ plans,
+ set(configs),
+ {},
+ hardware_request=True,
+ simulated_hardware_request=False,
+ simulation_fault="normal",
+ control_transport="wireless_mqtt",
+ )
+
+ receive_after.assert_not_called()
+ self.assertEqual(len(self.client.published), 10)
+ self.assertTrue(
+ all(
+ report["spec"]["status"] == "succeeded"
+ for report in reports.values()
+ )
+ )
+ self.assertTrue(
+ all(
+ json.loads(payload)["expectResponse"] is False
+ for _topic, payload, _qos, _retain in self.client.published
+ )
+ )
+
def test_model_added_nonwhitelisted_action_does_not_block_whitelisted_publish(
self,
) -> None:
diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py
index 0973fa3..c9e217c 100644
--- a/tests/unit/test_requested_frontend_enhancements.py
+++ b/tests/unit/test_requested_frontend_enhancements.py
@@ -261,6 +261,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"capability": "seat.set_backrest_recline",
"target": {"zone": "front_right"},
"parameters": {"position_pct": 36, "angle_deg": 120},
+ "parse_source": "local_rule",
},
]
}
@@ -272,6 +273,7 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"capability": "climate.set_temperature",
"target": {"zone": "driver"},
"parameters": {"temperature_c": 24},
+ "parse_source": "cloud_supplement",
}
]
}
@@ -300,11 +302,13 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"category": "副驾座椅靠背调节",
"command": "seat passenger backrest position 36",
"frame": "0x6A0 · C7 64 02 68 01 00 00",
+ "parseSource": "local_rule",
},
{
"channel": "CAN1",
"category": "空调温度",
"command": "ac temp driver 24",
+ "parseSource": "cloud_supplement",
},
]
diff --git a/tests/unit/test_two_stage_can_parsing.py b/tests/unit/test_two_stage_can_parsing.py
new file mode 100644
index 0000000..3838f5e
--- /dev/null
+++ b/tests/unit/test_two_stage_can_parsing.py
@@ -0,0 +1,192 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+from cognitive_agent.application.seat_service import SeatDemoService
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def _service(tmp_path: Path) -> SeatDemoService:
+ service = SeatDemoService(ROOT)
+ service.artifact_root = tmp_path / "artifacts"
+ service.openai.api_key = "offline-two-stage-test-key"
+ return service
+
+
+def _operations(result: dict) -> list[dict]:
+ return [
+ operation
+ for config in result.get("function_configs", {}).values()
+ for operation in config.get("spec", {}).get("operations", [])
+ ]
+
+
+def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path) -> None:
+ service = _service(tmp_path)
+ validated_events: list[dict] = []
+
+ with patch.object(
+ service.openai,
+ "interpret",
+ side_effect=AssertionError("preview must not call hosted parsing"),
+ ) as interpret:
+ result = service.preview_native_can(
+ "空调温度设置为24度,风量设置为2档,播放音乐",
+ provider="openai",
+ conversation_id="conv-two-stage-local-preview",
+ execution_mode="dry_run",
+ on_commands_validated=validated_events.append,
+ )
+
+ interpret.assert_not_called()
+ assert result["phase"] == "deterministic_preview"
+ assert result["status"] == "succeeded"
+ assert result["supplement_required"] is True
+ assert result["unresolved_clauses"] == ["播放音乐"]
+ assert [item["capability"] for item in _operations(result)] == [
+ "climate.set_temperature",
+ "climate.set_fan_level",
+ ]
+ assert {item["parse_source"] for item in _operations(result)} == {
+ "local_rule"
+ }
+ assert len(validated_events) == 1
+ assert validated_events[0]["phase"] == "deterministic_commands_validated"
+ assert validated_events[0]["status"] == "processing"
+ assert validated_events[0]["execution_report"] is None
+ assert _operations(validated_events[0]) == _operations(result)
+ assert validated_events[0]["command_plans"]
+
+
+def test_fully_local_cabin_request_streams_all_commands_without_supplement(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ validated_events: list[dict] = []
+
+ with patch.object(
+ service.openai,
+ "interpret",
+ side_effect=AssertionError("fully local request must not call GPT"),
+ ):
+ result = service.preview_native_can(
+ "空调24度、风量2档,遮阳帘打开30%,打开氛围灯",
+ provider="openai",
+ conversation_id="conv-two-stage-all-local",
+ execution_mode="dry_run",
+ on_commands_validated=validated_events.append,
+ )
+
+ operations = _operations(result)
+ assert result["supplement_required"] is False
+ assert [item["capability"] for item in operations] == [
+ "climate.set_temperature",
+ "climate.set_fan_level",
+ "roof.set_shade_position",
+ "ambient_light.set_power",
+ ]
+ assert operations[2]["parameters"]["position_pct"] == 30
+ assert {item["parse_source"] for item in operations} == {"local_rule"}
+ assert len(validated_events) == 1
+ assert _operations(validated_events[0]) == operations
+
+
+def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ preview = service.preview_native_can(
+ "观影时空调温度设置为24度并播放音乐",
+ provider="openai",
+ conversation_id="conv-two-stage-deduplicate",
+ execution_mode="dry_run",
+ )
+ assert [item["capability"] for item in _operations(preview)] == [
+ "climate.set_temperature"
+ ]
+
+ model_output = {
+ "detected_domains": ["climate", "ambient_light"],
+ "requests": [
+ {
+ "domain": "climate",
+ "capability": "climate.set_temperature",
+ "target_scope": "driver",
+ "operation": "set",
+ "duration_ms": None,
+ "parameters": [{"name": "temperature_c", "value": 24}],
+ "reason": "模型重复给出了本地温度动作。",
+ },
+ {
+ "domain": "ambient_light",
+ "capability": "ambient_light.set_power",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "duration_ms": None,
+ "parameters": [{"name": "state", "value": "on"}],
+ "reason": "模型补充观影氛围灯。",
+ },
+ ],
+ "ambiguities": [],
+ "decision_summary": ["仅补充本地规则未覆盖的观影含义。"],
+ "confidence": 0.9,
+ }
+ with patch.object(
+ service.openai,
+ "interpret",
+ return_value=model_output,
+ ) as interpret:
+ result = service.supplement_native_can(
+ preview["request_id"],
+ provider="openai",
+ )
+
+ assert interpret.call_args.args[0] == "观影时空调温度设置为24度并播放音乐"
+ operations = _operations(result)
+ assert [item["capability"] for item in operations].count(
+ "climate.set_temperature"
+ ) == 1
+ assert [item["capability"] for item in operations].count(
+ "ambient_light.set_power"
+ ) == 1
+ assert {
+ item["capability"]: item["parse_source"] for item in operations
+ } == {
+ "climate.set_temperature": "local_rule",
+ "ambient_light.set_power": "cloud_supplement",
+ }
+ cloud_results = (
+ result["phase_execution_reports"]["cloud_supplement"] or {}
+ ).get("spec", {}).get("results", [])
+ assert len(cloud_results) == 1
+
+
+def test_frontend_two_stage_contract_and_source_badges() -> None:
+ app = (ROOT / "apps" / "web-console" / "app.js").read_text(
+ encoding="utf-8"
+ )
+ console = (ROOT / "apps" / "web-console" / "console-v2.js").read_text(
+ encoding="utf-8"
+ )
+ styles = (ROOT / "apps" / "web-console" / "console-v2.css").read_text(
+ encoding="utf-8"
+ )
+ server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(
+ encoding="utf-8"
+ )
+
+ assert '"/api/requests/preview"' in app
+ assert "`/api/requests/${preview.request_id}/supplement`" in app
+ assert "renderResult(preview);" in app
+ assert 'Accept: "application/x-ndjson"' in app
+ assert '"cognitive-agent:validated-can-commands"' in app
+ assert "本地规则命令已执行;云端补充失败" in app
+ assert 'class="v2-can-command-source"' in console
+ assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console
+ assert '"cognitive-agent:validated-can-commands"' in console
+ assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
+ assert 'path not in {"/api/requests", "/api/requests/preview"}' in server
+ assert '"type": "validated_commands"' in server