From d09b5967f854ca4bc3829a4fb3895007636be1b8 Mon Sep 17 00:00:00 2001 From: vee Date: Sat, 12 Sep 2026 13:33:38 +0800 Subject: [PATCH 1/2] fix: restore mode clicks and compose seat-aware assistant scenes --- apps/web-console/app.js | 28 ++- apps/web-console/assistant-chat.css | 9 +- apps/web-console/assistant-chat.js | 56 ++++- apps/web-console/index.html | 6 +- apps/web-console/service-worker.js | 8 +- config/scene_templates/long_trip_comfort.yaml | 4 +- config/scene_templates/relaxation.yaml | 5 +- config/scene_templates/seat_comfort.yaml | 14 ++ .../application/assistant_capabilities.py | 24 ++ .../application/assistant_plan_guard.py | 28 +++ .../application/mode_profile_intent.py | 3 + .../application/scene_composer.py | 38 ++- .../application/seat_service.py | 28 ++- .../application/semantic_ir.py | 50 +++- tests/e2e/mode_rail_browser.cjs | 178 +++++++++++++ tests/e2e/test_mode_rail_browser.py | 86 +++++++ tests/e2e/test_seat_web_api.py | 20 +- .../test_scene_explicit_seat_scope.py | 235 ++++++++++++++++++ .../integration/test_scene_template_hybrid.py | 114 ++++++++- .../unit/test_assistant_capability_catalog.py | 2 +- .../unit/test_assistant_frontend_contract.py | 10 +- tests/unit/test_assistant_mode_draft_rows.py | 177 +++++++++++++ .../test_energy_monitor_frontend_contract.py | 4 +- tests/unit/test_frontend_theme_contract.py | 2 +- tests/unit/test_mode_gallery_grouping.py | 41 +++ .../test_scene_composer_capability_context.py | 3 + .../test_scene_template_semantic_aliases.py | 4 + .../unit/test_seat_comfort_scene_contract.py | 114 +++++++++ 28 files changed, 1247 insertions(+), 44 deletions(-) create mode 100644 config/scene_templates/seat_comfort.yaml create mode 100644 tests/e2e/mode_rail_browser.cjs create mode 100644 tests/e2e/test_mode_rail_browser.py create mode 100644 tests/integration/test_scene_explicit_seat_scope.py create mode 100644 tests/unit/test_assistant_mode_draft_rows.py create mode 100644 tests/unit/test_seat_comfort_scene_contract.py diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 68eef7d..1422d68 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -5910,22 +5910,34 @@ function installModeChipScroller() { pointerStartX = event.clientX; pointerStartScroll = group.scrollLeft; dragged = false; - group.setPointerCapture?.(pointerId); + suppressClick = false; }); group.addEventListener("pointermove", (event) => { if (pointerId !== event.pointerId) return; + if (event.buttons !== 1) { + finishPointerDrag(event); + return; + } const distance = event.clientX - pointerStartX; if (!dragged && Math.abs(distance) < 5) return; - dragged = true; - group.classList.add("is-dragging"); + if (!dragged) { + // Capturing on pointerdown retargets a normal button click to the rail. + // Capture only after movement establishes that this gesture is a drag. + dragged = true; + group.setPointerCapture?.(pointerId); + group.classList.add("is-dragging"); + } group.scrollLeft = Math.max(0, Math.min(maximumScroll(), pointerStartScroll - distance)); updateScrollState(); event.preventDefault(); }); const finishPointerDrag = (event) => { if (pointerId !== event.pointerId) return; - group.releasePointerCapture?.(pointerId); + const finishedPointerId = pointerId; pointerId = null; + if (group.hasPointerCapture?.(finishedPointerId)) { + group.releasePointerCapture(finishedPointerId); + } group.classList.remove("is-dragging"); if (dragged) { suppressClick = true; @@ -5935,6 +5947,7 @@ function installModeChipScroller() { }; group.addEventListener("pointerup", finishPointerDrag); group.addEventListener("pointercancel", finishPointerDrag); + group.addEventListener("lostpointercapture", finishPointerDrag); group.addEventListener("click", (event) => { if (!suppressClick) return; event.preventDefault(); @@ -6424,10 +6437,17 @@ function applyModeCommand(command, modeProfileId = null) { showToast("AI助手正在处理上一条指令,请稍候"); return false; } + const speech = window.CognitiveAgentSpeech; + if (["capturing", "finalizing"].includes(speech?.phase) + || (speech?.phase === "arming" && speech?.inputMode === "manual")) { + showToast("请等待语音整理完成后再发送"); + return false; + } if ($("#runButton")?.disabled) { showToast("上一条指令仍在执行,请稍候"); return false; } + hideModeActionPopover({ immediate: true }); const mode = modeProfiles().find((item) => item.id === modeProfileId); setModeRailSelection(mode); closeModeGallery({ restoreFocus: false }); diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css index b3d4d37..0ac9079 100644 --- a/apps/web-console/assistant-chat.css +++ b/apps/web-console/assistant-chat.css @@ -1478,7 +1478,14 @@ } .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-operation-shell { - top: 60px; + /* Share the content row on compact screens, never cover the mode rail. */ + position: relative; + grid-column: 1; + grid-row: 3; + width: 100%; + top: auto; + right: auto; + bottom: auto; } .console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-module-switcher { diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 26b1d3f..1840b4f 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -647,11 +647,39 @@ "seat.set_cushion_tilt": "坐垫角度", "seat.set_backrest_recline": "靠背角度", "seat.set_legrest_height": "腿托高度", + "seat.set_ventilation": "座椅通风", + "seat.set_heating": "座椅加热", + "seat.set_driver_ventilation_native": "座椅通风", + "seat.set_driver_heating_native": "座椅加热", + "seat.set_driver_massage_native": "座椅按摩", }; label = `${seatLabel}${seatCapabilityLabels[capability] || "座椅"}`; value = params.position_pct != null ? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}` : motionLabel(params.state || params.mode_id); + if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) { + // CH1 driver controls encode high as 1 and low as 2; passenger + // comfort levels use a different scale and must not reuse this map. + const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认"; + value = params.state === "off" ? "关闭" + : params.state === "on" ? `开启 · ${level}` : "开关状态待确认"; + } else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) { + value = params.state === "off" ? "关闭" + : params.state === "on" + ? `开启${params.level != null ? ` · ${params.level}档` : ""}` + : "开关状态待确认"; + } else if (capability === "seat.set_driver_massage_native") { + const massageModes = { + wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松", + }; + const massageStrengths = { 1: "轻柔", 2: "中等", 3: "较强" }; + value = params.action === "off" ? "关闭" + : params.action === "mode" + ? (massageModes[params.mode] ? `${massageModes[params.mode]}模式` : "按摩模式待确认") + : params.action === "strength" + ? (massageStrengths[params.strength] ? `${massageStrengths[params.strength]}力度` : "按摩力度待确认") + : "按摩动作待确认"; + } icon = "armchair"; order = instructionOrder("座椅", "副驾", "主驾"); } else if (capability === "climate.set_temperature") { @@ -660,11 +688,21 @@ icon = "thermometer"; order = instructionOrder("空调", "温度"); } else if (domain === "climate") { - const cycleLabels = { internal: "内循环", external: "外循环", auto: "自动循环" }; + const climateModeLabels = { + "climate.set_cycle_mode": { internal: "内循环", external: "外循环" }, + "climate.set_control_mode": { manual: "手动控制", auto: "自动控制" }, + "climate.set_outlet_mode": { + face: "吹面", face_feet: "吹面 / 吹脚", feet: "吹脚", + feet_defrost: "吹脚 / 除霜", defrost: "除霜", + face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜", + }, + }; label = capability === "climate.set_fan_level" ? "空调风量" - : capability === "climate.set_cycle_mode" ? "空调循环" : "空调"; + : capability === "climate.set_cycle_mode" ? "空调循环" + : capability === "climate.set_control_mode" ? "空调控制模式" + : capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调"; value = params.level != null ? `${params.level}档` - : params.mode != null ? (cycleLabels[String(params.mode)] || String(params.mode)) + : params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode)) : motionLabel(params.state); icon = "fan"; order = instructionOrder("空调", "风量"); @@ -676,6 +714,18 @@ } else if (["interior_light", "ambient_light"].includes(domain)) { label = domain === "ambient_light" ? "氛围灯" : "室内灯"; value = motionLabel(params.state); + if (capability === "ambient_light.set_brightness_level") { + label = "氛围灯亮度"; + value = params.brightness_level != null ? `${params.brightness_level}档` : "亮度档位待确认"; + } else if (capability === "ambient_light.set_color_index") { + label = "氛围灯颜色"; + value = params.color_index != null ? `色号 ${params.color_index}` : "颜色待确认"; + } else if (capability === "ambient_light.set_illumination") { + value = [ + params.color_hex != null ? `颜色 ${params.color_hex}` : "颜色待确认", + params.brightness_pct != null ? `亮度 ${params.brightness_pct}%` : "亮度待确认", + ].join(" · "); + } icon = "lightbulb"; order = instructionOrder(label, "灯光"); } diff --git a/apps/web-console/index.html b/apps/web-console/index.html index e4d1c87..637fe52 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -13,20 +13,20 @@ - + - + - + diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index d82721f..741c426 100644 --- a/apps/web-console/service-worker.js +++ b/apps/web-console/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_VERSION = "cognitive-agent-pad-v277"; +const CACHE_VERSION = "cognitive-agent-pad-v278"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,7 +6,7 @@ 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", @@ -16,8 +16,8 @@ const APP_SHELL = [ "/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.css?v=codex-assistant-27", + "/assistant-chat.js?v=codex-assistant-27", "/energy-monitor.css?v=energy-monitor-4", "/energy-monitor.js?v=energy-monitor-4", "/assets/pets/self-extension-cat.png?v=3", diff --git a/config/scene_templates/long_trip_comfort.yaml b/config/scene_templates/long_trip_comfort.yaml index b5457d5..2908fde 100644 --- a/config/scene_templates/long_trip_comfort.yaml +++ b/config/scene_templates/long_trip_comfort.yaml @@ -1,9 +1,11 @@ id: long-trip-comfort name: 长途舒适模式 aliases: [长途乘车模式, 高速舒适模式] -description: 为长途乘坐提供副驾恒温、柔和风量、外循环与适度遮光。 +description: 为长途乘坐提供主副驾低档座椅通风、副驾恒温、柔和风量、外循环与适度遮光,不改变座椅驾驶姿态。 icon: route operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风;该接口 1 为低档。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: passenger, operation: set, parameters: {temperature_c: 24}, reason: 保持副驾舒适温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。} diff --git a/config/scene_templates/relaxation.yaml b/config/scene_templates/relaxation.yaml index a9d8914..286edbd 100644 --- a/config/scene_templates/relaxation.yaml +++ b/config/scene_templates/relaxation.yaml @@ -1,9 +1,10 @@ id: relaxation name: 放松模式 -aliases: [休闲模式, 舒缓模式, 轻松模式, 放空模式] -description: 使用舒展的副驾姿态、安静恒温、适度遮光和低亮暖光,营造适合放松休息的座舱环境。 +aliases: [休闲模式, 舒缓模式, 轻松模式, 放空模式, 坐着放松模式, 乘坐放松模式, 座椅放松模式, 座位放松模式] +description: 使用主驾放松按摩、舒展的副驾姿态、安静恒温、适度遮光和低亮暖光,营造适合驻车放松休息的座舱环境;不改变主驾驾驶姿态。 icon: sofa operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已验证的主驾放松按摩模式;主驾没有绝对位置反馈,不自动移动主驾座椅。} - {domain: seat, capability: seat.set_longitudinal_position, target_scope: passenger, operation: set, parameters: {position_pct: 51}, reason: 将副驾移动到兼顾腿部空间与安全的休闲位置。} - {domain: seat, capability: seat.set_cushion_tilt, target_scope: passenger, operation: set, parameters: {position_pct: 30}, reason: 调整坐盆角度以提供稳定支撑。} - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 66}, reason: 将副驾靠背调整到舒展的休闲角度。} diff --git a/config/scene_templates/seat_comfort.yaml b/config/scene_templates/seat_comfort.yaml new file mode 100644 index 0000000..a2e45e0 --- /dev/null +++ b/config/scene_templates/seat_comfort.yaml @@ -0,0 +1,14 @@ +id: seat-comfort +name: 座椅舒适模式 +aliases: [座位舒适模式, 乘坐舒适模式, 坐着舒服模式, 坐着舒适模式, 座椅舒服模式, 座位舒服模式] +description: 为主副驾组合低档通风、主驾放松按摩和副驾适度靠背及腿托支撑;按两座实际能力分别编排,主驾没有绝对位置反馈,不自动移动主驾座椅。适合驻车调整并预览后使用。 +icon: armchair +operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾通风使用独立 CAN 接口的低档 2,减少背部闷热。} + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已验证的放松按摩模式,不假定副驾具有按摩能力。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾通风使用独立接口的低档 1。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 副驾靠背使用可反馈的适度舒展目标。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 副驾腿托提供适度腿部支撑。} + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启座舱恒温。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 保持中性舒适温度。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪和直吹打扰。} diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py index b37e672..ff25cd0 100644 --- a/src/cognitive_agent/application/assistant_capabilities.py +++ b/src/cognitive_agent/application/assistant_capabilities.py @@ -21,6 +21,7 @@ from cognitive_agent.application.assistant_plan_guard import ( ) from cognitive_agent.application.mode_profile_intent import ( normalize_mode_profile_name, + parse_mode_profile_create, parse_mode_profile_detail_query, ) from cognitive_agent.generated.can_semantic_protocol import CAN0_SUPPORTED_CAPABILITIES @@ -428,6 +429,9 @@ _MODE_CAPABILITY_LABELS = { "seat.set_legrest_extension": "座椅腿托伸展", "seat.set_heating": "座椅加热", "seat.set_ventilation": "座椅通风", + "seat.set_driver_ventilation_native": "座椅通风", + "seat.set_driver_heating_native": "座椅加热", + "seat.set_driver_massage_native": "座椅按摩", "seat.start_dynamic_mode": "座椅动态模式", "climate.set_power": "空调", "climate.set_temperature": "空调温度", @@ -575,6 +579,21 @@ def _mode_operation_text(raw: Mapping[str, object]) -> str: }: target_label = "" subject = f"{target_label}{label}" + if capability in {"seat.set_driver_ventilation_native", "seat.set_driver_heating_native"}: + if parameters.get("state") == "off": + return f"{subject}关闭" + level = {1: "高档", 2: "低档"}.get(parameters.get("level", 2), "") + return f"{subject}开启{level}" + if capability == "seat.set_driver_massage_native": + if parameters.get("action") == "off": + return f"{subject}关闭" + if parameters.get("action") == "strength": + strength = {1: "轻柔", 2: "中等", 3: "较强"}.get(parameters.get("strength"), "") + return f"{subject}设为{strength}力度" + mode = { + "wave": "波浪", "pulse": "脉冲", "stretch": "舒展", "slack": "舒缓", "relax": "放松", + }.get(str(parameters.get("mode") or ""), "") + return f"{subject}设为{mode}模式" if set(parameters) == {"state"}: return f"{subject}{values[0]}" if capability == "roof.set_shade_position" and parameters.get("position_pct") in {0, 100}: @@ -1750,6 +1769,11 @@ class AssistantCapabilityCatalog: normalized = "".join(text.lower().split()) if not normalized: return None + # A creation request can contain both a device and the noun 功能模式. + # That noun is not evidence that the user is asking about capabilities. + # Use the guarded intent parser, so genuine questions remain answerable. + if parse_mode_profile_create(text) is not None: + return None catalog = snapshot or self.snapshot() unsupported_control = _unsupported_control_answer( _canonical_control_text(text), diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py index dd87acd..6c7dca8 100644 --- a/src/cognitive_agent/application/assistant_plan_guard.py +++ b/src/cognitive_agent/application/assistant_plan_guard.py @@ -25,6 +25,8 @@ from collections.abc import Set as AbstractSet from dataclasses import dataclass from typing import Any +from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes + MAX_PLAN_BYTES = 24 * 1024 MAX_PLAN_REQUESTS = 12 MAX_PLAN_PARAMETERS = 12 @@ -39,6 +41,9 @@ SCENE_COMPOSABLE_CAPABILITIES = frozenset( "seat.set_legrest_extension", "seat.set_ventilation", "seat.set_heating", + "seat.set_driver_ventilation_native", + "seat.set_driver_heating_native", + "seat.set_driver_massage_native", "climate.set_power", "climate.set_temperature", "climate.set_fan_level", @@ -1085,6 +1090,7 @@ def guard_assistant_plan( fallback_intents: Sequence[Mapping[str, Any]] = (), trusted_local: Mapping[str, Any] | None = None, allow_scene_composition: bool = False, + scene_scope_utterance: str | None = None, ) -> GuardedAssistantPlan: """Authorize a Codex plan against the server-owned unresolved text. @@ -1153,6 +1159,10 @@ def guard_assistant_plan( parser_claims.update(_intent_claims(intent)) local_keys = _resolved_keys(trusted_local or {}) non_execution = bool(_NON_EXECUTION_PATTERN.search(text)) + scene_seat_scopes = ( + resolve_scene_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) + if allow_scene_composition else None + ) accepted: list[dict[str, Any]] = [] rejected: list[dict[str, str]] = [] @@ -1204,6 +1214,24 @@ def guard_assistant_plan( } ) continue + if ( + allow_scene_composition + and domain == "seat" + and scene_seat_scopes is not None + and ( + "unsupported" in scene_seat_scopes + or {"front_left": "driver", "front_right": "passenger"}.get( + target_scope, target_scope + ) not in scene_seat_scopes + ) + ): + rejected.append( + { + "field": f"requests[{index}].target_scope", + "question": "场景座椅动作超出了用户明确指定的座位范围,已拒绝生成该功能模式。", + } + ) + continue request_text = _relevant_text(capability, text) if non_execution or ( not allow_scene_composition diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py index db8252a..3befe36 100644 --- a/src/cognitive_agent/application/mode_profile_intent.py +++ b/src/cognitive_agent/application/mode_profile_intent.py @@ -34,6 +34,9 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = ( r"^(?:可以|能够|能)?让(?:我们|我|人们?|乘员们?|乘客们?|大家)" r"(?P.+?)的(?:功能|场景|自定义)?模式$" ), + re.compile( + r"^(?P(?:座椅|座位|乘坐|坐着).+?)的(?:功能|场景|自定义)?模式$" + ), ) _MODE_PROFILE_PURPOSE_INVALID = frozenset( {"", "功能", "场景", "自定义", "使用", "日常使用", "合适", "需要"} diff --git a/src/cognitive_agent/application/scene_composer.py b/src/cognitive_agent/application/scene_composer.py index efb2188..adc430e 100644 --- a/src/cognitive_agent/application/scene_composer.py +++ b/src/cognitive_agent/application/scene_composer.py @@ -14,7 +14,7 @@ from cognitive_agent.application.assistant_plan_guard import ( MAX_PLAN_REQUESTS, ) -SCENE_PLANNER_VERSION = "n7web-scene-composer/v2" +SCENE_PLANNER_VERSION = "n7web-scene-composer/v3" _MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024 _JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"}) @@ -46,6 +46,19 @@ For a commonly understood scene, infer conservative cabin settings. If the name meaningless, unsafe, contradictory, or cannot be grounded in the supplied catalog, return no requests and a short ambiguity. The application will independently check the complete live registry, parameters, compiler, safety policy, and dry-run result. + +The primary user need must be represented in the actions. For sitting comfort, +seating relaxation or rest, consider supported seat functions before auxiliary +climate and lights; climate and lights alone do not fulfil a seating request. +For an unqualified front-seat comfort scene, consider both driver and passenger +using each seat's own listed capabilities. Respect an explicitly named seat. +Driver heating/ventilation native levels use 2 for low and 1 for high; passenger +levels use 1 for low. Driver massage supports action=mode with one listed mode, +action=strength with strength, or action=off. Do not mix these action parameters. +Driver absolute axis positions, unlearned memory slots and passenger massage must +not be invented. Do not use timed axis movement as a substitute for an absolute +posture; leave unsupported axes unchanged. If the primary requested seat function +is unavailable, return an ambiguity rather than an unrelated cabin-only scene. Return only the JSON object required by the output schema. """ @@ -179,6 +192,29 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj } for names in parameter_combinations ] + if capability_id == "seat.set_driver_massage_native": + # The native command discriminates mode, strength and off. Its YAML + # scalar contracts cannot express that dependency on their own. + parameter_object_branches = [] + action_schema = value_schemas.get("action", {}) + for action, dependent_name in (("off", None), ("mode", "mode"), ("strength", "strength")): + if action not in action_schema.get("enum", []): + continue + if dependent_name is not None and dependent_name not in value_schemas: + continue + properties = {"action": {"type": "string", "enum": [action]}} + if dependent_name is not None: + properties[dependent_name] = value_schemas[dependent_name] + parameter_object_branches.append( + { + "type": "object", + "additionalProperties": False, + "required": list(properties), + "properties": properties, + } + ) + if not parameter_object_branches: + return None return { "type": "object", "additionalProperties": False, diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 5a4a7ab..72d702a 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -2195,6 +2195,7 @@ class SeatDemoService: assistant_plan: dict[str, Any] | None = None, allow_fuzzy_mode_profile_match: bool = True, allow_scene_composition: bool = False, + scene_scope_utterance: str | None = None, mode_profile_draft_context: dict[str, Any] | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) @@ -2526,8 +2527,12 @@ class SeatDemoService: model_parse_source=model_parse_source, assistant_plan=assistant_plan, allow_scene_composition=allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, + ) + scene_label_only = assistant_plan is not None and allow_scene_composition + dynamic_mode_stop = ( + not scene_label_only and self._is_dynamic_mode_stop_request(utterance) ) - dynamic_mode_stop = self._is_dynamic_mode_stop_request(utterance) if dynamic_mode_stop: parsed = self._apply_dynamic_mode_stop_request(parsed, utterance) # Reuse the same conservative clause segmentation for the final @@ -2535,7 +2540,12 @@ class SeatDemoService: # comma between independent controls can leak an early target # (for example ``副驾``) into a later HVAC clause and can also # hide otherwise valid window/temperature actions. - segmented_utterance = ",".join( + # Name-only scene plans have already been checked against exact + # targets by _interpret. Their synthesized 进入 label is not + # another atomic instruction: reparsing 座椅舒适模式 here applies + # the unscoped-seat driver default and discards valid passenger + # actions. Explicit user instructions never enter this branch. + segmented_utterance = "" if scene_label_only else ",".join( self._split_intent_clauses(utterance) ) relative_resolution = ( @@ -2617,7 +2627,12 @@ class SeatDemoService: parsed, excluded_control_signatures, ) - parsed = apply_language_safety_guards(parsed, utterance) + parsed = apply_language_safety_guards( + parsed, + utterance, + scene_composition=assistant_plan is not None and allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, + ) parsed = self._partition_native_actions( parsed, promote_supported=( @@ -5474,6 +5489,11 @@ class SeatDemoService: and source_profile is None and not requested_instruction ), + # A revision combines a server-loaded draft with changes that + # already passed the ordinary text/target authorization path. + # Its old display name cannot veto an explicitly requested new + # seat. This override is internal, never accepted from payloads. + scene_scope_utterance="" if source_draft is not None else None, ) except NativeCanCompileError: # Repository modes and model-composed scenes share the same native @@ -7500,6 +7520,7 @@ class SeatDemoService: model_parse_source: str = "cloud_model", assistant_plan: dict[str, Any] | None = None, allow_scene_composition: bool = False, + scene_scope_utterance: str | 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: @@ -7561,6 +7582,7 @@ class SeatDemoService: ], trusted_local=trusted_local, allow_scene_composition=allow_scene_composition, + scene_scope_utterance=scene_scope_utterance, ) model = self.model_plan_adapter.adapt( guarded.plan, diff --git a/src/cognitive_agent/application/semantic_ir.py b/src/cognitive_agent/application/semantic_ir.py index 3e6ef35..3288d35 100644 --- a/src/cognitive_agent/application/semantic_ir.py +++ b/src/cognitive_agent/application/semantic_ir.py @@ -71,6 +71,28 @@ _SEAT_UNSUPPORTED_SCOPE = re.compile( r"左后|右后|前舱|后舱|左侧|右侧|左边|右边|" r"所有座椅|全部座椅|全车座椅" ) +_SCENE_NON_SEAT_SCOPED_DEVICE = re.compile( + rf"(?:{_SEAT_DRIVER_SCOPE.pattern}|{_SEAT_PASSENGER_SCOPE.pattern}|" + rf"{_SEAT_UNSUPPORTED_SCOPE.pattern})\s*(?:的)?\s*" + r"(?=空调|温度|风量|出风|风口|除霜|车窗|窗户|车门|门锁|" + r"阅读灯|氛围灯|灯光|遮阳帘|天幕)", + re.IGNORECASE, +) + + +def resolve_scene_seat_scopes(utterance: str) -> frozenset[str] | None: + """Keep a scene's explicit seat scope without borrowing an HVAC/light zone. + + A name such as 主驾舒适模式 still constrains seat actions even when it + omits 座椅. But 副驾空调与座椅舒适模式 does not authorize restricting + every seat action to the passenger. Only remove scope tokens directly + attached to another device; all negation text remains untouched. + """ + + scoped_text = _SCENE_NON_SEAT_SCOPED_DEVICE.sub( + "", utterance.replace("主副驾", "主驾和副驾") + ) + return resolve_explicit_seat_scopes(scoped_text, assume_seat_context=True) def resolve_explicit_seat_scopes( @@ -356,7 +378,13 @@ def _remove_conflicting_controls( result[field] = kept -def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict[str, Any]: +def apply_language_safety_guards( + parsed: dict[str, Any], + utterance: str, + *, + scene_composition: bool = False, + scene_scope_utterance: str | None = None, +) -> dict[str, Any]: """Preserve negation and unresolved control language without producing CAN.""" result = deepcopy(parsed) @@ -386,7 +414,25 @@ def apply_language_safety_guards(parsed: dict[str, Any], utterance: str) -> dict ] result["confidence"] = min(float(result.get("confidence", 0)), 0.8) - explicit_seat_scopes = resolve_explicit_seat_scopes(utterance) + explicit_seat_scopes = ( + resolve_scene_seat_scopes( + utterance if scene_scope_utterance is None else scene_scope_utterance + ) + if scene_composition + else resolve_explicit_seat_scopes(utterance) + ) + if scene_composition and not any( + isinstance(item, dict) + and ( + item.get("domain") == "seat" + or str(item.get("capability", "")).startswith("seat.") + ) + for key in _EXECUTION_LIST_FIELDS + for item in result.get(key, []) + ): + # A rear-window ventilation scene is not a rear-seat request. The + # scene guard already checks every seat candidate before adaptation. + explicit_seat_scopes = None if explicit_seat_scopes is not None: removed = _filter_explicit_seat_scope(result, explicit_seat_scopes) if removed: diff --git a/tests/e2e/mode_rail_browser.cjs b/tests/e2e/mode_rail_browser.cjs new file mode 100644 index 0000000..cbd1829 --- /dev/null +++ b/tests/e2e/mode_rail_browser.cjs @@ -0,0 +1,178 @@ +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const [baseURL, evidenceDir] = process.argv.slice(2); +const desktop = { width: 1600, height: 1000 }; + +async function main() { + const browser = await chromium.launch({ + headless: true, + ...(process.env.N7WEB_BROWSER_CHANNEL + ? { channel: process.env.N7WEB_BROWSER_CHANNEL } + : process.platform === "win32" ? { channel: "msedge" } : {}), + }); + const context = await browser.newContext({ viewport: desktop, serviceWorkers: "block" }); + const page = await context.newPage(); + const requests = []; + const errors = []; + const unexpectedWrites = []; + let heldResponse = null; + let nextReply = "success"; + const answer = (success) => [ + { type: "action.completed", success, payload: { + status: success ? "succeeded" : "failed", execution_mode: "dry_run", + } }, + { type: "answer.completed", answer: success ? "模式执行测试完成" : "模式执行测试失败" }, + ].map((item) => JSON.stringify(item)).join("\n") + "\n"; + const fulfill = (route, success = true) => route.fulfill({ + status: 200, contentType: "application/x-ndjson", body: answer(success), + }); + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ''}`); + }); + async function handleApiRoute(route) { + const pathname = new URL(route.request().url()).pathname; + if (pathname === '/api/hardware/seat-state') { + // This legacy GET may attempt automatic firmware recovery. Never touch + // the physical gateway while verifying browser interactions. + await route.fulfill({json:{status:'unavailable',hardware_execution_allowed:false}}); + return; + } + // The isolated server deliberately does not launch Codex or publish a + // physical display mirror. Keep these background integration probes local. + if (pathname === '/api/health' || pathname === '/api/extension/display-sync') { + await route.fulfill({json:{status:'ok',assistant:{status:'ready',available:true}}}); + return; + } + if (pathname === "/api/assistant/stream") { + requests.push(route.request().postDataJSON()); + if (nextReply === "hold") { heldResponse = route; return; } + const success = nextReply !== "fail"; + nextReply = "success"; + await fulfill(route, success); + return; + } + if (route.request().method() !== "GET") { + unexpectedWrites.push(route.request().url()); + await route.fulfill({ status: 403, body: "test forbids vehicle writes" }); + return; + } + await route.continue(); + } + await context.route("**/api/**", handleApiRoute); + const rail = page.locator(".mode-chips"); + const office = rail.locator('button[data-mode-profile-id="office"]'); + const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending()); + const waitState = (id, state) => page.waitForFunction( + ({ id, state }) => document.querySelector(`.mode-chips [data-mode-profile-id="${id}"]`)?.dataset.modeState === state, + { id, state }, + ); + async function expectOneCommand(action, command) { + const before = requests.length; + await action(); + await page.waitForFunction(() => document.querySelector('#utterance').getAttribute('aria-busy') === 'false'); + await waitIdle(); + assert.equal(requests.length, before + 1, "gesture must submit exactly once"); + assert.equal(requests.at(-1).message, command); + } + try { + await page.goto(baseURL, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); + await page.waitForFunction(() => document.body.classList.contains('assistant-chat-enabled')); + assert.equal(await page.title(), "511工程-智能座舱"); + assert.equal(new URL(page.url()).pathname, "/"); + assert.ok((await page.locator('body').innerText()).includes("整车孪生界面")); + assert.ok(await rail.evaluate((element) => element.scrollWidth > element.clientWidth)); + + // This is a real mouse sequence: pointerdown -> pointerup -> click. The + // prior pointer capture bug cannot be detected by button.dispatchEvent(). + await page.evaluate(() => window.CognitiveAgentAssistant.close()); + await expectOneCommand(() => office.click(), "进入办公模式"); + await waitState("office", "succeeded"); + assert.equal(await page.locator('#assistantDrawerToggle').getAttribute('aria-expanded'), 'true'); + assert.ok((await page.locator('#assistantMessages').innerText()).includes('进入办公模式')); + + // A small physical hand movement is still a click. + const box = await office.boundingBox(); + await expectOneCommand(async () => { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2); + await page.mouse.up(); + }, "进入办公模式"); + + // Dragging from a card must scroll, without accidentally running it. + const beforeDrag = requests.length; + const startScroll = await rail.evaluate((element) => element.scrollLeft); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 - 90, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + assert.ok(await rail.evaluate((element) => element.scrollLeft) > startScroll); + assert.equal(requests.length, beforeDrag); + await rail.evaluate((element) => { element.scrollLeft = 0; }); + await expectOneCommand(() => office.click(), "进入办公模式"); + + // Keyboard activation and management cards share the same submission path. + await office.focus(); + await expectOneCommand(() => page.keyboard.press('Enter'), "进入办公模式"); + await office.focus(); + await expectOneCommand(() => page.keyboard.press('Space'), "进入办公模式"); + await page.locator('#modeGalleryToggle').click(); + await expectOneCommand(() => page.locator('#modeGalleryGroups button[data-mode-profile-id="nap"]').click(), "进入小憩模式"); + await waitState("nap", "succeeded"); + + // Busy requests are not duplicated, nor does another card cancel them. + nextReply = 'hold'; + const beforeBusy = requests.length; + await office.click(); + await waitState('office', 'running'); + await office.click(); + assert.equal(requests.length, beforeBusy + 1); + assert.equal(await page.evaluate(() => window.CognitiveAgentAssistant.isPending()), true); + await fulfill(heldResponse); + nextReply = 'success'; + await waitIdle(); + await waitState('office', 'succeeded'); + nextReply = 'fail'; + await expectOneCommand(() => office.click(), "进入办公模式"); + await waitState('office', 'failed'); + + // A catalog with few visible cards must also work without overflow. + await rail.evaluate(element => { + for (const child of element.children) { + if (child.dataset.modeProfileId !== 'office') child.style.display = 'none'; + } + }); + await page.waitForFunction(() => {const e=document.querySelector('.mode-chips');return e.scrollWidth <= e.clientWidth;}); + await expectOneCommand(() => office.click(), "进入办公模式"); + await rail.evaluate(element => { + for (const child of element.children) child.style.removeProperty('display'); + }); + await page.setViewportSize(desktop); + await page.screenshot({path:path.join(evidenceDir,'mode-rail-desktop.png')}); + assert.deepEqual(errors, []); + assert.deepEqual(unexpectedWrites, []); + + // Touch input uses native scrolling and also submits once when tapped. + const touchContext = await browser.newContext({viewport:{width:720,height:1000},hasTouch:true,serviceWorkers:'block'}); + await touchContext.route('**/api/**', handleApiRoute); + const touchPage = await touchContext.newPage(); + await touchPage.goto(baseURL, {waitUntil:'domcontentloaded'}); + await touchPage.waitForFunction(() => document.querySelectorAll('.mode-chips button').length > 3); + const beforeTouch = requests.length; + await touchPage.locator('.mode-chips button[data-mode-profile-id="office"]').tap(); + await touchPage.waitForFunction(() => document.querySelector('.mode-chips [data-mode-profile-id="office"]').dataset.modeState === 'succeeded'); + assert.equal(requests.length,beforeTouch+1); + assert.equal(requests.at(-1).message,'进入办公模式'); + await touchPage.screenshot({path:path.join(evidenceDir,'mode-rail-touch.png')}); + await touchContext.close(); + console.log('PASS: click, jitter, drag, Enter, Space, gallery, busy, failure, resize, touch; no runtime errors'); + } finally { + await browser.close(); + } +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/e2e/test_mode_rail_browser.py b/tests/e2e/test_mode_rail_browser.py new file mode 100644 index 0000000..8d4bc3a --- /dev/null +++ b/tests/e2e/test_mode_rail_browser.py @@ -0,0 +1,86 @@ +"""Exercise real pointer/click dispatch on the complete console in Chromium.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +from http import HTTPStatus +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.seat_service import SeatDemoService +from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler + + +def test_mode_rail_real_browser_interactions(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + pytest.skip("Node.js is required for the browser interaction regression") + env = dict(os.environ) + bundled_modules = Path(node).resolve().parent.parent / "node_modules" + if bundled_modules.is_dir(): + env["NODE_PATH"] = os.pathsep.join( + item for item in (str(bundled_modules), env.get("NODE_PATH")) if item + ) + available = subprocess.run( + [node, "-e", "require.resolve('playwright')"], + env=env, + capture_output=True, + encoding="utf-8", + check=False, + ) + if available.returncode: + pytest.skip("Playwright is required for the real browser regression") + with patch.dict( + os.environ, + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + }, + ): + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + + class BrowserHandler(SeatDemoHandler): + auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False)) + + def do_POST(self) -> None: # noqa: N802 + # Only GET reaches this test server. Browser requests to the + # assistant are intercepted; unexpected writes fail closed. + self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN) + + def log_message(self, *_args: object) -> None: + pass + + BrowserHandler.service = service + server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + completed = subprocess.run( + [ + node, + str(Path(__file__).with_name("mode_rail_browser.cjs")), + f"http://127.0.0.1:{server.server_port}", + str(tmp_path), + ], + env=env, + capture_output=True, + encoding="utf-8", + timeout=120, + check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + service.close() diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 1c19d9d..256b714 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -621,7 +621,7 @@ 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 @@ -656,7 +656,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( @@ -697,7 +697,7 @@ 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) @@ -718,8 +718,8 @@ operations: self.assertNotIn("滚动查看更多", html) 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('href="/assistant-chat.css?v=codex-assistant-27"', 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) @@ -992,14 +992,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('const CACHE_VERSION = "cognitive-agent-pad-v278"', 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('/assistant-chat.css?v=codex-assistant-26', service_worker) - self.assertIn('/assistant-chat.js?v=codex-assistant-26', service_worker) + self.assertIn('/assistant-chat.css?v=codex-assistant-27', 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) @@ -1456,7 +1456,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") diff --git a/tests/integration/test_scene_explicit_seat_scope.py b/tests/integration/test_scene_explicit_seat_scope.py new file mode 100644 index 0000000..2ea74fa --- /dev/null +++ b/tests/integration/test_scene_explicit_seat_scope.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService +from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes + + +@pytest.fixture(scope="module") +def scene_service(tmp_path_factory): + service = SeatDemoService() + service.artifact_root = tmp_path_factory.mktemp("scene-explicit-seat-scope") + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path_factory.mktemp("scene-explicit-seat-user-modes"), + ) + try: + yield service, AssistantCapabilityCatalog(service).scene_composition_context() + finally: + service.close() + + +def _plan(context, scopes): + requests = [] + if "driver" in scopes: + requests.append({ + "capability": "seat.set_driver_massage_native", + "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}, + }) + if "passenger" in scopes: + requests.append({ + "capability": "seat.set_backrest_recline", + "target_scope": "passenger", + "parameters": {"position_pct": 45}, + }) + requests.append({ + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "parameters": {"temperature_c": 24}, + }) + return normalize_scene_output({ + "requests": requests, "ambiguities": [], "confidence": 0.95, + }, context) + + +def _preview( + scene_service, message, scopes=("driver", "passenger"), *, execution_mode="hardware", +): + service, context = scene_service + with patch.object( + service.hardware_execution, "reserve", + side_effect=AssertionError("scene scope validation must not execute hardware"), + ): + return service.preview_native_can( + message, + provider="local", + conversation_id="conv-explicit-scene-seat-scope", + execution_mode=execution_mode, + vehicle_id="denza_n7", + assistant_plan=_plan(context, scopes), + ) + + +@pytest.mark.parametrize("message", ( + "构建一个主驾座椅舒适模式", + "创建一个仅主驾舒适模式", + "创建一个只给驾驶员坐着放松的功能模式", + "构建一个副驾座椅舒适模式", + "构建一个仅副驾舒适模式", + "构建一个右前座椅舒适模式", + "构建一个后排座椅舒适模式", + "构建一个主驾座椅与副驾空调舒适模式", + "构建一个主驾阅读灯与副驾座椅舒适模式", +)) +def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_service, message): + result = _preview(scene_service, message) + assert result["status"] != "succeeded" + assert result.get("mode_profile_draft") is None + assert result["hardware_execution_allowed"] is False + assert result.get("command_plan") is None + assert result.get("command_plans") == {} + intent = result.get("intent_ir") or {} + assert (intent.get("spec") or {}).get("actions", []) == [] + assert any("座位范围" in item["message"] for item in result["validation"]) + + +@pytest.mark.parametrize(("message", "scopes"), ( + ("构建一个主驾座椅舒适模式", ("driver",)), + ("构建一个副驾座椅舒适模式", ("passenger",)), + ("创建一个仅主驾舒适模式", ("driver",)), + ("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")), + ("构建一个主副驾座椅舒适模式", ("driver", "passenger")), + ("构建一个云端座椅舒适模式", ("driver", "passenger")), + ("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")), + ("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")), + ("构建一个主驾座椅与副驾空调舒适模式", ("driver",)), + ("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)), +)) +def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_zones( + scene_service, message, scopes, +): + result = _preview(scene_service, message, scopes) + assert result["status"] == "succeeded", result.get("validation") + assert result["mode_profile_draft"] + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + actions = result["intent_ir"]["spec"]["actions"] + assert { + action["target"]["instance"] + for action in actions if action["domain"] == "seat" + } == set(scopes) + + +@pytest.mark.parametrize("message", ( + "构建一个主驾舒适模式,副驾不要动", + "构建一个主驾舒适模式,不要动副驾", + "构建一个舒展模式,座椅靠背调整到45%", +)) +def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation( + scene_service, message, +): + result = _preview(scene_service, message, execution_mode="dry_run") + assert result["status"] != "succeeded" + assert result.get("mode_profile_draft") is None + assert result["hardware_execution_allowed"] is False + + +def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service): + result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%") + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + actions = result["intent_ir"]["spec"]["actions"] + assert len(actions) == 1 + assert actions[0]["capability"] == "seat.set_backrest_recline" + assert actions[0]["target"]["instance"] == "passenger" + assert actions[0]["parameters"]["position_pct"] == 45 + + +def test_rear_window_scene_is_not_rejected_as_an_unsupported_seat(scene_service): + service, context = scene_service + plan = normalize_scene_output({ + "requests": [{ + "capability": "window.set_position", + "target_scope": "rear_right", + "parameters": {"position_pct": 45}, + }], + "ambiguities": [], "confidence": 0.95, + }, context) + result = service.preview_native_can( + "构建一个后排换气模式", provider="local", + conversation_id="conv-rear-window-not-seat", + execution_mode="dry_run", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert result["status"] == "succeeded" + assert result["mode_profile_draft"] + + +@pytest.mark.parametrize(("text", "expected"), ( + ("进入主驾舒适模式", frozenset({"driver"})), + ("进入仅副驾舒适模式", frozenset({"passenger"})), + ("进入主副驾座椅舒适模式", frozenset({"driver", "passenger"})), + ("进入主驾座椅与副驾的空调舒适模式", frozenset({"driver"})), + ("进入副驾座椅与主驾的阅读灯模式", frozenset({"passenger"})), + ("进入座椅舒适与右前车窗模式", None), + ("进入座椅舒适与后排阅读灯模式", None), + ("进入后排座椅舒适模式", frozenset({"unsupported"})), +)) +def test_scene_scope_resolution_only_uses_seat_or_unqualified_scene_targets(text, expected): + assert resolve_scene_seat_scopes(text) == expected + + +def test_new_creation_cannot_borrow_draft_reference_to_bypass_explicit_scope(scene_service): + service, context = scene_service + built = _preview(scene_service, "构建一个主驾独享舒适模式", ("driver",)) + reference = {key: built["mode_profile_draft"][key] for key in ( + "name", "source_request_id", "vehicle_id", + )} + result = service.preview_native_can( + "构建一个主驾独享舒适模式", provider="local", + conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run", + vehicle_id="denza_n7", assistant_plan=_plan(context, ("driver", "passenger")), + mode_profile_draft_context=reference, + ) + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + + +@pytest.mark.parametrize(("initial_scope", "name", "change", "added_capability"), ( + ("driver", "主驾释压舒适模式", "副驾座椅通风调整为1档", "seat.set_ventilation"), + ("passenger", "副驾释压舒适模式", "主驾座椅通风调整为低档", "seat.set_driver_ventilation_native"), +)) +def test_explicit_revision_can_extend_old_label_scope_then_save_and_replay( + scene_service, initial_scope, name, change, added_capability, +): + service, _context = scene_service + built = _preview(scene_service, f"构建一个{name}", (initial_scope,)) + source_draft = built["mode_profile_draft"] + reference = {key: source_draft[key] for key in ( + "name", "source_request_id", "vehicle_id", + )} + revised = service.preview_native_can( + f"把{name}的{change}", provider="local", + conversation_id="conv-explicit-scene-seat-scope", execution_mode="dry_run", + vehicle_id="denza_n7", mode_profile_draft_context=reference, + ) + assert revised["status"] == "succeeded", revised.get("validation") + draft = revised["mode_profile_draft"] + assert draft["generation_source"] == "draft_revision" + assert draft["operation_count"] == source_draft["operation_count"] + 1 + actions = revised["intent_ir"]["spec"]["actions"] + assert added_capability in {action["capability"] for action in actions} + assert { + action["target"]["instance"] for action in actions if action["domain"] == "seat" + } == {"driver", "passenger"} + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + replayed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert replayed["status"] == "succeeded", replayed.get("validation") + assert { + (action["capability"], action["target"]["instance"]) + for action in replayed["intent_ir"]["spec"]["actions"] if action["domain"] == "seat" + } == { + (action["capability"], action["target"]["instance"]) + for action in actions if action["domain"] == "seat" + } diff --git a/tests/integration/test_scene_template_hybrid.py b/tests/integration/test_scene_template_hybrid.py index a71f01a..4f09efd 100644 --- a/tests/integration/test_scene_template_hybrid.py +++ b/tests/integration/test_scene_template_hybrid.py @@ -12,6 +12,7 @@ import pytest from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog from cognitive_agent.application.codex_assistant import CodexAssistantService from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService @@ -94,6 +95,7 @@ def _assistant_preview( client.available = False assistant = CodexAssistantService(client) snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast_answer = catalog.fast_answer(message, snapshot) events: list[dict[str, object]] = [] raw_results: list[dict[str, object]] = [] passed_plans: list[dict[str, object] | None] = [] @@ -122,6 +124,7 @@ def _assistant_preview( conversation_id=conversation_id, principal="scene-template-hybrid-test", history=[], + authoritative_answer=fast_answer.answer if fast_answer else None, emit=lambda event: events.append(event) or True, execute_control=execute_control, capability_snapshot=snapshot, @@ -141,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( templates = snapshot["scene_templates"] assert isinstance(templates, list) - assert len(templates) == 12 + assert len(templates) == 13 assert all(template["assistant_callable"] is True for template in templates) with patch.object( @@ -339,6 +342,115 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults( client.start_turn.assert_not_called() +@pytest.mark.parametrize( + ("message", "expected_name", "template_id"), + ( + ("构建一个座椅舒适的功能模式", "座椅舒适模式", "seat-comfort"), + ("构建一个让人坐着放松的功能模式", "坐着放松模式", "relaxation"), + ("请帮我创建一个适合乘坐舒适的模式吧", "乘坐舒适模式", "seat-comfort"), + ("帮我构建一个让我们坐着舒服的功能模式", "坐着舒服模式", "seat-comfort"), + ("创建一个座椅放松模式并预览", "座椅放松模式", "relaxation"), + ("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"), + ), +) +def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], + message: str, + expected_name: str, + template_id: str, +) -> None: + service, catalog, user_root = hybrid_service + with patch.object( + service.hardware_execution, + "reserve", + side_effect=AssertionError("seat comfort preview reached hardware execution"), + ): + response, result, _events, client, _plans = _assistant_preview( + service, catalog, message, conversation_id="conv-seat-comfort-preview", + ) + assert response["route"] == "scene_template" + assert response["success"] is True + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["mode_profile_draft"]["name"] == expected_name + assert result["mode_profile_draft"]["source_template_id"] == template_id + seat_actions = { + (capability, target): parameters + for (capability, target), parameters in _action_map(result).items() + if capability.startswith("seat.") + } + assert {target for _capability, target in seat_actions} == {"driver", "passenger"} + assert all( + "driver_axis" not in capability and "driver_position" not in capability + for capability, _target in seat_actions + ) + assert ("seat.set_driver_massage_native", "passenger") not in seat_actions + if template_id in {"seat-comfort", "long-trip-comfort"}: + assert seat_actions["seat.set_driver_ventilation_native", "driver"] == { + "state": "on", "level": 2, + } + assert seat_actions["seat.set_ventilation", "passenger"] == { + "state": "on", "level": 1, + } + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("name", ("云端座椅舒展模式", "晚间乘坐释压模式")) +def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merge( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], + name: str, +) -> None: + service, catalog, user_root = hybrid_service + plan = normalize_scene_output({ + "requests": [ + {"capability": "seat.set_driver_massage_native", "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}}, + {"capability": "seat.set_driver_heating_native", "target_scope": "driver", + "parameters": {"state": "on", "level": 2}}, + {"capability": "seat.set_backrest_recline", "target_scope": "passenger", + "parameters": {"position_pct": 45}}, + {"capability": "seat.set_heating", "target_scope": "passenger", + "parameters": {"state": "on", "level": 1}}, + ], + "ambiguities": [], "confidence": 0.95, + }, catalog.scene_composition_context()) + with patch.object( + service.hardware_execution, "reserve", + side_effect=AssertionError("scene composition reached hardware execution"), + ): + result = service.preview_native_can( + f"构建一个{name}", provider="local", conversation_id="conv-seat-generated", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert result["status"] == "succeeded" + assert result["execution_mode"] == "dry_run" + assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition" + assert set(_action_map(result)) == { + ("seat.set_driver_massage_native", "driver"), + ("seat.set_driver_heating_native", "driver"), + ("seat.set_backrest_recline", "passenger"), + ("seat.set_heating", "passenger"), + } + assert not list(user_root.rglob("*.yaml")) + + +def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard( + hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], +) -> None: + service, _catalog, _user_root = hybrid_service + result = service.preview_native_can( + "构建一个舒展模式,座椅靠背调整到45%", provider="local", + conversation_id="conv-seat-explicit-target", execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert result["status"] != "succeeded" + assert result.get("mode_profile_draft") is None + assert result["hardware_execution_allowed"] is False + + def test_template_metadata_cannot_authorize_modified_or_stale_template_actions( hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], ) -> None: diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py index e4e35e7..1168912 100644 --- a/tests/unit/test_assistant_capability_catalog.py +++ b/tests/unit/test_assistant_capability_catalog.py @@ -358,7 +358,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries( templates = snapshot["scene_templates"] assert snapshot["source_status"]["scene_templates"] == "ready" - assert len(templates) == 12 + assert len(templates) == 13 assert {item["name"] for item in templates} >= { "观影模式", "露营模式", diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index 15205ec..cc938fe 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface(): assert 'aria-label="AI助手对话"' in html 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 'href="/assistant-chat.css?v=codex-assistant-27"' 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 '"/assistant-chat.css?v=codex-assistant-26"' in service_worker - assert '"/assistant-chat.js?v=codex-assistant-26"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker + assert '"/assistant-chat.css?v=codex-assistant-27"' 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(): diff --git a/tests/unit/test_assistant_mode_draft_rows.py b/tests/unit/test_assistant_mode_draft_rows.py new file mode 100644 index 0000000..bb83d28 --- /dev/null +++ b/tests/unit/test_assistant_mode_draft_rows.py @@ -0,0 +1,177 @@ +"""Execute the mode preview formatter against actual seat/light parameter shapes.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def render_rows(): + node = shutil.which("node") + if not node: + runtime = ( + Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" + / "dependencies" / "node" / "bin" + ) + node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None) + assert node, "Node.js is required to validate rendered mode action text" + source = (ROOT / "apps" / "web-console" / "assistant-chat.js").read_text(encoding="utf-8") + start = source.index(" function modeDraftRows(") + end = source.index("\n function ", start + 1) + script = source[start:end] + "\nconst input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));\n" + script += "console.log(JSON.stringify(modeDraftRows(input.draft, input.actions)));" + + def render(actions, instruction=""): + completed = subprocess.run( + [node, "-e", script], + input=json.dumps({"draft": {"instruction": instruction}, "actions": actions}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(completed.stdout) + + return render + + +def action(capability, parameters, zone="driver"): + return { + "domain": capability.split(".")[0], + "capability": capability, + "target": {"zone": zone}, + "parameters": parameters, + } + + +@pytest.mark.parametrize("kind", ["ventilation", "heating"]) +@pytest.mark.parametrize( + ("parameters", "expected"), + [ + ({"state": "on", "level": 1}, "开启 · 高档"), + ({"state": "on", "level": 2}, "开启 · 低档"), + ({"state": "on"}, "开启 · 低档"), + ({"state": "off", "level": 1}, "关闭"), + ], +) +def test_driver_native_comfort_uses_its_own_level_scale(render_rows, kind, parameters, expected): + rows = render_rows([action(f"seat.set_driver_{kind}_native", parameters, "front_left")]) + assert rows[0]["label"] == f"主驾座椅{'通风' if kind == 'ventilation' else '加热'}" + assert rows[0]["value"] == expected + + +@pytest.mark.parametrize("kind", ["ventilation", "heating"]) +@pytest.mark.parametrize("level", [1, 2, 3, 4, 5]) +def test_passenger_levels_are_not_translated_using_driver_can_codes(render_rows, kind, level): + rows = render_rows([action(f"seat.set_{kind}", {"state": "on", "level": level}, "passenger")]) + assert rows[0]["label"] == f"副驾座椅{'通风' if kind == 'ventilation' else '加热'}" + assert rows[0]["value"] == f"开启 · {level}档" + + +@pytest.mark.parametrize( + ("parameters", "expected"), + [ + ({"action": "off", "mode": "relax"}, "关闭"), + ({"action": "mode", "mode": "wave"}, "波浪模式"), + ({"action": "mode", "mode": "pulse"}, "脉冲模式"), + ({"action": "mode", "mode": "stretch"}, "舒展模式"), + ({"action": "mode", "mode": "slack"}, "舒缓模式"), + ({"action": "mode", "mode": "relax"}, "放松模式"), + ({"action": "strength", "strength": 1}, "轻柔力度"), + ({"action": "strength", "strength": 2}, "中等力度"), + ({"action": "strength", "strength": 3}, "较强力度"), + ({"action": "mode"}, "按摩模式待确认"), + ({"action": "strength"}, "按摩力度待确认"), + ], +) +def test_massage_preview_distinguishes_operation_mode_and_strength(render_rows, parameters, expected): + rows = render_rows([action("seat.set_driver_massage_native", parameters)]) + assert rows[0]["label"] == "主驾座椅按摩" + assert rows[0]["value"] == expected + + +def test_ambient_native_color_and_brightness_remain_distinct_rows(render_rows): + rows = render_rows([ + action("ambient_light.set_color_index", {"color_index": 127}, "vehicle"), + action("ambient_light.set_brightness_level", {"brightness_level": 2}, "vehicle"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("氛围灯颜色", "色号 127"), + ("氛围灯亮度", "2档"), + ] + + +@pytest.mark.parametrize("brightness", [0, 20, 100]) +def test_rgb_illumination_keeps_exact_color_and_brightness(render_rows, brightness): + rows = render_rows([ + action("ambient_light.set_illumination", {"color_hex": "#FFB347", "brightness_pct": brightness}, "vehicle"), + ]) + assert rows[0]["value"] == f"颜色 #FFB347 · 亮度 {brightness}%" + + +def test_zero_gravity_grouping_keeps_driver_comfort_visible(render_rows): + rows = render_rows([ + action("seat.set_longitudinal_position", {"position_pct": 100}, "passenger"), + action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "front_right"), + action("seat.set_driver_massage_native", {"action": "mode", "mode": "relax"}), + ], "副驾零重力模式,并开启主驾放松按摩") + assert [(row["label"], row["value"]) for row in rows] == [ + ("副驾座椅", "零重力模式"), + ("主驾座椅按摩", "放松模式"), + ] + + +def test_position_and_power_rows_keep_existing_values(render_rows): + rows = render_rows([ + action("seat.set_backrest_recline", {"position_pct": 81, "angle_deg": 150}, "passenger"), + action("ambient_light.set_power", {"state": "off"}, "vehicle"), + action("seat.set_ventilation", {"state": "off", "level": 2}, "passenger"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("副驾靠背角度", "81%(150°)"), + ("氛围灯", "关闭"), + ("副驾座椅通风", "关闭"), + ] + + +@pytest.mark.parametrize( + ("capability", "mode", "label", "value"), + [ + ("climate.set_control_mode", "auto", "空调控制模式", "自动控制"), + ("climate.set_control_mode", "manual", "空调控制模式", "手动控制"), + ("climate.set_cycle_mode", "internal", "空调循环", "内循环"), + ("climate.set_cycle_mode", "external", "空调循环", "外循环"), + ("climate.set_outlet_mode", "face", "空调出风模式", "吹面"), + ("climate.set_outlet_mode", "face_feet", "空调出风模式", "吹面 / 吹脚"), + ("climate.set_outlet_mode", "feet", "空调出风模式", "吹脚"), + ("climate.set_outlet_mode", "feet_defrost", "空调出风模式", "吹脚 / 除霜"), + ("climate.set_outlet_mode", "defrost", "空调出风模式", "除霜"), + ("climate.set_outlet_mode", "face_feet_defrost", "空调出风模式", "吹面 / 吹脚 / 除霜"), + ("climate.set_outlet_mode", "face_defrost", "空调出风模式", "吹面 / 除霜"), + ], +) +def test_climate_modes_use_capability_specific_labels(render_rows, capability, mode, label, value): + rows = render_rows([action(capability, {"mode": mode}, "vehicle")]) + assert (rows[0]["label"], rows[0]["value"]) == (label, value) + + +def test_climate_control_circulation_and_outlet_are_not_conflated(render_rows): + rows = render_rows([ + action("climate.set_control_mode", {"mode": "auto"}, "vehicle"), + action("climate.set_cycle_mode", {"mode": "internal"}, "vehicle"), + action("climate.set_outlet_mode", {"mode": "face_feet"}, "vehicle"), + action("climate.set_fan_level", {"level": 2}, "vehicle"), + ]) + assert [(row["label"], row["value"]) for row in rows] == [ + ("空调控制模式", "自动控制"), + ("空调循环", "内循环"), + ("空调出风模式", "吹面 / 吹脚"), + ("空调风量", "2档"), + ] diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 33af131..5395ac1 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: ): assert asset in html assert f'"{asset}"' in service_worker - assert html.index("/assistant-chat.js?v=codex-assistant-26") < html.index( + assert html.index("/assistant-chat.js?v=codex-assistant-27") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) - assert 'const CACHE_VERSION = "cognitive-agent-pad-v277"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None: diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index cb5e655..8114dc0 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: ): 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-v278"' in service_worker def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py index 288b867..bb3b733 100644 --- a/tests/unit/test_mode_gallery_grouping.py +++ b/tests/unit/test_mode_gallery_grouping.py @@ -5,6 +5,8 @@ import shutil import subprocess from pathlib import Path +import pytest + from cognitive_agent.application.mode_profile import ModeProfileRegistry ROOT = Path(__file__).resolve().parents[2] @@ -285,6 +287,7 @@ const context = { setModeRailSelection(mode) { calls.push(["select", mode?.id]); }, setModeExecutionState(modeId, status) { calls.push(["state", modeId, status]); }, closeModeGallery(options) { calls.push(["close", options.restoreFocus]); }, + hideModeActionPopover() {}, showToast(message) { calls.push(["toast", message]); }, $(selector) { return { "#utterance": input, "#requestForm": form, "#runButton": runButton }[selector]; @@ -355,6 +358,44 @@ console.log(JSON.stringify({ result: context.result, calls })); } +@pytest.mark.parametrize(("phase", "input_mode"), ( + ("capturing", "manual"), + ("capturing", "wake"), + ("finalizing", "manual"), + ("finalizing", "wake"), + ("arming", "manual"), +)) +def test_mode_click_during_dictation_does_not_create_stuck_running_state( + phase: str, input_mode: str, +) -> None: + app = (WEB / "app.js").read_text(encoding="utf-8") + start = app.index("function applyModeCommand") + end = app.index("\n\nfunction applyRuntimeTargetPolicy", start) + script = "const snippet = " + json.dumps(app[start:end]) + ";\n" + script += "const speech = " + json.dumps({"phase": phase, "inputMode": input_mode}) + ";\n" + script += r""" +const vm = require("node:vm"); +const calls = []; +const context = { + window: { + CognitiveAgentAssistant: { isPending() { return false; } }, + CognitiveAgentSpeech: speech, + }, + showToast(message) { calls.push(message); }, +}; +vm.createContext(context); +vm.runInContext(snippet + '\nthis.result = applyModeCommand("进入办公模式", "office");', context); +console.log(JSON.stringify({ result: context.result, calls })); +""" + completed = subprocess.run( + [node_runtime(), "-e", script], check=True, capture_output=True, encoding="utf-8", + ) + assert json.loads(completed.stdout) == { + "result": False, + "calls": ["请等待语音整理完成后再发送"], + } + + def test_top_and_management_mode_cards_share_the_assistant_submission_entrypoint() -> None: app = (WEB / "app.js").read_text(encoding="utf-8") install_start = app.index('document.addEventListener("DOMContentLoaded"') diff --git a/tests/unit/test_scene_composer_capability_context.py b/tests/unit/test_scene_composer_capability_context.py index 7de9877..e92d573 100644 --- a/tests/unit/test_scene_composer_capability_context.py +++ b/tests/unit/test_scene_composer_capability_context.py @@ -36,6 +36,9 @@ EXPECTED_SCENE_CAPABILITIES = { "seat.set_legrest_height", "seat.set_longitudinal_position", "seat.set_ventilation", + "seat.set_driver_ventilation_native", + "seat.set_driver_heating_native", + "seat.set_driver_massage_native", "window.set_position", } diff --git a/tests/unit/test_scene_template_semantic_aliases.py b/tests/unit/test_scene_template_semantic_aliases.py index eb4edda..d093c4e 100644 --- a/tests/unit/test_scene_template_semantic_aliases.py +++ b/tests/unit/test_scene_template_semantic_aliases.py @@ -15,6 +15,8 @@ ROOT = Path(__file__).resolve().parents[2] ( ("帮我构建一个适合看书的功能模式", ("看书模式", "")), ("构建一个适合放松的功能模式", ("放松模式", "")), + ("构建一个座椅舒适的功能模式", ("座椅舒适模式", "")), + ("构建一个让人坐着放松的功能模式", ("坐着放松模式", "")), ("请帮我创建适合阅读的模式吧", ("阅读模式", "")), ("我想生成一个用来读书的模式并预览", ("读书模式", "")), ("请生成用于放松的模式,先看看", ("放松模式", "")), @@ -67,6 +69,8 @@ def test_explicit_names_are_never_rewritten( ("帮我构建一个适合看书的功能模式", "reading"), ("请创建一个用于阅读的功能模式", "reading"), ("构建一个适合放松的功能模式", "relaxation"), + ("构建一个座椅舒适的功能模式", "seat-comfort"), + ("构建一个让人坐着放松的功能模式", "relaxation"), ("我想创建一套用来舒缓的模式", "relaxation"), ("帮我构建一个适合看电影的功能模式", "cinema"), ), diff --git a/tests/unit/test_seat_comfort_scene_contract.py b/tests/unit/test_seat_comfort_scene_contract.py new file mode 100644 index 0000000..2e0c73d --- /dev/null +++ b/tests/unit/test_seat_comfort_scene_contract.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest +from jsonschema import Draft202012Validator + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.mode_profile_intent import parse_mode_profile_create +from cognitive_agent.application.scene_composer import build_scene_output_schema +from cognitive_agent.application.seat_service import SeatDemoService + + +@pytest.fixture(scope="module") +def catalog(): + service = SeatDemoService() + try: + yield AssistantCapabilityCatalog(service) + finally: + service.close() + + +@pytest.mark.parametrize("message", ( + "构建一个座椅舒适的功能模式", + "构建一个让人坐着放松的功能模式", + "可以帮我创建一个适合座椅放松的功能模式吗?", + "构建一个主驾按摩的功能模式", + "创建一个空调舒适的功能模式", + "构建一个适合看书的功能模式,阅读灯打开", +)) +def test_mode_creation_is_not_claimed_by_capability_fast_answer(catalog, message): + assert parse_mode_profile_create(message) is not None + assert catalog.fast_answer(message) is None + + +@pytest.mark.parametrize("message", ( + "支持主驾座椅按摩吗?", + "主驾与副驾座椅有哪些功能?", + "座椅通风支持哪些档位?", + "副驾座椅支持按摩吗?", +)) +def test_genuine_seat_questions_remain_read_only_capability_answers(catalog, message): + assert parse_mode_profile_create(message) is None + assert catalog.fast_answer(message) is not None + + +@pytest.mark.parametrize("message", ( + "不要构建座椅舒适模式", + "构建一个座椅舒适模式,但不要执行", + "取消创建坐着放松模式", + "如何构建一个座椅舒适模式?", + "解释构建一个座椅舒适的功能模式这句话", +)) +def test_negations_and_read_only_seat_mode_requests_never_authorize_creation(message): + assert parse_mode_profile_create(message) is None + + +def _response(capability, target, parameters): + return { + "requests": [{"capability": capability, "target_scope": target, "parameters": parameters}], + "ambiguities": [], + "confidence": 0.95, + } + + +@pytest.mark.parametrize(("capability", "target", "parameters", "valid"), ( + ("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 2}, True), + ("seat.set_driver_heating_native", "driver", {"state": "on", "level": 1}, True), + ("seat.set_driver_heating_native", "front_left", {"state": "off"}, True), + ("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"}, True), + ("seat.set_driver_massage_native", "driver", {"action": "strength", "strength": 1}, True), + ("seat.set_driver_massage_native", "driver", {"action": "off"}, True), + ("seat.set_driver_massage_native", "passenger", {"action": "mode", "mode": "relax"}, False), + ("seat.set_driver_massage_native", "driver", {"action": "mode"}, False), + ("seat.set_driver_massage_native", "driver", {"action": "mode", "strength": 1}, False), + ("seat.set_driver_massage_native", "driver", {"action": "off", "mode": "relax"}, False), + ("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax", "strength": 1}, False), + ("seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "unknown"}, False), + ("seat.set_driver_heating_native", "passenger", {"state": "on", "level": 2}, False), + ("seat.set_driver_ventilation_native", "driver", {"state": "on", "level": 3}, False), + ("seat.set_driver_axis_native", "driver", {"axis": "backrest", "direction": "backward", "duration_ms": 500}, False), + ("seat.set_driver_position_native", "driver", {"slot": 1}, False), + ("seat.set_backrest_recline", "driver", {"position_pct": 45}, False), +)) +def test_scene_schema_respects_real_driver_and_passenger_capabilities( + catalog, capability, target, parameters, valid, +): + context = catalog.scene_composition_context() + validator = Draft202012Validator(build_scene_output_schema(context)) + assert validator.is_valid(_response(capability, target, parameters)) is valid + + +def test_scene_context_removes_driver_capabilities_when_runtime_marks_them_unavailable(catalog): + snapshot = deepcopy(catalog.snapshot()) + for capability in snapshot["capabilities"]: + if capability["id"].startswith("seat.set_driver_"): + capability["assistant_callable"] = False + context = catalog.scene_composition_context(snapshot) + assert not any(item["id"].startswith("seat.set_driver_") for item in context["capabilities"]) + validator = Draft202012Validator(build_scene_output_schema(context)) + assert not validator.is_valid(_response( + "seat.set_driver_massage_native", "driver", {"action": "mode", "mode": "relax"}, + )) + + +def test_seat_comfort_detail_answer_uses_seat_specific_natural_language(catalog): + answer = catalog.fast_answer("座椅舒适模式会控制哪些设备?") + assert answer is not None + assert "主驾座椅通风开启低档" in answer.answer + assert "主驾座椅按摩设为放松模式" in answer.answer + assert "副驾座椅通风" in answer.answer + assert "副驾座椅靠背" in answer.answer + assert "action=" not in answer.answer + assert "主驾主驾" not in answer.answer From d8dad22ec030d145c69e4b82bea7fb74ddcdc932 Mon Sep 17 00:00:00 2001 From: vee Date: Sat, 12 Sep 2026 14:56:30 +0800 Subject: [PATCH 2/2] feat: add seat-scoped static and dynamic assistant modes --- apps/web-console/app.js | 230 +++++- apps/web-console/assistant-chat.css | 105 +++ apps/web-console/assistant-chat.js | 211 +++++- apps/web-console/index.html | 6 +- apps/web-console/service-worker.js | 8 +- .../vehicles/denza_n7/leisure_lounger.yaml | 3 +- .../vehicles/denza_n7/meditation_space.yaml | 3 +- config/modes/vehicles/denza_n7/nap.yaml | 3 +- config/modes/vehicles/denza_n7/office.yaml | 4 +- config/scene_templates/boarding_warmup.yaml | 26 + config/scene_templates/camping.yaml | 6 +- config/scene_templates/cinema.yaml | 6 +- config/scene_templates/quiet_night.yaml | 4 +- config/scene_templates/reading.yaml | 6 +- config/scene_templates/reception.yaml | 4 +- config/scene_templates/romantic.yaml | 4 +- config/scene_templates/seat_dynamic.yaml | 23 + config/scene_templates/summer_cooling.yaml | 3 +- config/scene_templates/winter_warmth.yaml | 3 +- docs/mode-schedules.md | 46 ++ .../agent/prompts/codex_cockpit_assistant.md | 1 + .../application/assistant_capabilities.py | 20 + .../application/assistant_plan_guard.py | 13 +- .../application/codex_assistant.py | 91 ++- .../application/mode_creation_seat_policy.py | 76 ++ .../application/mode_profile.py | 16 +- .../application/mode_profile_intent.py | 99 ++- .../application/mode_schedule.py | 429 +++++++++++ .../application/mode_schedule_edit.py | 314 ++++++++ .../application/mode_schedule_service.py | 238 +++++++ .../application/scene_composer.py | 121 +++- .../application/scene_templates.py | 30 +- .../application/schedule_intent.py | 25 + .../application/seat_service.py | 284 +++++++- .../application/semantic_ir.py | 17 +- src/cognitive_agent/server.py | 9 + tests/e2e/mode_schedule_browser.cjs | 256 +++++++ tests/e2e/test_mode_schedule_browser.py | 109 +++ tests/e2e/test_seat_web_api.py | 20 +- ...t_assistant_capability_execution_matrix.py | 9 +- .../test_custom_mode_creation_matrix.py | 6 +- .../test_mode_creation_seat_policy.py | 471 ++++++++++++ .../test_mode_draft_parameter_matrix.py | 9 +- tests/integration/test_mode_schedule_flow.py | 670 ++++++++++++++++++ ...est_mode_schedule_natural_language_edit.py | 278 ++++++++ .../test_named_mode_inference_regression.py | 5 +- .../test_registered_scene_seat_coverage.py | 347 +++++++++ .../test_scene_explicit_seat_scope.py | 25 +- .../integration/test_scene_template_hybrid.py | 50 +- tests/unit/mode_schedule_frontend.test.cjs | 435 ++++++++++++ .../unit/test_assistant_capability_catalog.py | 6 +- .../unit/test_assistant_frontend_contract.py | 10 +- .../test_energy_monitor_frontend_contract.py | 4 +- tests/unit/test_frontend_theme_contract.py | 2 +- tests/unit/test_mode_creation_seat_policy.py | 56 ++ tests/unit/test_mode_creation_seat_routing.py | 186 +++++ tests/unit/test_mode_gallery_grouping.py | 14 +- tests/unit/test_mode_schedule.py | 513 ++++++++++++++ .../unit/test_saved_mode_management_intent.py | 20 + tests/unit/test_scene_composer_fast_path.py | 8 +- 60 files changed, 5847 insertions(+), 149 deletions(-) create mode 100644 config/scene_templates/boarding_warmup.yaml create mode 100644 config/scene_templates/seat_dynamic.yaml create mode 100644 docs/mode-schedules.md create mode 100644 src/cognitive_agent/application/mode_creation_seat_policy.py create mode 100644 src/cognitive_agent/application/mode_schedule.py create mode 100644 src/cognitive_agent/application/mode_schedule_edit.py create mode 100644 src/cognitive_agent/application/mode_schedule_service.py create mode 100644 src/cognitive_agent/application/schedule_intent.py create mode 100644 tests/e2e/mode_schedule_browser.cjs create mode 100644 tests/e2e/test_mode_schedule_browser.py create mode 100644 tests/integration/test_mode_creation_seat_policy.py create mode 100644 tests/integration/test_mode_schedule_flow.py create mode 100644 tests/integration/test_mode_schedule_natural_language_edit.py create mode 100644 tests/integration/test_registered_scene_seat_coverage.py create mode 100644 tests/unit/mode_schedule_frontend.test.cjs create mode 100644 tests/unit/test_mode_creation_seat_policy.py create mode 100644 tests/unit/test_mode_creation_seat_routing.py create mode 100644 tests/unit/test_mode_schedule.py diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 1422d68..83b9df1 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -4860,7 +4860,18 @@ function modeOperationParameters(operation) { return Object.entries(parameters); } -function formatModeOperationValue(name, value) { +function formatModeOperationValue(name, value, capability = "") { + if (name === "level" && ["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) { + return { 1: "高档", 2: "低档" }[value] || formatConfigValue(name, value); + } + if (capability === "seat.set_driver_massage_native") { + const labels = { + action: { mode: "切换按摩模式", strength: "调节按摩力度", off: "关闭" }, + mode: { wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松" }, + strength: { 1: "轻柔", 2: "中等", 3: "较强" }, + }; + if (labels[name]?.[value]) return labels[name][value]; + } const valueLabels = { on: "开启", off: "关闭", @@ -4887,7 +4898,7 @@ function modeOperationView(operation) { const parameters = modeOperationParameters(operation) .map(([name, value]) => { const label = CONFIG_PARAMETER_LABELS[name] || name; - return `${label}:${formatModeOperationValue(name, value)}`; + return `${label}:${formatModeOperationValue(name, value, capability)}`; }) .join(","); return { @@ -4926,7 +4937,50 @@ function modeTimelineStepView(step, action) { }; } +function modeScheduleDurationLabel(durationMs) { + const minutes = Math.floor(durationMs / 60000); + const seconds = Number(((durationMs % 60000) / 1000).toFixed(3)); + return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`; +} + +function modeScheduleView(mode) { + const schedule = mode?.schedule; + if ( + schedule?.schema !== "cognitive-agent-mode-schedule/v1" + || !Array.isArray(schedule.phases) || !schedule.phases.length + || !Number.isInteger(schedule.repeat_count) + || schedule.repeat_count < 1 || schedule.repeat_count > 20 + || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms) + || phase.duration_ms <= 0 || !Array.isArray(phase.operations)) + ) return null; + let offsetMs = 0; + const phases = schedule.phases.map((phase, index) => { + const view = { + name: String(phase.name || `阶段 ${index + 1}`), + timing: `+${modeScheduleDurationLabel(offsetMs)} 开始 · 持续 ${modeScheduleDurationLabel(phase.duration_ms)}`, + actions: phase.operations.map(modeOperationView), + }; + offsetMs += phase.duration_ms; + return view; + }); + return { + phases, + summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${modeScheduleDurationLabel(offsetMs * schedule.repeat_count)}`, + stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚;时间为每轮的相对时间。", + }; +} + function modeExecutionActionViews(mode) { + const schedule = modeScheduleView(mode); + if (schedule) { + // Runtime operations may contain a flattened projection. Prefer phases so + // repeated device targets remain distinct and are not listed twice. + return schedule.phases.flatMap((phase, index) => phase.actions.map((action) => ({ + ...action, + phase: `阶段 ${index + 1}:${phase.name}`, + timing: phase.timing, + }))); + } const operations = Array.isArray(mode?.operations) ? mode.operations.map(modeOperationView) : []; @@ -4947,6 +5001,57 @@ function modeExecutionActionViews(mode) { return [...operations, ...timelineSteps]; } +function modeScheduleTrialMatches(draft, payload) { + if (!draft?.schedule) return true; + if (!modeScheduleView(draft) || !modeScheduleView(payload)) return false; + const signature = (schedule) => JSON.stringify([ + schedule.schema, schedule.repeat_count, + schedule.phases.map((phase) => [ + phase.id, phase.name, phase.duration_ms, + phase.operations.map((operation) => [ + operation.domain, operation.capability, + operation.target_scope || operation.target?.instance || operation.target?.zone, + modeOperationParameters(operation).sort(([left], [right]) => left.localeCompare(right)), + ]), + ]), + ]); + const execution = payload.schedule_execution; + const totalDurationMs = draft.schedule.phases.reduce((total, phase) => total + phase.duration_ms, 0) + * draft.schedule.repeat_count; + return signature(draft.schedule) === signature(payload.schedule) + && execution?.status === "completed" && execution.completed === true + && execution.virtual_elapsed_ms === totalDurationMs; +} + +function modeScheduleExecutionView(result) { + const execution = result?.schedule_execution; + if (!execution || typeof execution !== "object") return null; + const status = String(execution.status || ""); + if (status === "running" && result.status === "running") { + return { + state: "processing", badge: "已启动", title: "动态时间轴已启动", + detail: "后续动作将按阶段执行;可输入“停止动态模式”提前结束。已执行的设备状态不会自动回滚。", + }; + } + if (["stopped", "cancelled"].includes(status)) { + return { + state: "succeeded", badge: "已停止", title: "动态时间轴已停止", + detail: "后续阶段不再执行;已经执行的设备状态不会自动回滚。", + }; + } + if (status === "completed" && execution.completed === true) { + const simulated = execution.virtual_elapsed_ms != null; + return { + state: "succeeded", badge: simulated ? "模拟通过" : "已结束", + title: simulated ? "动态时间轴模拟试运行通过" : "动态时间轴已结束", + detail: simulated + ? "已使用虚拟时钟校验全部阶段;未按真实时间等待,也未向车辆下发控制。" + : "已处理全部计划阶段,请以车辆反馈确认各设备的实际状态。", + }; + } + return null; +} + function isUserModeProfile(mode) { return mode?.origin === "user" || mode?.deletable === true; } @@ -4954,6 +5059,7 @@ function isUserModeProfile(mode) { function isDynamicModeProfile(mode) { const behavior = String(mode?.behavior || "").trim().toLowerCase(); if (["cyclic", "dynamic"].includes(behavior)) return true; + if (modeScheduleView(mode)) return true; if (behavior === "static") return false; if (mode?.timeline && typeof mode.timeline === "object") return true; return LEGACY_DYNAMIC_MODE_IDS.has(String(mode?.id || "")); @@ -5034,6 +5140,34 @@ function createModeGalleryCard(mode) { button.append(iconBox, copy); shell.append(button); + const schedule = modeScheduleView(mode); + if (schedule) { + meta.textContent = schedule.summary; + const details = document.createElement("details"); + details.className = "mode-gallery-schedule-details"; + const summary = document.createElement("summary"); + summary.textContent = "查看分阶段执行动作"; + const list = document.createElement("ol"); + schedule.phases.forEach((phase) => { + const item = document.createElement("li"); + const title = document.createElement("strong"); + title.textContent = phase.name; + const timing = document.createElement("small"); + timing.textContent = phase.timing; + item.append(title, timing); + phase.actions.forEach((action) => { + const row = document.createElement("p"); + row.textContent = `${action.target} · ${action.action}:${action.parameters}`; + item.append(row); + }); + list.append(item); + }); + const hint = document.createElement("p"); + hint.textContent = schedule.stopHint; + details.append(summary, list, hint); + shell.append(details); + } + const editButton = document.createElement("button"); editButton.className = "mode-gallery-edit"; editButton.type = "button"; @@ -5998,12 +6132,19 @@ function ensureModeActionPopover() { function renderModeActionPopover(mode) { const popover = ensureModeActionPopover(); const actions = modeExecutionActionViews(mode); + const schedule = modeScheduleView(mode); $("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式"; $("[data-mode-action-count]", popover).textContent = actions.length ? `${actions.length} 步` : `${Number(mode?.operation_count || 0)} 项`; const list = $("[data-mode-action-list]", popover); list.replaceChildren(); + if (schedule) { + const summary = document.createElement("li"); + summary.className = "mode-schedule-popover-summary"; + summary.textContent = `${schedule.summary}。${schedule.stopHint}`; + list.append(summary); + } if (!actions.length) { const empty = document.createElement("li"); empty.className = "mode-chip-action-empty"; @@ -6020,6 +6161,12 @@ function renderModeActionPopover(mode) { copy.className = "mode-chip-action-copy"; const actionName = document.createElement("strong"); actionName.textContent = action.action; + if (action.phase) { + const phase = document.createElement("small"); + phase.className = "mode-schedule-popover-phase"; + phase.textContent = `${action.phase} · ${action.timing}`; + copy.append(phase); + } const metadata = document.createElement("small"); const device = document.createElement("span"); device.innerHTML = "设备"; @@ -6189,8 +6336,17 @@ function populateModeChips() { button.append(runIcon); } const timeline = mode.timeline; + const schedule = modeScheduleView(mode); let cycleBadge = $(".mode-cycle-badge", button); - if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { + if (schedule) { + if (!cycleBadge) { + cycleBadge = document.createElement("small"); + cycleBadge.className = "mode-cycle-badge"; + button.append(cycleBadge); + } + cycleBadge.textContent = schedule.summary; + button.dataset.modeBehavior = "dynamic"; + } else if (["infinite", "until_stopped_or_timeout"].includes(timeline?.repeat)) { if (!cycleBadge) { cycleBadge = document.createElement("small"); cycleBadge.className = "mode-cycle-badge"; @@ -6222,7 +6378,7 @@ function populateModeChips() { : `${phase.name} 目标到位`; }).join(" → ") : ""; - button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"] + button.title = [mode.description || mode.name, schedule?.summary || phaseSummary, "悬浮或聚焦查看全部执行动作"] .filter(Boolean) .join("\n"); }); @@ -6336,7 +6492,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { $$(".mode-chips button[data-mode-profile-id], #modeGallery button[data-mode-profile-id]").forEach((button) => { const active = button.dataset.modeProfileId === modeProfileId && modeState !== "idle"; button.classList.toggle("running", active && modeState === "running"); - button.classList.toggle("succeeded", active && modeState === "succeeded"); + button.classList.toggle("succeeded", active && ["succeeded", "started", "ended", "stopped"].includes(modeState)); button.classList.toggle("failed", active && modeState === "failed"); button.dataset.modeState = active ? modeState : "idle"; button.setAttribute("aria-pressed", active ? "true" : "false"); @@ -6345,6 +6501,12 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { if (stateLabel) { stateLabel.textContent = active && modeState === "running" ? "执行中" + : active && modeState === "started" + ? "已启动" + : active && modeState === "ended" + ? "已结束" + : active && modeState === "stopped" + ? "已停止" : active && modeState === "succeeded" ? "已完成" : active && modeState === "failed" @@ -6352,7 +6514,7 @@ function setModeExecutionState(modeProfileId, modeState = "idle") { : "点击执行"; } }); - if (modeProfileId && ["succeeded", "failed"].includes(modeState)) { + if (modeProfileId && ["succeeded", "failed", "started", "ended", "stopped"].includes(modeState)) { state.modeRunTimer = window.setTimeout(() => setModeExecutionState(null), 2800); } } @@ -6407,14 +6569,19 @@ async function executeSavedModeProfile(mode) { ); if (!payload) throw new Error("模式卡片未返回执行结果"); renderResult(payload); + const scheduleRunning = modeScheduleExecutionView(payload)?.state === "processing"; const completed = ["succeeded", "partially_succeeded"].includes(payload.status); $("#utterance").value = mode.name || mode.id; - $("#resultTitle").textContent = completed + $("#resultTitle").textContent = scheduleRunning + ? `${mode.name}时间轴已启动` + : completed ? `${mode.name}执行完成` : `${mode.name}执行未完成`; - setModeExecutionState(mode.id, completed ? "succeeded" : "failed"); + setModeExecutionState(mode.id, scheduleRunning ? "started" : completed ? "succeeded" : "failed"); if (state.executionMode === "hardware") await synchronizePhysicalSeatState(); - showToast(completed + showToast(scheduleRunning + ? "时间轴已启动,可输入“停止动态模式”提前结束" + : completed ? `${mode.name}已直接执行保存的控制指令流` : `${mode.name}未完成,请查看执行结果`); } catch (error) { @@ -7489,6 +7656,9 @@ const CONFIG_DOMAIN_META = { const CONFIG_CAPABILITY_LABELS = { "seat.set_ventilation": "设置座椅通风", "seat.set_heating": "设置座椅加热", + "seat.set_driver_ventilation_native": "设置主驾座椅通风", + "seat.set_driver_heating_native": "设置主驾座椅加热", + "seat.set_driver_massage_native": "设置主驾座椅按摩", "seat.set_longitudinal": "调节座椅前后位置", "seat.set_longitudinal_position": "调节座椅前后位置", "seat.set_cushion_tilt": "调节座椅高度", @@ -7519,6 +7689,8 @@ const CONFIG_CAPABILITY_LABELS = { "ambient_light.set_illumination": "设置氛围灯颜色与亮度", "ambient_light.set_color": "设置氛围灯颜色", "ambient_light.set_brightness": "设置氛围灯亮度", + "ambient_light.set_brightness_level": "设置氛围灯亮度", + "ambient_light.set_color_index": "设置氛围灯色号", "ambient_light.set_mode": "设置氛围灯模式", "roof.set_transmittance": "设置天幕透光率", "roof.set_shade_position": "设置遮阳帘位置", @@ -7527,6 +7699,10 @@ const CONFIG_CAPABILITY_LABELS = { }; const CONFIG_PARAMETER_LABELS = { + action: "动作", + strength: "力度", + color_index: "色号", + brightness_level: "亮度档位", power: "电源", state: "状态", level: "档位", @@ -7813,6 +7989,24 @@ function renderExecution(result) { const summary = $("#executionSummary"); const list = $("#resultList"); list.replaceChildren(); + const scheduleExecution = modeScheduleExecutionView(result); + if (scheduleExecution) { + summary.className = `execution-summary ${scheduleExecution.state === "processing" ? "partial" : scheduleExecution.state}`; + const icon = document.createElement("i"); + icon.dataset.lucide = scheduleExecution.state === "processing" ? "timer" : "circle-check"; + const copy = document.createElement("div"); + const title = document.createElement("strong"); + title.textContent = scheduleExecution.title; + const detail = document.createElement("span"); + detail.textContent = scheduleExecution.detail; + copy.append(title, detail); + summary.replaceChildren(icon, copy); + $("#resultNavMeta").textContent = scheduleExecution.badge; + $("#resultPanelState").textContent = scheduleExecution.badge; + $("#resultPanelState").className = `workspace-panel-state ${scheduleExecution.state}`; + refreshIcons(); + return; + } const report = result.execution_report; if (!report) { summary.className = "execution-summary failed"; @@ -8084,6 +8278,7 @@ window.addEventListener("cognitive-agent:extension-vehicle-result", (event) => { function renderResult(result) { state.result = result; + const scheduleExecution = modeScheduleExecutionView(result); const reportMode = result.execution_report?.spec?.mode; const feedbackSource = result.execution_report?.metadata?.labels?.seat_feedback; const submittedUnverified = executionVerificationStatus(result) === "submitted_unverified"; @@ -8118,7 +8313,9 @@ function renderResult(result) { }; const peripheralRoute = String(result.orchestration_route || "") .startsWith("nfc_peripheral"); - $("#resultTitle").textContent = peripheralRoute && executionSucceeded + $("#resultTitle").textContent = scheduleExecution + ? `${result.mode_profile?.name ? `${result.mode_profile.name} · ` : ""}${scheduleExecution.title}` + : peripheralRoute && executionSucceeded ? `${result.peripheral?.name || "第三方外设"} · ${result.peripheral?.action || "动作已执行"}` : partiallySucceeded ? `${domains.length} 域场景部分完成` @@ -8142,7 +8339,7 @@ function renderResult(result) { $("#requestMeta").textContent += ` / reasoning:${result.reasoning_effort_used}`; } setRunStatus( - partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed", + scheduleExecution?.state || (partiallySucceeded || submittedUnverified ? "partial" : executionSucceeded ? "succeeded" : "failed"), submittedUnverified ? "SUBMITTED_UNVERIFIED" : result.status.toUpperCase(), ); renderDecision(result); @@ -8722,12 +8919,17 @@ window.CognitiveAgentConsole = { && expectedSignatures.every((signature, index) => signature === actualSignatures[index])); if (payload.status !== "succeeded" || (expectedOperationCount > 0 && actualOperationCount < expectedOperationCount) - || !sameActions) { + || !sameActions || !modeScheduleTrialMatches(draft, payload)) { throw new Error("试运行未完整覆盖草稿动作,请重新生成模式预览。"); } return payload; }, revealModeProfile, + updateScheduleExecutionStatus(modeProfileId, status) { + if (!modeProfileId || (state.pendingModeProfileId && state.pendingModeProfileId !== modeProfileId)) return; + const terminal = { completed: "ended", failed: "failed", cancelled: "stopped", stopped: "stopped", idle: "idle" }[status]; + if (terminal) setModeExecutionState(modeProfileId, terminal); + }, notify(message) { showToast(String(message)); }, @@ -8750,9 +8952,11 @@ window.addEventListener("cognitive-agent:request-complete", (event) => { if (!modeProfileId) return; state.pendingModeProfileId = null; const result = event.detail?.result; + const scheduleRunning = !event.detail?.error + && modeScheduleExecutionView(result)?.state === "processing"; const succeeded = !event.detail?.error && ["succeeded", "partially_succeeded"].includes(String(result?.status || "")); - setModeExecutionState(modeProfileId, succeeded ? "succeeded" : "failed"); + setModeExecutionState(modeProfileId, scheduleRunning ? "started" : succeeded ? "succeeded" : "failed"); }); document.addEventListener("DOMContentLoaded", async () => { diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css index 0ac9079..7045259 100644 --- a/apps/web-console/assistant-chat.css +++ b/apps/web-console/assistant-chat.css @@ -551,6 +551,111 @@ padding: 9px 10px 8px; } +.assistant-mode-schedule-summary, +.assistant-mode-schedule-hint { + margin: 0; + padding: 9px 12px; + color: var(--assistant-muted, #64748b); + font-size: 10px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.assistant-mode-schedule-summary { + color: var(--status-info-foreground, #3764d9); + background: var(--status-info-soft, #eef3ff); + font-weight: 750; +} + +.assistant-mode-schedule-phase + .assistant-mode-schedule-phase, +.assistant-mode-schedule-hint { + border-top: 1px solid var(--assistant-border, #e5eaf1); +} + +.assistant-mode-schedule-phase > header { + display: grid; + gap: 3px; + padding: 10px 12px 5px; + color: var(--assistant-heading, #334155); + font-size: 11px; + overflow-wrap: anywhere; +} + +.assistant-mode-schedule-phase > header small { + color: var(--assistant-muted, #64748b); + font-size: 9px; + font-weight: 500; + line-height: 1.5; +} + +.assistant-mode-draft-scheduled .assistant-mode-draft-row { + grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr); + gap: 5px; +} + +.assistant-mode-draft-scheduled .assistant-mode-draft-row > strong { + white-space: normal; + overflow-wrap: anywhere; + text-align: right; +} + +.mode-gallery-card-shell:has(> .mode-gallery-schedule-details) > .mode-gallery-card { + height: auto; +} + +.mode-gallery-schedule-details { + padding: 9px 12px; + color: var(--foreground, #334155); + background: var(--card, #fff); + border: 1px solid var(--border, #e5eaf1); + border-radius: 10px; + font-size: 11px; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.mode-gallery-schedule-details > summary { + cursor: pointer; + color: var(--primary, #3764d9); + font-weight: 700; +} + +.mode-gallery-schedule-details ol { + margin: 10px 0; + padding-left: 19px; +} + +.mode-gallery-schedule-details li + li { + margin-top: 10px; +} + +.mode-gallery-schedule-details li > strong, +.mode-gallery-schedule-details li > small { + display: block; +} + +.mode-gallery-schedule-details small, +.mode-gallery-schedule-details > p { + color: var(--muted-foreground, #64748b); +} + +.mode-gallery-schedule-details p { + margin: 4px 0; +} + +.mode-chip-action-popover li.mode-schedule-popover-summary { + display: block; + color: var(--status-info-foreground, #3764d9); + background: var(--status-info-soft, #eef3ff); + font-size: 11px; + line-height: 1.6; +} + +.mode-schedule-popover-phase { + color: var(--primary, #3764d9); + line-height: 1.5; +} + .assistant-mode-draft-actions[hidden] { display: none; } diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 1840b4f..f64c260 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -11,6 +11,7 @@ let pendingMessage = null; let latestControlResult = null; let activeModeDraftContext = null; + let activeSchedulePoll = null; let initialized = false; const $ = (selector, root = document) => root.querySelector(selector); @@ -542,7 +543,7 @@ const consumed = new Set(); const rows = []; const targetZone = (action) => String( - action?.target?.instance || action?.target?.zone || action?.target || "", + action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "", ); const parameters = (action) => ( action?.parameters && typeof action.parameters === "object" ? action.parameters : {} @@ -613,6 +614,10 @@ const capability = String(action.capability || ""); const domain = String(action.domain || ""); const zone = targetZone(action); + const zoneLabel = { + driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾", + front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后", + }[zone] || ""; const params = parameters(action); let label = capability || domain || "座舱设备"; let value = "已配置"; @@ -683,7 +688,7 @@ icon = "armchair"; order = instructionOrder("座椅", "副驾", "主驾"); } else if (capability === "climate.set_temperature") { - label = "空调温度"; + label = `${zoneLabel}空调温度`; value = `${params.temperature_c ?? "--"}℃`; icon = "thermometer"; order = instructionOrder("空调", "温度"); @@ -701,13 +706,14 @@ : capability === "climate.set_cycle_mode" ? "空调循环" : capability === "climate.set_control_mode" ? "空调控制模式" : capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调"; + label = `${zoneLabel}${label}`; value = params.level != null ? `${params.level}档` : params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode)) : motionLabel(params.state); icon = "fan"; order = instructionOrder("空调", "风量"); } else if (capability === "interior_light.set_reading_power") { - label = "阅读灯"; + label = `${zoneLabel}阅读灯`; value = motionLabel(params.state); icon = "book-open"; order = instructionOrder("阅读灯", "灯光"); @@ -734,6 +740,63 @@ return rows.sort((left, right) => left.order - right.order); } + function modeDraftScheduleView(draft) { + const schedule = draft?.schedule; + if ( + schedule?.schema !== "cognitive-agent-mode-schedule/v1" + || !Array.isArray(schedule.phases) || !schedule.phases.length + || !Number.isInteger(schedule.repeat_count) + || schedule.repeat_count < 1 || schedule.repeat_count > 20 + || schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms) + || phase.duration_ms <= 0 || !Array.isArray(phase.operations)) + ) return null; + const durationLabel = (durationMs) => { + const minutes = Math.floor(durationMs / 60000); + const seconds = Number(((durationMs % 60000) / 1000).toFixed(3)); + return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`; + }; + let offsetMs = 0; + const phases = schedule.phases.map((phase, index) => { + // Each phase is rendered separately, without instruction-based grouping: + // the same seat or temperature can have a different target later on. + const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({ + ...operation, + target: operation.target || (operation.domain === "seat" + ? { instance: operation.target_scope } + : { zone: operation.target_scope }), + }))); + const view = { + id: String(phase.id || `phase-${index + 1}`), + name: String(phase.name || `阶段 ${index + 1}`), + timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`, + rows, + }; + offsetMs += phase.duration_ms; + return view; + }); + return { + phases, + operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0), + summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`, + stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。", + }; + } + + function appendModeDraftRows(container, rows) { + rows.forEach((item) => { + const row = document.createElement("div"); + row.className = "assistant-mode-draft-row"; + const icon = document.createElement("i"); + icon.dataset.lucide = item.icon; + const label = document.createElement("span"); + label.textContent = item.label; + const value = document.createElement("strong"); + value.textContent = item.value; + row.append(icon, label, value); + container.append(row); + }); + } + async function saveModeProfileDraft(message, card, draft, buttons, status) { buttons.forEach((button) => { button.disabled = true; }); card.classList.add("is-busy"); @@ -849,23 +912,39 @@ headingCopy.append(eyebrow, title); const badge = document.createElement("span"); badge.className = "assistant-mode-draft-badge"; - badge.textContent = `${Number(draft.operation_count) || 0} 项动作`; + const scheduleView = modeDraftScheduleView(draft); + badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`; heading.append(headingCopy, badge); const list = document.createElement("div"); list.className = "assistant-mode-draft-list"; - modeDraftRows(draft, actions).forEach((item) => { - const row = document.createElement("div"); - row.className = "assistant-mode-draft-row"; - const icon = document.createElement("i"); - icon.dataset.lucide = item.icon; - const label = document.createElement("span"); - label.textContent = item.label; - const value = document.createElement("strong"); - value.textContent = item.value; - row.append(icon, label, value); - list.append(row); - }); + if (scheduleView) { + card.classList.add("assistant-mode-draft-scheduled"); + const summary = document.createElement("p"); + summary.className = "assistant-mode-schedule-summary"; + summary.textContent = scheduleView.summary; + list.append(summary); + scheduleView.phases.forEach((phase, index) => { + const section = document.createElement("section"); + section.className = "assistant-mode-schedule-phase"; + section.dataset.modeSchedulePhase = phase.id; + const phaseHeading = document.createElement("header"); + const name = document.createElement("strong"); + name.textContent = `${index + 1}. ${phase.name}`; + const timing = document.createElement("small"); + timing.textContent = phase.timing; + phaseHeading.append(name, timing); + section.append(phaseHeading); + appendModeDraftRows(section, phase.rows); + list.append(section); + }); + const hint = document.createElement("p"); + hint.className = "assistant-mode-schedule-hint"; + hint.textContent = scheduleView.stopHint; + list.append(hint); + } else { + appendModeDraftRows(list, modeDraftRows(draft, actions)); + } const controls = document.createElement("div"); controls.className = "assistant-mode-draft-actions"; @@ -965,6 +1044,90 @@ } } + function stopScheduleStatusPoll() { + if (!activeSchedulePoll) return; + window.clearTimeout(activeSchedulePoll.timer); + activeSchedulePoll.controller?.abort(); + activeSchedulePoll = null; + } + + function startScheduleStatusPoll(payload, message) { + stopScheduleStatusPoll(); + const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id + || payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || ""); + if (!vehicleId) return; + const poll = { + vehicleId, conversationId: conversationId(), message, + sessionId: String(payload?.schedule_execution?.session_id || ""), + runId: String(payload?.schedule_execution?.run_id || ""), + modeProfileId: String(payload?.mode_profile?.id || ""), + timer: null, controller: null, + }; + activeSchedulePoll = poll; + const isCurrent = () => activeSchedulePoll === poll + && conversationId() === poll.conversationId + && (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId); + const update = async () => { + if (!isCurrent()) { + if (activeSchedulePoll === poll) stopScheduleStatusPoll(); + return; + } + poll.controller = new AbortController(); + try { + const response = await window.CognitiveAgentNetwork.request( + `/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`, + { method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 }, + ); + if (!isCurrent()) return; + if (response.status === 401) { + setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed"); + stopScheduleStatusPoll(); + return; + } + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const status = await response.json(); + if (!isCurrent()) return; + if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) { + throw new Error("时间轴状态车型不匹配"); + } + if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId) + || (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) { + setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working"); + stopScheduleStatusPoll(); + return; + } + const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status); + if (status.status === "running") { + const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0 + ? ` · 阶段 ${status.phase_index + 1}` : ""; + const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0 + ? ` · 第 ${status.repeat_index + 1} 轮` : ""; + setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working"); + } else if (terminal) { + const stopped = ["cancelled", "stopped"].includes(status.status); + const failed = status.status === "failed"; + const copy = failed + ? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。` + : stopped + ? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。" + : status.status === "completed" + ? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。" + : "当前没有正在运行的动态时间轴。"; + setActionStatus(message, copy, failed ? "failed" : "success"); + window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status); + stopScheduleStatusPoll(); + return; + } + } catch (error) { + if (!isCurrent() || poll.controller?.signal.aborted) return; + setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working"); + } finally { + if (isCurrent()) poll.timer = window.setTimeout(update, 2000); + } + }; + poll.timer = window.setTimeout(update, 2000); + } + function applyAssistantEvent(event, message) { if (event.type === "answer.delta") { setMessageContent(message, `${message.content}${event.delta || ""}`); @@ -985,14 +1148,20 @@ if (event.type === "action.completed" || event.type === "work.completed") { latestControlResult = event.payload || latestControlResult; const completed = event.success !== false; + const scheduleRunning = completed + && event.payload?.orchestration_route === "mode_schedule_execute" + && (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running"); const defaultText = event.type === "work.completed" ? (completed ? "AI助手已完成工作" : "AI助手未能完成工作") : (completed ? "座舱控制已完成" : "座舱控制未完成"); setActionStatus( message, - event.display_text || defaultText, - completed ? "success" : "failed", + scheduleRunning + ? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。" + : event.display_text || defaultText, + scheduleRunning ? "working" : completed ? "success" : "failed", ); + if (scheduleRunning) startScheduleStatusPoll(event.payload, message); if (event.payload) { window.CognitiveAgentConsole?.applySyncedResult?.(event.payload); if ( @@ -1238,11 +1407,17 @@ document.addEventListener("click", (event) => { const target = event.target instanceof Element ? event.target : null; if (!target?.closest("#newConversation")) return; + stopScheduleStatusPoll(); sessionStorage.removeItem(STORAGE_KEY); clearActiveModeDraftContext(); sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId()); }, true); + document.addEventListener("change", (event) => { + if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll(); + }); + window.addEventListener("pagehide", stopScheduleStatusPoll); + document.addEventListener("DOMContentLoaded", initialize); window.CognitiveAgentAssistant = { submit: submitAssistant, diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 637fe52..a3bf0d4 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -13,20 +13,20 @@ - + - + - + diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index 741c426..2732239 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-v278"; +const CACHE_VERSION = "cognitive-agent-pad-v279"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -6,7 +6,7 @@ const APP_SHELL = [ "/index.html", "/styles.css?v=dual-seat-23", "/network.js?v=android-pad-2", - "/app.js?v=dual-seat-131", + "/app.js?v=dual-seat-132", "/gesture-control.js?v=android-pad-9", "/pwa.js?v=android-pad-3", "/console-v2.css?v=delivery-state-66", @@ -16,8 +16,8 @@ const APP_SHELL = [ "/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-27", - "/assistant-chat.js?v=codex-assistant-27", + "/assistant-chat.css?v=codex-assistant-28", + "/assistant-chat.js?v=codex-assistant-28", "/energy-monitor.css?v=energy-monitor-4", "/energy-monitor.js?v=energy-monitor-4", "/assets/pets/self-extension-cat.png?v=3", diff --git a/config/modes/vehicles/denza_n7/leisure_lounger.yaml b/config/modes/vehicles/denza_n7/leisure_lounger.yaml index a4079e2..5e45c2e 100644 --- a/config/modes/vehicles/denza_n7/leisure_lounger.yaml +++ b/config/modes/vehicles/denza_n7/leisure_lounger.yaml @@ -2,12 +2,13 @@ id: leisure_lounger name: 休闲躺椅模式 aliases: - 休闲躺椅 -description: 腾势 N7 副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力。 +description: 腾势 N7 主驾放松按摩与副驾休闲躺椅姿态,并联动当前已接入的空调、天幕和氛围灯能力;驻车使用,不改变主驾驾驶姿态。 icon: sofa scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用已接入的主驾放松按摩,让主副驾按各自能力联动休闲;主驾没有绝对位置反馈,不自动移动其座椅。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/meditation_space.yaml b/config/modes/vehicles/denza_n7/meditation_space.yaml index b1d4eb5..1bbc62c 100644 --- a/config/modes/vehicles/denza_n7/meditation_space.yaml +++ b/config/modes/vehicles/denza_n7/meditation_space.yaml @@ -3,12 +3,13 @@ name: 冥想空间模式 aliases: - 冥想空间 - 冥想 -description: 腾势 N7 冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力。 +description: 腾势 N7 主驾放松按摩与副驾冥想空间姿态,并联动当前已接入的恒温、低风、内循环、遮阳帘和冥想氛围灯能力;驻车使用,不改变主驾驾驶姿态。 icon: leaf scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用当前工程已接入的主驾放松按摩辅助驻车冥想;不假定副驾具有按摩能力,不移动主驾座椅。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/nap.yaml b/config/modes/vehicles/denza_n7/nap.yaml index f21b476..4e4de9f 100644 --- a/config/modes/vehicles/denza_n7/nap.yaml +++ b/config/modes/vehicles/denza_n7/nap.yaml @@ -2,12 +2,13 @@ id: nap name: 小憩模式 aliases: - 小憩 -description: 腾势 N7 副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力。 +description: 腾势 N7 主驾低档座椅通风与副驾小憩姿态,并联动当前已接入的静音恒温、遮阳帘和氛围灯关闭能力;驻车使用,不改变主驾驾驶姿态。 icon: moon scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾使用低档座椅通风保持驻车静坐舒适,不自动移动主驾或开启按摩。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/modes/vehicles/denza_n7/office.yaml b/config/modes/vehicles/denza_n7/office.yaml index b492850..422be86 100644 --- a/config/modes/vehicles/denza_n7/office.yaml +++ b/config/modes/vehicles/denza_n7/office.yaml @@ -2,12 +2,14 @@ id: office name: 办公模式 aliases: - 办公 -description: 腾势 N7 副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力。 +description: 腾势 N7 主副驾低档座椅通风与副驾办公姿态,并联动当前已接入的恒温、中低风、内循环、遮阳帘和冷白氛围灯能力;不改变主驾驾驶姿态。 icon: briefcase scope: vehicle_ids: - denza_n7 operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 低档通风,改善驻车办公时久坐闷热;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口低档通风,与办公支撑姿态联动。} - domain: seat capability: seat.set_longitudinal_position target_scope: passenger diff --git a/config/scene_templates/boarding_warmup.yaml b/config/scene_templates/boarding_warmup.yaml new file mode 100644 index 0000000..a30fcc6 --- /dev/null +++ b/config/scene_templates/boarding_warmup.yaml @@ -0,0 +1,26 @@ +id: boarding-warmup +name: 上车预热模式 +aliases: [上车预热的功能模式, 上车预热功能模式, 上车暖舱模式, 分阶段预热模式, 动态预热模式, 空调预热模式] +description: 分阶段上车预热;前2分钟以28℃和4档风量快速暖舱,再用24℃和1档风量舒适保持5分钟,并分别编排主副驾加热。总计7分钟,不移动座椅位置;时间轴结束后保持最后设置,不承诺无人值守预约启动。 +icon: thermometer +schedule: + schema: cognitive-agent-mode-schedule/v1 + repeat_count: 1 + phases: + - id: warmup + name: 快速暖舱 + duration_ms: 120000 + operations: + - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: 'on'}, reason: 开启空调。} + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 28}, reason: 初始预热目标。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 4}, reason: 初始阶段较高风量。} + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾低档加热接口2为低档。} + - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 2}, reason: 副驾使用独立接口2档加热。} + - id: comfort + name: 舒适恒温 + duration_ms: 300000 + operations: + - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 预热结束后降低目标温度。} + - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 降低风噪。} + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: 'on', level: 2}, reason: 主驾维持低档。} + - {domain: seat, capability: seat.set_heating, target_scope: passenger, operation: set, parameters: {state: 'on', level: 1}, reason: 副驾降低为1档。} diff --git a/config/scene_templates/camping.yaml b/config/scene_templates/camping.yaml index fcbcdd3..a11d09f 100644 --- a/config/scene_templates/camping.yaml +++ b/config/scene_templates/camping.yaml @@ -1,9 +1,13 @@ id: camping name: 露营模式 aliases: [户外露营模式, 营地模式] -description: 使用低风外循环、打开遮阳帘并启用露营氛围;不自动开窗。 +description: 为驻车露营组合主副驾低档通风、副驾靠背和腿托支撑、低风外循环、开阔天幕与露营氛围;不移动主驾座椅,不自动开窗。 icon: tent-tree operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风,减少驻车久坐闷热。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾独立接口的低档通风。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车露营提供适度舒展的副驾靠背支撑。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 35}, reason: 使用适度副驾腿托支撑,不采用极限躺平姿态。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持座舱温度。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} diff --git a/config/scene_templates/cinema.yaml b/config/scene_templates/cinema.yaml index 565f709..6c2e74c 100644 --- a/config/scene_templates/cinema.yaml +++ b/config/scene_templates/cinema.yaml @@ -1,9 +1,13 @@ id: cinema name: 观影模式 aliases: [影院模式, 家庭影院模式, 电影模式, 看电影模式] -description: 关闭直射光和车内主照明,使用低风恒温与影院氛围灯;不包含屏幕或音响控制。 +description: 为驻车观影组合主驾放松按摩、副驾舒展靠背和腿托、低档通风、遮光与低风恒温;不改变主驾驾驶姿态,不包含屏幕或音响控制。 icon: film operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 使用主驾已接入的放松按摩,缓解驻车观影时的久坐不适;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风减少久坐闷热。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 55}, reason: 为驻车观影提供适度后仰的副驾靠背支撑。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 为驻车观影提供副驾腿部支撑。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 维持观影期间的座舱恒温。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用低风量降低噪声。} diff --git a/config/scene_templates/quiet_night.yaml b/config/scene_templates/quiet_night.yaml index bfb3af1..e88b24c 100644 --- a/config/scene_templates/quiet_night.yaml +++ b/config/scene_templates/quiet_night.yaml @@ -3,9 +3,11 @@ name: 夜间静享模式 aliases: - 夜间模式 - 静夜模式 -description: 使用低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静。 +description: 使用主副驾低档座椅通风、低亮冷色氛围、关闭主照明与阅读灯,并以低风恒温保持安静;不改变座椅姿态。 icon: moon-star operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风,降低夜间久坐闷热且不启动姿态调整或按摩。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,保持座椅当前姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启低亮氛围灯。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#102040', brightness_pct: 10}, reason: 使用低亮深蓝氛围。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} diff --git a/config/scene_templates/reading.yaml b/config/scene_templates/reading.yaml index 50b10a5..c511090 100644 --- a/config/scene_templates/reading.yaml +++ b/config/scene_templates/reading.yaml @@ -4,9 +4,13 @@ aliases: - 阅读空间模式 - 看书模式 - 读书模式 -description: 为座舱阅读提供暖色照明、低风恒温和适度遮光。 +description: 为驻车阅读提供主副驾低档座椅通风、副驾靠背与腿托支撑、暖色照明、低风恒温和适度遮光;不改变主驾驾驶姿态。 icon: book-open operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 主驾采用已接入原生 CAN 的低档通风,减少久坐闷热;不移动主驾座椅。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用独立接口的低档通风。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 22}, reason: 使用偏直立的副驾阅读靠背支撑,避免大幅躺倒。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 21}, reason: 副驾腿托适度支撑腿部,供驻车阅读预览使用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 保持阅读期间的座舱恒温。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 24}, reason: 使用舒适中性温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 1}, reason: 使用安静低风量。} diff --git a/config/scene_templates/reception.yaml b/config/scene_templates/reception.yaml index e322a1c..74aaeaa 100644 --- a/config/scene_templates/reception.yaml +++ b/config/scene_templates/reception.yaml @@ -1,9 +1,11 @@ id: reception name: 接待模式 aliases: [贵宾模式, 迎宾接待模式] -description: 使用柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境。 +description: 使用主副驾低档座椅通风、柔和暖光、车内照明、恒温和半开遮阳帘营造接待环境;不自动移动有人入座的座椅。 icon: handshake operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风营造舒适接待环境,保持当前座椅位置。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 副驾使用低档通风,不在迎宾入座时自动调整姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_illumination, target_scope: vehicle, operation: set, parameters: {color_hex: '#FFE2B8', brightness_pct: 50}, reason: 使用柔和暖色氛围光。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启室内主照明。} diff --git a/config/scene_templates/romantic.yaml b/config/scene_templates/romantic.yaml index f9f56df..5a5658c 100644 --- a/config/scene_templates/romantic.yaml +++ b/config/scene_templates/romantic.yaml @@ -1,9 +1,11 @@ id: romantic name: 浪漫模式 aliases: [约会模式, 浪漫氛围模式] -description: 使用浪漫氛围灯并关闭车内主照明与阅读灯,同时关闭遮阳帘。 +description: 使用主副驾低档座椅通风与浪漫氛围灯,关闭车内主照明、阅读灯和遮阳帘;保持当前座椅姿态。 icon: heart operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾低档通风减少久坐闷热,不调整驾驶姿态。} + - {domain: seat, capability: seat.set_ventilation, target_scope: passenger, operation: set, parameters: {state: "on", level: 1}, reason: 使用副驾低档通风,保持用户当前的座椅姿态。} - {domain: ambient_light, capability: ambient_light.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启氛围灯。} - {domain: ambient_light, capability: ambient_light.set_mode, target_scope: vehicle, operation: set, parameters: {mode: romantic}, reason: 使用工程已接入的浪漫氛围。} - {domain: interior_light, capability: interior_light.set_indoor_power, target_scope: vehicle, operation: set, parameters: {state: "off"}, reason: 关闭室内主照明。} diff --git a/config/scene_templates/seat_dynamic.yaml b/config/scene_templates/seat_dynamic.yaml new file mode 100644 index 0000000..0be451c --- /dev/null +++ b/config/scene_templates/seat_dynamic.yaml @@ -0,0 +1,23 @@ +id: seat-dynamic +name: 座椅动态舒缓模式 +aliases: [座椅动态模式, 座椅的动态模式, 动态座椅模式, 座椅循环模式, 座椅交替放松模式, 动态放松模式] +description: 驻车使用的有界双座舒缓时间轴;主驾在放松和舒缓按摩间切换,副驾靠背与腿托小幅交替,30秒一个阶段、重复5次,总计5分钟。不自动移动主驾位置;停止后不再进入后续阶段。 +icon: activity +schedule: + schema: cognitive-agent-mode-schedule/v1 + repeat_count: 5 + phases: + - id: relax + name: 舒展支撑 + duration_ms: 30000 + operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: relax}, reason: 主驾使用原生放松按摩,不改变驾驶姿态。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 45}, reason: 驻车时副驾适度舒展。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 25}, reason: 适度支撑腿部。} + - id: support + name: 轻柔回落 + duration_ms: 30000 + operations: + - {domain: seat, capability: seat.set_driver_massage_native, target_scope: driver, operation: set, parameters: {action: mode, mode: slack}, reason: 主驾切换舒缓按摩。} + - {domain: seat, capability: seat.set_backrest_recline, target_scope: passenger, operation: set, parameters: {position_pct: 40}, reason: 副驾小幅回到支撑姿态。} + - {domain: seat, capability: seat.set_legrest_height, target_scope: passenger, operation: set, parameters: {position_pct: 20}, reason: 腿托小幅回落,不使用无界轴运动。} diff --git a/config/scene_templates/summer_cooling.yaml b/config/scene_templates/summer_cooling.yaml index ca1f60f..5324658 100644 --- a/config/scene_templates/summer_cooling.yaml +++ b/config/scene_templates/summer_cooling.yaml @@ -1,9 +1,10 @@ id: summer-cooling name: 夏季清凉模式 aliases: [清凉模式, 酷夏模式] -description: 使用内循环、适中冷风、关闭遮阳帘并开启当前已接入的副驾座椅通风。 +description: 使用内循环、适中冷风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅通风;不改变座椅姿态。 icon: snowflake operations: + - {domain: seat, capability: seat.set_driver_ventilation_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用主驾原生 CAN 的低档通风;该接口 2 为低档,不与副驾档位混用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 23}, reason: 设置清凉但不过低的温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 3}, reason: 使用适中风量快速降温。} diff --git a/config/scene_templates/winter_warmth.yaml b/config/scene_templates/winter_warmth.yaml index f7e0472..df9dfef 100644 --- a/config/scene_templates/winter_warmth.yaml +++ b/config/scene_templates/winter_warmth.yaml @@ -1,9 +1,10 @@ id: winter-warmth name: 冬季温暖模式 aliases: [暖冬模式, 冬日模式] -description: 使用内循环、暖风、关闭遮阳帘并开启当前已接入的副驾座椅加热。 +description: 使用内循环、暖风、关闭遮阳帘并分别开启主驾低档和副驾中档座椅加热;不改变座椅姿态。 icon: flame operations: + - {domain: seat, capability: seat.set_driver_heating_native, target_scope: driver, operation: set, parameters: {state: "on", level: 2}, reason: 使用已接入的主驾原生 CAN 低档加热;该接口 2 为低档,不与副驾档位混用。} - {domain: climate, capability: climate.set_power, target_scope: vehicle, operation: set, parameters: {state: "on"}, reason: 开启空调。} - {domain: climate, capability: climate.set_temperature, target_scope: vehicle, operation: set, parameters: {temperature_c: 26}, reason: 设置温暖舒适温度。} - {domain: climate, capability: climate.set_fan_level, target_scope: vehicle, operation: set, parameters: {level: 2}, reason: 使用中低风量。} diff --git a/docs/mode-schedules.md b/docs/mode-schedules.md new file mode 100644 index 0000000..2fad154 --- /dev/null +++ b/docs/mode-schedules.md @@ -0,0 +1,46 @@ +# 功能模式的座椅联动与时间轴 + +## 行为 + +新建或重新构建功能模式时,未指定座位默认只编排副驾座椅;明确“主驾”时只编排主驾座椅;只有明确要求主驾和副驾时才组合双座。该规则同时覆盖已登记模板、工程预设复制、未登记模型组合,以及时间轴每一个阶段。其他设备的独立区域不改变座椅选择,例如“主驾空调”并不代表选择主驾座椅。 + +模板保留主副驾能力作为可选来源。构建时服务端先按所选座位投影,再逐动作校验,不能把副驾靠背、腿托的位置能力重标为主驾。模型规划的能力目录也按所选座位收窄,越界方案整体拒绝。已经保存的用户模式及其后续参数编辑仍以原有配置为准,不因本次默认规则变更静默删除座椅动作。 + +常用阅读、观影、露营、夜间静享、接待、浪漫、冬季温暖、夏季清凉模板,以及办公、小憩、冥想、休闲躺椅预设,包含本工程支持的主驾和副驾舒适动作。主驾优先使用加热、通风、按摩,不凭空增加没有协议支持的位置动作。除明确座椅场景外,不随意改变主驾坐姿。 + +已有用户保存的配置优先,不被模板升级静默覆盖。要采用新版组合,可删除不再需要的旧自定义副本后重新构建,或明确修改已有模式。除霜、新风等专项模式不强行增加无关的座椅动作。 + +“构建一个座椅的动态模式”和“构建一个上车预热的功能模式”命中本地模板,无需模型推理。其他带时间变化的需求由结构化场景编排产生分阶段计划,每个阶段再次经过能力、参数、语义、安全和 CAN 编译校验;任意阶段失败则整份草稿拒绝,不生成静态替代品。 + +## 模板默认值 + +- 座椅动态舒缓:30 秒舒缓、30 秒支撑,重复 5 次,总计 5 分钟。默认副驾靠背与腿托小幅改变;明确主驾时改用主驾支持的按摩切换,不移动主驾姿态。仅应在车辆安全停稳后使用。 +- 上车预热:升温阶段 2 分钟,空调 28℃、风量 4;舒适保持阶段 5 分钟,空调 24℃、风量 1,同时编排所选座位的加热(默认副驾)。总计 7 分钟。 + +预览卡片展示每阶段的目标与时长,默认值属于可审核的建议。生成和试运行都不下发实际车辆控制;保存后进入模式才开始计时。“上车预热”目前是模式名称,不表示已接入自动开门触发。 + +## 存储与执行 + +`cognitive-agent-mode-schedule/v1` 保存 `repeat_count` 与有序 `phases`。每阶段有唯一 ID、名称、`duration_ms` 和绝对目标 `operations`。同一能力在不同阶段的不同值不能合并或去重。 + +约束:2–8 阶段;每阶段 1–12 项动作,1 秒–15 分钟;1–20 轮,含重复的总时长不超过 1 小时。禁止无限循环、相对累加动作和未经校验的底层指令。 + +真实执行由服务端计时。每阶段重新编译并检查权限/硬件租约,任意阶段失败即停止后续阶段。同车只运行一条时间轴,新时间轴替换旧时间轴;同车阶段回调互斥。不同车辆独立。 + +输入“停止动态模式”可取消后续阶段。已下发的目标不自动回滚,当前已进入执行器的操作可能继续完成。模拟停止不会改变真实任务。服务关闭时取消时间轴,重启不会自动续跑;网页关闭不会隐式修改服务端已启动任务。 + +`GET /api/modes/schedule/status?vehicle_id=denza_n7` 只读返回运行 ID、会话、阶段和终态,不暴露底层指令或 CAN 数据。助手区分已启动、模拟完成、真实完成、失败和取消,不把刚启动显示为全部完成。 + +## 验证 + +核心时钟/并发/取消:`tests/unit/test_mode_schedule.py`。 + +真实能力链创建、试运行、保存、重载、执行与防篡改:`tests/integration/test_mode_schedule_flow.py`。 + +座椅模板覆盖:`tests/integration/test_registered_scene_seat_coverage.py`。 + +新建座位选择、模型目录约束、非座椅分区隔离、保存绑定及越界拒绝:`tests/integration/test_mode_creation_seat_policy.py`。词汇与路由:`tests/unit/test_mode_creation_seat_policy.py`、`tests/unit/test_mode_creation_seat_routing.py`。 + +时间轴卡片与状态轮询:`tests/unit/mode_schedule_frontend.test.cjs`;隔离服务的真实浏览器:`tests/e2e/test_mode_schedule_browser.py`。 + +自动化测试使用模拟执行、临时用户目录并阻断硬件访问,不代表完成实车 CAN 验收。 diff --git a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md index d3a909c..3671dd9 100644 --- a/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md +++ b/src/cognitive_agent/agent/prompts/codex_cockpit_assistant.md @@ -8,6 +8,7 @@ - 对目录中的原子设备控制,调用工具时同时提交结构化 `plan`:只能逐字使用本回合能力目录中 `assistant_callable=true` 的 capability、target scope 和参数契约。对于目录中已有的座舱模式或当前 NFC 卡已授权的红外外设,省略 `plan`,由应用按动态注册表解析。结构化计划只是候选数据,应用会再次执行原始文本证据校验、Schema 校验和安全校验;本地确定性解析与计划重叠时,以本地结果为准。 - 用户仅给出一个尚未登记的功能模式名称时,应结合该场景的通常含义,从本回合能力目录中选择少量、明确且相互协调的 `assistant_callable=true` 原子能力,提交一个结构化 `plan`。每项动作必须是可重复的绝对 `set` 状态,不得使用相对增减、时长动作、动态/循环模式、保存或应用操作;同一能力与目标不得重复。若任何动作或参数有歧义,就不要提交残缺计划。不得引用目录外能力,不得生成原始 CAN,不得声称已经保存或真实执行;应用会把整组计划原子校验为 dry-run 动作预览,用户确认后才会保存。已有模式名称不要重新推测动作,应省略 `plan` 复用工程配置。 - 用户明确要求控制车辆,或直接表达当前座舱不适并期待处理时,必须调用该工具;不要只给建议,也不要要求用户先选择“问答/车控”模式。 +- 新建功能模式时,座椅默认只编排副驾;用户明确指定主驾时只编排主驾,只有明确要求两座时才同时编排。该规则适用于每个动态阶段,以本回合 `seat_scope_policy` 为准。不得把副驾姿态能力替换目标后冒充主驾能力,不得把后排或未知座位映射成前排。空调分区、车窗等非座椅设备的主副驾限定不改变座椅选择。单纯执行或修改已保存模式时,应保留其已保存的座位范围,不要重新套用新建默认值。 - “车上有点热”“有点热”“太热了”等当前不适,在没有否定、假设或纯知识提问时,直接调用一次工具;具体规范动作由服务端决定。 - “如果车上有点热应该怎么办”“为什么暴晒后车里会热”“车上有点热,但不要调整设备”等是假设、知识问题或否定请求,不得执行。 - 用户说“不要下发”“不执行”“只解析”“仅预览”或同义表达时,不得调用车辆工具。应直接用自然语言给出解析结果,保留用户明确说出的设备、目标位置、方向、数值、单位、时长和动作顺序,并明确说明未向车辆下发。 diff --git a/src/cognitive_agent/application/assistant_capabilities.py b/src/cognitive_agent/application/assistant_capabilities.py index ff25cd0..c796add 100644 --- a/src/cognitive_agent/application/assistant_capabilities.py +++ b/src/cognitive_agent/application/assistant_capabilities.py @@ -615,6 +615,19 @@ def _mode_detail_answer( if isinstance(item, Mapping) ] name = _safe_text(mode.get("name"), limit=80) + schedule = mode.get("schedule") + if isinstance(schedule, Mapping) and isinstance(schedule.get("phases"), list): + phases = schedule["phases"] + details = ";".join( + f"第{index}阶段({phase['name']},保持{phase['duration_ms'] // 1000}秒):" + + "、".join(_mode_operation_text(op) for op in phase["operations"]) + for index, phase in enumerate(phases, 1) + ) + return CapabilityAnswer( + f"“{name}”按时间轴执行,重复{schedule['repeat_count']}次。{details}。" + "停止动态模式可取消后续阶段,已下发目标保持。本次仅查询,未下发车辆控制。", + "specific_mode_detail", + ) if not operations: return CapabilityAnswer( f"已找到“{name}”,但当前模式目录没有返回可核验的动作明细;本次仅查询,未下发车辆控制。", @@ -2257,6 +2270,7 @@ class AssistantCapabilityCatalog: "origin": _safe_text(raw.get("origin"), limit=32), "operation_ids": operation_ids, "operations": operations, + **({"schedule": raw["schedule"]} if raw.get("schedule") else {}), "assistant_callable": callable_mode, "all_operations_have_native_binding": bool(operation_ids) and all( @@ -2290,6 +2304,11 @@ class AssistantCapabilityCatalog: requests = ( raw_plan.get("requests", []) if isinstance(raw_plan, Mapping) else [] ) + schedule = raw.get("schedule") + if isinstance(schedule, Mapping): + requests = [operation for phase in schedule.get("phases", []) + if isinstance(phase, Mapping) + for operation in phase.get("operations", [])] operation_ids = [ _safe_text(item.get("capability"), limit=120) for item in requests @@ -2327,6 +2346,7 @@ class AssistantCapabilityCatalog: # The assistant consumes this only after an exact name/alias # match and the vehicle service validates it again. "plan": dict(raw_plan) if callable_template else None, + **({"schedule": schedule} if isinstance(schedule, Mapping) else {}), } ) return templates, "ready" diff --git a/src/cognitive_agent/application/assistant_plan_guard.py b/src/cognitive_agent/application/assistant_plan_guard.py index 6c7dca8..d257f83 100644 --- a/src/cognitive_agent/application/assistant_plan_guard.py +++ b/src/cognitive_agent/application/assistant_plan_guard.py @@ -25,8 +25,6 @@ from collections.abc import Set as AbstractSet from dataclasses import dataclass from typing import Any -from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes - MAX_PLAN_BYTES = 24 * 1024 MAX_PLAN_REQUESTS = 12 MAX_PLAN_PARAMETERS = 12 @@ -1159,10 +1157,13 @@ def guard_assistant_plan( parser_claims.update(_intent_claims(intent)) local_keys = _resolved_keys(trusted_local or {}) non_execution = bool(_NON_EXECUTION_PATTERN.search(text)) - scene_seat_scopes = ( - resolve_scene_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) - if allow_scene_composition else None - ) + # An explicit empty internal scope is reserved for already validated saved + # schedules/draft revisions. New scenes use passenger when no seat is named. + scene_seat_scopes = None + if allow_scene_composition and scene_scope_utterance != "": + from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes + + scene_seat_scopes = creation_seat_scopes(text if scene_scope_utterance is None else scene_scope_utterance) accepted: list[dict[str, Any]] = [] rejected: list[dict[str, str]] = [] diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index b2ef03c..ff5543a 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -20,19 +20,25 @@ from typing import Protocol from cognitive_agent.application.mode_profile_intent import ( is_mode_profile_query, + mode_creation_source_name, normalize_mode_profile_name, parse_mode_profile_create, parse_mode_profile_delete, parse_mode_profile_update, ) +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_service import public_schedule_status from cognitive_agent.application.scene_composer import ( SCENE_COMPOSER_INSTRUCTIONS, SCENE_PLANNER_VERSION, + SCHEDULE_COMPOSER_INSTRUCTIONS, SceneCompositionError, build_scene_output_schema, normalize_scene_output, + scene_context_for_mode_creation, ) from cognitive_agent.application.scene_templates import normalize_scene_template_name +from cognitive_agent.application.schedule_intent import is_schedule_stop, needs_mode_schedule LOGGER = logging.getLogger(__name__) INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力" @@ -374,6 +380,9 @@ def _catalog_exact_mode_match_count( }: matched_ids.add(str(raw_mode.get("id") or "")) matched_ids.discard("") + subject = mode_creation_source_name(requested_name) + if not matched_ids and normalize_mode_profile_name(subject) != needle: + return _catalog_exact_mode_match_count(catalog, subject) return len(matched_ids) @@ -475,6 +484,10 @@ def _catalog_exact_scene_template_plan( template_id = str(raw_template.get("id") or "").strip() if template_id: matches[template_id] = raw_template + if not matches: + subject = mode_creation_source_name(requested_name) + if normalize_scene_template_name(subject) != needle: + return _catalog_exact_scene_template_plan(catalog, subject) if len(matches) != 1: return None template = next(iter(matches.values())) @@ -499,7 +512,12 @@ def _catalog_exact_scene_template_plan( def _vehicle_result_succeeded(result: Mapping[str, object]) -> bool: """Only a terminal, fully successful service result completes a tool call.""" - return str(result.get("status") or "").lower() == "succeeded" + schedule = result.get("schedule_execution") + return str(result.get("status") or "").lower() == "succeeded" or ( + result.get("status") == "running" + and result.get("orchestration_route") == "mode_schedule_execute" + and isinstance(schedule, Mapping) and schedule.get("status") == "running" + ) def _mode_profile_result_succeeded(result: Mapping[str, object]) -> bool: @@ -594,6 +612,10 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str] if not _vehicle_result_succeeded(result): return "failed", False, "车辆控制未完成" + if result.get("orchestration_route") == "mode_schedule_stop": + return ("simulated" if result.get("execution_mode") != "hardware" else "cancelled"), True, "动态模式停止请求已处理" + if result.get("status") == "running": + return "running", True, "时间轴已启动,尚未完成所有阶段" execution_mode = str(result.get("execution_mode") or "").lower() if execution_mode == "dry_run": return "simulated", True, "车辆控制模拟编排已完成" @@ -621,6 +643,8 @@ def _authorize_vehicle_request( message=message, ) normalized = "".join(str(message).lower().split()) + if is_schedule_stop(message): + return _VehicleAuthorization(message, "explicit_schedule_stop") mode_profile_create = _mode_profile_create_parts(message) mode_profile_update = _mode_profile_update_parts(message) mode_profile_delete = parse_mode_profile_delete(message) @@ -899,6 +923,9 @@ class CodexAssistantService: "explicit_mode_profile_draft_update", } mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None + if mode_parts: + scene_context = scene_context_for_mode_creation(scene_context, message) + model_context = scene_context_for_mode_creation(model_context, message) exact_mode_matches = ( _catalog_exact_mode_match_count(catalog, mode_parts[0]) if mode_parts @@ -909,10 +936,11 @@ class CodexAssistantService: if mode_parts and not mode_parts[1] and exact_mode_matches == 0 else None ) + dynamic_scene_request = bool(mode_parts and needs_mode_schedule(message)) direct_mode_profile_preview = bool( mode_parts and ( - mode_parts[1] + (mode_parts[1] and not dynamic_scene_request) # Ambiguous or unavailable mode catalogs fail closed in the # local builder. Only a known-zero match is safe to hand to # semantic scene composition. @@ -929,7 +957,7 @@ class CodexAssistantService: }.get(authorization.reason) scene_composition_preview = bool( mode_parts - and not mode_parts[1] + and (not mode_parts[1] or dynamic_scene_request) and exact_mode_matches == 0 and scene_template_plan is None and scene_context.get("composition_revision") @@ -941,6 +969,7 @@ class CodexAssistantService: # vehicle service still limits it to dry-run and requires confirmation. direct_vehicle_control = ( fast_comfort_control + or authorization.reason == "explicit_schedule_stop" or exact_quick_control or saved_mode_execution_id is not None or direct_mode_profile_preview @@ -1248,11 +1277,17 @@ class CodexAssistantService: revision = str(scene_context.get("composition_revision") or "") if not revision: raise _ScenePlannerFallback("scene capability revision is unavailable") + scope_policy = scene_context.get("seat_scope_policy") + selected_scopes = ( + scope_policy.get("selected_scopes", []) + if isinstance(scope_policy, Mapping) else [] + ) + scope_key = ",".join(sorted(str(scope) for scope in selected_scopes)) return ( vehicle_id, revision, normalize_mode_profile_name(mode_name), - SCENE_PLANNER_VERSION, + f"{SCENE_PLANNER_VERSION}:seats={scope_key}", ) def _begin_scene_plan( @@ -1493,7 +1528,8 @@ class CodexAssistantService: raise _ScenePlannerFallback("structured turns are not supported") planning_started_at = time.monotonic() - output_schema = build_scene_output_schema(scene_context) + dynamic = needs_mode_schedule(message) + output_schema = build_scene_output_schema(scene_context, dynamic=dynamic) context_bytes = len( json.dumps( dict(scene_context), @@ -1522,7 +1558,7 @@ class CodexAssistantService: try: try: thread_id = self.client.start_thread( - developer_instructions=SCENE_COMPOSER_INSTRUCTIONS, + developer_instructions=SCHEDULE_COMPOSER_INSTRUCTIONS if dynamic else SCENE_COMPOSER_INSTRUCTIONS, ephemeral=True, replace_developer_instructions=True, timeout=self._remaining_scene_time(deadline), @@ -1652,7 +1688,7 @@ class CodexAssistantService: raise SceneCompositionError("scene composer output was not valid JSON") from exc if not isinstance(raw_output, Mapping): raise SceneCompositionError("scene composer output must be an object") - normalized = normalize_scene_output(raw_output, scene_context) + normalized = normalize_scene_output(raw_output, scene_context, dynamic=dynamic) finished_at = time.monotonic() LOGGER.info( "Scene Composer model completed context_bytes=%d schema_bytes=%d " @@ -1887,7 +1923,9 @@ class CodexAssistantService: "success": successful, "state": action_state, "display_text": ( - "功能模式预览已生成" + completion_text + if action_state == "running" or result.get("orchestration_route") == "mode_schedule_stop" + else "功能模式预览已生成" if mode_profile_preview and successful else "功能模式预览生成失败" if mode_profile_preview @@ -2047,6 +2085,13 @@ class CodexAssistantService: if isinstance(draft, Mapping) else "新功能模式" ) + if isinstance(draft, Mapping) and isinstance(draft.get("schedule"), dict): + schedule = ModeSchedule.from_dict(draft["schedule"]) + return (f"已为你生成“{name}”的分阶段时间轴:{len(schedule.phases)}个阶段," + f"每轮{schedule.operation_count}项动作,重复{schedule.repeat_count}次," + f"总时长{schedule.total_duration_ms // 1000}秒。" + "每个阶段的设备目标和持续时间见卡片。当前未保存或执行;确认后保存,进入模式才开始计时。" + "执行中可输入“停止动态模式”取消后续阶段,已下发目标不会自动回滚。") if ( isinstance(draft, Mapping) and draft.get("generation_source") == "draft_revision" @@ -2251,6 +2296,18 @@ class CodexAssistantService: result: Mapping[str, object], successful: bool, ) -> str: + if result.get("orchestration_route") == "mode_schedule_stop": + if result.get("execution_mode") == "hardware" and (result.get("schedule_execution") or {}).get("status") != "cancelled": + return "当前没有正在执行的动态时间轴。" + return ("已停止动态模式后续阶段。已下发的设备目标保持,不自动回滚。" + if result.get("execution_mode") == "hardware" + else "已模拟停止动态模式,未改变正在运行的车辆任务。") + if result.get("orchestration_route") == "mode_schedule_execute": + if not successful: + return "时间轴未能完成,后续阶段已停止。请检查车辆状态后重试。" + return ("动态模式时间轴已启动,将按卡片中的时长逐阶段调节。尚未完成所有阶段;输入“停止动态模式”可取消后续阶段。" + if result.get("status") == "running" + else "已按虚拟时钟模拟验证动态模式的全部阶段,未向实际车辆下发操作。") if reason in {"exact_quick_control", "explicit_saved_mode_execute"}: instruction = re.sub( r"\s+", @@ -3011,7 +3068,7 @@ class CodexAssistantService: if isinstance(intent_ir, dict): spec = intent_ir.get("spec") if isinstance(spec, dict) and isinstance(spec.get("actions"), list): - for item in spec["actions"][:12]: + for item in spec["actions"][:96 if result.get("schedule") else 12]: if not isinstance(item, dict): continue actions.append( @@ -3125,6 +3182,22 @@ class CodexAssistantService: ) if key in mode_profile_draft } + for key in ("mode_profile_draft", "mode_profile"): + source = result.get(key) + if isinstance(source, dict) and isinstance(source.get("schedule"), dict): + try: + safe[key]["schedule"] = ModeSchedule.from_dict(source["schedule"]).to_dict() + except ValueError: + pass + if isinstance(result.get("schedule"), dict): + try: + safe["schedule"] = ModeSchedule.from_dict(result["schedule"]).to_dict() + except ValueError: + pass + if isinstance(result.get("schedule_execution"), dict): + safe["schedule_execution"] = public_schedule_status(result["schedule_execution"]) + if isinstance(result.get("vehicle"), dict): + safe["vehicle"] = {"id": str(result["vehicle"].get("id") or "")[:128]} failure = result.get("mode_creation_failure") if isinstance(failure, Mapping): reason, next_step = CodexAssistantService._mode_profile_failure_feedback( diff --git a/src/cognitive_agent/application/mode_creation_seat_policy.py b/src/cognitive_agent/application/mode_creation_seat_policy.py new file mode 100644 index 0000000..ccfd228 --- /dev/null +++ b/src/cognitive_agent/application/mode_creation_seat_policy.py @@ -0,0 +1,76 @@ +"""Seat selection for new mode drafts, not for replaying saved user modes.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from copy import deepcopy +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.semantic_ir import resolve_scene_seat_scopes + + +class ModeCreationSeatPolicyError(ValueError): + """A source mode cannot satisfy the selected seat without substitution.""" + + +def creation_seat_scopes(text: str) -> frozenset[str]: + """Default to passenger; HVAC/window zone names do not select a seat.""" + normalized = text.replace("主副驾", "主驾和副驾") + # Do not let the generic 驾驶席/驾驶位 aliases inside 副驾驶席/位 + # accidentally select the driver as well as the passenger. + normalized = re.sub(r"副驾驶(?:席|位|座(?!椅))?", "副驾", normalized) + if re.search(r"(?:未知|不明|不存在的?|任意)(?:一个)?(?:座位|座椅)|第[四五六七八九0-9]+排座", normalized): + return frozenset({"unsupported"}) + normalized = re.sub(r"(?:前排)?两(?:个|张)?(?:座椅|座位|座)|双座(?:椅)?", "主驾和副驾", normalized) + excluded = set() + for scope, aliases in (("driver", r"主驾(?:驶)?|驾驶位|驾驶席|司机位"), + ("passenger", r"副驾(?:驶)?|乘客位")): + pattern = re.compile( + rf"(?:不要|别|不用|无需|不需要|不涉及|不包含|排除)" + rf"(?:再|去|动|移动|调节|调整|控制|联动|编排|使用|添加|包含)*" + rf"(?:{aliases})(?:座椅|座位)?|" + rf"(?:{aliases})(?:座椅|座位)?(?:不动|不要动|不用动|不联动|不参与|不编排)" + ) + if pattern.search(normalized): + excluded.add(scope) + normalized = pattern.sub("", normalized) + selected = resolve_scene_seat_scopes(normalized) or frozenset({"passenger"}) + return frozenset(selected - excluded) + + +def normalized_seat_scope(scope: object) -> str: + value = str(scope or "") + return {"front_left": "driver", "front_right": "passenger"}.get(value, value) + + +def project_seed_operations( + operations: Sequence[Mapping[str, Any]], scopes: frozenset[str], +) -> list[dict[str, Any]]: + """Choose from trusted seat variants. Never retarget a passenger capability.""" + if "unsupported" in scopes: + raise ModeCreationSeatPolicyError("当前功能模式仅支持明确的主驾或副驾座椅,不会替换为其他座位。") + seats = [op for op in operations if op.get("domain") == "seat"] + if any(normalized_seat_scope(op.get("target_scope")) not in {"driver", "passenger"} for op in seats): + raise ModeCreationSeatPolicyError("模式包含无法验证的座位范围,请重新生成。") + projected = [deepcopy(dict(op)) for op in operations + if op.get("domain") != "seat" or normalized_seat_scope(op.get("target_scope")) in scopes] + if seats and not any(op.get("domain") == "seat" for op in projected): + target = "主驾" if scopes == {"driver"} else "副驾" if scopes == {"passenger"} else "所选座位" + raise ModeCreationSeatPolicyError(f"该模式没有支持{target}的座椅动作,不会把另一座位的能力冒充为{target}能力。") + return projected + + +def project_seed_plan(plan: Mapping[str, Any], scopes: frozenset[str]) -> dict[str, Any]: + projected = deepcopy(dict(plan)) + projected["requests"] = project_seed_operations(plan.get("requests", []), scopes) + projected["detected_domains"] = list(dict.fromkeys(op["domain"] for op in projected["requests"])) + return projected + + +def project_seed_schedule(schedule: ModeSchedule, scopes: frozenset[str]) -> ModeSchedule: + document = schedule.to_dict() + for phase in document["phases"]: + phase["operations"] = project_seed_operations(phase["operations"], scopes) + return ModeSchedule.from_dict(document) diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index f2ca53e..1796a8f 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -12,6 +12,8 @@ from typing import Any import yaml +from cognitive_agent.application.mode_schedule import ModeSchedule + _MATCH_FORMATTING = re.compile(r"[\s()()]+") _NON_ASCII_SLUG = re.compile(r"[^a-z0-9]+") _MODE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") @@ -450,6 +452,7 @@ class ModeProfile: user_defined: bool = False instruction: str = "" command_flow: dict[str, Any] | None = None + schedule: ModeSchedule | None = None @classmethod def from_yaml(cls, path: Path, profile_root: Path | None = None) -> "ModeProfile": @@ -486,10 +489,12 @@ class ModeProfile: user_defined=_is_user_profile_path(path, profile_root), instruction=_clean_instruction(document.get("instruction", "")), command_flow=_clean_command_flow(document.get("command_flow")), + schedule=(ModeSchedule.from_dict(document["schedule"]) + if document.get("schedule") is not None else None), ) def public_dict(self) -> dict[str, Any]: - is_cyclic = self.timeline is not None or any( + is_cyclic = self.schedule is not None or self.timeline is not None or any( operation.capability == "seat.start_dynamic_mode" for operation in self.operations ) @@ -514,6 +519,9 @@ class ModeProfile: } if self.timeline is not None: result["timeline"] = self.timeline.public_dict() + if self.schedule is not None: + result["schedule"] = self.schedule.to_dict() + result["operation_count"] = self.schedule.operation_count return result @@ -656,6 +664,7 @@ class ModeProfileRegistry: }, "operations": [operation.to_yaml() for operation in profile.operations], "timeline": profile.timeline.to_yaml() if profile.timeline is not None else None, + "schedule": profile.schedule.to_dict() if profile.schedule is not None else None, "instruction": profile.instruction, "command_flow": profile.command_flow, } @@ -688,6 +697,7 @@ class ModeProfileRegistry: instruction: str = "", command_flow: dict[str, Any] | None = None, timeline: ModeTimeline | None = None, + schedule: ModeSchedule | None = None, expected_revision: str | None = None, create_only: bool = False, ) -> ModeProfile: @@ -725,6 +735,8 @@ class ModeProfileRegistry: document["command_flow"] = clean_command_flow if timeline is not None: document["timeline"] = timeline.to_yaml() + if schedule is not None: + document["schedule"] = schedule.to_dict() path = ( self.user_root / "vehicles" / vehicle_id / "user" / f"{profile_id}.yaml" if vehicle_id @@ -994,6 +1006,8 @@ def _mode_profile_document( } if profile.timeline is not None: document["timeline"] = profile.timeline.to_yaml() + if profile.schedule is not None: + document["schedule"] = profile.schedule.to_dict() if profile.instruction: document["instruction"] = profile.instruction if profile.command_flow is not None: diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py index 3befe36..7919332 100644 --- a/src/cognitive_agent/application/mode_profile_intent.py +++ b/src/cognitive_agent/application/mode_profile_intent.py @@ -9,12 +9,37 @@ _MODE_NAME = r"(?P[^,。,.!!::\s“”\"'「」『』《》]{1,64}? _QUOTED_MODE_NAME = ( r"(?P[“\"'「『《]?)" + _MODE_NAME + r"(?P[”\"'」』》]?)" ) +_CREATION_DUAL_SEAT_NAMES = ( + r"主驾(?:和|与|及)副驾|主副驾|双座(?:椅)?|(?:前排)?两(?:个|张)?(?:座椅|座位|座)" +) +_CREATION_SEAT_NAMES = ( + rf"{_CREATION_DUAL_SEAT_NAMES}|" + r"副驾驶席|副驾驶位|副驾驶|副驾|乘客座椅|乘客席|" + r"主驾驶席|主驾驶位|主驾驶|主驾|驾驶席|驾驶位" +) +_CREATION_SEAT_PREFIX = re.compile( + rf"^(?:为|给|针对|供)?(?P{_CREATION_SEAT_NAMES})" + r"(?:(?:座椅|座位|座席)?(?:专属|专用))?的?" +) +_CREATION_SEAT_CLAUSE = re.compile( + rf"^(?:仅|只)?(?:为|给|针对|适用于|用于|面向|联动|编排)" + rf"(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?" + r"(?:使用|联动|编排|的能力|的动作)?$" +) +_CREATION_SEAT_GENERIC_SCENE = re.compile( + r"^(?:座椅|座位|座席)的?(?P" + r"阅读|看书|读书|办公|观影|看电影|电影|露营)模式$" +) +_CREATION_SEAT_FUNCTION_SUBJECT = re.compile( + r"^(?:舒适|舒服|舒缓|放松|动态|循环|通风|加热|按摩|靠背|腿托|坐垫).{0,32}模式$" +) _MODE_PROFILE_CREATE_HEAD = re.compile( r"^\s*" r"(?:(?:请问(?:可以|能不能|能否|可不可以|能)帮我|" r"(?:可以|能不能|能否|可不可以|能)帮我|" r"我(?:想要|想|希望|需要|打算|准备)(?:让你)?|" r"想要|想|希望|需要|打算|准备|请你?|麻烦你?|帮我|帮忙|给我|先)\s*){0,6}" + rf"(?:(?:为|给|针对)(?P{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?\s*)?" r"(?P构建|创建|新建|新增|生成|添加)\s*" r"(?:一下\s*)?(?:一个|个|一套|套)?\s*" r"(?P命名为|名称为|取名为|名叫|名为|叫做)?\s*" @@ -35,7 +60,7 @@ _MODE_PROFILE_PURPOSE_WRAPPERS = ( r"(?P.+?)的(?:功能|场景|自定义)?模式$" ), re.compile( - r"^(?P(?:座椅|座位|乘坐|坐着).+?)的(?:功能|场景|自定义)?模式$" + r"^(?P(?:座椅|座位|乘坐|坐着|上车|空调).+?)的(?:功能|场景|自定义)?模式$" ), ) _MODE_PROFILE_PURPOSE_INVALID = frozenset( @@ -70,6 +95,7 @@ _MODE_PROFILE_CREATE_CAPABILITY_CLAUSE = re.compile( ) _MODE_PROFILE_CREATE_ASSISTED_REQUEST = re.compile( r"^(?:请问)?(?:可以|能不能|能否|可不可以|能)帮我(?:先)?" + rf"(?:(?:为|给|针对)(?:{_CREATION_SEAT_NAMES})(?:座椅|座位|座席)?)?" r"(?:构建|创建|新建|新增|生成|添加)" ) _MODE_PROFILE_CREATE_SUFFIX_UNIT = re.compile( @@ -146,6 +172,10 @@ _MODE_PROFILE_UPDATE_NEGATION = re.compile( r"不要|别(?:再)?|不用|无需|不必|禁止|不许|不得|" r"取消(?:修改|调整|更改|更新|变更|操作)|不执行|不下发|不保存" ) +_MODE_PROFILE_UPDATE_PREFIX_NEGATION = re.compile( + r"^(?:请|麻烦)?(?:先|暂时)?(?:不要|别|不用|无需|不必|禁止|不许|不得)" + r"(?:把|将|修改|调整|更改|更新|变更)" +) _MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)" _MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])" _PREFIX_UPDATE = re.compile( @@ -173,7 +203,9 @@ MODE_PROFILE_CHANGE_PREDICATE = re.compile( r"设置|设为|设成|设到|设至|" r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|" r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|" - r"复位|半开|开到|关到|拉开|拉上|上锁|解锁" + r"复位|半开|开到|关到|拉开|拉上|上锁|解锁|" + r"(?:持续|保持)(?:时间)?(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)(?:分钟|秒钟|秒|s)|" + r"(?:重复|循环)(?:[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+)次" ) MODE_PROFILE_QUERY_CUE = re.compile( r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|" @@ -233,6 +265,41 @@ def normalize_mode_profile_name(value: object) -> str: return compact_mode_profile_text(value).strip("“”\"'「」『』《》").casefold() +def _creation_seat_label(value: str) -> str: + if re.fullmatch(_CREATION_DUAL_SEAT_NAMES, value): + return "主副驾" + return "副驾" if value.startswith(("副", "乘客")) else "主驾" + + +def mode_creation_source_name(value: object) -> str: + """Remove only an explicit front-seat qualifier for source lookup. + + A scoped draft keeps its distinct display name. This helper only allows its + existing scene seed to be located; it never authorizes any seat operation or + rewrites an unsupported rear/unknown target into a front seat. + """ + + name = compact_mode_profile_text(value).strip("“”\"'「」『』《》") + match = _CREATION_SEAT_PREFIX.match(name) + if match is None: + return name + subject = name[match.end():] + if not subject or subject == "模式" or not subject.endswith("模式"): + return name + # A seat location before a generic activity is a scope wrapper. Keep the + # actual device topic in 座椅舒适/座椅动态 rather than dropping every 座椅. + generic_scene = _CREATION_SEAT_GENERIC_SCENE.fullmatch(subject) + if generic_scene is not None: + subject = f"{generic_scene.group('scene')}模式" + elif ( + match.group("seat").endswith(("座椅", "座位", "座")) + and _CREATION_SEAT_FUNCTION_SUBJECT.fullmatch(subject) + ): + subject = f"座椅{subject}" + inferred = _infer_mode_profile_purpose_name(subject) + return inferred[0] if inferred is not None else subject + + def _infer_mode_profile_purpose_name(value: object) -> tuple[str, str] | None: """Turn one bounded purpose description into a concise mode name. @@ -448,12 +515,24 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None: # Keep explicit user names byte-for-byte while giving inferred scenes a # concise, reusable name (for example 看书模式 instead of # 适合看书的功能模式). - if not str(match.group("naming") or "") and not opening: + explicit_name = bool(str(match.group("naming") or "") or opening) + if not explicit_name: inferred_purpose = _infer_mode_profile_purpose_name(name) if inferred_purpose is not None: name = inferred_purpose[0] + seat_prefix = _CREATION_SEAT_PREFIX.match(name) + if seat_prefix is not None and mode_creation_source_name(name) != name: + name = _creation_seat_label(seat_prefix.group("seat")) + mode_creation_source_name(name) + if match.group("seat") and seat_prefix is None: + name = _creation_seat_label(match.group("seat")) + name if normalize_mode_profile_name(name) in _GENERIC_MODE_PROFILE_CREATE_NAMES: return None + name_seat = _CREATION_SEAT_PREFIX.match(name) + if ( + match.group("seat") and name_seat is not None + and _creation_seat_label(match.group("seat")) != _creation_seat_label(name_seat.group("seat")) + ): + return None raw_match_end = raw_ends[match.end() - 1] if "\n" in raw[:raw_match_end] or "\r" in raw[:raw_match_end]: @@ -498,6 +577,19 @@ def parse_mode_profile_create(value: object) -> tuple[str, str] | None: return name, "" instruction = _strip_mode_profile_create_suffix(instruction) + seat_clause = _CREATION_SEAT_CLAUSE.fullmatch(compact_mode_profile_text(instruction)) + if seat_clause is not None: + # A seat-only clause selects the draft, not an extra actuator command. + # Real device settings in a following clause remain untouched below. + name_seat = _CREATION_SEAT_PREFIX.match(name) + if ( + name_seat is not None + and _creation_seat_label(name_seat.group("seat")) != _creation_seat_label(seat_clause.group("seat")) + ): + return None + if not explicit_name and name_seat is None: + name = _creation_seat_label(seat_clause.group("seat")) + name + return name, "" if ( not instruction or len(instruction) > 800 @@ -637,6 +729,7 @@ def parse_mode_profile_update( text = compact_mode_profile_text(value) if ( not text + or _MODE_PROFILE_UPDATE_PREFIX_NEGATION.search(text) or _MODE_PROFILE_CREATE_REFERENCE_CLAUSE.search(text) or _MODE_PROFILE_CREATE_UNSAFE_PREFIX.search(text) ): diff --git a/src/cognitive_agent/application/mode_schedule.py b/src/cognitive_agent/application/mode_schedule.py new file mode 100644 index 0000000..93c4c16 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule.py @@ -0,0 +1,429 @@ +"""Bounded, cancellable timelines for validated absolute cockpit operations. + +This module validates the *schedule* contract, not vehicle authorization. Callers +must validate every phase against the current registry and execute it through the +ordinary compiler/safety path. It deliberately has no CAN or ModeProfile imports. +Durations are holds after one phase dispatch, never actuator motion durations. +Cancelling prevents future phases; it does not roll back an already applied state. +""" + +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable +from copy import deepcopy +from dataclasses import dataclass, field +from threading import Event, Lock, RLock, Thread, current_thread +from typing import Any +from uuid import uuid4 + +from cognitive_agent.application.assistant_plan_guard import SCENE_COMPOSABLE_CAPABILITIES + +MODE_SCHEDULE_SCHEMA = "cognitive-agent-mode-schedule/v1" +MAX_SCHEDULE_DURATION_MS = 60 * 60 * 1000 +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_FORBIDDEN_PARAMETERS = frozenset( + {"duration", "duration_ms", "seconds", "delta", "relative", "relative_pct", + "direction", "toggle", "repeat", "repeat_count", "axis"} +) +_FAILURE_STATUSES = frozenset( + {"failed", "error", "cancelled", "canceled", "rejected", "blocked", "aborted"} +) +_SUCCESS_STATUSES = frozenset({"succeeded", "success", "ok", "completed"}) + + +def _object(value: Any, fields: set[str], required: set[str], label: str) -> dict[str, Any]: + if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): + raise ValueError(f"{label} must be an object with string keys") + if unknown := set(value) - fields: + raise ValueError(f"{label} contains unknown fields: {', '.join(sorted(unknown))}") + if missing := required - set(value): + raise ValueError(f"{label} is missing fields: {', '.join(sorted(missing))}") + return value + + +def _text(value: Any, label: str, maximum: int, *, allow_empty: bool = False) -> str: + if not isinstance(value, str) or len(value) > maximum: + raise ValueError(f"{label} must be a string of at most {maximum} characters") + if not allow_empty and not value.strip(): + raise ValueError(f"{label} must not be empty") + if "\x00" in value: + raise ValueError(f"{label} contains a null character") + return value.strip() + + +def _integer(value: Any, minimum: int, maximum: int, label: str) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise ValueError(f"{label} must be an integer between {minimum} and {maximum}") + return value + + +def _operation(value: Any) -> dict[str, Any]: + required = {"domain", "capability", "target_scope", "parameters"} + document = _object(value, required | {"operation", "reason"}, required, "schedule operation") + domain = _text(document["domain"], "operation domain", 64) + capability = _text(document["capability"], "operation capability", 128) + if capability not in SCENE_COMPOSABLE_CAPABILITIES or not capability.startswith(domain + "."): + raise ValueError("schedule capability must be a stable, scene-composable capability") + if document.get("operation", "set") != "set": + raise ValueError("schedule operations must use absolute set operations") + parameters = document["parameters"] + if not isinstance(parameters, dict) or not 1 <= len(parameters) <= 12: + raise ValueError("schedule operation parameters must contain 1 to 12 scalar fields") + for key, parameter in parameters.items(): + _text(key, "parameter name", 64) + if key.casefold() in _FORBIDDEN_PARAMETERS: + raise ValueError("schedule operations cannot contain relative, motion or timing parameters") + if parameter is None or type(parameter) not in (str, int, float, bool): + raise ValueError("schedule parameter values must be non-null JSON scalars") + if type(parameter) is float and not math.isfinite(parameter): + raise ValueError("schedule numeric parameters must be finite") + if isinstance(parameter, str): + _text(parameter, "parameter value", 256) + if parameter.casefold() in {"toggle", "increase", "decrease", "relative"}: + raise ValueError("schedule operations cannot request relative state changes") + return { + "domain": domain, + "capability": capability, + "target_scope": _text(document["target_scope"], "operation target_scope", 128), + "operation": "set", + "parameters": deepcopy(parameters), + "reason": _text(document.get("reason", ""), "operation reason", 1000, allow_empty=True), + } + + +def _operation_key(operation: dict[str, Any]) -> tuple[str, str]: + target = operation["target_scope"] + if operation["domain"] == "seat": + target = {"front_left": "driver", "front_right": "passenger"}.get(target, target) + return operation["capability"], target + + +@dataclass(frozen=True) +class ModeSchedulePhase: + id: str + name: str + duration_ms: int + operations: tuple[dict[str, Any], ...] + + def __post_init__(self) -> None: + phase_id = _text(self.id, "schedule phase id", 64) + if not _ID_PATTERN.fullmatch(phase_id): + raise ValueError("schedule phase id must contain lowercase letters, numbers, '_' or '-'") + object.__setattr__(self, "id", phase_id) + object.__setattr__(self, "name", _text(self.name, "schedule phase name", 128)) + _integer(self.duration_ms, 1000, 15 * 60 * 1000, "schedule phase duration_ms") + if not isinstance(self.operations, (list, tuple)) or not 1 <= len(self.operations) <= 12: + raise ValueError("schedule phase must contain 1 to 12 operations") + operations = tuple(_operation(operation) for operation in self.operations) + keys = [_operation_key(operation) for operation in operations] + if len(set(keys)) != len(keys): + raise ValueError("schedule phase contains duplicate capability/target operations") + object.__setattr__(self, "operations", operations) + + @classmethod + def from_dict(cls, document: dict[str, Any]) -> ModeSchedulePhase: + fields = {"id", "name", "duration_ms", "operations"} + value = _object(document, fields, fields, "schedule phase") + if not isinstance(value["operations"], list): + raise ValueError("schedule phase operations must be an array") # noqa: TRY004 + return cls(value["id"], value["name"], value["duration_ms"], tuple(value["operations"])) + + def to_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "duration_ms": self.duration_ms, + "operations": deepcopy(list(self.operations)), + } + + +@dataclass(frozen=True) +class ModeSchedule: + phases: tuple[ModeSchedulePhase, ...] + repeat_count: int = 1 + schema: str = MODE_SCHEDULE_SCHEMA + + def __post_init__(self) -> None: + if self.schema != MODE_SCHEDULE_SCHEMA: + raise ValueError("unsupported mode schedule schema") + _integer(self.repeat_count, 1, 20, "schedule repeat_count") + if not isinstance(self.phases, (list, tuple)) or not 2 <= len(self.phases) <= 8: + raise ValueError("schedule must contain 2 to 8 phases") + if any(not isinstance(phase, ModeSchedulePhase) for phase in self.phases): + raise ValueError("schedule phases must be ModeSchedulePhase objects") + object.__setattr__(self, "phases", tuple(self.phases)) + if len({phase.id for phase in self.phases}) != len(self.phases): + raise ValueError("schedule phase ids must be unique") + if self.total_duration_ms > MAX_SCHEDULE_DURATION_MS: + raise ValueError("schedule total duration including repetitions must not exceed 60 minutes") + + @property + def total_duration_ms(self) -> int: + return sum(phase.duration_ms for phase in self.phases) * self.repeat_count + + @property + def operation_count(self) -> int: + """Number of operations in one full repetition.""" + return sum(len(phase.operations) for phase in self.phases) + + @classmethod + def from_dict(cls, document: dict[str, Any]) -> ModeSchedule: + fields = {"schema", "repeat_count", "phases"} + value = _object(document, fields, fields, "schedule") + if not isinstance(value["phases"], list): + raise ValueError("schedule phases must be an array") # noqa: TRY004 + return cls( + phases=tuple(ModeSchedulePhase.from_dict(phase) for phase in value["phases"]), + repeat_count=value["repeat_count"], + schema=value["schema"], + ) + + def to_dict(self) -> dict[str, Any]: + return { + "schema": self.schema, + "repeat_count": self.repeat_count, + "phases": [phase.to_dict() for phase in self.phases], + } + + +ExecutePhase = Callable[[ModeSchedulePhase], dict[str, Any]] +WaitFunction = Callable[[Event, float], bool] + + +def _result_error(result: Any) -> str | None: + if not isinstance(result, dict): + return "phase executor did not return an object with an explicit success result" + status = result.get("status") + if result.get("success") is False or result.get("error") or status in _FAILURE_STATUSES: + return str(result.get("error") or result.get("message") or "phase execution failed")[:1000] + if result.get("success") is True or status in _SUCCESS_STATUSES: + return None + return str(result.get("error") or "phase executor did not confirm successful execution")[:1000] + + +@dataclass +class _Run: + vehicle_id: str + session_id: str + schedule: ModeSchedule + started_at: float + run_id: str = field(default_factory=lambda: uuid4().hex) + cancelled: Event = field(default_factory=Event) + thread: Thread | None = None + status: str = "running" + phase_index: int = -1 + repeat_index: int = 0 + executed_phases: int = 0 + error: str | None = None + cancel_reason: str | None = None + finished_at: float | None = None + + +class ModeScheduleRunner: + """At most one active timeline and one phase callback per vehicle. + + A replacement cancels its predecessor immediately and waits for any already + running phase callback to return before dispatching. Callbacks must themselves + be bounded and cancellation never implies undoing a previously sent operation. + ``wait(event, seconds)`` may be injected for deterministic tests; like Event.wait + it returns true on cancellation. No callback runs while the state lock is held. + """ + + def __init__( + self, + *, + clock: Callable[[], float] = time.monotonic, + wait: WaitFunction | None = None, + ) -> None: + self._clock = clock + self._wait = wait or (lambda event, seconds: event.wait(seconds)) + self._lock = RLock() + self._runs: dict[str, _Run] = {} + self._dispatch_locks: dict[str, Lock] = {} + self._workers: dict[str, _Run] = {} + self._closed = False + + def _snapshot(self, run: _Run) -> dict[str, Any]: + end = run.finished_at if run.finished_at is not None else self._clock() + return { + "run_id": run.run_id, + "vehicle_id": run.vehicle_id, + "session_id": run.session_id, + "status": run.status, + "phase_index": run.phase_index, + "repeat_index": run.repeat_index, + "executed_phases": run.executed_phases, + "phase_count": len(run.schedule.phases), + "repeat_count": run.schedule.repeat_count, + "elapsed_ms": max(0, round((end - run.started_at) * 1000)), + "total_duration_ms": run.schedule.total_duration_ms, + "completed": run.status == "completed", + "error": run.error, + "cancel_reason": run.cancel_reason, + } + + def status(self, vehicle_id: str) -> dict[str, Any]: + with self._lock: + run = self._runs.get(vehicle_id) + if run is not None: + return self._snapshot(run) + return { + "vehicle_id": vehicle_id, "status": "idle", "phase_index": -1, + "repeat_index": 0, "completed": False, "error": None, + } + + def _cancel(self, run: _Run, reason: str) -> None: + if run.status == "running": + run.cancelled.set() + run.status = "cancelled" + run.cancel_reason = reason + run.finished_at = self._clock() + + def cancel(self, vehicle_id: str, *, session_id: str | None = None) -> dict[str, Any]: + """Cancel a vehicle timeline, optionally only if it belongs to this session.""" + with self._lock: + run = self._runs.get(vehicle_id) + if run is not None and (session_id is None or session_id == run.session_id): + self._cancel(run, "cancelled") + return self.status(vehicle_id) + + def start( + self, + vehicle_id: str, + schedule: ModeSchedule, + execute_phase: ExecutePhase, + *, + session_id: str = "", + ) -> dict[str, Any]: + vehicle_id = _text(vehicle_id, "vehicle_id", 128) + session_id = _text(session_id, "session_id", 256, allow_empty=True) + if not isinstance(schedule, ModeSchedule) or not callable(execute_phase): + raise ValueError("start requires a ModeSchedule and callable phase executor") # noqa: TRY004 + # Snapshot/revalidate to prevent caller-owned parameter dictionaries from + # altering the executable contract after the runner has accepted it. + accepted = ModeSchedule.from_dict(schedule.to_dict()) + with self._lock: + if self._closed: + raise RuntimeError("mode schedule runner is closed") + previous = self._runs.get(vehicle_id) + if previous is not None: + self._cancel(previous, "replaced") + dispatch_lock = self._dispatch_locks.setdefault(vehicle_id, Lock()) + run = _Run(vehicle_id, session_id, accepted, self._clock()) + self._runs[vehicle_id] = run + self._workers[run.run_id] = run + run.thread = Thread( + target=self._run, args=(run, dispatch_lock, execute_phase), + name=f"mode-schedule-{run.run_id[:8]}", daemon=True, + ) + try: + run.thread.start() + except RuntimeError: + run.status = "failed" + run.error = "schedule worker could not start" + run.finished_at = self._clock() + self._workers.pop(run.run_id, None) + raise + return self._snapshot(run) + + def _run(self, run: _Run, dispatch_lock: Lock, execute_phase: ExecutePhase) -> None: + try: + for repeat_index in range(run.schedule.repeat_count): + for phase_index, phase in enumerate(run.schedule.phases): + # A cancelled replacement must not wait indefinitely behind + # an unrelated in-flight callback just to observe cancellation. + while not dispatch_lock.acquire(timeout=0.05): + if run.cancelled.is_set(): + return + try: + with self._lock: + if run.cancelled.is_set(): + return + run.repeat_index = repeat_index + run.phase_index = phase_index + # Isolate executor mutations from subsequent repetitions. + result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict())) + finally: + dispatch_lock.release() + with self._lock: + if run.cancelled.is_set(): + return + if error := _result_error(result): + run.status = "failed" + run.error = error + run.finished_at = self._clock() + return + run.executed_phases += 1 + if self._wait(run.cancelled, phase.duration_ms / 1000): + with self._lock: + self._cancel(run, "cancelled") + return + if run.cancelled.is_set(): + return + with self._lock: + if not run.cancelled.is_set(): + run.status = "completed" + run.finished_at = self._clock() + except Exception as exc: # noqa: BLE001 - any executor failure must stop later phases + with self._lock: + if not run.cancelled.is_set(): + run.status = "failed" + run.error = f"{type(exc).__name__}: {exc}"[:1000] + run.finished_at = self._clock() + finally: + with self._lock: + self._workers.pop(run.run_id, None) + + @staticmethod + def simulate(schedule: ModeSchedule, execute_phase: ExecutePhase) -> dict[str, Any]: + """Validate/execute phases synchronously with virtual holds and no thread. + + The supplied callback must select the ordinary *dry-run* execution path; + this method never changes an executor's hardware policy implicitly. + """ + if not isinstance(schedule, ModeSchedule) or not callable(execute_phase): + raise ValueError("simulate requires a ModeSchedule and callable phase executor") # noqa: TRY004 + schedule = ModeSchedule.from_dict(schedule.to_dict()) + report: dict[str, Any] = { + "status": "completed", "completed": True, "error": None, + "execution_mode": "dry_run", "virtual_elapsed_ms": 0, + "total_duration_ms": schedule.total_duration_ms, + "phase_index": -1, "repeat_index": 0, "executed_phases": 0, + "phase_results": [], + } + for repeat_index in range(schedule.repeat_count): + for phase_index, phase in enumerate(schedule.phases): + report.update(phase_index=phase_index, repeat_index=repeat_index) + try: + result = execute_phase(ModeSchedulePhase.from_dict(phase.to_dict())) + error = _result_error(result) + except Exception as exc: # noqa: BLE001 - simulation uses the same fail-closed policy + result = None + error = f"{type(exc).__name__}: {exc}"[:1000] + report["phase_results"].append({ + "phase_id": phase.id, "phase_index": phase_index, + "repeat_index": repeat_index, "duration_ms": phase.duration_ms, + "virtual_start_ms": report["virtual_elapsed_ms"], "result": result, + }) + if error: + report.update(status="failed", completed=False, error=error) + return report + report["executed_phases"] += 1 + report["virtual_elapsed_ms"] += phase.duration_ms + return report + + def close(self, *, timeout: float = 2.0) -> bool: + """Cancel all waits; return whether bounded, already-running callbacks exited.""" + with self._lock: + self._closed = True + workers = list(self._workers.values()) + for run in workers: + self._cancel(run, "closed") + deadline = time.monotonic() + max(0, timeout) + for run in workers: + if run.thread is not None and run.thread is not current_thread(): + run.thread.join(max(0, deadline - time.monotonic())) + return all(run.thread is None or not run.thread.is_alive() for run in workers) diff --git a/src/cognitive_agent/application/mode_schedule_edit.py b/src/cognitive_agent/application/mode_schedule_edit.py new file mode 100644 index 0000000..4b65ae8 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule_edit.py @@ -0,0 +1,314 @@ +"""Phase-aware natural-language edits, always revalidated before persistence.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from copy import deepcopy +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule + +_NUMBER = r"[0-9]+(?:\.[0-9]+)?|[零一二两三四五六七八九十]+" +_PHASE = re.compile(r"第(?P[0-9]+|[一二两三四五六七八九十]+)(?:个)?(?:阶段|步)") +_ALL_PHASES = re.compile(r"(?:全部|所有|每个|每一|各个|各)(?:的)?阶段") +_HOLD = re.compile( + rf"(?:持续时间|保持时间|持续|保持|时长|^(?:把|将|的)?(?=调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为))\s*" + rf"(?:调整为|调整到|设置为|设置成|设为|改为|改成|变成|变为)?\s*" + rf"(?P{_NUMBER})\s*(?P分钟|秒钟|秒|minutes?|mins?|min|seconds?|secs?|s)" +) +_REPEAT = re.compile( + rf"(?:重复|循环)(?:次数)?\s*(?:调整为|调整到|设置为|设为|改为|改成)?\s*" + rf"(?P{_NUMBER})\s*次" +) + + +def _number(value: str) -> float: + if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value): + return float(value) + digits = {char: index for index, char in enumerate("零一二三四五六七八九")} + if value == "两": + return 2 + if "十" in value: + tens, ones = value.split("十", 1) + if (tens and tens not in digits) or (ones and ones not in digits): + raise ValueError("请使用有效的整数编号或次数。") + return (digits[tens] if tens else 1) * 10 + (digits[ones] if ones else 0) + if value in digits: + return digits[value] + raise ValueError("请使用明确的阶段编号、持续时间或重复次数。") + + +def edit_schedule( + schedule: ModeSchedule, + change_text: str, + *, + parse_operations: Callable[[str], list[dict[str, Any]]], + target_is_explicit: Callable[[str, str], bool], +) -> ModeSchedule: + """Edit one/all phases; an unscoped differing trajectory needs clarification.""" + text = change_text.strip() + if not text: + raise ValueError("请说明要修改的阶段、动作、时长或重复次数。") + if re.search(r"(?:秒钟|分钟|秒|s)(?:之后|以后|后|再)", text): + raise ValueError( + "阶段时间表示动作执行后的保持时长,请明确阶段动作和持续时间,不使用延时后执行的表述。" + ) + source = schedule.to_dict() + selected: set[int] | None = None + phase_matches = list(_PHASE.finditer(text)) + all_match = _ALL_PHASES.search(text) + if all_match: + if phase_matches: + raise ValueError("请分别修改单个阶段或全部阶段,不要混用阶段范围。") + selected = set(range(len(schedule.phases))) + text = _ALL_PHASES.sub("", text) + elif phase_matches: + indexes = {int(_number(match.group("number"))) - 1 for match in phase_matches} + if len(indexes) != 1 or any( + index < 0 or index >= len(schedule.phases) for index in indexes + ): + raise ValueError(f"当前只有{len(schedule.phases)}个阶段,请明确一次修改哪一个阶段。") + selected = indexes + text = _PHASE.sub("", text) + else: + named = {index for index, phase in enumerate(schedule.phases) if phase.name in text} + if len(named) > 1: + raise ValueError("请一次修改一个阶段,或明确指定全部阶段。") + if named: + selected = named + phase_name = schedule.phases[next(iter(named))].name + text = text.replace(phase_name + "阶段", "").replace(phase_name, "") + + repeats = list(_REPEAT.finditer(text)) + if repeats: + if len(repeats) != 1: + raise ValueError("请提供一个明确的整条时间轴重复次数。") + count = _number(repeats[0].group("number")) + if count != int(count): + raise ValueError("重复次数必须为整数。") + source["repeat_count"] = int(count) + text = _REPEAT.sub("", text) + + holds = list(_HOLD.finditer(text)) + if holds: + if len(holds) != 1 or selected is None: + raise ValueError("修改持续时间需要明确第几个阶段或全部阶段。") + seconds = _number(holds[0].group("number")) + if holds[0].group("unit") in {"分钟", "minute", "minutes", "min", "mins"}: + seconds *= 60 + milliseconds = seconds * 1000 + if milliseconds != int(milliseconds): + raise ValueError("阶段持续时间最多精确到毫秒。") + for index in selected: + source["phases"][index]["duration_ms"] = int(milliseconds) + text = _HOLD.sub("", text) + + if re.search(rf"(?:{_NUMBER})\s*(?:分钟|秒钟|秒|minutes?|mins?|seconds?|secs?|s)", text): + raise ValueError("动态阶段只支持明确绝对设备目标;时长请单独表述为阶段保持时间,不支持按秒运动。") + + text = re.sub(r"改为|改成|变更为|换成|变成|变为", "调整为", text) + text = re.sub( + r"^(?:把|将|的|中|里|阶段)+|(?:然后|并且|同时|并|,|,|;|;|。|\s)+$", "", text + ).strip("的中里,,;;。 ") + changes = parse_operations(text) if text else [] + if not changes and not holds and not repeats: + raise ValueError("没有识别到可执行的阶段调整,请说明设备目标、阶段时长或重复次数。") + for raw_change in changes: + change = deepcopy(raw_change) + indexes = selected if selected is not None else set(range(len(schedule.phases))) + candidates = [ + (index, operation) + for index in indexes + for operation in source["phases"][index]["operations"] + if operation["domain"] == change["domain"] + and operation["capability"] == change["capability"] + ] + if not target_is_explicit(text, change["target_scope"]): + targets = {operation["target_scope"] for _, operation in candidates} + if len(targets) > 1: + raise ValueError("该阶段的设备有多个区域,请明确主驾、副驾或具体目标区域。") + if targets: + change["target_scope"] = next(iter(targets)) + candidates = [ + (index, operation) + for index, operation in candidates + if operation["target_scope"] == change["target_scope"] + ] + if selected is None: + values = { + json.dumps(operation["parameters"], sort_keys=True, ensure_ascii=False) + for _, operation in candidates + } + if len(values) > 1: + raise ValueError("该设备在多个阶段使用不同目标,请明确第几个阶段,或说“全部阶段”。") + if not candidates: + raise ValueError("新增设备动作需要明确放入第几个阶段,或明确全部阶段。") + indexes = {index for index, _ in candidates} + key = (change["capability"], change["target_scope"]) + for index in indexes: + operations = source["phases"][index]["operations"] + found = False + for position, operation in enumerate(operations): + if (operation["capability"], operation["target_scope"]) == key: + operations[position] = { + **deepcopy(operation), + "parameters": deepcopy(change["parameters"]), + } + found = True + if not found: + operations.append(deepcopy(change)) + return ModeSchedule.from_dict(source) + + +def revise_schedule( + service, + schedule: ModeSchedule, + *, + change_text: str, + utterance: str, + name: str, + conversation_id: str, + vehicle_profile, + context: dict[str, Any], + control_transport: str, + source_draft=None, + source_profile=None, +) -> dict[str, Any]: + """Parse changed atoms locally, then recompile every phase before success.""" + + def parse_changes(text): + if any( + status != "complete" + for _clause, _parsed, status in service._local_clause_results(text, context) + ): + raise ValueError("阶段调整说明不完整,请使用设备和明确目标值;未修改原时间轴。") + result = service._process_once( + text, + "local", + conversation_id, + "dry_run", + None, + vehicle_profile.id, + control_transport=control_transport, + allow_fuzzy_mode_profile_match=False, + ) + if ( + result.get("status") != "succeeded" + or result.get("deferred_requests") + or result.get("unresolved_clauses") + or result.get("supplement_required") + or any(issue.get("severity") == "error" for issue in result.get("validation", [])) + ): + raise ValueError("阶段动作未通过完整的设备能力或安全校验。") + operations = service._absolute_mode_operations(service._preview_mode_operations(result)) + if not operations: + raise ValueError("未识别到可复用的绝对设备目标。") + return operations + + revised = edit_schedule( + schedule, + change_text, + parse_operations=parse_changes, + target_is_explicit=service._mode_update_target_is_explicit, + ) + preview = service._preview_mode_schedule( + revised, + name=name, + utterance=utterance, + conversation_id=conversation_id, + vehicle_profile=vehicle_profile, + control_transport=control_transport, + generation_source="draft_revision" if source_draft else "registered_mode_profile", + source_draft=source_draft, + source_profile=source_profile, + ) + if preview.get("status") == "succeeded" and source_draft: + draft = preview["mode_profile_draft"] + draft["revises_source_request_id"] = source_draft["source_request_id"] + for key in ("source_template_id", "source_template_revision"): + if key in source_draft: + draft[key] = source_draft[key] + service._persist(preview) + return preview + + +def update_saved_schedule( + service, + profile, + *, + change_text: str, + utterance: str, + conversation_id: str, + vehicle_profile, + context: dict[str, Any], + result: dict[str, Any], +) -> dict[str, Any]: + try: + preview = revise_schedule( + service, + profile.schedule, + change_text=change_text, + utterance=utterance, + name=profile.name, + conversation_id=conversation_id, + vehicle_profile=vehicle_profile, + context=context, + control_transport="wired_can", + source_profile=profile, + ) + if preview.get("status") != "succeeded": + return preview + draft = preview["mode_profile_draft"] + saved = service.save_mode_profile_card( + profile.id, + draft["name"], + draft["instruction"], + draft["source_request_id"], + vehicle_profile.id, + ) + updated = service.mode_profiles.get_profile(profile.id, vehicle_profile.id) + report = service._mode_profile_control_report( + result["request_id"], + "update_mode_profile", + updated, + f"已更新“{profile.name}”的阶段时间轴;未向车辆下发控制。", + {"can_command_emitted": False, "phase_count": len(updated.schedule.phases)}, + ) + result.update( + status="succeeded", + execution_mode="dry_run", + hardware_execution_allowed=False, + orchestration_route="mode_profile_update", + provider_used="mode_schedule_editor", + mode_profile=saved["mode_profile"], + matched_modes=[saved["mode_profile"]], + schedule=updated.schedule.to_dict(), + execution_report=report, + validation=service.contracts.validate(report), + mode_profile_changes=[ + deepcopy(op) for phase in updated.schedule.phases for op in phase.operations + ], + decision_summary=[ + f"已更新“{profile.name}”的阶段时间轴,并完整重新校验全部阶段;未下发车辆控制。" + ], + ) + except ValueError as exc: + result.update( + status="needs_confirmation", + execution_mode="dry_run", + hardware_execution_allowed=False, + orchestration_route="mode_profile_update", + validation=[ + { + "severity": "error", + "code": "MODE_SCHEDULE_UPDATE_REJECTED", + "path": "schedule", + "message": str(exc), + } + ], + decision_summary=[f"未修改“{profile.name}”的时间轴:{exc}"], + ) + service._persist(result) + return result diff --git a/src/cognitive_agent/application/mode_schedule_service.py b/src/cognitive_agent/application/mode_schedule_service.py new file mode 100644 index 0000000..0f98cd5 --- /dev/null +++ b/src/cognitive_agent/application/mode_schedule_service.py @@ -0,0 +1,238 @@ +"""Service integration for server-owned, fully validated phase schedules.""" +from __future__ import annotations + +import uuid +from copy import deepcopy +from datetime import UTC, datetime +from typing import Any + +from cognitive_agent.application.mode_schedule import ModeSchedule + + +def public_schedule_status(state: dict[str, Any]) -> dict[str, Any]: + """Expose lifecycle evidence, never internal errors, commands or CAN frames.""" + keys = ("run_id", "vehicle_id", "session_id", "status", "phase_index", "repeat_index", + "executed_phases", "phase_count", "repeat_count", "elapsed_ms", "total_duration_ms", + "virtual_elapsed_ms", "completed", "cancel_reason") + public = {key: value for key in keys if isinstance((value := state.get(key)), (str, int, bool))} + if state.get("error"): + public["error"] = "阶段执行未通过校验或未返回成功结果,后续阶段已停止。" + return public + + +def phase_plan(phase) -> dict[str, Any]: + operations = phase.operations + return { + "detected_domains": list(dict.fromkeys(op["domain"] for op in operations)), + "requests": [{**{key: op[key] for key in ("domain", "capability", "target_scope")}, + "operation": "set", "duration_ms": None, + "parameters": [{"name": key, "value": value} for key, value in op["parameters"].items()], + "reason": op.get("reason") or "已校验时间轴阶段。"} for op in operations], + "ambiguities": [], "confidence": 1.0, + "decision_summary": ["按阶段保留动作顺序与持续时间。"], + } + + +class ModeScheduleServiceMixin: + def _validated_schedule_artifact(self, result: dict[str, Any]) -> ModeSchedule | None: + draft = result.get("mode_profile_draft") or {} + raw = result.get("schedule") + if raw is None and draft.get("schedule") is None: + return None + schedule = ModeSchedule.from_dict(raw) + if draft.get("schedule") != schedule.to_dict() or draft.get("operation_count") != schedule.operation_count: + raise ValueError("时间轴草稿结构和服务端证据不一致,请重新生成。") + actions = (result.get("intent_ir") or {}).get("spec", {}).get("actions", []) + if len(actions) != schedule.operation_count: + raise ValueError("时间轴草稿动作证据不完整,请重新生成。") + offset = 0 + for phase in schedule.phases: + phase_actions = actions[offset:offset + len(phase.operations)] + plan = phase_plan(phase) + if (self._scene_plan_signatures(plan) != self._scene_action_signatures(phase_actions) + or not self._scene_parameters_preserved(plan, phase_actions)): + raise ValueError("时间轴阶段目标与校验结果不一致,请重新生成。") + offset += len(phase.operations) + return schedule + + def _preview_mode_schedule( + self, schedule: ModeSchedule, *, name: str, utterance: str, + conversation_id: str, vehicle_profile, control_transport: str, + generation_source: str, source_profile=None, source_template=None, + source_draft: dict[str, Any] | None = None, + scene_scope_utterance: str | None = None, + ) -> dict[str, Any]: + results = [] + for phase in schedule.phases: + plan = phase_plan(phase) + preview = self._process_once( + f"进入{name}", "local", conversation_id, "dry_run", None, + vehicle_profile.id, control_transport=control_transport, + assistant_plan=plan, allow_scene_composition=True, + allow_fuzzy_mode_profile_match=False, + # An existing-name binding is not evidence that a newly + # supplied model plan came from the saved profile. New builds + # pass the original request even when reusing a saved name. + scene_scope_utterance=(scene_scope_utterance if scene_scope_utterance is not None + else "" if source_draft or source_profile else utterance), + ) + actions = (preview.get("intent_ir") or {}).get("spec", {}).get("actions", []) + complete = (preview.get("status") == "succeeded" + and self._scene_plan_signatures(plan) == self._scene_action_signatures(actions) + and self._scene_parameters_preserved(plan, actions)) + if not complete: + preview["status"] = "validation_failed" + self._scrub_failed_scene_preview(preview) + preview["mode_creation_failure"] = { + "code": "MODE_SCHEDULE_PHASE_INVALID", + "reason": f"时间轴阶段“{phase.name}”未完整通过设备能力和安全校验", + "next_step": "请调整该阶段的设备目标;没有保存或执行任何阶段", + } + preview["validation"] = [*preview.get("validation", []), { + "severity": "error", "code": "MODE_SCHEDULE_PHASE_INVALID", + "path": f"schedule.phases.{phase.id}", + "message": preview["mode_creation_failure"]["reason"], + }] + self._persist(preview) + return preview + results.append(preview) + result = deepcopy(results[0]) + request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}" + instruction = f"进入{name}" + result.update(request_id=request_id, utterance=instruction, status="succeeded", + execution_mode="dry_run", hardware_execution_allowed=False, + orchestration_route="mode_profile_create_preview") + actions = [] + for phase, preview in zip(schedule.phases, results, strict=True): + for original in preview["intent_ir"]["spec"]["actions"]: + action = deepcopy(original) + if "id" in action: + action["id"] = f"{phase.id}-{action['id']}" + actions.append(action) + result["intent_ir"]["spec"]["actions"] = actions + for field in ("function_configs", "command_plans"): + result[field] = {f"{phase.id}-{key}": deepcopy(value) + for phase, preview in zip(schedule.phases, results, strict=True) + for key, value in preview[field].items()} + result["function_config"] = None + result["command_plan"] = None + result["schedule"] = schedule.to_dict() + result["schedule_phase_previews"] = [ + {"phase_id": phase.id, "source_request_id": preview["request_id"], + "status": preview["status"], "duration_ms": phase.duration_ms} + for phase, preview in zip(schedule.phases, results, strict=True) + ] + binding = ({"existing_mode_id": source_profile.id, + "existing_mode_revision": self.mode_profiles.profile_revision(source_profile)} + if source_profile is not None else {}) + if source_draft: + binding = {key: source_draft[key] for key in ("existing_mode_id", "existing_mode_revision") if key in source_draft} + result["mode_profile_draft"] = { + "name": name, "instruction": instruction, "source_request_id": request_id, + "vehicle_id": vehicle_profile.id, "operation_count": schedule.operation_count, + "domains": sorted({op["domain"] for phase in schedule.phases for op in phase.operations}), + "generation_source": generation_source, "schedule": schedule.to_dict(), + "draft_revision": int((source_draft or {}).get("draft_revision", 0)) + 1, + **binding, + **({"source_template_id": source_template.id, "source_template_revision": source_template.revision} + if source_template is not None else {}), + } + result["mode_creation"] = {"requested_utterance": utterance, "source_utterance": instruction, + "source_status": "succeeded", "operation_count": schedule.operation_count} + result["decision_summary"] = [ + f"已逐阶段验证“{name}”:{len(schedule.phases)}个阶段,重复{schedule.repeat_count}次,总计{schedule.total_duration_ms // 1000}秒。", + "当前仅生成草稿;保存后进入模式才开始计时执行。停止会取消后续阶段,已应用的设备状态保持。", + ] + self._persist(result) + return result + + def _run_mode_schedule( + self, profile, *, utterance: str, conversation_id: str | None, + execution_mode: str, approval_token: str | None, vehicle_id: str, + simulation_fault: str, hardware_client_id: str | None, + hardware_access_bypass: bool, control_transport: str, + ) -> dict[str, Any]: + schedule = ModeSchedule.from_dict(profile.schedule.to_dict()) + requested, supported = self._explicit_mode_seat_scopes(profile, utterance) + if requested and not requested.issubset(supported): + raise ValueError("该动态模式不支持指定座位,不会替换或扩大控制目标。") + if requested and requested != supported: + document = schedule.to_dict() + for phase in document["phases"]: + phase["operations"] = [op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] in requested] + # An empty phase is not silently removed: that would alter timing. + schedule = ModeSchedule.from_dict(document) + conversation_id = conversation_id or f"conv-{uuid.uuid4().hex}" + vehicle = self.vehicle_profiles.get(vehicle_id) + # Revalidate the entire saved schedule before the first write. No stale + # cached command bytes or client-authored phase plans are replayed. + preview = self._preview_mode_schedule( + schedule, name=profile.name, utterance="已保存时间轴复核", + conversation_id=conversation_id, vehicle_profile=vehicle, + control_transport=control_transport, generation_source="registered_mode_profile", + source_profile=profile, + ) + if preview["status"] != "succeeded": + return preview + + def execute_phase(phase): + context = self._context_for_vehicle(self.conversations.snapshot(conversation_id), vehicle) + parsed, _, _ = self._interpret( + "执行已验证的时间轴阶段", "local", context, vehicle, + assistant_plan=phase_plan(phase), allow_scene_composition=True, + scene_scope_utterance="", + ) + return self.process( + "执行已验证的时间轴阶段", provider="local", conversation_id=conversation_id, + execution_mode=execution_mode, approval_token=approval_token, + vehicle_id=vehicle_id, simulation_fault=simulation_fault, + hardware_client_id=hardware_client_id, hardware_access_bypass=hardware_access_bypass, + control_transport=control_transport, _direct_can_batch=([], parsed), + ) + + if execution_mode == "hardware": + # Authentication/bench ownership is checked now and again for each + # phase, so a later lease loss stops further device commands. + if not hardware_access_bypass and control_transport != "wireless_mqtt" and not self._driver_only_vehicle_execution_active(control_transport): + self.hardware_access.ensure_authorized(hardware_client_id) + execution = self.mode_schedule_runner.start(vehicle_id, schedule, execute_phase, session_id=conversation_id) + else: + execution = self.mode_schedule_runner.simulate(schedule, execute_phase) + result = deepcopy(preview) + result.pop("mode_profile_draft", None) + result.update( + status="running" if execution["status"] == "running" else "succeeded" if execution.get("completed") else "failed", + utterance=utterance, execution_mode=execution_mode, + hardware_execution_allowed=execution_mode == "hardware", + orchestration_route="mode_schedule_execute", schedule_execution=execution, + mode_profile=profile.public_dict(), matched_modes=[profile.public_dict()], + ) + result["decision_summary"] = [ + f"已启动“{profile.name}”时间轴。" if execution["status"] == "running" + else f"“{profile.name}”已按虚拟时钟验证全部阶段。" if execution.get("completed") + else "时间轴执行失败,后续阶段已停止。", + "停止动态模式可取消后续阶段;已下发的设备目标不会自动回滚。", + ] + self._persist(result) + return result + + def _cancel_mode_schedule(self, vehicle_id: str, conversation_id: str | None, utterance: str, + execution_mode: str) -> dict[str, Any]: + state = (self.mode_schedule_runner.cancel(vehicle_id) if execution_mode == "hardware" + else {"status": "simulated", "completed": True, "vehicle_id": vehicle_id}) + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "status": "succeeded", "execution_mode": execution_mode, "hardware_execution_allowed": False, + "vehicle": {"id": vehicle_id}, + "utterance": utterance, "conversation_id": conversation_id or f"conv-{uuid.uuid4().hex}", + "orchestration_route": "mode_schedule_stop", "schedule_execution": state, + "decision_summary": [ + "已模拟停止动态模式,未改变正在运行的车辆任务。" if execution_mode != "hardware" + else "已停止动态模式后续阶段。已下发目标保持,不自动回滚。" if state.get("status") == "cancelled" + else "当前没有正在执行的动态时间轴。" + ], + "validation": [], "trace_events": [], + } + self._persist(result) + return result diff --git a/src/cognitive_agent/application/scene_composer.py b/src/cognitive_agent/application/scene_composer.py index adc430e..7685401 100644 --- a/src/cognitive_agent/application/scene_composer.py +++ b/src/cognitive_agent/application/scene_composer.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import math from collections.abc import Mapping, Sequence +from copy import deepcopy from itertools import combinations from typing import Any @@ -13,8 +14,13 @@ from cognitive_agent.application.assistant_plan_guard import ( MAX_PLAN_PARAMETERS, MAX_PLAN_REQUESTS, ) +from cognitive_agent.application.mode_creation_seat_policy import ( + creation_seat_scopes, + normalized_seat_scope, +) +from cognitive_agent.application.mode_schedule import ModeSchedule -SCENE_PLANNER_VERSION = "n7web-scene-composer/v3" +SCENE_PLANNER_VERSION = "n7web-scene-composer/v5" _MAX_OUTPUT_SCHEMA_BYTES = 64 * 1024 _JSON_PARAMETER_TYPES = frozenset({"boolean", "integer", "number", "string"}) @@ -50,8 +56,14 @@ the complete live registry, parameters, compiler, safety policy, and dry-run res The primary user need must be represented in the actions. For sitting comfort, seating relaxation or rest, consider supported seat functions before auxiliary climate and lights; climate and lights alone do not fulfil a seating request. -For an unqualified front-seat comfort scene, consider both driver and passenger -using each seat's own listed capabilities. Respect an explicitly named seat. +Mode creation defaults to the passenger seat only. Include driver seat actions +only when the user explicitly selects the driver; in that case omit passenger +seat actions. Include both seats only when explicitly requested. Respect seat +exclusions. Seat selection does not exclude unrelated cabin devices or confuse +a driver/passenger HVAC zone with a request to move that seat. Never reinterpret +rear or unknown seats as either front seat. The seat_scope_policy in the context +is authoritative. Use each selected seat's own listed capabilities; never +retarget a passenger capability to the driver or vice versa. Driver heating/ventilation native levels use 2 for low and 1 for high; passenger levels use 1 for low. Driver massage supports action=mode with one listed mode, action=strength with strength, or action=off. Do not mix these action parameters. @@ -62,11 +74,59 @@ is unavailable, return an ambiguity rather than an unrelated cabin-only scene. Return only the JSON object required by the output schema. """ +SCHEDULE_COMPOSER_INSTRUCTIONS = SCENE_COMPOSER_INSTRUCTIONS + """ +This request requires a bounded temporal mode, not one static final state. +The static root shape above is replaced by the phase schema below. Capability +uniqueness and the 12-action limit apply within each phase, not across phases. +The ban on timed actions means motor-duration commands, not bounded phase holds. +Return an empty root requests array and a schedule containing 2-8 ordered phases. +Each phase has id, name, duration_ms and requests using the same capability contract. +Hold a phase for its duration, then execute the next phase; repeat_count is 1-20. +Every phase lasts 1 second to 15 minutes; total duration including repeats <= 1 hour. +Use a finite, conservative timeline. Boarding warmup should first warm the cabin +then reduce temperature/fan for comfort. Seat dynamic modes must apply the same +selected seat scope in every phase: default passenger absolute targets, or driver +comfort states if the user selected the driver; never invent driver axes or add +the other seat merely to make the timeline dynamic. +Respect explicit user temperatures, times, repetitions, seat scopes and exclusions. +Do not collapse successive values of the same device into a single final value. +No unattended calendar scheduling or wakeup is supported. Stop cancels future phases; +already applied states remain. If the requested timing is unsupported, give ambiguity. +""" + class SceneCompositionError(ValueError): """Raised when a model scene candidate cannot enter the local safety chain.""" +def scene_context_for_mode_creation( + context: Mapping[str, object], message: str, +) -> dict[str, object]: + """Constrain model seat choices without hiding unrelated cabin capabilities.""" + + projected = deepcopy(dict(context)) + scopes = creation_seat_scopes(message) + capabilities = [] + for capability in projected.get("capabilities", []): + if not isinstance(capability, dict): + continue + if capability.get("domain") != "seat": + capabilities.append(capability) + continue + selected_targets = [target for target in capability.get("target_scopes", []) + if normalized_seat_scope(target) in scopes] + if selected_targets: + capability["target_scopes"] = selected_targets + capabilities.append(capability) + projected["capabilities"] = capabilities + projected["seat_scope_policy"] = { + "selected_scopes": sorted(scopes), + "default_scope": "passenger", + "applies_to": "seat capabilities in every phase", + } + return projected + + def _is_relative_parameter(parameter: Mapping[str, object]) -> bool: """Return whether a parameter describes a transient or relative movement.""" @@ -229,7 +289,7 @@ def _capability_schema_branch(capability: Mapping[str, object]) -> dict[str, obj } -def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object]: +def build_scene_output_schema(context: Mapping[str, object], *, dynamic: bool = False) -> dict[str, object]: """Build a compact strict schema from the projected scene capability data.""" raw_capabilities = context.get("capabilities", []) @@ -267,6 +327,27 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object "confidence": {"type": "number", "minimum": 0, "maximum": 1}, }, } + if dynamic: + phase_requests = schema["properties"]["requests"] + schema["properties"]["requests"] = {"type": "array", "maxItems": 0, "items": {"type": "string"}} + schema["required"].append("schedule") + schema["properties"]["schedule"] = { + "anyOf": [{"type": "null"}, { + "type": "object", "additionalProperties": False, + "required": ["repeat_count", "phases"], + "properties": { + "repeat_count": {"type": "integer", "minimum": 1, "maximum": 20}, + "phases": {"type": "array", "minItems": 2, "maxItems": 8, + "items": {"type": "object", "additionalProperties": False, + "required": ["id", "name", "duration_ms", "requests"], + "properties": { + "id": {"type": "string"}, "name": {"type": "string"}, + "duration_ms": {"type": "integer", "minimum": 1000, "maximum": 900000}, + "requests": phase_requests, + }}}, + }, + }], + } encoded_schema = json.dumps( schema, ensure_ascii=False, @@ -280,9 +361,41 @@ def build_scene_output_schema(context: Mapping[str, object]) -> dict[str, object def normalize_scene_output( value: Mapping[str, Any], context: Mapping[str, object], + *, dynamic: bool = False, ) -> dict[str, Any]: """Expand the lean model response into the existing guarded plan contract.""" + if dynamic: + if not isinstance(value, Mapping) or set(value) != {"requests", "ambiguities", "confidence", "schedule"}: + raise SceneCompositionError("dynamic scene response must include a schedule") + if value["requests"]: + raise SceneCompositionError("dynamic scene must not mix static root requests") + base = normalize_scene_output({key: value[key] for key in ("requests", "ambiguities", "confidence")}, context) + raw_schedule = value["schedule"] + if raw_schedule is None: + return base + if not isinstance(raw_schedule, Mapping) or set(raw_schedule) != {"repeat_count", "phases"}: + raise SceneCompositionError("invalid dynamic scene schedule") + phases = [] + if not isinstance(raw_schedule["phases"], list): + raise SceneCompositionError("dynamic phases must be an array") + for phase in raw_schedule["phases"]: + if not isinstance(phase, Mapping) or set(phase) != {"id", "name", "duration_ms", "requests"}: + raise SceneCompositionError("invalid dynamic phase") + normalized = normalize_scene_output({"requests": phase["requests"], "ambiguities": [], "confidence": value["confidence"]}, context) + operations = [{"domain": op["domain"], "capability": op["capability"], + "target_scope": op["target_scope"], "operation": "set", + "parameters": {p["name"]: p["value"] for p in op["parameters"]}, + "reason": "经过时间轴编排的候选状态。"} for op in normalized["requests"]] + phases.append({"id": phase["id"], "name": phase["name"], "duration_ms": phase["duration_ms"], "operations": operations}) + try: + schedule = ModeSchedule.from_dict({"schema": "cognitive-agent-mode-schedule/v1", "repeat_count": raw_schedule["repeat_count"], "phases": phases}) + except (TypeError, ValueError) as exc: + raise SceneCompositionError(str(exc)) from exc + first = normalize_scene_output({"requests": raw_schedule["phases"][0]["requests"], "ambiguities": value["ambiguities"], "confidence": value["confidence"]}, context) + first["schedule"] = schedule.to_dict() + return first + if not isinstance(value, Mapping) or set(value) != { "requests", "ambiguities", diff --git a/src/cognitive_agent/application/scene_templates.py b/src/cognitive_agent/application/scene_templates.py index 36bae32..d5c0e8b 100644 --- a/src/cognitive_agent/application/scene_templates.py +++ b/src/cognitive_agent/application/scene_templates.py @@ -24,11 +24,13 @@ from cognitive_agent.application.assistant_plan_guard import ( SCENE_COMPOSABLE_CAPABILITIES, ) from cognitive_agent.application.mode_profile import ModeOperation +from cognitive_agent.application.mode_profile_intent import mode_creation_source_name +from cognitive_agent.application.mode_schedule import ModeSchedule _TEMPLATE_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _SURROUNDING_PUNCTUATION = " \t\r\n,。,.!!??;;::'\"‘’“”《》<>【】[]()()" _DOCUMENT_FIELDS = frozenset( - {"id", "name", "aliases", "description", "icon", "operations"} + {"id", "name", "aliases", "description", "icon", "operations", "schedule"} ) @@ -66,6 +68,7 @@ class SceneTemplate: operations: tuple[ModeOperation, ...] source: str revision: str + schedule: ModeSchedule | None = None @classmethod def from_yaml(cls, path: Path) -> "SceneTemplate": @@ -100,7 +103,13 @@ class SceneTemplate: alias_keys.add(key) aliases.append(alias) + schedule = (ModeSchedule.from_dict(document["schedule"]) + if document.get("schedule") is not None else None) raw_operations = document.get("operations") + if schedule is not None: + if raw_operations: + raise ValueError(f"{path} cannot mix static operations and a schedule") + raw_operations = list(schedule.phases[0].operations) if not isinstance(raw_operations, list) or not raw_operations: raise ValueError(f"{path} must contain at least one operation") if len(raw_operations) > MAX_PLAN_REQUESTS: @@ -155,6 +164,8 @@ class SceneTemplate: ), ), } + if schedule is not None: + semantic_document["schedule"] = schedule.to_dict() canonical = json.dumps( semantic_document, ensure_ascii=False, @@ -171,6 +182,7 @@ class SceneTemplate: operations=tuple(operations), source=str(path), revision=revision, + schedule=schedule, ) def plan(self) -> dict[str, Any]: @@ -179,7 +191,7 @@ class SceneTemplate: request = operation.to_request(self.name) request["duration_ms"] = None requests.append(request) - return { + plan = { "detected_domains": list( dict.fromkeys(operation.domain for operation in self.operations) ), @@ -195,6 +207,9 @@ class SceneTemplate: "template_revision": self.revision, }, } + if self.schedule is not None: + plan["schedule"] = self.schedule.to_dict() + return plan def public_dict(self, *, include_plan: bool = False) -> dict[str, Any]: result: dict[str, Any] = { @@ -204,13 +219,16 @@ class SceneTemplate: "description": self.description, "icon": self.icon, "domains": list(dict.fromkeys(item.domain for item in self.operations)), - "operation_count": len(self.operations), + "operation_count": self.schedule.operation_count if self.schedule else len(self.operations), "template_revision": self.revision, "origin": "builtin_scene_template", "read_only": True, } if include_plan: result["plan"] = self.plan() + if self.schedule is not None: + result["schedule"] = self.schedule.to_dict() + result["domains"] = sorted({op["domain"] for phase in self.schedule.phases for op in phase.operations}) return result @@ -257,7 +275,11 @@ class SceneTemplateRegistry: def resolve_exact_name(self, value: object) -> SceneTemplate | None: key = normalize_scene_template_name(value) - return self._by_name.get(key) if key else None + exact = self._by_name.get(key) if key else None + if exact is not None: + return exact + subject = normalize_scene_template_name(mode_creation_source_name(value)) + return self._by_name.get(subject) if subject and subject != key else None def get_template(self, template_id: str) -> SceneTemplate: try: diff --git a/src/cognitive_agent/application/schedule_intent.py b/src/cognitive_agent/application/schedule_intent.py new file mode 100644 index 0000000..7d50ce0 --- /dev/null +++ b/src/cognitive_agent/application/schedule_intent.py @@ -0,0 +1,25 @@ +"""Narrow detection of temporal scene needs; never authorizes execution.""" +from __future__ import annotations + +import re + +_TEMPORAL = re.compile( + r"动态|循环|分阶段|随时间|交替|轮流|每隔|预热|预冷|" + r"(?:秒|分钟|分|小时)(?:钟)?(?:后|再)|持续.{0,8}(?:秒|分钟|小时)" +) +_STOP = re.compile( + r"^(?:请|帮我|立即|马上|先)?(?:停止|停下|结束|取消|关闭)" + r"(?:当前|正在执行的|正在运行的|这个|该)?(?:座椅|空调|功能)?(?:动态|循环|分阶段|时间轴|定时)" + r"(?:座椅|空调|功能)?(?:模式|编排|任务)?[。!!\s]*$" +) + + +def needs_mode_schedule(text: str) -> bool: + # HVAC recirculation is a stable device enum, not a timed repetition. + normalized = re.sub(r"内外循环|内循环|外循环|自动循环|新风循环|循环风", "空气循环状态", text) + normalized = normalized.replace("空气循环状态", "空气状态") + return bool(_TEMPORAL.search(normalized)) + + +def is_schedule_stop(text: str) -> bool: + return bool(_STOP.fullmatch(text.strip())) diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 72d702a..ef01468 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -47,17 +47,27 @@ from cognitive_agent.application.direct_can_control import ( # noqa: E402 build_direct_can_intent, ) from cognitive_agent.application.domain_runtime import ConfiguredDomainRuntime # noqa: E402 +from cognitive_agent.application.mode_creation_seat_policy import ( # noqa: E402 + ModeCreationSeatPolicyError, + creation_seat_scopes, + project_seed_operations, + project_seed_plan, + project_seed_schedule, +) from cognitive_agent.application.mode_profile import ( # noqa: E402 ModeProfile, ModeProfileRegistry, ) from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 is_mode_profile_query, + mode_creation_source_name, normalize_mode_profile_name, parse_mode_profile_create, parse_mode_profile_delete, parse_mode_profile_update, ) +from cognitive_agent.application.mode_schedule import ModeSchedule, ModeScheduleRunner # noqa: E402 +from cognitive_agent.application.mode_schedule_service import ModeScheduleServiceMixin # noqa: E402 from cognitive_agent.application.model_plan_adapter import ( # noqa: E402 APPLY_PRESET_CAPABILITY, DYNAMIC_MODE_STOP_CAPABILITY, @@ -90,6 +100,10 @@ from cognitive_agent.application.native_can_runtime import ( # noqa: E402 NativeCanRuntime, ) from cognitive_agent.application.scene_templates import SceneTemplateRegistry # noqa: E402 +from cognitive_agent.application.schedule_intent import ( # noqa: E402 + is_schedule_stop, + needs_mode_schedule, +) from cognitive_agent.application.seat_calibration import position_pct_to_angle # noqa: E402 from cognitive_agent.application.semantic_ir import ( # noqa: E402 apply_language_safety_guards, @@ -331,8 +345,9 @@ DYNAMIC_MODE_WORDS = ( ) -class SeatDemoService: +class SeatDemoService(ModeScheduleServiceMixin): def __init__(self, repository_root: Path = REPOSITORY_ROOT): + self.mode_schedule_runner = ModeScheduleRunner() self.root = repository_root self.domain_root = repository_root / "domains" / "seat" self.contracts = ContractValidator(repository_root) @@ -663,7 +678,7 @@ class SeatDemoService: 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"): + if profile.schedule is None and (not profile.command_flow or not profile.command_flow.get("plans")): raise ValueError("该模式尚未生成可直接执行的控制指令流。") result = self.process( profile.instruction or f"进入{profile.name}", @@ -678,6 +693,8 @@ class SeatDemoService: control_transport=control_transport, _direct_mode_profile_id=profile.id, ) + if profile.schedule is not None: + return result public_profile = profile.public_dict() result["provider_used"] = "saved_mode_command_flow" result["utterance"] = profile.name @@ -796,7 +813,7 @@ class SeatDemoService: ): raise ValueError("功能模式草稿未通过安全校验,请重新生成。") - return { + trial = { "request_id": clean_request_id, "status": "succeeded", "execution_mode": "dry_run", @@ -809,6 +826,14 @@ class SeatDemoService: "validation": deepcopy(validation), "trial_source": "server_validated_preview_artifact", } + schedule = self._validated_schedule_artifact(source_result) + if schedule is not None: + trial["schedule"] = schedule.to_dict() + trial["schedule_execution"] = self.mode_schedule_runner.simulate( + schedule, lambda phase: {"status": "succeeded", "phase_id": phase.id, + "operations": deepcopy(list(phase.operations))}, + ) + return trial def _persist_mode_profile_card( self, @@ -1007,6 +1032,11 @@ class SeatDemoService: # generation workflow. "execution_steps": [], } + schedule = self._validated_schedule_artifact(source_result) + if profile is not None and profile.schedule is not None and schedule is None: + raise ValueError("动态模式更新必须保留完整时间轴,请按阶段重新生成预览。") + if schedule is not None: + operations = [deepcopy(op) for phase in schedule.phases for op in phase.operations] saved = self.mode_profiles.save_mode( clean_name, operations, @@ -1022,6 +1052,7 @@ class SeatDemoService: instruction=clean_instruction, command_flow=command_flow, timeline=profile.timeline if profile is not None else None, + schedule=schedule, expected_revision=draft_existing_mode_revision or None, create_only=not bool(clean_mode_id), ) @@ -1220,6 +1251,7 @@ class SeatDemoService: self._vehicle_mqtt_transport.start() def close(self) -> None: + self.mode_schedule_runner.close() if self._vehicle_state_monitor is not None: self._vehicle_state_monitor.close() self._vehicle_mqtt_transport.close() @@ -2076,6 +2108,32 @@ class SeatDemoService: or mode_profile_query ): execution_mode = "dry_run" + schedule_requested = "模式" in utterance or _direct_mode_profile_id or is_schedule_stop(utterance) + schedule_vehicle_id = vehicle_id or (self.vehicle_profiles.default().id if schedule_requested else "") + if execution_mode not in {"dry_run", "simulated_hardware", "hardware"}: + raise ValueError(f"unsupported execution mode: {execution_mode}") + if is_schedule_stop(utterance): + return self._cancel_mode_schedule(schedule_vehicle_id, conversation_id, utterance, execution_mode) + schedule_profile = (self.mode_profiles.get_profile(_direct_mode_profile_id, schedule_vehicle_id) + if _direct_mode_profile_id else None) + if schedule_requested and schedule_profile is None and not (draft_update or mode_delete_name or mode_profile_query or parse_mode_profile_create(utterance)): + candidates = self.mode_profiles.match_text(utterance, vehicle_id=schedule_vehicle_id) + if len(candidates) == 1 and candidates[0].schedule is not None: + aliases = "|".join(re.escape(name) for name in (candidates[0].name, *candidates[0].aliases)) + if re.fullmatch( + r"(?:(?:请|麻烦|帮我|帮忙|给我|我想|我要|想要)\s*){0,4}" + r"(?:(?:主驾|副驾|驾驶位|副驾驶|主驾驶)(?:座椅)?\s*)?" + r"(?:进入|启动|执行|开启|启用|运行|打开|调用|应用|使用|切换到)\s*" + rf"(?:{aliases})(?:吧|一下)?[。!!\s]*", utterance.strip(), + ): + schedule_profile = candidates[0] + if schedule_profile is not None and schedule_profile.schedule is not None: + return self._run_mode_schedule( + schedule_profile, utterance=utterance, conversation_id=conversation_id, + execution_mode=execution_mode, approval_token=approval_token, vehicle_id=schedule_vehicle_id, + simulation_fault=simulation_fault, hardware_client_id=hardware_client_id, + hardware_access_bypass=hardware_access_bypass, control_transport=control_transport, + ) if execution_mode != "hardware": return self._process_once( utterance, @@ -2196,6 +2254,7 @@ class SeatDemoService: allow_fuzzy_mode_profile_match: bool = True, allow_scene_composition: bool = False, scene_scope_utterance: str | None = None, + mode_creation_scopes: frozenset[str] | None = None, mode_profile_draft_context: dict[str, Any] | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) @@ -2454,6 +2513,8 @@ class SeatDemoService: mode_requests = self.mode_profiles.requests_for_profiles( [direct_mode_profile] ) + if mode_creation_scopes is not None: + mode_requests = project_seed_operations(mode_requests, mode_creation_scopes) detected_domains = list( dict.fromkeys( str(item.get("domain") or "") @@ -3687,6 +3748,16 @@ class SeatDemoService: ) -> dict[str, Any]: if not matched_modes: return parsed + if any(mode.schedule is not None for mode in matched_modes): + rejected = deepcopy(parsed) + for key in ("actions", "domain_actions", "native_can_actions", "preset_actions", + "preset_events", "deferred_requests", "model_requests"): + rejected[key] = [] + rejected["ambiguities"] = [{ + "field": "mode_profile", + "question": "动态模式必须通过时间轴执行,请独立输入“进入模式名称”;不会将各阶段压平执行。", + }] + return rejected if self._contains_seat_preset_request(parsed): return parsed if len(matched_modes) > 1: @@ -3767,6 +3838,19 @@ class SeatDemoService: return parsed mode_requests = self.mode_profiles.requests_for_profiles(matched_modes) + selected_seat_scope = self._explicit_mode_seat_selection( + matched_modes[0], utterance, + ) + if selected_seat_scope is not None: + # A cabin profile can carry independent comfort operations for both + # front seats. An explicit single-seat invocation selects only that + # seat; it must not authorize its sibling or mutate the stored mode. + mode_requests = [ + request for request in mode_requests + if request["domain"] != "seat" + or self._normalized_mode_seat_scope(request["target_scope"]) + in selected_seat_scope + ] detected_domains = [] for request in mode_requests: if request["domain"] not in detected_domains: @@ -3787,6 +3871,24 @@ class SeatDemoService: context, vehicle_profile, ) + # Native-only comfort capabilities are represented as deferred items by + # the generic adapter. A verified stored mode must materialize them too; + # otherwise a successful named-mode run silently drops its driver seat. + mode_native_actions = [] + for item in mode_parsed.get("deferred_requests", []): + if item.get("execution_backend") != "native_can": + continue + action = { + "domain": item["domain"], + "capability": item["capability"], + "target": item["target_scope"], + "parameters": deepcopy(item.get("parameters", {})), + "operation": item.get("desired_change", "set"), + "reason": item.get("reason", ""), + "execution_backend": "native_can", + } + if self.native_can_runtime.supports(action): + mode_native_actions.append(action) parsed_has_explicit_request = bool( parsed.get("actions") or parsed.get("domain_actions") @@ -3803,6 +3905,10 @@ class SeatDemoService: mode_parsed.get("domain_actions", []), parsed.get("domain_actions", []), ) + merged["native_can_actions"] = self._merge_control_actions( + mode_native_actions, + parsed.get("native_can_actions", []), + ) for key in ( "preset_actions", "preset_events", @@ -3834,13 +3940,35 @@ class SeatDemoService: ) return merged - @staticmethod + @classmethod def _explicit_mode_target_conflict( + cls, profile: ModeProfile, utterance: str, ) -> tuple[str, tuple[str, ...]] | None: """Reject a named mode when its persisted seat scope contradicts text.""" + requested, normalized_scopes = cls._explicit_mode_seat_scopes(profile, utterance) + if requested is None or requested == normalized_scopes: + return None + if cls._explicit_mode_seat_selection(profile, utterance) is not None: + return None + return ",".join(sorted(requested)), tuple(sorted(normalized_scopes)) + + @staticmethod + def _normalized_mode_seat_scope(scope: str) -> str: + return ( + "driver" if scope == "front_left" else + "passenger" if scope == "front_right" else + "rear" if scope in {"rear", "rear_left", "rear_right"} else scope + ) + + @classmethod + def _explicit_mode_seat_scopes( + cls, + profile: ModeProfile, + utterance: str, + ) -> tuple[frozenset[str] | None, set[str]]: aliases = (profile.id, profile.name, *profile.aliases) mode_clause = next( ( @@ -3856,23 +3984,34 @@ class SeatDemoService: utterance, ) requested = resolve_explicit_seat_scopes( - mode_clause, + mode_clause.replace("主副驾", "主驾和副驾"), assume_seat_context=True, ) - if requested is None: - return None - normalized_scopes = { - "driver" if operation.target_scope == "front_left" else - "passenger" if operation.target_scope == "front_right" else - "rear" if operation.target_scope in {"rear", "rear_left", "rear_right"} else - operation.target_scope + cls._normalized_mode_seat_scope(operation.target_scope) for operation in profile.operations if operation.domain == "seat" } - if requested == normalized_scopes: + return requested, normalized_scopes + + @classmethod + def _explicit_mode_seat_selection( + cls, + profile: ModeProfile, + utterance: str, + ) -> frozenset[str] | None: + """Scope a dual-seat mode invocation, never mode creation or editing.""" + requested, supported = cls._explicit_mode_seat_scopes(profile, utterance) + if ( + requested is None + or len(requested) != 1 + or supported != {"driver", "passenger"} + or not requested.issubset(supported) + or cls.is_mode_profile_configuration_request(utterance) + or not re.search(r"进入|开启|启动|执行|启用|运行|切换|打开|调用|应用|使用", utterance) + ): return None - return ",".join(sorted(requested)), tuple(sorted(normalized_scopes)) + return requested @staticmethod def _apply_implicit_mode_target_policy( @@ -4205,7 +4344,9 @@ class SeatDemoService: for issue in validation ): raise ValueError("待保存功能模式草稿没有通过完整安全校验。") - operations = self._preview_mode_operations(source_result) + schedule = self._validated_schedule_artifact(source_result) + operations = ([deepcopy(op) for phase in schedule.phases for op in phase.operations] + if schedule is not None else self._preview_mode_operations(source_result)) operation_count = draft.get("operation_count") if ( not isinstance(operation_count, int) @@ -4335,6 +4476,16 @@ class SeatDemoService: vehicle_id=vehicle_profile.id, ) ) + if source_draft.get("schedule") is not None: + from cognitive_agent.application.mode_schedule_edit import revise_schedule + + return revise_schedule( + self, ModeSchedule.from_dict(source_draft["schedule"]), + change_text=change_text, utterance=utterance, name=mode_name, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + context=context, control_transport=control_transport, + source_draft=source_draft, + ) base_operations = self._absolute_mode_operations(source_operations) if not change_text: raise ValueError("请说明要调整的设备和明确目标值。") @@ -4684,6 +4835,14 @@ class SeatDemoService: if profile is None: self._persist(result) return result + if profile.schedule is not None: + from cognitive_agent.application.mode_schedule_edit import update_saved_schedule + + return update_saved_schedule( + self, profile, change_text=change_text, utterance=utterance, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + context=context, result=result, + ) if not change_text: result["status"] = "needs_confirmation" result["validation"] = [ @@ -5351,6 +5510,25 @@ class SeatDemoService: result["scene_config"] = None result["execution_report"] = None + def _mode_creation_seat_failure( + self, utterance: str, conversation_id: str, vehicle: VehicleProfile, reason: str, + ) -> dict[str, Any]: + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "utterance": utterance, "conversation_id": conversation_id, + "vehicle": vehicle.public_dict(), "status": "validation_failed", + "execution_mode": "dry_run", "hardware_execution_allowed": False, + "orchestration_route": "mode_profile_create_preview", "trace_events": [], + "validation": [{"severity": "error", "code": "MODE_SEAT_SCOPE_UNAVAILABLE", + "path": "mode_profile_draft.seat_scope", "message": reason}], + "mode_creation_failure": {"code": "MODE_SEAT_SCOPE_UNAVAILABLE", "reason": reason, + "next_step": "请选择该模式实际支持的座位,或改用当前座位支持的舒适功能"}, + "decision_summary": [reason, "没有保存模式或下发控制。"], + } + self._scrub_failed_scene_preview(result) + self._persist(result) + return result + def _process_mode_profile_create( self, utterance: str, @@ -5385,6 +5563,14 @@ class SeatDemoService: ): raise ValueError("功能模式名称与创建请求不一致。") requested_instruction = create_parts[1] if create_parts is not None else "" + selected_seats = creation_seat_scopes(utterance) if source_draft is None else None + if selected_seats is not None and "unsupported" in selected_seats and re.search( + r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩|零重力", utterance + ): + return self._mode_creation_seat_failure( + utterance, conversation_id, vehicle_profile, + "当前模式只支持明确的主驾或副驾座椅,不会将未支持的座位替换为其他座位。", + ) raw_plan_metadata = ( assistant_plan.get("metadata") if isinstance(assistant_plan, dict) @@ -5411,6 +5597,7 @@ class SeatDemoService: or requested_instruction or assistant_plan.get("requests") != candidate_template.plan().get("requests") + or assistant_plan.get("schedule") != candidate_template.plan().get("schedule") ): raise ValueError("内置常用模式模板已更新,请重新提交。") scene_template = candidate_template @@ -5441,6 +5628,10 @@ class SeatDemoService: if not requested_instruction and source_draft is None else None ) + if source_profile is None and source_draft is None and not requested_instruction and scene_template is None: + source_name = mode_creation_source_name(mode_name) + if source_name != mode_name: + source_profile = self.mode_profiles.resolve_exact_name(source_name, vehicle_id=vehicle_profile.id) draft_mode_name = ( str(source_draft.get("name") or mode_name).strip() if isinstance(source_draft, dict) @@ -5456,7 +5647,7 @@ class SeatDemoService: else "registered_mode_profile" if source_profile is not None else "codex_scene_composition" - if assistant_plan is not None and not requested_instruction + if assistant_plan is not None and (not requested_instruction or needs_mode_schedule(utterance)) else "explicit_scene_requirements" ) # A name-only request may already describe a repository-backed scene. @@ -5465,7 +5656,63 @@ class SeatDemoService: # being parsed as a second control clause on the assistant's local fast # path, turning a valid nine-action mode into an incomplete draft. source_utterance = requested_instruction or f"进入{draft_mode_name}" + if requested_instruction and selected_seats is not None and len(selected_seats) == 1: + # Qualify only explicit seat-device clauses, never an HVAC/window + # zone or unrelated control. General one-shot controls keep their + # existing target policy; this is scoped to new mode construction. + label = "主驾" if selected_seats == {"driver"} else "副驾" + instruction_clauses = self._split_intent_clauses(requested_instruction) + qualified_clauses = [ + f"{label}{clause}" if ( + re.search(r"座椅|座位|靠背|椅背|腿托|脚托|坐垫|坐盆|按摩", clause) + and resolve_explicit_seat_scopes(clause, assume_seat_context=True) is None + ) else clause + for clause in instruction_clauses + ] + # Preserve the user's punctuation and wording unless a seat really + # needs qualification; explicit instructions need no rewriting. + if qualified_clauses != instruction_clauses: + source_utterance = ",".join(qualified_clauses) + raw_schedule = (assistant_plan.get("schedule") if isinstance(assistant_plan, dict) else None) + if source_profile is not None and source_profile.schedule is not None: + raw_schedule = source_profile.schedule.to_dict() + if raw_schedule is not None: + if isinstance(assistant_plan, dict) and assistant_plan.get("ambiguities"): + raise ValueError("动态需求仍有未解决的问题,不能生成部分成功的时间轴,请补充或调整需求。") + if source_profile is None and source_draft is None and scene_template is None and not needs_mode_schedule(utterance): + raise ValueError("用户未请求动态或分阶段模式,不能擅自生成定时任务。") + schedule = ModeSchedule.from_dict(raw_schedule) + if selected_seats is not None and (source_profile is not None or scene_template is not None): + try: + schedule = project_seed_schedule(schedule, selected_seats) + except ModeCreationSeatPolicyError as exc: + return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) + return self._preview_mode_schedule( + schedule, name=draft_mode_name, utterance=utterance, + conversation_id=conversation_id, vehicle_profile=vehicle_profile, + control_transport=control_transport, generation_source=generation_source, + source_profile=existing_profile, source_template=scene_template, source_draft=source_draft, + scene_scope_utterance="" if source_draft is not None else utterance, + ) + if needs_mode_schedule(utterance) and scene_template is None and not (source_profile is not None and source_profile.timeline): + result = { + "request_id": f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}", + "utterance": utterance, "conversation_id": conversation_id, + "vehicle": vehicle_profile.public_dict(), "validation": [], "trace_events": [], + "status": "validation_failed", "execution_mode": "dry_run", "hardware_execution_allowed": False, + "orchestration_route": "mode_profile_create_preview", + } + result["mode_creation_failure"] = { + "code": "MODE_SCHEDULE_REQUIRED", + "reason": "该需求包含时间变化,但没有生成完整的分阶段时间轴", + "next_step": "请重试,或补充每个阶段的设备目标和持续时间;未保存或执行静态替代动作", + } + self._scrub_failed_scene_preview(result) + self._persist(result) + return result try: + if scene_template is not None and selected_seats is not None: + assistant_plan = project_seed_plan(assistant_plan, selected_seats) source_result = self._process_once( source_utterance, provider, @@ -5478,6 +5725,7 @@ class SeatDemoService: direct_mode_profile_id=( source_profile.id if source_profile is not None else None ), + mode_creation_scopes=selected_seats, assistant_plan=assistant_plan, # ``source_utterance`` is synthesized for a name-only, unknown # scene. It must not fall back to substring matching (for example @@ -5493,8 +5741,10 @@ class SeatDemoService: # already passed the ordinary text/target authorization path. # Its old display name cannot veto an explicitly requested new # seat. This override is internal, never accepted from payloads. - scene_scope_utterance="" if source_draft is not None else None, + scene_scope_utterance="" if source_draft is not None else utterance, ) + except ModeCreationSeatPolicyError as exc: + return self._mode_creation_seat_failure(utterance, conversation_id, vehicle_profile, str(exc)) except NativeCanCompileError: # Repository modes and model-composed scenes share the same native # compiler. A stale mode parameter must become a bounded failed diff --git a/src/cognitive_agent/application/semantic_ir.py b/src/cognitive_agent/application/semantic_ir.py index 3288d35..37771af 100644 --- a/src/cognitive_agent/application/semantic_ir.py +++ b/src/cognitive_agent/application/semantic_ir.py @@ -8,7 +8,6 @@ from collections.abc import Mapping from copy import deepcopy from typing import Any - CLAUSE_SEPARATOR = re.compile(r"[,,;;。!?!?\n]+") NEGATED_OPERATION = re.compile( r"(?:不要|别|不想|无需|不必|禁止)" @@ -414,13 +413,17 @@ def apply_language_safety_guards( ] result["confidence"] = min(float(result.get("confidence", 0)), 0.8) - explicit_seat_scopes = ( - resolve_scene_seat_scopes( + if scene_composition: + # Reuse exactly the selection already applied to the model schema and + # trusted seed. A second, older scope parser must not reject aliases + # such as 前排两座 or widen 副驾驶席 to both seats after validation. + from cognitive_agent.application.mode_creation_seat_policy import creation_seat_scopes + + explicit_seat_scopes = (None if scene_scope_utterance == "" else creation_seat_scopes( utterance if scene_scope_utterance is None else scene_scope_utterance - ) - if scene_composition - else resolve_explicit_seat_scopes(utterance) - ) + )) + else: + explicit_seat_scopes = resolve_explicit_seat_scopes(utterance) if scene_composition and not any( isinstance(item, dict) and ( diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index 0918f36..d5a9153 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -47,6 +47,7 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 normalize_mode_profile_name, parse_mode_profile_update, ) +from cognitive_agent.application.mode_schedule_service import public_schedule_status # noqa: E402 from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402 from cognitive_agent.application.self_extension import ( # noqa: E402 ExtensionIntegrationError, @@ -304,6 +305,14 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path == "/api/runtime": self._json(self.service.runtime_info()) return + if path == "/api/modes/schedule/status": + query = parse_qs(parsed_url.query) + try: + vehicle = self.service.vehicle_profiles.get(str(query.get("vehicle_id", [""])[0]) or None) + self._json(public_schedule_status(self.service.mode_schedule_runner.status(vehicle.id))) + except ValueError as exc: + self._json({"error": "invalid_request", "message": str(exc)}, HTTPStatus.BAD_REQUEST) + return if path == "/api/vehicle-display-sync": self._json(self.vehicle_display_sync.snapshot(self._extension_health())) return diff --git a/tests/e2e/mode_schedule_browser.cjs b/tests/e2e/mode_schedule_browser.cjs new file mode 100644 index 0000000..927f729 --- /dev/null +++ b/tests/e2e/mode_schedule_browser.cjs @@ -0,0 +1,256 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const [baseURL, evidenceDir] = process.argv.slice(2); + +async function main() { + const browser = await chromium.launch({ + headless: true, + ...(process.env.N7WEB_BROWSER_CHANNEL ? { channel: process.env.N7WEB_BROWSER_CHANNEL } + : process.platform === "win32" ? { channel: "msedge" } : {}), + }); + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, serviceWorkers: "block" }); + const page = await context.newPage(); + const errors = []; + const posted = []; + const responses = []; + let mockedExecution = null; + let statusReads = 0; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(`${message.text()} ${message.location().url || ""}`); + }); + await context.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (["/api/hardware/seat-state", "/api/hardware/vehicle-state"].includes(pathname)) { + await route.fulfill({ json: { status: "unavailable", hardware_execution_allowed: false } }); + return; + } + if (request.method() !== "GET") posted.push({ pathname, payload: request.postDataJSON() }); + if (mockedExecution && pathname === "/api/assistant/stream") { + await route.fulfill({ contentType: "application/x-ndjson", body: [ + { type: "action.completed", success: true, payload: mockedExecution }, + { type: "answer.completed", answer: "动态时间轴已启动,后续阶段会按计划执行。" }, + ].map((event) => JSON.stringify(event)).join("\n") + "\n" }); + return; + } + if (mockedExecution && pathname === "/api/modes/schedule/status") { + statusReads += 1; + assert.equal(new URL(request.url()).searchParams.get("vehicle_id"), "denza_n7"); + await route.fulfill({ json: { + ...mockedExecution.schedule_execution, vehicle_id: "denza_n7", + phase_index: 1, repeat_index: 0, completed: false, + status: statusReads === 1 ? "running" : "failed", + error: statusReads === 1 ? null : "QA阶段模拟失败", + } }); + return; + } + if (["/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"].includes(pathname)) { + // Chromium can discard completed streaming response bodies from CDP. + // Fetch the real isolated endpoint and retain its exact response bytes. + const response = await route.fetch(); + const text = await response.text(); + responses.push({ pathname, text, status: response.status() }); + await route.fulfill({ response, body: text }); + return; + } + await route.continue(); + }); + const waitIdle = () => page.waitForFunction(() => !window.CognitiveAgentAssistant.isPending()); + const responseFor = (pathname) => page.waitForResponse((response) => new URL(response.url()).pathname === pathname); + try { + await page.goto(baseURL, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.classList.contains("assistant-chat-enabled") + && document.querySelectorAll(".mode-chips button").length > 3); + assert.equal(await page.title(), "511工程-智能座舱"); + assert.equal(new URL(page.url()).origin, baseURL); + assert.match(await page.locator("body").innerText(), /整车孪生界面/); + if (!await page.locator('[data-execution-mode="dry_run"]').isVisible()) { + await page.locator('#v2SidebarToggle').click(); + } + await page.locator('[data-execution-mode="dry_run"]').click(); + assert.equal(await page.evaluate(() => window.CognitiveAgentConsole.getExecutionMode()), "dry_run"); + + // Exercise the same real assistant endpoint and rendered cards for both + // implicit passenger selection and an explicit driver request. These + // previews stay unsaved, so the timeline save/trial tests remain isolated. + for (const [index, testCase] of [ + { text: "构建一个阅读模式", seat: "passenger", label: "副驾", excluded: "主驾" }, + { text: "构建一个主驾阅读模式", seat: "driver", label: "主驾", excluded: "副驾" }, + { text: "构建一个主驾座椅动态模式", seat: "driver", label: "主驾", excluded: "副驾" }, + ].entries()) { + const responsePromise = responseFor("/api/assistant/stream"); + await page.locator("#utterance").fill(testCase.text); + await page.locator("#utterance").press("Enter"); + const response = await responsePromise; + assert.equal(response.status(), 200); + const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1) + .text.trim().split("\n").map((line) => JSON.parse(line)); + await waitIdle(); + const payload = events.find((event) => event.payload?.mode_profile_draft)?.payload; + assert.ok(payload, `Missing selected-seat draft for ${testCase.text}: ${JSON.stringify(events)}`); + const draft = payload.mode_profile_draft; + const actions = draft.schedule ? draft.schedule.phases.flatMap((phase) => phase.operations) : payload.actions; + const seats = actions.filter((action) => action.capability?.startsWith("seat.")) + .map((action) => action.target_scope || action.target?.instance || action.target?.zone); + assert.ok(seats.length, `Missing selected-seat actions for ${testCase.text}`); + assert.deepEqual([...new Set(seats)], [testCase.seat]); + const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`); + await card.waitFor({ state: "visible" }); + const actionText = await card.locator(".assistant-mode-draft-row").allTextContents(); + assert.ok(actionText.some((text) => text.includes(testCase.label)), actionText.join("\n")); + assert.ok(actionText.every((text) => !text.includes(testCase.excluded)), actionText.join("\n")); + await card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, `seat-scope-preview-${index + 1}.png`) }); + } + + const savedModes = []; + for (const [index, text] of ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"].entries()) { + const responsePromise = responseFor("/api/assistant/stream"); + await page.locator("#utterance").fill(text); + await page.locator("#utterance").press("Enter"); + const response = await responsePromise; + assert.equal(response.status(), 200); + const events = responses.filter((item) => item.pathname === "/api/assistant/stream").at(-1).text.trim().split("\n").map((line) => JSON.parse(line)); + await waitIdle(); + const draftEvent = events.find((event) => event.payload?.mode_profile_draft); + assert.ok(draftEvent, `Missing schedule draft for ${text}: ${JSON.stringify(events)}`); + const draft = draftEvent.payload.mode_profile_draft; + assert.equal(draft.schedule?.schema, "cognitive-agent-mode-schedule/v1"); + assert.ok(draft.schedule.phases.length >= 2); + const card = page.locator(`[data-assistant-mode-draft="${draft.source_request_id}"]`); + assert.equal(await card.locator(".assistant-mode-schedule-phase").count(), draft.schedule.phases.length); + assert.equal(await card.locator(".assistant-mode-draft-row").count(), draft.operation_count); + assert.match(await card.innerText(), /重复 \d+ 次 · 总时长/); + assert.match(await card.innerText(), /停止动态模式/); + if (index === 0) { + assert.match(await card.innerText(), /副驾/); + assert.doesNotMatch(await card.innerText(), /主驾/); + } else { + const temperatures = draft.schedule.phases.flatMap((phase) => phase.operations) + .filter((operation) => operation.capability === "climate.set_temperature") + .map((operation) => operation.parameters.temperature_c); + assert.ok(new Set(temperatures).size >= 2, "preheat must change temperature over time"); + for (const value of new Set(temperatures)) assert.ok((await card.innerText()).includes(`${value}℃`)); + } + await card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, `schedule-preview-${index + 1}.png`) }); + + const trialPromise = responseFor("/api/mode-profile-drafts/trial"); + await card.locator(".assistant-mode-draft-trial").click(); + const trialResponse = await trialPromise; + const trial = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profile-drafts/trial").at(-1).text); + assert.equal(trialResponse.status(), 200, JSON.stringify(trial)); + assert.equal(trial.status, "succeeded", JSON.stringify(trial)); + assert.equal(trial.schedule_execution?.status, "completed"); + await page.waitForFunction((requestId) => { + const status = document.querySelector(`[data-assistant-mode-draft="${requestId}"] .assistant-mode-draft-status`); + return status?.classList.contains("success") || status?.classList.contains("failed"); + }, draft.source_request_id); + assert.match(await card.locator(".assistant-mode-draft-status").innerText(), /试运行通过/); + + const savePromise = responseFor("/api/mode-profiles"); + await card.locator(".assistant-mode-draft-save").click(); + const saveResponse = await savePromise; + const saved = JSON.parse(responses.filter((item) => item.pathname === "/api/mode-profiles").at(-1).text); + assert.ok([200, 201].includes(saveResponse.status()), JSON.stringify(saved)); + assert.ok(saved.mode_profile?.id); + savedModes.push(saved.mode_profile); + await page.locator(".assistant-mode-created").filter({ hasText: saved.mode_profile.name }).waitFor({ state: "visible" }); + const button = page.locator(`.mode-chips button[data-mode-profile-id="${saved.mode_profile.id}"]`); + await button.waitFor({ state: "visible" }); + // Saving reveals the card with smooth scrolling. A tooltip correctly + // closes while its rail is still moving, so wait for that motion to end. + await page.evaluate(() => new Promise((resolve) => { + const rail = document.querySelector('.mode-chips'); + let previous = rail.scrollLeft; + let stable = 0; + const sample = () => { + const current = rail.scrollLeft; + stable = Math.abs(current - previous) < 0.1 ? stable + 1 : 0; + previous = current; + if (stable >= 12) resolve(); + else requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + })); + await page.mouse.move(1, 1); + const buttonBox = await button.boundingBox(); + const railBox = await page.locator('.mode-chips').boundingBox(); + const visibleLeft = Math.max(buttonBox.x, railBox.x); + const visibleRight = Math.min(buttonBox.x + buttonBox.width, railBox.x + railBox.width); + assert.ok(visibleRight > visibleLeft, 'saved card is visible in the mode rail'); + // A native mouse move does not request another smooth scroll, unlike + // locator.hover() which scrolls the target to the center first. + await page.mouse.move((visibleLeft + visibleRight) / 2, buttonBox.y + buttonBox.height / 2); + const tooltip = page.locator("#modeChipActionPopover"); + await tooltip.waitFor({ state: "visible" }); + assert.match(await tooltip.innerText(), /重复 \d+ 次 · 总时长/); + assert.equal(await tooltip.locator(".mode-schedule-popover-phase").count(), draft.operation_count); + for (const phase of draft.schedule.phases) assert.ok((await tooltip.innerText()).includes(phase.name)); + await tooltip.screenshot({ path: path.join(evidenceDir, `schedule-tooltip-${index + 1}.png`), timeout: 4000 }); + await page.mouse.move(1, 1); + } + + await page.locator("#modeGalleryToggle").click(); + for (const mode of savedModes) { + const shell = page.locator(`#modeGalleryGroups .mode-gallery-card-shell`).filter({ has: page.locator(`button[data-mode-profile-id="${mode.id}"]`) }); + const details = shell.locator(".mode-gallery-schedule-details"); + await details.locator("summary").click(); + assert.match(await details.innerText(), /开始 · 持续/); + assert.match(await details.innerText(), /不自动回滚/); + } + await page.locator("#modeGalleryGroups").evaluate((element) => { element.scrollTop = 0; }); + await page.screenshot({ path: path.join(evidenceDir, "schedule-management-desktop.png") }); + await page.locator("#modeGalleryClose").click(); + await page.setViewportSize({ width: 720, height: 1000 }); + if (await page.locator('#v2SidebarToggle').getAttribute('aria-expanded') === 'true') { + await page.locator('#v2SidebarToggle').click(); + } + await page.locator(".assistant-mode-draft-scheduled").last().scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(evidenceDir, "schedule-preview-mobile.png") }); + assert.equal(await page.locator("body").evaluate((body) => body.scrollWidth > window.innerWidth + 1), false); + + // The backend sequence above is real and strictly dry-run. Separately + // verify the asynchronous UI contract with a synthetic running/failed + // response; it must never launch an actual timed hardware execution. + await page.setViewportSize({ width: 1600, height: 1000 }); + const mode = savedModes[1]; + mockedExecution = { + request_id: "browser-running-qa", status: "running", execution_mode: "dry_run", + orchestration_route: "mode_schedule_execute", vehicle_id: "denza_n7", mode_profile: mode, + schedule_execution: { status: "running", run_id: "qa-run", session_id: "qa-session" }, + }; + const messagesBefore = await page.locator('#assistantMessages [data-message-role]').count(); + await page.locator(`.mode-chips button[data-mode-profile-id="${mode.id}"]`).click(); + await waitIdle(); + await page.waitForFunction((id) => document.querySelector(`.mode-chips button[data-mode-profile-id="${id}"]`)?.dataset.modeState === "started", mode.id); + const actionStatus = page.locator('.assistant-action-status').last(); + assert.match(await actionStatus.innerText(), /已启动动态时间轴/); + await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('阶段 2')); + await page.waitForFunction(() => Array.from(document.querySelectorAll('.assistant-action-status')).at(-1)?.textContent.includes('QA阶段模拟失败')); + assert.equal(await actionStatus.getAttribute('class'), 'assistant-action-status failed'); + assert.equal(statusReads, 2); + assert.equal(await page.locator('#assistantMessages [data-message-role]').count(), messagesBefore + 2); + await page.screenshot({ path: path.join(evidenceDir, "schedule-background-failure.png") }); + assert.deepEqual(errors, []); + assert.ok(posted.filter((request) => request.pathname === "/api/assistant/stream").every((request) => request.payload.execution_mode === "dry_run")); + assert.equal(posted.filter((request) => request.pathname === "/api/mode-profile-drafts/trial").length, 2); + assert.equal(posted.filter((request) => request.pathname === "/api/mode-profiles").length, 2); + console.log(`PASS ${baseURL}: default passenger and explicit driver static/dynamic previews -> both exact user phrases -> phased preview -> virtual trial -> save -> top tooltip -> management; desktop 1600x1000 and 720x1000; no JS errors; no hardware requests forwarded`); + console.log(`Evidence: ${evidenceDir}`); + } catch (error) { + await page.screenshot({ path: path.join(evidenceDir, "schedule-browser-failure.png") }); + console.error(`Page text: ${(await page.locator("body").innerText()).slice(-5000)}`); + throw error; + } finally { + await context.close(); + await browser.close(); + } +} + +main().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/e2e/test_mode_schedule_browser.py b/tests/e2e/test_mode_schedule_browser.py new file mode 100644 index 0000000..2530226 --- /dev/null +++ b/tests/e2e/test_mode_schedule_browser.py @@ -0,0 +1,109 @@ +"""Full browser -> deterministic assistant -> schedule trial/save, without hardware.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +from http import HTTPStatus +from http.server import ThreadingHTTPServer +from pathlib import Path +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import pytest + +from cognitive_agent.application.codex_assistant import CodexAssistantService +from cognitive_agent.application.seat_service import SeatDemoService +from cognitive_agent.auth import AuthConfig, AuthRuntime +from cognitive_agent.server import REPOSITORY_ROOT, SeatDemoHandler + + +def test_mode_schedule_real_browser_preview_trial_and_save(tmp_path: Path) -> None: + node = shutil.which("node") + if not node: + runtime = Path.home() / ".cache" / "codex-runtimes" / "codex-primary-runtime" / "dependencies" / "node" / "bin" + node = next((str(path) for path in (runtime / "node.exe", runtime / "node") if path.is_file()), None) + if not node: + pytest.skip("Node.js is required for the schedule browser regression") + env = dict(os.environ) + modules = Path(node).resolve().parent.parent / "node_modules" + if modules.is_dir(): + env["NODE_PATH"] = os.pathsep.join(value for value in (str(modules), env.get("NODE_PATH")) if value) + probe = subprocess.run([node, "-e", "require.resolve('playwright')"], env=env, capture_output=True, encoding="utf-8", check=False) + if probe.returncode: + pytest.skip("Playwright is required for the schedule browser regression") + + with patch.dict(os.environ, { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + "COGNITIVE_AGENT_EXECUTION_MODE": "dry_run", + "COGNITIVE_AGENT_MODE_PROFILE_ROOT": str(tmp_path / "user-modes"), + }): + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + # A failed deterministic route must fail visibly, never launch a model or + # make network calls to an external assistant during the browser test. + client = MagicMock() + client.available = False + client.status = "test-offline" + assistant = CodexAssistantService(client, fast_implicit_controls=True) + forbidden = [] + + class BrowserHandler(SeatDemoHandler): + auth = AuthRuntime(AuthConfig(False, "", "", b"", 3600, 5, 300, 120, False)) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: + # Both legacy GET paths can touch physical adapters or firmware. + self._json({"status": "unavailable", "hardware_execution_allowed": False}) + return + if path == "/api/health": + self._json({"status": "ok", "assistant": {"status": "ready", "available": True}}) + return + super().do_GET() + + def do_POST(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path == "/api/extension/display-sync": + self._json({"status": "ok"}) + return + if path not in {"/api/assistant/stream", "/api/mode-profile-drafts/trial", "/api/mode-profiles"}: + forbidden.append(path) + self._json({"error": "browser_test_write_forbidden"}, HTTPStatus.FORBIDDEN) + return + super().do_POST() + + def _read_json_body(self, **kwargs): + payload = super()._read_json_body(**kwargs) + if payload.get("execution_mode", "dry_run") != "dry_run": + forbidden.append(f"execution_mode:{payload.get('execution_mode')}") + raise ValueError("Browser regression permits dry_run only") + return payload + + def log_message(self, *_args: object) -> None: + pass + + BrowserHandler.service = service + BrowserHandler.assistant = assistant + server = ThreadingHTTPServer(("127.0.0.1", 0), BrowserHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + completed = subprocess.run([ + node, str(Path(__file__).with_name("mode_schedule_browser.cjs")), + f"http://127.0.0.1:{server.server_port}", str(tmp_path), + ], env=env, capture_output=True, encoding="utf-8", timeout=180, check=False) + assert completed.returncode == 0, completed.stdout + completed.stderr + assert not forbidden, forbidden + assert len(list((tmp_path / "user-modes").rglob("*.yaml"))) == 2 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + assistant.close() + service.close() diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 256b714..99b8f4b 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -621,7 +621,7 @@ 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-131", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-132", 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 @@ -656,7 +656,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-27", timeout=3 + f"{self.base_url}/assistant-chat.js?v=codex-assistant-28", timeout=3 ) as response: assistant_script = response.read().decode("utf-8") with self.opener.open( @@ -697,7 +697,7 @@ 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-131"', html) + self.assertIn('src="/app.js?v=dual-seat-132"', 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) @@ -718,8 +718,8 @@ operations: self.assertNotIn("滚动查看更多", html) self.assertIn('aria-label="发送消息"', html) self.assertIn('id="assistantTranscript"', html) - self.assertIn('href="/assistant-chat.css?v=codex-assistant-27"', html) - self.assertIn('src="/assistant-chat.js?v=codex-assistant-27"', html) + self.assertIn('href="/assistant-chat.css?v=codex-assistant-28"', html) + self.assertIn('src="/assistant-chat.js?v=codex-assistant-28"', 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) @@ -992,14 +992,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-v278"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v279"', 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('/assistant-chat.css?v=codex-assistant-27', service_worker) - self.assertIn('/assistant-chat.js?v=codex-assistant-27', service_worker) + self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker) + self.assertIn('/assistant-chat.js?v=codex-assistant-28', 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-131"', service_worker) + self.assertIn('"/app.js?v=dual-seat-132"', 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) @@ -1456,7 +1456,7 @@ operations: ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-131", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-132", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/integration/test_assistant_capability_execution_matrix.py b/tests/integration/test_assistant_capability_execution_matrix.py index b6e43c1..937a9f1 100644 --- a/tests/integration/test_assistant_capability_execution_matrix.py +++ b/tests/integration/test_assistant_capability_execution_matrix.py @@ -715,9 +715,16 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes( for mode_index, mode in enumerate(modes): assert isinstance(mode, Mapping) + profile = vehicle_service.mode_profiles.get_profile(str(mode["id"]), "denza_n7") + has_driver = any( + operation.domain == "seat" and operation.target_scope in {"driver", "front_left"} + for operation in profile.operations + ) for scope_index, scope in enumerate( ("主驾", "驾驶员", "后排", "二排", "前排", "所有座椅", "全车座椅") ): + if has_driver and scope in {"主驾", "驾驶员"}: + continue # Valid dual-seat subsets have a separate exact-action matrix. message = f"请让{scope}进入{mode['name']}" result = vehicle_service.preview_native_can( message, @@ -754,7 +761,7 @@ def test_every_catalog_mode_rejects_incompatible_explicit_seat_scopes( def test_preview_and_process_share_mode_target_safety_gate( vehicle_service: SeatDemoService, ) -> None: - message = "主驾开启小憩模式" + message = "主驾开启零重力模式" direct = vehicle_service.process( message, provider="local", diff --git a/tests/integration/test_custom_mode_creation_matrix.py b/tests/integration/test_custom_mode_creation_matrix.py index 9ca9d2d..bc4db1a 100644 --- a/tests/integration/test_custom_mode_creation_matrix.py +++ b/tests/integration/test_custom_mode_creation_matrix.py @@ -754,7 +754,11 @@ def test_incomplete_ambiguous_or_unsupported_scene_never_creates_partial_draft( assert result["status"] != "succeeded" assert result.get("mode_profile_draft") is None - assert result.get("orchestration_route") != "mode_profile_create_preview" + # Seat policy can reject on the creation route before compiling any action. + assert not (result.get("intent_ir") or {}).get("spec", {}).get("actions") + assert not result.get("actions") + assert result.get("command_plan") is None + assert not result.get("command_plans") assert result.get("validation") failure = result.get("mode_creation_failure") assert isinstance(failure, dict) diff --git a/tests/integration/test_mode_creation_seat_policy.py b/tests/integration/test_mode_creation_seat_policy.py new file mode 100644 index 0000000..f46b62b --- /dev/null +++ b/tests/integration/test_mode_creation_seat_policy.py @@ -0,0 +1,471 @@ +"""Creation selects one seat by default without remapping incompatible actuators.""" + +from __future__ import annotations + +import json +import queue +from copy import deepcopy +from unittest.mock import MagicMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.scene_composer import normalize_scene_output +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@pytest.fixture +def policy_service(tmp_path, monkeypatch): + for variable in ( + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", + ): + monkeypatch.setenv(variable, "false") + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", user_root=user_root, + ) + try: + with ( + patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical CAN forbidden")) as reserve, + patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT forbidden")) as mqtt, + ): + yield service, AssistantCapabilityCatalog(service), user_root + reserve.assert_not_called() + mqtt.assert_not_called() + finally: + service.close() + + +def _actions(result): + return (result.get("intent_ir") or {}).get("spec", {}).get("actions", []) + + +def _seat_scopes(result): + return { + action["target"]["instance"] for action in _actions(result) + if action["domain"] == "seat" + } + + +def _operation_key(operation): + target = operation.get("target_scope") + if target is None: + raw_target = operation.get("target") or {} + target = raw_target.get("instance") or raw_target.get("zone") + return ( + operation["capability"], target, + json.dumps(operation["parameters"], ensure_ascii=False, sort_keys=True), + ) + + +def _preview(service, message, *, plan=None, draft=None): + return service.preview_native_can( + message, provider="local", conversation_id="conv-seat-policy", + execution_mode="hardware", vehicle_id="denza_n7", + assistant_plan=plan, mode_profile_draft_context=draft, + ) + + +def _assert_created(result, scopes): + assert result["status"] == "succeeded", result.get("validation") + assert result.get("mode_profile_draft"), result + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert _seat_scopes(result) == set(scopes) + schedule = result.get("schedule") + if schedule: + for phase in schedule["phases"]: + assert { + op["target_scope"] for op in phase["operations"] if op["domain"] == "seat" + } <= set(scopes) + assert result["mode_profile_draft"]["schedule"] == schedule + + +def _assistant_preview(service, catalog, message, *, client=None): + if client is None: + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast = catalog.fast_answer(message, snapshot) + results, events = [], [] + + def execute(instruction, progress, assistant_plan=None): + result = _preview(service, instruction, plan=assistant_plan) + results.append(result) + return result + + response = assistant.stream( + message, conversation_id="conv-seat-policy", principal="seat-policy-test", history=[], + authoritative_answer=fast.answer if fast else None, + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + assert len(results) == 1, (response, results) + return response, results[0], client + + +@pytest.mark.parametrize("name", [ + "阅读模式", "观影模式", "办公模式", "座椅舒适模式", "放松模式", "座椅动态模式", "上车预热模式", +]) +@pytest.mark.parametrize(("prefix", "scopes"), [ + ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}), +]) +def test_registered_modes_use_selected_seat_through_assistant_fast_path(policy_service, name, prefix, scopes): + service, catalog, user_root = policy_service + response, preview, client = _assistant_preview(service, catalog, f"构建一个{prefix}{name}") + assert response["success"] is True, (response, preview) + _assert_created(preview, scopes) + client.start_thread.assert_not_called() + client.start_turn.assert_not_called() + client.start_structured_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("message", "scopes"), [ + ("构建一个两个座椅的阅读模式", {"driver", "passenger"}), + ("构建一个两张座椅的阅读模式", {"driver", "passenger"}), + ("构建一个两座椅的阅读模式", {"driver", "passenger"}), + ("构建一个前排两座的阅读模式", {"driver", "passenger"}), + ("构建一个副驾驶席阅读模式", {"passenger"}), + ("构建一个副驾驶位阅读模式", {"passenger"}), + ("构建一个副驾驶座椅阅读模式", {"passenger"}), + ("构建一个两个座椅的动态模式", {"driver", "passenger"}), + ("构建一个两张座椅的动态模式", {"driver", "passenger"}), + ("构建一个两座椅的动态模式", {"driver", "passenger"}), + ("构建一个前排两座的动态模式", {"driver", "passenger"}), + ("构建一个副驾驶座椅动态模式", {"passenger"}), +]) +def test_registered_seat_aliases_select_correct_targets_without_model(policy_service, message, scopes): + service, catalog, _ = policy_service + response, preview, client = _assistant_preview(service, catalog, message) + assert response["success"] is True, ( + response["answer"], preview.get("validation"), preview.get("mode_profile_draft"), + ) + _assert_created(preview, scopes) + assert response["route"] == "scene_template" + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + + +@pytest.mark.parametrize("template_id", [ + "reading", "cinema", "camping", "quiet-night", "reception", "romantic", + "winter-warmth", "summer-cooling", "long-trip-comfort", "relaxation", "seat-comfort", + "seat-dynamic", "boarding-warmup", +]) +@pytest.mark.parametrize(("prefix", "scope"), [("", "passenger"), ("主驾", "driver")]) +def test_trusted_template_selection_keeps_nonseat_devices_and_native_parameters(policy_service, template_id, prefix, scope): + service, _, _ = policy_service + template = service.scene_templates.get_template(template_id) + original = deepcopy(template.plan()) + result = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan()) + _assert_created(result, {scope}) + raw_operations = ( + [op for phase in original["schedule"]["phases"] for op in phase["operations"]] + if original.get("schedule") else [op.to_yaml() for op in template.operations] + ) + expected = [ + _operation_key(op) for op in raw_operations + if op["domain"] != "seat" or op["target_scope"] == scope + ] + assert sorted(map(_operation_key, _actions(result))) == sorted(expected) + assert template.plan() == original, "projecting a request must not mutate the registered template" + + +def _model_output(scopes, *, dynamic=False): + requests = [] + if "driver" in scopes: + requests.append({ + "capability": "seat.set_driver_massage_native", "target_scope": "driver", + "parameters": {"action": "mode", "mode": "relax"}, + }) + if "passenger" in scopes: + requests.append({ + "capability": "seat.set_backrest_recline", "target_scope": "passenger", + "parameters": {"position_pct": 45}, + }) + requests.append({ + "capability": "climate.set_temperature", "target_scope": "vehicle", + "parameters": {"temperature_c": 24}, + }) + if not dynamic: + return {"requests": requests, "ambiguities": [], "confidence": 0.95} + return { + "requests": [], "ambiguities": [], "confidence": 0.95, + "schedule": { + "repeat_count": 2, + "phases": [ + {"id": "relax", "name": "舒缓", "duration_ms": 30000, "requests": deepcopy(requests)}, + {"id": "support", "name": "支撑", "duration_ms": 60000, "requests": deepcopy(requests)}, + ], + }, + } + + +@pytest.mark.parametrize("dynamic", [False, True]) +@pytest.mark.parametrize(("prefix", "scopes"), [ + ("", {"passenger"}), ("主驾", {"driver"}), ("主驾和副驾", {"driver", "passenger"}), +]) +def test_unregistered_model_plan_preserves_correct_explicit_or_default_targets(policy_service, dynamic, prefix, scopes): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output(scopes, dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic, + ) + dynamic_label = "动态" if dynamic else "" + result = _preview(service, f"构建一个{prefix}星河释压{dynamic_label}模式", plan=plan) + _assert_created(result, scopes) + assert result["mode_profile_draft"]["generation_source"] == "codex_scene_composition" + + +@pytest.mark.parametrize("dynamic", [False, True]) +@pytest.mark.parametrize("prefix", ["", "主驾", "副驾"]) +def test_unregistered_model_cannot_invent_cross_seat_actions(policy_service, dynamic, prefix): + service, catalog, user_root = policy_service + plan = normalize_scene_output( + _model_output({"driver", "passenger"}, dynamic=dynamic), + catalog.scene_composition_context(), dynamic=dynamic, + ) + try: + result = _preview(service, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert result["hardware_execution_allowed"] is False + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("prefix", "requested_scope", "unexpected_scope"), [ + ("", "passenger", "driver"), ("主驾", "driver", "passenger"), +]) +def test_unexpected_seat_only_in_later_dynamic_phase_rejects_entire_draft( + policy_service, prefix, requested_scope, unexpected_scope, +): + service, catalog, user_root = policy_service + output = _model_output({requested_scope}, dynamic=True) + output["schedule"]["phases"][1]["requests"] = _model_output({unexpected_scope})["requests"] + plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=True) + try: + result = _preview(service, f"构建一个{prefix}星河释压动态模式", plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert not result.get("command_plans") + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("message", [ + "构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", + "构建一个办公模式,主驾座椅分两个阶段动态舒缓", +]) +def test_existing_mode_binding_cannot_authorize_cross_seat_model_schedule(policy_service, message): + service, catalog, user_root = policy_service + original = service.mode_profiles.get_profile("office", "denza_n7") + revision = service.mode_profiles.profile_revision(original) + plan = normalize_scene_output( + _model_output({"driver", "passenger"}, dynamic=True), + catalog.scene_composition_context(), dynamic=True, + ) + try: + result = _preview(service, message, plan=plan) + except ValueError: + pass + else: + assert result["status"] != "succeeded", "binding an existing mode is not authorization for model-added seats" + assert not result.get("mode_profile_draft") + assert not _actions(result) + assert not result.get("command_plans") + assert service.mode_profiles.profile_revision(service.mode_profiles.get_profile("office", "denza_n7")) == revision + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize(("message", "scope"), [ + ("构建一个办公模式,空调先24度持续30秒,然后24度持续60秒,循环2次", "passenger"), + ("构建一个办公模式,主驾座椅分两个阶段动态舒缓", "driver"), +]) +def test_existing_mode_binding_keeps_valid_selected_seat_schedule_saveable(policy_service, message, scope): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output({scope}, dynamic=True), catalog.scene_composition_context(), dynamic=True, + ) + result = _preview(service, message, plan=plan) + _assert_created(result, {scope}) + draft = result["mode_profile_draft"] + assert draft["existing_mode_id"] == "office" + assert draft["existing_mode_revision"] + saved = service.save_mode_profile_card( + draft["existing_mode_id"], draft["name"], draft["instruction"], + draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + assert saved["mode_profile"]["schedule"] == result["schedule"] + executed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded" + assert _seat_scopes(executed) == {scope} + + +@pytest.mark.parametrize("dynamic", [False, True]) +def test_default_seat_preference_does_not_force_seat_actions_into_nonseat_scene(policy_service, dynamic): + service, catalog, _ = policy_service + plan = normalize_scene_output( + _model_output(set(), dynamic=dynamic), catalog.scene_composition_context(), dynamic=dynamic, + ) + result = _preview(service, f"构建一个星河恒温{'动态' if dynamic else ''}模式", plan=plan) + _assert_created(result, set()) + assert all(action["domain"] == "climate" for action in _actions(result)) + + +def test_explicit_atomic_mode_requirements_default_unqualified_seat_to_passenger(policy_service): + service, _, _ = policy_service + result = _preview(service, "构建一个自定义支撑模式,座椅靠背调整到45%,空调24度") + _assert_created(result, {"passenger"}) + assert {_operation_key(action) for action in _actions(result)} == { + ("seat.set_backrest_recline", "passenger", '{"position_pct": 45}'), + # Only the seat default changes; the existing local HVAC zone stays driver. + ("climate.set_temperature", "driver", '{"temperature_c": 24}'), + } + + +@pytest.mark.parametrize("dynamic", [False, True]) +def test_passenger_position_capability_is_not_retargeted_to_driver(policy_service, dynamic): + service, catalog, _ = policy_service + output = _model_output({"passenger"}, dynamic=dynamic) + requests = output["schedule"]["phases"][0]["requests"] if dynamic else output["requests"] + requests[0]["target_scope"] = "driver" + try: + plan = normalize_scene_output(output, catalog.scene_composition_context(), dynamic=dynamic) + result = _preview(service, f"构建一个主驾星河释压{'动态' if dynamic else ''}模式", plan=plan) + except ValueError: + return + assert result["status"] != "succeeded" + assert not result.get("mode_profile_draft") + assert not _actions(result) + + +@pytest.mark.parametrize(("message", "scopes"), [ + ("构建一个主驾空调与座椅舒适模式", {"passenger"}), + ("构建一个主驾阅读灯与座椅舒适模式", {"passenger"}), + ("构建一个副驾空调与主驾座椅舒适模式", {"driver"}), + ("构建一个主驾阅读灯与副驾座椅舒适模式", {"passenger"}), +]) +def test_other_device_zones_do_not_select_or_broaden_seat_scope(policy_service, message, scopes): + service, catalog, _ = policy_service + plan = normalize_scene_output(_model_output(scopes), catalog.scene_composition_context()) + result = _preview(service, message, plan=plan) + _assert_created(result, scopes) + + +@pytest.mark.parametrize(("template_id", "prefix", "scope"), [ + ("reading", "", "passenger"), ("reading", "主驾", "driver"), + ("seat-dynamic", "", "passenger"), ("seat-dynamic", "主驾", "driver"), + ("boarding-warmup", "", "passenger"), ("boarding-warmup", "主驾", "driver"), +]) +def test_save_reload_and_simulated_execution_preserve_selected_seat(policy_service, template_id, prefix, scope): + service, _, user_root = policy_service + template = service.scene_templates.get_template(template_id) + preview = _preview(service, f"构建一个{prefix}{template.name}", plan=template.plan()) + _assert_created(preview, {scope}) + draft = preview["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded", saved + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + executed = service.execute_mode_profile_card( + saved["mode_profile"]["id"], conversation_id="conv-seat-policy-replay", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded", executed.get("validation") + assert executed["hardware_execution_allowed"] is False + assert _seat_scopes(executed) == {scope} + assert sorted(map(_operation_key, _actions(executed))) == sorted(map(_operation_key, _actions(preview))) + if preview.get("schedule"): + assert executed["schedule"] == preview["schedule"] + assert executed["schedule_execution"]["completed"] is True + assert all( + _seat_scopes(phase["result"]) <= {scope} + for phase in executed["schedule_execution"]["phase_results"] + ) + + +@pytest.mark.parametrize(("initial_scope", "prefix"), [("driver", "主驾"), ("passenger", "")]) +def test_unrelated_draft_edit_retains_original_seat_and_explicit_edit_may_extend_it(policy_service, initial_scope, prefix): + service, catalog, _ = policy_service + name = f"{prefix}星河释压模式" + built = _preview( + service, f"构建一个{name}", + plan=normalize_scene_output(_model_output({initial_scope}), catalog.scene_composition_context()), + ) + _assert_created(built, {initial_scope}) + draft = built["mode_profile_draft"] + revised = _preview( + service, f"把{name}的空调温度调整到26度", + draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}, + ) + _assert_created(revised, {initial_scope}) + original_seat_ops = {_operation_key(a) for a in _actions(built) if a["domain"] == "seat"} + assert {_operation_key(a) for a in _actions(revised) if a["domain"] == "seat"} == original_seat_ops + added = "副驾座椅通风调整到1档" if initial_scope == "driver" else "主驾座椅通风调整到低档" + draft = revised["mode_profile_draft"] + extended = _preview( + service, f"把{name}的{added}", + draft={key: draft[key] for key in ("name", "source_request_id", "vehicle_id")}, + ) + _assert_created(extended, {"driver", "passenger"}) + assert {_operation_key(a) for a in _actions(extended) if a["domain"] == "seat"} >= original_seat_ops + + +def _structured_client(output): + client = MagicMock() + client.available = True + client.status = "ready" + client.start_thread.return_value = "thread-seat-policy" + client.start_turn.side_effect = AssertionError("scene fell back to unconstrained agent") + + def start(thread_id, text, *, application_context, output_schema, timeout=None): + Draft202012Validator(output_schema).validate(output) + turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-seat-policy", events=queue.Queue()) + turn.events.put({"method": "item/completed", "params": {"item": { + "id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False), + }}}) + turn.events.put({"method": "turn/completed", "params": {"turn": { + "id": turn.turn_id, "status": "completed", "items": [], "error": None, + }}}) + return turn + + client.start_structured_turn.side_effect = start + return client + + +@pytest.mark.parametrize(("prefix", "scopes"), [("", {"passenger"}), ("主驾", {"driver"})]) +@pytest.mark.parametrize("dynamic", [False, True]) +def test_unregistered_assistant_structured_path_uses_seat_policy(policy_service, prefix, scopes, dynamic): + service, catalog, _ = policy_service + client = _structured_client(_model_output(scopes, dynamic=dynamic)) + response, preview, _ = _assistant_preview( + service, catalog, f"构建一个{prefix}星河释压{'动态' if dynamic else ''}模式", client=client, + ) + assert response["success"] is True, (response, preview) + assert response["route"] == "scene_composer" + _assert_created(preview, scopes) + client.start_structured_turn.assert_called_once() + client.start_turn.assert_not_called() diff --git a/tests/integration/test_mode_draft_parameter_matrix.py b/tests/integration/test_mode_draft_parameter_matrix.py index 255d81e..8156148 100644 --- a/tests/integration/test_mode_draft_parameter_matrix.py +++ b/tests/integration/test_mode_draft_parameter_matrix.py @@ -1237,6 +1237,10 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied( ) -> None: service, user_root = mode_service nap = service.mode_profiles.get_profile("nap", "denza_n7") + selected_operations = [ + operation for operation in nap.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} + ] result = _preview_without_hardware( service, "构建一个小憩模式", @@ -1249,15 +1253,16 @@ def test_known_mode_name_reuses_repository_profile_even_if_a_plan_is_supplied( draft = _assert_successful_preview( result, name=nap.name, - operation_count=len(nap.operations), + operation_count=len(selected_operations), ) assert draft["generation_source"] == "registered_mode_profile" assert draft["existing_mode_id"] == "nap" expected = { (operation.capability, operation.target_scope): operation.parameters - for operation in nap.operations + for operation in selected_operations } actual = _action_map(result) + assert {target for capability, target in actual if capability.startswith("seat.")} == {"passenger"} for (capability, target), parameters in expected.items(): rendered_target = "passenger" if target == "front_right" and capability.startswith("seat.") else target for name, value in parameters.items(): diff --git a/tests/integration/test_mode_schedule_flow.py b/tests/integration/test_mode_schedule_flow.py new file mode 100644 index 0000000..9d215ca --- /dev/null +++ b/tests/integration/test_mode_schedule_flow.py @@ -0,0 +1,670 @@ +"""Dynamic scene creation through the real assistant, compiler and saved-mode APIs.""" + +from __future__ import annotations + +import json +import queue +from copy import deepcopy +from threading import Event +from unittest.mock import MagicMock, patch + +import pytest +from jsonschema import Draft202012Validator + +from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog +from cognitive_agent.application.codex_assistant import ActiveCodexTurn, CodexAssistantService +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_service import public_schedule_status +from cognitive_agent.application.scene_composer import ( + SceneCompositionError, + build_scene_output_schema, + normalize_scene_output, +) +from cognitive_agent.application.schedule_intent import needs_mode_schedule +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService +from cognitive_agent.infrastructure.can.access import HardwareAccessRequiredError + + +@pytest.fixture +def schedule_service(tmp_path, monkeypatch): + for variable in ( + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED", "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED", "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED", + ): + monkeypatch.setenv(variable, "false") + service = SeatDemoService(REPOSITORY_ROOT) + service.artifact_root = tmp_path / "artifacts" + user_root = tmp_path / "user-modes" + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + with ( + patch.object(service.hardware_execution, "reserve", side_effect=AssertionError("physical vehicle access forbidden")) as reserve, + patch.object(service._vehicle_mqtt_transport, "bus", side_effect=AssertionError("physical MQTT access forbidden")), + ): + try: + yield service, AssistantCapabilityCatalog(service), user_root + finally: + service.close() + reserve.assert_not_called() + + +def assistant_preview(service, catalog, message, conversation="conv-schedule-flow", client=None): + if client is None: + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + fast = catalog.fast_answer(message, snapshot) + results, events, passed_plans = [], [], [] + + def execute(instruction, progress, assistant_plan=None): + passed_plans.append(deepcopy(assistant_plan)) + result = service.preview_native_can( + instruction, provider="local", conversation_id=conversation, + execution_mode="hardware", vehicle_id="denza_n7", + on_commands_validated=progress, assistant_plan=assistant_plan, + ) + results.append(result) + return result + + response = assistant.stream( + message, conversation_id=conversation, principal="schedule-test", history=[], + authoritative_answer=fast.answer if fast else None, + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + assert len(results) == 1, (response, results) + return response, results[0], events, passed_plans, client + + +def create_saved(service, catalog, message="构建一个上车预热的功能模式"): + response, preview, _, _, _ = assistant_preview(service, catalog, message) + assert response["success"] is True, (response, preview) + draft = preview["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + return preview, saved["mode_profile"] + + +@pytest.mark.parametrize(("message", "template_id", "count", "repeats", "elapsed"), [ + ("构建一个座椅的动态模式", "seat-dynamic", 4, 5, 300000), + ("构建一个上车预热的功能模式", "boarding-warmup", 7, 1, 420000), + ("帮我创建一个座椅动态模式吧", "seat-dynamic", 4, 5, 300000), + ("我想构建一个上车预热模式并预览", "boarding-warmup", 7, 1, 420000), + ("构建一个上车暖舱模式", "boarding-warmup", 7, 1, 420000), + ("构建一个空调预热模式", "boarding-warmup", 7, 1, 420000), + ("构建一个动态座椅模式", "seat-dynamic", 4, 5, 300000), + ("构建一个座椅交替放松模式", "seat-dynamic", 4, 5, 300000), +]) +def test_user_temporal_requests_create_complete_multistage_template_without_model( + schedule_service, message, template_id, count, repeats, elapsed, +): + service, catalog, user_root = schedule_service + response, preview, events, plans, client = assistant_preview(service, catalog, message) + assert response["success"] is True, (response, preview) + assert response["route"] == "scene_template" + assert preview["status"] == "succeeded" + assert preview["execution_mode"] == "dry_run" + assert preview["hardware_execution_allowed"] is False + assert preview["orchestration_route"] == "mode_profile_create_preview" + draft = preview["mode_profile_draft"] + assert draft["source_template_id"] == template_id + assert draft["generation_source"] == "scene_template_copy" + assert draft["operation_count"] == count + expected_schedule = deepcopy(plans[0]["schedule"]) + for phase in expected_schedule["phases"]: + phase["operations"] = [ + op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] == "passenger" + ] + assert draft["schedule"] == preview["schedule"] == expected_schedule + schedule = ModeSchedule.from_dict(preview["schedule"]) + assert schedule.repeat_count == repeats + assert schedule.total_duration_ms == elapsed + actions = preview["intent_ir"]["spec"]["actions"] + assert len(actions) == count + assert len({action["id"] for action in actions}) == count + assert {item["phase_id"] for item in preview["schedule_phase_previews"]} == {phase.id for phase in schedule.phases} + assert len(preview["schedule_phase_previews"]) == 2 + assert all(item["status"] == "succeeded" for item in preview["schedule_phase_previews"]) + assert all(any(key.startswith(phase.id + "-") for key in preview["command_plans"]) for phase in schedule.phases) + assert { + op["target_scope"] for phase in schedule.phases for op in phase.operations + if op["domain"] == "seat" + } == {"passenger"} + assert events + client.start_thread.assert_not_called() + client.start_turn.assert_not_called() + client.start_structured_turn.assert_not_called() + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"]) +def test_draft_trial_preserves_all_stages_repeats_and_virtual_time_without_thread(schedule_service, message): + service, catalog, _ = schedule_service + _, preview, _, _, _ = assistant_preview(service, catalog, message) + draft = preview["mode_profile_draft"] + with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")): + trial = service.trial_mode_profile_draft( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"], + ) + assert trial["status"] == "succeeded" + assert trial["hardware_execution_allowed"] is False + assert trial["schedule"] == preview["schedule"] + execution = trial["schedule_execution"] + schedule = ModeSchedule.from_dict(preview["schedule"]) + assert execution["completed"] is True + assert execution["virtual_elapsed_ms"] == schedule.total_duration_ms + assert execution["executed_phases"] == len(schedule.phases) * schedule.repeat_count + assert [step["phase_id"] for step in execution["phase_results"]] == [phase.id for phase in schedule.phases] * schedule.repeat_count + assert len(trial["intent_ir"]["spec"]["actions"]) == schedule.operation_count + + +@pytest.mark.parametrize("message", ["构建一个座椅的动态模式", "构建一个上车预热的功能模式"]) +def test_save_reload_and_direct_execute_keep_temporal_semantics(schedule_service, message): + service, catalog, user_root = schedule_service + preview, public_profile = create_saved(service, catalog, message) + assert public_profile["schedule"] == preview["schedule"] + assert public_profile["behavior"] == "cyclic" + mode_id = public_profile["id"] + service.mode_profiles = ModeProfileRegistry(REPOSITORY_ROOT / "config" / "modes", user_root=user_root) + reloaded = service.mode_profiles.get_profile(mode_id, "denza_n7") + assert reloaded.schedule.to_dict() == preview["schedule"] + with patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("dry-run spawned worker")): + executed = service.execute_mode_profile_card( + mode_id, conversation_id="conv-schedule-execute", execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert executed["status"] == "succeeded", executed + assert executed["orchestration_route"] == "mode_schedule_execute" + assert executed["execution_mode"] == "dry_run" + assert executed["hardware_execution_allowed"] is False + assert "mode_profile_draft" not in executed + execution = executed["schedule_execution"] + assert execution["completed"] is True + assert execution["virtual_elapsed_ms"] == reloaded.schedule.total_duration_ms + assert len(execution["phase_results"]) == len(reloaded.schedule.phases) * reloaded.schedule.repeat_count + expected_phases = list(reloaded.schedule.phases) * reloaded.schedule.repeat_count + for expected, actual in zip(expected_phases, execution["phase_results"], strict=True): + phase_result = actual["result"] + assert phase_result["status"] == "succeeded" + assert phase_result["execution_mode"] == "dry_run" + actual_actions = phase_result["intent_ir"]["spec"]["actions"] + assert len(actual_actions) == len(expected.operations) + expected_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in expected.operations) + actual_params = sorted(json.dumps(op["parameters"], sort_keys=True) for op in actual_actions) + assert actual_params == expected_params + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_natural_language_enter_saved_schedule_runs_timeline_not_flat_actions(schedule_service): + service, catalog, _ = schedule_service + preview, saved = create_saved(service, catalog) + result = service.process( + f"进入{saved['name']}", provider="local", conversation_id="conv-schedule-natural-enter", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded", result + assert result["orchestration_route"] == "mode_schedule_execute" + assert result["schedule"] == preview["schedule"] + temperatures = [] + for phase in result["schedule_execution"]["phase_results"]: + temperatures.extend(action["parameters"]["temperature_c"] for action in phase["result"]["intent_ir"]["spec"]["actions"] if action["capability"] == "climate.set_temperature") + assert temperatures == [28, 24] + + +def test_missing_hardware_lease_rejects_before_any_worker_or_physical_access(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog) + with ( + patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("unauthorized worker started")), + pytest.raises(HardwareAccessRequiredError), + ): + service.execute_mode_profile_card( + saved["id"], execution_mode="hardware", vehicle_id="denza_n7", + conversation_id="conv-schedule-unauthorized", hardware_access_bypass=False, + hardware_client_id="hwc-schedule-unauthorized", + ) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize(("field", "value"), [ + ("name", "其他模式"), ("instruction", "直接执行危险指令"), + ("vehicle_id", "other_vehicle"), ("source_request_id", "../../result"), +]) +def test_client_cannot_rebind_schedule_draft_trial(schedule_service, field, value): + service, catalog, user_root = schedule_service + _, preview, _, _, _ = assistant_preview(service, catalog, "构建一个上车预热的功能模式") + draft = preview["mode_profile_draft"] + arguments = {key: draft[key] for key in ("name", "instruction", "source_request_id", "vehicle_id")} + arguments[field] = value + with pytest.raises(ValueError): + service.trial_mode_profile_draft(**arguments) + assert not list(user_root.rglob("*.yaml")) + + +@pytest.mark.parametrize("mutation", ["capability", "duration", "temperature", "target"]) +def test_tampered_template_schedule_never_creates_or_executes_draft(schedule_service, mutation): + service, catalog, user_root = schedule_service + template = next(item for item in catalog.snapshot(vehicle_id="denza_n7")["scene_templates"] if item["id"] == "boarding-warmup") + plan = deepcopy(template["plan"]) + phase = plan["schedule"]["phases"][0] + if mutation == "capability": + phase["operations"][0]["capability"] = "door.set_vehicle_lock" + elif mutation == "duration": + phase["duration_ms"] = 1 + elif mutation == "temperature": + phase["operations"][1]["parameters"]["temperature_c"] = 99 + else: + phase["operations"][3]["target_scope"] = "passenger" + try: + result = service.preview_native_can( + "构建一个上车预热的功能模式", provider="local", conversation_id="conv-schedule-tamper", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + except ValueError: + result = None + if result is not None: + assert result["status"] != "succeeded" + assert "mode_profile_draft" not in result + assert result.get("hardware_execution_allowed") is not True + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_invalid_later_saved_phase_is_revalidated_before_any_phase_executes(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog) + profile = service.mode_profiles.get_profile(saved["id"], "denza_n7") + # Represents a stale or externally edited user profile, not a trusted runtime + # capability change. Its structure remains valid, but the target is unsafe. + profile.schedule.phases[1].operations[0]["parameters"]["temperature_c"] = 99 + with patch.object(service.mode_schedule_runner, "simulate", side_effect=AssertionError("invalid later phase reached executor")): + result = service.execute_mode_profile_card( + saved["id"], conversation_id="conv-schedule-stale-capability", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "validation_failed", result + assert result["mode_creation_failure"]["code"] == "MODE_SCHEDULE_PHASE_INVALID" + assert "mode_profile_draft" not in result + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +def test_execution_failure_after_validated_first_phase_prevents_later_repeats(schedule_service): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式") + preview_method = service._preview_mode_schedule + virtual_method = service.native_can_runtime.execute_virtual + execution_started = False + executed_massage_modes = [] + + def validate_then_arm(*args, **kwargs): + nonlocal execution_started + result = preview_method(*args, **kwargs) + execution_started = True + return result + + def fault_on_second_phase(command_plan, function_config): + if execution_started: + for op in function_config["spec"]["operations"]: + if op["capability"] == "seat.set_driver_massage_native": + executed_massage_modes.append(op["parameters"]["mode"]) + if op["parameters"]["mode"] == "slack": + raise RuntimeError("injected lower-level actuator failure") + return virtual_method(command_plan, function_config) + + with ( + patch.object(service, "_preview_mode_schedule", side_effect=validate_then_arm), + patch.object(service.native_can_runtime, "execute_virtual", side_effect=fault_on_second_phase), + ): + result = service.execute_mode_profile_card( + saved["id"], conversation_id="conv-schedule-injected-failure", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + execution = result["schedule_execution"] + assert result["status"] == "failed", result + assert execution["completed"] is False + assert execution["error"] + assert execution["executed_phases"] == 1 + assert execution["virtual_elapsed_ms"] == 30000 + assert execution["repeat_index"] == 0 + assert len(execution["phase_results"]) == 2 + assert executed_massage_modes == ["relax", "slack"] + assert result["hardware_execution_allowed"] is False + + +def structured_schedule(): + return { + "requests": [], "ambiguities": [], "confidence": 0.96, + "schedule": { + "repeat_count": 2, + "phases": [ + {"id": "warm", "name": "暖舱", "duration_ms": 30000, + "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 28}}]}, + {"id": "settle", "name": "恒温", "duration_ms": 60000, + "requests": [{"capability": "climate.set_temperature", "target_scope": "vehicle", "parameters": {"temperature_c": 24}}]}, + ], + }, + } + + +def structured_client(output): + client = MagicMock() + client.available = True + client.status = "ready" + client.start_thread.return_value = "thread-schedule-test" + client.start_turn.side_effect = AssertionError("dynamic creation fell back to unstructured agent") + + def start(thread_id, text, *, application_context, output_schema, timeout=None): + Draft202012Validator(output_schema).validate(output) + turn = ActiveCodexTurn(thread_id=thread_id, turn_id="turn-schedule-test", events=queue.Queue()) + turn.events.put({"method": "item/completed", "params": {"item": {"id": "answer", "type": "agentMessage", "text": json.dumps(output, ensure_ascii=False)}}}) + turn.events.put({"method": "turn/completed", "params": {"turn": {"id": turn.turn_id, "status": "completed", "items": [], "error": None}}}) + return turn + + client.start_structured_turn.side_effect = start + return client + + +@pytest.mark.parametrize("message", [ + "构建一个星云动态模式,空调先28度持续30秒,然后24度持续60秒,循环2次", + "构建一个阅读模式,空调先28度持续30秒,然后24度持续60秒,循环2次", + "构建一个上车预热的功能模式,空调先28度持续30秒,然后24度持续60秒,循环2次", +]) +def test_explicit_temporal_parameters_use_structured_composition_not_static_template(schedule_service, message): + service, catalog, user_root = schedule_service + output = structured_schedule() + client = structured_client(output) + response, preview, _, plans, _ = assistant_preview(service, catalog, message, client=client) + assert response["route"] == "scene_composer", response + assert response["success"] is True, (response, preview) + assert preview["mode_profile_draft"]["generation_source"] != "scene_template_copy" + client.start_structured_turn.assert_called_once() + client.start_turn.assert_not_called() + assert "schedule" in client.start_structured_turn.call_args.kwargs["output_schema"]["properties"] + assert preview["schedule"] == plans[0]["schedule"] + assert preview["schedule"]["repeat_count"] == 2 + assert [phase["duration_ms"] for phase in preview["schedule"]["phases"]] == [30000, 60000] + assert [phase["operations"][0]["parameters"]["temperature_c"] for phase in preview["schedule"]["phases"]] == [28, 24] + assert ModeSchedule.from_dict(preview["schedule"]).total_duration_ms == 180000 + assert not list(user_root.rglob("*.yaml")) + + +def test_dynamic_schema_normalization_preserves_complete_phase_contract(schedule_service): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + schema = build_scene_output_schema(context, dynamic=True) + Draft202012Validator(schema).validate(output) + plan = normalize_scene_output(output, context, dynamic=True) + assert plan["ambiguities"] == [] + schedule = ModeSchedule.from_dict(plan["schedule"]) + assert schedule.total_duration_ms == 180000 + assert schedule.operation_count == 2 + assert schedule.phases[0].operations[0]["domain"] == "climate" + assert schedule.phases[1].operations[0]["parameters"] == {"temperature_c": 24} + + +@pytest.mark.parametrize("mutation", [ + "static-root", "unknown-root", "unknown-schedule", "unknown-phase", "unknown-capability", + "duplicate-phase", "duplicate-operation", "no-phases", "empty-phase", "bool-duration", + "bool-repeat", "long-duration", "long-total", "nan-parameter", "motion-capability", +]) +def test_invalid_structured_dynamic_plans_fail_closed_during_normalization(schedule_service, mutation): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + schedule = output["schedule"] + phase = schedule["phases"][0] + if mutation == "static-root": + output["requests"] = deepcopy(phase["requests"]) + elif mutation == "unknown-root": + output["execute_now"] = True + elif mutation == "unknown-schedule": + schedule["unbounded"] = True + elif mutation == "unknown-phase": + phase["skip_safety"] = True + elif mutation == "unknown-capability": + phase["requests"][0]["capability"] = "unknown.actuate" + elif mutation == "motion-capability": + phase["requests"][0] = {"capability": "seat.set_driver_axis_native", "target_scope": "driver", "parameters": {"duration_ms": 1000, "axis": "backrest", "direction": "backward"}} + elif mutation == "duplicate-phase": + schedule["phases"][1]["id"] = phase["id"] + elif mutation == "duplicate-operation": + phase["requests"].append(deepcopy(phase["requests"][0])) + elif mutation == "no-phases": + schedule["phases"] = [] + elif mutation == "empty-phase": + phase["requests"] = [] + elif mutation == "bool-duration": + phase["duration_ms"] = True + elif mutation == "bool-repeat": + schedule["repeat_count"] = True + elif mutation == "long-duration": + phase["duration_ms"] = 900001 + elif mutation == "long-total": + schedule["repeat_count"] = 20 + phase["duration_ms"] = 900000 + elif mutation == "nan-parameter": + phase["requests"][0]["parameters"]["temperature_c"] = float("nan") + with pytest.raises(SceneCompositionError): + normalize_scene_output(output, context, dynamic=True) + + +def test_dynamic_ambiguity_produces_no_schedule_or_executable_actions(schedule_service): + _, catalog, _ = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = {"requests": [], "ambiguities": ["当前未开放该动态动作,请指定其他设备。"], "confidence": 0.2, "schedule": None} + Draft202012Validator(build_scene_output_schema(context, dynamic=True)).validate(output) + plan = normalize_scene_output(output, context, dynamic=True) + assert not plan["requests"] + assert "schedule" not in plan + assert plan["ambiguities"] + + +def test_model_schedule_with_unresolved_requirement_never_generates_successful_draft(schedule_service): + service, catalog, user_root = schedule_service + context = catalog.scene_composition_context(catalog.snapshot(vehicle_id="denza_n7")) + output = structured_schedule() + output["ambiguities"] = ["不能完成要求的主驾座椅绝对位置控制。"] + plan = normalize_scene_output(output, context, dynamic=True) + assert "metadata" not in plan + with pytest.raises(ValueError, match="未解决"): + service.preview_native_can( + "构建一个流光动态模式", provider="local", conversation_id="conv-schedule-ambiguous", + execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, + ) + assert not list(user_root.rglob("*.yaml")) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "idle" + + +@pytest.mark.parametrize(("seat", "allowed_targets"), [ + ("主驾", {"driver", "front_left"}), ("副驾", {"passenger", "front_right"}), +]) +def test_single_seat_dynamic_execution_cannot_expand_to_both_seats(schedule_service, seat, allowed_targets): + service, catalog, _ = schedule_service + _, saved = create_saved(service, catalog, "构建一个主驾和副驾的座椅动态模式") + result = service.process( + f"只让{seat}进入{saved['name']}", provider="local", conversation_id="conv-schedule-single-seat", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + if result["status"] == "succeeded": + assert result["orchestration_route"] == "mode_schedule_execute" + execution = result["schedule_execution"] + for phase in execution["phase_results"]: + actions = phase["result"]["intent_ir"]["spec"]["actions"] + seat_targets = {action["target"].get("instance") or action["target"].get("zone") for action in actions if action["domain"] == "seat"} + assert seat_targets and seat_targets <= allowed_targets + else: + assert result["status"] in {"validation_failed", "needs_clarification", "needs_confirmation", "failed"} + assert not result.get("schedule_execution", {}).get("phase_results") + assert result.get("hardware_execution_allowed") is not True + + +def start_waiting_schedule(service): + """Run only a no-device test callback, holding until the tested stop request.""" + calls = [] + entered = Event() + schedule = ModeSchedule.from_dict({ + "schema": "cognitive-agent-mode-schedule/v1", "repeat_count": 1, + "phases": [ + {"id": phase_id, "name": phase_id, "duration_ms": 900000, + "operations": [{"domain": "climate", "capability": "climate.set_temperature", + "target_scope": "vehicle", "operation": "set", + "parameters": {"temperature_c": temperature}, "reason": "test only"}]} + for phase_id, temperature in (("first", 28), ("second", 24)) + ], + }) + + def execute(phase): + calls.append(phase.id) + entered.set() + return {"status": "succeeded"} + + service.mode_schedule_runner.start("denza_n7", schedule, execute, session_id="conv-stop-status") + assert entered.wait(2) + assert service.mode_schedule_runner.status("denza_n7")["status"] == "running" + return calls + + +def assistant_control(catalog, message, execute): + client = MagicMock() + client.available = False + assistant = CodexAssistantService(client) + snapshot = catalog.snapshot(vehicle_id="denza_n7") + events = [] + response = assistant.stream( + message, conversation_id="conv-stop-status", principal="schedule-status-test", history=[], + emit=lambda event: events.append(event) or True, + execute_control=execute, capability_snapshot=snapshot, + capability_context=catalog.compact_context(snapshot), + scene_capability_context=catalog.scene_composition_context(snapshot), + control_terms=catalog.control_terms(snapshot), + ) + client.start_thread.assert_not_called() + client.start_structured_turn.assert_not_called() + client.start_turn.assert_not_called() + return response, events + + +def test_dry_run_stop_does_not_cancel_a_running_vehicle_schedule(schedule_service): + service, _, _ = schedule_service + calls = start_waiting_schedule(service) + result = service.process( + "停止动态模式", provider="local", conversation_id="conv-stop-status", + execution_mode="dry_run", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert result["schedule_execution"]["status"] == "simulated" + assert result["hardware_execution_allowed"] is False + assert service.mode_schedule_runner.status("denza_n7")["status"] == "running" + assert calls == ["first"] + persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8")) + assert persisted["orchestration_route"] == "mode_schedule_stop" + assert persisted["trace_events"] == [] + + +def test_hardware_stop_cancels_future_stages_and_persists_the_result(schedule_service): + service, _, _ = schedule_service + calls = start_waiting_schedule(service) + result = service.process( + "停止动态模式", provider="local", conversation_id="conv-stop-status", + execution_mode="hardware", vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert result["schedule_execution"]["status"] == "cancelled" + assert result["schedule_execution"]["completed"] is False + assert service.mode_schedule_runner.status("denza_n7")["status"] == "cancelled" + assert service.mode_schedule_runner.close(timeout=1) + assert calls == ["first"] + persisted = json.loads((service.artifact_root / result["request_id"] / "result.json").read_text(encoding="utf-8")) + assert persisted["orchestration_route"] == "mode_schedule_stop" + assert persisted["schedule_execution"]["status"] == "cancelled" + assert persisted["trace_events"] == [] + + +@pytest.mark.parametrize(("execution_mode", "action_state", "runner_state"), [ + ("hardware", "cancelled", "cancelled"), ("dry_run", "simulated", "running"), +]) +def test_assistant_stop_is_zero_model_success_and_preserves_execution_policy( + schedule_service, execution_mode, action_state, runner_state, +): + service, catalog, _ = schedule_service + calls = start_waiting_schedule(service) + + def execute(message, progress): + return service.process( + message, provider="local", conversation_id="conv-stop-status", + execution_mode=execution_mode, vehicle_id="denza_n7", + ) + + response, events = assistant_control(catalog, "停止动态模式", execute) + assert response["success"] is True, response + assert response["route"] == "vehicle_control" + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["state"] == action_state + assert action["payload"]["orchestration_route"] == "mode_schedule_stop" + assert service.mode_schedule_runner.status("denza_n7")["status"] == runner_state + assert calls == ["first"] + assert "已完成" not in response["answer"] + assert "未改变正在运行" in response["answer"] if execution_mode == "dry_run" else "已停止" in response["answer"] + + +def test_assistant_running_schedule_is_accepted_without_claiming_all_phases_completed(schedule_service): + service, catalog, _ = schedule_service + preview, profile = create_saved(service, catalog) + start_waiting_schedule(service) + state = service.mode_schedule_runner.status("denza_n7") + state["phase_results"] = [{"command_frames": ["sensitive-can-frame"]}] + + def execute(message, progress): + return { + "status": "running", "execution_mode": "hardware", "hardware_execution_allowed": True, + "orchestration_route": "mode_schedule_execute", "schedule_execution": state, + "mode_profile": profile, "matched_modes": [profile], "schedule": preview["schedule"], + } + + response, events = assistant_control(catalog, f"进入{profile['name']}", execute) + assert response["success"] is True, response + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["state"] == "running" + assert action["payload"]["status"] == "running" + assert action["payload"]["schedule_execution"]["completed"] is False + assert "phase_results" not in action["payload"]["schedule_execution"] + assert "sensitive-can-frame" not in json.dumps(events, ensure_ascii=False) + assert "尚未完成所有阶段" in action["display_text"] + assert "尚未完成所有阶段" in response["answer"] + completed = next(event for event in events if event["type"] == "work.completed") + assert completed["state"] == "running" + assert "已完成" not in completed["display_text"] + + +def test_public_schedule_status_exposes_only_lifecycle_and_redacts_raw_error(): + state = { + "run_id": "run-public", "vehicle_id": "denza_n7", "status": "failed", + "phase_index": 1, "repeat_index": 0, "completed": False, + "error": "secret-token /private/path/driver.dll CAN payload 123456", + "phase_results": [{"command_plan": {"data_hex": "deadbeef"}}], + "raw_error": {"token": "secret-token"}, "command_plans": {"internal": "data"}, + "phase_count": {"nested": "unexpected"}, + } + public = public_schedule_status(state) + assert public["status"] == "failed" + assert public["phase_index"] == 1 + assert public["completed"] is False + assert public["error"] and public["error"] != state["error"] + assert set(public) == {"run_id", "vehicle_id", "status", "phase_index", "repeat_index", "completed", "error"} + for sensitive in ("secret-token", "driver.dll", "deadbeef", "123456"): + assert sensitive not in json.dumps(public, ensure_ascii=False) + + +@pytest.mark.parametrize("message", ["外循环通风模式", "内循环模式"]) +def test_hvac_circulation_state_is_not_a_temporal_schedule(message): + assert needs_mode_schedule(message) is False diff --git a/tests/integration/test_mode_schedule_natural_language_edit.py b/tests/integration/test_mode_schedule_natural_language_edit.py new file mode 100644 index 0000000..ea1c278 --- /dev/null +++ b/tests/integration/test_mode_schedule_natural_language_edit.py @@ -0,0 +1,278 @@ +"""Natural-language edits cannot flatten or silently broaden dynamic phases.""" + +from __future__ import annotations + +import hashlib +from copy import deepcopy +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.mode_schedule import ModeSchedule +from cognitive_agent.application.mode_schedule_edit import edit_schedule +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + + +@pytest.fixture +def service(tmp_path): + with patch.dict( + "os.environ", + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + }, + ): + instance = SeatDemoService(REPOSITORY_ROOT) + instance.artifact_root = tmp_path / "runs" + instance.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path / "user-modes", + ) + try: + with patch.object( + instance.hardware_execution, + "reserve", + side_effect=AssertionError("schedule edit reached hardware"), + ): + yield instance + finally: + instance.close() + + +def _build(service): + template = service.scene_templates.get_template("boarding-warmup") + result = service.preview_native_can( + "构建一个上车预热模式", + provider="local", + conversation_id="conv-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=template.plan(), + ) + assert result["status"] == "succeeded", result.get("validation") + return result + + +def _edit(service, built, change): + draft = built["mode_profile_draft"] + reference = {key: draft[key] for key in ("name", "source_request_id", "vehicle_id")} + return service.preview_native_can( + f"把上车预热模式的{change}", + provider="local", + conversation_id="conv-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=reference, + ) + + +def _parameters(schedule, index, capability, target="vehicle"): + return next( + op["parameters"] + for op in schedule["phases"][index]["operations"] + if op["capability"] == capability and op["target_scope"] == target + ) + + +@pytest.mark.parametrize( + "change", + ( + "第二阶段空调温度调整到26度", + "第2个阶段的空调温度变成26度", + "舒适恒温阶段空调温度改为26度", + ), +) +def test_draft_temperature_edit_preserves_first_phase_and_timing(service, change): + built = _build(service) + original = deepcopy(built["schedule"]) + revised = _edit(service, built, change) + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + expected = deepcopy(original) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + assert revised["schedule"] == expected + assert built["schedule"] == original + assert revised["mode_profile_draft"]["schedule"] == expected + assert revised["mode_profile_draft"]["draft_revision"] == 2 + assert revised["mode_profile_draft"]["revises_source_request_id"] == built["request_id"] + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + + +@pytest.mark.parametrize( + ("change", "expected_hold", "expected_repeat"), + ( + ("第二阶段持续时间调整为3分钟", 180000, 1), + ("第二阶段保持30秒", 30000, 1), + ("第二阶段时长改成2.5分钟", 150000, 1), + ("循环次数改成3次", 300000, 3), + ("重复两次", 300000, 2), + ), +) +def test_draft_time_and_repeat_edits_keep_operations( + service, change, expected_hold, expected_repeat +): + built = _build(service) + revised = _edit(service, built, change) + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + assert revised["schedule"]["phases"][1]["duration_ms"] == expected_hold + assert revised["schedule"]["repeat_count"] == expected_repeat + for index in (0, 1): + assert ( + revised["schedule"]["phases"][index]["operations"] + == built["schedule"]["phases"][index]["operations"] + ) + + +def test_all_phases_temperature_edit_is_explicit_and_preserves_all_other_operations(service): + built = _build(service) + revised = _edit(service, built, "全部阶段空调温度调整到25度") + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + expected = deepcopy(built["schedule"]) + for index in (0, 1): + _parameters(expected, index, "climate.set_temperature")["temperature_c"] = 25 + assert revised["schedule"] == expected + + +@pytest.mark.parametrize( + "change", + ( + "空调温度调整到25度", + "第三阶段空调温度调整到25度", + "第二阶段持续16分钟", + "循环21次", + "重复1.5次", + "持续30秒", + "第二阶段主驾靠背调整到90%", + "第二阶段空调温度调整到99度", + "第二阶段打开后备箱", + "第二阶段空调温度调整到26度并打开后备箱", + ), +) +def test_ambiguous_unsafe_or_unbounded_edit_leaves_original_draft_unchanged(service, change): + built = _build(service) + original = deepcopy(built["schedule"]) + revised = _edit(service, built, change) + assert revised["status"] in {"needs_confirmation", "validation_failed"} + assert not revised.get("mode_profile_draft") + assert revised["hardware_execution_allowed"] is False + source = service._load_mode_profile_draft_reference( + { + key: built["mode_profile_draft"][key] + for key in ("name", "source_request_id", "vehicle_id") + }, + conversation_id="conv-schedule-edit", + mode_name="上车预热模式", + vehicle_id="denza_n7", + )[1] + assert source["schedule"] == original + + +def test_saved_schedule_edit_revalidates_and_persists_timeline_not_flat_actions(service): + built = _build(service) + draft = built["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"] + ) + mode_id = saved["mode_profile"]["id"] + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + path = Path(profile.source) + before = hashlib.sha256(path.read_bytes()).hexdigest() + rejected = service.preview_native_can( + "把上车预热模式的空调温度调整到26度", + provider="local", + conversation_id="conv-saved-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert rejected["status"] == "needs_confirmation" + assert hashlib.sha256(path.read_bytes()).hexdigest() == before + + revised = service.preview_native_can( + "把上车预热模式的第二阶段空调温度调整到26度", + provider="local", + conversation_id="conv-saved-schedule-edit", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + assert revised["status"] == "succeeded", revised.get("validation") + assert revised["orchestration_route"] == "mode_profile_update" + assert revised["hardware_execution_allowed"] is False + actual = service.mode_profiles.get_profile(mode_id, "denza_n7").schedule.to_dict() + expected = deepcopy(built["schedule"]) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + assert actual == expected + assert revised["schedule"] == expected + assert revised["mode_profile"]["behavior"] == "cyclic" + + +def test_combined_phase_action_hold_then_repeat_revision_preserves_timeline(service): + built = _build(service) + revised = _edit(service, built, "第二阶段空调温度调整到26度,持续3分钟") + assert revised["status"] == "succeeded", revised.get("mode_creation_failure") + repeated = _edit(service, revised, "重复两次") + assert repeated["status"] == "succeeded", repeated.get("mode_creation_failure") + expected = deepcopy(built["schedule"]) + _parameters(expected, 1, "climate.set_temperature")["temperature_c"] = 26 + expected["phases"][1]["duration_ms"] = 180000 + expected["repeat_count"] = 2 + assert repeated["schedule"] == expected + assert repeated["mode_profile_draft"]["draft_revision"] == 3 + draft = repeated["mode_profile_draft"] + saved = service.create_mode_profile_card( + draft["name"], draft["instruction"], draft["source_request_id"], draft["vehicle_id"] + ) + assert ( + service.mode_profiles.get_profile( + saved["mode_profile"]["id"], "denza_n7" + ).schedule.to_dict() + == expected + ) + + +def test_all_phases_is_not_mistaken_for_all_vehicle_zones(service): + source = service.scene_templates.get_template("boarding-warmup").plan()["schedule"] + for phase in source["phases"]: + for operation in phase["operations"]: + if operation["capability"] == "climate.set_temperature": + operation["target_scope"] = "passenger" + schedule = ModeSchedule.from_dict(source) + updated = edit_schedule( + schedule, + "全部阶段空调温度调整到25度", + parse_operations=lambda _: [ + { + "domain": "climate", + "capability": "climate.set_temperature", + "target_scope": "vehicle", + "operation": "set", + "parameters": {"temperature_c": 25}, + } + ], + target_is_explicit=service._mode_update_target_is_explicit, + ) + for index in (0, 1): + assert _parameters(updated.to_dict(), index, "climate.set_temperature", "passenger") == { + "temperature_c": 25 + } + assert not any( + op["capability"] == "climate.set_temperature" and op["target_scope"] == "vehicle" + for op in updated.phases[index].operations + ) + + +def test_phase_motor_duration_cannot_become_a_hold_duration(service): + built = _build(service) + result = _edit(service, built, "第二阶段副驾靠背向后移动3s") + assert result["status"] in {"needs_confirmation", "validation_failed"} + assert not result.get("mode_profile_draft") + + +def test_stage_selector_does_not_authorize_delayed_action_semantics(service): + built = _build(service) + result = _edit(service, built, "第二阶段30秒后空调温度调整到26度") + assert result["status"] == "needs_confirmation" + assert not result.get("mode_profile_draft") diff --git a/tests/integration/test_named_mode_inference_regression.py b/tests/integration/test_named_mode_inference_regression.py index 80c6129..8802bd4 100644 --- a/tests/integration/test_named_mode_inference_regression.py +++ b/tests/integration/test_named_mode_inference_regression.py @@ -273,7 +273,8 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( draft = result["mode_profile_draft"] assert isinstance(draft, dict) assert draft["name"] == "小憩模式" - assert draft["operation_count"] == len(preset.operations) == 9 + assert draft["operation_count"] == 9 + assert len(preset.operations) == 10 # The registry retains both optional seat variants. assert set(draft["domains"]) == {"seat", "climate", "roof", "ambient_light"} assert draft["existing_mode_id"] == "nap" assert isinstance(draft["existing_mode_revision"], str) @@ -283,6 +284,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( actions = _intent_actions(result) assert {action["capability"] for action in actions} == { operation.capability for operation in preset.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} } == EXPECTED_NAP_CAPABILITIES expected_targets_and_parameters = { operation.capability: ( @@ -292,6 +294,7 @@ def test_name_only_nap_request_reuses_the_verified_repository_profile( operation.parameters, ) for operation in preset.operations + if operation.domain != "seat" or operation.target_scope in {"passenger", "front_right"} } actual_targets_and_parameters = { action["capability"]: ( diff --git a/tests/integration/test_registered_scene_seat_coverage.py b/tests/integration/test_registered_scene_seat_coverage.py new file mode 100644 index 0000000..1b5c7c0 --- /dev/null +++ b/tests/integration/test_registered_scene_seat_coverage.py @@ -0,0 +1,347 @@ +"""Seat-aware built-in defaults must remain safe and preserve saved user intent.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService + +COMFORT_TEMPLATE_IDS = ( + "reading", + "cinema", + "camping", + "quiet-night", + "reception", + "romantic", + "winter-warmth", + "summer-cooling", + "long-trip-comfort", + "relaxation", + "seat-comfort", +) +DRIVER_COMFORT_CAPABILITIES = { + "seat.set_driver_ventilation_native", + "seat.set_driver_heating_native", + "seat.set_driver_massage_native", +} + + +@pytest.fixture +def service(tmp_path: Path) -> Iterator[SeatDemoService]: + with patch.dict( + "os.environ", + { + "COGNITIVE_AGENT_HARDWARE_EXECUTION_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_CAN_ENABLED": "false", + "COGNITIVE_AGENT_WIRED_PASSENGER_CAN_ENABLED": "false", + "COGNITIVE_AGENT_VEHICLE_MQTT_ENABLED": "false", + }, + ): + instance = SeatDemoService(REPOSITORY_ROOT) + instance.artifact_root = tmp_path / "runs" + instance.mode_profiles = ModeProfileRegistry( + REPOSITORY_ROOT / "config" / "modes", + user_root=tmp_path / "user-modes", + ) + try: + with patch.object( + instance.hardware_execution, + "reserve", + side_effect=AssertionError("creating or previewing a mode reached real hardware"), + ): + yield instance + finally: + instance.close() + + +def _preview(service: SeatDemoService, instruction: str, *, plan=None) -> dict: + result = service.preview_native_can( + instruction, + provider="local", + conversation_id="conv-registered-seat-coverage", + execution_mode="hardware", + vehicle_id="denza_n7", + assistant_plan=plan, + ) + assert result["status"] == "succeeded", result.get("validation") + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + return result + + +def _action_map(result: dict) -> dict: + return { + ( + action["capability"], + action["target"].get("instance") or action["target"].get("zone"), + ): action["parameters"] + for action in result["intent_ir"]["spec"]["actions"] + } + + +@pytest.mark.parametrize("template_id", COMFORT_TEMPLATE_IDS) +@pytest.mark.parametrize(("seat_prefix", "targets"), ( + ("", {"passenger"}), ("主驾", {"driver"}), + ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}), +)) +def test_comfort_templates_select_only_requested_seat_without_mutating_seat_seeds( + service: SeatDemoService, + template_id: str, + seat_prefix: str, + targets: set[str], +) -> None: + template = service.scene_templates.get_template(template_id) + original_revision = template.revision + result = _preview(service, f"构建一个{seat_prefix}{template.name}", plan=template.plan()) + actions = _action_map(result) + seat_keys = {key for key in actions if key[0].startswith("seat.")} + assert {target for _, target in seat_keys} == targets + assert { + operation.target_scope for operation in template.operations if operation.domain == "seat" + } == {"driver", "passenger"} + assert all( + operation.capability in DRIVER_COMFORT_CAPABILITIES + for operation in template.operations + if operation.domain == "seat" and operation.target_scope == "driver" + ), "driver positional or timed movement must not enter static comfort defaults" + assert not any( + "massage" in capability for capability, target in seat_keys if target == "passenger" + ) + assert actions == { + (operation.capability, operation.target_scope): operation.parameters + for operation in template.operations + if operation.domain != "seat" or operation.target_scope in targets + } + assert result["mode_profile_draft"]["generation_source"] == "scene_template_copy" + assert service.scene_templates.get_template(template_id).revision == original_revision + + +@pytest.mark.parametrize( + ("mode_id", "mode_name"), + ( + ("office", "办公模式"), + ("nap", "小憩模式"), + ("meditation_space", "冥想空间模式"), + ("leisure_lounger", "休闲躺椅模式"), + ), +) +@pytest.mark.parametrize(("seat_prefix", "targets"), ( + ("", {"passenger"}), ("主驾", {"driver"}), + ("副驾", {"passenger"}), ("主驾和副驾", {"driver", "passenger"}), +)) +def test_registered_cabin_modes_select_only_requested_seat_on_creation( + service: SeatDemoService, + mode_id: str, + mode_name: str, + seat_prefix: str, + targets: set[str], +) -> None: + result = _preview(service, f"构建一个{seat_prefix}{mode_name}") + actions = _action_map(result) + assert {target for capability, target in actions if capability.startswith("seat.")} == targets + if "passenger" in targets: + assert ("seat.set_backrest_recline", "passenger") in actions + draft = result["mode_profile_draft"] + if seat_prefix: + assert "existing_mode_id" not in draft, "a selected-seat copy must not overwrite the original preset" + else: + assert draft["existing_mode_id"] == mode_id + assert draft["generation_source"] == "registered_mode_profile" + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + assert actions == { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + if operation.domain != "seat" or operation.target_scope in targets + } + + +@pytest.mark.parametrize("template_id", ("fresh-air", "rain-defrost")) +def test_air_quality_and_visibility_templates_do_not_invent_seat_actions( + service: SeatDemoService, + template_id: str, +) -> None: + template = service.scene_templates.get_template(template_id) + result = _preview(service, f"构建一个{template.name}", plan=template.plan()) + assert not any(capability.startswith("seat.") for capability, _ in _action_map(result)) + + +@pytest.mark.parametrize( + "template_id", + ( + "long-trip-comfort", + "summer-cooling", + "winter-warmth", + "reception", + "quiet-night", + "romantic", + ), +) +def test_non_posture_templates_keep_both_seat_positions_unchanged( + service: SeatDemoService, + template_id: str, +) -> None: + template = service.scene_templates.get_template(template_id) + assert all( + "position_pct" not in operation.parameters and "duration_ms" not in operation.parameters + for operation in template.operations + if operation.domain == "seat" + ) + + +def test_native_driver_and_passenger_thermal_levels_are_not_confused( + service: SeatDemoService, +) -> None: + for template_id in ( + "reading", + "camping", + "quiet-night", + "reception", + "romantic", + "long-trip-comfort", + "seat-comfort", + ): + template = service.scene_templates.get_template(template_id) + params = {(op.capability, op.target_scope): op.parameters for op in template.operations} + assert params[("seat.set_driver_ventilation_native", "driver")] == { + "state": "on", + "level": 2, + } + assert params[("seat.set_ventilation", "passenger")] == {"state": "on", "level": 1} + + +def test_upgraded_template_does_not_silently_add_seats_to_a_saved_custom_mode( + service: SeatDemoService, + tmp_path: Path, +) -> None: + # Simulate a previously saved user preference with the same name as a template. + original = _preview(service, "构建一个观影模式,空调温度调整到25度") + draft = original["mode_profile_draft"] + assert len(_action_map(original)) == 1 + saved = service.create_mode_profile_card( + draft["name"], + draft["instruction"], + draft["source_request_id"], + draft["vehicle_id"], + ) + assert saved["status"] == "succeeded" + paths = list((tmp_path / "user-modes").rglob("*.yaml")) + hashes = {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} + + repeated = _preview(service, "构建一个观影模式") + assert repeated["mode_profile_draft"]["generation_source"] == "registered_mode_profile" + assert _action_map(repeated) == _action_map(original) + assert {path: hashlib.sha256(path.read_bytes()).hexdigest() for path in paths} == hashes + assert len(service.scene_templates.get_template("cinema").operations) == 11 + + +def test_explicit_seat_only_requirement_is_not_expanded_to_other_seat_defaults( + service: SeatDemoService, +) -> None: + result = _preview(service, "构建一个阅读模式,副驾座椅通风关闭") + assert _action_map(result) == {("seat.set_ventilation", "passenger"): {"state": "off"}} + assert result["mode_profile_draft"]["generation_source"] == "explicit_scene_requirements" + + +@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger")) +@pytest.mark.parametrize(("seat", "spoken_seat"), (("driver", "主驾"), ("passenger", "副驾驶"))) +def test_explicit_single_seat_invocation_selects_without_mutating_cabin_mode( + service: SeatDemoService, + mode_id: str, + seat: str, + spoken_seat: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + original_revision = service.mode_profiles.profile_revision(profile) + expected = { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + if operation.domain != "seat" or operation.target_scope == seat + } + result = service.preview_native_can( + f"请让{spoken_seat}进入{profile.name}", + provider="local", + conversation_id="conv-explicit-seat-mode-selection", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded", result.get("validation") + assert _action_map(result) == expected + assert ( + service.mode_profiles.profile_revision( + service.mode_profiles.get_profile(mode_id, "denza_n7"), + ) + == original_revision + ) + assert any( + operation.target_scope != seat + for operation in service.mode_profiles.get_profile(mode_id, "denza_n7").operations + if operation.domain == "seat" + ), "single-seat execution must not edit the persisted dual-seat profile" + + +@pytest.mark.parametrize( + ("mode_id", "spoken_seat"), + [ + (mode_id, seat) + for mode_id in ("zero_gravity", "easy_entry_exit", "rocking_horse", "office") + for seat in ("主驾", "后排", "所有座椅") + if (mode_id, seat) != ("office", "主驾") + ], +) +def test_mode_invocation_still_rejects_genuinely_unsupported_seat_scopes( + service: SeatDemoService, + mode_id: str, + spoken_seat: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + result = service.preview_native_can( + f"请让{spoken_seat}进入{profile.name}", + provider="local", + conversation_id="conv-explicit-seat-mode-rejection", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] in {"no_action", "needs_confirmation"} + assert _action_map(result) == {} + assert any( + issue.get("reason") == "mode_target_scope_mismatch" + for issue in result["intent_ir"]["spec"].get("ambiguities", []) + ) + + +@pytest.mark.parametrize( + "message", + ("创建一个主驾办公模式", "修改办公模式,只要主驾", "主驾办公模式"), +) +def test_mode_seat_selection_requires_an_explicit_invocation_not_configuration( + service: SeatDemoService, + message: str, +) -> None: + profile = service.mode_profiles.get_profile("office", "denza_n7") + assert service._explicit_mode_seat_selection(profile, message) is None + + +@pytest.mark.parametrize("mode_id", ("office", "nap", "meditation_space", "leisure_lounger")) +def test_unscoped_mode_invocation_keeps_registered_native_driver_actions( + service: SeatDemoService, + mode_id: str, +) -> None: + profile = service.mode_profiles.get_profile(mode_id, "denza_n7") + result = service.preview_native_can( + f"进入{profile.name}", + provider="local", + conversation_id="conv-mode-native-seat-preserved", + execution_mode="dry_run", + vehicle_id="denza_n7", + ) + assert result["status"] == "succeeded" + assert _action_map(result) == { + (operation.capability, operation.target_scope): operation.parameters + for operation in profile.operations + } diff --git a/tests/integration/test_scene_explicit_seat_scope.py b/tests/integration/test_scene_explicit_seat_scope.py index 2ea74fa..7ab6bab 100644 --- a/tests/integration/test_scene_explicit_seat_scope.py +++ b/tests/integration/test_scene_explicit_seat_scope.py @@ -87,7 +87,10 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv assert result.get("command_plans") == {} intent = result.get("intent_ir") or {} assert (intent.get("spec") or {}).get("actions", []) == [] - assert any("座位范围" in item["message"] for item in result["validation"]) + assert any( + "座位范围" in item["message"] or item.get("code") == "MODE_SEAT_SCOPE_UNAVAILABLE" + for item in result["validation"] + ) @pytest.mark.parametrize(("message", "scopes"), ( @@ -96,9 +99,9 @@ def test_explicit_scene_seat_scope_rejects_cross_seat_plan_atomically(scene_serv ("创建一个仅主驾舒适模式", ("driver",)), ("构建一个主驾和副驾座椅舒适模式", ("driver", "passenger")), ("构建一个主副驾座椅舒适模式", ("driver", "passenger")), - ("构建一个云端座椅舒适模式", ("driver", "passenger")), - ("构建一个副驾空调与座椅舒适模式", ("driver", "passenger")), - ("构建一个座椅舒适与副驾阅读灯模式", ("driver", "passenger")), + ("构建一个云端座椅舒适模式", ("passenger",)), + ("构建一个副驾空调与座椅舒适模式", ("passenger",)), + ("构建一个座椅舒适与副驾阅读灯模式", ("passenger",)), ("构建一个主驾座椅与副驾空调舒适模式", ("driver",)), ("构建一个主驾阅读灯与副驾座椅舒适模式", ("passenger",)), )) @@ -120,7 +123,6 @@ def test_scene_scope_preserves_supported_targets_without_borrowing_other_device_ @pytest.mark.parametrize("message", ( "构建一个主驾舒适模式,副驾不要动", "构建一个主驾舒适模式,不要动副驾", - "构建一个舒展模式,座椅靠背调整到45%", )) def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation( scene_service, message, @@ -131,6 +133,19 @@ def test_explicit_atomic_requirements_do_not_receive_scene_scope_relaxation( assert result["hardware_execution_allowed"] is False +def test_unqualified_explicit_seat_requirement_defaults_to_passenger_without_model_additions(scene_service): + result = _preview(scene_service, "构建一个舒展模式,座椅靠背调整到45%", execution_mode="dry_run") + assert result["status"] == "succeeded", result.get("validation") + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["mode_profile_draft"]["operation_count"] == 1 + actions = result["intent_ir"]["spec"]["actions"] + assert len(actions) == 1 + assert actions[0]["capability"] == "seat.set_backrest_recline" + assert actions[0]["target"]["instance"] == "passenger" + assert actions[0]["parameters"] == {"position_pct": 45} + + def test_explicit_atomic_requirements_override_label_without_adding_model_actions(scene_service): result = _preview(scene_service, "构建一个主驾舒适模式,副驾靠背调整到45%") assert result["status"] == "succeeded" diff --git a/tests/integration/test_scene_template_hybrid.py b/tests/integration/test_scene_template_hybrid.py index 4f09efd..53c3f63 100644 --- a/tests/integration/test_scene_template_hybrid.py +++ b/tests/integration/test_scene_template_hybrid.py @@ -144,7 +144,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( templates = snapshot["scene_templates"] assert isinstance(templates, list) - assert len(templates) == 13 + assert len(templates) == 15 assert all(template["assistant_callable"] is True for template in templates) with patch.object( @@ -169,11 +169,30 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( assert result["orchestration_route"] == "mode_profile_create_preview" draft = result["mode_profile_draft"] assert draft["generation_source"] == "scene_template_copy" - assert draft["operation_count"] == template["operation_count"] assert draft["source_template_id"] == template["id"] assert draft["source_template_revision"] == template["template_revision"] assert "existing_mode_id" not in draft + expected_requests = plan["requests"] + if plan.get("schedule"): + expected_schedule = deepcopy(plan["schedule"]) + for phase in expected_schedule["phases"]: + phase["operations"] = [ + op for op in phase["operations"] + if op["domain"] != "seat" or op["target_scope"] == "passenger" + ] + assert draft["schedule"] == expected_schedule + expected_requests = [ + {**op, "parameters": [{"name": key, "value": value} + for key, value in op["parameters"].items()]} + for phase in expected_schedule["phases"] for op in phase["operations"] + ] + else: + expected_requests = [ + request for request in expected_requests + if request["domain"] != "seat" or request["target_scope"] == "passenger" + ] + assert draft["operation_count"] == len(expected_requests) expected = { ( str(request["capability"]), @@ -187,7 +206,7 @@ def test_all_production_scene_templates_complete_the_full_local_dry_run( sort_keys=True, ), ) - for request in plan["requests"] + for request in expected_requests } actual = { ( @@ -311,7 +330,10 @@ def test_purpose_descriptions_use_a_concise_name_and_zero_model_template_route( assert draft["name"] == expected_name assert draft["generation_source"] == "scene_template_copy" assert draft["source_template_id"] == template_id - assert draft["operation_count"] == len(template.operations) + assert draft["operation_count"] == sum( + operation.domain != "seat" or operation.target_scope == "passenger" + for operation in template.operations + ) assert passed_plans[0]["metadata"]["template_id"] == template_id assert events[0]["engine"] == "n7web-scene-template" assert not list(user_root.rglob("*.yaml")) @@ -353,7 +375,7 @@ def test_explicit_settings_on_a_purpose_scene_bypass_template_defaults( ("构建一个长途舒适模式", "长途舒适模式", "long-trip-comfort"), ), ) -def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_call( +def test_seating_comfort_requests_preview_only_passenger_with_no_model_or_hardware_call( hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], message: str, expected_name: str, @@ -380,16 +402,13 @@ def test_seating_comfort_requests_preview_both_seats_with_no_model_or_hardware_c for (capability, target), parameters in _action_map(result).items() if capability.startswith("seat.") } - assert {target for _capability, target in seat_actions} == {"driver", "passenger"} + assert {target for _capability, target in seat_actions} == {"passenger"} assert all( "driver_axis" not in capability and "driver_position" not in capability for capability, _target in seat_actions ) assert ("seat.set_driver_massage_native", "passenger") not in seat_actions if template_id in {"seat-comfort", "long-trip-comfort"}: - assert seat_actions["seat.set_driver_ventilation_native", "driver"] == { - "state": "on", "level": 2, - } assert seat_actions["seat.set_ventilation", "passenger"] == { "state": "on", "level": 1, } @@ -422,7 +441,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg side_effect=AssertionError("scene composition reached hardware execution"), ): result = service.preview_native_can( - f"构建一个{name}", provider="local", conversation_id="conv-seat-generated", + f"构建一个主驾和副驾的{name}", provider="local", conversation_id="conv-seat-generated", execution_mode="hardware", vehicle_id="denza_n7", assistant_plan=plan, ) assert result["status"] == "succeeded" @@ -437,7 +456,7 @@ def test_unregistered_seat_scene_keeps_both_explicit_targets_through_native_merg assert not list(user_root.rglob("*.yaml")) -def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard( +def test_unscoped_seat_instruction_inside_mode_creation_defaults_to_passenger( hybrid_service: tuple[SeatDemoService, AssistantCapabilityCatalog, Path], ) -> None: service, _catalog, _user_root = hybrid_service @@ -446,8 +465,11 @@ def test_explicit_unscoped_seat_instruction_keeps_target_ambiguity_guard( conversation_id="conv-seat-explicit-target", execution_mode="hardware", vehicle_id="denza_n7", ) - assert result["status"] != "succeeded" - assert result.get("mode_profile_draft") is None + assert result["status"] == "succeeded" + assert _action_map(result) == { + ("seat.set_backrest_recline", "passenger"): {"position_pct": 45}, + } + assert result.get("mode_profile_draft") assert result["hardware_execution_allowed"] is False @@ -552,7 +574,7 @@ def test_template_copy_can_be_revised_twice_without_losing_sibling_actions( assert shade_revised["execution_mode"] == final["execution_mode"] == "dry_run" assert shade_revised["hardware_execution_allowed"] is False assert final["hardware_execution_allowed"] is False - assert len(built_actions) == len(shade_actions) == len(final_actions) == 7 + assert len(built_actions) == len(shade_actions) == len(final_actions) == 10 assert built_actions[shade_key] == {"position_pct": 0} assert shade_actions[shade_key] == final_actions[shade_key] == {"position_pct": 100} assert built_actions[temperature_key] == shade_actions[temperature_key] == {"temperature_c": 24} diff --git a/tests/unit/mode_schedule_frontend.test.cjs b/tests/unit/mode_schedule_frontend.test.cjs new file mode 100644 index 0000000..f0b7c82 --- /dev/null +++ b/tests/unit/mode_schedule_frontend.test.cjs @@ -0,0 +1,435 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const web = path.resolve(__dirname, "../../apps/web-console"); +const assistant = fs.readFileSync(path.join(web, "assistant-chat.js"), "utf8"); +const app = fs.readFileSync(path.join(web, "app.js"), "utf8"); + +function functionSource(source, name) { + const start = source.search(new RegExp(`(?: )?(?:async )?function ${name}\\(`)); + assert.ok(start >= 0, `missing production function ${name}`); + const rest = source.slice(start); + const next = rest.slice(1).search(/\n(?: )?(?:async )?function /); + return next < 0 ? rest : rest.slice(0, next + 1); +} + +class Element { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.dataset = {}; + this.attributes = {}; + this.listeners = {}; + this.className = ""; + this.textContent = ""; + this.classList = { add: (name) => { this.className += ` ${name}`; } }; + } + append(...children) { this.children.push(...children); } + replaceChildren(...children) { this.children = children; } + setAttribute(name, value) { this.attributes[name] = value; } + addEventListener(name, listener) { this.listeners[name] = listener; } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + querySelectorAll(selector) { + return descendants(this).filter((item) => { + if (selector.startsWith(".")) return item.className.split(/\s+/).includes(selector.slice(1)); + const match = selector.match(/^\[data-([\w-]+)(?:="([^"]*)")?\]$/); + if (match) { + const key = match[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + return key in item.dataset && (match[2] === undefined || item.dataset[key] === match[2]); + } + return item.tagName === selector; + }); + } +} + +function descendants(element) { + return element.children.flatMap((item) => typeof item === "object" ? [item, ...descendants(item)] : []); +} + +function textOf(element) { + return [element.textContent, ...descendants(element).map((item) => item.textContent)].filter(Boolean).join("\n"); +} + +function harness() { + const body = new Element("body"); + const calls = []; + const document = { + body, + createElement: (tag) => new Element(tag), + createTextNode: (text) => Object.assign(new Element("#text"), { textContent: text }), + querySelectorAll: (selector) => body.querySelectorAll(selector), + }; + const context = { + document, window: {}, CSS: { escape: String }, + $: (selector, parent = body) => parent.querySelector(selector), + rememberModeDraftContext() {}, supersedeModeDraftCards() {}, followLatest() {}, + normalizeModeName: String, + saveModeProfileDraft: (...args) => calls.push({ kind: "save", args }), + tryModeProfileDraft: (...args) => calls.push({ kind: "trial", args }), + modeSecondsLabel: (value) => String(Number(value) / 1000), + LEGACY_DYNAMIC_MODE_IDS: new Set(["rocking_horse", "dynamic_cradle"]), + }; + vm.createContext(context); + const constants = app.slice(app.indexOf("const MODE_DOMAIN_LABELS"), app.indexOf("function modeOperationParameters")) + + app.slice(app.indexOf("const CONFIG_CAPABILITY_LABELS"), app.indexOf("function makeConfigElement")); + const assistantNames = ["modeDraftRows", "modeDraftScheduleView", "appendModeDraftRows", "appendModeDraftState"]; + const appNames = ["formatConfigValue", "modeOperationParameters", "formatModeOperationValue", "modeOperationView", "modeTimelineTargetSummary", "modeTimelineStepView", "modeScheduleDurationLabel", "modeScheduleView", "modeExecutionActionViews", "modeScheduleTrialMatches", "modeScheduleExecutionView", "isUserModeProfile", "isDynamicModeProfile", "createModeGalleryCard", "renderModeActionPopover"]; + vm.runInContext(constants + assistantNames.map((name) => functionSource(assistant, name)).join("\n") + + appNames.map((name) => functionSource(app, name)).join("\n"), context); + return { context, body, calls }; +} + +function operation(capability, parameters, scope = "vehicle") { + return { domain: capability.split(".")[0], capability, target_scope: scope, operation: "set", parameters }; +} + +function schedule(overrides = {}) { + return { + schema: "cognitive-agent-mode-schedule/v1", + repeat_count: 2, + phases: [ + { id: "warm", name: "快速预热", duration_ms: 60000, operations: [ + operation("climate.set_temperature", { temperature_c: 28 }), + operation("seat.set_driver_massage_native", { action: "mode", mode: "relax" }, "driver"), + operation("seat.set_heating", { state: "on", level: 1 }, "passenger"), + ] }, + { id: "comfortable", name: "舒适保持", duration_ms: 30000, operations: [ + operation("climate.set_temperature", { temperature_c: 24 }), + operation("seat.set_driver_massage_native", { action: "off" }, "driver"), + operation("seat.set_heating", { state: "off" }, "passenger"), + ] }, + ], + ...overrides, + }; +} + +test("assistant keeps phases, relative timing, repeat count, total duration and stop guidance", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule() }); + assert.equal(view.summary, "2 个阶段 · 重复 2 次 · 总时长 3分"); + assert.equal(view.operationCount, 6); + assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1分"); + assert.equal(view.phases[1].timing, "+1分 开始 · 持续 30秒"); + assert.match(view.stopHint, /停止动态模式/); + assert.match(view.stopHint, /每轮/); +}); + +test("driver and passenger scopes and changed device values are not collapsed", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule() }); + assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [ + ["空调温度", "28℃"], ["主驾座椅按摩", "放松模式"], ["副驾座椅加热", "开启 · 1档"], + ]); + assert.deepEqual(Array.from(view.phases[1].rows, (row) => [row.label, row.value]), [ + ["空调温度", "24℃"], ["主驾座椅按摩", "关闭"], ["副驾座椅加热", "关闭"], + ]); +}); + +test("phase previews never apply whole-instruction zero-gravity grouping", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ instruction: "副驾零重力", schedule: schedule({ phases: [{ + duration_ms: 1500, operations: [ + operation("seat.set_longitudinal_position", { position_pct: 80 }, "passenger"), + operation("seat.set_backrest_recline", { position_pct: 60 }, "passenger"), + ], + }] }) }); + assert.equal(view.phases[0].rows.length, 2); + assert.equal(view.phases[0].name, "阶段 1"); + assert.equal(view.phases[0].timing, "+0秒 开始 · 持续 1.5秒"); +}); + +test("per-zone climate targets are explicit even within the same phase", () => { + const { context } = harness(); + const view = context.modeDraftScheduleView({ schedule: schedule({ phases: [{ + duration_ms: 2000, operations: [ + operation("climate.set_temperature", { temperature_c: 26 }, "front_left"), + operation("climate.set_temperature", { temperature_c: 24 }, "front_right"), + ], + }] }) }); + assert.deepEqual(Array.from(view.phases[0].rows, (row) => [row.label, row.value]), [ + ["主驾空调温度", "26℃"], ["副驾空调温度", "24℃"], + ]); +}); + +for (const [name, value] of [ + ["absent", undefined], ["unknown schema", schedule({ schema: "other" })], + ["zero repeat", schedule({ repeat_count: 0 })], ["unbounded repeat", schedule({ repeat_count: 21 })], + ["fractional repeat", schedule({ repeat_count: 1.5 })], ["string repeat", schedule({ repeat_count: "2" })], + ["empty phases", schedule({ phases: [] })], ["non-array phases", schedule({ phases: {} })], + ["zero duration", schedule({ phases: [{ duration_ms: 0, operations: [] }] })], + ["negative duration", schedule({ phases: [{ duration_ms: -1, operations: [] }] })], + ["invalid duration", schedule({ phases: [{ duration_ms: Infinity, operations: [] }] })], + ["missing operations", schedule({ phases: [{ duration_ms: 1000 }] })], +]) { + test(`schedule formatter safely rejects ${name}`, () => { + const { context } = harness(); + assert.equal(context.modeDraftScheduleView({ schedule: value }), null); + assert.equal(context.modeScheduleView({ schedule: value }), null); + }); +} + +test("hover formatter prefers stage operations over an overlapping flat projection", () => { + const { context } = harness(); + const mode = { schedule: schedule(), operations: [operation("climate.set_temperature", { temperature_c: 24 })] }; + const rows = context.modeExecutionActionViews(mode); + assert.equal(rows.length, 6); + assert.equal(rows[0].phase, "阶段 1:快速预热"); + assert.equal(rows[3].phase, "阶段 2:舒适保持"); + assert.equal(rows[0].parameters, "目标温度:28 ℃"); + assert.equal(rows[3].parameters, "目标温度:24 ℃"); + assert.equal(rows[1].target, "主驾"); + assert.equal(rows[2].target, "副驾"); + assert.equal(context.modeScheduleView(mode).summary, context.modeDraftScheduleView(mode).summary); + assert.equal(context.isDynamicModeProfile({ ...mode, behavior: "static" }), true); +}); + +test("legacy static modes and seat ModeTimeline retain their original action semantics", () => { + const { context } = harness(); + const operations = [operation("climate.set_temperature", { temperature_c: 24 })]; + assert.equal(context.modeExecutionActionViews({ operations }).length, 1); + assert.equal(context.isDynamicModeProfile({ operations, behavior: "static" }), false); + const mode = { operations, timeline: { + entry: { targets: { backrest_recline: { position_pct: 40 } }, timeout_ms: 15000 }, + phases: [{ name: "抬起", targets: { backrest_recline: { position_pct: 50 } }, duration_ms: 2000 }], + } }; + const views = context.modeExecutionActionViews(mode); + assert.equal(views.length, 3); + assert.equal(views[1].action, "进入动态模式初始位置"); + assert.match(views[1].parameters, /保护上限 15s/); + assert.equal(views[2].action, "循环阶段 1:抬起"); + assert.match(views[2].parameters, /持续 2s/); + assert.equal(context.isDynamicModeProfile(mode), true); +}); + +test("assistant renders a stage card, preserving opaque save/trial request context", () => { + const { context, body, calls } = harness(); + const message = { body: new Element("div"), article: new Element("article") }; + body.append(message.body); + const draft = { name: "上车预热模式", source_request_id: "opaque-preview-id", schedule: schedule(), operation_count: 6 }; + const flatActions = []; + context.appendModeDraftState(message, draft, flatActions); + const card = message.body.children[0]; + assert.equal(card.querySelectorAll(".assistant-mode-schedule-phase").length, 2); + assert.equal(card.querySelectorAll(".assistant-mode-draft-row").length, 6); + assert.equal(card.querySelector(".assistant-mode-draft-badge").textContent, "6 项动作"); + assert.match(textOf(card), /28℃/); + assert.match(textOf(card), /24℃/); + assert.match(textOf(card), /重复 2 次 · 总时长 3分/); + assert.match(textOf(card), /停止动态模式/); + card.querySelector(".assistant-mode-draft-save").listeners.click(); + card.querySelector(".assistant-mode-draft-trial").listeners.click(); + assert.equal(calls[0].kind, "save"); + assert.equal(calls[0].args[2], draft); + assert.equal(calls[1].kind, "trial"); + assert.equal(calls[1].args[1], draft); + assert.equal(calls[1].args[2], flatActions); +}); + +test("management schedule details are independently expandable without executing the card", () => { + const { context } = harness(); + const card = context.createModeGalleryCard({ id: "custom-warm", name: "上车预热模式", origin: "user", schedule: schedule() }); + const details = card.querySelector(".mode-gallery-schedule-details"); + assert.ok(details); + assert.equal(details.querySelector("summary").textContent, "查看分阶段执行动作"); + assert.equal(details.querySelectorAll("li").length, 2); + assert.equal(details.querySelectorAll("button").length, 0); + assert.match(textOf(details), /快速预热/); + assert.match(textOf(details), /舒适保持/); + assert.match(textOf(details), /主驾 · 设置主驾座椅按摩:动作:切换按摩模式,工作模式:放松/); + assert.match(textOf(card.querySelector(".mode-gallery-card-meta")), /重复 2 次 · 总时长 3分/); +}); + +test("popover presents stage timings and stop guidance without interpreting text as HTML", () => { + const { context } = harness(); + const popover = new Element("aside"); + for (const [tag, key] of [["strong", "modeActionTitle"], ["b", "modeActionCount"], ["ol", "modeActionList"]]) { + const item = new Element(tag); + item.dataset[key] = "true"; + popover.append(item); + } + context.ensureModeActionPopover = () => popover; + const value = schedule(); + value.phases[0].name = ''; + context.renderModeActionPopover({ name: "测试动态模式", schedule: value }); + assert.equal(popover.querySelectorAll(".mode-chip-action-copy").length, 6); + assert.equal(popover.querySelectorAll(".mode-schedule-popover-phase").length, 6); + assert.match(textOf(popover), /阶段 1: { + const { context } = harness(); + const running = context.modeScheduleExecutionView({ status: "running", schedule_execution: { status: "running" } }); + assert.equal(running.state, "processing"); + assert.equal(running.title, "动态时间轴已启动"); + assert.doesNotMatch(running.title, /完成/); + const trial = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 90000 } }); + assert.equal(trial.title, "动态时间轴模拟试运行通过"); + assert.match(trial.detail, /未向车辆下发控制/); + const stop = context.modeScheduleExecutionView({ status: "succeeded", schedule_execution: { status: "cancelled" } }); + assert.equal(stop.title, "动态时间轴已停止"); + assert.match(stop.detail, /不会自动回滚/); + assert.equal(context.modeScheduleExecutionView({ status: "failed", schedule_execution: { status: "failed" } }), null); +}); + +test("virtual trial verifies temporal structure, not just a flattened action set", () => { + const { context } = harness(); + const draft = { schedule: schedule() }; + const response = () => ({ schedule: schedule(), schedule_execution: { status: "completed", completed: true, virtual_elapsed_ms: 180000 } }); + assert.equal(context.modeScheduleTrialMatches(draft, response()), true); + assert.equal(context.modeScheduleTrialMatches({}, {}), true, "legacy static drafts remain unchanged"); + for (const mutate of [ + (value) => { value.schedule.repeat_count = 1; }, + (value) => { value.schedule.phases.reverse(); }, + (value) => { value.schedule.phases[0].duration_ms = 45000; value.schedule.phases[1].duration_ms = 45000; }, + (value) => { value.schedule.phases[0].operations[0].parameters.temperature_c = 26; }, + (value) => { value.schedule.phases[0].operations[1].target_scope = "passenger"; }, + (value) => { value.schedule_execution.virtual_elapsed_ms = 90000; }, + (value) => { value.schedule_execution.completed = false; }, + (value) => { value.schedule_execution.status = "running"; }, + (value) => { delete value.schedule; }, + ]) { + const value = response(); + mutate(value); + assert.equal(context.modeScheduleTrialMatches(draft, value), false); + } +}); + +function pollHarness(statuses = []) { + const { context } = harness(); + const timers = new Map(); + const requests = []; + const updates = []; + const modeUpdates = []; + const vehicle = { value: "denza_n7" }; + let timerId = 0; + context.activeSchedulePoll = null; + context.AbortController = AbortController; + context.conversation = "conversation-1"; + context.conversationId = () => context.conversation; + context.setActionStatus = (_message, copy, state) => updates.push({ copy, state }); + const originalQuery = context.$; + context.$ = (selector, parent) => selector === "#vehicleSelect" ? vehicle : originalQuery(selector, parent); + context.window.setTimeout = (callback, delay) => { + assert.equal(delay, 2000); + timers.set(++timerId, callback); + return timerId; + }; + context.window.clearTimeout = (id) => timers.delete(id); + context.window.CognitiveAgentConsole = { updateScheduleExecutionStatus: (...args) => modeUpdates.push(args) }; + context.window.CognitiveAgentNetwork = { request: async (url, options) => { + requests.push({ url, options }); + const next = statuses.shift(); + if (next instanceof Error) throw next; + if (typeof next === "function") return next(); + return { ok: true, status: 200, json: async () => next }; + } }; + vm.runInContext(["stopScheduleStatusPoll", "startScheduleStatusPoll"].map((name) => functionSource(assistant, name)).join("\n"), context); + const start = (session = "session-1") => context.startScheduleStatusPoll({ + vehicle_id: "denza_n7", mode_profile: { id: "test-mode" }, schedule_execution: { session_id: session }, + }, {}); + const tick = async () => { + const [id, callback] = timers.entries().next().value || []; + assert.ok(callback, "poll timer must be scheduled"); + timers.delete(id); + await callback(); + }; + return { context, timers, requests, updates, modeUpdates, vehicle, start, tick }; +} + +test("polls only the active vehicle using GET and updates the original status until completion", async () => { + const h = pollHarness([ + { status: "running", session_id: "session-1", vehicle_id: "denza_n7", phase_index: 1, repeat_index: 0 }, + { status: "completed", session_id: "session-1", completed: true }, + ]); + h.start(); + assert.equal(h.requests.length, 0); + await h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.requests[0].url, "/api/modes/schedule/status?vehicle_id=denza_n7"); + assert.equal(h.requests[0].options.method, "GET"); + assert.equal(h.requests[0].options.body, undefined); + assert.match(h.updates[0].copy, /第 1 轮 · 阶段 2/); + assert.equal(h.updates[0].state, "working"); + await h.tick(); + assert.equal(h.timers.size, 0); + assert.equal(h.context.activeSchedulePoll, null); + assert.match(h.updates[1].copy, /时间轴已结束/); + assert.deepEqual(h.modeUpdates, [["test-mode", "completed"]]); +}); + +for (const status of ["failed", "cancelled", "stopped", "idle"]) { + test(`terminal ${status} ends polling without a new request or action`, async () => { + const h = pollHarness([{ status, session_id: "session-1", error: "阶段下发失败" }]); + h.start(); + await h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.timers.size, 0); + assert.equal(h.updates[0].state, status === "failed" ? "failed" : "success"); + if (status === "failed") assert.match(h.updates[0].copy, /阶段下发失败/); + if (["cancelled", "stopped"].includes(status)) assert.match(h.updates[0].copy, /已停止/); + }); +} + +test("a transient status failure is visible and retried, not reported as execution failure", async () => { + const h = pollHarness([new Error("offline"), { status: "completed", completed: true }]); + h.start(); + await h.tick(); + assert.match(h.updates[0].copy, /暂时无法获取时间轴状态/); + assert.equal(h.updates[0].state, "working"); + assert.equal(h.timers.size, 1); + await h.tick(); + assert.equal(h.timers.size, 0); +}); + +test("vehicle or conversation changes terminate observation before another request", async () => { + for (const kind of ["vehicle", "conversation"]) { + const h = pollHarness(); + h.start(); + if (kind === "vehicle") h.vehicle.value = "another-vehicle"; + else h.context.conversation = "conversation-2"; + await h.tick(); + assert.equal(h.requests.length, 0); + assert.equal(h.context.activeSchedulePoll, null); + assert.equal(h.timers.size, 0); + } +}); + +test("replacement session does not apply a different schedule outcome to the old message", async () => { + const h = pollHarness([{ status: "failed", session_id: "another-session" }]); + h.start(); + await h.tick(); + assert.match(h.updates[0].copy, /已被新的运行任务替换/); + assert.doesNotMatch(h.updates[0].copy, /执行失败/); + assert.equal(h.timers.size, 0); + assert.equal(h.modeUpdates.length, 0); +}); + +test("stop aborts an in-flight status read and ignores its late completion", async () => { + let resolve; + const response = new Promise((done) => { resolve = done; }); + const h = pollHarness([() => response]); + h.start(); + const pending = h.tick(); + assert.equal(h.requests.length, 1); + assert.equal(h.timers.size, 0, "an in-flight read cannot overlap another timer"); + h.context.stopScheduleStatusPoll(); + assert.equal(h.requests[0].options.signal.aborted, true); + resolve({ ok: true, status: 200, json: async () => ({ status: "completed", completed: true }) }); + await pending; + assert.equal(h.updates.length, 0); + assert.equal(h.timers.size, 0); +}); + +test("cleanup is wired to new conversation, vehicle changes and page exit", () => { + assert.match(assistant, /if \(!target\?\.closest\("#newConversation"\)\) return;\s+stopScheduleStatusPoll\(\);/); + assert.match(assistant, /event\.target\?\.id === "vehicleSelect"\) stopScheduleStatusPoll\(\)/); + assert.match(assistant, /window\.addEventListener\("pagehide", stopScheduleStatusPoll\)/); +}); diff --git a/tests/unit/test_assistant_capability_catalog.py b/tests/unit/test_assistant_capability_catalog.py index 1168912..bf8299f 100644 --- a/tests/unit/test_assistant_capability_catalog.py +++ b/tests/unit/test_assistant_capability_catalog.py @@ -284,7 +284,7 @@ def test_mode_catalog_projects_bounded_action_details_without_free_form_reasons( snapshot = _catalog(vehicle_service).snapshot() office = next(item for item in snapshot["modes"] if item["id"] == "office") - assert len(office["operations"]) == office["operation_count"] == 10 + assert len(office["operations"]) == office["operation_count"] == 12 temperature = next( item for item in office["operations"] @@ -327,7 +327,7 @@ def test_saved_mode_detail_fast_answer_is_exact_read_only_and_natural( "遮阳帘", "氛围灯", "副驾空调温度设为25℃", - "共10项动作", + "共12项动作", "本次仅查询,未下发车辆控制", ) ) @@ -358,7 +358,7 @@ def test_common_scene_templates_are_read_only_callable_catalog_entries( templates = snapshot["scene_templates"] assert snapshot["source_status"]["scene_templates"] == "ready" - assert len(templates) == 13 + assert len(templates) == 15 assert {item["name"] for item in templates} >= { "观影模式", "露营模式", diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index cc938fe..6e5b792 100644 --- a/tests/unit/test_assistant_frontend_contract.py +++ b/tests/unit/test_assistant_frontend_contract.py @@ -13,15 +13,15 @@ def test_console_contains_ai_assistant_surface(): assert 'aria-label="AI助手对话"' in html assert 'placeholder="向 AI助手提问或交代工作"' in html assert 'aria-label="发送消息"' in html - assert 'href="/assistant-chat.css?v=codex-assistant-27"' in html - assert 'src="/assistant-chat.js?v=codex-assistant-27"' in html + assert 'href="/assistant-chat.css?v=codex-assistant-28"' in html + assert 'src="/assistant-chat.js?v=codex-assistant-28"' 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-v278"' in service_worker - assert '"/assistant-chat.css?v=codex-assistant-27"' in service_worker - assert '"/assistant-chat.js?v=codex-assistant-27"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker + assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker + assert '"/assistant-chat.js?v=codex-assistant-28"' in service_worker def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events(): diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 5395ac1..bd909de 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -35,10 +35,10 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None: ): assert asset in html assert f'"{asset}"' in service_worker - assert html.index("/assistant-chat.js?v=codex-assistant-27") < html.index( + assert html.index("/assistant-chat.js?v=codex-assistant-28") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) - assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None: diff --git a/tests/unit/test_frontend_theme_contract.py b/tests/unit/test_frontend_theme_contract.py index 8114dc0..bf48bf2 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -47,7 +47,7 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None: ): assert asset in html assert f'"{asset}"' in service_worker - assert 'const CACHE_VERSION = "cognitive-agent-pad-v278"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v279"' in service_worker def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: diff --git a/tests/unit/test_mode_creation_seat_policy.py b/tests/unit/test_mode_creation_seat_policy.py new file mode 100644 index 0000000..dc48c7d --- /dev/null +++ b/tests/unit/test_mode_creation_seat_policy.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from cognitive_agent.application.mode_creation_seat_policy import ( + ModeCreationSeatPolicyError, + creation_seat_scopes, + project_seed_operations, +) + + +@pytest.mark.parametrize(("text", "expected"), [ + ("构建一个阅读模式", {"passenger"}), + ("构建一个座椅动态模式", {"passenger"}), + ("构建一个主驾阅读模式", {"driver"}), + ("为驾驶员构建一个舒适模式", {"driver"}), + ("构建一个副驾阅读模式", {"passenger"}), + ("构建一个副驾驶座椅舒适模式", {"passenger"}), + ("构建一个副驾驶席阅读模式", {"passenger"}), + ("为副驾驶位构建阅读模式", {"passenger"}), + ("构建一个主副驾阅读模式", {"driver", "passenger"}), + ("构建一个双座舒适模式", {"driver", "passenger"}), + ("构建一个前排两座舒适模式", {"driver", "passenger"}), + ("构建一个两个座椅阅读模式", {"driver", "passenger"}), + ("构建一个两张座椅阅读模式", {"driver", "passenger"}), + ("构建一个两座椅阅读模式", {"driver", "passenger"}), + ("构建一个主驾空调舒适模式", {"passenger"}), + ("构建一个模式,主驾温度26度", {"passenger"}), + ("构建一个副驾座椅舒适模式,主驾空调26度", {"passenger"}), + ("构建一个主驾舒适模式,副驾空调26度", {"driver"}), + ("构建一个阅读模式,不要动主驾", {"passenger"}), + ("构建一个主驾阅读模式,副驾不要动", {"driver"}), + ("构建一个模式,只联动副驾,不要联动主驾", {"passenger"}), + ("构建一个后排座椅舒适模式", {"unsupported"}), + ("构建一个未知座位模式", {"unsupported"}), + ("构建一个任意座位模式", {"unsupported"}), +]) +def test_creation_seat_selection(text, expected): + assert creation_seat_scopes(text) == expected + + +def test_seed_projection_is_non_mutating_and_does_not_borrow_capabilities(): + seed = [ + {"domain": "seat", "capability": "seat.set_driver_ventilation_native", "target_scope": "driver", "parameters": {"level": 2}}, + {"domain": "seat", "capability": "seat.set_backrest_recline", "target_scope": "passenger", "parameters": {"position_pct": 45}}, + {"domain": "climate", "capability": "climate.set_temperature", "target_scope": "passenger", "parameters": {"temperature_c": 24}}, + ] + original = deepcopy(seed) + projected = project_seed_operations(seed, frozenset({"driver"})) + assert projected == [seed[0], seed[2]] + projected[0]["parameters"]["level"] = 1 + assert seed == original + with pytest.raises(ModeCreationSeatPolicyError): + project_seed_operations(seed[1:], frozenset({"driver"})) diff --git a/tests/unit/test_mode_creation_seat_routing.py b/tests/unit/test_mode_creation_seat_routing.py new file mode 100644 index 0000000..446a9e6 --- /dev/null +++ b/tests/unit/test_mode_creation_seat_routing.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from copy import deepcopy +from pathlib import Path + +import pytest + +from cognitive_agent.application.codex_assistant import ( + CodexAssistantService, + _catalog_exact_mode_match_count, + _catalog_exact_scene_template_plan, +) +from cognitive_agent.application.mode_profile_intent import ( + mode_creation_source_name, + parse_mode_profile_create, +) +from cognitive_agent.application.scene_composer import ( + SCENE_COMPOSER_INSTRUCTIONS, + SCHEDULE_COMPOSER_INSTRUCTIONS, + scene_context_for_mode_creation, +) +from cognitive_agent.application.scene_templates import SceneTemplateRegistry + +ROOT = Path(__file__).resolve().parents[2] + + +@pytest.mark.parametrize(("message", "name"), [ + ("构建主驾阅读模式", "主驾阅读模式"), + ("为主驾构建一个阅读模式", "主驾阅读模式"), + ("帮我为副驾驶构建一个阅读模式", "副驾阅读模式"), + ("给驾驶席创建一个阅读模式", "主驾阅读模式"), + ("可以帮我为主驾构建一个阅读模式吗?", "主驾阅读模式"), + ("构建一个阅读模式,针对主驾", "主驾阅读模式"), + ("构建一个阅读模式,仅联动副驾座椅", "副驾阅读模式"), + ("构建一个阅读模式,给主驾使用,先看看", "主驾阅读模式"), + ("构建一个主驾驶座椅专属的阅读模式", "主驾阅读模式"), + ("构建一个主驾适合看书的功能模式", "主驾看书模式"), + ("构建一个适合主驾看书的功能模式", "主驾看书模式"), + ("为主驾和副驾创建一个阅读模式", "主副驾阅读模式"), + ("构建一个双座阅读模式", "主副驾阅读模式"), + ("构建一个阅读模式,针对双座", "主副驾阅读模式"), +]) +def test_scoped_creation_keeps_distinct_name_and_fast_template_source(message, name): + assert parse_mode_profile_create(message) == (name, "") + registry = SceneTemplateRegistry(ROOT / "config" / "scene_templates") + template = registry.resolve_exact_name(name) + assert template is not None and template.id == "reading" + + +@pytest.mark.parametrize(("message", "expected"), [ + ("为主驾构建一个阅读模式,空调调整到26度", ("主驾阅读模式", "空调调整到26度")), + ("构建一个阅读模式,针对主驾,空调调整到26度", ("阅读模式", "针对主驾,空调调整到26度")), + ("构建一个主驾阅读模式,靠背向后移动3s", ("主驾阅读模式", "靠背向后移动3s")), + ("构建一个名为主驾驶阅读模式", ("主驾驶阅读模式", "")), + ("构建一个“阅读模式”,针对主驾", ("阅读模式", "")), +]) +def test_explicit_names_and_actual_device_instructions_are_preserved(message, expected): + assert parse_mode_profile_create(message) == expected + + +@pytest.mark.parametrize("message", [ + "不要为主驾构建一个阅读模式", "如何为主驾构建一个阅读模式?", + "为主驾构建一个阅读模式,但不要执行", "引用为主驾构建一个阅读模式", +]) +def test_scope_grammar_does_not_bypass_authorization(message): + assert parse_mode_profile_create(message) is None + + +@pytest.mark.parametrize("name", ["后排阅读模式", "左后排阅读模式", "第三排阅读模式", "未知座位阅读模式"]) +def test_unsupported_seats_never_alias_to_front_seat_templates(name): + assert mode_creation_source_name(name) == name + assert SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name) is None + + +@pytest.mark.parametrize(("name", "source", "template_id"), [ + ("主驾座椅舒适模式", "座椅舒适模式", "seat-comfort"), + ("主驾和副驾座椅舒适模式", "座椅舒适模式", "seat-comfort"), + ("主副驾座椅动态模式", "座椅动态模式", "seat-dynamic"), + ("副驾座椅的动态模式", "座椅的动态模式", "seat-dynamic"), +]) +def test_seat_word_is_preserved_when_it_is_the_template_subject(name, source, template_id): + assert mode_creation_source_name(name) == source + template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").resolve_exact_name(name) + assert template is not None and template.id == template_id + + +def test_catalog_seed_fallback_is_exact_and_preserves_explicit_match_priority(): + template = SceneTemplateRegistry(ROOT / "config" / "scene_templates").get_template("reading").public_dict(include_plan=True) + template["assistant_callable"] = True + catalog = { + "source_status": {"scene_templates": "ready", "modes": "ready"}, + "scene_templates": [template], + "modes": [{"id": "office", "name": "办公模式"}], + } + assert _catalog_exact_scene_template_plan(catalog, "主驾阅读模式") == template["plan"] + assert _catalog_exact_scene_template_plan(catalog, "后排阅读模式") is None + assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 1 + catalog["modes"].extend([ + {"id": "driver-office-a", "name": "主驾办公模式"}, + {"id": "driver-office-b", "name": "主驾办公模式"}, + ]) + assert _catalog_exact_mode_match_count(catalog, "主驾办公模式") == 2 + + +@pytest.mark.parametrize(("message", "expected"), [ + ("构建一个神游模式", {"passenger"}), + ("构建一个主驾神游模式", {"driver", "front_left"}), + ("构建一个主副驾神游模式", {"driver", "front_left", "passenger"}), + ("构建一个神游模式,主驾空调26度", {"passenger"}), + ("构建一个后排神游模式", set()), +]) +def test_model_context_scopes_only_seat_capabilities_and_does_not_mutate_source(message, expected): + context = { + "composition_revision": "revision", "vehicle": {"id": "denza_n7"}, + "capabilities": [ + {"id": "seat.driver", "domain": "seat", "target_scopes": ["driver", "front_left"]}, + {"id": "seat.passenger", "domain": "seat", "target_scopes": ["passenger"]}, + {"id": "climate.zone", "domain": "climate", "target_scopes": ["driver", "passenger"]}, + {"id": "roof.shade", "domain": "roof", "target_scopes": ["vehicle"]}, + ], + } + original = deepcopy(context) + projected = scene_context_for_mode_creation(context, message) + scopes = {scope for capability in projected["capabilities"] if capability["domain"] == "seat" + for scope in capability["target_scopes"]} + assert scopes == expected + assert [capability for capability in projected["capabilities"] if capability["domain"] != "seat"] == original["capabilities"][2:] + assert context == original + assert projected["seat_scope_policy"]["default_scope"] == "passenger" + + +def test_static_and_dynamic_prompts_use_one_selected_seat(): + assert "defaults to the passenger seat only" in SCENE_COMPOSER_INSTRUCTIONS + assert "retarget a passenger capability to the driver" in SCENE_COMPOSER_INSTRUCTIONS + assert "selected seat scope in every phase" in SCHEDULE_COMPOSER_INSTRUCTIONS + + +def test_identical_explicit_mode_names_do_not_share_cached_plans_across_seats(): + context = {"composition_revision": "revision", "vehicle": {"id": "denza_n7"}, "capabilities": []} + # Explicitly named drafts may intentionally keep the same display name even + # when the outer request selects a different seat. + driver = scene_context_for_mode_creation(context, "为主驾构建一个名为神游模式") + passenger = scene_context_for_mode_creation(context, "为副驾构建一个名为神游模式") + driver_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=driver) + passenger_key = CodexAssistantService._scene_cache_key(None, mode_name="神游模式", scene_context=passenger) + assert driver_key != passenger_key + + +@pytest.mark.parametrize("seat", [ + "两个座椅", "两张座椅", "两座椅", "两座", "两个座位", "前排两座", + "前排两座椅", "前排两个座椅", "前排两张座椅", "双座", "双座椅", +]) +@pytest.mark.parametrize("pattern", ["构建一个{}阅读模式", "为{}构建一个阅读模式", "构建一个阅读模式,针对{}"]) +def test_all_two_seat_wordings_have_one_canonical_name(seat, pattern): + message = pattern.format(seat) + assert parse_mode_profile_create(message) == ("主副驾阅读模式", "") + assert mode_creation_source_name(f"{seat}阅读模式") == "阅读模式" + + +@pytest.mark.parametrize("message", [ + "构建一个主驾阅读模式,针对副驾", "构建一个副驾阅读模式,针对主驾", + "为主驾构建一个副驾阅读模式", "为副驾构建一个主驾阅读模式", + "构建一个主副驾阅读模式,只针对主驾", "构建一个主驾阅读模式,针对双座", + "为主驾构建一个名为副驾阅读模式", "构建一个“副驾阅读模式”,针对主驾", +]) +def test_conflicting_seat_selection_cannot_silently_become_a_two_seat_creation(message): + assert parse_mode_profile_create(message) is None + + +@pytest.mark.parametrize(("name", "source"), [ + ("副驾驶座椅阅读模式", "阅读模式"), ("主驾座椅的阅读模式", "阅读模式"), + ("主驾座椅办公模式", "办公模式"), ("副驾座位的观影模式", "观影模式"), + ("副驾座椅看书模式", "看书模式"), ("主驾座椅露营模式", "露营模式"), + ("主驾座椅舒适模式", "座椅舒适模式"), ("副驾座椅动态模式", "座椅动态模式"), + ("乘客座椅舒适模式", "座椅舒适模式"), ("两个座椅的动态模式", "座椅动态模式"), + ("两张座椅舒适模式", "座椅舒适模式"), ("前排两座动态模式", "座椅动态模式"), +]) +def test_seat_location_wrappers_do_not_hide_generic_scene_sources_or_actual_seat_topics(name, source): + assert mode_creation_source_name(name) == source + + +@pytest.mark.parametrize("message", [ + "构建一个两个座椅的动态模式", "构建一个两张座椅的动态模式", "构建一个前排两座动态模式", +]) +def test_double_seat_dynamic_topic_is_not_lost_with_the_scope_qualifier(message): + assert parse_mode_profile_create(message) == ("主副驾座椅动态模式", "") diff --git a/tests/unit/test_mode_gallery_grouping.py b/tests/unit/test_mode_gallery_grouping.py index bb3b733..6d1ad49 100644 --- a/tests/unit/test_mode_gallery_grouping.py +++ b/tests/unit/test_mode_gallery_grouping.py @@ -46,8 +46,18 @@ def test_runtime_mode_catalog_exposes_safe_executable_action_details() -> None: if mode["id"] == "office" ) - assert len(office["operations"]) == office["operation_count"] == 10 + assert len(office["operations"]) == office["operation_count"] == 12 assert office["operations"][0] == { + "domain": "seat", + "capability": "seat.set_driver_ventilation_native", + "target_scope": "driver", + "operation": "set", + "parameters": {"state": "on", "level": 2}, + } + assert next( + operation for operation in office["operations"] + if operation["capability"] == "seat.set_longitudinal_position" + ) == { "domain": "seat", "capability": "seat.set_longitudinal_position", "target_scope": "passenger", @@ -237,7 +247,7 @@ def test_top_mode_action_formatter_uses_device_target_action_and_parameter_value actions = json.loads(completed.stdout) assert len(actions) == office["operation_count"] - assert actions[0] == { + assert next(action for action in actions if action["action"] == "调节座椅前后位置") == { "device": "座椅", "target": "副驾", "action": "调节座椅前后位置", diff --git a/tests/unit/test_mode_schedule.py b/tests/unit/test_mode_schedule.py new file mode 100644 index 0000000..32a90ad --- /dev/null +++ b/tests/unit/test_mode_schedule.py @@ -0,0 +1,513 @@ +from __future__ import annotations + +from queue import Queue +from threading import Event, Lock, current_thread +from unittest.mock import patch + +import pytest + +from cognitive_agent.application.mode_schedule import ( + MODE_SCHEDULE_SCHEMA, + ModeSchedule, + ModeSchedulePhase, + ModeScheduleRunner, +) + + +def operation(capability="climate.set_temperature", target="whole_vehicle", **parameters): + return { + "domain": capability.split(".")[0], "capability": capability, + "target_scope": target, "operation": "set", + "parameters": parameters or {"temperature_c": 24}, "reason": "舒适阶段", + } + + +def document(*, repeat=1, duration=1000): + return { + "schema": MODE_SCHEDULE_SCHEMA, "repeat_count": repeat, + "phases": [ + {"id": "warmup", "name": "预热", "duration_ms": duration, + "operations": [operation(temperature_c=28)]}, + {"id": "comfort", "name": "舒适", "duration_ms": duration, + "operations": [operation(temperature_c=24)]}, + ], + } + + +def test_round_trip_has_independent_parameters_and_counts(): + source = document(repeat=3, duration=1500) + schedule = ModeSchedule.from_dict(source) + assert schedule.to_dict() == source + assert schedule.total_duration_ms == 9000 + assert schedule.operation_count == 2 + source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 10 + returned = schedule.to_dict() + returned["phases"][0]["operations"][0]["parameters"]["temperature_c"] = 11 + assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28 + + +@pytest.mark.parametrize("value", [None, [], "schedule", 1, True]) +def test_schedule_root_must_be_object(value): + with pytest.raises(ValueError): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("field", ["schema", "repeat_count", "phases"]) +def test_schedule_requires_all_contract_fields(field): + value = document() + del value[field] + with pytest.raises(ValueError, match="missing"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("value", [0, 21, -1, 1.0, "2", True, False, None, float("nan")]) +def test_repeat_count_is_a_bounded_non_boolean_integer(value): + with pytest.raises(ValueError, match="repeat_count"): + ModeSchedule.from_dict(document(repeat=value)) + + +@pytest.mark.parametrize("value", [0, 999, 900001, 1000.0, "1000", True, None, float("inf")]) +def test_phase_duration_is_a_bounded_non_boolean_integer(value): + with pytest.raises(ValueError, match="duration_ms"): + ModeSchedule.from_dict(document(duration=value)) + + +@pytest.mark.parametrize("count", [0, 1, 9]) +def test_phase_count_bounds(count): + value = document() + value["phases"] = [dict(value["phases"][0], id=f"phase-{index}") for index in range(count)] + with pytest.raises(ValueError, match="2 to 8"): + ModeSchedule.from_dict(value) + + +def test_total_duration_includes_repeats_and_enforces_one_hour(): + assert ModeSchedule.from_dict(document(repeat=2, duration=900000)).total_duration_ms == 3600000 + with pytest.raises(ValueError, match="60 minutes"): + ModeSchedule.from_dict(document(repeat=3, duration=900000)) + + +@pytest.mark.parametrize("path", [(), ("phases", 0), ("phases", 0, "operations", 0)]) +def test_unknown_fields_rejected_at_every_contract_level(path): + value = document() + target = value + for key in path: + target = target[key] + target["untrusted_instruction"] = "bypass validation" + with pytest.raises(ValueError, match="unknown"): + ModeSchedule.from_dict(value) + + +def test_non_string_object_keys_are_rejected(): + value = document() + value[1] = "unknown" + with pytest.raises(ValueError, match="string keys"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("schema", ["v2", "", None, 1, True]) +def test_schema_is_exact(schema): + value = document() + value["schema"] = schema + with pytest.raises(ValueError, match="schema"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("field", ["id", "name", "duration_ms", "operations"]) +def test_phase_requires_all_contract_fields(field): + value = document() + del value["phases"][0][field] + with pytest.raises(ValueError, match="missing"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("phase_id", ["", "Bad ID", "hello.world", "座椅", "x" * 65, 5]) +def test_phase_ids_are_bounded_identifiers(phase_id): + value = document() + value["phases"][0]["id"] = phase_id + with pytest.raises(ValueError): + ModeSchedule.from_dict(value) + + +def test_duplicate_phase_ids_rejected(): + value = document() + value["phases"][1]["id"] = value["phases"][0]["id"] + with pytest.raises(ValueError, match="ids must be unique"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("value", [[], [operation()] * 13, {}, "operations"]) +def test_operation_count_and_array_contract(value): + source = document() + source["phases"][0]["operations"] = value + with pytest.raises(ValueError): + ModeSchedule.from_dict(source) + + +@pytest.mark.parametrize("capability", [ + "seat.set_driver_axis_native", "seat.set_driver_position_native", "seat.move", + "seat.apply_dynamic_mode", "door.set_vehicle_lock", "window.set_motion", "custom.anything", +]) +def test_only_stable_scene_composable_capabilities_allowed(capability): + value = document() + value["phases"][0]["operations"] = [operation(capability)] + with pytest.raises(ValueError, match="stable"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("kind", ["toggle", "increase", "decrease", "activate", "apply", None, True]) +def test_relative_or_non_set_operation_types_rejected(kind): + value = document() + value["phases"][0]["operations"][0]["operation"] = kind + with pytest.raises(ValueError, match="absolute set"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("name", ["duration_ms", "duration", "seconds", "direction", "delta", "relative", "axis", "toggle"]) +def test_phase_holds_cannot_be_smuggled_as_axis_motion(name): + value = document() + value["phases"][0]["operations"][0]["parameters"][name] = 1000 + with pytest.raises(ValueError, match="relative, motion or timing"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), {}, [], None]) +def test_non_json_or_non_scalar_parameters_rejected(value): + source = document() + source["phases"][0]["operations"][0]["parameters"]["temperature_c"] = value + with pytest.raises(ValueError): + ModeSchedule.from_dict(source) + + +@pytest.mark.parametrize("value", ["toggle", "relative", "increase", "decrease"]) +def test_relative_parameter_values_rejected(value): + source = document() + source["phases"][0]["operations"][0]["parameters"]["state"] = value + with pytest.raises(ValueError, match="relative state"): + ModeSchedule.from_dict(source) + + +def test_domain_must_match_capability(): + value = document() + value["phases"][0]["operations"][0]["domain"] = "seat" + with pytest.raises(ValueError, match="stable"): + ModeSchedule.from_dict(value) + + +@pytest.mark.parametrize("target", ["passenger", "front_right"]) +def test_duplicate_physical_seat_target_aliases_rejected(target): + value = document() + value["phases"][0]["operations"] = [ + operation("seat.set_ventilation", "passenger", state="on", level=1), + operation("seat.set_ventilation", target, state="off"), + ] + with pytest.raises(ValueError, match="duplicate"): + ModeSchedule.from_dict(value) + + +def test_driver_and_passenger_comfort_are_valid_independent_operations(): + value = document() + value["phases"][0]["operations"] = [ + operation("seat.set_driver_ventilation_native", "driver", state="on", level=2), + operation("seat.set_driver_massage_native", "driver", action="mode", mode="relax"), + operation("seat.set_ventilation", "passenger", state="on", level=1), + operation("seat.set_backrest_recline", "passenger", position_pct=40), + ] + assert ModeSchedule.from_dict(value).operation_count == 5 + + +def test_direct_dataclass_construction_cannot_bypass_duration_or_repeat_limits(): + with pytest.raises(ValueError, match="duration_ms"): + ModeSchedulePhase("a", "a", True, (operation(),)) + phase = ModeSchedulePhase.from_dict(document()["phases"][0]) + second = ModeSchedulePhase.from_dict(document()["phases"][1]) + with pytest.raises(ValueError, match="repeat_count"): + ModeSchedule((phase, second), True) + + +def test_simulation_has_no_thread_or_real_wait_and_preserves_repetition_order(): + schedule = ModeSchedule.from_dict(document(repeat=3, duration=2000)) + calls = [] + caller = current_thread() + + def execute(phase): + assert current_thread() is caller + calls.append(phase.id) + return {"success": True} + + with ( + patch("cognitive_agent.application.mode_schedule.Thread", side_effect=AssertionError("thread")), + patch("cognitive_agent.application.mode_schedule.Event.wait", side_effect=AssertionError("wait")), + ): + result = ModeScheduleRunner.simulate(schedule, execute) + assert result["status"] == "completed" + assert result["completed"] is True + assert result["execution_mode"] == "dry_run" + assert result["virtual_elapsed_ms"] == 12000 + assert result["executed_phases"] == 6 + assert result["phase_index"] == 1 + assert result["repeat_index"] == 2 + assert calls == ["warmup", "comfort"] * 3 + assert [item["virtual_start_ms"] for item in result["phase_results"]] == list(range(0, 12000, 2000)) + + +@pytest.mark.parametrize("failed_result", [ + None, [], {}, {"success": False}, {"status": "failed"}, {"status": "submitted_unverified"}, + {"status": "error", "success": True}, {"status": "succeeded", "success": False}, + {"success": True, "error": "rejected by safety"}, + {"status": "pending"}, {"success": 1}, +]) +def test_simulation_fails_closed_on_unconfirmed_or_failed_execution(failed_result): + calls = [] + + def execute(phase): + calls.append(phase.id) + return failed_result + + result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute) + assert result["status"] == "failed" + assert result["completed"] is False + assert result["error"] + assert result["virtual_elapsed_ms"] == 0 + assert calls == ["warmup"] + + +def test_simulation_stops_on_second_phase_exception_and_reports_prior_hold(): + def execute(phase): + if phase.id == "comfort": + raise RuntimeError("runtime safety rejection") + return {"status": "succeeded"} + + result = ModeScheduleRunner.simulate(ModeSchedule.from_dict(document(repeat=3)), execute) + assert result["completed"] is False + assert result["error"] == "RuntimeError: runtime safety rejection" + assert result["virtual_elapsed_ms"] == 1000 + assert result["executed_phases"] == 1 + + +def test_executor_mutations_do_not_change_next_repetition_or_source(): + values = [] + schedule = ModeSchedule.from_dict(document(repeat=2)) + + def execute(phase): + values.append(phase.operations[0]["parameters"]["temperature_c"]) + phase.operations[0]["parameters"]["temperature_c"] = 999 + return {"status": "ok"} + + assert ModeScheduleRunner.simulate(schedule, execute)["completed"] + assert values == [28, 24, 28, 24] + assert schedule.phases[0].operations[0]["parameters"]["temperature_c"] == 28 + + +class ManualClock: + def __init__(self): + self.value = 0.0 + self.holds = Queue() + + def __call__(self): + return self.value + + def wait(self, cancelled, seconds): + release = Event() + self.holds.put((seconds, release)) + while not cancelled.is_set(): + if release.wait(0.01): + self.value += seconds + return False + return True + + def release(self, seconds=1.0): + expected, release = self.holds.get(timeout=2) + assert expected == seconds + release.set() + + +def test_runner_holds_without_repeated_dispatch_and_finishes_finite_repeats(): + clock = ManualClock() + calls = Queue() + runner = ModeScheduleRunner(clock=clock, wait=clock.wait) + schedule = ModeSchedule.from_dict(document(repeat=2)) + try: + runner.start("car", schedule, lambda phase: calls.put(phase.id) or {"success": True}, session_id="a") + for expected in ["warmup", "comfort", "warmup", "comfort"]: + assert calls.get(timeout=2) == expected + assert calls.empty() + state = runner.status("car") + assert state["status"] == "running" + assert state["completed"] is False + assert state["session_id"] == "a" + clock.release() + # Joining a bounded, virtual-time worker is deterministic and does not + # cancel it as close() would do while it leaves its final hold. + runner._runs["car"].thread.join(2) + assert runner.status("car")["status"] == "completed" + assert runner.status("car")["elapsed_ms"] == 4000 + assert runner.status("car")["executed_phases"] == 4 + finally: + assert runner.close() + + +def test_cancel_interrupts_real_hold_before_next_phase(): + started = Event() + calls = [] + runner = ModeScheduleRunner() + + def execute(phase): + calls.append(phase.id) + started.set() + return {"status": "success"} + + runner.start("car", ModeSchedule.from_dict(document(duration=900000)), execute) + assert started.wait(2) + assert runner.cancel("car")["status"] == "cancelled" + assert runner.close(timeout=1) + assert calls == ["warmup"] + assert runner.status("car")["completed"] is False + + +def test_wrong_session_cannot_cancel_active_timeline(): + started = Event() + runner = ModeScheduleRunner() + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: started.set() or {"success": True}, session_id="a") + assert started.wait(2) + assert runner.cancel("car", session_id="b")["status"] == "running" + assert runner.cancel("car", session_id="a")["status"] == "cancelled" + assert runner.close() + + +def test_replace_waits_for_inflight_callback_and_never_overlaps_same_car(): + entered = Event() + release = Event() + second_entered = Event() + calls = [] + inside = 0 + guard = Lock() + runner = ModeScheduleRunner() + + def first(phase): + nonlocal inside + with guard: + inside += 1 + entered.set() + assert release.wait(2) + calls.append("old") + with guard: + inside -= 1 + return {"success": True} + + def second(phase): + with guard: + assert inside == 0 + calls.append("new") + second_entered.set() + return {"success": True} + + try: + old = runner.start("car", ModeSchedule.from_dict(document()), first, session_id="old") + old_run = runner._runs["car"] + assert entered.wait(2) + new = runner.start("car", ModeSchedule.from_dict(document()), second, session_id="new") + assert old["run_id"] != new["run_id"] + assert old_run.status == "cancelled" + assert old_run.cancel_reason == "replaced" + assert not second_entered.is_set() + release.set() + assert second_entered.wait(2) + assert calls == ["old", "new"] + finally: + release.set() + assert runner.close() + + +def test_different_vehicles_do_not_share_a_global_execution_lock(): + first_entered, second_entered, release = Event(), Event(), Event() + runner = ModeScheduleRunner() + + def first(phase): + first_entered.set() + assert release.wait(2) + return {"success": True} + + try: + runner.start("car-a", ModeSchedule.from_dict(document()), first) + assert first_entered.wait(2) + runner.start("car-b", ModeSchedule.from_dict(document()), lambda phase: second_entered.set() or {"success": True}) + assert second_entered.wait(2) + finally: + release.set() + assert runner.close() + + +@pytest.mark.parametrize("failure", ["return", "exception"]) +def test_runner_stops_later_phases_and_repeats_after_failure(failure): + calls = [] + runner = ModeScheduleRunner(wait=lambda event, duration: False) + + def execute(phase): + calls.append(phase.id) + if phase.id == "comfort": + if failure == "exception": + raise ValueError("bad runtime capability") + return {"status": "rejected", "error": "bad runtime capability"} + return {"status": "ok"} + + runner.start("car", ModeSchedule.from_dict(document(repeat=3)), execute) + runner._runs["car"].thread.join(2) + assert runner.status("car")["status"] == "failed" + assert "bad runtime capability" in runner.status("car")["error"] + assert runner.status("car")["executed_phases"] == 1 + assert calls == ["warmup", "comfort"] + assert runner.close() + + +def test_close_prevents_start_and_reports_uninterruptible_callback_honestly(): + entered, release = Event(), Event() + runner = ModeScheduleRunner() + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: entered.set() or release.wait(2) or {"success": True}) + assert entered.wait(2) + assert runner.close(timeout=0) is False + assert runner.status("car")["status"] == "cancelled" + assert runner.status("car")["cancel_reason"] == "closed" + with pytest.raises(RuntimeError, match="closed"): + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) + release.set() + assert runner.close() + + +def test_idle_and_terminal_cancellation_are_idempotent(): + runner = ModeScheduleRunner(wait=lambda event, duration: False) + assert runner.status("unknown")["status"] == "idle" + assert runner.cancel("unknown")["status"] == "idle" + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) + runner._runs["car"].thread.join(2) + assert runner.cancel("car")["status"] == "completed" + assert runner.close() + assert runner.close() + + +def test_start_revalidates_caller_mutation_before_accepting_schedule(): + runner = ModeScheduleRunner() + schedule = ModeSchedule.from_dict(document()) + schedule.phases[0].operations[0]["operation"] = "toggle" + with pytest.raises(ValueError, match="absolute set"): + runner.start("car", schedule, lambda phase: {"success": True}) + assert runner.status("car")["status"] == "idle" + assert runner.close() + + +def test_failed_thread_start_is_not_left_running_and_close_remains_safe(): + runner = ModeScheduleRunner() + with ( + patch("cognitive_agent.application.mode_schedule.Thread.start", side_effect=RuntimeError("thread unavailable")), + pytest.raises(RuntimeError, match="thread unavailable"), + ): + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) + assert runner.status("car")["status"] == "failed" + assert runner.close() + + +def test_injected_cancelled_wait_cannot_leave_stale_running_status(): + runner = ModeScheduleRunner(wait=lambda event, duration: True) + runner.start("car", ModeSchedule.from_dict(document()), lambda phase: {"success": True}) + runner._runs["car"].thread.join(2) + assert runner.status("car")["status"] == "cancelled" + assert runner.close() diff --git a/tests/unit/test_saved_mode_management_intent.py b/tests/unit/test_saved_mode_management_intent.py index 5810e27..e33268e 100644 --- a/tests/unit/test_saved_mode_management_intent.py +++ b/tests/unit/test_saved_mode_management_intent.py @@ -46,6 +46,26 @@ def test_saved_mode_update_parser_accepts_natural_variants( assert parse_mode_profile_update(message) == expected +@pytest.mark.parametrize("change", ( + "第二阶段保持30秒", "第二阶段持续16分钟", "循环21次", "重复两次", + "重复1.5次", "持续30秒", "全部阶段持续2分钟", +)) +def test_schedule_update_predicates_stay_on_control_plane_even_when_bounds_are_invalid(change): + assert parse_mode_profile_update(f"把上车预热模式的{change}") == ("上车预热模式", change) + + +@pytest.mark.parametrize("message", ( + "把上车预热模式的第二阶段保持30秒合适吗?", + "不要把上车预热模式的第二阶段保持30秒", + "上车预热模式的第二阶段不要保持30秒", + "把上车预热模式的重复两次,但是不要保存", + "解释把上车预热模式的重复两次", + "创建一个上车预热模式,第二阶段保持30秒", +)) +def test_schedule_update_predicates_do_not_authorize_queries_negation_or_creation(message): + assert parse_mode_profile_update(message) is None + + @pytest.mark.parametrize( "message", ( diff --git a/tests/unit/test_scene_composer_fast_path.py b/tests/unit/test_scene_composer_fast_path.py index 772c5a0..30b8e46 100644 --- a/tests/unit/test_scene_composer_fast_path.py +++ b/tests/unit/test_scene_composer_fast_path.py @@ -792,7 +792,13 @@ 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["seat_scope_policy"] = { + "selected_scopes": ["passenger"], + "default_scope": "passenger", + "applies_to": "seat capabilities in every phase", + } + 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"]