diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 68eef7d..ada5115 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -4488,27 +4488,7 @@ function renderControlTransport() {
? runtime?.state_sync?.last_error || ""
: `${id === "wireless_mqtt" ? "无线 MQTT" : "有线 CAN"} 当前未配置(仍可手动选择)`;
});
- const selected = selectedVehicleTransport();
- const note = $("#vehicleTransportNote");
renderCabinLiveStatus();
- if (!note) return;
- if (selected?.state_configured !== true) {
- note.textContent = state.controlTransport === "wireless_mqtt"
- ? "已手动选择无线控制 · MQTT 当前未配置或不可用"
- : "已手动选择有线控制 · ZLG CAN 当前未配置或不可用";
- return;
- }
- if (state.controlTransport === "wireless_mqtt") {
- const connection = transportConnectionOnline("wireless_mqtt", selected) ? "已连接" : "未连接";
- note.textContent = `MQTT ${connection} · 状态 ${selected?.condition_topic || "n7/n7-headunit/can/state"} · 控制 ${selected?.control_topic || "n7/n7-headunit/can/command"}`;
- } else {
- const connection = transportConnectionOnline("wired_can", selected) ? "已连接" : "未连接";
- const dualChannel = selected?.target_policy
- === "dual_channel_wired_can0_passenger_can1_vehicle";
- note.textContent = dualChannel
- ? `ZLG 有线 ${connection} · CAN0 副驾经典 CAN 500k · CAN1 车辆 CAN FD 500k/2M`
- : `ZLG 有线 ${connection} · CH${selected?.channel ?? 1} · CAN FD 500k/2M`;
- }
}
function selectControlTransport(transport) {
@@ -6507,7 +6487,6 @@ async function loadRuntime() {
mqttDualChannel ? vehicleCan.configured : hardware.configured,
);
const vehicleHardwareReady = Boolean(vehicleCan.configured);
- const linkedHardwareReady = seatHardwareReady && vehicleHardwareReady;
const ch1DriverOnly = vehicleHardwareReady && !seatHardwareReady;
const hardwareExecutionReady = Boolean(hardware.configured || vehicleCan.configured);
const gptButton = $('[data-provider="openai"]');
@@ -6532,8 +6511,6 @@ async function loadRuntime() {
? "真实下发已通过后端执行配置校验"
: `可手动选择真实下发;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
const simulation = state.runtime.simulated_hardware || {};
- const profileText = (hardware.firmware_profiles || []).join(" / ") || "未选择 profile";
- const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
? " · 动作防冲突"
: "";
@@ -6541,23 +6518,6 @@ async function loadRuntime() {
&& hardware.access_policy === "local_approval_idle_lease"
? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
: "";
- const vehicleRouteText = mqttSelected
- ? `MQTT ${vehicleCan.host || "127.0.0.1"}:${vehicleCan.port || 1883} · ${vehicleCan.condition_topic || "n7/n7-headunit/can/state"} / ${vehicleCan.control_topic || "n7/n7-headunit/can/command"}`
- : `${vehicleCan.backend || "zlgcan"} · CH${vehicleCan.channel ?? 1} 车身 CAN FD ${Number(vehicleCan.arbitration?.bitrate || 500000) / 1000}k/${Number(vehicleCan.data?.bitrate || 2000000) / 1000000}M`;
- $("#hardwareNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
- ? `${vehicleRouteText} · 仅主驾/车端白名单 · CH0/T2G/IAR 已禁用${accessText}${concurrencyText}`
- : linkedHardwareReady
- ? `${mqttDualChannel ? "MQTT CAN0 副驾座椅 · MQTT CAN1 其余车辆" : `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s`} · ${vehicleRouteText}${mqttDualChannel ? " · 远端自动烧录禁用" : ` · ${profileText} · ${flashText}`}${accessText}${concurrencyText}`
- : seatHardwareReady
- ? `${hardware.backend} · CH${hardware.channel} 座椅 ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · 车端 CAN 未启用,其他领域保持虚拟执行${accessText}${concurrencyText}`
- : `可手动选择真实下发;当前不可执行:${hardwareExecutionIssue(hardware, vehicleCan)}`;
- $("#hardwareAliasNote").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
- ? `${mqttSelected ? "MQTT" : "CH1"} → 车身 CAN / 主驾座椅 · CH0 与副驾 T2G 路径禁止`
- : linkedHardwareReady
- ? `${mqttDualChannel ? "MQTT CAN0" : "CH0"} → 副驾座椅控制器 · ${mqttDualChannel ? "MQTT CAN1" : "CH1"} → 其余车辆 CAN`
- : seatHardwareReady
- ? `CH${hardware.channel} → 副驾座椅控制器 · 车身控制暂用虚拟 CAN`
- : "尚未配置可执行的物理 CAN 通道";
$("#hardwareCoverage").textContent = ch1DriverOnly && vehicleHardwareReady && !seatHardwareReady
? `${mqttSelected ? "MQTT" : "CH1"} 主驾 + 车端设备 · CAN 字段回读${accessText}${concurrencyText}`
: linkedHardwareReady
diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js
index 26b1d3f..74c67d7 100644
--- a/apps/web-console/assistant-chat.js
+++ b/apps/web-console/assistant-chat.js
@@ -916,6 +916,13 @@
}
function applyAssistantEvent(event, message) {
+ if (event.type === "validated_control_commands" && event.payload) {
+ window.dispatchEvent(new CustomEvent(
+ "cognitive-agent:validated-can-commands",
+ { detail: event.payload },
+ ));
+ return;
+ }
if (event.type === "answer.delta") {
setMessageContent(message, `${message.content}${event.delta || ""}`);
return;
@@ -1094,6 +1101,21 @@
return;
}
+ window.dispatchEvent(new CustomEvent(
+ "cognitive-agent:control-request-start",
+ {
+ detail: {
+ utterance: messageText,
+ speech_utterance_id: suggestedMessage
+ ? ""
+ : String(input?.dataset.speechUtteranceId || ""),
+ speech_value: suggestedMessage
+ ? ""
+ : String(input?.dataset.speechValue || ""),
+ },
+ },
+ ));
+
createMessage("user", messageText);
if (!suggestedMessage) {
input.value = "";
diff --git a/apps/web-console/console-v2.css b/apps/web-console/console-v2.css
index b1ec1b9..9b41131 100644
--- a/apps/web-console/console-v2.css
+++ b/apps/web-console/console-v2.css
@@ -281,19 +281,6 @@
box-shadow: 0 5px 13px color-mix(in srgb, var(--v2-accent) 20%, transparent);
}
-.console-v2 .v2-transport-field > #vehicleTransportNote {
- display: -webkit-box;
- margin: 6px 1px 0;
- overflow: hidden;
- color: #809091;
- font-size: 8px;
- line-height: 1.35;
- overflow-wrap: anywhere;
- white-space: normal;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
.v2-sidebar-footer {
min-height: 50px;
display: grid;
@@ -8233,28 +8220,6 @@ body.device-power-drawer-open {
opacity: 0.72;
}
-.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field .provider-note {
- min-height: 0;
- margin: 5px 1px 0;
- overflow: hidden;
- color: #809091;
- font-size: 8px;
- line-height: 1.35;
- overflow-wrap: anywhere;
- white-space: normal;
-}
-
-.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field #hardwareNote {
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-execution-field #hardwareAliasNote {
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-simulation-fault-field select {
min-height: 34px;
padding: 0 8px;
@@ -8336,8 +8301,83 @@ body.device-power-drawer-open {
transform: rotate(180deg);
}
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing {
+ display: grid;
+ gap: 6px;
+ padding: 7px 9px;
+ color: #557270;
+ background: rgba(232, 244, 242, 0.7);
+ border-top: 1px solid rgba(19, 105, 99, 0.1);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing[hidden] {
+ display: none;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > p {
+ margin: 0;
+ font-size: 8px;
+ font-weight: 750;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header strong {
+ font-size: 9px;
+ font-weight: 900;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header span {
+ padding: 1px 5px;
+ color: #2366a1;
+ background: #e4f0fb;
+ border-radius: 999px;
+ font-size: 7px;
+ font-weight: 850;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header b {
+ margin-left: auto;
+ color: #08766f;
+ font: 850 8px/1.4 "SFMono-Regular", Consolas, monospace;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing ol {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 3px 9px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li {
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 5px;
+ font-size: 7px;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li b {
+ flex: 0 0 auto;
+ color: #315e5b;
+ font: 800 7px/1.4 "SFMono-Regular", Consolas, monospace;
+}
+
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list {
- max-height: min(24dvh, 190px);
+ max-height: min(34dvh, 280px);
padding: 7px;
overflow: auto;
border-top: 1px solid rgba(19, 105, 99, 0.1);
@@ -8472,6 +8512,35 @@ body.device-power-drawer-open {
overflow-wrap: anywhere;
}
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-layer {
+ min-width: 0;
+ display: grid;
+ gap: 1px;
+ padding: 4px 5px;
+ border-radius: 5px;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-layer > small {
+ color: #758886;
+ font-size: 7px;
+ font-weight: 900;
+ letter-spacing: 0.04em;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-semantic {
+ background: rgba(35, 102, 161, 0.07);
+ border-left: 2px solid rgba(35, 102, 161, 0.38);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-semantic code {
+ color: #245c8d;
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-actual {
+ background: rgba(8, 118, 111, 0.07);
+ border-left: 2px solid rgba(8, 118, 111, 0.42);
+}
+
.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-frame {
display: block;
color: #8a6b36;
@@ -8479,6 +8548,10 @@ body.device-power-drawer-open {
overflow-wrap: anywhere;
}
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-frame > b {
+ font-weight: 900;
+}
+
.console-v2.v2-passive-display.v2-interactive-sync .v2-sync-transport-field > .field-label {
color: #466664;
font-size: 10px;
@@ -9530,6 +9603,40 @@ body.device-power-drawer-open {
color: var(--command-frame);
}
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing {
+ color: var(--muted-foreground);
+ background: color-mix(in oklch, var(--accent) 55%, transparent);
+ border-top-color: var(--border);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header span {
+ color: var(--command-source);
+ background: color-mix(in oklch, var(--command-source) 12%, var(--card));
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing > header b,
+.console-v2.v2-passive-display.v2-interactive-sync .v2-command-timing li b {
+ color: var(--primary);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-layer > small {
+ color: var(--muted-foreground);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-semantic {
+ background: color-mix(in oklch, var(--command-source) 8%, var(--card));
+ border-left-color: color-mix(in oklch, var(--command-source) 52%, var(--border));
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-semantic code {
+ color: var(--command-source);
+}
+
+.console-v2.v2-passive-display.v2-interactive-sync .v2-can-command-list li[data-can-channel] .v2-can-command-actual {
+ background: color-mix(in oklch, var(--primary) 8%, var(--card));
+ border-left-color: color-mix(in oklch, var(--primary) 52%, var(--border));
+}
+
@media (max-width: 1120px) {
.console-v2 .v2-mode-rail > .mode-rail-bar {
grid-template-columns: auto minmax(150px, 1fr) auto;
diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js
index ce92d6d..f023a70 100644
--- a/apps/web-console/console-v2.js
+++ b/apps/web-console/console-v2.js
@@ -23,15 +23,37 @@
let reasoningPickerControl = null;
let validatedCommandsVisible = false;
let validatedCommandContext = { requestId: "", utterance: "" };
+ let pendingSpeechTiming = null;
+ let commandTimingState = {
+ inputMode: "text",
+ utterance: "",
+ requestId: "",
+ requestStartedAt: null,
+ validatedAt: null,
+ displayedAt: null,
+ renderDurationMs: null,
+ speechStartedAt: null,
+ speechResultAt: null,
+ speechTimings: null,
+ };
function resetValidatedCommandContext() {
validatedCommandsVisible = false;
validatedCommandContext = { requestId: "", utterance: "" };
}
+ function commandCorrelationIds(result = {}) {
+ return [...new Set([
+ result.preview_request_id,
+ result.supplement_of,
+ result.request_id,
+ ].map((value) => String(value || "").trim()).filter(Boolean))];
+ }
+
function rememberValidatedCommandContext(result = {}) {
+ const ids = commandCorrelationIds(result);
validatedCommandContext = {
- requestId: String(result.request_id || ""),
+ requestId: ids[0] || "",
utterance: String(result.utterance || "").trim(),
};
}
@@ -39,10 +61,10 @@
function validatedCommandsBelongToTask(task = {}) {
if (!validatedCommandsVisible) return false;
const result = task.result && typeof task.result === "object" ? task.result : {};
- const requestId = String(result.request_id || "");
+ const requestIds = commandCorrelationIds(result);
const utterance = String(task.utterance || result.utterance || "").trim();
- if (validatedCommandContext.requestId && requestId
- && validatedCommandContext.requestId !== requestId) return false;
+ if (validatedCommandContext.requestId && requestIds.length
+ && !requestIds.includes(validatedCommandContext.requestId)) return false;
if (validatedCommandContext.utterance && utterance
&& validatedCommandContext.utterance !== utterance
&& !validatedCommandContext.utterance.includes(utterance)
@@ -83,6 +105,123 @@
: null;
};
+ const monotonicNow = () => (
+ typeof performance !== "undefined" && typeof performance.now === "function"
+ ? performance.now()
+ : Date.now()
+ );
+
+ const finiteMilliseconds = (value) => {
+ const numeric = Number(value);
+ return Number.isFinite(numeric) && numeric >= 0 ? numeric : null;
+ };
+
+ const semanticValue = (value) => {
+ if (value == null) return "null";
+ if (["string", "number", "boolean"].includes(typeof value)) return String(value);
+ try {
+ return JSON.stringify(value);
+ } catch (_error) {
+ return String(value);
+ }
+ };
+
+ function semanticControlInstruction(operation = {}) {
+ const capability = String(operation.capability || "unknown");
+ const rawTarget = operation.target || {};
+ const target = rawTarget.zone || rawTarget.instance || rawTarget.device;
+ const argumentsList = [];
+ if (target != null && String(target).trim()) {
+ argumentsList.push(`target=${String(target).replaceAll("_", "-")}`);
+ }
+ Object.entries(operation.parameters || {})
+ .sort(([left], [right]) => left.localeCompare(right))
+ .forEach(([key, value]) => {
+ argumentsList.push(`${key}=${semanticValue(value)}`);
+ });
+ return `${capability}(${argumentsList.join(", ")})`;
+ }
+
+ function commandPlanEntries(result = {}) {
+ const plans = result.command_plans && typeof result.command_plans === "object"
+ ? Object.entries(result.command_plans)
+ : [];
+ if (!plans.length && result.command_plan) plans.push(["default", result.command_plan]);
+ return plans;
+ }
+
+ function compiledCommandsForOperation(result, configKey, operation = {}) {
+ const operationId = String(operation.id || "");
+ if (!operationId) return [];
+ const plans = commandPlanEntries(result);
+ const preferred = plans.filter(([key]) => key === configKey);
+ const operationRefs = (command) => {
+ const raw = command?.operation_ref;
+ const refs = Array.isArray(raw) ? raw : String(raw || "").split(",");
+ return refs.map((ref) => String(ref).trim()).filter(Boolean);
+ };
+ const matches = (entries) => entries.flatMap(([planKey, plan]) => (
+ (plan?.spec?.commands || [])
+ .filter((command) => operationRefs(command).includes(operationId))
+ .map((command) => ({ command, plan, planKey }))
+ ));
+ if (preferred.length) return matches(preferred);
+ const configurations = result.function_configs && typeof result.function_configs === "object"
+ ? Object.entries(result.function_configs)
+ : result.function_config ? [["default", result.function_config]] : [];
+ return configurations.length === 1 && plans.length === 1 ? matches(plans) : [];
+ }
+
+ function compiledCommandPresentation(entries, fallback = {}) {
+ if (!entries.length) return fallback;
+ const actualCommands = [];
+ const frames = [];
+ let channel = fallback.channel || "CAN1";
+ entries.forEach(({ command, plan }) => {
+ const signals = command?.signals || {};
+ const message = String(command?.message || "");
+ const planDomain = String(plan?.spec?.domain || "");
+ const bus = String(command?.bus || "").toLowerCase();
+ const slot = boundedInteger(signals.slot, 1, 248);
+ if (message === "infrared.emit" || planDomain === "self_extension") {
+ channel = "IR";
+ } else if (bus.includes("seat") || bus.includes("can0")) {
+ channel = "CAN0";
+ } else if (bus.includes("body") || bus.includes("can1")) {
+ channel = "CAN1";
+ }
+ const actual = String(signals.debug_command || "").trim()
+ || (message === "infrared.emit" && slot !== null ? `infrared emit ${slot}` : "")
+ || String(fallback.command || message).trim();
+ if (actual && !actualCommands.includes(actual)) actualCommands.push(actual);
+ const frameId = String(signals.frame_id || "").trim();
+ const dataHex = String(signals.data_hex || "").trim();
+ if (frameId && dataHex) {
+ frames.push({ label: `${channel} 帧`, value: `${frameId} · ${dataHex}` });
+ } else if (message === "infrared.emit" && slot !== null) {
+ frames.push({
+ label: "GSCU1B TCP",
+ value: JSON.stringify({ action: "emit", data: { no: slot }, type: "infrared" }),
+ });
+ }
+ });
+ const uniqueFrames = frames.filter((frame, index) => (
+ frames.findIndex((candidate) => (
+ candidate.label === frame.label && candidate.value === frame.value
+ )) === index
+ ));
+ return {
+ ...fallback,
+ channel,
+ command: actualCommands.join(";") || fallback.command || "",
+ ...(uniqueFrames.length ? {
+ frame: uniqueFrames[0].value,
+ frameLabel: uniqueFrames[0].label,
+ frames: uniqueFrames,
+ } : {}),
+ };
+ }
+
function parsedCanCommandForOperation(operation = {}) {
const capability = String(operation.capability || "");
const parameters = operation.parameters || {};
@@ -397,15 +536,68 @@
};
};
const commands = [];
- configurationEntries.forEach(([_configKey, configuration]) => {
- (configuration?.spec?.operations || []).forEach((operation) => {
- const item = parsedCanCommandForOperation(operation);
- if (item) commands.push({ ...item, ...deliveryForOperation(operation) });
+ const validatedControlCommands = Array.isArray(result.validated_control_commands)
+ ? result.validated_control_commands
+ : [];
+ if (validatedControlCommands.length) {
+ validatedControlCommands.forEach((presentation) => {
+ const operation = presentation?.operation || {};
+ const fallback = parsedCanCommandForOperation(operation) || {
+ channel: presentation?.domain === "self_extension" ? "IR" : "CAN1",
+ category: String(operation.capability || "控制指令"),
+ command: "",
+ ...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}),
+ };
+ const channel = new Set(["CAN0", "CAN1", "IR"]).has(presentation?.channel)
+ ? presentation.channel
+ : fallback.channel;
+ const frames = (Array.isArray(presentation?.frames) ? presentation.frames : [])
+ .map((frame) => ({
+ label: String(frame?.label || "控制帧"),
+ value: String(frame?.value || ""),
+ }))
+ .filter((frame) => frame.value);
+ const command = String(presentation?.actual_command || fallback.command || "").trim();
+ if (!command) return;
+ commands.push({
+ ...fallback,
+ channel,
+ command,
+ ...(frames.length ? {
+ frame: frames[0].value,
+ frameLabel: frames[0].label,
+ frames,
+ } : {}),
+ semantic: semanticControlInstruction(operation),
+ operationRef: String(operation.id || ""),
+ ...deliveryForOperation(operation),
+ });
});
- });
+ } else {
+ configurationEntries.forEach(([configKey, configuration]) => {
+ (configuration?.spec?.operations || []).forEach((operation) => {
+ const fallback = parsedCanCommandForOperation(operation) || {
+ channel: configuration?.spec?.domain === "self_extension" ? "IR" : "CAN1",
+ category: String(operation.capability || "控制指令"),
+ command: "",
+ ...(operation.parse_source ? { parseSource: String(operation.parse_source) } : {}),
+ };
+ const compiled = compiledCommandsForOperation(result, configKey, operation);
+ const item = compiledCommandPresentation(compiled, fallback);
+ if (item.command) {
+ commands.push({
+ ...item,
+ semantic: semanticControlInstruction(operation),
+ operationRef: String(operation.id || ""),
+ ...deliveryForOperation(operation),
+ });
+ }
+ });
+ });
+ }
const unique = new Map();
commands.forEach((item) => {
- const key = `${item.channel}:${item.command}`;
+ const key = `${item.operationRef || item.semantic}:${item.channel}:${item.command}`;
const previous = unique.get(key);
if (!previous
|| (previous.deliveryState !== "submitted" && item.deliveryState === "submitted")
@@ -425,7 +617,192 @@
}[source] || "云端模型";
}
- function renderParsedCanCommands(result, { message = "" } = {}) {
+ const commandTraceStageLabels = {
+ intent_parse: "意图解析",
+ context_resolve: "上下文解析",
+ domain_route: "控制域路由",
+ function_generate: "语义指令生成",
+ schema_validate: "Schema 校验",
+ domain_validate: "领域语义校验",
+ compile: "实际指令编译",
+ nfc_peripheral_route: "红外能力路由",
+ local_peripheral_plan: "红外本地规则编译",
+ nfc_peripheral_sequence: "红外指令序列生成",
+ };
+
+ const speechTimingLabels = {
+ audio_read_ms: "音频读取与重采样",
+ vad_ms: "语音活动检测",
+ asr_ms: "语音识别 ASR",
+ assembly_ms: "分段文本组装",
+ semantic_ms: "语音语义处理",
+ command_resolution_ms: "本地指令消歧",
+ recovery_ms: "语音识别恢复",
+ total_ms: "语音服务总耗时",
+ };
+
+ function parsedCommandTimingRows(result = {}, timingState = {}) {
+ const rows = [];
+ const push = (group, key, label, value) => {
+ const durationMs = finiteMilliseconds(value);
+ if (durationMs !== null) rows.push({ group, key, label, durationMs });
+ };
+ if (timingState.inputMode === "speech") {
+ Object.entries(speechTimingLabels).forEach(([key, label]) => {
+ push("speech", key, label, timingState.speechTimings?.[key]);
+ });
+ if (timingState.speechStartedAt != null && timingState.speechResultAt != null) {
+ push(
+ "speech",
+ "speech_capture_to_text",
+ "语音开始 → 文本就绪",
+ timingState.speechResultAt - timingState.speechStartedAt,
+ );
+ }
+ }
+ const traceEvents = Array.isArray(result.trace_events) ? result.trace_events : [];
+ const compileIndex = traceEvents.map((event) => event?.stage).lastIndexOf("compile");
+ const validatedTraceEvents = compileIndex >= 0
+ ? traceEvents.slice(0, compileIndex + 1)
+ : traceEvents;
+ const localRuleResult = String(result.provider_used || "").startsWith("local")
+ || parsedCanCommands(result).some((item) => item.parseSource === "local_rule");
+ const localBudgetMs = finiteMilliseconds(result.local_control_budget_ms);
+ const localWithinBudget = result.local_control_within_budget === true;
+ push(
+ "backend",
+ "local_control_elapsed",
+ localBudgetMs === null
+ ? "本地规则解析与下发"
+ : `本地规则解析与下发(${localWithinBudget ? "1 秒内" : "超出 1 秒"})`,
+ result.local_control_elapsed_ms,
+ );
+ validatedTraceEvents.forEach((event, index) => {
+ const stage = String(event?.stage || "");
+ if (!(stage in commandTraceStageLabels)) return;
+ const label = stage === "intent_parse" && localRuleResult
+ ? "本地规则意图解析"
+ : commandTraceStageLabels[stage];
+ push("backend", `${stage}-${index}`, label, event.duration_ms);
+ });
+ if (timingState.requestStartedAt != null && timingState.validatedAt != null) {
+ push(
+ "frontend",
+ "request_to_validated",
+ "请求提交 → 编译结果到达",
+ timingState.validatedAt - timingState.requestStartedAt,
+ );
+ }
+ push("frontend", "dom_render", "前端指令渲染", timingState.renderDurationMs);
+ if (timingState.requestStartedAt != null && timingState.displayedAt != null) {
+ push(
+ "total",
+ "request_to_display",
+ "文本提交 → 指令显示",
+ timingState.displayedAt - timingState.requestStartedAt,
+ );
+ }
+ if (timingState.inputMode === "speech"
+ && timingState.speechStartedAt != null
+ && timingState.displayedAt != null) {
+ push(
+ "total",
+ "speech_to_display",
+ "语音开始 → 指令显示",
+ timingState.displayedAt - timingState.speechStartedAt,
+ );
+ }
+ return rows;
+ }
+
+ const formattedDuration = (value) => {
+ const duration = finiteMilliseconds(value);
+ if (duration === null) return "--";
+ if (duration > 0 && duration < 0.1) return "<0.1 ms";
+ return `${duration < 10 ? duration.toFixed(1) : Math.round(duration)} ms`;
+ };
+
+ function renderCommandTimings(result = null, { pending = false } = {}) {
+ const container = $("#v2CommandTiming");
+ if (!container) return;
+ const rows = result ? parsedCommandTimingRows(result, commandTimingState) : [];
+ if (!rows.length && !pending) {
+ container.hidden = true;
+ container.innerHTML = "";
+ return;
+ }
+ container.hidden = false;
+ if (!rows.length) {
+ container.innerHTML = `
阶段耗时计时中 · ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}
`;
+ return;
+ }
+ const total = [...rows].reverse().find((row) => row.group === "total");
+ container.innerHTML = `
+
+ 阶段耗时
+ ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}
+ ${total ? `${escapeHtml(formattedDuration(total.durationMs))}` : ""}
+
+ ${rows.map((row) => `
+ -
+ ${escapeHtml(row.label)}${escapeHtml(formattedDuration(row.durationMs))}
+
`).join("")}
`;
+ }
+
+ function beginCommandTiming(request = {}) {
+ const input = $("#utterance");
+ const utterance = String(request.utterance ?? input?.value ?? "").trim();
+ const speechUtteranceId = String(
+ request.speech_utterance_id ?? input?.dataset?.speechUtteranceId ?? "",
+ );
+ const speechValue = String(
+ request.speech_value ?? input?.dataset?.speechValue ?? "",
+ );
+ const speechMatch = Boolean(
+ speechUtteranceId
+ && pendingSpeechTiming?.utteranceId === speechUtteranceId
+ && speechValue === utterance,
+ );
+ commandTimingState = {
+ inputMode: speechMatch ? "speech" : "text",
+ utterance,
+ requestId: "",
+ requestStartedAt: monotonicNow(),
+ validatedAt: null,
+ displayedAt: null,
+ renderDurationMs: null,
+ speechStartedAt: speechMatch ? pendingSpeechTiming.startedAt : null,
+ speechResultAt: speechMatch ? pendingSpeechTiming.resultAt : null,
+ speechTimings: speechMatch ? { ...(pendingSpeechTiming.timings || {}) } : null,
+ };
+ }
+
+ function commandResultMatchesTimedRequest(result = {}) {
+ if (commandTimingState.requestStartedAt == null) return true;
+ const expectedUtterance = String(commandTimingState.utterance || "")
+ .replace(/\s+/g, "")
+ .trim();
+ const receivedUtterance = String(result.utterance || "")
+ .replace(/\s+/g, "")
+ .trim();
+ if (expectedUtterance && receivedUtterance
+ && expectedUtterance !== receivedUtterance
+ && !expectedUtterance.includes(receivedUtterance)
+ && !receivedUtterance.includes(expectedUtterance)) return false;
+ const requestIds = commandCorrelationIds(result);
+ if (commandTimingState.requestId && requestIds.length
+ && !requestIds.includes(commandTimingState.requestId)) return false;
+ if (!commandTimingState.requestId && requestIds.length) {
+ [commandTimingState.requestId] = requestIds;
+ }
+ return true;
+ }
+
+ function renderParsedCanCommands(
+ result,
+ { message = "", pending = false, recordTiming = false, reveal = false } = {},
+ ) {
+ const renderStartedAt = monotonicNow();
const panel = $("#v2CanCommandPanel");
const list = $("#v2CanCommandList");
const count = $("#v2CanCommandCount");
@@ -442,12 +819,31 @@
${escapeHtml(item.deliveryLabel)}
${escapeHtml(item.category)}
- ${escapeHtml(item.command)}
- ${item.frame ? `${escapeHtml(item.frameLabel || "固件原生 CAN0")} · ${escapeHtml(item.frame)}` : ""}
+
+ 语义控制指令
+ ${escapeHtml(item.semantic)}
+
+
+ 实际控制指令
+ ${escapeHtml(item.command)}
+
+ ${(item.frames || (item.frame ? [{ label: item.frameLabel || "控制帧", value: item.frame }] : [])).map((frame) => `${escapeHtml(frame.label)} · ${escapeHtml(frame.value)}`).join("")}
`).join("")}`;
} else {
list.innerHTML = `${escapeHtml(message || "本次请求未解析出控制指令")}
`;
}
+ if (reveal && commands.length) {
+ panel.open = true;
+ if (ui.sidebarCollapsed) setSidebarCollapsed(false);
+ }
+ if (recordTiming && commands.length) {
+ if (commandTimingState.validatedAt == null) commandTimingState.validatedAt = renderStartedAt;
+ if (commandTimingState.displayedAt == null) {
+ commandTimingState.displayedAt = monotonicNow();
+ commandTimingState.renderDurationMs = commandTimingState.displayedAt - renderStartedAt;
+ }
+ }
+ renderCommandTimings(result, { pending });
requestAnimationFrame(syncSidebarLogBaseline);
}
@@ -767,6 +1163,11 @@
logKeys: new Set(),
extensionFingerprint: "",
};
+ const nonDispatchTaskSources = new Set(["mode_editor_compile"]);
+
+ function isDispatchTask(task = {}) {
+ return !nonDispatchTaskSources.has(String(task.source || "").trim());
+ }
const readText = (selector, fallback = "--") => $(selector)?.textContent?.trim() || fallback;
@@ -1625,26 +2026,28 @@
if (revision !== displaySync.revision) {
displaySync.revision = revision;
const task = snapshot?.task || {};
- setTaskPresentation(task);
- const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
- if (task.utterance) {
- const submittedUnverified = task.result?.execution_outcome === "submitted_unverified"
- || task.result?.execution_report?.spec?.verification_status === "submitted_unverified";
- appendSyncLog({
- key: `task:${revision}:${task.status}`,
- category: "task",
- title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
- detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`,
- status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
- });
- }
- const result = task.result;
- const requestId = result?.request_id || null;
- if (result && requestId !== displaySync.requestId) {
- displaySync.requestId = requestId;
- window.CognitiveAgentConsole?.applySyncedResult?.(result, {
- utterance: task.utterance || "",
- });
+ if (isDispatchTask(task)) {
+ setTaskPresentation(task);
+ const sourceLabel = task.source === "web_console" ? "网页端" : "车机端";
+ if (task.utterance) {
+ const submittedUnverified = task.result?.execution_outcome === "submitted_unverified"
+ || task.result?.execution_report?.spec?.verification_status === "submitted_unverified";
+ appendSyncLog({
+ key: `task:${revision}:${task.status}`,
+ category: "task",
+ title: `${sourceLabel}${task.status === "running" ? "下发编排任务" : "任务状态已同步"}`,
+ detail: `${task.utterance} · ${submittedUnverified ? "指令已提交,等待车辆状态确认" : task.status || "idle"}`,
+ status: submittedUnverified ? "info" : ["succeeded", "partially_succeeded"].includes(task.status) ? "success" : task.status === "failed" ? "error" : "info",
+ });
+ }
+ const result = task.result;
+ const requestId = result?.request_id || null;
+ if (result && requestId !== displaySync.requestId) {
+ displaySync.requestId = requestId;
+ window.CognitiveAgentConsole?.applySyncedResult?.(result, {
+ utterance: task.utterance || "",
+ });
+ }
}
}
const extensionFingerprint = synchronizedCatalog
@@ -1763,12 +2166,13 @@
insertVisibleControl(simulationFaultField, "v2-sync-simulation-fault-field");
const canCommandPanel = make("details", "v2-can-command-panel", `
- 已解析控制指令
+ 下发控制指令
0
+
-
等待自然语言解析结果
+
等待准备下发的控制指令
`);
canCommandPanel.id = "v2CanCommandPanel";
syncOverview.append(canCommandPanel);
@@ -1875,17 +2279,57 @@
if (event.target.matches("select[data-device-control]")) storeDeviceControlDraft(event.target);
});
$("#v2LogFilter")?.addEventListener("change", renderSyncLogs);
- $("#requestForm")?.addEventListener("submit", () => {
+ const beginPendingControlRequest = (request = {}) => {
+ beginCommandTiming(request);
resetValidatedCommandContext();
- renderParsedCanCommands(null, { message: "正在解析自然语言并生成控制指令…" });
+ renderParsedCanCommands(null, {
+ message: "正在解析自然语言并生成控制指令…",
+ pending: true,
+ });
+ };
+ $("#requestForm")?.addEventListener("submit", () => {
+ beginPendingControlRequest();
+ }, true);
+ window.addEventListener("cognitive-agent:control-request-start", (event) => {
+ beginPendingControlRequest(event.detail || {});
+ });
+ window.addEventListener("cognitive-agent:speech-state", (event) => {
+ const phase = event.detail?.phase;
+ if (phase === "capturing" && !pendingSpeechTiming?.captureActive) {
+ pendingSpeechTiming = {
+ utteranceId: "",
+ startedAt: monotonicNow(),
+ resultAt: null,
+ timings: null,
+ captureActive: true,
+ };
+ } else if (["finalizing", "success", "error"].includes(phase)
+ && pendingSpeechTiming) {
+ pendingSpeechTiming.captureActive = false;
+ }
+ });
+ window.addEventListener("cognitive-agent:speech-result", (event) => {
+ const result = event.detail || {};
+ pendingSpeechTiming = {
+ ...(pendingSpeechTiming || { startedAt: null }),
+ utteranceId: String(result.utterance_id || ""),
+ resultAt: monotonicNow(),
+ timings: result.timings && typeof result.timings === "object"
+ ? { ...result.timings }
+ : null,
+ captureActive: false,
+ };
});
window.addEventListener("cognitive-agent:validated-can-commands", (event) => {
const result = event.detail || {};
+ if (!commandResultMatchesTimedRequest(result)) return;
validatedCommandsVisible = parsedCanCommands(result).length > 0;
if (validatedCommandsVisible) rememberValidatedCommandContext(result);
else resetValidatedCommandContext();
renderParsedCanCommands(result, {
message: "控制指令已解析,正在等待传输结果…",
+ recordTiming: true,
+ reveal: true,
});
});
window.addEventListener("cognitive-agent:result", (event) => {
@@ -1893,11 +2337,15 @@
const submittedUnverified = result.execution_outcome === "submitted_unverified"
|| result.execution_report?.spec?.verification_status === "submitted_unverified";
const commands = parsedCanCommands(result);
- if (commands.length) {
+ const matchesTimedRequest = commandResultMatchesTimedRequest(result);
+ if (matchesTimedRequest && commands.length) {
validatedCommandsVisible = true;
rememberValidatedCommandContext(result);
- renderParsedCanCommands(result);
- } else if (!validatedCommandsBelongToTask({
+ renderParsedCanCommands(result, {
+ recordTiming: commandTimingState.displayedAt == null,
+ reveal: commandTimingState.requestStartedAt != null,
+ });
+ } else if (matchesTimedRequest && !validatedCommandsBelongToTask({
utterance: result.utterance,
result,
})) {
@@ -1917,7 +2365,15 @@
});
});
window.addEventListener("cognitive-agent:request-complete", (event) => {
- if (event.detail?.error && !validatedCommandsVisible) {
+ const completedUtterance = String(event.detail?.utterance || "")
+ .replace(/\s+/g, "")
+ .trim();
+ const expectedUtterance = String(commandTimingState.utterance || "")
+ .replace(/\s+/g, "")
+ .trim();
+ const matchesTimedRequest = !expectedUtterance || !completedUtterance
+ || expectedUtterance === completedUtterance;
+ if (matchesTimedRequest && !validatedCommandsVisible) {
renderParsedCanCommands(null, { message: "本次请求未生成控制指令" });
}
if (event.detail?.error) {
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index e4d1c87..b5a8c09 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -10,7 +10,7 @@
511工程-智能座舱
-
+
@@ -20,13 +20,13 @@
-
+
-
+
-
+
@@ -131,8 +131,6 @@
真实下发
- 正在检查 CAN 适配器与 IAR 固件配置
- 所有座椅能力仅控制副驾实体电机、加热与通风执行器
@@ -145,7 +143,6 @@
无线 CAN
- 正在检查车辆连接
diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js
index d82721f..b0736fd 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-v277";
+const CACHE_VERSION = "cognitive-agent-pad-v282";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,18 +6,18 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
- "/app.js?v=dual-seat-130",
+ "/app.js?v=dual-seat-131",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
- "/console-v2.css?v=delivery-state-66",
- "/console-v2.js?v=delivery-state-62",
+ "/console-v2.css?v=delivery-state-68",
+ "/console-v2.js?v=local-first-66",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.js?v=gscu-tcp-43",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-26",
- "/assistant-chat.js?v=codex-assistant-26",
+ "/assistant-chat.js?v=codex-assistant-27",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/assets/pets/self-extension-cat.png?v=3",
diff --git a/config/environments/n7web_remote_mqtt.example.env b/config/environments/n7web_remote_mqtt.example.env
new file mode 100644
index 0000000..a5d6b44
--- /dev/null
+++ b/config/environments/n7web_remote_mqtt.example.env
@@ -0,0 +1,12 @@
+# Remote NanoMQ broker exposed by Caddy as MQTT 3.1.1 over WebSocket Secure.
+# Copy these values into the repository-root .env file and replace the
+# credential placeholders with an account configured on the broker. Never
+# commit the real password.
+COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED=true
+COGNITIVE_AGENT_VEHICLE_MQTT_HOST=511.kendeji.fun
+COGNITIVE_AGENT_VEHICLE_MQTT_PORT=443
+COGNITIVE_AGENT_VEHICLE_MQTT_TRANSPORT=websockets
+COGNITIVE_AGENT_VEHICLE_MQTT_WEBSOCKET_PATH=/mqtt
+COGNITIVE_AGENT_VEHICLE_MQTT_TLS=true
+COGNITIVE_AGENT_VEHICLE_MQTT_USERNAME=
+COGNITIVE_AGENT_VEHICLE_MQTT_PASSWORD=
diff --git a/domains/seat/src/seat_domain/intent.py b/domains/seat/src/seat_domain/intent.py
index e082c62..9e75914 100755
--- a/domains/seat/src/seat_domain/intent.py
+++ b/domains/seat/src/seat_domain/intent.py
@@ -81,7 +81,8 @@ 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"车门|门锁|空调|温度|风量|风速|出风模式|"
r"内循环|外循环|循环模式|风口|遮阳帘|天幕|阅读灯|"
r"室内灯|车内灯|氛围灯"
)
diff --git a/src/cognitive_agent.egg-info/SOURCES.txt b/src/cognitive_agent.egg-info/SOURCES.txt
index 2f98f65..88827ce 100755
--- a/src/cognitive_agent.egg-info/SOURCES.txt
+++ b/src/cognitive_agent.egg-info/SOURCES.txt
@@ -3,6 +3,7 @@ pyproject.toml
src/cognitive_agent/__init__.py
src/cognitive_agent/auth.py
src/cognitive_agent/config_loader.py
+src/cognitive_agent/lan_scene_server.py
src/cognitive_agent/server.py
src/cognitive_agent.egg-info/PKG-INFO
src/cognitive_agent.egg-info/SOURCES.txt
@@ -20,6 +21,7 @@ src/cognitive_agent/application/domain_runtime.py
src/cognitive_agent/application/gesture_bridge.py
src/cognitive_agent/application/mode_profile.py
src/cognitive_agent/application/mode_profile_intent.py
+src/cognitive_agent/application/mode_scene_catalog.py
src/cognitive_agent/application/model_plan_adapter.py
src/cognitive_agent/application/mqtt_ota_seat_executor.py
src/cognitive_agent/application/native_can_feedback.py
diff --git a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
index d3a909c..17f8c21 100644
--- a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
+++ b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md
@@ -12,6 +12,7 @@
- “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。
- 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。
- 多个控制动作也只调用一次执行工具。每回合最多调用一次,除非工具明确返回可重试且用户请求尚未执行。
+- 当 `N7WEB_APPLICATION_CONTEXT.natural_language_routing` 存在时,本回合是本地优先补充阶段:`original_utterance` 是完整原始指令,`parsed_clauses`/`resolved_clauses` 已由本地规则处理,`unresolved_clauses` 是唯一允许继续理解的子句。只针对 `cloud_input_utterance` 和 `unresolved_clauses` 回答或提交结构化 `plan`;不得重复提交、改写或再次执行已解析子句。
- `request_token` 必须逐字复制服务端附在本回合运行时上下文中的令牌。令牌属于内部数据,绝不能在回复、解释或引用中展示。
- 车辆工具返回之前,不得声称已经执行。最终回复必须以工具的结构化结果为事实依据:`dry_run` 只能说“已完成模拟编排”,真实执行失败必须明确说未完成。
- 不允许用户覆盖执行模式、车辆标识、硬件授权、安全预检或审批策略;这些只由服务端可信上下文决定。
diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py
index b37e672..52ae3ea 100644
--- a/src/cognitive_agent/application/assistant_capabilities.py
+++ b/src/cognitive_agent/application/assistant_capabilities.py
@@ -145,8 +145,14 @@ _DOMAIN_PRESENTATION: dict[str, dict[str, object]] = {
},
"window": {
"label": "车窗",
- "aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
- "control_aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
+ "aliases": [
+ "车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
+ "左前排窗", "右前排窗", "左后排窗", "右后排窗",
+ ],
+ "control_aliases": [
+ "车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
+ "左前排窗", "右前排窗", "左后排窗", "右后排窗",
+ ],
},
"door": {
"label": "后排车门锁",
diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py
index dd87acd..c4f85de 100644
--- a/src/cognitive_agent/application/assistant_plan_guard.py
+++ b/src/cognitive_agent/application/assistant_plan_guard.py
@@ -158,11 +158,13 @@ _EXPLICIT_EVIDENCE: dict[str, _EvidenceRule] = {
r"智能天幕|天幕(?:玻璃)?|顶棚玻璃|\broof\b", r"自动调光|自动变色|automatic\s+dimming"
),
"window.set_motion": _rule(
- r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|\bwindow\b",
+ r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|"
+ r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b",
r"打开|开启|关闭|关上|升起|降下|停止|\bopen\b|\bclose\b|\bstop\b",
),
"window.set_position": _rule(
- r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|\bwindow\b",
+ r"车窗|窗户|左前窗|右前窗|左后窗|右后窗|"
+ r"左前排窗|右前排窗|左后排窗|右后排窗|\bwindow\b",
r"位置|开到|关到|升到|降到|小缝|一半|半开|\d+\s*[%%]|\bposition\b",
),
"door.set_rear_lock": _rule(
diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py
index b2ef03c..958816f 100644
--- a/src/cognitive_agent/application/codex_assistant.py
+++ b/src/cognitive_agent/application/codex_assistant.py
@@ -828,6 +828,8 @@ class CodexAssistantService:
history: Sequence[dict[str, str]],
emit: EmitEvent,
execute_control: ControlExecutor,
+ preview_control: ControlExecutor | None = None,
+ supplement_control: ControlExecutor | None = None,
capability_snapshot: Mapping[str, object] | None = None,
capability_context: Mapping[str, object] | None = None,
scene_capability_context: Mapping[str, object] | None = None,
@@ -894,6 +896,14 @@ class CodexAssistantService:
authorization.reason == "explicit_registered_control"
and _normalize_instruction(message) in normalized_quick_controls
)
+ explicit_local_control = (
+ authorization.reason == "explicit_registered_control"
+ )
+ local_first_control = bool(
+ explicit_local_control
+ and preview_control is not None
+ and supplement_control is not None
+ )
mode_profile_preview = authorization.reason in {
"explicit_mode_profile_create",
"explicit_mode_profile_draft_update",
@@ -942,6 +952,7 @@ class CodexAssistantService:
direct_vehicle_control = (
fast_comfort_control
or exact_quick_control
+ or local_first_control
or saved_mode_execution_id is not None
or direct_mode_profile_preview
or authorization.reason == "explicit_mode_profile_draft_update"
@@ -978,7 +989,7 @@ class CodexAssistantService:
else "codex-app-server-scene-composer"
if scene_composition_preview
else "n7web-control-router"
- if fast_comfort_control or exact_quick_control
+ if fast_comfort_control or exact_quick_control or local_first_control
else "codex-app-server"
)
@@ -1024,6 +1035,8 @@ class CodexAssistantService:
if fast_comfort_control
else "正在执行快捷车控……"
if exact_quick_control
+ else "正在本地解析并下发明确控制指令……"
+ if local_first_control
else "小迪正在理解并处理……"
),
}
@@ -1092,6 +1105,25 @@ class CodexAssistantService:
),
}
+ if (
+ local_first_control
+ and preview_control is not None
+ and supplement_control is not None
+ ):
+ return self._stream_local_first_vehicle_control(
+ authorization,
+ message_id=message_id,
+ emit=emit,
+ preview_control=preview_control,
+ supplement_control=supplement_control,
+ conversation_id=conversation_id,
+ principal=principal,
+ capability_snapshot=catalog,
+ capability_context=model_context,
+ control_terms=control_terms,
+ query_capabilities=catalog_query,
+ )
+
if direct_vehicle_control:
return self._stream_direct_vehicle_control(
(
@@ -1535,7 +1567,16 @@ class CodexAssistantService:
active = start_structured_turn(
thread_id,
message,
- application_context=scene_context,
+ application_context={
+ **dict(scene_context),
+ "natural_language_routing": {
+ "original_utterance": message,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [message],
+ "cloud_input_utterance": message,
+ },
+ },
output_schema=output_schema,
timeout=self._remaining_scene_time(deadline),
)
@@ -1730,6 +1771,285 @@ class CodexAssistantService:
except (RuntimeError, TypeError) as exc:
LOGGER.debug("Scene Composer thread cleanup failed", exc_info=exc)
+ def _stream_local_first_vehicle_control(
+ self,
+ authorization: _VehicleAuthorization,
+ *,
+ message_id: str,
+ emit: EmitEvent,
+ preview_control: ControlExecutor,
+ supplement_control: ControlExecutor,
+ conversation_id: str,
+ principal: str,
+ capability_snapshot: Mapping[str, object],
+ capability_context: Mapping[str, object],
+ control_terms: Sequence[str],
+ query_capabilities: CapabilityQuery,
+ ) -> dict[str, object]:
+ """Publish deterministic controls, then delegate only unresolved text."""
+
+ instruction = authorization.instruction
+ if instruction is None:
+ raise CodexAssistantError("local-first vehicle control was not authorized")
+ if (
+ emit(
+ {
+ "type": "action.started",
+ "message_id": message_id,
+ "display_text": "正在通过本地规则解析明确控制指令……",
+ }
+ )
+ is False
+ ):
+ return {"cancelled": True}
+
+ stream_open = True
+
+ def on_progress(payload: dict[str, object]) -> None:
+ nonlocal stream_open
+ if not stream_open:
+ return
+ validated_payload = self._validated_control_payload(payload)
+ if validated_payload is not None:
+ stream_open = (
+ emit(
+ {
+ "type": "validated_control_commands",
+ "message_id": message_id,
+ "payload": validated_payload,
+ }
+ )
+ is not False
+ )
+ if not stream_open:
+ return
+ stream_open = (
+ emit(
+ {
+ "type": "action.progress",
+ "message_id": message_id,
+ "display_text": "本地控制指令已解析并进入下发链路……",
+ "payload": self._safe_vehicle_result(payload),
+ }
+ )
+ is not False
+ )
+
+ try:
+ preview = preview_control(instruction, on_progress)
+ if not isinstance(preview, dict):
+ raise TypeError("vehicle preview returned an invalid result")
+ except Exception: # noqa: BLE001 - stable browser-facing failure
+ answer = "车辆控制未完成,本地安全执行链没有返回有效结果。"
+ for event in (
+ {
+ "type": "action.completed",
+ "message_id": message_id,
+ "success": False,
+ "state": "failed",
+ "display_text": "本地控制未完成",
+ },
+ {
+ "type": "answer.delta",
+ "message_id": message_id,
+ "delta": answer,
+ },
+ {
+ "type": "work.completed",
+ "message_id": message_id,
+ "success": False,
+ "display_text": "车辆控制未完成",
+ },
+ {
+ "type": "answer.completed",
+ "message_id": message_id,
+ "route": "vehicle_control",
+ "answer": answer,
+ },
+ ):
+ if emit(event) is False:
+ return {"cancelled": True, "answer": answer}
+ return {
+ "route": "vehicle_control",
+ "answer": answer,
+ "success": False,
+ }
+
+ if not stream_open:
+ return {"cancelled": True}
+
+ routing_value = preview.get("natural_language_routing")
+ routing = dict(routing_value) if isinstance(routing_value, Mapping) else {}
+ raw_unresolved = routing.get("unresolved_clauses")
+ if not isinstance(raw_unresolved, Sequence) or isinstance(
+ raw_unresolved,
+ (str, bytes, bytearray),
+ ):
+ raw_unresolved = preview.get("unresolved_clauses")
+ unresolved_clauses = list(
+ dict.fromkeys(
+ text
+ for item in (
+ list(raw_unresolved)[:64]
+ if isinstance(raw_unresolved, Sequence)
+ and not isinstance(raw_unresolved, (str, bytes, bytearray))
+ else []
+ )
+ for text in [str(item).strip()[:1000]]
+ if text
+ )
+ )
+ safe_preview = self._safe_vehicle_result(preview)
+
+ if not unresolved_clauses:
+ action_state, successful, completion_text = _vehicle_action_state(preview)
+ answer = self._fast_comfort_answer(
+ "explicit_registered_control",
+ preview,
+ successful,
+ )
+ if not stream_open:
+ return {"cancelled": True, "answer": answer}
+ for event in (
+ {
+ "type": "action.completed",
+ "message_id": message_id,
+ "success": successful,
+ "state": action_state,
+ "display_text": completion_text,
+ "payload": safe_preview,
+ },
+ {
+ "type": "answer.delta",
+ "message_id": message_id,
+ "delta": answer,
+ },
+ {
+ "type": "work.completed",
+ "message_id": message_id,
+ "success": successful,
+ "state": action_state,
+ "display_text": completion_text,
+ },
+ {
+ "type": "answer.completed",
+ "message_id": message_id,
+ "route": "vehicle_control",
+ "answer": answer,
+ },
+ ):
+ if emit(event) is False:
+ return {"cancelled": True, "answer": answer}
+ return {
+ "route": "vehicle_control",
+ "answer": answer,
+ "success": successful,
+ "result": safe_preview,
+ }
+
+ local_has_commands = bool(
+ preview.get("function_configs") and preview.get("command_plans")
+ )
+ local_status = str(preview.get("status") or "")
+ local_succeeded = local_has_commands and local_status in {
+ "succeeded",
+ "partially_succeeded",
+ "no_action",
+ }
+ if stream_open and emit(
+ {
+ "type": "action.completed",
+ "message_id": message_id,
+ "success": local_succeeded,
+ "state": "submitted" if local_succeeded else "failed",
+ "display_text": (
+ "本地明确控制已先行处理"
+ if local_succeeded
+ else "本地阶段未生成控制指令"
+ ),
+ "payload": safe_preview,
+ }
+ ) is False:
+ return {"cancelled": True}
+ if emit(
+ {
+ "type": "work.progress",
+ "message_id": message_id,
+ "display_text": "本地明确子句已处理,正在补充未解析子句……",
+ }
+ ) is False:
+ return {"cancelled": True}
+
+ cloud_message = ",".join(unresolved_clauses)
+ routing.update(
+ {
+ "original_utterance": str(
+ routing.get("original_utterance") or instruction
+ )[:1000],
+ "parsed_clauses": list(routing.get("parsed_clauses") or []),
+ "resolved_clauses": list(routing.get("resolved_clauses") or []),
+ "unresolved_clauses": unresolved_clauses,
+ "cloud_input_utterance": cloud_message,
+ }
+ )
+ if not self.client.available:
+ answer = (
+ "已先行处理本地可解析的控制;其余子句暂无法连接云端模型,"
+ "未生成额外控制。"
+ )
+ emit(
+ {
+ "type": "answer.delta",
+ "message_id": message_id,
+ "delta": answer,
+ }
+ )
+ emit(
+ {
+ "type": "work.completed",
+ "message_id": message_id,
+ "success": local_succeeded,
+ "display_text": "本地控制已处理,云端补充不可用",
+ }
+ )
+ emit(
+ {
+ "type": "answer.completed",
+ "message_id": message_id,
+ "route": "vehicle_control",
+ "answer": answer,
+ }
+ )
+ return {
+ "route": "vehicle_control",
+ "answer": answer,
+ "success": local_succeeded,
+ "result": safe_preview,
+ }
+
+ conversation, conversation_key = self._conversation(
+ principal,
+ conversation_id,
+ )
+ try:
+ with conversation.lock:
+ return self._stream_locked(
+ conversation,
+ cloud_message,
+ message_id=message_id,
+ emit=emit,
+ execute_control=supplement_control,
+ capability_snapshot=capability_snapshot,
+ capability_context={
+ **dict(capability_context),
+ "natural_language_routing": routing,
+ },
+ control_terms=control_terms,
+ query_capabilities=query_capabilities,
+ )
+ finally:
+ self._release_conversation(conversation_key, conversation)
+
def _stream_direct_vehicle_control(
self,
authorization: _VehicleAuthorization,
@@ -1753,6 +2073,10 @@ class CodexAssistantService:
authorization.reason == "explicit_mode_profile_draft_update"
)
exact_quick_control = authorization.reason == "exact_quick_control"
+ explicit_local_control = authorization.reason in {
+ "exact_quick_control",
+ "explicit_registered_control",
+ }
saved_mode_execution = authorization.reason == "explicit_saved_mode_execute"
mode_profile_management_route = {
"explicit_mode_profile_update": "mode_profile_update",
@@ -1776,6 +2100,8 @@ class CodexAssistantService:
if saved_mode_execution
else "正在通过车辆安全链执行快捷控制……"
if exact_quick_control
+ else "正在通过本地规则解析并下发控制……"
+ if explicit_local_control
else "正在通过车辆安全链执行舒适性调节……"
),
}
@@ -1790,6 +2116,20 @@ class CodexAssistantService:
nonlocal stream_open
if not stream_open:
return
+ validated_payload = self._validated_control_payload(payload)
+ if validated_payload is not None:
+ stream_open = (
+ emit(
+ {
+ "type": "validated_control_commands",
+ "message_id": message_id,
+ "payload": validated_payload,
+ }
+ )
+ is not False
+ )
+ if not stream_open:
+ return
stream_open = (
emit(
{
@@ -1802,6 +2142,8 @@ class CodexAssistantService:
if saved_mode_execution
else "功能模式存储请求已解析,正在校验……"
if mode_profile_management_route is not None
+ else "本地控制指令已通过校验并进入下发链路……"
+ if explicit_local_control
else "控制指令已通过校验,正在执行……"
),
"payload": self._safe_vehicle_result(payload),
@@ -1909,6 +2251,14 @@ class CodexAssistantService:
if exact_quick_control and successful
else "快捷控制未完成"
if exact_quick_control
+ else "明确控制已提交,等待车辆状态确认"
+ if explicit_local_control and action_state == "submitted"
+ else "明确控制模拟编排已完成"
+ if explicit_local_control and action_state == "simulated"
+ else "明确控制已完成"
+ if explicit_local_control and successful
+ else "明确控制未完成"
+ if explicit_local_control
else "舒适性调节已提交,等待车辆状态确认"
if action_state == "submitted"
else "舒适性调节模拟编排已完成"
@@ -2251,7 +2601,11 @@ class CodexAssistantService:
result: Mapping[str, object],
successful: bool,
) -> str:
- if reason in {"exact_quick_control", "explicit_saved_mode_execute"}:
+ if reason in {
+ "exact_quick_control",
+ "explicit_registered_control",
+ "explicit_saved_mode_execute",
+ }:
instruction = re.sub(
r"\s+",
" ",
@@ -2260,7 +2614,7 @@ class CodexAssistantService:
or (
"功能模式"
if reason == "explicit_saved_mode_execute"
- else "快捷控制指令"
+ else "控制指令"
)
),
).strip()[:160]
@@ -2332,12 +2686,24 @@ class CodexAssistantService:
answer_redactor = _StreamingSecretRedactor(request_token)
cancelled = False
deadline = time.monotonic() + self._turn_timeout
+ application_context = dict(capability_context)
+ if not isinstance(
+ application_context.get("natural_language_routing"),
+ Mapping,
+ ):
+ application_context["natural_language_routing"] = {
+ "original_utterance": message,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [message],
+ "cloud_input_utterance": message,
+ }
try:
active = self.client.start_turn(
conversation.thread_id,
message,
request_token,
- application_context=capability_context,
+ application_context=application_context,
)
terminal: dict[str, object] | None = None
while terminal is None:
@@ -2642,6 +3008,17 @@ class CodexAssistantService:
)
def on_progress(payload: dict[str, object]) -> None:
+ validated_payload = self._validated_control_payload(
+ payload,
+ secret=request_token,
+ )
+ if validated_payload is not None:
+ context.events.put(
+ {
+ "type": "validated_control_commands",
+ "payload": validated_payload,
+ }
+ )
context.events.put(
{
"type": "action.progress",
@@ -2960,8 +3337,9 @@ class CodexAssistantService:
for raw_key, raw_value in list(value.items())[:24]:
key = str(raw_key)[:80]
if re.search(
- r"token|secret|password|authorization|approval|raw.?can|"
- r"can.?frame|data.?hex|payload.?hex",
+ r"token|secret|password|authorization|approval|api.?key|"
+ r"credential|cookie|bearer|private.?key|access.?key|"
+ r"raw.?can|can.?frame|data.?hex|payload.?hex",
key,
re.IGNORECASE,
):
@@ -2984,7 +3362,290 @@ class CodexAssistantService:
)
for item in list(value)[:24]
]
- return str(value).replace(secret, "[内部令牌已隐藏]")[:240]
+ text = str(value)
+ if secret:
+ text = text.replace(secret, "[内部令牌已隐藏]")
+ return text[:240]
+
+ @staticmethod
+ def _validated_control_payload(
+ result: Mapping[str, object],
+ *,
+ secret: str = "",
+ ) -> dict[str, object] | None:
+ """Build a browser-only allowlist projection of compiled controls.
+
+ MCP tool results intentionally exclude command plans and raw CAN
+ material. The web console still needs a deterministic view after
+ validation, so copy only semantic fields, canonical debug commands,
+ reconstructed IR payloads, display-safe CAN bytes, and stage timings.
+ """
+
+ raw_configurations = result.get("function_configs")
+ configuration_entries: list[tuple[str, Mapping[str, object]]] = []
+ if isinstance(raw_configurations, Mapping):
+ configuration_entries.extend(
+ (str(key), value)
+ for key, value in raw_configurations.items()
+ if isinstance(value, Mapping)
+ )
+ if not configuration_entries and isinstance(
+ result.get("function_config"), Mapping
+ ):
+ configuration_entries.append(("default", result["function_config"]))
+
+ raw_plans = result.get("command_plans")
+ plan_entries: list[tuple[str, Mapping[str, object]]] = []
+ if isinstance(raw_plans, Mapping):
+ plan_entries.extend(
+ (str(key), value)
+ for key, value in raw_plans.items()
+ if isinstance(value, Mapping)
+ )
+ if not plan_entries and isinstance(result.get("command_plan"), Mapping):
+ plan_entries.append(("default", result["command_plan"]))
+ if not configuration_entries or not plan_entries:
+ return None
+
+ def safe_text(value: object, limit: int) -> str:
+ """Return bounded display text without copying the request secret."""
+
+ text = str(value or "")
+ if secret and secret in text:
+ return ""
+ return text[:limit]
+
+ def command_refs(command: Mapping[str, object]) -> tuple[str, ...]:
+ raw_refs = command.get("operation_ref")
+ if isinstance(raw_refs, Sequence) and not isinstance(
+ raw_refs, (str, bytes, bytearray)
+ ):
+ values = raw_refs
+ else:
+ values = str(raw_refs or "").split(",")
+ return tuple(str(value).strip() for value in values if str(value).strip())
+
+ def matching_commands(
+ entries: Sequence[tuple[str, Mapping[str, object]]],
+ operation_id: str,
+ ) -> list[tuple[Mapping[str, object], Mapping[str, object]]]:
+ matches: list[tuple[Mapping[str, object], Mapping[str, object]]] = []
+ for _plan_key, plan in entries:
+ spec = plan.get("spec")
+ if not isinstance(spec, Mapping):
+ continue
+ commands = spec.get("commands")
+ if not isinstance(commands, Sequence) or isinstance(
+ commands, (str, bytes, bytearray)
+ ):
+ continue
+ matches.extend(
+ (command, plan)
+ for command in commands
+ if isinstance(command, Mapping)
+ and operation_id in command_refs(command)
+ )
+ return matches
+
+ presentations: list[dict[str, object]] = []
+ for config_key, configuration in configuration_entries[:16]:
+ spec = configuration.get("spec")
+ if not isinstance(spec, Mapping):
+ continue
+ operations = spec.get("operations")
+ if not isinstance(operations, Sequence) or isinstance(
+ operations, (str, bytes, bytearray)
+ ):
+ continue
+ raw_domain = str(spec.get("domain") or "")
+ domain = safe_text(raw_domain, 80)
+ preferred_plans = [
+ entry for entry in plan_entries if entry[0] == config_key
+ ]
+ for operation in operations[:24]:
+ if not isinstance(operation, Mapping):
+ continue
+ raw_operation_id = str(operation.get("id") or "").strip()[:160]
+ operation_id = safe_text(raw_operation_id, 160).strip()
+ capability = safe_text(operation.get("capability"), 160).strip()
+ if not raw_operation_id or not operation_id or not capability:
+ continue
+ compiled = matching_commands(preferred_plans, raw_operation_id)
+ if (
+ not preferred_plans
+ and len(configuration_entries) == 1
+ and len(plan_entries) == 1
+ ):
+ # Keep the legacy function_config/command_plan pair working,
+ # but never search globally across multi-config results. A
+ # repeated operation id must fail closed instead of borrowing
+ # a compiled command from another domain.
+ compiled = matching_commands(plan_entries, raw_operation_id)
+ if not compiled:
+ continue
+
+ channel = "IR" if raw_domain == "self_extension" else "CAN1"
+ actual_commands: list[str] = []
+ frames: list[dict[str, str]] = []
+ for command, plan in compiled[:16]:
+ signals = command.get("signals")
+ signals = signals if isinstance(signals, Mapping) else {}
+ plan_spec = plan.get("spec")
+ plan_domain = (
+ str(plan_spec.get("domain") or "")
+ if isinstance(plan_spec, Mapping)
+ else ""
+ )
+ message = str(command.get("message") or "")
+ bus = str(command.get("bus") or "").lower()
+ if message == "infrared.emit" or plan_domain == "self_extension":
+ command_channel = "IR"
+ elif "seat" in bus or "can0" in bus:
+ command_channel = "CAN0"
+ else:
+ command_channel = "CAN1"
+ channel = command_channel
+
+ debug_command = " ".join(
+ safe_text(signals.get("debug_command"), 256).split()
+ )
+ if re.fullmatch(r"[A-Za-z0-9_.:+% -]{1,256}", debug_command):
+ if debug_command not in actual_commands:
+ actual_commands.append(debug_command)
+
+ slot_value = signals.get("slot")
+ slot = (
+ int(slot_value)
+ if isinstance(slot_value, int)
+ and not isinstance(slot_value, bool)
+ and 1 <= slot_value <= 248
+ else None
+ )
+ if message == "infrared.emit" and slot is not None:
+ actual = f"infrared emit {slot}"
+ if actual not in actual_commands:
+ actual_commands.append(actual)
+ frame = {
+ "label": "GSCU1B TCP",
+ "value": json.dumps(
+ {
+ "action": "emit",
+ "data": {"no": slot},
+ "type": "infrared",
+ },
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ),
+ }
+ if frame not in frames:
+ frames.append(frame)
+ continue
+
+ frame_id = str(signals.get("frame_id") or "").strip()
+ data_bytes = " ".join(
+ str(signals.get("data_hex") or "").strip().split()
+ ).upper()
+ if re.fullmatch(
+ r"0[xX][0-9A-Fa-f]{1,8}", frame_id
+ ) and re.fullmatch(
+ r"[0-9A-F]{2}(?: [0-9A-F]{2}){0,63}", data_bytes
+ ):
+ frame = {
+ "label": f"{command_channel} 帧",
+ "value": f"0x{frame_id[2:].upper()} · {data_bytes}",
+ }
+ if frame not in frames:
+ frames.append(frame)
+
+ safe_target = CodexAssistantService._safe_result_value(
+ operation.get("target") or {}, secret=secret
+ )
+ safe_parameters = CodexAssistantService._safe_result_value(
+ operation.get("parameters") or {}, secret=secret
+ )
+ presentations.append(
+ {
+ "operation": {
+ "id": operation_id,
+ "capability": capability,
+ "target": safe_target if isinstance(safe_target, dict) else {},
+ "parameters": (
+ safe_parameters
+ if isinstance(safe_parameters, dict)
+ else {}
+ ),
+ **(
+ {
+ "parse_source": safe_text(
+ operation.get("parse_source"), 80
+ )
+ }
+ if safe_text(operation.get("parse_source"), 80)
+ else {}
+ ),
+ },
+ "domain": domain,
+ "channel": channel,
+ "actual_command": ";".join(actual_commands),
+ "frames": frames,
+ }
+ )
+
+ if not presentations:
+ return None
+
+ allowed_trace_stages = {
+ "intent_parse",
+ "context_resolve",
+ "domain_route",
+ "function_generate",
+ "schema_validate",
+ "domain_validate",
+ "compile",
+ "nfc_peripheral_route",
+ "local_peripheral_plan",
+ "nfc_peripheral_sequence",
+ }
+ safe_trace: list[dict[str, object]] = []
+ raw_trace = result.get("trace_events")
+ if isinstance(raw_trace, Sequence) and not isinstance(
+ raw_trace, (str, bytes, bytearray)
+ ):
+ for item in raw_trace[:32]:
+ if not isinstance(item, Mapping):
+ continue
+ stage = str(item.get("stage") or "")
+ duration = item.get("duration_ms")
+ if (
+ stage in allowed_trace_stages
+ and isinstance(duration, (int, float))
+ and not isinstance(duration, bool)
+ and duration >= 0
+ ):
+ safe_trace.append({"stage": stage, "duration_ms": duration})
+
+ payload: dict[str, object] = {
+ "validated_control_commands": presentations,
+ "trace_events": safe_trace,
+ }
+ for key in (
+ "request_id",
+ "preview_request_id",
+ "supplement_of",
+ "utterance",
+ "provider_used",
+ "execution_mode",
+ "control_transport",
+ "local_control_budget_ms",
+ "local_control_elapsed_ms",
+ "local_control_within_budget",
+ "natural_language_routing",
+ ):
+ if result.get(key) is not None:
+ payload[key] = CodexAssistantService._safe_result_value(
+ result[key], secret=secret
+ )
+ return payload
@staticmethod
def _safe_vehicle_result(
diff --git a/src/cognitive_agent/application/native_can_intent.py b/src/cognitive_agent/application/native_can_intent.py
index 4987c6b..4a59b9e 100644
--- a/src/cognitive_agent/application/native_can_intent.py
+++ b/src/cognitive_agent/application/native_can_intent.py
@@ -20,6 +20,10 @@ WINDOW_TARGETS = {
"所有": "all",
"四个": "all",
"全车": "all",
+ "左前排": "front_left",
+ "右前排": "front_right",
+ "左后排": "rear_left",
+ "右后排": "rear_right",
"前排": "front",
"前座": "front",
"后排": "rear",
@@ -88,7 +92,8 @@ LIGHT_TARGETS = {
_EXPLICIT_LOCATION_HINT = re.compile(
r"全部|所有|四个|全车|整车|前排|前座|后排|后座|二排|第二排|"
r"中排|第三排|主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|"
- r"乘客位|乘员|左前|前左|右前|前右|左后|后左|右后|后右|"
+ r"乘客位|乘员|左前排|右前排|左后排|右后排|"
+ r"左前|前左|右前|前右|左后|后左|右后|后右|"
r"左侧|右侧|左边|右边"
)
_WHOLE_VEHICLE_HINT = re.compile(r"全部|所有|四个|全车|整车")
@@ -339,6 +344,7 @@ CH1_DEFAULT_TARGETS = {
_EXPLICIT_TARGET_PATTERNS = {
"window": re.compile(
r"(?:全部|所有|四个|全车|前排|前座|后排|后座|二排|第二排|"
+ r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|"
r"主驾驶|驾驶位|驾驶员|司机位|主驾|副驾驶|乘客位|乘员|副驾)"
r"(?:的)?(?:车窗|窗)|window\s+(?:all|front|rear|front-left|front-right|rear-left|rear-right)",
@@ -1094,11 +1100,16 @@ def _parse_chinese_window_commands(compact: str) -> list[dict[str, Any]]:
clauses = [clause for clause in CLAUSE_SEPARATOR.split(compact) if clause]
exclusion_pattern = re.compile(
r"^(?:主驾|主驾驶|驾驶位|司机位|副驾|副驾驶|乘客位|"
+ r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右)"
- r"(?:车窗)?(?:除外|除去|不包括|不含)$"
+ r"(?:车窗|窗)?(?:除外|除去|不包括|不含)$"
)
for index, clause in enumerate(clauses):
- if "车窗" not in clause:
+ if (
+ "车窗" not in clause
+ and "窗户" not in clause
+ and not _EXPLICIT_TARGET_PATTERNS["window"].search(clause)
+ ):
continue
window_text = clause
if index + 1 < len(clauses) and exclusion_pattern.search(
@@ -2007,7 +2018,8 @@ def merge_native_can_intent(
]
constraints = list(result.get("semantic_constraints", []))
for match in re.finditer(
- r"(?P左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?"
+ r"(?P左前排|右前排|左后排|右后排|"
+ r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗|窗)?"
r"(?:除外|除去|不包括|不含)",
utterance,
):
diff --git a/src/cognitive_agent/application/native_can_relative.py b/src/cognitive_agent/application/native_can_relative.py
index 81980b7..a738587 100644
--- a/src/cognitive_agent/application/native_can_relative.py
+++ b/src/cognitive_agent/application/native_can_relative.py
@@ -34,6 +34,8 @@ CHINESE_DIGITS = {
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10,
}
WINDOW_TARGET_LABELS = {
+ "左前排": ("front_left",), "右前排": ("front_right",),
+ "左后排": ("rear_left",), "右后排": ("rear_right",),
"后排左侧": ("rear_left",), "后座左侧": ("rear_left",),
"二排左侧": ("rear_left",), "第二排左侧": ("rear_left",),
"后排右侧": ("rear_right",), "后座右侧": ("rear_right",),
@@ -57,6 +59,7 @@ WINDOW_TARGET_LABELS = {
NARROW_LOCATION = re.compile(
r"主驾|主驾驶|驾驶位|驾驶员|司机位|副驾|副驾驶|乘客位|乘员|"
r"前排|前座|后排|后座|二排|第二排|中排|第三排|三排|前舱|后舱|"
+ r"左前排|右前排|左后排|右后排|"
r"左前|前左|右前|前右|左后|后左|右后|后右|左侧|右侧|左边|右边"
)
UNSUPPORTED_SINGLE_HVAC_ZONE = re.compile(
diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py
index 1a722d7..fb412be 100755
--- a/src/cognitive_agent/application/seat_service.py
+++ b/src/cognitive_agent/application/seat_service.py
@@ -171,6 +171,7 @@ MODE_PROFILE_UPDATE_EXPLICIT_TARGET_PATTERN = re.compile(
)
WIRED_CAN_TRANSPORT = "wired_can"
WIRELESS_MQTT_TRANSPORT = "wireless_mqtt"
+LOCAL_CONTROL_BUDGET_MS = 1000
VEHICLE_CONTROL_TRANSPORTS = frozenset(
{WIRED_CAN_TRANSPORT, WIRELESS_MQTT_TRANSPORT}
)
@@ -292,6 +293,7 @@ CONTROL_CLAUSE_SUBJECT_PATTERN = re.compile(
r"座椅|座位|靠背|椅背|坐盆|坐垫|腿托|脚托|滑轨|"
r"通风|加热|按摩|零重力|半躺|女王副驾|休闲躺椅|"
r"遮阳帘|天幕帘|天幕|车窗|窗户|"
+ r"(?:左前|前左|右前|前右|左后|后左|右后|后右)(?:排)?窗|"
r"灯光|室内灯|车内灯|阅读灯|氛围灯|"
r"空调|压缩机|温度|风量|风速|内循环|外循环|"
r"车门|门锁|后备箱|前大灯|近光灯|远光灯|雾灯|转向灯|双闪|"
@@ -302,10 +304,17 @@ CONTROL_CLAUSE_PREDICATE_PATTERN = re.compile(
r"调整|调节|调到|调成|调为|调至|设置|设为|设到|切换|"
r"移动|移到|移至|前移|后移|上移|下移|升高|降低|抬起|放倒|"
r"调直|立直|竖直|归零|复位|锁上|上锁|落锁|解锁|"
- r"零重力|半躺|最大|最小|最高|最低|最前|最后|到底|"
+ r"零重力|半躺|一半|半开|半关|最大|最小|最高|最低|最前|最后|到底|"
r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)"
r"(?:%|%|度|℃|档|级|秒|毫秒)"
)
+# These verbs identify an independent request that may be outside the local
+# vehicle registry. They are used only to split mixed sentences so an
+# already deterministic clause never has to be repeated to a hosted model.
+CLOUD_REQUEST_CLAUSE_PATTERN = re.compile(
+ r"播放|暂停播放|停止播放|导航|搜索|查找|查询|拨打|呼叫|发送|"
+ r"提醒|推荐|讲解|介绍|告诉|规划"
+)
RESTORE_MODE_PROFILE_PREFIX = re.compile(
r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)"
r"(?P[^,。,.!!\s]{1,64}?模式)"
@@ -1613,9 +1622,11 @@ class SeatDemoService:
on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
assistant_plan: dict[str, Any] | None = None,
mode_profile_draft_context: dict[str, Any] | None = None,
+ _cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate and execute deterministic controls before hosted parsing."""
+ local_stage_started = time.perf_counter()
utterance = utterance.strip()
if not utterance:
raise ValueError("utterance must not be empty")
@@ -1644,6 +1655,7 @@ class SeatDemoService:
_on_commands_validated=on_commands_validated,
_assistant_plan=assistant_plan,
_mode_profile_draft_context=mode_profile_draft_context,
+ _cloud_instruction_context=_cloud_instruction_context,
)
result.update(
{
@@ -1662,14 +1674,71 @@ class SeatDemoService:
vehicle_profile,
)
clause_results = self._local_clause_results(utterance, context)
- unresolved_clauses = [
+ locally_parsed_clauses = [
+ clause
+ for clause, _, status in clause_results
+ if status != "unparsed"
+ ]
+ locally_resolved_clauses = [
+ clause
+ for clause, _, status in clause_results
+ if status == "complete"
+ ]
+ locally_unresolved_clauses = [
clause
for clause, _, status in clause_results
if status != "complete"
]
- started = time.perf_counter()
+ supplied_routing = (
+ _cloud_instruction_context
+ if isinstance(_cloud_instruction_context, dict)
+ else {}
+ )
+
+ def routing_clauses(key: str) -> list[str]:
+ raw = supplied_routing.get(key)
+ if not isinstance(raw, list):
+ return []
+ return [
+ text
+ for item in raw[:64]
+ for text in [str(item).strip()[:1000]]
+ if text
+ ]
+
+ def unique_clauses(*groups: list[str]) -> list[str]:
+ return list(dict.fromkeys(item for group in groups for item in group))
+
+ parsed_clauses = unique_clauses(
+ routing_clauses("parsed_clauses"),
+ locally_parsed_clauses,
+ )
+ resolved_clauses = unique_clauses(
+ routing_clauses("resolved_clauses"),
+ locally_resolved_clauses,
+ )
+ unresolved_clauses = locally_unresolved_clauses
+ routing_unresolved_clauses = unique_clauses(
+ routing_clauses("unresolved_clauses"),
+ locally_unresolved_clauses,
+ )
+ original_utterance = str(
+ supplied_routing.get("original_utterance") or utterance
+ ).strip()[:1000]
+ natural_language_routing = {
+ "original_utterance": original_utterance,
+ "parsed_clauses": parsed_clauses,
+ "resolved_clauses": resolved_clauses,
+ "unresolved_clauses": routing_unresolved_clauses,
+ }
+ local_dispatch_elapsed_ms: float | None = None
def publish_local_commands(validated: dict[str, Any]) -> None:
+ nonlocal local_dispatch_elapsed_ms
+ local_dispatch_elapsed_ms = round(
+ (time.perf_counter() - local_stage_started) * 1000,
+ 3,
+ )
if on_commands_validated is None:
return
event = deepcopy(validated)
@@ -1681,6 +1750,14 @@ class SeatDemoService:
"supplement_required": bool(
provider != "local" and unresolved_clauses
),
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": local_dispatch_elapsed_ms,
+ "local_control_within_budget": (
+ local_dispatch_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
+ ),
}
)
on_commands_validated(event)
@@ -1702,8 +1779,17 @@ class SeatDemoService:
_on_commands_validated=publish_local_commands,
_assistant_plan=assistant_plan,
_mode_profile_draft_context=mode_profile_draft_context,
+ _cloud_instruction_context=natural_language_routing,
+ )
+ elapsed_ms = round(
+ (time.perf_counter() - local_stage_started) * 1000,
+ 3,
+ )
+ local_control_elapsed_ms = (
+ local_dispatch_elapsed_ms
+ if local_dispatch_elapsed_ms is not None
+ else elapsed_ms
)
- elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
supplement_required = bool(
provider != "local" and unresolved_clauses
)
@@ -1713,7 +1799,17 @@ class SeatDemoService:
"provider_requested": provider,
"preview_request_id": result["request_id"],
"preview_elapsed_ms": elapsed_ms,
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": local_control_elapsed_ms,
+ "local_control_within_budget": (
+ local_control_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "parsed_clauses": parsed_clauses,
+ "resolved_clauses": resolved_clauses,
"unresolved_clauses": unresolved_clauses,
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
"supplement_required": supplement_required,
}
)
@@ -1737,7 +1833,10 @@ class SeatDemoService:
"hardware_client_id": hardware_client_id,
"reasoning_effort": reasoning_effort,
"control_transport": control_transport,
+ "parsed_clauses": parsed_clauses,
+ "resolved_clauses": resolved_clauses,
"unresolved_clauses": unresolved_clauses,
+ "natural_language_routing": natural_language_routing,
"preview_control_signatures": result.get(
"parsed_control_signatures", []
),
@@ -1802,6 +1901,34 @@ class SeatDemoService:
for item in manifest.get("preview_control_signatures", [])
if isinstance(item, list)
}
+ manifest_routing = manifest.get("natural_language_routing")
+ natural_language_routing = (
+ deepcopy(manifest_routing)
+ if isinstance(manifest_routing, dict)
+ else {
+ "original_utterance": str(manifest.get("utterance") or ""),
+ "parsed_clauses": list(manifest.get("parsed_clauses") or []),
+ "resolved_clauses": list(manifest.get("resolved_clauses") or []),
+ "unresolved_clauses": unresolved_clauses,
+ }
+ )
+ routing_unresolved = natural_language_routing.get("unresolved_clauses")
+ natural_language_routing["unresolved_clauses"] = list(
+ dict.fromkeys(
+ [
+ str(item).strip()
+ for item in (
+ routing_unresolved
+ if isinstance(routing_unresolved, list)
+ else []
+ )
+ if str(item).strip()
+ ]
+ + unresolved_clauses
+ )
+ )
+ cloud_utterance = ",".join(unresolved_clauses)
+ natural_language_routing["cloud_input_utterance"] = cloud_utterance
def publish_cloud_commands(validated: dict[str, Any]) -> None:
if on_commands_validated is None:
@@ -1818,7 +1945,7 @@ class SeatDemoService:
on_commands_validated(event)
supplement = self.process(
- ",".join(unresolved_clauses),
+ cloud_utterance,
provider=selected_provider,
conversation_id=str(manifest["conversation_id"]),
execution_mode=str(manifest.get("execution_mode") or "dry_run"),
@@ -1837,6 +1964,7 @@ class SeatDemoService:
_model_parse_source="cloud_supplement",
_allow_empty_control_result=True,
_on_commands_validated=publish_cloud_commands,
+ _cloud_instruction_context=natural_language_routing,
)
merged = self._merge_native_can_phases(preview, supplement)
merged.update(
@@ -1853,6 +1981,7 @@ class SeatDemoService:
"supplement_request_id": supplement["request_id"],
"supplement_required": False,
"unresolved_clauses": unresolved_clauses,
+ "natural_language_routing": natural_language_routing,
}
)
self._persist(merged)
@@ -2149,6 +2278,7 @@ class SeatDemoService:
_speech_understanding: dict[str, Any] | None = None,
_assistant_plan: dict[str, Any] | None = None,
_mode_profile_draft_context: dict[str, Any] | None = None,
+ _cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
# Creating a reusable scene is a configuration workflow. Synthesize
@@ -2197,6 +2327,7 @@ class SeatDemoService:
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
+ cloud_instruction_context=_cloud_instruction_context,
)
if control_transport == WIRELESS_MQTT_TRANSPORT:
# The head unit owns both physical CAN channels in wireless mode.
@@ -2222,6 +2353,7 @@ class SeatDemoService:
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
+ cloud_instruction_context=_cloud_instruction_context,
)
if hardware_access_bypass or self._driver_only_vehicle_execution_active(
control_transport
@@ -2247,6 +2379,7 @@ class SeatDemoService:
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
+ cloud_instruction_context=_cloud_instruction_context,
)
authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id)
with self.hardware_execution.reserve():
@@ -2271,6 +2404,7 @@ class SeatDemoService:
_speech_understanding,
assistant_plan=_assistant_plan,
mode_profile_draft_context=_mode_profile_draft_context,
+ cloud_instruction_context=_cloud_instruction_context,
)
def _process_once(
@@ -2296,6 +2430,7 @@ class SeatDemoService:
allow_fuzzy_mode_profile_match: bool = True,
allow_scene_composition: bool = False,
mode_profile_draft_context: dict[str, Any] | None = None,
+ cloud_instruction_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
control_transport = self._validate_control_transport(control_transport)
utterance = utterance.strip()
@@ -2626,6 +2761,7 @@ class SeatDemoService:
model_parse_source=model_parse_source,
assistant_plan=assistant_plan,
allow_scene_composition=allow_scene_composition,
+ cloud_instruction_context=cloud_instruction_context,
)
dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance)
if dynamic_mode_stop:
@@ -7280,6 +7416,11 @@ class SeatDemoService:
and CONTROL_CLAUSE_PREDICATE_PATTERN.search(value)
)
+ def looks_like_independent_request(value: str) -> bool:
+ return looks_like_control(value) or bool(
+ CLOUD_REQUEST_CLAUSE_PATTERN.search(value)
+ )
+
def split_independent_controls(
value: str,
separator_pattern: str,
@@ -7290,7 +7431,14 @@ class SeatDemoService:
for index in range(1, len(tokens), 2):
separator = tokens[index]
next_item = tokens[index + 1].strip()
- if looks_like_control(current) and looks_like_control(next_item):
+ if (
+ looks_like_independent_request(current)
+ and looks_like_independent_request(next_item)
+ and (
+ looks_like_control(current)
+ or looks_like_control(next_item)
+ )
+ ):
if current:
separated.append(current)
current = next_item
@@ -7600,6 +7748,7 @@ class SeatDemoService:
model_parse_source: str = "cloud_model",
assistant_plan: dict[str, Any] | None = None,
allow_scene_composition: bool = False,
+ cloud_instruction_context: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, str | None]:
vehicle_profile = vehicle_profile or self.vehicle_profiles.default()
if assistant_plan is not None and allow_scene_composition:
@@ -7764,9 +7913,29 @@ class SeatDemoService:
interpret_options = {}
if reasoning_effort is not None:
interpret_options["reasoning_effort"] = reasoning_effort
+ hosted_context = deepcopy(context)
+ if isinstance(cloud_instruction_context, dict):
+ routing_context = deepcopy(cloud_instruction_context)
+ else:
+ routing_context = {
+ "original_utterance": utterance,
+ "parsed_clauses": [
+ clause
+ for clause, _, status in clause_results
+ if status != "unparsed"
+ ],
+ "resolved_clauses": [
+ clause
+ for clause, _, status in clause_results
+ if status == "complete"
+ ],
+ "unresolved_clauses": fallback_clauses,
+ }
+ routing_context["cloud_input_utterance"] = fallback_utterance
+ hosted_context["natural_language_routing"] = routing_context
plan = self.openai.interpret(
fallback_utterance,
- context,
+ hosted_context,
self.capability_context,
**interpret_options,
)
diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py
index e7f3a4b..9407db8 100644
--- a/src/cognitive_agent/application/self_extension.py
+++ b/src/cognitive_agent/application/self_extension.py
@@ -15,7 +15,7 @@ import uuid
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
-from typing import Any
+from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
@@ -2387,6 +2387,7 @@ class SelfExtensionService:
conversation_turn: int = 0,
execution_mode: str = "dry_run",
provider_requested: str = "auto",
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a live-NFC peripheral match through the main request API."""
@@ -2398,41 +2399,8 @@ class SelfExtensionService:
request_id = f"req-extension-{uuid.uuid4().hex}"
conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}"
hardware_request = execution_mode == "hardware"
- if hardware_request:
- gateway_result = self.execute_gateway("emit", {"slot": slot})
- transport = str(gateway_result.get("transport") or self.gateway.transport)
- gateway_acknowledged = gateway_result.get("gateway_acknowledged") is True
- command = gateway_result.get("request")
- operation_message = (
- f"{capability.get('device_name')} · {capability.get('action')} 已发送至 "
- "GSCU1B;网关已确认接收,外设物理效果待独立确认。"
- )
- delivery_status = "published"
- else:
- command = build_gateway_command("emit", {"slot": slot})
- transport = "dry_run"
- gateway_acknowledged = False
- operation_message = (
- f"{capability.get('device_name')} · {capability.get('action')} 已完成红外路由校验;"
- "当前为 dry-run,未向 GSCU1B 发射。"
- )
- delivery_status = "planned"
-
+ transport = self.gateway.transport if hardware_request else "dry_run"
operation_ref = str(capability.get("id") or f"ir-slot-{slot}")
- operation = {
- "ref": operation_ref,
- "domain": "self_extension",
- "status": "succeeded",
- "message": operation_message,
- "telemetry": {
- "transport": transport,
- "slot": slot,
- "delivery_status": delivery_status,
- "gateway_acknowledged": gateway_acknowledged,
- "physical_effect_confirmed": False,
- "command": command,
- },
- }
function_config = {
"apiVersion": "cognitive-agent.io/v1alpha1",
"kind": "FunctionConfig",
@@ -2455,7 +2423,9 @@ class SelfExtensionService:
"action": capability.get("action"),
"slot": slot,
},
- "parse_source": capability.get("parse_source", "local_rule"),
+ "parse_source": capability.get(
+ "parse_source", "local_rule"
+ ),
"timeout_ms": int(self.command_timeout * 1000),
"failure_policy": "abort",
}
@@ -2479,6 +2449,78 @@ class SelfExtensionService:
],
},
}
+ if on_commands_validated is not None:
+ on_commands_validated(
+ {
+ "request_id": request_id,
+ "utterance": utterance,
+ "conversation_id": conversation_id,
+ "conversation_turn": conversation_turn,
+ "status": "processing",
+ "phase": "commands_validated",
+ "orchestration_route": "nfc_peripheral",
+ "provider_requested": provider_requested,
+ "provider_used": "nfc_peripheral_router",
+ "execution_mode": execution_mode,
+ "control_transport": (
+ "wireless_mqtt"
+ if transport == "mqtt"
+ else f"gscu_{transport}"
+ ),
+ "trace_events": [
+ {
+ "stage": "nfc_peripheral_route",
+ "status": "succeeded",
+ "summary": "本地规则已完成红外能力路由与指令编译",
+ "duration_ms": 0,
+ }
+ ],
+ "validation": [],
+ "function_config": function_config,
+ "function_configs": {"self_extension": function_config},
+ "command_plan": command_plan,
+ "command_plans": {"self_extension": command_plan},
+ "execution_report": None,
+ "operation_results": [],
+ "deferred_requests": [],
+ "domain_state": {},
+ "actuator_state": {},
+ }
+ )
+ if hardware_request:
+ gateway_result = self.execute_gateway("emit", {"slot": slot})
+ transport = str(gateway_result.get("transport") or self.gateway.transport)
+ gateway_acknowledged = gateway_result.get("gateway_acknowledged") is True
+ command = gateway_result.get("request")
+ operation_message = (
+ f"{capability.get('device_name')} · {capability.get('action')} 已发送至 "
+ "GSCU1B;网关已确认接收,外设物理效果待独立确认。"
+ )
+ delivery_status = "published"
+ else:
+ command = build_gateway_command("emit", {"slot": slot})
+ transport = "dry_run"
+ gateway_acknowledged = False
+ operation_message = (
+ f"{capability.get('device_name')} · {capability.get('action')} 已完成红外路由校验;"
+ "当前为 dry-run,未向 GSCU1B 发射。"
+ )
+ delivery_status = "planned"
+
+ operation = {
+ "ref": operation_ref,
+ "domain": "self_extension",
+ "status": "succeeded",
+ "message": operation_message,
+ "telemetry": {
+ "transport": transport,
+ "slot": slot,
+ "delivery_status": delivery_status,
+ "gateway_acknowledged": gateway_acknowledged,
+ "physical_effect_confirmed": False,
+ "command": command,
+ },
+ }
control_transport = (
"wireless_mqtt" if transport == "mqtt" else f"gscu_{transport}"
)
@@ -2548,6 +2590,7 @@ class SelfExtensionService:
conversation_turn: int = 0,
execution_mode: str = "dry_run",
provider_requested: str = "auto",
+ on_commands_validated: Callable[[dict[str, Any]], None] | None = None,
) -> dict[str, Any]:
"""Execute a validated ordered peripheral/delay plan for the live NFC."""
@@ -2642,6 +2685,76 @@ class SelfExtensionService:
}
)
+ function_config = {
+ "apiVersion": "cognitive-agent.io/v1alpha1",
+ "kind": "FunctionConfig",
+ "metadata": {
+ "name": f"{request_id}-sequence",
+ "labels": {"execution_scope": "nfc_bound_ir_peripheral"},
+ },
+ "spec": {
+ "domain": "self_extension",
+ "operations": function_operations,
+ },
+ }
+ command_plan = {
+ "apiVersion": "cognitive-agent.io/v1alpha1",
+ "kind": "CommandPlan",
+ "metadata": {"name": f"{request_id}-gscu-sequence"},
+ "spec": {
+ "domain": "self_extension",
+ "platform": "gscu1b",
+ "commands": commands,
+ },
+ }
+ if on_commands_validated is not None:
+ on_commands_validated(
+ {
+ "request_id": request_id,
+ "utterance": utterance,
+ "conversation_id": conversation_id,
+ "conversation_turn": conversation_turn,
+ "status": "processing",
+ "phase": "commands_validated",
+ "orchestration_route": "nfc_peripheral_sequence",
+ "provider_requested": provider_requested,
+ "provider_used": str(
+ plan_result.get("provider_used")
+ or f"gpt:{plan_result.get('model') or self.openai_model}"
+ ),
+ "execution_mode": execution_mode,
+ "control_transport": (
+ "wireless_mqtt"
+ if transport == "mqtt"
+ else f"gscu_{transport}"
+ ),
+ "trace_events": [
+ {
+ "stage": (
+ "local_peripheral_plan"
+ if str(plan_result.get("provider_used") or "").startswith(
+ "infrared_local"
+ )
+ else "gpt_peripheral_plan"
+ ),
+ "status": "succeeded",
+ "summary": f"已编译并校验 {len(steps)} 个有序步骤",
+ "duration_ms": 0,
+ }
+ ],
+ "validation": [],
+ "function_config": function_config,
+ "function_configs": {"self_extension": function_config},
+ "command_plan": command_plan,
+ "command_plans": {"self_extension": command_plan},
+ "execution_report": None,
+ "operation_results": [],
+ "deferred_requests": [],
+ "domain_state": {},
+ "actuator_state": {},
+ }
+ )
+
for index, step in enumerate(steps):
if step.get("kind") == "delay":
delay_ms = int(step.get("delay_ms", 0))
@@ -2781,28 +2894,6 @@ class SelfExtensionService:
)
report_status = "succeeded" if succeeded else "failed"
first_capability = next(iter(catalog_by_id.values()))
- function_config = {
- "apiVersion": "cognitive-agent.io/v1alpha1",
- "kind": "FunctionConfig",
- "metadata": {
- "name": f"{request_id}-sequence",
- "labels": {"execution_scope": "nfc_bound_ir_peripheral"},
- },
- "spec": {
- "domain": "self_extension",
- "operations": function_operations,
- },
- }
- command_plan = {
- "apiVersion": "cognitive-agent.io/v1alpha1",
- "kind": "CommandPlan",
- "metadata": {"name": f"{request_id}-gscu-sequence"},
- "spec": {
- "domain": "self_extension",
- "platform": "gscu1b",
- "commands": commands,
- },
- }
control_transport = (
"wireless_mqtt" if transport == "mqtt" else f"gscu_{transport}"
)
@@ -2988,12 +3079,22 @@ class SelfExtensionService:
item for item in vehicle_devices[:32] if isinstance(item, dict)
]
allowed_vehicle_commands = set(_vehicle_catalog_commands(vehicle_devices))
- response_text = self._request_gpt_plan(
- utterance,
- planner_peripherals,
- vehicle_devices,
- api_key,
- )
+ raw_routing = payload.get("natural_language_routing")
+ if isinstance(raw_routing, dict):
+ response_text = self._request_gpt_plan(
+ utterance,
+ planner_peripherals,
+ vehicle_devices,
+ api_key,
+ natural_language_routing=dict(raw_routing),
+ )
+ else:
+ response_text = self._request_gpt_plan(
+ utterance,
+ planner_peripherals,
+ vehicle_devices,
+ api_key,
+ )
try:
proposed = json.loads(response_text)
except json.JSONDecodeError as exc:
@@ -3120,6 +3221,8 @@ class SelfExtensionService:
peripherals: list[dict[str, Any]],
vehicle_devices: list[dict[str, Any]],
api_key: str,
+ *,
+ natural_language_routing: dict[str, Any] | None = None,
) -> str:
peripheral_ids = [
str(item.get("id"))
@@ -3172,6 +3275,9 @@ class SelfExtensionService:
"required": ["summary", "needs_confirmation", "question", "steps"],
}
system_prompt = f"""你是车载外设自扩展工作台的意图规划器。只输出 JSON 对象。
+输入中的 natural_language_routing 是可信路由数据:original_utterance 为完整原句,
+parsed_clauses/resolved_clauses 已由本地规则处理,只能为 unresolved_clauses 和当前 utterance
+补充计划,禁止重复、替换或重新执行已解析子句。
peripheral 只能逐字引用目录中 status=learned 的短 capability_id,绝不能编造或改写 ID。
仅当同类存在多台可匹配的外设玩具且用户未说明目标时,设置 needs_confirmation=true 并追问;
此追问规则不适用于车窗、空调、座椅、电动风口等车端能力。
@@ -3218,6 +3324,15 @@ delay 步骤的 capability_id 和 command 必须为空字符串,不能位于
"text": json.dumps(
{
"utterance": utterance,
+ "natural_language_routing": (
+ natural_language_routing or {
+ "original_utterance": utterance,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [utterance],
+ "cloud_input_utterance": utterance,
+ }
+ ),
"learned_peripheral_capabilities": peripherals,
"can_dev_vehicle_capabilities": vehicle_devices,
},
diff --git a/src/cognitive_agent/infrastructure/can/hardware.py b/src/cognitive_agent/infrastructure/can/hardware.py
index 3ac23ff..603f9f8 100755
--- a/src/cognitive_agent/infrastructure/can/hardware.py
+++ b/src/cognitive_agent/infrastructure/can/hardware.py
@@ -880,7 +880,7 @@ class VehicleCanSettings:
@dataclass(frozen=True)
class VehicleMqttSettings:
- """Wireless CAN0/CAN1 relay settings for the local head-unit broker."""
+ """Wireless CAN0/CAN1 relay settings for the head-unit broker."""
enabled: bool
approval_required: bool
@@ -895,10 +895,19 @@ class VehicleMqttSettings:
password: str | None
tls_enabled: bool
feedback_timeout_ms: int
+ transport: str = "tcp"
+ websocket_path: str = "/mqtt"
@classmethod
def from_env(cls) -> "VehicleMqttSettings":
username = os.getenv("COGNITIVE_AGENT_VEHICLE_MQTT_USERNAME")
+ raw_transport = os.getenv(
+ "COGNITIVE_AGENT_VEHICLE_MQTT_TRANSPORT", "tcp"
+ ).strip().lower()
+ transport = {
+ "ws": "websockets",
+ "websocket": "websockets",
+ }.get(raw_transport, raw_transport)
return cls(
enabled=_env_bool("COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", default=True),
approval_required=_env_bool(
@@ -927,6 +936,10 @@ class VehicleMqttSettings:
feedback_timeout_ms=_env_int(
"COGNITIVE_AGENT_VEHICLE_MQTT_FEEDBACK_TIMEOUT_MS", 1_500
),
+ transport=transport,
+ websocket_path=os.getenv(
+ "COGNITIVE_AGENT_VEHICLE_MQTT_WEBSOCKET_PATH", "/mqtt"
+ ).strip(),
)
@property
@@ -940,6 +953,11 @@ class VehicleMqttSettings:
and self.qos in {0, 1, 2}
and 5 <= self.keepalive_s <= 3_600
and 100 <= self.feedback_timeout_ms <= 10_000
+ and self.transport in {"tcp", "websockets"}
+ and (
+ self.transport != "websockets"
+ or self.websocket_path.startswith("/")
+ )
and _module_available("paho.mqtt.client")
)
@@ -984,6 +1002,10 @@ class VehicleMqttSettings:
"control_topic": self.control_topic,
"qos": self.qos,
"tls_enabled": self.tls_enabled,
+ "transport": self.transport,
+ "websocket_path": (
+ self.websocket_path if self.transport == "websockets" else None
+ ),
"authentication_configured": bool(self.username),
"channels": {"seat": 0, "vehicle": 1},
"target_policy": "dual_channel_mqtt_can0_passenger_can1_vehicle",
diff --git a/src/cognitive_agent/infrastructure/can/mqtt_transport.py b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
index ef3bca7..d9948f2 100644
--- a/src/cognitive_agent/infrastructure/can/mqtt_transport.py
+++ b/src/cognitive_agent/infrastructure/can/mqtt_transport.py
@@ -627,14 +627,20 @@ class MqttCanTransport:
except ImportError as exc:
raise CanGatewayError("paho-mqtt is not installed") from exc
client_id = f"n7-cognitive-agent-{uuid.uuid4().hex[:12]}"
+ transport = getattr(self.settings, "transport", "tcp")
try:
return mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
protocol=mqtt.MQTTv311,
+ transport=transport,
)
except (AttributeError, TypeError):
- return mqtt.Client(client_id=client_id, protocol=mqtt.MQTTv311)
+ return mqtt.Client(
+ client_id=client_id,
+ protocol=mqtt.MQTTv311,
+ transport=transport,
+ )
def start(self) -> None:
with self._condition:
@@ -647,6 +653,14 @@ class MqttCanTransport:
client.on_connect = self._on_connect
client.on_disconnect = self._on_disconnect
client.on_message = self._on_message
+ if getattr(self.settings, "transport", "tcp") == "websockets":
+ if not hasattr(client, "ws_set_options"):
+ raise CanGatewayError(
+ "the installed MQTT client does not support WebSockets"
+ )
+ client.ws_set_options(
+ path=getattr(self.settings, "websocket_path", "/mqtt")
+ )
if self.settings.username:
client.username_pw_set(self.settings.username, self.settings.password)
if self.settings.tls_enabled:
diff --git a/src/cognitive_agent/infrastructure/llm/openai_responses.py b/src/cognitive_agent/infrastructure/llm/openai_responses.py
index 0e54b48..f985f54 100755
--- a/src/cognitive_agent/infrastructure/llm/openai_responses.py
+++ b/src/cognitive_agent/infrastructure/llm/openai_responses.py
@@ -135,6 +135,11 @@ The capability registry is the source of truth for available domains, capabiliti
target scopes, and execution availability. The runtime conversation context is the source of truth
for current actuator state and prior successful actions.
+When runtime context contains natural_language_routing, original_utterance is the complete user
+instruction, parsed_clauses/resolved_clauses have already been handled by deterministic local rules,
+and unresolved_clauses are the only text you may supplement. Never repeat, replace, or reinterpret
+controls represented by parsed_clauses; the current user message is the bounded cloud input.
+
Interpret the user's goal semantically. Consider causal language, explicit scope, current state,
follow-up references, and simultaneous compatible goals. Do not classify by isolated keywords.
Prefer the smallest reversible action that addresses the goal when the user leaves intensity open.
diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py
index 1b3ed83..9b26f27 100755
--- a/src/cognitive_agent/server.py
+++ b/src/cognitive_agent/server.py
@@ -48,7 +48,10 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
parse_mode_profile_update,
)
-from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
+from cognitive_agent.application.seat_service import ( # noqa: E402
+ LOCAL_CONTROL_BUDGET_MS,
+ SeatDemoService,
+)
from cognitive_agent.application.self_extension import ( # noqa: E402
ExtensionIntegrationError,
GatewayError,
@@ -100,6 +103,7 @@ PUBLIC_AUTH_PATHS = {
"/api/auth/status",
"/api/auth/login",
}
+NON_DISPATCH_DISPLAY_SOURCES = frozenset({"mode_editor_compile"})
class VehicleDisplaySync:
@@ -538,9 +542,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
utterance = ""
source = "head_unit"
+ sync_vehicle_task = True
def fail_display_sync(code: str, error: Exception) -> None:
- if not utterance:
+ if not utterance or not sync_vehicle_task:
return
self.vehicle_display_sync.update_task(
status="failed",
@@ -581,7 +586,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else str(payload.get("utterance", ""))
)
source = str(payload.get("client_type", "head_unit"))
- if utterance:
+ sync_vehicle_task = source not in NON_DISPATCH_DISPLAY_SOURCES
+ if utterance and sync_vehicle_task:
self.vehicle_display_sync.update_task(
status="running",
utterance=utterance,
@@ -747,12 +753,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"routing": "validated_peripheral_text",
}
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,
- )
+ if sync_vehicle_task:
+ self.vehicle_display_sync.update_task(
+ status=str(result.get("status", "succeeded")),
+ utterance=utterance,
+ source=source,
+ result=result,
+ )
self._json(result, HTTPStatus.CREATED)
except HardwareAccessRequiredError as exc:
fail_display_sync("hardware_access_required", exc)
@@ -856,6 +863,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
provider: str,
conversation_id: str | None,
execution_mode: str,
+ on_commands_validated: Callable[[dict[str, object]], None] | None = None,
+ natural_language_routing: Mapping[str, object] | None = None,
) -> dict[str, object] | None:
"""Route a command to the NFC-bound peripheral when one is present."""
@@ -896,6 +905,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"peripherals": plan_catalog,
"vehicle_devices": [],
"provider": provider,
+ **(
+ {
+ "natural_language_routing": dict(
+ natural_language_routing
+ )
+ }
+ if natural_language_routing is not None
+ else {}
+ ),
}
)
if plan_result.get("needs_confirmation"):
@@ -910,6 +928,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
+ on_commands_validated=on_commands_validated,
)
self.vehicle_display_sync.update_extension(
action={
@@ -929,6 +948,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
+ on_commands_validated=on_commands_validated,
)
self.vehicle_display_sync.update_extension(
action={
@@ -1011,6 +1031,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
provider: str,
conversation_id: str | None,
execution_mode: str,
+ on_commands_validated: Callable[[dict[str, object]], None] | None = None,
) -> dict[str, object]:
"""Execute already validated infrared capabilities."""
@@ -1037,6 +1058,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
+ on_commands_validated=on_commands_validated,
)
else:
result = self.extension.execute_peripheral_plan_request(
@@ -1057,6 +1079,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
conversation_turn=conversation_turn,
execution_mode=execution_mode,
provider_requested=provider,
+ on_commands_validated=on_commands_validated,
)
result["provider_used"] = "infrared_local_rules"
self.vehicle_display_sync.update_extension(
@@ -1092,6 +1115,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
command_plans = deepcopy(can_result.get("command_plans") or {})
command_plans.update(deepcopy(infrared_result.get("command_plans") or {}))
+ trace_events = [
+ *deepcopy(list(infrared_result.get("trace_events") or [])),
+ *deepcopy(list(can_result.get("trace_events") or [])),
+ ]
can_operations = list(can_result.get("operation_results") or [])
infrared_operations = list(infrared_result.get("operation_results") or [])
operation_results = [*can_operations, *infrared_operations]
@@ -1156,6 +1183,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"command_plans": command_plans,
"function_config": next(iter(function_configs.values()), None),
"command_plan": next(iter(command_plans.values()), None),
+ "trace_events": trace_events,
"execution_report": execution_report,
"operation_results": operation_results,
"infrared_actions": infrared_actions,
@@ -1173,6 +1201,109 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
return merged
+ @staticmethod
+ def _merge_peripheral_phase_results(
+ utterance: str,
+ local_result: dict[str, object],
+ cloud_result: dict[str, object],
+ ) -> dict[str, object]:
+ """Merge local and hosted IR phases without replaying local actions."""
+
+ merged = deepcopy(cloud_result)
+
+ def phase_documents(
+ local_documents: object,
+ cloud_documents: object,
+ ) -> dict[str, object]:
+ local_mapping = local_documents if isinstance(local_documents, Mapping) else {}
+ cloud_mapping = cloud_documents if isinstance(cloud_documents, Mapping) else {}
+ return {
+ **{
+ f"local_{key}": deepcopy(value)
+ for key, value in local_mapping.items()
+ },
+ **{
+ f"cloud_{key}": deepcopy(value)
+ for key, value in cloud_mapping.items()
+ },
+ }
+
+ function_configs = phase_documents(
+ local_result.get("function_configs"),
+ cloud_result.get("function_configs"),
+ )
+ command_plans = phase_documents(
+ local_result.get("command_plans"),
+ cloud_result.get("command_plans"),
+ )
+ successful = {"succeeded", "no_action"}
+ local_ok = str(local_result.get("status") or "") in successful
+ cloud_ok = str(cloud_result.get("status") or "") in successful
+ local_request_id = str(local_result.get("request_id") or "")
+ cloud_request_id = str(cloud_result.get("request_id") or "")
+ merged.update(
+ {
+ "utterance": utterance,
+ "status": (
+ "succeeded"
+ if local_ok and cloud_ok
+ else "partially_succeeded"
+ if local_ok or cloud_ok
+ else "failed"
+ ),
+ "success": local_ok and cloud_ok,
+ "orchestration_route": "two_stage_nfc_peripheral",
+ "provider_used": (
+ f"{local_result.get('provider_used') or 'infrared_local_rules'}+"
+ f"{cloud_result.get('provider_used') or 'cloud_model'}"
+ ),
+ "preview_request_id": local_request_id,
+ "supplement_of": local_request_id,
+ "supplement_request_id": cloud_request_id,
+ "decision_summary": [
+ *list(local_result.get("decision_summary") or []),
+ *list(cloud_result.get("decision_summary") or []),
+ "本地红外动作先行下发;云端只补充未解析子句。",
+ ],
+ "function_configs": function_configs,
+ "command_plans": command_plans,
+ "function_config": next(iter(function_configs.values()), None),
+ "command_plan": next(iter(command_plans.values()), None),
+ "trace_events": [
+ *deepcopy(list(local_result.get("trace_events") or [])),
+ *deepcopy(list(cloud_result.get("trace_events") or [])),
+ ],
+ "validation": [
+ *deepcopy(list(local_result.get("validation") or [])),
+ *deepcopy(list(cloud_result.get("validation") or [])),
+ ],
+ "operation_results": [
+ *deepcopy(list(local_result.get("operation_results") or [])),
+ *deepcopy(list(cloud_result.get("operation_results") or [])),
+ ],
+ "phase_execution_reports": {
+ "infrared_local": deepcopy(local_result.get("execution_report")),
+ "infrared_cloud": deepcopy(cloud_result.get("execution_report")),
+ },
+ "execution_report": deepcopy(cloud_result.get("execution_report"))
+ or deepcopy(local_result.get("execution_report")),
+ "peripheral": deepcopy(
+ cloud_result.get("peripheral") or local_result.get("peripheral")
+ ),
+ }
+ )
+ for key in (
+ "execution_mode",
+ "control_transport",
+ "local_control_budget_ms",
+ "local_control_elapsed_ms",
+ "local_control_within_budget",
+ "natural_language_routing",
+ ):
+ if local_result.get(key) is not None:
+ merged[key] = deepcopy(local_result[key])
+ return merged
+
@staticmethod
def _apply_nfc_command_denials(
result: dict[str, object],
@@ -1232,6 +1363,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) -> dict[str, object] | None:
"""Route IR-only or mixed IR/CAN requests without changing CAN parsing."""
+ local_stage_started = time.perf_counter()
utterance = str(common_arguments.get("utterance") or "").strip()
if self.service.is_mode_profile_configuration_request(utterance):
# Creating, saving, updating, or restoring a reusable mode is
@@ -1267,6 +1399,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
residual = str(partition.get("residual_utterance") or "").strip()
unresolved = list(partition.get("unresolved_peripheral_clauses") or [])
denied = list(partition.get("denied_peripheral_actions") or [])
+ parsed_peripheral_clauses = list(
+ dict.fromkeys(
+ str(entry.get("clause") or "").strip()
+ for entry in entries
+ if isinstance(entry, dict) and str(entry.get("clause") or "").strip()
+ )
+ )
+ unresolved_peripheral_clauses = list(
+ dict.fromkeys(str(item).strip() for item in unresolved if str(item).strip())
+ )
+ natural_language_routing: dict[str, object] = {
+ "original_utterance": utterance,
+ "parsed_clauses": parsed_peripheral_clauses,
+ "resolved_clauses": parsed_peripheral_clauses,
+ "unresolved_clauses": unresolved_peripheral_clauses,
+ }
provider = str(common_arguments.get("provider") or "auto")
conversation_id = (
str(common_arguments["conversation_id"])
@@ -1274,203 +1422,366 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
else None
)
execution_mode = str(common_arguments.get("execution_mode") or "dry_run")
+ callback = on_commands_validated if callable(on_commands_validated) else None
+
+ peripheral_validation_published = False
+ peripheral_validation_elapsed_ms: float | None = None
+
+ def publish_peripheral_validation(result: dict[str, object]) -> None:
+ nonlocal peripheral_validation_elapsed_ms, peripheral_validation_published
+ peripheral_validation_published = True
+ peripheral_validation_elapsed_ms = round(
+ (time.perf_counter() - local_stage_started) * 1000,
+ 3,
+ )
+ if callback is None:
+ return
+ validated = deepcopy(result)
+ validated["status"] = "processing"
+ validated["phase"] = "commands_validated"
+ validated.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": peripheral_validation_elapsed_ms,
+ "local_control_within_budget": (
+ peripheral_validation_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
+ }
+ )
+ callback(validated)
+
infrared_result: dict[str, object] | None = None
- if unresolved:
- if provider == "local":
- raise ExtensionIntegrationError(
- "本地规则已识别外设,但以下动作未唯一命中已学习能力:"
- + "、".join(str(item) for item in unresolved)
- )
- candidates = list(partition.get("unresolved_candidates") or [])
- if not candidates:
- raise ExtensionIntegrationError("没有可供云端补充选择的已学习红外能力。")
- plan_result = self.extension.plan(
- {
- "utterance": ",".join(str(item) for item in unresolved),
- "peripherals": candidates,
- "vehicle_devices": [],
- "provider": provider,
- }
- )
- if plan_result.get("needs_confirmation"):
- raise ExtensionIntegrationError(
- str(plan_result.get("question") or "请明确要控制的红外动作。")
- )
- cloud_steps = list(plan_result.get("steps") or [])
- if not cloud_steps:
- raise ExtensionIntegrationError("云端模型没有命中已学习红外能力。")
- candidate_by_id = {
- str(item.get("id")): {**item, "parse_source": "cloud_model"}
- for item in candidates
- if isinstance(item, dict) and item.get("id")
- }
- local_capabilities = [
- {
- **dict(entry["capability"]),
- "parse_source": "local_rule",
- }
- for entry in entries
- if isinstance(entry, dict)
- and isinstance(entry.get("capability"), dict)
- ]
- selected_cloud = [
- candidate_by_id[str(step.get("capability_id"))]
- for step in cloud_steps
- if isinstance(step, dict)
- and step.get("kind") == "peripheral"
- and str(step.get("capability_id")) in candidate_by_id
- ]
- execution_catalog = {
- str(item["id"]): item for item in [*local_capabilities, *selected_cloud]
- }
- combined_steps = [
- {"kind": "peripheral", "capability_id": item["id"]}
- for item in local_capabilities
- ] + cloud_steps
- conversation_turn = 1
- if conversation_id:
- context = self.service.conversations.snapshot(conversation_id)
- conversation_turn = int(context.get("turn_count", 0)) + 1
- infrared_result = self.extension.execute_peripheral_plan_request(
- utterance,
- {
- **plan_result,
- "steps": combined_steps,
- },
- list(execution_catalog.values()),
- conversation_id=conversation_id,
- conversation_turn=conversation_turn,
- execution_mode=execution_mode,
- provider_requested=provider,
- )
- next_index = max(
- [int(entry.get("index", 0)) for entry in entries if isinstance(entry, dict)],
- default=-1,
- ) + 1
- entries.extend(
- {
- "index": next_index + index,
- "clause": ",".join(str(item) for item in unresolved),
- "capability": capability,
- }
- for index, capability in enumerate(selected_cloud)
- )
- if not entries and not denied:
- return self._execute_active_peripheral_request(
- utterance,
- provider=provider,
- conversation_id=conversation_id,
- execution_mode=execution_mode,
- )
- if not entries:
- if not residual:
- labels = "、".join(
- str(item.get("clause") or "未命名动作")
- for item in denied
- if isinstance(item, dict)
- )
- raise ExtensionIntegrationError(
- f"当前 NFC 未授权执行以下红外命令:{labels}。"
- )
- can_arguments = {**common_arguments, "utterance": residual}
- callback = (
- on_commands_validated
- if callable(on_commands_validated)
- else None
- )
- if path == "/api/requests/preview":
- result = self.service.preview_native_can(
- **can_arguments,
- on_commands_validated=callback,
- )
- else:
- result = self.service.process(
- **can_arguments,
- _on_commands_validated=callback,
- )
- result = self._apply_nfc_command_denials(result, denied)
- self.service.persist_composed_result(result)
- if callback is not None:
- callback(result)
- return result
+ can_result: dict[str, object] | None = None
+ cloud_supplement_error: str | None = None
+
+ # Preserve the legacy all-peripheral planner when there is no hosted
+ # ambiguity to resolve. It retains explicit delay/order semantics.
if (
- infrared_result is None
+ entries
and residual
+ and not unresolved
and not self._residual_has_vehicle_language(residual)
+ and bool(
+ re.search(
+ r"然后|随后|接着|之后|"
+ r"(?:\d+(?:\.\d+)?|[一二三四五六七八九十两半]+)"
+ r"(?:毫秒|秒|分钟)",
+ residual,
+ )
+ )
):
- # Preserve the richer existing ordered/delay peripheral planner
- # when the remaining text contains no vehicle-domain request.
planned = self._execute_active_peripheral_request(
utterance,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
+ on_commands_validated=publish_peripheral_validation,
+ natural_language_routing=natural_language_routing,
)
if planned is not None:
+ if not peripheral_validation_published:
+ publish_peripheral_validation(planned)
+ if peripheral_validation_elapsed_ms is not None:
+ planned.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": (
+ peripheral_validation_elapsed_ms
+ ),
+ "local_control_within_budget": (
+ peripheral_validation_elapsed_ms
+ < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
+ }
+ )
return planned
- if infrared_result is None:
+
+ if not entries and not denied and not unresolved:
+ result = self._execute_active_peripheral_request(
+ utterance,
+ provider=provider,
+ conversation_id=conversation_id,
+ execution_mode=execution_mode,
+ on_commands_validated=publish_peripheral_validation,
+ natural_language_routing=natural_language_routing,
+ )
+ if result is not None:
+ if not peripheral_validation_published:
+ publish_peripheral_validation(result)
+ if peripheral_validation_elapsed_ms is not None:
+ result.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": (
+ peripheral_validation_elapsed_ms
+ ),
+ "local_control_within_budget": (
+ peripheral_validation_elapsed_ms
+ < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
+ }
+ )
+ return result
+
+ # Compile, publish and execute every deterministic IR action before
+ # any hosted planner is contacted.
+ if entries:
+ peripheral_validation_published = False
infrared_result = self._execute_local_peripheral_actions(
utterance,
entries,
provider=provider,
conversation_id=conversation_id,
execution_mode=execution_mode,
+ on_commands_validated=publish_peripheral_validation,
)
- if not residual:
- infrared_result = self._apply_nfc_command_denials(
- infrared_result, denied
- )
- if denied:
- self.service.persist_composed_result(infrared_result)
- return infrared_result
-
- can_arguments = {**common_arguments, "utterance": residual}
- callback = on_commands_validated if callable(on_commands_validated) else None
- can_validation_callback = None
- if callback is not None:
- def publish_combined_validation(
- validated_can: dict[str, object],
- ) -> None:
- # Native CAN publishes its fully compiled commands before the
- # physical executor starts. A mixed request has already
- # compiled (and in hardware mode may already have submitted)
- # the infrared phase, so never replace the UI with this
- # CAN-only intermediate snapshot. If CAN subsequently fails,
- # this combined validation event remains the authoritative
- # parsed-command view with per-operation delivery evidence.
- validated = self._merge_can_and_peripheral_results(
- utterance,
- validated_can,
- infrared_result,
- entries,
+ if not peripheral_validation_published:
+ publish_peripheral_validation(infrared_result)
+ if peripheral_validation_elapsed_ms is not None:
+ infrared_result.update(
+ {
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": (
+ peripheral_validation_elapsed_ms
+ ),
+ "local_control_within_budget": (
+ peripheral_validation_elapsed_ms
+ < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "natural_language_routing": deepcopy(
+ natural_language_routing
+ ),
+ }
)
- validated = self._apply_nfc_command_denials(validated, denied)
- validated["status"] = "processing"
- validated["phase"] = "commands_validated"
- callback(validated)
- can_validation_callback = publish_combined_validation
- if path == "/api/requests/preview":
- can_result = self.service.preview_native_can(
- **can_arguments,
- on_commands_validated=can_validation_callback,
+ # The native preview is also strictly local. Its own manifest records
+ # unresolved CAN clauses for the later supplement endpoint.
+ if residual:
+ can_arguments = {
+ **common_arguments,
+ "utterance": residual,
+ "_cloud_instruction_context": natural_language_routing,
+ }
+ can_validation_callback = callback
+ if callback is not None and infrared_result is not None:
+ def publish_combined_validation(
+ validated_can: dict[str, object],
+ ) -> None:
+ validated = self._merge_can_and_peripheral_results(
+ utterance,
+ validated_can,
+ infrared_result,
+ entries,
+ )
+ validated = self._apply_nfc_command_denials(validated, denied)
+ validated["status"] = "processing"
+ validated["phase"] = "commands_validated"
+ callback(validated)
+
+ can_validation_callback = publish_combined_validation
+ if path == "/api/requests/preview":
+ can_result = self.service.preview_native_can(
+ **can_arguments,
+ on_commands_validated=can_validation_callback,
+ )
+ else:
+ can_result = self.service.process(
+ **can_arguments,
+ _on_commands_validated=can_validation_callback,
+ )
+
+ routed = can_result.get("natural_language_routing")
+ if isinstance(routed, dict):
+ natural_language_routing = deepcopy(routed)
+
+ # Hosted IR planning is deliberately last. Only the unresolved IR
+ # text is submitted, while the trusted context retains the full
+ # sentence and the clauses already handled above.
+ if unresolved and provider != "local":
+ try:
+ candidates = list(partition.get("unresolved_candidates") or [])
+ if not candidates:
+ raise ExtensionIntegrationError(
+ "没有可供云端补充选择的已学习红外能力。"
+ )
+ cloud_utterance = ",".join(unresolved_peripheral_clauses)
+ plan_result = self.extension.plan(
+ {
+ "utterance": cloud_utterance,
+ "peripherals": candidates,
+ "vehicle_devices": [],
+ "provider": provider,
+ "natural_language_routing": {
+ **natural_language_routing,
+ "cloud_input_utterance": cloud_utterance,
+ },
+ }
+ )
+ if plan_result.get("needs_confirmation"):
+ raise ExtensionIntegrationError(
+ str(
+ plan_result.get("question")
+ or "请明确要控制的红外动作。"
+ )
+ )
+ cloud_steps = list(plan_result.get("steps") or [])
+ if not cloud_steps:
+ raise ExtensionIntegrationError(
+ "云端模型没有命中已学习红外能力。"
+ )
+ candidate_by_id = {
+ str(item.get("id")): {
+ **item,
+ "parse_source": "cloud_model",
+ }
+ for item in candidates
+ if isinstance(item, dict) and item.get("id")
+ }
+ selected_cloud = [
+ candidate_by_id[str(step.get("capability_id"))]
+ for step in cloud_steps
+ if isinstance(step, dict)
+ and step.get("kind") == "peripheral"
+ and str(step.get("capability_id")) in candidate_by_id
+ ]
+ if not selected_cloud:
+ raise ExtensionIntegrationError(
+ "云端模型没有返回允许执行的已学习红外能力。"
+ )
+ conversation_turn = 1
+ if conversation_id:
+ context = self.service.conversations.snapshot(conversation_id)
+ conversation_turn = int(context.get("turn_count", 0)) + 1
+ cloud_infrared_result = (
+ self.extension.execute_peripheral_plan_request(
+ utterance,
+ plan_result,
+ selected_cloud,
+ conversation_id=conversation_id,
+ conversation_turn=conversation_turn,
+ execution_mode=execution_mode,
+ provider_requested=provider,
+ )
+ )
+ next_index = max(
+ [
+ int(entry.get("index", 0))
+ for entry in entries
+ if isinstance(entry, dict)
+ ],
+ default=-1,
+ ) + 1
+ entries.extend(
+ {
+ "index": next_index + index,
+ "clause": cloud_utterance,
+ "capability": capability,
+ }
+ for index, capability in enumerate(selected_cloud)
+ )
+ infrared_result = (
+ self._merge_peripheral_phase_results(
+ utterance,
+ infrared_result,
+ cloud_infrared_result,
+ )
+ if infrared_result is not None
+ else cloud_infrared_result
+ )
+ except ExtensionIntegrationError as exc:
+ cloud_supplement_error = str(exc)
+ if infrared_result is None and can_result is None:
+ raise
+
+ if can_result is not None and infrared_result is not None:
+ result = self._merge_can_and_peripheral_results(
+ utterance,
+ can_result,
+ infrared_result,
+ entries,
)
+ elif can_result is not None:
+ result = can_result
+ result["utterance"] = utterance
+ elif infrared_result is not None:
+ result = infrared_result
+ elif unresolved and provider == "local":
+ local_elapsed_ms = round(
+ (time.perf_counter() - local_stage_started) * 1000,
+ 3,
+ )
+ request_id = f"req-local-preview-{os.urandom(8).hex()}"
+ result = {
+ "request_id": request_id,
+ "preview_request_id": request_id,
+ "utterance": utterance,
+ "conversation_id": conversation_id,
+ "status": "no_action",
+ "success": False,
+ "phase": "deterministic_preview",
+ "provider_requested": provider,
+ "provider_used": "local_rules",
+ "execution_mode": execution_mode,
+ "decision_summary": [
+ "本地规则未生成控制;仅未解析子句等待云端补充。"
+ ],
+ "function_config": None,
+ "function_configs": {},
+ "command_plan": None,
+ "command_plans": {},
+ "trace_events": [],
+ "validation": [],
+ "execution_report": None,
+ "operation_results": [],
+ "parsed_control_signatures": [],
+ "preview_elapsed_ms": local_elapsed_ms,
+ "local_control_budget_ms": LOCAL_CONTROL_BUDGET_MS,
+ "local_control_elapsed_ms": local_elapsed_ms,
+ "local_control_within_budget": (
+ local_elapsed_ms < LOCAL_CONTROL_BUDGET_MS
+ ),
+ "supplement_required": True,
+ }
else:
- can_result = self.service.process(
- **can_arguments,
- _on_commands_validated=can_validation_callback,
+ labels = "、".join(
+ str(item.get("clause") or "未命名动作")
+ for item in denied
+ if isinstance(item, dict)
)
- combined = self._merge_can_and_peripheral_results(
- utterance,
- can_result,
- infrared_result,
- entries,
- )
- combined = self._apply_nfc_command_denials(combined, denied)
- self.service.persist_composed_result(combined)
+ raise ExtensionIntegrationError(
+ f"当前 NFC 未授权执行以下红外命令:{labels}。"
+ )
+
+ result["natural_language_routing"] = deepcopy(natural_language_routing)
+ for key in ("parsed_clauses", "resolved_clauses", "unresolved_clauses"):
+ value = natural_language_routing.get(key)
+ if isinstance(value, list):
+ result[key] = deepcopy(value)
+ if cloud_supplement_error:
+ result["cloud_supplement_error"] = {
+ "stage": "unresolved_peripheral_clauses",
+ "message": cloud_supplement_error,
+ }
+ result["status"] = "partially_succeeded"
+ result["success"] = False
+ result["decision_summary"] = [
+ *list(result.get("decision_summary") or []),
+ "明确控制已由本地规则先行处理;未解析的红外子句云端补充失败。",
+ ]
+ result = self._apply_nfc_command_denials(result, denied)
+ self.service.persist_composed_result(result)
if callback is not None:
- callback(combined)
- return combined
+ callback(result)
+ return result
def _stream_control_request(
self,
@@ -1483,6 +1794,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
) -> None:
"""Stream validated command plans before physical execution finishes."""
+ sync_vehicle_task = source not in NON_DISPATCH_DISPLAY_SOURCES
+
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-store")
@@ -1524,12 +1837,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
"outcome": "failed_or_blocked",
**({"detail": detail} if detail else {}),
}
- self.vehicle_display_sync.update_task(
- status="failed",
- utterance=utterance,
- source=source,
- error=error,
- )
+ if sync_vehicle_task:
+ self.vehicle_display_sync.update_task(
+ status="failed",
+ utterance=utterance,
+ source=source,
+ error=error,
+ )
emit({
"type": "error",
"error": code,
@@ -1584,12 +1898,13 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
_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,
- )
+ if sync_vehicle_task:
+ 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:
emit_terminal_error(
@@ -1710,10 +2025,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
common_arguments: dict[str, object] = {
"utterance": message,
- # Codex has already converted an implicit request into one explicit
- # vehicle instruction. Keep the safety-critical execution pass
- # deterministic and avoid paying for a second model invocation.
- "provider": "local",
+ # Explicit controls take the deterministic preview path first.
+ # ``auto`` permits a second phase only when that preview reports
+ # genuinely unresolved clauses; fully local requests never invoke
+ # a hosted model.
+ "provider": "auto",
"conversation_id": conversation_id,
"execution_mode": str(payload.get("execution_mode") or "dry_run"),
"approval_token": os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
@@ -1785,6 +2101,153 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
error={"code": code, "message": public_message},
)
+ local_preview_result: dict[str, object] | None = None
+
+ def preview_control(
+ instruction: str,
+ on_commands_validated: Callable[[dict[str, object]], None],
+ ) -> dict[str, object]:
+ """Run only deterministic phases and retain their merge evidence."""
+
+ nonlocal local_preview_result
+ self.vehicle_display_sync.update_task(
+ status="running",
+ utterance=message,
+ source=source,
+ )
+ preview_arguments = {
+ **common_arguments,
+ "utterance": instruction,
+ "provider": "local",
+ }
+ result = self._execute_request_with_peripherals(
+ "/api/requests/preview",
+ preview_arguments,
+ on_commands_validated=on_commands_validated,
+ )
+ if result is None:
+ result = self.service.preview_native_can(
+ **preview_arguments,
+ on_commands_validated=on_commands_validated,
+ )
+ local_preview_result = result
+ self.vehicle_display_sync.update_task(
+ status=str(result.get("status") or "succeeded"),
+ utterance=message,
+ source=source,
+ result=result,
+ )
+ return result
+
+ def supplement_control(
+ instruction: str,
+ on_commands_validated: Callable[[dict[str, object]], None],
+ assistant_plan: Mapping[str, object] | None = None,
+ ) -> dict[str, object]:
+ """Execute a Codex proposal for unresolved text without replay."""
+
+ preview = local_preview_result
+ if preview is None:
+ raise CodexAssistantError("local control preview is missing")
+ routing_value = preview.get("natural_language_routing")
+ routing = (
+ deepcopy(routing_value)
+ if isinstance(routing_value, Mapping)
+ else {
+ "original_utterance": message,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [instruction],
+ }
+ )
+ routing["cloud_input_utterance"] = instruction
+ excluded_signatures = {
+ tuple(item)
+ for item in preview.get("parsed_control_signatures", [])
+ if isinstance(item, list)
+ }
+
+ def publish_supplement(validated: dict[str, object]) -> None:
+ merged_event = self.service._merge_native_can_phases(
+ preview,
+ validated,
+ )
+ merged_event.update(
+ {
+ "phase": "supplement_commands_validated",
+ "preview_request_id": preview.get("request_id"),
+ "supplement_of": preview.get("request_id"),
+ "natural_language_routing": deepcopy(routing),
+ }
+ )
+ on_commands_validated(merged_event)
+
+ process_arguments = {
+ key: value
+ for key, value in common_arguments.items()
+ if key
+ not in {
+ "utterance",
+ "provider",
+ "mode_profile_draft_context",
+ }
+ }
+ if assistant_plan is not None:
+ supplement = self.service.process(
+ instruction,
+ provider="local",
+ **process_arguments,
+ _excluded_control_signatures=excluded_signatures,
+ _model_parse_source="codex_supplement",
+ _allow_empty_control_result=True,
+ _on_commands_validated=publish_supplement,
+ _assistant_plan=dict(assistant_plan),
+ _cloud_instruction_context=routing,
+ )
+ else:
+ supplement_arguments = {
+ **common_arguments,
+ "utterance": instruction,
+ "provider": "local",
+ }
+ supplement = self._execute_request_with_peripherals(
+ "/api/requests",
+ supplement_arguments,
+ on_commands_validated=publish_supplement,
+ )
+ if supplement is None:
+ supplement = self.service.process(
+ instruction,
+ provider="local",
+ **process_arguments,
+ _excluded_control_signatures=excluded_signatures,
+ _model_parse_source="codex_supplement",
+ _allow_empty_control_result=True,
+ _on_commands_validated=publish_supplement,
+ _cloud_instruction_context=routing,
+ )
+
+ merged = self.service._merge_native_can_phases(preview, supplement)
+ merged.update(
+ {
+ "phase": "two_stage_complete",
+ "utterance": message,
+ "preview_request_id": preview.get("request_id"),
+ "supplement_of": preview.get("request_id"),
+ "supplement_request_id": supplement.get("request_id"),
+ "supplement_required": False,
+ "natural_language_routing": routing,
+ }
+ )
+ self.service.persist_composed_result(merged)
+ self.vehicle_display_sync.update_task(
+ status=str(merged.get("status") or "succeeded"),
+ utterance=message,
+ source=source,
+ result=merged,
+ )
+ return merged
+
def execute_control(
instruction: str,
on_commands_validated: Callable[[dict[str, object]], None],
@@ -1798,6 +2261,9 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
turn_arguments = {
**common_arguments,
"utterance": instruction,
+ # Codex has already normalized this agent-tool request. Keep
+ # the safety execution pass deterministic; explicit user text
+ # uses preview_control/supplement_control above instead.
"provider": "local",
**({"assistant_plan": dict(assistant_plan)} if assistant_plan is not None else {}),
}
@@ -1856,6 +2322,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
history=history,
emit=emit,
execute_control=execute_control,
+ preview_control=preview_control,
+ supplement_control=supplement_control,
capability_snapshot=capability_snapshot,
capability_context=capability_context,
scene_capability_context=scene_capability_context,
diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py
index 1a39c21..911e6cd 100644
--- a/tests/e2e/test_assistant_web_api.py
+++ b/tests/e2e/test_assistant_web_api.py
@@ -1101,7 +1101,7 @@ class TestCodexAssistantWebApi:
assert "not-for-mcp" not in serialized_events
assert "模拟编排" in events[-1]["answer"]
- def test_structured_plan_crosses_the_internal_bridge_without_replacing_original_text(
+ def test_explicit_roof_control_uses_local_preview_without_codex(
self,
) -> None:
events = self.post_assistant(
@@ -1112,17 +1112,14 @@ class TestCodexAssistantWebApi:
}
)
- assert FAKE_CODEX.bridge_results[-1][0] == 200
call = AssistantApiHandler.service.calls[-1]
assert call["utterance"] == "天幕透光率调到30%"
assert call["provider"] == "local"
- forwarded_plan = call["assistant_plan"]
- assert forwarded_plan["requests"][0]["capability"] == "roof.set_transmittance"
- assert forwarded_plan["requests"][0]["parameters"] == [
- {"name": "transmittance_pct", "value": 30}
- ]
- assert forwarded_plan["requests"][0]["reason"] == "原始用户请求的结构化候选动作。"
- assert "instruction" not in call
+ assert "assistant_plan" not in call
+ assert not FAKE_CODEX.turn_calls
+ assert not FAKE_CODEX.bridge_results
+ assert events[0]["route"] == "vehicle_control"
+ assert events[0]["engine"] == "n7web-control-router"
assert events[-1]["type"] == "answer.completed"
def test_assistant_returns_named_mode_draft_without_saving_it(self) -> None:
diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py
index 39e8bc5..9451040 100755
--- a/tests/e2e/test_seat_web_api.py
+++ b/tests/e2e/test_seat_web_api.py
@@ -13,6 +13,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
+from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.self_extension import (
@@ -438,6 +439,12 @@ operations:
}
)
try:
+ with self.opener.open(
+ f"{self.base_url}/api/vehicle-display-sync", timeout=3
+ ) as response:
+ display_before_compile = json.loads(
+ response.read().decode("utf-8")
+ )
generate_request = urllib.request.Request(
f"{self.base_url}/api/requests/preview",
method="POST",
@@ -457,6 +464,16 @@ operations:
)
with self.opener.open(generate_request, timeout=3) as response:
generated = json.loads(response.read().decode("utf-8"))
+ with self.opener.open(
+ f"{self.base_url}/api/vehicle-display-sync", timeout=3
+ ) as response:
+ display_after_compile = json.loads(
+ response.read().decode("utf-8")
+ )
+ self.assertEqual(
+ display_after_compile["task"],
+ display_before_compile["task"],
+ )
save_request = urllib.request.Request(
f"{self.base_url}/api/mode-profiles/office",
@@ -650,18 +667,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-130", timeout=3) as response:
+ with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-131", 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=delivery-state-62", timeout=3
+ f"{self.base_url}/console-v2.js?v=local-first-66", timeout=3
) as response:
console_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/console-v2.css?v=delivery-state-66", timeout=3
+ f"{self.base_url}/console-v2.css?v=delivery-state-68", timeout=3
) as response:
console_styles = response.read().decode("utf-8")
with self.opener.open(
@@ -685,7 +702,7 @@ operations:
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
- f"{self.base_url}/assistant-chat.js?v=codex-assistant-26", timeout=3
+ f"{self.base_url}/assistant-chat.js?v=codex-assistant-27", timeout=3
) as response:
assistant_script = response.read().decode("utf-8")
with self.opener.open(
@@ -726,12 +743,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-130"', html)
+ self.assertIn('src="/app.js?v=dual-seat-131"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', 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=delivery-state-62"', html)
- self.assertIn('href="/console-v2.css?v=delivery-state-66"', html)
+ self.assertIn('src="/console-v2.js?v=local-first-66"', html)
+ self.assertIn('href="/console-v2.css?v=delivery-state-68"', html)
self.assertIn('src="/speech-input.js?v=sensevoice-13"', html)
self.assertIn('href="/speech-input.css?v=sensevoice-7"', html)
self.assertIn('id="speechDictationButton"', html)
@@ -748,7 +765,7 @@ operations:
self.assertIn('aria-label="发送消息"', html)
self.assertIn('id="assistantTranscript"', html)
self.assertIn('href="/assistant-chat.css?v=codex-assistant-26"', html)
- self.assertIn('src="/assistant-chat.js?v=codex-assistant-26"', html)
+ self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertNotIn('"admin"', console_script)
@@ -931,7 +948,12 @@ operations:
self.assertNotIn('data-mode-command="seat driver vent low"', app_script)
self.assertIn("CH0 副驾座椅与 CH1 主驾座椅独立收发、独立反馈", app_script)
self.assertIn("CH1 主驾实车测试不启用无闭环的座椅位置手势", app_script)
- self.assertIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
+ self.assertNotIn("车端 CAN 未启用,其他领域保持虚拟执行", app_script)
+ self.assertNotIn('id="hardwareNote"', html)
+ self.assertNotIn('id="hardwareAliasNote"', html)
+ self.assertNotIn('id="vehicleTransportNote"', html)
+ self.assertIn("下发控制指令", console_script)
+ self.assertIn('new Set(["mode_editor_compile"])', console_script)
self.assertIn("function extractReferenceWindowPanes", app_script)
self.assertIn("function collectReferenceFrontLampMeshes", app_script)
self.assertIn("function emphasizeReferenceWindowPanes", app_script)
@@ -1021,14 +1043,14 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
- self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v277"', service_worker)
- self.assertIn('/console-v2.css?v=delivery-state-66', service_worker)
- self.assertIn('/console-v2.js?v=delivery-state-62', service_worker)
+ self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v282"', service_worker)
+ self.assertIn('/console-v2.css?v=delivery-state-68', service_worker)
+ self.assertIn('/console-v2.js?v=local-first-66', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-26', service_worker)
- self.assertIn('/assistant-chat.js?v=codex-assistant-26', service_worker)
+ self.assertIn('/assistant-chat.js?v=codex-assistant-27', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
- self.assertIn('"/app.js?v=dual-seat-130"', service_worker)
+ self.assertIn('"/app.js?v=dual-seat-131"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1485,7 +1507,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
- f"{self.base_url}/app.js?v=dual-seat-130", timeout=3
+ f"{self.base_url}/app.js?v=dual-seat-131", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -2134,7 +2156,7 @@ operations:
},
data=json.dumps(
{
- "utterance": "打开闪烁灯,颜色切换到红色,打开空调",
+ "utterance": "空调温度设置到23度,打开闪烁灯",
"provider": "local",
"conversation_id": "conv-mixed-nfc-can",
"execution_mode": "dry_run",
@@ -2160,6 +2182,14 @@ operations:
self.assertEqual(synced["learned_count"], 2)
self.assertEqual(events[-1]["type"], "result")
self.assertTrue(any(item["type"] == "validated_commands" for item in events))
+ self.assertLess(
+ next(
+ index
+ for index, item in enumerate(events)
+ if item["type"] == "validated_commands"
+ ),
+ len(events) - 1,
+ )
validated_payloads = [
item["payload"]
for item in events
@@ -2168,15 +2198,118 @@ operations:
self.assertTrue(validated_payloads)
self.assertTrue(
all(
- "self_extension" in payload["function_configs"]
+ {"native_climate", "self_extension"}.issubset(
+ payload["function_configs"]
+ )
for payload in validated_payloads
)
)
+ first_validated = validated_payloads[0]
+ self.assertTrue(
+ {"native_climate", "self_extension"}.issubset(
+ first_validated["command_plans"]
+ )
+ )
+ validated_operations = [
+ operation
+ for configuration in first_validated["function_configs"].values()
+ for operation in configuration["spec"].get("operations", [])
+ ]
+ self.assertEqual(
+ {operation["capability"] for operation in validated_operations},
+ {"climate.set_temperature", "infrared.emit"},
+ )
+ self.assertTrue(
+ all(
+ operation.get("parse_source") == "local_rule"
+ for operation in validated_operations
+ )
+ )
+ validated_commands = [
+ command
+ for plan in first_validated["command_plans"].values()
+ for command in plan["spec"].get("commands", [])
+ ]
+ climate_command = next(
+ command
+ for command in validated_commands
+ if command.get("signals", {}).get("debug_command")
+ == "ac temp driver 23"
+ )
+ self.assertEqual(climate_command["signals"]["frame_id"], "0x1DE")
+ self.assertEqual(
+ climate_command["signals"]["data_hex"],
+ "00 00 00 00 17 00 00 00",
+ )
+ infrared_command = next(
+ command
+ for command in validated_commands
+ if command.get("message") == "infrared.emit"
+ )
+ self.assertEqual(infrared_command["signals"]["slot"], 1)
+ assistant_projection = CodexAssistantService._validated_control_payload(
+ first_validated
+ )
+ self.assertIsNotNone(assistant_projection)
+ projected_commands = assistant_projection["validated_control_commands"]
+ projected_by_capability = {
+ item["operation"]["capability"]: item for item in projected_commands
+ }
+ self.assertEqual(
+ projected_by_capability["climate.set_temperature"]["actual_command"],
+ "ac temp driver 23",
+ )
+ self.assertEqual(
+ projected_by_capability["climate.set_temperature"]["frames"][0][
+ "value"
+ ],
+ "0x1DE · 00 00 00 00 17 00 00 00",
+ )
+ self.assertEqual(
+ projected_by_capability["infrared.emit"]["actual_command"],
+ "infrared emit 1",
+ )
+ serialized_projection = json.dumps(
+ assistant_projection, ensure_ascii=False
+ )
+ self.assertNotIn("command_plans", serialized_projection)
+ self.assertNotIn("data_hex", serialized_projection)
+ compile_trace = next(
+ event
+ for event in first_validated["trace_events"]
+ if event["stage"] == "compile"
+ )
+ validated_traces = first_validated["trace_events"][
+ : first_validated["trace_events"].index(compile_trace) + 1
+ ]
+ required_validation_stages = {
+ "intent_parse",
+ "context_resolve",
+ "domain_route",
+ "function_generate",
+ "schema_validate",
+ "compile",
+ }
+ actual_validation_stages = {
+ event["stage"] for event in validated_traces
+ }
+ self.assertTrue(
+ required_validation_stages.issubset(actual_validation_stages),
+ sorted(actual_validation_stages),
+ )
+ self.assertTrue(
+ all(
+ isinstance(event.get("duration_ms"), int)
+ and event["duration_ms"] >= 0
+ for event in validated_traces
+ )
+ )
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["orchestration_route"], "unified_can_infrared")
self.assertEqual(
- [item["slot"] for item in result["infrared_actions"]], [1, 3]
+ result["provider_used"], "local_rules+infrared_local_rules"
)
+ self.assertEqual([item["slot"] for item in result["infrared_actions"]], [1])
self.assertEqual(
result["infrared_actions"][0]["parse_source"], "local_rule"
)
diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs
index 1cada33..7d7daa6 100644
--- a/tests/unit/assistant_stream_runtime.test.cjs
+++ b/tests/unit/assistant_stream_runtime.test.cjs
@@ -287,6 +287,59 @@ test("a completed NDJSON answer remains the authoritative visible response", asy
assert.equal(harness.completed[0].error, null);
});
+test("assistant bridges request start and validated local controls to the console", async () => {
+ const payload = {
+ request_id: "req-local-mixed",
+ utterance: "空调温度设置到23度,打开闪烁灯",
+ execution_mode: "dry_run",
+ provider_used: "local_rules+infrared_local_rules",
+ validated_control_commands: [
+ {
+ operation: {
+ id: "climate-temperature-1",
+ capability: "climate.set_temperature",
+ target: { zone: "driver" },
+ parameters: { temperature_c: 23 },
+ parse_source: "local_rule",
+ },
+ channel: "CAN1",
+ actual_command: "ac temp driver 23",
+ frames: [{
+ label: "CAN1 帧",
+ value: "0x1DE · 00 00 00 00 17 00 00 00",
+ }],
+ },
+ ],
+ trace_events: [{ stage: "compile", duration_ms: 2 }],
+ };
+ const harness = createHarness([
+ { type: "validated_control_commands", payload },
+ { type: "answer.completed", route: "vehicle_control", answer: "模拟编排已完成。" },
+ ]);
+ const starts = [];
+ const validated = [];
+ harness.sandbox.addEventListener("cognitive-agent:control-request-start", (event) => {
+ starts.push(event.detail);
+ });
+ harness.sandbox.addEventListener("cognitive-agent:validated-can-commands", (event) => {
+ validated.push(event.detail);
+ });
+ harness.input.value = payload.utterance;
+ harness.input.dataset.speechUtteranceId = "speech-local-mixed";
+ harness.input.dataset.speechValue = payload.utterance;
+
+ await harness.sandbox.CognitiveAgentAssistant.submit();
+
+ assert.deepEqual(JSON.parse(JSON.stringify(starts)), [{
+ utterance: payload.utterance,
+ speech_utterance_id: "speech-local-mixed",
+ speech_value: payload.utterance,
+ }]);
+ assert.deepEqual(JSON.parse(JSON.stringify(validated)), [payload]);
+ assert.equal(harness.requestCalls.length, 1);
+ assert.equal(JSON.parse(harness.requestCalls[0].options.body).execution_mode, "dry_run");
+});
+
test("unsupported-control suggestions render once and click through as a new command", async () => {
const suggestions = [
{ id: "climate-on", label: "打开空调", command: "打开空调", domain: "climate" },
diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py
index 15205ec..6e2802b 100644
--- a/tests/unit/test_assistant_frontend_contract.py
+++ b/tests/unit/test_assistant_frontend_contract.py
@@ -14,14 +14,14 @@ def test_console_contains_ai_assistant_surface():
assert 'placeholder="向 AI助手提问或交代工作"' in html
assert 'aria-label="发送消息"' in html
assert 'href="/assistant-chat.css?v=codex-assistant-26"' in html
- assert 'src="/assistant-chat.js?v=codex-assistant-26"' in html
+ assert 'src="/assistant-chat.js?v=codex-assistant-27"' in html
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v282"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-26"' in service_worker
- assert '"/assistant-chat.js?v=codex-assistant-26"' in service_worker
+ assert '"/assistant-chat.js?v=codex-assistant-27"' in service_worker
def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
@@ -33,6 +33,11 @@ def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events():
assert 'event.type === "answer.delta"' in script
assert '"work.started", "work.progress"' in script
assert 'event.type === "work.completed"' in script
+ assert 'event.type === "validated_control_commands"' in script
+ assert '"cognitive-agent:validated-can-commands"' in script
+ assert '"cognitive-agent:control-request-start"' in script
+ assert "speech_utterance_id:" in script
+ assert "speech_value:" in script
assert 'Object.prototype.hasOwnProperty.call(event, "answer")' in script
assert 'pendingController.abort("user_stop")' in script
assert 'signal.addEventListener("abort", cancelReader, { once: true })' in script
diff --git a/tests/unit/test_assistant_validated_control_projection.py b/tests/unit/test_assistant_validated_control_projection.py
new file mode 100644
index 0000000..33650be
--- /dev/null
+++ b/tests/unit/test_assistant_validated_control_projection.py
@@ -0,0 +1,410 @@
+from __future__ import annotations
+
+import json
+import threading
+import unittest
+from copy import deepcopy
+
+from cognitive_agent.application.codex_assistant import (
+ CodexAssistantService,
+ _VehicleAuthorization,
+ _VehicleTurnContext,
+)
+
+
+UTTERANCE = "空调温度设置到23度,打开闪烁灯"
+
+
+def compiled_mixed_result() -> dict[str, object]:
+ return {
+ "request_id": "req-assistant-local-mixed",
+ "utterance": UTTERANCE,
+ "status": "processing",
+ "phase": "commands_validated",
+ "provider_used": "local_rules+infrared_local_rules",
+ "execution_mode": "dry_run",
+ "control_transport": "wired_can",
+ "function_configs": {
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "climate-temperature-1",
+ "capability": "climate.set_temperature",
+ "target": {"zone": "driver"},
+ "parameters": {"temperature_c": 23},
+ "parse_source": "local_rule",
+ }
+ ],
+ }
+ },
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "operations": [
+ {
+ "id": "infrared-1",
+ "capability": "infrared.emit",
+ "target": {"device": "toy-001", "name": "闪灯玩具"},
+ "parameters": {"action": "开启", "slot": 1},
+ "parse_source": "local_rule",
+ }
+ ],
+ }
+ },
+ },
+ "command_plans": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "commands": [
+ {
+ "operation_ref": "unrelated-operation",
+ "message": "Native_0xBAD",
+ "signals": {
+ "debug_command": "must-not-cross-tool-boundary",
+ "frame_id": "0xBAD",
+ "data_hex": "DE AD BE EF",
+ },
+ },
+ {
+ "operation_ref": "infrared-1",
+ "bus": "TCP",
+ "message": "infrared.emit",
+ "signals": {"slot": 1},
+ },
+ ],
+ }
+ },
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "commands": [
+ {
+ "operation_ref": "climate-temperature-1",
+ "bus": "body_can",
+ "message": "Native_0x1DE",
+ "signals": {
+ "debug_command": "ac temp driver 23",
+ "frame_id": "0x1DE",
+ "data_hex": "00 00 00 00 17 00 00 00",
+ },
+ }
+ ],
+ }
+ },
+ },
+ "trace_events": [
+ {"stage": "intent_parse", "duration_ms": 2},
+ {"stage": "context_resolve", "duration_ms": 1},
+ {"stage": "domain_route", "duration_ms": 1},
+ {"stage": "function_generate", "duration_ms": 2},
+ {"stage": "schema_validate", "duration_ms": 1},
+ {"stage": "compile", "duration_ms": 2},
+ {"stage": "execute", "duration_ms": 999},
+ ],
+ "raw_can": "must-not-cross-tool-boundary",
+ }
+
+
+class ValidatedControlProjectionTest(unittest.TestCase):
+ def test_projection_exposes_only_exact_display_commands(self) -> None:
+ projection = CodexAssistantService._validated_control_payload(
+ compiled_mixed_result()
+ )
+
+ self.assertIsNotNone(projection)
+ assert projection is not None
+ commands = projection["validated_control_commands"]
+ assert isinstance(commands, list)
+ self.assertEqual(len(commands), 2)
+ by_capability = {
+ command["operation"]["capability"]: command for command in commands
+ }
+ climate = by_capability["climate.set_temperature"]
+ infrared = by_capability["infrared.emit"]
+ self.assertEqual(climate["actual_command"], "ac temp driver 23")
+ self.assertEqual(
+ climate["frames"],
+ [
+ {
+ "label": "CAN1 帧",
+ "value": "0x1DE · 00 00 00 00 17 00 00 00",
+ }
+ ],
+ )
+ self.assertEqual(infrared["actual_command"], "infrared emit 1")
+ self.assertEqual(
+ infrared["frames"],
+ [
+ {
+ "label": "GSCU1B TCP",
+ "value": '{"action":"emit","data":{"no":1},"type":"infrared"}',
+ }
+ ],
+ )
+ self.assertEqual(
+ [event["stage"] for event in projection["trace_events"]],
+ [
+ "intent_parse",
+ "context_resolve",
+ "domain_route",
+ "function_generate",
+ "schema_validate",
+ "compile",
+ ],
+ )
+ serialized = json.dumps(projection, ensure_ascii=False)
+ self.assertNotIn("command_plans", serialized)
+ self.assertNotIn("data_hex", serialized)
+ self.assertNotIn("raw_can", serialized)
+ self.assertNotIn("must-not-cross-tool-boundary", serialized)
+
+ def test_projection_drops_secrets_and_sensitive_nested_keys(self) -> None:
+ result = compiled_mixed_result()
+ secret = "projection-secret-token"
+ climate_config = result["function_configs"]["native_climate"]
+ climate_config["spec"]["domain"] = f"climate-{secret}"
+ operation = climate_config["spec"]["operations"][0]
+ operation["target"].update(
+ {
+ "api_key": "API-LEAK",
+ "cookie": "COOKIE-LEAK",
+ }
+ )
+ operation["parameters"].update(
+ {
+ "credential": "CRED-LEAK",
+ "access_key": "ACCESS-LEAK",
+ }
+ )
+ operation["parse_source"] = f"local-{secret}"
+ result["command_plans"]["native_climate"]["spec"]["commands"][0][
+ "signals"
+ ]["debug_command"] = f"ac temp driver 23 {secret}"
+
+ projection = CodexAssistantService._validated_control_payload(
+ result,
+ secret=secret,
+ )
+
+ self.assertIsNotNone(projection)
+ assert projection is not None
+ serialized = json.dumps(projection, ensure_ascii=False)
+ for forbidden in (
+ secret,
+ "API-LEAK",
+ "COOKIE-LEAK",
+ "CRED-LEAK",
+ "ACCESS-LEAK",
+ "api_key",
+ "credential",
+ "access_key",
+ ):
+ self.assertNotIn(forbidden, serialized)
+ climate = next(
+ item
+ for item in projection["validated_control_commands"]
+ if item["operation"]["capability"] == "climate.set_temperature"
+ )
+ self.assertEqual(climate["domain"], "")
+ self.assertNotIn("parse_source", climate["operation"])
+ self.assertEqual(climate["operation"]["target"], {"zone": "driver"})
+ self.assertEqual(
+ climate["operation"]["parameters"],
+ {"temperature_c": 23},
+ )
+ self.assertEqual(climate["actual_command"], "")
+
+ def test_projection_drops_operation_when_identity_contains_secret(self) -> None:
+ result = compiled_mixed_result()
+ secret = "projection-secret-token"
+ operation = result["function_configs"]["native_climate"]["spec"][
+ "operations"
+ ][0]
+ operation["id"] = f"climate-{secret}"
+ result["command_plans"]["native_climate"]["spec"]["commands"][0][
+ "operation_ref"
+ ] = operation["id"]
+
+ projection = CodexAssistantService._validated_control_payload(
+ result,
+ secret=secret,
+ )
+
+ self.assertIsNotNone(projection)
+ assert projection is not None
+ serialized = json.dumps(projection, ensure_ascii=False)
+ self.assertNotIn(secret, serialized)
+ self.assertEqual(
+ [
+ item["operation"]["capability"]
+ for item in projection["validated_control_commands"]
+ ],
+ ["infrared.emit"],
+ )
+
+ def test_duplicate_operation_refs_never_cross_configuration_boundaries(self) -> None:
+ result = {
+ "function_configs": {
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "op-1",
+ "capability": "climate.set_temperature",
+ "target": {"zone": "driver"},
+ "parameters": {"temperature_c": 23},
+ }
+ ],
+ }
+ },
+ "native_seat": {
+ "spec": {
+ "domain": "seat",
+ "operations": [
+ {
+ "id": "op-1",
+ "capability": "seat.set_heating",
+ "target": {"zone": "passenger"},
+ "parameters": {"level": 2},
+ }
+ ],
+ }
+ },
+ },
+ "command_plans": {
+ "native_seat": {
+ "spec": {
+ "domain": "seat",
+ "commands": [
+ {
+ "operation_ref": "op-1",
+ "bus": "seat_can0",
+ "signals": {
+ "debug_command": "seat passenger heat 2"
+ },
+ }
+ ],
+ }
+ }
+ },
+ }
+
+ projection = CodexAssistantService._validated_control_payload(result)
+
+ self.assertIsNotNone(projection)
+ assert projection is not None
+ commands = projection["validated_control_commands"]
+ self.assertEqual(len(commands), 1)
+ self.assertEqual(
+ commands[0]["operation"]["capability"],
+ "seat.set_heating",
+ )
+ self.assertEqual(commands[0]["actual_command"], "seat passenger heat 2")
+
+ def test_compiled_semantic_operation_is_kept_without_debug_command(self) -> None:
+ result = deepcopy(compiled_mixed_result())
+ climate_command = result["command_plans"]["native_climate"]["spec"][
+ "commands"
+ ][0]
+ climate_command["signals"].pop("debug_command")
+
+ projection = CodexAssistantService._validated_control_payload(result)
+
+ self.assertIsNotNone(projection)
+ assert projection is not None
+ climate = next(
+ item
+ for item in projection["validated_control_commands"]
+ if item["operation"]["capability"] == "climate.set_temperature"
+ )
+ self.assertEqual(climate["actual_command"], "")
+ self.assertEqual(
+ climate["frames"][0]["value"],
+ "0x1DE · 00 00 00 00 17 00 00 00",
+ )
+
+ def test_direct_stream_emits_projection_before_progress_and_completion(self) -> None:
+ client = type("UnavailableClient", (), {"available": False})()
+ assistant = CodexAssistantService(client)
+ events: list[dict[str, object]] = []
+
+ def execute_control(instruction, progress):
+ self.assertEqual(instruction, UTTERANCE)
+ result = compiled_mixed_result()
+ progress(result)
+ return {**result, "status": "succeeded"}
+
+ result = assistant.stream(
+ UTTERANCE,
+ conversation_id="conv-validated-projection",
+ principal="local-test",
+ history=[],
+ emit=events.append,
+ execute_control=execute_control,
+ control_terms=("空调", "闪烁灯"),
+ quick_control_commands=[UTTERANCE],
+ )
+
+ event_types = [event["type"] for event in events]
+ validated_index = event_types.index("validated_control_commands")
+ self.assertLess(validated_index, event_types.index("action.progress"))
+ self.assertLess(validated_index, event_types.index("action.completed"))
+ self.assertLess(validated_index, event_types.index("answer.completed"))
+ self.assertEqual(result["route"], "vehicle_control")
+ self.assertEqual(result["success"], True)
+
+ safe_result = CodexAssistantService._safe_vehicle_result(
+ compiled_mixed_result()
+ )
+ self.assertNotIn("command_plans", safe_result)
+ self.assertNotIn("validated_control_commands", safe_result)
+
+ def test_agent_tool_path_queues_projection_for_the_browser_thread(self) -> None:
+ client = type("UnavailableClient", (), {"available": False})()
+ assistant = CodexAssistantService(client)
+ request_token = "projection-secret-token"
+
+ def execute_control(instruction, progress):
+ self.assertEqual(instruction, UTTERANCE)
+ result = compiled_mixed_result()
+ progress(result)
+ return {**result, "status": "succeeded"}
+
+ context = _VehicleTurnContext(
+ original_message=UTTERANCE,
+ execute_control=execute_control,
+ authorization=_VehicleAuthorization(
+ instruction=UTTERANCE,
+ reason="explicit_registered_control",
+ ),
+ capability_snapshot={},
+ query_capabilities=lambda _snapshot, _query: {},
+ lock=threading.Lock(),
+ )
+ assistant._tool_contexts[request_token] = context
+
+ safe_result = assistant.execute_vehicle_control(request_token, UTTERANCE)
+
+ queued_events = []
+ while not context.events.empty():
+ queued_events.append(context.events.get_nowait())
+ event_types = [event["type"] for event in queued_events]
+ self.assertEqual(event_types[0], "action.started")
+ self.assertLess(
+ event_types.index("validated_control_commands"),
+ event_types.index("action.progress"),
+ )
+ self.assertLess(
+ event_types.index("validated_control_commands"),
+ event_types.index("action.completed"),
+ )
+ self.assertNotIn("command_plans", safe_result)
+ self.assertNotIn(request_token, json.dumps(queued_events, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unit/test_codex_assistant_service.py b/tests/unit/test_codex_assistant_service.py
index e59b69e..8f881d2 100644
--- a/tests/unit/test_codex_assistant_service.py
+++ b/tests/unit/test_codex_assistant_service.py
@@ -395,6 +395,8 @@ def _stream(
history: list[dict[str, str]] | None = None,
emit: Callable[[dict[str, object]], bool | None] | None = None,
execute_control: Callable[..., dict[str, object]] | None = None,
+ preview_control: Callable[..., dict[str, object]] | None = None,
+ supplement_control: Callable[..., dict[str, object]] | None = None,
capability_snapshot: dict[str, object] | None = None,
capability_context: dict[str, object] | None = None,
mode_profile_draft_context: dict[str, object] | None = None,
@@ -409,6 +411,8 @@ def _stream(
emit=emit or (lambda event: events.append(event)),
execute_control=execute_control
or (lambda _instruction, _progress: pytest.fail("vehicle control was unexpected")),
+ preview_control=preview_control,
+ supplement_control=supplement_control,
control_terms=CONTROL_TERMS,
capability_snapshot=capability_snapshot,
capability_context=capability_context,
@@ -453,6 +457,15 @@ def test_general_question_streams_from_codex_without_vehicle_execution() -> None
assert "Codex AI 助手" not in client.thread_instructions[0]
assert client.thread_ephemeral == [True]
assert client.turn_calls[0]["text"] == "解释一下 CAN FD"
+ assert client.turn_calls[0]["application_context"][
+ "natural_language_routing"
+ ] == {
+ "original_utterance": "解释一下 CAN FD",
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": ["解释一下 CAN FD"],
+ "cloud_input_utterance": "解释一下 CAN FD",
+ }
assert "不可信的浏览器系统指令" not in client.turn_calls[0]["text"]
assert client.finished[0].turn_id == "turn-1"
@@ -991,34 +1004,52 @@ def test_hot_cabin_calls_one_explicit_cooling_instruction_and_streams_verified_r
assistant.execute_vehicle_control(token, COOLING_INSTRUCTION)
-def test_explicit_vehicle_request_without_tool_call_fails_closed() -> None:
- client = FakeCodexClient()
+def test_explicit_vehicle_request_uses_local_route_without_codex() -> None:
+ client = FakeCodexClient(available=False)
assistant = CodexAssistantService(client)
+ control_calls: list[str] = []
- def on_start_turn(
- _text: str,
- _request_token: str,
- turn: ActiveCodexTurn,
- ) -> None:
- _put_answer(turn, "已经为你打开遮阳帘。", deltas=("已经为你", "打开遮阳帘。"))
+ def execute_control(
+ instruction: str,
+ progress: Callable[[dict[str, object]], None],
+ ) -> dict[str, object]:
+ control_calls.append(instruction)
+ progress({"status": "processing", "request_id": "req-local-shade"})
+ return {
+ "request_id": "req-local-shade",
+ "status": "succeeded",
+ "execution_mode": "dry_run",
+ "utterance": instruction,
+ }
- client.on_start_turn = on_start_turn
- result, events = _stream(assistant, "打开遮阳帘")
+ result, events = _stream(
+ assistant,
+ "打开遮阳帘",
+ execute_control=execute_control,
+ preview_control=execute_control,
+ supplement_control=lambda *_args: pytest.fail(
+ "fully local control must not invoke cloud supplement"
+ ),
+ )
- assert result["answer"].startswith("车辆控制未完成")
- assert "未向车辆下发任何操作" in result["answer"]
- assert "已经为你" not in str(events)
+ assert control_calls == ["打开遮阳帘"]
+ assert result["route"] == "vehicle_control"
+ assert result["success"] is True
+ assert "打开遮阳帘" in result["answer"]
assert [event["type"] for event in events] == [
"message.accepted",
"work.started",
+ "action.started",
+ "action.progress",
"action.completed",
"answer.delta",
"work.completed",
"answer.completed",
]
- assert events[2]["success"] is False
- assert events[4]["success"] is False
- assert events[-1]["answer"] == result["answer"]
+ assert events[0]["engine"] == "n7web-control-router"
+ assert events[1]["display_text"] == "正在本地解析并下发明确控制指令……"
+ assert not client.thread_instructions
+ assert not client.turn_calls
def test_exact_server_owned_quick_control_executes_without_codex() -> None:
@@ -1057,22 +1088,253 @@ def test_exact_server_owned_quick_control_executes_without_codex() -> None:
assert not client.turn_calls
-def test_quick_control_requires_exact_normalized_command_match() -> None:
- client = FakeCodexClient()
+def test_explicit_control_does_not_require_exact_quick_card_match() -> None:
+ client = FakeCodexClient(available=False)
assistant = CodexAssistantService(client)
control_calls: list[str] = []
+ def execute_control(
+ instruction: str,
+ _progress: Callable[[dict[str, object]], None],
+ ) -> dict[str, object]:
+ control_calls.append(instruction)
+ return {
+ "request_id": "req-explicit-local",
+ "status": "succeeded",
+ "execution_mode": "dry_run",
+ "utterance": instruction,
+ }
+
result, events = _stream(
assistant,
"请关闭所有车窗",
- execute_control=lambda instruction, _progress: control_calls.append(instruction),
+ execute_control=execute_control,
+ preview_control=execute_control,
+ supplement_control=lambda *_args: pytest.fail(
+ "fully local control must not invoke cloud supplement"
+ ),
quick_control_commands=["关闭所有车窗"],
)
- assert events[0]["route"] == "agent"
- assert result["answer"].startswith("车辆控制未完成")
- assert not control_calls
- assert client.turn_calls
+ assert events[0]["route"] == "vehicle_control"
+ assert result["success"] is True
+ assert control_calls == ["请关闭所有车窗"]
+ assert not client.turn_calls
+
+
+def test_mixed_control_streams_local_result_before_unresolved_codex_turn() -> None:
+ client = FakeCodexClient()
+ assistant = CodexAssistantService(client)
+ order: list[str] = []
+ original = "空调温度设置为23度并把遮阳帘调整到适合观影"
+ local_clause = "空调温度设置为23度"
+ unresolved_clause = "把遮阳帘调整到适合观影"
+ preview_result = {
+ "request_id": "req-local-preview",
+ "status": "partially_succeeded",
+ "execution_mode": "dry_run",
+ "utterance": original,
+ "provider_used": "local_rules",
+ "local_control_budget_ms": 1000,
+ "local_control_elapsed_ms": 18.0,
+ "local_control_within_budget": True,
+ "natural_language_routing": {
+ "original_utterance": original,
+ "parsed_clauses": [local_clause],
+ "resolved_clauses": [local_clause],
+ "unresolved_clauses": [unresolved_clause],
+ },
+ "function_configs": {
+ "climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "local-temperature",
+ "capability": "climate.set_temperature",
+ "target": {"zone": "driver"},
+ "parameters": {"temperature_c": 23},
+ "parse_source": "local_rule",
+ }
+ ],
+ }
+ }
+ },
+ "command_plans": {
+ "climate": {
+ "spec": {
+ "domain": "climate",
+ "commands": [
+ {
+ "operation_ref": "local-temperature",
+ "bus": "body-can1",
+ "message": "climate.set_temperature",
+ "signals": {"debug_command": "ac temp driver 23"},
+ }
+ ],
+ }
+ }
+ },
+ }
+
+ def preview_control(
+ instruction: str,
+ progress: Callable[[dict[str, object]], None],
+ ) -> dict[str, object]:
+ assert instruction == original
+ order.append("local_preview")
+ progress(preview_result)
+ return preview_result
+
+ def supplement_control(
+ instruction: str,
+ progress: Callable[[dict[str, object]], None],
+ plan: dict[str, object],
+ ) -> dict[str, object]:
+ assert instruction == unresolved_clause
+ assert plan["requests"][0]["capability"] == "roof.set_shade_position"
+ order.append("cloud_supplement")
+ result = {
+ "request_id": "req-cloud-supplement",
+ "status": "succeeded",
+ "execution_mode": "dry_run",
+ "utterance": original,
+ }
+ progress(result)
+ return result
+
+ cloud_plan = {
+ "detected_domains": ["roof"],
+ "requests": [
+ {
+ "domain": "roof",
+ "capability": "roof.set_shade_position",
+ "target_scope": "vehicle",
+ "operation": "set",
+ "duration_ms": None,
+ "parameters": [{"name": "position_pct", "value": 100}],
+ "reason": "补足未解析的观影遮光需求。",
+ }
+ ],
+ "ambiguities": [],
+ "decision_summary": ["只处理未解析的遮阳帘子句。"],
+ "confidence": 0.9,
+ }
+
+ def on_start_turn(
+ text: str,
+ request_token: str,
+ turn: ActiveCodexTurn,
+ ) -> None:
+ order.append("cloud_turn")
+ assert text == unresolved_clause
+ assistant.execute_cockpit_request(request_token, cloud_plan)
+ _put_answer(turn, "本地温度已先行处理,遮阳帘补充控制已完成。")
+
+ client.on_start_turn = on_start_turn
+ result, events = _stream(
+ assistant,
+ original,
+ preview_control=preview_control,
+ supplement_control=supplement_control,
+ )
+
+ assert order == ["local_preview", "cloud_turn", "cloud_supplement"]
+ assert client.turn_calls[0]["text"] == unresolved_clause
+ assert client.turn_calls[0]["application_context"][
+ "natural_language_routing"
+ ] == {
+ "original_utterance": original,
+ "parsed_clauses": [local_clause],
+ "resolved_clauses": [local_clause],
+ "unresolved_clauses": [unresolved_clause],
+ "cloud_input_utterance": unresolved_clause,
+ }
+ validated_index = next(
+ index
+ for index, event in enumerate(events)
+ if event["type"] == "validated_control_commands"
+ )
+ cloud_progress_index = next(
+ index
+ for index, event in enumerate(events)
+ if event.get("display_text")
+ == "本地明确子句已处理,正在补充未解析子句……"
+ )
+ assert validated_index < cloud_progress_index
+ assert result["route"] == "agent"
+
+
+def test_fully_unresolved_control_starts_bounded_cloud_turn_and_fails_closed() -> None:
+ client = FakeCodexClient()
+ assistant = CodexAssistantService(client)
+ instruction = "把遮阳帘调整到适合观影"
+ preview_result = {
+ "request_id": "req-unresolved-preview",
+ "preview_request_id": "req-unresolved-preview",
+ "status": "no_action",
+ "execution_mode": "dry_run",
+ "utterance": instruction,
+ "provider_used": "local_rules",
+ "function_configs": {},
+ "command_plans": {},
+ "local_control_budget_ms": 1000,
+ "local_control_elapsed_ms": 4.0,
+ "local_control_within_budget": True,
+ "supplement_required": True,
+ "natural_language_routing": {
+ "original_utterance": instruction,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [instruction],
+ },
+ }
+
+ def preview_control(
+ text: str,
+ _progress: Callable[[dict[str, object]], None],
+ ) -> dict[str, object]:
+ assert text == instruction
+ return preview_result
+
+ def on_start_turn(
+ text: str,
+ _request_token: str,
+ turn: ActiveCodexTurn,
+ ) -> None:
+ assert text == instruction
+ # The browser must never receive this unsupported execution claim
+ # because no guarded vehicle tool call follows it.
+ _put_answer(turn, "遮阳帘已经调整完成。")
+
+ client.on_start_turn = on_start_turn
+ result, events = _stream(
+ assistant,
+ instruction,
+ preview_control=preview_control,
+ supplement_control=lambda *_args: pytest.fail(
+ "a model answer without a tool call must not execute control"
+ ),
+ )
+
+ assert client.turn_calls[0]["text"] == instruction
+ assert client.turn_calls[0]["application_context"][
+ "natural_language_routing"
+ ] == {
+ "original_utterance": instruction,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [instruction],
+ "cloud_input_utterance": instruction,
+ }
+ assert not any(
+ event["type"] == "validated_control_commands" for event in events
+ )
+ assert result == {
+ "route": "agent",
+ "answer": "车辆控制未完成:AI 助手没有调用受控执行链,未向车辆下发任何操作。",
+ }
+ assert events[-1]["answer"] == result["answer"]
@pytest.mark.parametrize(
diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py
index 33af131..25ded9f 100644
--- a/tests/unit/test_energy_monitor_frontend_contract.py
+++ b/tests/unit/test_energy_monitor_frontend_contract.py
@@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
):
assert asset in html
assert f'"{asset}"' in service_worker
- assert html.index("/assistant-chat.js?v=codex-assistant-26") < html.index(
+ assert html.index("/assistant-chat.js?v=codex-assistant-27") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v282"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
@@ -160,7 +160,7 @@ def test_energy_monitor_tracks_ai_and_direct_control_activity() -> None:
assert "completeControlActivity" in script
assert 'event.type === "action.progress" && event.payload' in assistant
assert 'new CustomEvent("cognitive-agent:vehicle-control-activity"' in assistant
- assert 'new CustomEvent("cognitive-agent:validated-can-commands"' not in assistant
+ assert '"cognitive-agent:validated-can-commands"' in assistant
def test_energy_monitor_reads_persistent_seat_state_for_both_seats() -> None:
diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py
index cb5e655..bcff074 100644
--- a/tests/unit/test_frontend_theme_contract.py
+++ b/tests/unit/test_frontend_theme_contract.py
@@ -41,13 +41,13 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
for asset in (
"/styles.css?v=dual-seat-23",
- "/console-v2.css?v=delivery-state-66",
+ "/console-v2.css?v=delivery-state-68",
"/self-extension.css?v=gscu-tcp-43",
"/speech-input.css?v=sensevoice-7",
):
assert asset in html
assert f'"{asset}"' in service_worker
- assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker
+ assert 'const CACHE_VERSION = "cognitive-agent-pad-v282"' in service_worker
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
diff --git a/tests/unit/test_mqtt_can_transport.py b/tests/unit/test_mqtt_can_transport.py
index 9071ba2..fa87e41 100644
--- a/tests/unit/test_mqtt_can_transport.py
+++ b/tests/unit/test_mqtt_can_transport.py
@@ -42,10 +42,22 @@ class _FakeMqttClient:
self.on_message = None
self.subscriptions: list[tuple[str, int]] = []
self.published: list[tuple[str, str, int, bool]] = []
+ self.websocket_options: list[dict[str, object]] = []
+ self.credentials: tuple[str, str | None] | None = None
+ self.tls_configured = False
def reconnect_delay_set(self, **_kwargs) -> None:
return None
+ def ws_set_options(self, **kwargs: object) -> None:
+ self.websocket_options.append(kwargs)
+
+ def username_pw_set(self, username: str, password: str | None) -> None:
+ self.credentials = (username, password)
+
+ def tls_set(self) -> None:
+ self.tls_configured = True
+
def connect_async(self, host: str, port: int, keepalive: int) -> None:
self.connection = (host, port, keepalive)
@@ -89,6 +101,8 @@ def _settings():
"password": None,
"tls_enabled": False,
"feedback_timeout_ms": 100,
+ "transport": "tcp",
+ "websocket_path": "/mqtt",
}
values["public_info"] = lambda: {
"enabled": True,
@@ -109,9 +123,31 @@ class MqttCanPayloadTests(unittest.TestCase):
self.assertEqual(settings.condition_topic, "n7/n7-headunit/can/state")
self.assertEqual(settings.control_topic, "n7/n7-headunit/can/command")
self.assertFalse(settings.approval_required)
+ self.assertEqual(settings.transport, "tcp")
+ self.assertEqual(settings.websocket_path, "/mqtt")
self.assertEqual(info["state_contract"], "n7-can-signals/v1")
self.assertEqual(info["control_contract"], "n7-headunit-can-command/v1")
+ def test_websocket_transport_settings_are_loaded_from_environment(self) -> None:
+ with patch.dict(
+ "os.environ",
+ {
+ "COGNITIVE_AGENT_VEHICLE_MQTT_HOST": "mqtt.example.test",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_PORT": "443",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_TRANSPORT": "websocket",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_WEBSOCKET_PATH": "/mqtt",
+ "COGNITIVE_AGENT_VEHICLE_MQTT_TLS": "true",
+ },
+ clear=True,
+ ):
+ settings = VehicleMqttSettings.from_env()
+
+ self.assertTrue(settings.adapter_configured)
+ self.assertEqual(settings.transport, "websockets")
+ self.assertEqual(settings.websocket_path, "/mqtt")
+ self.assertTrue(settings.tls_enabled)
+ self.assertEqual(settings.public_info()["transport"], "websockets")
+
def test_explicit_shared_approval_policy_still_locks_mqtt_control(self) -> None:
with patch.dict(
"os.environ",
@@ -461,6 +497,29 @@ class MqttCanTransportTests(unittest.TestCase):
def tearDown(self) -> None:
self.transport.close()
+ def test_websocket_client_applies_transport_path_authentication_and_tls(self) -> None:
+ client = _FakeMqttClient()
+ settings = _settings()
+ settings.transport = "websockets"
+ settings.websocket_path = "/mqtt"
+ settings.username = "n7web"
+ settings.password = "secret"
+ settings.tls_enabled = True
+ transport = MqttCanTransport(settings)
+
+ with patch("paho.mqtt.client.Client", return_value=client) as constructor:
+ transport.start()
+ try:
+ self.assertEqual(
+ constructor.call_args.kwargs["transport"], "websockets"
+ )
+ self.assertEqual(client.websocket_options, [{"path": "/mqtt"}])
+ self.assertEqual(client.credentials, ("n7web", "secret"))
+ self.assertTrue(client.tls_configured)
+ self.assertEqual(client.connection, ("127.0.0.1", 1883, 30))
+ finally:
+ transport.close()
+
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
diff --git a/tests/unit/test_nfc_frontend_contract.py b/tests/unit/test_nfc_frontend_contract.py
index ef18df5..81ed498 100644
--- a/tests/unit/test_nfc_frontend_contract.py
+++ b/tests/unit/test_nfc_frontend_contract.py
@@ -137,7 +137,8 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No
assert 'capability === "infrared.emit"' in console
assert 'channel: "IR"' in console
assert 'frameLabel: "GSCU1B TCP"' in console
- assert "已解析控制指令" in console
+ assert "下发控制指令" in console
+ assert "已解析控制指令" not in console
assert 'li[data-can-channel="IR"]' in styles
- assert "console-v2.js?v=delivery-state-62" in html
+ assert "console-v2.js?v=local-first-66" in html
assert 'speech_semantic: "语音语义"' in console
diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py
index b6334ea..db9268e 100644
--- a/tests/unit/test_requested_frontend_enhancements.py
+++ b/tests/unit/test_requested_frontend_enhancements.py
@@ -195,9 +195,15 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert 'canCommandPanel.id = "v2CanCommandPanel"' in console
assert 'id="v2CanCommandCount">0' in console
assert 'id="v2CanCommandList"' in console
- assert "canCommandPanel.open" not in console
+ assert 'id="v2CommandTiming"' in console
+ assert "panel.open = true;" in console
+ assert "if (ui.sidebarCollapsed) setSidebarCollapsed(false);" in console
assert "function parsedCanCommandForOperation(operation = {})" in console
assert "function parsedCanCommands(result = {})" in console
+ assert "function semanticControlInstruction(operation = {})" in console
+ assert "function compiledCommandsForOperation(result, configKey, operation = {})" in console
+ assert "function parsedCommandTimingRows(result = {}, timingState = {})" in console
+ assert "result.validated_control_commands" in console
assert "getParsedCanCommands: (result) => parsedCanCommands(result)" in console
assert "const configurations = result.function_configs || {};" in console
assert "if (!configurationEntries.length && result.function_config)" in console
@@ -206,8 +212,18 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert 'const command = (category, text, channel = "CAN1", frame = "")' in console
assert 'data-can-channel="${escapeHtml(item.channel)}"' in console
assert 'class="v2-can-command-channel"' in console
+ assert 'class="v2-can-command-layer v2-can-command-semantic"' in console
+ assert 'class="v2-can-command-layer v2-can-command-actual"' in console
+ assert console.index("语义控制指令") < console.index("实际控制指令")
+ assert 'recordTiming: true' in console
+ assert 'reveal: true' in console
+ assert 'window.addEventListener("cognitive-agent:speech-state"' in console
+ assert 'window.addEventListener("cognitive-agent:speech-result"' in console
+ assert 'window.addEventListener("cognitive-agent:control-request-start"' in console
assert 'window.addEventListener("cognitive-agent:result"' in console
assert '$("#requestForm")?.addEventListener("submit"' in console
+ assert "function commandResultMatchesTimedRequest(result = {})" in console
+ assert "matchesTimedRequest && !validatedCommandsVisible" in console
for command_contract in (
"door rear ${action}",
@@ -246,6 +262,9 @@ def test_left_sidebar_exposes_collapsed_parsed_can_command_panel() -> None:
assert ".v2-can-command-list li code" in styles
assert ".v2-can-command-channel" in styles
assert ".v2-can-command-delivery" in styles
+ assert ".v2-command-timing" in styles
+ assert ".v2-can-command-semantic" in styles
+ assert ".v2-can-command-actual" in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert 'li[data-can-channel="CAN0"]' in styles
assert ".v2-sidebar-collapsed .v2-can-command-panel" in styles
@@ -297,10 +316,12 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"process.stdout.write(JSON.stringify(parsedCanCommands(sample)));"
)
completed = subprocess.run(
- [node, "-e", script],
+ [node, "-"],
+ input=script,
check=True,
capture_output=True,
text=True,
+ encoding="utf-8",
)
assert json.loads(completed.stdout) == [
@@ -310,6 +331,8 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"command": "seat passenger backrest position 36",
"frame": "0x300 · 01 12 10 0E 30 75",
"parseSource": "local_rule",
+ "semantic": "seat.set_backrest_recline(target=front-right, angle_deg=120, position_pct=36)",
+ "operationRef": "",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
@@ -319,6 +342,8 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
"category": "空调温度",
"command": "ac temp driver 24",
"parseSource": "cloud_supplement",
+ "semantic": "climate.set_temperature(target=driver, temperature_c=24)",
+ "operationRef": "",
"deliveryState": "not_submitted",
"deliveryLabel": "未下发",
"deliveryTransport": "有线 CAN",
@@ -326,6 +351,335 @@ def test_parsed_can_command_panel_combines_can0_seat_and_can1_vehicle_commands()
]
+def test_local_mixed_command_uses_compiled_can_and_ir_details_with_stage_timings() -> None:
+ node = shutil.which("node")
+ if node is None:
+ return
+ console = source("console-v2.js")
+ start = console.index(" const normalizedCanTarget")
+ end = console.index("\n\n function renderParsedCanCommands", start)
+ helpers = console[start:end]
+ sample = {
+ "provider_used": "local_rules",
+ "function_configs": {
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "climate-temperature-1",
+ "capability": "climate.set_temperature",
+ "target": {"zone": "driver"},
+ "parameters": {"temperature_c": 23},
+ "parse_source": "local_rule",
+ }
+ ],
+ }
+ },
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "operations": [
+ {
+ "id": "infrared-1",
+ "capability": "infrared.emit",
+ "target": {"device": "toy-001", "name": "闪灯玩具"},
+ "parameters": {"slot": 1, "action": "开启"},
+ "parse_source": "local_rule",
+ }
+ ],
+ }
+ },
+ },
+ # Deliberately reverse the plan order and include a distracting command;
+ # the renderer must join only on the exact operation_ref.
+ "command_plans": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "commands": [
+ {
+ "operation_ref": "unrelated-ir",
+ "message": "infrared.emit",
+ "signals": {"slot": 9},
+ },
+ {
+ "operation_ref": "infrared-1",
+ "bus": "TCP",
+ "message": "infrared.emit",
+ "signals": {"slot": 1},
+ },
+ ],
+ }
+ },
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "commands": [
+ {
+ "operation_ref": "climate-temperature-1",
+ "bus": "body_can",
+ "message": "Native_0x1DE",
+ "signals": {
+ "debug_command": "ac temp driver 23",
+ "frame_id": "0x1DE",
+ "data_hex": "00 00 00 00 17 00 00 00",
+ },
+ }
+ ],
+ }
+ },
+ },
+ "trace_events": [
+ {"stage": "local_peripheral_plan", "duration_ms": 1},
+ {"stage": "nfc_peripheral_sequence", "duration_ms": 0},
+ {"stage": "intent_parse", "duration_ms": 12},
+ {"stage": "context_resolve", "duration_ms": 1},
+ {"stage": "compile", "duration_ms": 2},
+ {"stage": "execute", "duration_ms": 999},
+ ],
+ }
+ timing_state = {
+ "inputMode": "speech",
+ "speechStartedAt": 0,
+ "speechResultAt": 80,
+ "speechTimings": {
+ "audio_read_ms": 1,
+ "vad_ms": 2,
+ "asr_ms": 30,
+ "assembly_ms": 1,
+ "semantic_ms": 3,
+ "command_resolution_ms": 1,
+ "recovery_ms": 0,
+ "total_ms": 37,
+ },
+ "requestStartedAt": 100,
+ "validatedAt": 125,
+ "displayedAt": 126,
+ "renderDurationMs": 1,
+ }
+ browser_projection = {
+ "provider_used": "local_rules+infrared_local_rules",
+ "execution_mode": "dry_run",
+ "validated_control_commands": [
+ {
+ "operation": sample["function_configs"]["native_climate"]["spec"][
+ "operations"
+ ][0],
+ "domain": "climate",
+ "channel": "CAN1",
+ "actual_command": "ac temp driver 23",
+ "frames": [
+ {
+ "label": "CAN1 帧",
+ "value": "0x1DE · 00 00 00 00 17 00 00 00",
+ }
+ ],
+ },
+ {
+ "operation": sample["function_configs"]["self_extension"]["spec"][
+ "operations"
+ ][0],
+ "domain": "self_extension",
+ "channel": "IR",
+ "actual_command": "infrared emit 1",
+ "frames": [
+ {
+ "label": "GSCU1B TCP",
+ "value": '{"action":"emit","data":{"no":1},"type":"infrared"}',
+ }
+ ],
+ },
+ ],
+ "trace_events": sample["trace_events"][:3],
+ }
+ script = (
+ f"{helpers}\n"
+ f"const sample = {json.dumps(sample, ensure_ascii=False)};\n"
+ f"const browserProjection = {json.dumps(browser_projection, ensure_ascii=False)};\n"
+ f"const timingState = {json.dumps(timing_state)};\n"
+ "process.stdout.write(JSON.stringify({"
+ "commands: parsedCanCommands(sample), "
+ "projectedCommands: parsedCanCommands(browserProjection), "
+ "timings: parsedCommandTimingRows(sample, timingState)"
+ "}));"
+ )
+ completed = subprocess.run(
+ [node, "-"],
+ input=script,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+
+ parsed = json.loads(completed.stdout)
+ climate, infrared = parsed["commands"]
+ projected_climate, projected_infrared = parsed["projectedCommands"]
+ assert climate["semantic"] == (
+ "climate.set_temperature(target=driver, temperature_c=23)"
+ )
+ assert climate["command"] == "ac temp driver 23"
+ assert climate["frame"] == "0x1DE · 00 00 00 00 17 00 00 00"
+ assert climate["frameLabel"] == "CAN1 帧"
+ assert climate["parseSource"] == "local_rule"
+ assert infrared["semantic"] == (
+ "infrared.emit(target=toy-001, action=开启, slot=1)"
+ )
+ assert infrared["command"] == "infrared emit 1"
+ assert infrared["frameLabel"] == "GSCU1B TCP"
+ assert json.loads(infrared["frame"])["data"]["no"] == 1
+ assert infrared["parseSource"] == "local_rule"
+ assert projected_climate["semantic"] == climate["semantic"]
+ assert projected_climate["command"] == climate["command"]
+ assert projected_climate["frames"] == climate["frames"]
+ assert projected_infrared["semantic"] == infrared["semantic"]
+ assert projected_infrared["command"] == infrared["command"]
+ assert projected_infrared["frames"] == infrared["frames"]
+
+ timing_by_key = {item["key"]: item for item in parsed["timings"]}
+ assert timing_by_key["local_peripheral_plan-0"]["label"] == "红外本地规则编译"
+ assert timing_by_key["nfc_peripheral_sequence-1"]["label"] == "红外指令序列生成"
+ assert timing_by_key["intent_parse-2"]["label"] == "本地规则意图解析"
+ assert timing_by_key["request_to_validated"]["durationMs"] == 25
+ assert timing_by_key["dom_render"]["durationMs"] == 1
+ assert timing_by_key["request_to_display"]["durationMs"] == 26
+ assert timing_by_key["speech_to_display"]["durationMs"] == 126
+ assert timing_by_key["total_ms"]["durationMs"] == 37
+ assert all(item["durationMs"] >= 0 for item in parsed["timings"])
+ assert not any(item["key"].startswith("execute") for item in parsed["timings"])
+
+
+def test_duplicate_operation_refs_do_not_borrow_another_configs_plan() -> None:
+ node = shutil.which("node")
+ if node is None:
+ return
+ console = source("console-v2.js")
+ start = console.index(" const normalizedCanTarget")
+ end = console.index("\n\n function renderParsedCanCommands", start)
+ helpers = console[start:end]
+ sample = {
+ "function_configs": {
+ "native_climate": {
+ "spec": {
+ "domain": "climate",
+ "operations": [
+ {
+ "id": "op-1",
+ "capability": "climate.set_temperature",
+ "target": {"zone": "driver"},
+ "parameters": {"temperature_c": 23},
+ }
+ ],
+ }
+ },
+ "native_seat": {
+ "spec": {
+ "domain": "seat",
+ "operations": [
+ {
+ "id": "op-1",
+ "capability": "seat.set_heating",
+ "target": {"zone": "passenger"},
+ "parameters": {"level": 2},
+ }
+ ],
+ }
+ },
+ },
+ "command_plans": {
+ "native_seat": {
+ "spec": {
+ "domain": "seat",
+ "commands": [
+ {
+ "operation_ref": "op-1",
+ "bus": "seat_can0",
+ "signals": {
+ "debug_command": "seat passenger heat 2",
+ "frame_id": "0x300",
+ "data_hex": "01 02 03 04 05 06 07 08",
+ },
+ }
+ ],
+ }
+ }
+ },
+ }
+ script = (
+ f"{helpers}\n"
+ f"const sample = {json.dumps(sample)};\n"
+ "process.stdout.write(JSON.stringify(parsedCanCommands(sample)));"
+ )
+
+ completed = subprocess.run(
+ [node, "-"],
+ input=script,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+
+ parsed = json.loads(completed.stdout)
+ by_semantic = {item["semantic"]: item for item in parsed}
+ climate = by_semantic[
+ "climate.set_temperature(target=driver, temperature_c=23)"
+ ]
+ seat = by_semantic["seat.set_heating(target=passenger, level=2)"]
+ assert climate["command"] == "ac temp driver 23"
+ assert "frame" not in climate
+ assert climate["channel"] == "CAN1"
+ assert seat["command"] == "seat passenger heat 2"
+ assert seat["channel"] == "CAN0"
+
+
+def test_command_timing_rejects_stale_request_events() -> None:
+ node = shutil.which("node")
+ if node is None:
+ return
+ console = source("console-v2.js")
+ correlation_start = console.index(" function commandCorrelationIds")
+ correlation_end = console.index(
+ "\n\n function rememberValidatedCommandContext",
+ correlation_start,
+ )
+ correlation_helper = console[correlation_start:correlation_end]
+ start = console.index(" const normalizedCanTarget")
+ end = console.index("\n\n function renderParsedCanCommands", start)
+ helpers = console[start:end]
+ script = (
+ "let commandTimingState = {requestStartedAt: 1, requestId: '', "
+ "utterance: '空调温度设置到23度,打开闪烁灯'};\n"
+ f"{correlation_helper}\n"
+ f"{helpers}\n"
+ "const first = commandResultMatchesTimedRequest({request_id: 'req-current', "
+ "utterance: '空调温度设置到23度,打开闪烁灯'});\n"
+ "const staleId = commandResultMatchesTimedRequest({request_id: 'req-old', "
+ "utterance: '空调温度设置到23度,打开闪烁灯'});\n"
+ "const staleText = commandResultMatchesTimedRequest({request_id: 'req-current', "
+ "utterance: '打开车窗'});\n"
+ "process.stdout.write(JSON.stringify({first, staleId, staleText, "
+ "boundRequestId: commandTimingState.requestId}));"
+ )
+
+ completed = subprocess.run(
+ [node, "-"],
+ input=script,
+ check=True,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ )
+
+ assert json.loads(completed.stdout) == {
+ "first": True,
+ "staleId": False,
+ "staleText": False,
+ "boundRequestId": "req-current",
+ }
+
+
def test_parsed_command_delivery_is_independent_of_transport_and_target() -> None:
node = shutil.which("node")
if node is None:
@@ -398,10 +752,12 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
"parsedCanCommands(sample))));"
)
completed = subprocess.run(
- [node, "-e", script],
+ [node, "-"],
+ input=script,
check=True,
capture_output=True,
text=True,
+ encoding="utf-8",
)
parsed = json.loads(completed.stdout)
@@ -423,6 +779,7 @@ def test_parsed_command_delivery_is_independent_of_transport_and_target() -> Non
def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() -> None:
app = source("app.js")
+ console = source("console-v2.js")
styles = source("styles.css")
html = source("index.html")
@@ -442,6 +799,10 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
assert 'executionMode: "dry_run"' in app
assert 'clientType: "mode_editor_compile"' in app
assert "previewOnly: true" in app
+ assert 'const nonDispatchTaskSources = new Set(["mode_editor_compile"])' in console
+ assert "if (isDispatchTask(task))" in console
+ assert "下发控制指令" in console
+ assert "已解析控制指令" not in console
assert "function executeSavedModeProfile(mode)" in app
assert "/api/mode-profiles/${encodeURIComponent(mode.id)}/execute" in app
assert '$("#utterance").value = mode.name || mode.id;' in app
@@ -450,6 +811,9 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences()
assert "生成指令" in html
assert ">执行" not in html
+ assert 'id="hardwareNote"' not in html
+ assert 'id="hardwareAliasNote"' not in html
+ assert 'id="vehicleTransportNote"' not in html
assert ".mode-editor-command-card" in styles
assert ".mode-editor-command-meta" in styles
@@ -544,10 +908,12 @@ def test_mode_editor_prepares_one_to_many_mqtt_message_sequences() -> None:
"process.stdout.write(JSON.stringify(modeEditorReadableCommands(flow)));"
)
completed = subprocess.run(
- [node, "-e", script],
+ [node, "-"],
+ input=script,
check=True,
capture_output=True,
text=True,
+ encoding="utf-8",
)
commands = json.loads(completed.stdout)
@@ -619,10 +985,12 @@ def test_left_sidebar_extracts_speech_and_peripheral_control_commands() -> None:
"}));"
)
completed = subprocess.run(
- [node, "-e", script],
+ [node, "-"],
+ input=script,
check=True,
capture_output=True,
text=True,
+ encoding="utf-8",
)
output = json.loads(completed.stdout)
@@ -924,7 +1292,8 @@ def test_assistant_mode_preview_groups_validated_actions_for_the_card() -> None:
"process.stdout.write(JSON.stringify(modeDraftRows(draft, actions)));"
)
completed = subprocess.run(
- [node, "-e", script],
+ [node, "-"],
+ input=script,
check=True,
capture_output=True,
text=True,
diff --git a/tests/unit/test_scene_composer_fast_path.py b/tests/unit/test_scene_composer_fast_path.py
index 772c5a0..ff3c5c0 100644
--- a/tests/unit/test_scene_composer_fast_path.py
+++ b/tests/unit/test_scene_composer_fast_path.py
@@ -792,7 +792,15 @@ def test_unknown_mode_uses_one_structured_turn_then_local_dry_run() -> None:
"roof.set_shade_position",
]
call = client.structured_calls[0]
- assert call["application_context"] == _scene_context()
+ expected_context = _scene_context()
+ expected_context["natural_language_routing"] = {
+ "original_utterance": "构建一个星夜模式",
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": ["构建一个星夜模式"],
+ "cloud_input_utterance": "构建一个星夜模式",
+ }
+ assert call["application_context"] == expected_context
assert call["output_schema"]["type"] == "object"
assert call["output_schema"]["additionalProperties"] is False
assert "requests" in call["output_schema"]["required"]
diff --git a/tests/unit/test_two_stage_can_parsing.py b/tests/unit/test_two_stage_can_parsing.py
index e66fb84..5b324ae 100644
--- a/tests/unit/test_two_stage_can_parsing.py
+++ b/tests/unit/test_two_stage_can_parsing.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
+from types import SimpleNamespace
from unittest.mock import patch
from cognitive_agent.application.seat_service import SeatDemoService
@@ -47,6 +48,19 @@ def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path)
assert result["status"] == "succeeded"
assert result["supplement_required"] is True
assert result["unresolved_clauses"] == ["播放音乐"]
+ assert result["parsed_clauses"] == [
+ "空调温度设置为24度",
+ "风量设置为2档",
+ ]
+ assert result["natural_language_routing"] == {
+ "original_utterance": "空调温度设置为24度,风量设置为2档,播放音乐",
+ "parsed_clauses": ["空调温度设置为24度", "风量设置为2档"],
+ "resolved_clauses": ["空调温度设置为24度", "风量设置为2档"],
+ "unresolved_clauses": ["播放音乐"],
+ }
+ assert result["local_control_budget_ms"] == 1000
+ assert result["local_control_elapsed_ms"] < 1000
+ assert result["local_control_within_budget"] is True
assert [item["capability"] for item in _operations(result)] == [
"climate.set_temperature",
"climate.set_fan_level",
@@ -58,6 +72,8 @@ def test_preview_validates_local_controls_without_calling_cloud(tmp_path: Path)
assert validated_events[0]["phase"] == "deterministic_commands_validated"
assert validated_events[0]["status"] == "processing"
assert validated_events[0]["execution_report"] is None
+ assert validated_events[0]["local_control_elapsed_ms"] < 1000
+ assert validated_events[0]["local_control_within_budget"] is True
assert _operations(validated_events[0]) == _operations(result)
assert validated_events[0]["command_plans"]
@@ -119,12 +135,90 @@ def test_fully_local_cabin_request_streams_all_commands_without_supplement(
assert _operations(validated_events[0]) == operations
+def test_qualitative_window_position_keeps_adjacent_reading_light_clause(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ cases = (
+ ("空调 29℃、打开阅读灯、副驾车窗开到20%", 20),
+ ("空调 29℃、打开阅读灯、副驾车窗开到一半", 50),
+ )
+
+ for index, (utterance, expected_position) in enumerate(cases):
+ result = service.preview_native_can(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-reading-window-{index:02d}",
+ execution_mode="dry_run",
+ )
+ operations = _operations(result)
+ by_capability = {item["capability"]: item for item in operations}
+
+ assert result["unresolved_clauses"] == []
+ assert result["local_control_elapsed_ms"] < 1000
+ assert result["local_control_within_budget"] is True
+ assert result["parsed_clauses"] == [
+ "空调 29℃",
+ "打开阅读灯",
+ utterance.rsplit("、", 1)[-1],
+ ]
+ assert set(by_capability) == {
+ "climate.set_temperature",
+ "interior_light.set_reading_power",
+ "window.set_position",
+ }
+ assert (
+ by_capability["interior_light.set_reading_power"]["target"]["zone"]
+ == "all"
+ )
+ assert by_capability["interior_light.set_reading_power"]["parameters"] == {
+ "state": "on"
+ }
+ assert (
+ by_capability["window.set_position"]["target"]["zone"]
+ == "front_right"
+ )
+ assert by_capability["window.set_position"]["parameters"] == {
+ "position_pct": expected_position
+ }
+
+
+def test_local_window_parser_accepts_front_and_rear_row_side_aliases(
+ tmp_path: Path,
+) -> None:
+ service = _service(tmp_path)
+ cases = (
+ ("左前排车窗开到20%", "front_left", 20),
+ ("右前排窗开到一半", "front_right", 50),
+ ("左后排车窗开到35%", "rear_left", 35),
+ ("右后排窗开到一半", "rear_right", 50),
+ )
+
+ for index, (utterance, expected_target, expected_position) in enumerate(cases):
+ result = service.preview_native_can(
+ utterance,
+ provider="local",
+ conversation_id=f"conv-window-alias-{index:02d}",
+ execution_mode="dry_run",
+ )
+ operations = _operations(result)
+
+ assert result["status"] == "succeeded"
+ assert result["unresolved_clauses"] == []
+ assert len(operations) == 1
+ assert operations[0]["capability"] == "window.set_position"
+ assert operations[0]["target"]["zone"] == expected_target
+ assert operations[0]["parameters"] == {
+ "position_pct": expected_position
+ }
+
+
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度并播放音乐",
+ "空调温度设置为24度并播放音乐",
provider="openai",
conversation_id="conv-two-stage-deduplicate",
execution_mode="dry_run",
@@ -169,7 +263,15 @@ def test_supplement_receives_only_unresolved_text_and_deduplicates_local_control
provider="openai",
)
- assert interpret.call_args.args[0] == "观影时空调温度设置为24度并播放音乐"
+ assert interpret.call_args.args[0] == "播放音乐"
+ routing = interpret.call_args.args[1]["natural_language_routing"]
+ assert routing == {
+ "original_utterance": "空调温度设置为24度并播放音乐",
+ "parsed_clauses": ["空调温度设置为24度"],
+ "resolved_clauses": ["空调温度设置为24度"],
+ "unresolved_clauses": ["播放音乐"],
+ "cloud_input_utterance": "播放音乐",
+ }
operations = _operations(result)
assert [item["capability"] for item in operations].count(
"climate.set_temperature"
@@ -217,6 +319,11 @@ def test_frontend_two_stage_contract_and_source_badges() -> None:
assert 'item.deliveryLabel' in console
assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console
assert '"cognitive-agent:validated-can-commands"' in console
+ assert "function commandCorrelationIds(result = {})" in console
+ assert "result.preview_request_id" in console
+ assert "result.supplement_of" in console
+ assert '"local_control_elapsed"' in console
+ assert '"本地规则解析与下发"' in console
assert '.v2-can-command-source[data-parse-source="local_rule"]' in styles
assert '.v2-can-command-delivery[data-delivery-state="submitted"]' in styles
assert '"/api/can/commands"' in server
@@ -287,6 +394,7 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
"command_plans": {},
"operation_results": [],
"decision_summary": [],
+ "trace_events": [{"stage": "compile", "duration_ms": 3}],
}
infrared_result = {
"status": "succeeded",
@@ -296,6 +404,10 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
"command_plans": {},
"operation_results": [],
"decision_summary": [],
+ "trace_events": [
+ {"stage": "local_peripheral_plan", "duration_ms": 1},
+ {"stage": "nfc_peripheral_sequence", "duration_ms": 0},
+ ],
}
entries = [
{
@@ -321,6 +433,368 @@ def test_unified_result_preserves_cloud_infrared_attribution() -> None:
assert merged["provider_used"] == "local_rules+gpt:gpt-5.6-luna"
assert merged["infrared_actions"][0]["parse_source"] == "cloud_model"
+ assert [event["stage"] for event in merged["trace_events"]] == [
+ "local_peripheral_plan",
+ "nfc_peripheral_sequence",
+ "compile",
+ ]
+
+
+def test_pure_infrared_request_publishes_compiled_commands() -> None:
+ handler = SeatDemoHandler.__new__(SeatDemoHandler)
+ handler.service = SimpleNamespace(
+ is_mode_profile_configuration_request=lambda _utterance: False,
+ conversations=SimpleNamespace(snapshot=lambda _conversation_id: {}),
+ persist_composed_result=lambda _result: None,
+ )
+ handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: [])
+ handler.nfc = SimpleNamespace(snapshot=lambda: {})
+ infrared_result = {
+ "request_id": "req-ir-only",
+ "utterance": "打开闪烁灯",
+ "status": "succeeded",
+ "function_configs": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "operations": [
+ {
+ "id": "infrared-1",
+ "capability": "infrared.emit",
+ }
+ ],
+ }
+ }
+ },
+ "command_plans": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "commands": [
+ {
+ "operation_ref": "infrared-1",
+ "message": "infrared.emit",
+ "signals": {"slot": 1},
+ }
+ ],
+ }
+ }
+ },
+ "trace_events": [
+ {"stage": "nfc_peripheral_route", "duration_ms": 0}
+ ],
+ }
+ handler._execute_local_peripheral_actions = (
+ lambda _utterance, _entries, **_kwargs: infrared_result
+ )
+ validated_events: list[dict[str, object]] = []
+ partition = {
+ "peripheral_actions": [
+ {
+ "index": 0,
+ "clause": "打开闪烁灯",
+ "capability": {
+ "id": "toy-001-light-on",
+ "slot": 1,
+ },
+ }
+ ],
+ "residual_utterance": "",
+ "unresolved_peripheral_clauses": [],
+ "denied_peripheral_actions": [],
+ }
+
+ with patch(
+ "cognitive_agent.server.partition_active_peripheral_intent",
+ return_value=partition,
+ ):
+ result = handler._execute_request_with_peripherals(
+ "/api/requests/preview",
+ {
+ "utterance": "打开闪烁灯",
+ "provider": "local",
+ "execution_mode": "dry_run",
+ },
+ on_commands_validated=validated_events.append,
+ )
+
+ assert result is infrared_result
+ assert len(validated_events) == 2
+ assert validated_events[0]["status"] == "processing"
+ assert validated_events[0]["phase"] == "commands_validated"
+ assert validated_events[0]["command_plans"] == infrared_result["command_plans"]
+ assert validated_events[0]["trace_events"] == infrared_result["trace_events"]
+ assert validated_events[1] is infrared_result
+
+
+def test_pure_unresolved_peripheral_returns_cloud_ready_local_preview(
+ tmp_path: Path,
+) -> None:
+ handler = SeatDemoHandler.__new__(SeatDemoHandler)
+ handler.service = _service(tmp_path)
+ handler.extension = SimpleNamespace(list_peripheral_capabilities=lambda: [])
+ handler.nfc = SimpleNamespace(snapshot=lambda: {})
+ validated_events: list[dict[str, object]] = []
+ utterance = "让闪烁灯喜庆一点"
+ partition = {
+ "peripheral_actions": [],
+ "residual_utterance": "",
+ "unresolved_peripheral_clauses": [utterance],
+ "unresolved_candidates": [
+ {
+ "id": "light-festive",
+ "status": "learned",
+ "device_id": "toy-001",
+ "device_name": "闪烁灯",
+ "action": "喜庆灯效",
+ "slot": 2,
+ }
+ ],
+ "denied_peripheral_actions": [],
+ }
+
+ with patch(
+ "cognitive_agent.server.partition_active_peripheral_intent",
+ return_value=partition,
+ ):
+ result = handler._execute_request_with_peripherals(
+ "/api/requests/preview",
+ {
+ "utterance": utterance,
+ "provider": "local",
+ "conversation_id": "conv-unresolved-peripheral-preview",
+ "execution_mode": "dry_run",
+ },
+ on_commands_validated=validated_events.append,
+ )
+
+ assert result is not None
+ assert result["status"] == "no_action"
+ assert result["supplement_required"] is True
+ assert result["function_configs"] == {}
+ assert result["command_plans"] == {}
+ assert result["preview_request_id"] == result["request_id"]
+ assert result["local_control_budget_ms"] == 1000
+ assert result["local_control_elapsed_ms"] < 1000
+ assert result["local_control_within_budget"] is True
+ assert result["natural_language_routing"] == {
+ "original_utterance": utterance,
+ "parsed_clauses": [],
+ "resolved_clauses": [],
+ "unresolved_clauses": [utterance],
+ }
+ assert validated_events == [result]
+ assert (
+ handler.service.artifact_root
+ / str(result["request_id"])
+ / "result.json"
+ ).is_file()
+
+
+def test_mixed_request_finishes_all_local_phases_before_cloud_ir() -> None:
+ handler = SeatDemoHandler.__new__(SeatDemoHandler)
+ order: list[str] = []
+ validated_events: list[dict[str, object]] = []
+ local_capability = {
+ "id": "light-on",
+ "status": "learned",
+ "device_id": "toy-001",
+ "device_name": "闪烁灯",
+ "card_uid": "A1B2",
+ "action": "打开",
+ "slot": 1,
+ }
+ cloud_capability = {
+ **local_capability,
+ "id": "light-festive",
+ "action": "喜庆灯效",
+ "slot": 2,
+ }
+
+ def peripheral_result(
+ request_id: str,
+ capability: dict[str, object],
+ parse_source: str,
+ ) -> dict[str, object]:
+ operation_id = str(capability["id"])
+ return {
+ "request_id": request_id,
+ "utterance": "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点",
+ "status": "succeeded",
+ "success": True,
+ "provider_used": (
+ "infrared_local_rules"
+ if parse_source == "local_rule"
+ else "gpt:test"
+ ),
+ "execution_mode": "dry_run",
+ "control_transport": "gscu_dry_run",
+ "function_configs": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "operations": [
+ {
+ "id": operation_id,
+ "capability": "infrared.emit",
+ "parse_source": parse_source,
+ }
+ ],
+ }
+ }
+ },
+ "command_plans": {
+ "self_extension": {
+ "spec": {
+ "domain": "self_extension",
+ "commands": [
+ {
+ "operation_ref": operation_id,
+ "message": "infrared.emit",
+ "signals": {"slot": capability["slot"]},
+ }
+ ],
+ }
+ }
+ },
+ "trace_events": [],
+ "validation": [],
+ "operation_results": [],
+ "decision_summary": [],
+ "execution_report": None,
+ }
+
+ local_result = peripheral_result(
+ "req-local-ir",
+ local_capability,
+ "local_rule",
+ )
+ cloud_result = peripheral_result(
+ "req-cloud-ir",
+ cloud_capability,
+ "cloud_model",
+ )
+ can_result = {
+ "request_id": "req-local-can",
+ "utterance": "空调温度设置到23度",
+ "status": "succeeded",
+ "success": True,
+ "provider_used": "local_rules",
+ "execution_mode": "dry_run",
+ "control_transport": "wired_can",
+ "function_configs": {},
+ "command_plans": {},
+ "trace_events": [],
+ "validation": [],
+ "operation_results": [],
+ "decision_summary": [],
+ "execution_report": None,
+ "natural_language_routing": {
+ "original_utterance": "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点",
+ "parsed_clauses": ["打开闪烁灯", "空调温度设置到23度"],
+ "resolved_clauses": ["打开闪烁灯", "空调温度设置到23度"],
+ "unresolved_clauses": ["让闪烁灯喜庆一点"],
+ },
+ }
+
+ def preview_native_can(**kwargs: object) -> dict[str, object]:
+ order.append("local_can")
+ callback = kwargs.get("on_commands_validated")
+ if callable(callback):
+ callback(dict(can_result))
+ return dict(can_result)
+
+ handler.service = SimpleNamespace(
+ is_mode_profile_configuration_request=lambda _utterance: False,
+ conversations=SimpleNamespace(snapshot=lambda _conversation_id: {}),
+ preview_native_can=preview_native_can,
+ persist_composed_result=lambda _result: None,
+ )
+
+ def plan(payload: dict[str, object]) -> dict[str, object]:
+ order.append("cloud_plan")
+ assert payload["utterance"] == "让闪烁灯喜庆一点"
+ routing = payload["natural_language_routing"]
+ assert isinstance(routing, dict)
+ assert routing["original_utterance"] == (
+ "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点"
+ )
+ assert routing["parsed_clauses"] == [
+ "打开闪烁灯",
+ "空调温度设置到23度",
+ ]
+ return {
+ "steps": [
+ {"kind": "peripheral", "capability_id": "light-festive"}
+ ]
+ }
+
+ def execute_cloud(
+ _utterance: str,
+ _plan: dict[str, object],
+ catalog: list[dict[str, object]],
+ **_kwargs: object,
+ ) -> dict[str, object]:
+ order.append("cloud_ir")
+ assert [item["id"] for item in catalog] == ["light-festive"]
+ return cloud_result
+
+ handler.extension = SimpleNamespace(
+ list_peripheral_capabilities=lambda: [],
+ plan=plan,
+ execute_peripheral_plan_request=execute_cloud,
+ )
+ handler.nfc = SimpleNamespace(snapshot=lambda: {})
+
+ def execute_local(
+ _utterance: str,
+ _entries: list[dict[str, object]],
+ **kwargs: object,
+ ) -> dict[str, object]:
+ order.append("local_ir")
+ callback = kwargs.get("on_commands_validated")
+ if callable(callback):
+ callback(local_result)
+ return local_result
+
+ handler._execute_local_peripheral_actions = execute_local
+ partition = {
+ "peripheral_actions": [
+ {"index": 1, "clause": "打开闪烁灯", "capability": local_capability}
+ ],
+ "residual_utterance": "空调温度设置到23度",
+ "unresolved_peripheral_clauses": ["让闪烁灯喜庆一点"],
+ "unresolved_candidates": [cloud_capability],
+ "denied_peripheral_actions": [],
+ }
+
+ with patch(
+ "cognitive_agent.server.partition_active_peripheral_intent",
+ return_value=partition,
+ ):
+ result = handler._execute_request_with_peripherals(
+ "/api/requests/preview",
+ {
+ "utterance": (
+ "空调温度设置到23度,打开闪烁灯,让闪烁灯喜庆一点"
+ ),
+ "provider": "auto",
+ "conversation_id": "conv-mixed-order",
+ "execution_mode": "dry_run",
+ },
+ on_commands_validated=validated_events.append,
+ )
+
+ assert order == ["local_ir", "local_can", "cloud_plan", "cloud_ir"]
+ assert result is not None
+ assert result["status"] == "succeeded"
+ assert any(event.get("local_control_within_budget") for event in validated_events)
+ operation_sources = {
+ operation["parse_source"]
+ for config in result["function_configs"].values()
+ for operation in config["spec"].get("operations", [])
+ }
+ assert operation_sources == {"local_rule", "cloud_model"}
def test_nfc_command_denial_keeps_allowed_result_and_reports_partial() -> None: