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