From cbf230182d1d04683f72300da063901325e361d4 Mon Sep 17 00:00:00 2001 From: minghanchen <17188325+minghanchen@user.noreply.gitee.com> Date: Tue, 1 Sep 2026 16:18:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A8=A1=E5=BC=8F=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E3=80=81NFC=E5=8F=8A=E5=A4=9A=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-console/app.js | 329 ++++++++++++------ apps/web-console/console-v2.js | 2 +- apps/web-console/index.html | 16 +- apps/web-console/service-worker.js | 6 +- contracts/schemas/function-config.schema.json | 8 +- .../application/mode_profile.py | 28 +- .../application/seat_service.py | 199 ++++++++--- .../application/self_extension.py | 3 +- src/cognitive_agent/server.py | 223 +++++++++++- tests/e2e/test_seat_web_api.py | 258 +++++++++++++- tests/integration/test_multidomain_service.py | 177 +++++++++- tests/unit/test_nfc_frontend_contract.py | 2 +- .../test_requested_frontend_enhancements.py | 14 +- tests/unit/test_self_extension.py | 17 +- tests/unit/test_two_stage_can_parsing.py | 1 + 15 files changed, 1092 insertions(+), 191 deletions(-) diff --git a/apps/web-console/app.js b/apps/web-console/app.js index ad43a24..500101b 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -4949,9 +4949,6 @@ function modeEditorFlowFromResult(result) { const functionConfigs = result?.function_configs && typeof result.function_configs === "object" ? result.function_configs : {}; - const executionSteps = Array.isArray(result?.execution_report?.spec?.results) - ? result.execution_report.spec.results - : []; return { schema: "cognitive-agent-mode-command-flow/v1", source_request_id: result?.request_id || "", @@ -4959,7 +4956,7 @@ function modeEditorFlowFromResult(result) { control_transport: result?.control_transport || "", function_configs: functionConfigs, plans, - execution_steps: executionSteps, + execution_steps: [], }; } @@ -5283,7 +5280,7 @@ function renderModeEditorFlow() { const empty = document.createElement("p"); empty.textContent = commandCount > 0 ? "已生成 CAN 控制流,但当前指令没有可读映射。" - : "点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。"; + : "点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。"; preview.append(empty); } } @@ -5299,9 +5296,9 @@ function setModeEditorFlowExpanded(expanded) { button.setAttribute("aria-expanded", String(nextExpanded)); button.setAttribute( "aria-label", - nextExpanded ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域", + nextExpanded ? "还原控制指令区域" : "展开控制指令区域", ); - button.title = nextExpanded ? "还原 CAN 控制指令区域" : "展开 CAN 控制指令区域"; + button.title = nextExpanded ? "还原控制指令区域" : "展开控制指令区域"; button.innerHTML = ``; refreshIcons(); } @@ -5328,8 +5325,8 @@ function openModeEditor(modeProfileId) { $("#modeEditorName").value = mode.name || ""; $("#modeEditorInstruction").value = instruction; $("#modeEditorStatus").textContent = flow - ? "已载入永久保存的自然语言和 CAN 指令流,可修改后重新执行。" - : "修改自然语言后需要重新执行,才能点击完成永久保存。"; + ? "已载入永久保存的自然语言和控制指令流,可修改后重新生成。" + : "修改自然语言后需要重新生成指令,才能点击完成永久保存。"; setModeEditorFlowExpanded(false); backdrop.hidden = false; document.body.classList.add("mode-editor-open"); @@ -5360,8 +5357,8 @@ function invalidateModeEditorFlow() { const instruction = $("#modeEditorInstruction").value.trim(); const current = instruction === state.modeEditor.executedInstruction; $("#modeEditorStatus").textContent = current && modeEditorCommandCount(state.modeEditor.flow) > 0 - ? "当前自然语言已有 CAN 指令流,可点击完成永久保存。" - : "自然语言已修改,请重新点击执行生成 CAN 控制指令流。"; + ? "当前自然语言已有控制指令流,可点击完成永久保存。" + : "自然语言已修改,请重新点击“生成指令”。"; renderModeEditorFlow(); } @@ -5376,38 +5373,37 @@ async function executeModeEditorInstruction() { const execute = $("#modeEditorExecute"); execute.disabled = true; execute.classList.add("loading"); - execute.innerHTML = '执行中…'; + execute.innerHTML = '生成中…'; $("#modeEditorComplete").disabled = true; - $("#modeEditorStatus").textContent = "正在解析自然语言并生成、执行 CAN 控制指令流…"; + $("#modeEditorStatus").textContent = "正在复用主页解析流程生成控制指令;不会执行设备…"; refreshIcons(); try { - const execution = await requestCommandAndWait(instruction); - const result = execution.result; + const { payload: result } = await requestNaturalLanguageControl(instruction, { + executionMode: "dry_run", + clientType: "mode_editor_compile", + previewOnly: true, + }); const flow = modeEditorFlowFromResult(result); const commandCount = modeEditorCommandCount(flow); if (!result || commandCount === 0) { - throw new Error(execution.error?.message || "未生成可保存的 CAN 控制指令流"); + throw new Error("未生成可保存的控制指令流"); } editor.flow = flow; editor.sourceRequestId = result.request_id; editor.executedInstruction = instruction; - $("#modeEditorStatus").textContent = `已生成 ${commandCount} 条 CAN 控制指令;执行状态:${result.status}。`; - setModeExecutionState( - editor.modeId, - ["succeeded", "partially_succeeded"].includes(result.status) ? "succeeded" : "failed", - ); + $("#modeEditorStatus").textContent = `已生成 ${commandCount} 条控制指令;尚未执行设备。点击“完成”保存到模式卡片。`; renderModeEditorFlow(); } catch (error) { editor.flow = null; editor.sourceRequestId = ""; editor.executedInstruction = ""; - $("#modeEditorStatus").textContent = error.message || "CAN 控制指令流生成失败。"; + $("#modeEditorStatus").textContent = error.message || "控制指令流生成失败。"; renderModeEditorFlow(); - showToast(error.message || "CAN 控制指令流生成失败"); + showToast(error.message || "控制指令流生成失败"); } finally { execute.disabled = false; execute.classList.remove("loading"); - execute.innerHTML = '执行'; + execute.innerHTML = '生成指令'; refreshIcons(); } } @@ -5426,7 +5422,7 @@ async function completeModeEditor() { || editor.executedInstruction !== instruction || modeEditorCommandCount(editor.flow) === 0 ) { - showToast("请先执行当前自然语言并生成 CAN 控制指令流"); + showToast("请先根据当前自然语言生成控制指令流"); return; } const complete = $("#modeEditorComplete"); @@ -5588,7 +5584,7 @@ async function deleteUserModeProfile(modeProfileId) { if (!mode || !isUserModeProfile(mode)) return; const presetOverride = mode.preset_override === true; const confirmation = presetOverride - ? `恢复“${mode.name}”的初始预设配置?当前保存的名称、自然语言和 CAN 指令流将被删除。` + ? `恢复“${mode.name}”的初始预设配置?当前保存的名称、自然语言和控制指令流将被删除。` : `删除自定义功能模式“${mode.name}”?删除后无法恢复。`; if (!window.confirm(confirmation)) return; @@ -5655,12 +5651,83 @@ function setModeChipsDisabled(disabled) { }); } +async function executeSavedModeProfile(mode) { + if (!mode?.id || !mode?.command_flow?.source_request_id) return false; + const driverOnlyCh1 = isDriverOnlyCh1Runtime(); + if (state.executionMode === "hardware" + && state.controlTransport !== "wireless_mqtt" + && !driverOnlyCh1 + && !isLocalDecisionHost() + && state.hardwareAccess?.state !== "approved") { + showToast(state.hardwareAccess?.state === "pending" + ? "申请仍在等待本地审批" + : "请先申请并获得本地真实台架使用权"); + return true; + } + + closeModeGallery({ restoreFocus: false }); + const runButton = $("#runButton"); + runButton.disabled = true; + setModeChipsDisabled(true); + setModeExecutionState(mode.id, "running"); + $("#utterance").value = mode.name || mode.id; + setRunStatus("processing", "MODE COMMAND FLOW"); + $("#resultTitle").textContent = `正在直接执行${mode.name}`; + try { + const payload = await postControlPhase( + `/api/mode-profiles/${encodeURIComponent(mode.id)}/execute`, + { + client_type: "web_console_mode_card", + conversation_id: state.conversationId, + vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, + execution_mode: state.executionMode, + control_transport: state.controlTransport, + ...(state.executionMode === "hardware" + && state.controlTransport !== "wireless_mqtt" + && !driverOnlyCh1 + ? { hardware_client_id: state.hardwareClientId } + : {}), + ...(state.executionMode === "simulated_hardware" + ? { simulation_fault: $("#simulationFault").value } + : {}), + }, + state.executionMode, + ); + if (!payload) throw new Error("模式卡片未返回执行结果"); + renderResult(payload); + const completed = ["succeeded", "partially_succeeded"].includes(payload.status); + $("#utterance").value = mode.name || mode.id; + $("#resultTitle").textContent = completed + ? `${mode.name}执行完成` + : `${mode.name}执行未完成`; + setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); + if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); + showToast(completed + ? `${mode.name}已直接执行保存的控制指令流` + : `${mode.name}未完成,请查看执行结果`); + } catch (error) { + setModeExecutionState(mode.id, "failed"); + setRunStatus("failed", "MODE FAILED"); + $("#resultTitle").textContent = `${mode.name}执行失败`; + showToast(error.message || "模式执行失败"); + } finally { + runButton.disabled = false; + setModeChipsDisabled(false); + } + return true; +} + function applyModeCommand(command, modeProfileId = null) { if (!command) return; if ($("#runButton").disabled) { showToast("上一条指令仍在执行,请稍候"); return; } + const mode = modeProfiles().find((item) => item.id === modeProfileId); + if (mode?.command_flow?.source_request_id) { + void executeSavedModeProfile(mode); + return; + } closeModeGallery({ restoreFocus: false }); state.pendingModeProfileId = modeProfileId; if (modeProfileId) setModeExecutionState(modeProfileId, "running"); @@ -7094,7 +7161,8 @@ function renderExecution(result) { .startsWith("nfc_peripheral"); summary.className = `execution-summary ${submittedUnverified || partiallySucceeded ? "partial" : reportSucceeded ? "succeeded" : "failed"}`; const presetSave = Boolean(result.preset_config); - const modeSave = Boolean(result.mode_profile); + const savedModeExecution = result.orchestration_route === "saved_mode_command_flow"; + const modeSave = Boolean(result.mode_profile) && !savedModeExecution; const simulatedHil = report.metadata?.labels?.seat_feedback === "simulated_hardware"; const physical = report.spec.mode === "hil" && !simulatedHil; const executedDomains = [...new Set(report.spec.results.map((item) => item.domain))]; @@ -7379,7 +7447,9 @@ function renderResult(result) { ? `${domains.length} 域场景部分完成` : executionSucceeded ? result.mode_profile - ? "长期模式配置已保存" + ? savedModeExecution + ? `${result.mode_profile.name}执行完成` + : "长期模式配置已保存" : result.preset_config ? "座椅姿态预设已保存" : submittedUnverified @@ -7481,6 +7551,115 @@ async function postControlPhase(path, body, executionMode, onEvent = null) { return payload; } +function naturalLanguageRequestBody( + utterance, + { + executionMode = state.executionMode, + clientType = "web_console", + } = {}, +) { + const driverOnlyCh1 = isDriverOnlyCh1Runtime(); + return { + utterance, + client_type: clientType, + provider: state.provider, + conversation_id: state.conversationId, + vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, + execution_mode: executionMode, + control_transport: state.controlTransport, + ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), + ...(executionMode === "hardware" + && state.controlTransport !== "wireless_mqtt" + && !driverOnlyCh1 + ? { hardware_client_id: state.hardwareClientId } + : {}), + ...(executionMode === "simulated_hardware" + ? { simulation_fault: $("#simulationFault").value } + : {}), + }; +} + +async function requestNaturalLanguageControl( + utterance, + { + executionMode = state.executionMode, + clientType = "web_console", + previewOnly = false, + onControlPhaseEvent = null, + onPreview = null, + onSupplementStart = null, + } = {}, +) { + const requestBody = naturalLanguageRequestBody(utterance, { + executionMode, + clientType, + }); + const usePreview = previewOnly || state.provider === "openai"; + if (!usePreview) { + const payload = await postControlPhase( + "/api/requests", + requestBody, + executionMode, + onControlPhaseEvent, + ); + return { payload, supplementWarning: "" }; + } + + const preview = await postControlPhase( + "/api/requests/preview", + requestBody, + executionMode, + onControlPhaseEvent, + ); + if (!preview) return { payload: null, supplementWarning: "" }; + if (typeof onPreview === "function") onPreview(preview); + if (!preview.supplement_required) { + return { payload: preview, supplementWarning: "" }; + } + + if (typeof onSupplementStart === "function") onSupplementStart(preview); + try { + const payload = await postControlPhase( + `/api/requests/${preview.request_id}/supplement`, + { + client_type: clientType, + provider: state.provider, + reasoning_effort: state.reasoningEffort, + ...(executionMode === "hardware" + && state.controlTransport !== "wireless_mqtt" + && !isDriverOnlyCh1Runtime() + ? { hardware_client_id: state.hardwareClientId } + : {}), + }, + executionMode, + onControlPhaseEvent, + ); + return { payload, supplementWarning: "" }; + } catch (error) { + const localCommandCount = Object.values(preview.function_configs || {}) + .reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0); + if (!localCommandCount) throw error; + return { + payload: { + ...preview, + status: "partially_succeeded", + supplement_required: false, + cloud_supplement_error: { + code: error.code || "cloud_supplement_failed", + message: error.message, + }, + decision_summary: [ + ...(preview.decision_summary || []), + `云端补充失败:${error.message};本地规则命令已保留且不会撤销。`, + ], + }, + supplementWarning: previewOnly + ? "本地规则指令已生成;云端补充失败" + : "本地规则命令已执行;云端补充失败,已保留本地结果", + }; + } +} + async function executeCanCommands(canCommands, { deviceName = "车辆设备" } = {}) { const commands = Array.isArray(canCommands) ? canCommands.map((command) => String(command || "").trim()).filter(Boolean) @@ -7568,26 +7747,6 @@ async function submitRequest(event) { setRunStatus("processing", "PROCESSING"); $("#resultTitle").textContent = "正在执行车舱编排"; try { - const requestBody = { - utterance, - client_type: "web_console", - provider: state.provider, - conversation_id: state.conversationId, - vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id, - execution_mode: state.executionMode, - control_transport: state.controlTransport, - ...(state.provider === "openai" ? { reasoning_effort: state.reasoningEffort } : {}), - ...(state.executionMode === "hardware" - && state.controlTransport !== "wireless_mqtt" - && !driverOnlyCh1 - ? { hardware_client_id: state.hardwareClientId } - : {}), - ...(state.executionMode === "simulated_hardware" - ? { simulation_fault: $("#simulationFault").value } - : {}), - }; - let payload; - let supplementWarning = ""; const onControlPhaseEvent = (phaseEvent) => { if (phaseEvent.type !== "validated_commands" || !phaseEvent.payload) return; window.dispatchEvent(new CustomEvent( @@ -7595,67 +7754,21 @@ async function submitRequest(event) { { detail: phaseEvent.payload }, )); }; - if (state.provider === "openai") { - const preview = await postControlPhase( - "/api/requests/preview", - requestBody, - state.executionMode, + const { payload, supplementWarning } = await requestNaturalLanguageControl( + utterance, + { onControlPhaseEvent, - ); - if (!preview) return; - renderResult(preview); - completedResult = preview; - if (preview.supplement_required) { - setRunStatus("processing", "CLOUD SUPPLEMENT"); - $("#resultTitle").textContent = "本地指令已处理,正在补充云端解析"; - try { - payload = await postControlPhase( - `/api/requests/${preview.request_id}/supplement`, - { - client_type: "web_console", - provider: state.provider, - reasoning_effort: state.reasoningEffort, - ...(state.executionMode === "hardware" - && state.controlTransport !== "wireless_mqtt" - && !driverOnlyCh1 - ? { hardware_client_id: state.hardwareClientId } - : {}), - }, - state.executionMode, - onControlPhaseEvent, - ); - if (!payload) return; - } catch (error) { - const localCommandCount = Object.values(preview.function_configs || {}) - .reduce((total, config) => total + (config?.spec?.operations?.length || 0), 0); - if (!localCommandCount) throw error; - payload = { - ...preview, - status: "partially_succeeded", - supplement_required: false, - cloud_supplement_error: { - code: error.code || "cloud_supplement_failed", - message: error.message, - }, - decision_summary: [ - ...(preview.decision_summary || []), - `云端补充失败:${error.message};本地规则命令已保留且不会撤销。`, - ], - }; - supplementWarning = "本地规则命令已执行;云端补充失败,已保留本地结果"; - } - } else { - payload = preview; - } - } else { - payload = await postControlPhase( - "/api/requests", - requestBody, - state.executionMode, - onControlPhaseEvent, - ); - if (!payload) return; - } + onPreview: (preview) => { + renderResult(preview); + completedResult = preview; + }, + onSupplementStart: () => { + setRunStatus("processing", "CLOUD SUPPLEMENT"); + $("#resultTitle").textContent = "本地指令已处理,正在补充云端解析"; + }, + }, + ); + if (!payload) return; renderResult(payload); completedResult = payload; const peripheralRoute = String(payload.orchestration_route || "") diff --git a/apps/web-console/console-v2.js b/apps/web-console/console-v2.js index 7be340f..b2aab48 100644 --- a/apps/web-console/console-v2.js +++ b/apps/web-console/console-v2.js @@ -429,7 +429,7 @@
  • ${escapeHtml(item.channel)} - ${item.parseSource ? `${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}` : ""} + ${item.parseSource ? `${item.parseSource === "direct_control" ? "设备控件" : item.parseSource === "saved_mode_command_flow" ? "模式卡片" : item.parseSource === "local_rule" ? "本地规则" : "云端模型"}` : ""} ${escapeHtml(item.deliveryLabel)} ${escapeHtml(item.category)} diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 65e6c7d..a60374e 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -17,10 +17,10 @@ - + - + @@ -574,7 +574,7 @@
    MODE WORKFLOW

    编辑功能模式

    -

    执行后生成 CAN 控制指令流;完成后永久保存到当前模式卡片。

    +

    复用主页自然语言解析流程生成控制指令;此步骤不会执行设备。

    @@ -591,22 +591,22 @@
    - CAN 控制指令流 + 控制指令流
    尚未生成 -
    -

    点击“执行”后,这里会显示由后端生成的 CAN 控制指令流。

    +

    点击“生成指令”后,这里会显示由后端解析、校验并编译的控制指令流。

    -

    修改自然语言后需要重新执行,才能点击完成永久保存。

    +

    修改自然语言后需要重新生成指令,才能点击完成永久保存。

    diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index feada6a..f4665e6 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v218"; +const CACHE_VERSION = "cognitive-agent-pad-v220"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,11 +6,11 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-16", "/network.js?v=android-pad-2", - "/app.js?v=dual-seat-107", + "/app.js?v=dual-seat-109", "/gesture-control.js?v=android-pad-9", "/pwa.js?v=android-pad-3", "/console-v2.css?v=delivery-state-43", - "/console-v2.js?v=delivery-state-49", + "/console-v2.js?v=delivery-state-50", "/self-extension.css?v=gscu-tcp-39", "/self-extension.js?v=gscu-tcp-40", "/assets/pets/self-extension-cat.png?v=3", diff --git a/contracts/schemas/function-config.schema.json b/contracts/schemas/function-config.schema.json index 1774de8..c026946 100755 --- a/contracts/schemas/function-config.schema.json +++ b/contracts/schemas/function-config.schema.json @@ -14,7 +14,13 @@ "parameters": {"type": "object"}, "parse_source": { "type": "string", - "enum": ["local_rule", "cloud_model", "cloud_supplement", "direct_control"] + "enum": [ + "local_rule", + "cloud_model", + "cloud_supplement", + "direct_control", + "saved_mode_command_flow" + ] }, "motion": { "type": "object", diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index e3b34a2..301adb2 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -112,10 +112,16 @@ class ModeOperation: ) def to_request(self, mode_name: str) -> dict[str, Any]: + target_scope = self.target_scope + if self.domain == "seat" and target_scope == "front_right": + # Compiled seat operations use the physical zone name, while the + # model-plan adapter exposes the logical passenger target. Normalize + # persisted cards so a validated target is not queried again. + target_scope = "passenger" return { "domain": self.domain, "capability": self.capability, - "target_scope": self.target_scope, + "target_scope": target_scope, "operation": self.operation, "parameters": [ {"name": name, "value": value} @@ -450,6 +456,26 @@ class ModeProfileRegistry: public.append(item) return public + def get_profile( + self, + mode_id: str, + vehicle_id: str | None = None, + ) -> ModeProfile: + """Resolve the effective preset or user override by its stable ID.""" + + clean_mode_id = str(mode_id).strip() + profile = next( + ( + item + for item in self._profiles_for_vehicle(vehicle_id) + if item.id == clean_mode_id + ), + None, + ) + if profile is None: + raise KeyError(clean_mode_id) + return profile + def match_text( self, utterance: str, diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 9e2ec35..3693c35 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -608,6 +608,59 @@ class SeatDemoService: "vehicle_id": resolved_vehicle_id, } + def get_mode_profile_card( + self, + mode_id: str, + vehicle_id: str | None = None, + ) -> ModeProfile: + resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id + return self.mode_profiles.get_profile(mode_id, resolved_vehicle_id) + + def execute_mode_profile_card( + self, + mode_id: str, + *, + conversation_id: str | None = None, + execution_mode: str = "dry_run", + approval_token: str | None = None, + vehicle_id: str | None = None, + simulation_fault: str = "normal", + hardware_client_id: str | None = None, + hardware_access_bypass: bool = True, + control_transport: str = WIRED_CAN_TRANSPORT, + ) -> dict[str, Any]: + """Execute a persisted mode by ID without interpreting natural language.""" + + resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id + profile = self.mode_profiles.get_profile(mode_id, resolved_vehicle_id) + if not profile.command_flow or not profile.command_flow.get("plans"): + raise ValueError("该模式尚未生成可直接执行的控制指令流。") + result = self.process( + profile.instruction or f"进入{profile.name}", + provider="local", + conversation_id=conversation_id, + execution_mode=execution_mode, + approval_token=approval_token, + vehicle_id=resolved_vehicle_id, + simulation_fault=simulation_fault, + hardware_client_id=hardware_client_id, + hardware_access_bypass=hardware_access_bypass, + control_transport=control_transport, + _direct_mode_profile_id=profile.id, + ) + public_profile = profile.public_dict() + result["provider_used"] = "saved_mode_command_flow" + result["utterance"] = profile.name + result["matched_modes"] = [public_profile] + result["mode_profile"] = public_profile + result["orchestration_route"] = "saved_mode_command_flow" + result["decision_summary"] = [ + f"直接载入已保存的模式“{profile.name}”,未再次解析自然语言。", + *list(result.get("decision_summary") or []), + ] + self._persist(result) + return result + def save_mode_profile_card( self, mode_id: str, @@ -616,7 +669,7 @@ class SeatDemoService: source_request_id: str, vehicle_id: str | None = None, ) -> dict[str, Any]: - """Persist one edited mode from a server-generated CAN command flow.""" + """Persist one edited mode from a server-generated control flow.""" resolved_vehicle_id = vehicle_id or self.vehicle_profiles.default().id clean_mode_id = str(mode_id).strip() @@ -632,7 +685,7 @@ class SeatDemoService: if len(clean_instruction) > 1000: raise ValueError("自然语言指令不能超过 1000 个字符。") if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id): - raise ValueError("CAN 指令流来源请求编号无效。") + raise ValueError("控制指令流来源请求编号无效。") visible_profiles = self.mode_profiles._profiles_for_vehicle( # noqa: SLF001 resolved_vehicle_id @@ -653,15 +706,15 @@ class SeatDemoService: result_path = self.artifact_root / clean_request_id / "result.json" if not result_path.is_file(): - raise ValueError("未找到执行按钮生成的 CAN 指令流,请重新执行。") + raise ValueError("未找到“生成指令”按钮产生的控制指令流,请重新生成。") try: source_result = json.loads(result_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: - raise ValueError("CAN 指令流来源结果无法读取,请重新执行。") from exc + raise ValueError("控制指令流来源结果无法读取,请重新生成。") from exc if not isinstance(source_result, dict) or source_result.get( "request_id" ) != clean_request_id: - raise ValueError("CAN 指令流来源结果不完整,请重新执行。") + raise ValueError("控制指令流来源结果不完整,请重新生成。") recorded_instruction = str(source_result.get("utterance", "")).strip() if not recorded_instruction: source_spans = { @@ -676,12 +729,12 @@ class SeatDemoService: if len(source_spans) == 1: recorded_instruction = next(iter(source_spans)) if recorded_instruction and recorded_instruction != clean_instruction: - raise ValueError("自然语言指令已修改,请重新点击执行生成 CAN 指令流。") + raise ValueError("自然语言指令已修改,请重新点击“生成指令”。") plans = source_result.get("command_plans") or {} configs = source_result.get("function_configs") or {} if not isinstance(plans, dict) or not isinstance(configs, dict): - raise ValueError("执行结果中没有可永久化的 CAN 指令流。") + raise ValueError("解析结果中没有可永久化的控制指令流。") executable_plan_keys = { key for key, plan in plans.items() @@ -691,7 +744,7 @@ class SeatDemoService: and plan["spec"]["commands"] } if not executable_plan_keys: - raise ValueError("请先点击执行并成功生成 CAN 控制指令流。") + raise ValueError("请先点击“生成指令”并成功生成控制指令流。") operations: list[dict[str, Any]] = [] signatures: set[tuple[str, str, str, str]] = set() @@ -707,7 +760,12 @@ class SeatDemoService: if not isinstance(operation, dict): continue capability = str(operation.get("capability", "")).strip() - target = str((operation.get("target") or {}).get("zone", "")).strip() + operation_target = operation.get("target") or {} + target = str( + operation_target.get("instance") + or operation_target.get("zone") + or "" + ).strip() parameters = operation.get("parameters") or {} if not domain or not capability or not target or not isinstance( parameters, dict @@ -733,37 +791,8 @@ class SeatDemoService: } ) if not operations: - raise ValueError("CAN 指令流没有关联到可复用的模式操作。") + raise ValueError("控制指令流没有关联到可复用的模式操作。") - execution_steps = [] - for item in ( - (source_result.get("execution_report") or {}).get("spec", {}).get( - "results", [] - ) - ): - if not isinstance(item, dict): - continue - telemetry = item.get("telemetry") or {} - command_evidence = { - key: deepcopy(telemetry[key]) - for key in ( - "command_frame", - "sent_frames", - "mqtt_messages", - "response_frame", - "requested_parameters", - ) - if key in telemetry - } - if command_evidence: - execution_steps.append( - { - "ref": item.get("ref"), - "domain": item.get("domain"), - "status": item.get("status"), - **command_evidence, - } - ) command_flow = { "schema": "cognitive-agent-mode-command-flow/v1", "source_request_id": clean_request_id, @@ -776,7 +805,10 @@ class SeatDemoService: if key in executable_plan_keys and key in configs }, "plans": {key: deepcopy(plans[key]) for key in plans if key in executable_plan_keys}, - "execution_steps": execution_steps, + # A mode card stores only the validated, deterministic plan. Live + # delivery evidence belongs to each later execution, not to the + # generation workflow. + "execution_steps": [], } saved = self.mode_profiles.save_mode( clean_name, @@ -1677,6 +1709,7 @@ class SeatDemoService: _allow_empty_control_result: bool = False, _on_commands_validated: Callable[[dict[str, Any]], None] | None = None, _direct_can_batch: tuple[list[str], dict[str, Any]] | None = None, + _direct_mode_profile_id: str | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) # Creating a reusable scene is a configuration workflow. Synthesize @@ -1702,6 +1735,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _direct_mode_profile_id, ) if control_transport == WIRELESS_MQTT_TRANSPORT: # The head unit owns both physical CAN channels in wireless mode. @@ -1723,6 +1757,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _direct_mode_profile_id, ) if hardware_access_bypass or self._driver_only_vehicle_execution_active( control_transport @@ -1744,6 +1779,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _direct_mode_profile_id, ) authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id) with self.hardware_execution.reserve(): @@ -1764,6 +1800,7 @@ class SeatDemoService: _allow_empty_control_result, _on_commands_validated, _direct_can_batch, + _direct_mode_profile_id, ) def _process_once( @@ -1783,6 +1820,7 @@ class SeatDemoService: allow_empty_control_result: bool = False, on_commands_validated: Callable[[dict[str, Any]], None] | None = None, direct_can_batch: tuple[list[str], dict[str, Any]] | None = None, + direct_mode_profile_id: str | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) utterance = utterance.strip() @@ -1831,6 +1869,14 @@ class SeatDemoService: if not CONVERSATION_ID_PATTERN.fullmatch(conversation_id): raise ValueError("conversation_id must start with conv- and contain only lowercase letters, numbers, or hyphens") vehicle_profile = self.vehicle_profiles.get(vehicle_id) + direct_mode_profile = ( + self.mode_profiles.get_profile( + direct_mode_profile_id, + vehicle_profile.id, + ) + if direct_mode_profile_id + else None + ) if mode_create_name is not None: return self._process_mode_profile_create( @@ -1852,7 +1898,9 @@ class SeatDemoService: ) context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id) matched_modes = ( - [] + [direct_mode_profile] + if direct_mode_profile is not None + else [] if direct_can_batch is not None else self.mode_profiles.match_text( utterance, @@ -1941,7 +1989,56 @@ class SeatDemoService: result, traces, ) - if direct_can_batch is not None: + if direct_mode_profile is not None: + mode_requests = self.mode_profiles.requests_for_profiles( + [direct_mode_profile] + ) + detected_domains = list( + dict.fromkeys( + str(item.get("domain") or "") + for item in mode_requests + if str(item.get("domain") or "") + ) + ) + parsed = self.model_plan_adapter.adapt( + { + "detected_domains": detected_domains or ["seat"], + "requests": mode_requests, + "ambiguities": [], + "decision_summary": [ + f"已载入模式“{direct_mode_profile.name}”保存的结构化操作。" + ], + "confidence": 1.0, + }, + context, + vehicle_profile, + ) + saved_native_actions: list[dict[str, Any]] = [] + for request in mode_requests: + parameters = { + str(item.get("name")): deepcopy(item.get("value")) + for item in request.get("parameters", []) + if isinstance(item, dict) and str(item.get("name") or "") + } + action = { + "domain": str(request.get("domain") or ""), + "capability": str(request.get("capability") or ""), + "target": str(request.get("target_scope") or "vehicle"), + "parameters": parameters, + "operation": str(request.get("operation") or "set"), + "reason": str(request.get("reason") or ""), + "execution_backend": "native_can", + "parse_source": "saved_mode_command_flow", + } + if self.native_can_runtime.supports(action): + saved_native_actions.append(action) + parsed["native_can_actions"] = [ + *list(parsed.get("native_can_actions", [])), + *saved_native_actions, + ] + provider_used = "saved_mode_command_flow" + provider_notice = None + elif direct_can_batch is not None: parsed = deepcopy(direct_can_batch[1]) provider_used = "direct_can_control" provider_notice = None @@ -1980,6 +2077,11 @@ class SeatDemoService: relative_resolution.claimed_controls if relative_resolution else None ), ) + if direct_mode_profile is not None: + parsed = self._mark_control_parse_source( + parsed, + "saved_mode_command_flow", + ) if local_preview: parsed["ambiguities"] = [] parsed["unresolved_spans"] = [] @@ -1993,11 +2095,14 @@ class SeatDemoService: parsed = self._partition_native_actions( parsed, promote_supported=( - hardware_request - and ( - self.vehicle_mqtt_settings.enabled - if control_transport == WIRELESS_MQTT_TRANSPORT - else self.vehicle_can_settings.enabled + direct_mode_profile is not None + or ( + hardware_request + and ( + self.vehicle_mqtt_settings.enabled + if control_transport == WIRELESS_MQTT_TRANSPORT + else self.vehicle_can_settings.enabled + ) ) ), ) diff --git a/src/cognitive_agent/application/self_extension.py b/src/cognitive_agent/application/self_extension.py index 30dd3b6..cd87127 100644 --- a/src/cognitive_agent/application/self_extension.py +++ b/src/cognitive_agent/application/self_extension.py @@ -35,6 +35,7 @@ MAX_PLAN_STEPS = 12 MIN_PLAN_DELAY_MS = 1 MAX_PLAN_DELAY_MS = 30_000 MAX_TOTAL_PLAN_DELAY_MS = 60_000 +ADJACENT_INFRARED_COMMAND_DELAY_SECONDS = 0.8 DEFAULT_GSCU_MQTT_HOST = "127.0.0.1" DEFAULT_GSCU_MQTT_PORT = 1883 DEFAULT_GSCU_COMMAND_TOPIC = "topic/from_n7" @@ -2542,7 +2543,7 @@ class SelfExtensionService: and isinstance(next_step, dict) and next_step.get("kind") == "peripheral" ): - time.sleep(0.25) + time.sleep(ADJACENT_INFRARED_COMMAND_DELAY_SECONDS) succeeded = not interrupted and completed_steps == len(steps) status = "succeeded" if succeeded else ( diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index b457cfb..3c6ae3a 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -795,15 +795,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler): conversation_id: str | None, execution_mode: str, ) -> dict[str, object]: - """Execute already validated local-rule infrared capabilities.""" + """Execute already validated infrared capabilities.""" capabilities = [ { - **dict(entry["capability"]), - "parse_source": "local_rule", + **capability, + "parse_source": str(capability.get("parse_source") or "local_rule"), } for entry in entries if isinstance(entry, dict) and isinstance(entry.get("capability"), dict) + for capability in [dict(entry["capability"])] ] if not capabilities: raise ExtensionIntegrationError("没有可执行的已学习红外能力。") @@ -1454,6 +1455,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler): ) def _handle_mode_profile_post(self, path: str) -> None: + execute_match = re.fullmatch( + r"/api/mode-profiles/(?P[a-z0-9][a-z0-9_-]{0,63})/execute", + path, + ) + if execute_match is not None: + self._handle_mode_profile_execute(execute_match.group("mode_id")) + return + mode_id = path.removeprefix("/api/mode-profiles/").strip() if not mode_id: self._json( @@ -1482,6 +1491,214 @@ class SeatDemoHandler(BaseHTTPRequestHandler): HTTPStatus.BAD_REQUEST, ) + def _saved_mode_infrared_entries( + self, + profile: object, + ) -> list[dict[str, object]]: + command_flow = getattr(profile, "command_flow", None) + if not isinstance(command_flow, dict): + return [] + configurations = command_flow.get("function_configs") or {} + extension_config = None + if isinstance(configurations, dict): + extension_config = configurations.get("self_extension") + if not isinstance(extension_config, dict): + extension_config = next( + ( + config + for config in configurations.values() + if isinstance(config, dict) + and str((config.get("spec") or {}).get("domain")) + == "self_extension" + ), + None, + ) + operations = ( + (extension_config.get("spec") or {}).get("operations") or [] + if isinstance(extension_config, dict) + else [] + ) + if not operations: + return [] + + snapshot = self.nfc.snapshot() + card = snapshot.get("card") if isinstance(snapshot, dict) else None + active_uid = re.sub( + r"[^0-9A-Fa-f]", + "", + str((card or {}).get("uid") or ""), + ).upper() + if not active_uid or str((card or {}).get("action") or "present") != "present": + raise ExtensionIntegrationError( + "该模式包含 NFC 红外指令,请先放置生成此模式时绑定的 NFC 卡。" + ) + + catalog = self.extension.list_peripheral_capabilities() + entries: list[dict[str, object]] = [] + for index, operation in enumerate(operations): + if not isinstance(operation, dict) or operation.get("capability") != "infrared.emit": + continue + parameters = operation.get("parameters") or {} + target = operation.get("target") or {} + try: + slot = int(parameters.get("slot")) + except (TypeError, ValueError) as exc: + raise ExtensionIntegrationError( + "已保存模式包含无效的红外槽位。" + ) from exc + device_id = str(target.get("device") or "").strip() + capability = next( + ( + item + for item in catalog + if item.get("status") == "learned" + and item.get("enabled", True) is True + and int(item.get("slot") or 0) == slot + and str(item.get("device_id") or "") == device_id + and re.sub( + r"[^0-9A-Fa-f]", + "", + str(item.get("card_uid") or ""), + ).upper() + == active_uid + ), + None, + ) + if capability is None: + raise ExtensionIntegrationError( + f"当前 NFC 未授权模式中的红外命令:{device_id} / IR {slot}。" + ) + entries.append( + { + "index": index, + "clause": str(parameters.get("action") or f"IR {slot}"), + "capability": { + **capability, + "parse_source": "saved_mode_command_flow", + }, + } + ) + return entries + + def _handle_mode_profile_execute(self, mode_id: str) -> None: + utterance = "" + source = "web_console_mode_card" + try: + payload = self._read_json_body(max_bytes=16 * 1024) + vehicle_id = str(payload.get("vehicle_id", "")) or None + profile = self.service.get_mode_profile_card(mode_id, vehicle_id) + utterance = profile.name + source = str(payload.get("client_type") or source) + execution_mode = str(payload.get("execution_mode", "dry_run")) + control_transport = str( + payload.get("control_transport", "wired_can") + ) + conversation_id = ( + str(payload["conversation_id"]) + if payload.get("conversation_id") + else None + ) + hardware_client_id = ( + str(payload["hardware_client_id"]) + if payload.get("hardware_client_id") + else None + ) + self.vehicle_display_sync.update_task( + status="running", + utterance=utterance, + source=source, + ) + # Validate the complete saved flow, including the live NFC + # binding, before any transport is allowed to execute. + entries = self._saved_mode_infrared_entries(profile) + can_result = self.service.execute_mode_profile_card( + mode_id, + conversation_id=conversation_id, + execution_mode=execution_mode, + approval_token=os.getenv( + "COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN" + ), + vehicle_id=vehicle_id, + simulation_fault=str(payload.get("simulation_fault", "normal")), + hardware_client_id=hardware_client_id, + hardware_access_bypass=self._is_unproxied_loopback_request(), + control_transport=control_transport, + ) + if entries: + infrared_result = self._execute_local_peripheral_actions( + utterance, + entries, + provider="saved_mode_command_flow", + conversation_id=conversation_id, + execution_mode=execution_mode, + ) + result = self._merge_can_and_peripheral_results( + utterance, + can_result, + infrared_result, + entries, + ) + result["provider_used"] = "saved_mode_command_flow" + result["orchestration_route"] = "saved_mode_command_flow" + result["mode_profile"] = profile.public_dict() + self.service.persist_composed_result(result) + else: + result = can_result + self.vehicle_display_sync.update_task( + status=str(result.get("status") or "succeeded"), + utterance=utterance, + source=source, + result=result, + ) + self._json(result, HTTPStatus.OK) + except KeyError: + self._json( + {"error": "not_found", "message": "未找到需要执行的模式卡片。"}, + HTTPStatus.NOT_FOUND, + ) + except HardwareAccessRequiredError as exc: + self._json( + { + "error": "hardware_access_required", + "message": str(exc), + "access_state": exc.state, + }, + HTTPStatus.FORBIDDEN, + ) + except HardwareBusyError as exc: + self._json( + { + "error": "hardware_busy", + "message": str(exc), + "retry_after_seconds": exc.retry_after_seconds, + }, + HTTPStatus.CONFLICT, + ) + except (HardwareSafetyError, CanGatewayError, GatewayError) as exc: + if utterance: + self.vehicle_display_sync.update_task( + status="failed", + utterance=utterance, + source=source, + error={"code": "mode_execution_failed", "message": str(exc)}, + ) + self._json( + {"error": "mode_execution_failed", "message": str(exc)}, + HTTPStatus.SERVICE_UNAVAILABLE, + ) + except (ExtensionIntegrationError, ValueError, json.JSONDecodeError) as exc: + if utterance: + self.vehicle_display_sync.update_task( + status="failed", + utterance=utterance, + source=source, + error={"code": "invalid_mode_command_flow", "message": str(exc)}, + ) + self._json( + {"error": "invalid_mode_command_flow", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + def _handle_extension_post(self, path: str) -> None: action_map = { "/api/extension/gscu/info": "info", diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 73e4f5b..68db27e 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -289,6 +289,247 @@ operations: self.service.mode_profiles = original_registry self.service.artifact_root = original_artifact_root + def test_office_mode_generation_and_card_execution_reuse_saved_flow(self) -> None: + original_registry = self.service.mode_profiles + original_artifact_root = self.service.artifact_root + original_extension = SeatDemoHandler.extension + original_nfc = SeatDemoHandler.nfc + instruction = ( + "空调温度设置为25度,风速设置为1档," + "启动闪烁灯,颜色设置为红色" + ) + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + preset_root = base / "presets" + user_root = base / "users" + preset = preset_root / "vehicles" / "denza_n7" / "office.yaml" + preset.parent.mkdir(parents=True) + preset.write_text( + """ +id: office +name: 办公模式 +icon: briefcase +scope: + vehicle_ids: [denza_n7] +operations: +- domain: climate + capability: climate.set_temperature + target_scope: driver + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + extension = SelfExtensionService( + SimulationGateway(), + catalog_db_path=base / "office-mode-ir.sqlite3", + ) + extension.sync_peripheral_catalog( + [ + { + "id": "ir-slot:tony-light-001:1", + "status": "learned", + "enabled": True, + "device_id": "tony-light-001", + "device_name": "闪灯玩具", + "object_names": ["闪灯玩具", "闪烁灯", "闪光灯"], + "card_uid": "2CC8B103", + "action": "打开", + "action_names": ["打开", "开启", "启动"], + "risk": "low", + "slot": 1, + }, + { + "id": "ir-slot:tony-light-001:3", + "status": "learned", + "enabled": True, + "device_id": "tony-light-001", + "device_name": "闪灯玩具", + "object_names": ["闪灯玩具", "闪烁灯", "闪光灯"], + "card_uid": "2CC8B103", + "action": "切换到红色", + "action_names": [ + "切换到红色", + "切换为红色", + "设置为红色", + ], + "risk": "low", + "slot": 3, + }, + ] + ) + self.service.mode_profiles = ModeProfileRegistry( + preset_root, + user_root=user_root, + ) + self.service.artifact_root = base / "runs" + SeatDemoHandler.extension = extension + SeatDemoHandler.nfc = SimpleNamespace( + snapshot=lambda: { + "revision": 1, + "reader": {"online": True}, + "card": {"action": "present", "uid": "2CC8B103"}, + } + ) + try: + generate_request = urllib.request.Request( + f"{self.base_url}/api/requests/preview", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "utterance": instruction, + "provider": "local", + "conversation_id": "conv-office-mode-generate", + "vehicle_id": "denza_n7", + "execution_mode": "dry_run", + "control_transport": "wired_can", + "client_type": "mode_editor_compile", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + with self.opener.open(generate_request, timeout=3) as response: + generated = json.loads(response.read().decode("utf-8")) + + save_request = urllib.request.Request( + f"{self.base_url}/api/mode-profiles/office", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "name": "办公模式", + "instruction": instruction, + "source_request_id": generated["request_id"], + "vehicle_id": "denza_n7", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + with self.opener.open(save_request, timeout=3) as response: + saved = json.loads(response.read().decode("utf-8")) + + execute_request = urllib.request.Request( + f"{self.base_url}/api/mode-profiles/office/execute", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "conversation_id": "conv-office-mode-execute", + "vehicle_id": "denza_n7", + "execution_mode": "dry_run", + "control_transport": "wired_can", + "client_type": "web_console_mode_card", + }, + ensure_ascii=False, + ).encode("utf-8"), + ) + with patch.object( + self.service, + "_interpret", + side_effect=AssertionError( + "saved mode card must not reinterpret natural language" + ), + ), self.opener.open(execute_request, timeout=3) as response: + executed = json.loads(response.read().decode("utf-8")) + finally: + self.service.mode_profiles = original_registry + self.service.artifact_root = original_artifact_root + SeatDemoHandler.extension = original_extension + SeatDemoHandler.nfc = original_nfc + + generated_commands = [ + ( + command.get("signals", {}).get("debug_command") + or ( + f"infrared emit {command.get('signals', {}).get('slot')}" + if command.get("message") == "infrared.emit" + else None + ) + ) + for plan in generated.get("command_plans", {}).values() + for command in plan.get("spec", {}).get("commands", []) + ] + self.assertEqual( + generated_commands, + [ + "ac temp driver 25", + "ac fan 1", + "infrared emit 1", + "infrared emit 3", + ], + ) + self.assertEqual(saved["mode_profile"]["command_flow"]["execution_steps"], []) + self.assertEqual(executed["status"], "succeeded") + self.assertEqual(executed["utterance"], "办公模式") + self.assertEqual(executed["provider_used"], "saved_mode_command_flow") + self.assertEqual(executed["orchestration_route"], "saved_mode_command_flow") + self.assertEqual( + [item["slot"] for item in executed["infrared_actions"]], + [1, 3], + ) + self.assertTrue( + all( + item["parse_source"] == "saved_mode_command_flow" + for item in executed["infrared_actions"] + ) + ) + + def test_saved_mode_accepts_prefixed_self_extension_flow_key(self) -> None: + with tempfile.TemporaryDirectory() as directory: + extension = SelfExtensionService( + SimulationGateway(), + catalog_db_path=Path(directory, "prefixed-mode-ir.sqlite3"), + ) + extension.sync_peripheral_catalog( + [ + { + "id": "ir-slot:tony-light-001:1", + "status": "learned", + "enabled": True, + "device_id": "tony-light-001", + "device_name": "闪灯玩具", + "card_uid": "2CC8B103", + "action": "打开", + "slot": 1, + } + ] + ) + handler = object.__new__(SeatDemoHandler) + handler.extension = extension + handler.nfc = SimpleNamespace( + snapshot=lambda: { + "card": {"action": "present", "uid": "2CC8B103"} + } + ) + profile = SimpleNamespace( + command_flow={ + "function_configs": { + "local_self_extension": { + "spec": { + "domain": "self_extension", + "operations": [ + { + "capability": "infrared.emit", + "target": {"device": "tony-light-001"}, + "parameters": {"action": "打开", "slot": 1}, + } + ], + } + } + } + } + ) + + entries = handler._saved_mode_infrared_entries(profile) + + self.assertEqual([item["capability"]["slot"] for item in entries], [1]) + self.assertEqual( + entries[0]["capability"]["parse_source"], + "saved_mode_command_flow", + ) + def test_head_unit_display_sync_tracks_tasks_and_usb_catalog(self) -> None: task_request = urllib.request.Request( f"{self.base_url}/api/requests", @@ -344,14 +585,14 @@ 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-107", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-109", 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-49", timeout=3 + f"{self.base_url}/console-v2.js?v=delivery-state-50", timeout=3 ) as response: console_script = response.read().decode("utf-8") with self.opener.open( @@ -394,11 +635,11 @@ 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-107"', html) + self.assertIn('src="/app.js?v=dual-seat-109"', html) self.assertIn('href="/styles.css?v=dual-seat-16"', html) self.assertIn('src="/gesture-control.js?v=android-pad-9"', html) self.assertNotIn('class="vehicle-system-hud"', html) - self.assertIn('src="/console-v2.js?v=delivery-state-49"', html) + self.assertIn('src="/console-v2.js?v=delivery-state-50"', html) self.assertIn('href="/console-v2.css?v=delivery-state-43"', html) self.assertIn('', html) self.assertIn('id="v2BootScreen"', html) @@ -641,7 +882,8 @@ operations: ) self.assertIn("const stageHasLayout = () =>", app_script) self.assertIn("window.requestAnimationFrame(initSeatTwin)", app_script) - self.assertIn('client_type: "web_console"', app_script) + self.assertIn('clientType = "web_console"', app_script) + self.assertIn("client_type: clientType", app_script) self.assertIn("function executionVerificationStatus(result = {})", app_script) self.assertIn('"submitted_unverified"', app_script) self.assertIn("指令已提交,等待状态确认", app_script) @@ -649,8 +891,8 @@ 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-v218"', service_worker) - self.assertIn('"/app.js?v=dual-seat-107"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v220"', service_worker) + self.assertIn('"/app.js?v=dual-seat-109"', service_worker) self.assertIn('"/assets/pets/self-extension-cat.png?v=3"', service_worker) self.assertIn(".then(() => self.skipWaiting())", service_worker) self.assertNotIn("const cached = await caches.match(request)", service_worker) @@ -890,7 +1132,7 @@ operations: ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-107", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-109", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/integration/test_multidomain_service.py b/tests/integration/test_multidomain_service.py index b1c3552..9b3d014 100755 --- a/tests/integration/test_multidomain_service.py +++ b/tests/integration/test_multidomain_service.py @@ -621,7 +621,7 @@ operations: if item["id"] == "office" ) self.assertEqual(persisted["name"], "专注办公模式") - with self.assertRaisesRegex(ValueError, "重新点击执行"): + with self.assertRaisesRegex(ValueError, "生成指令"): self.service.save_mode_profile_card( "office", "专注办公模式", @@ -630,6 +630,181 @@ operations: "denza_n7", ) + def test_saved_mode_card_executes_without_reinterpreting_instruction(self) -> None: + mode_root = Path(self.temporary_directory.name) / "direct-mode-presets" + user_root = Path(self.temporary_directory.name) / "direct-mode-user" + preset = mode_root / "vehicles" / "denza_n7" / "office.yaml" + preset.parent.mkdir(parents=True) + preset.write_text( + """ +id: office +name: 办公模式 +icon: briefcase +scope: + vehicle_ids: [denza_n7] +operations: +- domain: climate + capability: climate.set_temperature + target_scope: driver + parameters: + temperature_c: 24 +""".strip() + + "\n", + encoding="utf-8", + ) + self.service.mode_profiles = ModeProfileRegistry( + mode_root, user_root=user_root + ) + instruction = "空调温度设置为25度,风速设置为1档" + generated = self.service.preview_native_can( + instruction, + provider="local", + conversation_id="conv-generate-office-card", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + saved = self.service.save_mode_profile_card( + "office", + "办公模式", + instruction, + generated["request_id"], + "denza_n7", + ) + + with patch.object( + self.service, + "_interpret", + side_effect=AssertionError("saved mode must not reinterpret natural language"), + ): + executed = self.service.execute_mode_profile_card( + "office", + conversation_id="conv-execute-office-card", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + + operations = [ + operation + for config in executed.get("function_configs", {}).values() + for operation in config.get("spec", {}).get("operations", []) + ] + self.assertEqual(executed["status"], "succeeded") + self.assertEqual(executed["utterance"], "办公模式") + self.assertEqual(executed["provider_used"], "saved_mode_command_flow") + self.assertEqual(executed["orchestration_route"], "saved_mode_command_flow") + self.assertEqual( + [ + ( + operation["capability"], + operation["target"]["zone"], + operation["parameters"], + operation["parse_source"], + ) + for operation in operations + ], + [ + ( + "climate.set_temperature", + "driver", + {"temperature_c": 25}, + "saved_mode_command_flow", + ), + ( + "climate.set_fan_level", + "vehicle", + {"level": 1}, + "saved_mode_command_flow", + ), + ], + ) + self.assertEqual( + saved["mode_profile"]["command_flow"]["execution_steps"], [] + ) + + def test_saved_mode_card_replays_front_right_dynamic_target_as_passenger( + self, + ) -> None: + mode_root = Path(self.temporary_directory.name) / "dynamic-mode-presets" + user_root = Path(self.temporary_directory.name) / "dynamic-mode-user" + self.service.mode_profiles = ModeProfileRegistry( + mode_root, + user_root=user_root, + ) + self.service.mode_profiles.save_mode( + "小憩模式", + [ + { + "domain": "seat", + "capability": "seat.start_dynamic_mode", + "target_scope": "front_right", + "parameters": {"mode_id": 2}, + }, + { + "domain": "climate", + "capability": "climate.set_power", + "target_scope": "vehicle", + "parameters": {"state": "on"}, + }, + { + "domain": "roof", + "capability": "roof.set_shade_motion", + "target_scope": "vehicle", + "parameters": {"motion": "open"}, + }, + ], + mode_id="nap", + vehicle_id="denza_n7", + instruction="打开摇篮模式,开启空调", + command_flow={ + "schema": "cognitive-agent-mode-command-flow/v1", + "source_request_id": "req-saved-dynamic-mode", + "plans": { + "local_seat": { + "spec": { + "commands": [ + { + "message": "Seat_Dynamic_Mode_Virtual", + "signals": {"Dynamic_Mode_ID": 2}, + } + ] + } + } + }, + }, + ) + + with patch.object( + self.service, + "_interpret", + side_effect=AssertionError("saved mode must not reinterpret natural language"), + ): + executed = self.service.execute_mode_profile_card( + "nap", + conversation_id="conv-execute-saved-dynamic-card", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + + self.assertEqual(executed["status"], "succeeded") + self.assertEqual(executed["utterance"], "小憩模式") + self.assertEqual(executed["provider_used"], "saved_mode_command_flow") + self.assertFalse( + [item for item in executed.get("validation", []) if item["severity"] == "error"] + ) + dynamic_request = next( + item + for item in executed["model_requests"] + if item["capability"] == "seat.start_dynamic_mode" + ) + self.assertEqual(dynamic_request["target_scope"], "passenger") + self.assertTrue(executed["command_plans"]) + replayed_capabilities = { + operation["capability"] + for config in executed["function_configs"].values() + for operation in config["spec"]["operations"] + } + self.assertIn("roof.set_shade_motion", replayed_capabilities) + def test_build_named_mode_executes_and_persists_in_one_turn(self) -> None: mode_root = Path(self.temporary_directory.name) / "modes" self.service.mode_profiles = ModeProfileRegistry(mode_root) diff --git a/tests/unit/test_nfc_frontend_contract.py b/tests/unit/test_nfc_frontend_contract.py index fa3431d..217c0e5 100644 --- a/tests/unit/test_nfc_frontend_contract.py +++ b/tests/unit/test_nfc_frontend_contract.py @@ -139,4 +139,4 @@ def test_main_parsed_control_panel_renders_validated_infrared_operations() -> No assert 'frameLabel: "GSCU1B TCP"' in console assert "已解析控制指令" in console assert 'li[data-can-channel="IR"]' in styles - assert "console-v2.js?v=delivery-state-49" in html + assert "console-v2.js?v=delivery-state-50" in html diff --git a/tests/unit/test_requested_frontend_enhancements.py b/tests/unit/test_requested_frontend_enhancements.py index 2fa39e9..c37bc30 100644 --- a/tests/unit/test_requested_frontend_enhancements.py +++ b/tests/unit/test_requested_frontend_enhancements.py @@ -437,7 +437,19 @@ def test_mode_editor_renders_readable_commands_with_expandable_mqtt_sequences() assert 'source: "published"' in app assert 'source: flow?.control_transport === "wireless_mqtt" ? "projected" : "can"' in app assert "function_configs: functionConfigs" in app - assert "execution_steps: executionSteps" in app + assert "execution_steps: []" in app + assert "requestNaturalLanguageControl(instruction" in app + assert 'executionMode: "dry_run"' in app + assert 'clientType: "mode_editor_compile"' in app + assert "previewOnly: true" in app + 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 + assert 'const savedModeExecution = result.orchestration_route === "saved_mode_command_flow";' in app + assert "`${result.mode_profile.name}执行完成`" in app + + assert "生成指令" in html + assert ">执行" not in html assert ".mode-editor-command-card" in styles assert ".mode-editor-command-meta" in styles diff --git a/tests/unit/test_self_extension.py b/tests/unit/test_self_extension.py index 49c6f42..d0cd317 100644 --- a/tests/unit/test_self_extension.py +++ b/tests/unit/test_self_extension.py @@ -970,7 +970,7 @@ class SelfExtensionTests(unittest.TestCase): ["failed"], ) - def test_submitted_emit_without_ack_continues_complete_infrared_plan(self) -> None: + def test_power_on_then_white_waits_800ms_and_continues_without_ack(self) -> None: service = SelfExtensionService(SimulationGateway()) catalog = [ { @@ -984,7 +984,7 @@ class SelfExtensionTests(unittest.TestCase): } for capability_id, action, slot in ( ("light-on", "打开", 1), - ("light-green", "切换到绿色", 4), + ("light-white", "切换到白色", 6), ) ] @@ -1009,16 +1009,18 @@ class SelfExtensionTests(unittest.TestCase): "execute_gateway", side_effect=submit_without_ack, ) as execute_gateway, - patch("cognitive_agent.application.self_extension.time.sleep"), + patch( + "cognitive_agent.application.self_extension.time.sleep" + ) as sleep, ): result = service.execute_peripheral_plan_request( - "开启闪光灯,颜色设置为绿色", + "启动闪烁灯,颜色设置为白色", { "provider_used": "infrared_local_rules", - "summary": "打开闪光灯并切换为绿色", + "summary": "启动闪烁灯并切换为白色", "steps": [ {"kind": "peripheral", "capability_id": "light-on"}, - {"kind": "peripheral", "capability_id": "light-green"}, + {"kind": "peripheral", "capability_id": "light-white"}, ], }, catalog, @@ -1027,11 +1029,12 @@ class SelfExtensionTests(unittest.TestCase): ) self.assertEqual(execute_gateway.call_count, 2) + sleep.assert_called_once_with(0.8) self.assertEqual(result["status"], "succeeded") self.assertEqual(result["sequence"]["emit_count"], 2) self.assertEqual( [item["telemetry"]["slot"] for item in result["operation_results"]], - [1, 4], + [1, 6], ) self.assertEqual( [ diff --git a/tests/unit/test_two_stage_can_parsing.py b/tests/unit/test_two_stage_can_parsing.py index 1985483..3c6a675 100644 --- a/tests/unit/test_two_stage_can_parsing.py +++ b/tests/unit/test_two_stage_can_parsing.py @@ -187,6 +187,7 @@ def test_frontend_two_stage_contract_and_source_badges() -> None: assert "本地规则命令已执行;云端补充失败" in app assert 'class="v2-can-command-source"' in console assert 'item.parseSource === "direct_control" ? "设备控件"' in console + assert 'item.parseSource === "saved_mode_command_flow" ? "模式卡片"' in console assert 'item.parseSource === "local_rule" ? "本地规则" : "云端模型"' in console assert 'item.deliveryLabel' in console assert 'data-delivery-state="${escapeHtml(item.deliveryState)}"' in console