mqtt切换为远程服务器部署,优化简单指令解析时长
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
+143
-36
@@ -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;
|
||||
|
||||
+497
-41
@@ -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 = `<p>阶段耗时计时中 · ${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}</p>`;
|
||||
return;
|
||||
}
|
||||
const total = [...rows].reverse().find((row) => row.group === "total");
|
||||
container.innerHTML = `
|
||||
<header>
|
||||
<strong>阶段耗时</strong>
|
||||
<span>${commandTimingState.inputMode === "speech" ? "语音输入" : "普通文本"}</span>
|
||||
${total ? `<b>${escapeHtml(formattedDuration(total.durationMs))}</b>` : ""}
|
||||
</header>
|
||||
<ol>${rows.map((row) => `
|
||||
<li data-timing-group="${escapeHtml(row.group)}" data-timing-stage="${escapeHtml(row.key)}">
|
||||
<span>${escapeHtml(row.label)}</span><b>${escapeHtml(formattedDuration(row.durationMs))}</b>
|
||||
</li>`).join("")}</ol>`;
|
||||
}
|
||||
|
||||
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 @@
|
||||
<b class="v2-can-command-delivery" data-delivery-state="${escapeHtml(item.deliveryState)}" title="${escapeHtml(`${item.deliveryTransport} · ${item.deliveryLabel}`)}">${escapeHtml(item.deliveryLabel)}</b>
|
||||
<i class="v2-can-command-category">${escapeHtml(item.category)}</i>
|
||||
</span>
|
||||
<code>${escapeHtml(item.command)}</code>
|
||||
${item.frame ? `<small class="v2-can-command-frame">${escapeHtml(item.frameLabel || "固件原生 CAN0")} · ${escapeHtml(item.frame)}</small>` : ""}
|
||||
<div class="v2-can-command-layer v2-can-command-semantic">
|
||||
<small>语义控制指令</small>
|
||||
<code>${escapeHtml(item.semantic)}</code>
|
||||
</div>
|
||||
<div class="v2-can-command-layer v2-can-command-actual">
|
||||
<small>实际控制指令</small>
|
||||
<code>${escapeHtml(item.command)}</code>
|
||||
</div>
|
||||
${(item.frames || (item.frame ? [{ label: item.frameLabel || "控制帧", value: item.frame }] : [])).map((frame) => `<small class="v2-can-command-frame"><b>${escapeHtml(frame.label)}</b> · ${escapeHtml(frame.value)}</small>`).join("")}
|
||||
</li>`).join("")}</ol>`;
|
||||
} else {
|
||||
list.innerHTML = `<p>${escapeHtml(message || "本次请求未解析出控制指令")}</p>`;
|
||||
}
|
||||
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", `
|
||||
<summary>
|
||||
<span><i data-lucide="binary"></i><b>已解析控制指令</b></span>
|
||||
<span><i data-lucide="binary"></i><b>下发控制指令</b></span>
|
||||
<strong id="v2CanCommandCount">0</strong>
|
||||
<i data-lucide="chevron-down"></i>
|
||||
</summary>
|
||||
<section class="v2-command-timing" id="v2CommandTiming" aria-live="polite" hidden></section>
|
||||
<div class="v2-can-command-list" id="v2CanCommandList">
|
||||
<p>等待自然语言解析结果</p>
|
||||
<p>等待准备下发的控制指令</p>
|
||||
</div>`);
|
||||
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) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<link rel="icon" href="/icons/app-icon-192.png" />
|
||||
<title>511工程-智能座舱</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-66" />
|
||||
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-68" />
|
||||
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
|
||||
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
|
||||
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-26" />
|
||||
@@ -20,13 +20,13 @@
|
||||
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
|
||||
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
|
||||
<script src="/network.js?v=android-pad-2" defer></script>
|
||||
<script src="/app.js?v=dual-seat-130" defer></script>
|
||||
<script src="/app.js?v=dual-seat-131" defer></script>
|
||||
<script src="/speech-input.js?v=sensevoice-13" defer></script>
|
||||
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
|
||||
<script src="/pwa.js?v=android-pad-3" defer></script>
|
||||
<script src="/console-v2.js?v=delivery-state-62" defer></script>
|
||||
<script src="/console-v2.js?v=local-first-66" defer></script>
|
||||
<script src="/self-extension.js?v=gscu-tcp-43" defer></script>
|
||||
<script src="/assistant-chat.js?v=codex-assistant-26" defer></script>
|
||||
<script src="/assistant-chat.js?v=codex-assistant-27" defer></script>
|
||||
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
|
||||
</head>
|
||||
<body class="console-v2">
|
||||
@@ -131,8 +131,6 @@
|
||||
<i data-lucide="radio-tower"></i>真实下发
|
||||
</button>
|
||||
</div>
|
||||
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
|
||||
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group" id="vehicleTransportField">
|
||||
@@ -145,7 +143,6 @@
|
||||
<i data-lucide="wifi"></i>无线 CAN
|
||||
</button>
|
||||
</div>
|
||||
<p class="provider-note" id="vehicleTransportNote">正在检查车辆连接</p>
|
||||
</div>
|
||||
|
||||
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=<broker-username>
|
||||
COGNITIVE_AGENT_VEHICLE_MQTT_PASSWORD=<broker-password>
|
||||
@@ -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"室内灯|车内灯|氛围灯"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 只能说“已完成模拟编排”,真实执行失败必须明确说未完成。
|
||||
- 不允许用户覆盖执行模式、车辆标识、硬件授权、安全预检或审批策略;这些只由服务端可信上下文决定。
|
||||
|
||||
@@ -145,8 +145,14 @@ _DOMAIN_PRESENTATION: dict[str, dict[str, object]] = {
|
||||
},
|
||||
"window": {
|
||||
"label": "车窗",
|
||||
"aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
|
||||
"control_aliases": ["车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗"],
|
||||
"aliases": [
|
||||
"车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
|
||||
"左前排窗", "右前排窗", "左后排窗", "右后排窗",
|
||||
],
|
||||
"control_aliases": [
|
||||
"车窗", "窗户", "玻璃窗", "左前窗", "右前窗", "左后窗", "右后窗",
|
||||
"左前排窗", "右前排窗", "左后排窗", "右后排窗",
|
||||
],
|
||||
},
|
||||
"door": {
|
||||
"label": "后排车门锁",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<target>左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗)?"
|
||||
r"(?P<target>左前排|右前排|左后排|右后排|"
|
||||
r"左前|前左|右前|前右|左后|后左|右后|后右)(?:车窗|窗)?"
|
||||
r"(?:除外|除去|不包括|不含)",
|
||||
utterance,
|
||||
):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<name>[^,。,.!!\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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+664
-196
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
+151
-18
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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</strong>' 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 <SEQ_LO> <SEQ_HI> 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 ">执行</span></button>" 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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user