diff --git a/apps/web-console/assistant-chat.css b/apps/web-console/assistant-chat.css index 6c26b57..511fd90 100644 --- a/apps/web-console/assistant-chat.css +++ b/apps/web-console/assistant-chat.css @@ -634,6 +634,15 @@ background: #fff0f2; } +.assistant-mode-draft-status.superseded { + color: #687386; + background: #f1f3f6; +} + +.assistant-mode-draft.is-superseded { + opacity: 0.72; +} + .assistant-mode-draft.is-saved { border-color: #bfe3d4; } @@ -1191,6 +1200,11 @@ background: var(--status-error-soft); } +.assistant-mode-draft-status.superseded { + color: var(--assistant-muted); + background: var(--muted); +} + .assistant-mode-draft.is-saved { border-color: color-mix(in oklch, var(--status-success) 36%, var(--border)); } diff --git a/apps/web-console/assistant-chat.js b/apps/web-console/assistant-chat.js index 484bf66..d2dc86a 100644 --- a/apps/web-console/assistant-chat.js +++ b/apps/web-console/assistant-chat.js @@ -1,6 +1,7 @@ (function initializeUnifiedAssistant() { const STORAGE_KEY = "cognitive-agent.assistant-messages"; const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id"; + const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft"; const MAX_STORED_MESSAGES = 24; const COMPOSER_MIN_HEIGHT = 64; const COMPOSER_MAX_HEIGHT = 116; @@ -8,6 +9,7 @@ let pendingController = null; let pendingMessage = null; let latestControlResult = null; + let activeModeDraftContext = null; let initialized = false; const $ = (selector, root = document) => root.querySelector(selector); @@ -106,6 +108,108 @@ return created; } + function normalizeModeName(value) { + return String(value || "").trim().replace(/\s+/g, "").toLowerCase(); + } + + function sanitizeModeDraftContext(value) { + if (!value || typeof value !== "object") return null; + const name = String(value.name || "").trim(); + const sourceRequestId = String(value.source_request_id || "").trim(); + const vehicleId = String(value.vehicle_id || "").trim(); + const draftConversationId = String(value.conversation_id || "").trim(); + if ( + !name + || name.length > 64 + || !/^req-[a-z0-9-]{6,100}$/.test(sourceRequestId) + ) return null; + if (!vehicleId || vehicleId.length > 80 || draftConversationId !== conversationId()) return null; + return { + name, + source_request_id: sourceRequestId, + vehicle_id: vehicleId, + conversation_id: draftConversationId, + }; + } + + function loadActiveModeDraftContext() { + try { + return sanitizeModeDraftContext( + JSON.parse(sessionStorage.getItem(ACTIVE_MODE_DRAFT_STORAGE_KEY) || "null"), + ); + } catch (_error) { + return null; + } + } + + function rememberModeDraftContext(draft) { + const candidate = sanitizeModeDraftContext({ + name: draft?.name, + source_request_id: draft?.source_request_id, + vehicle_id: draft?.vehicle_id || $("#vehicleSelect")?.value || null, + conversation_id: conversationId(), + }); + if (!candidate) return; + activeModeDraftContext = candidate; + sessionStorage.setItem(ACTIVE_MODE_DRAFT_STORAGE_KEY, JSON.stringify(candidate)); + } + + function clearActiveModeDraftContext(sourceRequestId = null) { + if ( + sourceRequestId + && activeModeDraftContext?.source_request_id !== String(sourceRequestId) + ) return; + activeModeDraftContext = null; + sessionStorage.removeItem(ACTIVE_MODE_DRAFT_STORAGE_KEY); + } + + function isModeDraftUpdateRequest(message, context) { + const compact = String(message || "").trim().replace(/\s+/g, ""); + if (!compact || !context?.name) return false; + const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?"; + const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)"; + const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)"; + const modeName = "([^,。!?!?、;;::]{1,64}?模式)"; + const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)"; + const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`; + const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|吗[。.!!]?$/; + const candidates = [ + { + pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`), + predicateRequired: false, + }, + { + pattern: new RegExp(`^${politePrefix}(?:把|将)${modeName}${modeMemberBoundary}[,,::]?(.+)$`), + predicateRequired: true, + }, + { + pattern: new RegExp(`^${politePrefix}${modeName}${modeMemberBoundary}[,,::]?(.+)$`), + predicateRequired: true, + }, + ]; + return candidates.some(({ pattern, predicateRequired }) => { + const matched = compact.match(pattern); + const change = String(matched?.[2] || ""); + return Boolean( + matched + && normalizeModeName(matched[1]) === normalizeModeName(context.name) + && !question.test(change) + && (!predicateRequired || new RegExp(changePredicate).test(change)) + ); + }); + } + + function requestModeDraftContext(message) { + const context = activeModeDraftContext || loadActiveModeDraftContext(); + if (!context || !isModeDraftUpdateRequest(message, context)) return null; + activeModeDraftContext = context; + return { + name: context.name, + source_request_id: context.source_request_id, + vehicle_id: context.vehicle_id, + }; + } + function loadStoredMessages() { try { const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]"); @@ -475,6 +579,7 @@ status.className = "assistant-mode-draft-status success"; status.textContent = existingModeId ? "模式已更新" : "模式已保存"; card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", ""); + clearActiveModeDraftContext(draft.source_request_id); await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id); appendModeCreatedState(message, payload.mode_profile, { updated: Boolean(existingModeId) }); persistMessages(); @@ -506,13 +611,40 @@ } } + function supersedeModeDraftCards(draft) { + const nextName = normalizeModeName(draft?.name); + const nextRequestId = String(draft?.source_request_id || ""); + if (!nextName || !nextRequestId) return; + document.querySelectorAll("[data-assistant-mode-draft]").forEach((card) => { + if ( + card.dataset.assistantModeName !== nextName + || card.dataset.assistantModeDraft === nextRequestId + ) return; + card.classList.remove("is-busy"); + card.classList.add("is-superseded"); + card.querySelectorAll("button").forEach((button) => { + button.disabled = true; + }); + card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", ""); + const priorStatus = card.querySelector(".assistant-mode-draft-status"); + if (priorStatus) { + priorStatus.hidden = false; + priorStatus.className = "assistant-mode-draft-status superseded"; + priorStatus.textContent = "已由下方最新草稿替换"; + } + }); + } + function appendModeDraftState(message, draft, actions) { if (!message?.body || !draft?.name || !draft?.source_request_id) return; + rememberModeDraftContext(draft); + supersedeModeDraftCards(draft); const selector = `[data-assistant-mode-draft="${CSS.escape(String(draft.source_request_id))}"]`; if ($(selector, message.body)) return; const card = document.createElement("section"); card.className = "assistant-mode-draft"; card.dataset.assistantModeDraft = String(draft.source_request_id); + card.dataset.assistantModeName = normalizeModeName(draft.name); message.article?.classList.add("assistant-message-mode-draft"); const heading = document.createElement("header"); @@ -613,6 +745,7 @@ const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can"; const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id"); const vehicleId = $("#vehicleSelect")?.value || null; + const modeProfileDraftContext = requestModeDraftContext(message); return { message, conversation_id: conversationId(), @@ -620,6 +753,9 @@ execution_mode: executionMode, control_transport: controlTransport, ...(vehicleId ? { vehicle_id: vehicleId } : {}), + ...(modeProfileDraftContext + ? { mode_profile_draft_context: modeProfileDraftContext } + : {}), ...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId ? { hardware_client_id: hardwareClientId } : {}), @@ -681,6 +817,7 @@ event.payload.mode_profile && event.payload.orchestration_route === "mode_profile_create" ) { + clearActiveModeDraftContext(); appendModeCreatedState(message, event.payload.mode_profile); } } @@ -859,6 +996,7 @@ resizeComposerInput(input); }, 300); renderInitialMessages(); + activeModeDraftContext = loadActiveModeDraftContext(); setComposerPending(false); } @@ -873,6 +1011,7 @@ const target = event.target instanceof Element ? event.target : null; if (!target?.closest("#newConversation")) return; sessionStorage.removeItem(STORAGE_KEY); + clearActiveModeDraftContext(); sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId()); }, true); diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 28895d7..13c53ca 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -13,7 +13,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/apps/web-console/service-worker.js b/apps/web-console/service-worker.js index fde7aaf..4e0d693 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-v271"; +const CACHE_VERSION = "cognitive-agent-pad-v272"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`; const APP_SHELL = [ @@ -16,8 +16,8 @@ const APP_SHELL = [ "/speech-input.css?v=sensevoice-7", "/speech-input.js?v=sensevoice-12", "/speech-audio-worklet.js?v=sensevoice-5", - "/assistant-chat.css?v=codex-assistant-22", - "/assistant-chat.js?v=codex-assistant-22", + "/assistant-chat.css?v=codex-assistant-23", + "/assistant-chat.js?v=codex-assistant-23", "/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/office.yaml b/config/modes/vehicles/denza_n7/office.yaml index 111de8c..b492850 100644 --- a/config/modes/vehicles/denza_n7/office.yaml +++ b/config/modes/vehicles/denza_n7/office.yaml @@ -43,8 +43,8 @@ operations: target_scope: passenger operation: set parameters: - temperature_c: 24.5 - reason: 办公座位专属恒温 24.5 度。 + temperature_c: 25 + reason: 办公座位专属恒温 25 度,使用当前 CAN 协议支持的整数温度。 - domain: climate capability: climate.set_fan_level target_scope: vehicle diff --git a/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml b/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml index fcff010..0c935a1 100644 --- a/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml +++ b/config/modes/vehicles/denza_n7/queen_passenger_driving.yaml @@ -44,8 +44,8 @@ operations: target_scope: passenger operation: set parameters: - temperature_c: 23.5 - reason: 副驾恒温锁定 23.5 度。 + temperature_c: 24 + reason: 副驾恒温锁定 24 度,使用当前 CAN 协议支持的整数温度。 - domain: climate capability: climate.set_fan_level target_scope: vehicle diff --git a/src/cognitive_agent/application/codex_assistant.py b/src/cognitive_agent/application/codex_assistant.py index 72d1b19..71dfa3c 100644 --- a/src/cognitive_agent/application/codex_assistant.py +++ b/src/cognitive_agent/application/codex_assistant.py @@ -18,6 +18,12 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Protocol +from cognitive_agent.application.mode_profile_intent import ( + is_mode_profile_query, + normalize_mode_profile_name, + parse_mode_profile_update, +) + LOGGER = logging.getLogger(__name__) INTERNAL_SCENE_BUILDER_MARKER = "根据该场景名称使用当前可控原子能力" @@ -145,6 +151,7 @@ _MODE_PROFILE_CREATE_PATTERN = re.compile( r"(?:配置)?(?:并(?:添加|加入)(?:到)?功能模式(?:栏)?)?" r"(?:[,,::](?P.{1,800}))?[。.!!]?$" ) +_MODE_EDITOR_REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]{6,100}$") _HOT_DISCOMFORT_PATTERN = re.compile(r"热|闷热|好热|太晒") _COLD_DISCOMFORT_PATTERN = re.compile(r"冷|好冷") _IMPLICIT_HOT_REQUEST_PATTERN = re.compile( @@ -185,6 +192,59 @@ def is_mode_profile_create_request(message: str) -> bool: return _mode_profile_create_parts(message) is not None +def _mode_profile_update_parts( + message: str, + *, + expected_mode_name: object | None = None, +) -> tuple[str, str] | None: + """Return a bounded named-mode update without interpreting its action.""" + + return parse_mode_profile_update( + message, + expected_mode_name=expected_mode_name, + ) + + +def _validated_mode_profile_draft_context( + value: Mapping[str, object] | None, + *, + message: str, +) -> dict[str, str] | None: + """Validate the browser's opaque draft reference, never client actions.""" + + if value is None: + return None + update = _mode_profile_update_parts( + message, + expected_mode_name=(value.get("name") if isinstance(value, Mapping) else None), + ) + if update is None: + return None + if not isinstance(value, Mapping): + raise ValueError("mode_profile_draft_context must be an object") + referenced_name = " ".join(str(value.get("name") or "").strip().split()) + if normalize_mode_profile_name(update[0]) != normalize_mode_profile_name( + referenced_name + ): + return None + required = {"name", "source_request_id", "vehicle_id"} + if set(value) != required: + raise ValueError("mode_profile_draft_context fields are invalid") + clean = {key: " ".join(str(value.get(key) or "").strip().split()) for key in required} + if ( + not clean["name"] + or len(clean["name"]) > 64 + or not clean["vehicle_id"] + or len(clean["vehicle_id"]) > 80 + or not _MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean["source_request_id"]) + ): + raise ValueError("mode_profile_draft_context values are invalid") + # The UI keeps the latest card selected while ordinary questions are + # asked. A reference is only a routing hint when this exact sentence edits + # that exact draft; all non-matching references returned above are inert. + return clean + + def _catalog_exact_mode_match_count( catalog: Mapping[str, object], requested_name: str, @@ -314,13 +374,32 @@ def _vehicle_action_state(result: Mapping[str, object]) -> tuple[str, bool, str] def _authorize_vehicle_request( message: str, control_terms: Sequence[str] = (), + mode_profile_draft_context: Mapping[str, object] | None = None, ) -> _VehicleAuthorization: """Create a server-owned capability envelope from the original user text.""" + expected_draft_name = ( + mode_profile_draft_context.get("name") + if isinstance(mode_profile_draft_context, Mapping) + else None + ) + mode_profile_draft_context = _validated_mode_profile_draft_context( + mode_profile_draft_context, + message=message, + ) normalized = "".join(str(message).lower().split()) if _DENIAL_PATTERN.search(normalized): return _VehicleAuthorization(None, "denied_by_user") + if is_mode_profile_query( + normalized, + expected_mode_name=expected_draft_name, + ): + # A question about a named mode may contain device names, control + # verbs and numeric targets. It remains a question and must never + # fall through to the registered-control executor. + return _VehicleAuthorization(None, "mode_profile_query") + implicit_request = re.sub(r"[,。!?!?、]", "", normalized) if not _KNOWLEDGE_PATTERN.search(normalized): if _IMPLICIT_HOT_REQUEST_PATTERN.fullmatch(implicit_request): @@ -342,6 +421,12 @@ def _authorize_vehicle_request( # user as a saveable draft. return _VehicleAuthorization(message, "explicit_mode_profile_create") + if mode_profile_draft_context is not None: + # A validated opaque reference means this sentence edits a pending + # server-generated preview. It is a configuration operation, never a + # request to execute the mentioned device against the live vehicle. + return _VehicleAuthorization(message, "explicit_mode_profile_draft_update") + normalized_terms = { "".join(str(term).lower().split()) for term in control_terms if str(term).strip() } @@ -453,6 +538,7 @@ class CodexAssistantService: control_terms: Sequence[str] = (), query_capabilities: CapabilityQuery | None = None, authoritative_answer: str | None = None, + mode_profile_draft_context: Mapping[str, object] | None = None, ) -> dict[str, object]: del history # Codex owns conversation history after the thread is created. message = " ".join(str(message).strip().split()) @@ -472,11 +558,22 @@ class CodexAssistantService: authoritative_answer = " ".join(str(authoritative_answer or "").strip().split()) if len(authoritative_answer) > 16_000: raise ValueError("authoritative_answer must not exceed 16000 characters") - authorization = _authorize_vehicle_request(message, control_terms) + draft_context = _validated_mode_profile_draft_context( + mode_profile_draft_context, + message=message, + ) + authorization = _authorize_vehicle_request( + message, + control_terms, + draft_context, + ) fast_comfort_control = ( self._fast_implicit_controls and authorization.reason in _FAST_COMFORT_REASONS ) - mode_profile_preview = authorization.reason == "explicit_mode_profile_create" + mode_profile_preview = authorization.reason in { + "explicit_mode_profile_create", + "explicit_mode_profile_draft_update", + } mode_parts = _mode_profile_create_parts(message) if mode_profile_preview else None exact_mode_matches = ( _catalog_exact_mode_match_count(catalog, mode_parts[0]) @@ -493,11 +590,18 @@ class CodexAssistantService: or exact_mode_matches != 0 ) ) + direct_mode_profile_update = ( + authorization.reason == "explicit_mode_profile_draft_update" + ) # Explicit device settings and repository-backed scenes remain on the # zero-model-latency path. A genuinely new name-only scene needs Codex # to compose a candidate plan from the live capability catalog; the # vehicle service still limits it to dry-run and requires confirmation. - direct_vehicle_control = fast_comfort_control or direct_mode_profile_preview + direct_vehicle_control = ( + fast_comfort_control + or direct_mode_profile_preview + or authorization.reason == "explicit_mode_profile_draft_update" + ) route = ( "capability_catalog" if authoritative_answer @@ -509,7 +613,7 @@ class CodexAssistantService: "n7web-capability-registry" if authoritative_answer else "n7web-mode-builder" - if direct_mode_profile_preview + if direct_mode_profile_preview or direct_mode_profile_update else "n7web-control-router" if fast_comfort_control else "codex-app-server" @@ -538,7 +642,7 @@ class CodexAssistantService: "正在读取工程能力目录……" if authoritative_answer else "正在构建并校验功能模式……" - if direct_mode_profile_preview + if direct_mode_profile_preview or direct_mode_profile_update else "Codex 正在理解场景并组合工程能力……" if mode_profile_preview else "正在快速处理座舱舒适需求……" @@ -622,7 +726,13 @@ class CodexAssistantService: instruction = authorization.instruction if instruction is None: raise CodexAssistantError("direct vehicle control was not authorized") - mode_profile_preview = authorization.reason == "explicit_mode_profile_create" + mode_profile_preview = authorization.reason in { + "explicit_mode_profile_create", + "explicit_mode_profile_draft_update", + } + mode_profile_draft_update = ( + authorization.reason == "explicit_mode_profile_draft_update" + ) if ( emit( { @@ -630,7 +740,9 @@ class CodexAssistantService: "message_id": message_id, "display_text": ( "正在生成模式动作预览……" - if mode_profile_preview + if mode_profile_preview and not mode_profile_draft_update + else "正在调整待保存模式动作……" + if mode_profile_draft_update else "正在通过车辆安全链执行舒适性调节……" ), } @@ -854,6 +966,28 @@ class CodexAssistantService: if isinstance(draft, Mapping) else "新功能模式" ) + if ( + isinstance(draft, Mapping) + and draft.get("generation_source") == "draft_revision" + ): + raw_operation_count = draft.get("operation_count") + operation_count = ( + raw_operation_count + if isinstance(raw_operation_count, int) + and not isinstance(raw_operation_count, bool) + and raw_operation_count > 0 + else 0 + ) + persistence_hint = ( + "确认后可更新现有模式。" + if draft.get("existing_mode_id") + else "确认后可保存为功能模式。" + ) + return ( + f"已调整待保存的“{name}”模式,并重新校验全部" + f"{operation_count}项组合动作。原草稿未被保存或执行," + f"可以继续调整;{persistence_hint}" + ) if isinstance(draft, Mapping) and draft.get("existing_mode_id"): raw_operation_count = draft.get("operation_count") operation_count = ( @@ -1614,6 +1748,10 @@ class CodexAssistantService: "status": str(result.get("status") or "unknown"), "execution_mode": str(result.get("execution_mode") or "dry_run"), } + if isinstance(result.get("hardware_execution_allowed"), bool): + safe["hardware_execution_allowed"] = result[ + "hardware_execution_allowed" + ] for key in ("request_id", "utterance", "decision_summary"): if result.get(key) is not None: safe[key] = CodexAssistantService._safe_result_value( @@ -1699,6 +1837,8 @@ class CodexAssistantService: "existing_mode_id", "existing_mode_revision", "generation_source", + "draft_revision", + "revises_source_request_id", ) if key in mode_profile_draft } diff --git a/src/cognitive_agent/application/mode_profile_intent.py b/src/cognitive_agent/application/mode_profile_intent.py new file mode 100644 index 0000000..6f8825e --- /dev/null +++ b/src/cognitive_agent/application/mode_profile_intent.py @@ -0,0 +1,121 @@ +"""Canonical, side-effect-free parsing for named-mode configuration turns.""" + +from __future__ import annotations + +import re + + +_LEADING_POLITE = r"(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|(?:帮我|帮忙|给我))?" +_MODE_NAME = r"(?P[^,。,.!!::\s“”\"'「」『』]{1,64}?模式)" +_MODE_POSSESSIVE_BOUNDARY = r"(?:中的|里的|内的|之中的|的|中|里|内)" +_MODE_MEMBER_BOUNDARY = rf"(?:{_MODE_POSSESSIVE_BOUNDARY}|[,,::])" +_PREFIX_UPDATE = re.compile( + rf"^{_LEADING_POLITE}" + rf"(?:修改一下|修改|调整|更改|更新|改一下|变更){_MODE_NAME}" + rf"{_MODE_MEMBER_BOUNDARY}[,,::]?(?P.*?)[。.!!]?$" +) +_BA_UPDATE = re.compile( + rf"^{_LEADING_POLITE}(?:把|将){_MODE_NAME}" + rf"{_MODE_MEMBER_BOUNDARY}[,,::]?" + r"(?P.+?)[。.!!]?$" +) +_NAME_FIRST_UPDATE = re.compile( + rf"^{_LEADING_POLITE}{_MODE_NAME}" + rf"{_MODE_POSSESSIVE_BOUNDARY}[,,::]?" + r"(?P.+?)[。.!!]?$" +) +_CONTEXTUAL_NAME_FIRST_UPDATE = re.compile( + rf"^{_LEADING_POLITE}{_MODE_NAME}[,,::]" + r"(?P.+?)[。.!!]?$" +) +MODE_PROFILE_CHANGE_PREDICATE = re.compile( + r"修改|改成|改为|更改|更新|变更|变为|换成|" + r"调整|调节|调高|调低|调到|调成|调为|调至|" + r"设置|设为|设成|设到|设至|" + r"打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|" + r"移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|" + r"复位|半开|开到|关到|拉开|拉上|上锁|解锁" +) +MODE_PROFILE_QUERY_CUE = re.compile( + r"什么|哪些|多少|几(?:度|档|级|个|项)?|是否|是不是|" + r"合适(?:吗)?|合理(?:吗)?|会(?:怎么样|怎样|如何)|" + r"怎么|如何|列(?:出|一下)|介绍(?:一下)?|说明(?:一下)?|" + r"告诉我|查询|查看|有什么作用|是何作用|还是|[??]" +) + + +def compact_mode_profile_text(value: object) -> str: + """Remove presentation whitespace without changing CJK command meaning.""" + + return re.sub(r"\s+", "", str(value or "").strip()) + + +def normalize_mode_profile_name(value: object) -> str: + return compact_mode_profile_text(value).strip("“”\"'「」『』").casefold() + + +def _candidate_matches( + text: str, + expected_mode_name: object | None, +) -> list[tuple[re.Match[str], bool]]: + matches: list[tuple[re.Match[str], bool]] = [] + for pattern, predicate_required in ( + (_PREFIX_UPDATE, False), + (_BA_UPDATE, True), + (_NAME_FIRST_UPDATE, True), + ): + match = pattern.fullmatch(text) + if match is not None: + matches.append((match, predicate_required)) + if expected_mode_name is not None: + contextual = _CONTEXTUAL_NAME_FIRST_UPDATE.fullmatch(text) + if ( + contextual is not None + and normalize_mode_profile_name(contextual.group("name")) + == normalize_mode_profile_name(expected_mode_name) + ): + matches.append((contextual, True)) + return matches + + +def is_mode_profile_query( + value: object, + *, + expected_mode_name: object | None = None, +) -> bool: + """Return whether a named-mode-shaped sentence asks instead of edits.""" + + text = compact_mode_profile_text(value) + if not text: + return False + for match, _predicate_required in _candidate_matches( + text, + expected_mode_name, + ): + change = str(match.group("change") or "").strip("的中里内,,::。.!!") + if MODE_PROFILE_QUERY_CUE.search(change): + return True + return False + + +def parse_mode_profile_update( + value: object, + *, + expected_mode_name: object | None = None, +) -> tuple[str, str] | None: + """Parse one explicit update and reject nouns such as ``空调`` as verbs.""" + + text = compact_mode_profile_text(value) + if not text: + return None + for match, predicate_required in _candidate_matches(text, expected_mode_name): + change = str(match.group("change") or "").strip("的中里内,,::。.!!") + if ( + (not predicate_required or MODE_PROFILE_CHANGE_PREDICATE.search(change)) + and not is_mode_profile_query( + text, + expected_mode_name=expected_mode_name, + ) + ): + return str(match.group("name") or "").strip(), change + return None diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 1c3135e..95acc4c 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -74,6 +74,11 @@ from cognitive_agent.application.semantic_ir import ( # noqa: E402 apply_language_safety_guards, resolve_explicit_seat_scopes, ) +from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 + is_mode_profile_query, + normalize_mode_profile_name, + parse_mode_profile_update, +) from cognitive_agent.application.native_can_feedback import ( # noqa: E402 VehicleSafetyPreflightError, authoritative_physical_ch1_feedback_state, @@ -82,6 +87,7 @@ from cognitive_agent.application.native_can_feedback import ( # noqa: E402 require_stationary_vehicle, ) from cognitive_agent.application.native_can_runtime import ( # noqa: E402 + NativeCanCompileError, NativeCanExecutor, NativeCanRuntime, UNVERIFIED_NATIVE_CAPABILITIES, @@ -297,16 +303,6 @@ CONTROL_CLAUSE_PREDICATE_PATTERN = re.compile( r"(?:\d+(?:\.\d+)?|[零一二三四五六七八九十百两半]+)" r"(?:%|%|度|℃|档|级|秒|毫秒)" ) -UPDATE_MODE_PROFILE_PREFIX = re.compile( - r"^(?:请(?:帮我)?|帮我)?(?:修改一下|修改|调整|更改|更新|改一下)" - r"(?P[^,。,.!!\s]{1,64}?模式)" - r"(?:[,。,.!!\s]*(?P.*))?$" -) -UPDATE_MODE_PROFILE_BA = re.compile( - r"^(?:请(?:帮我)?|帮我)?(?:把|将)" - r"(?P[^,。,.!!\s]{1,64}?模式)" - r"的(?P.+)$" -) RESTORE_MODE_PROFILE_PREFIX = re.compile( r"^(?:请(?:帮我)?|帮我)?(?:恢复|还原|重置|初始化)" r"(?P[^,。,.!!\s]{1,64}?模式)" @@ -1515,6 +1511,7 @@ class SeatDemoService: control_transport: str = WIRED_CAN_TRANSPORT, on_commands_validated: Callable[[dict[str, Any]], None] | None = None, assistant_plan: dict[str, Any] | None = None, + mode_profile_draft_context: dict[str, Any] | None = None, ) -> dict[str, Any]: """Validate and execute deterministic controls before hosted parsing.""" @@ -1545,6 +1542,7 @@ class SeatDemoService: control_transport=control_transport, _on_commands_validated=on_commands_validated, _assistant_plan=assistant_plan, + _mode_profile_draft_context=mode_profile_draft_context, ) result.update( { @@ -1602,6 +1600,7 @@ class SeatDemoService: _allow_empty_control_result=True, _on_commands_validated=publish_local_commands, _assistant_plan=assistant_plan, + _mode_profile_draft_context=mode_profile_draft_context, ) elapsed_ms = round((time.perf_counter() - started) * 1000, 3) supplement_required = bool( @@ -2048,13 +2047,37 @@ class SeatDemoService: _direct_mode_profile_id: str | None = None, _speech_understanding: dict[str, Any] | None = None, _assistant_plan: dict[str, Any] | None = None, + _mode_profile_draft_context: dict[str, Any] | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) # Creating a reusable scene is a configuration workflow. Synthesize # and validate it in dry-run even when the console currently targets # the real bench, otherwise virtual domains such as climate or ambient # light would be rejected before the profile can be persisted. - if self._parse_mode_profile_create_name(utterance.strip()) is not None: + draft_reference_name = ( + str(_mode_profile_draft_context.get("name") or "").strip() + if isinstance(_mode_profile_draft_context, dict) + else "" + ) + draft_update = parse_mode_profile_update( + utterance.strip(), + expected_mode_name=draft_reference_name or None, + ) + mode_profile_query = is_mode_profile_query( + utterance, + expected_mode_name=draft_reference_name or None, + ) + draft_update_requested = bool( + draft_update is not None + and draft_reference_name + and normalize_mode_profile_name(draft_update[0]) + == normalize_mode_profile_name(draft_reference_name) + ) + if ( + self._parse_mode_profile_create_name(utterance.strip()) is not None + or draft_update_requested + or mode_profile_query + ): execution_mode = "dry_run" if execution_mode != "hardware": return self._process_once( @@ -2076,6 +2099,7 @@ class SeatDemoService: _direct_mode_profile_id, _speech_understanding, assistant_plan=_assistant_plan, + mode_profile_draft_context=_mode_profile_draft_context, ) if control_transport == WIRELESS_MQTT_TRANSPORT: # The head unit owns both physical CAN channels in wireless mode. @@ -2100,6 +2124,7 @@ class SeatDemoService: _direct_mode_profile_id, _speech_understanding, assistant_plan=_assistant_plan, + mode_profile_draft_context=_mode_profile_draft_context, ) if hardware_access_bypass or self._driver_only_vehicle_execution_active( control_transport @@ -2124,6 +2149,7 @@ class SeatDemoService: _direct_mode_profile_id, _speech_understanding, assistant_plan=_assistant_plan, + mode_profile_draft_context=_mode_profile_draft_context, ) authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id) with self.hardware_execution.reserve(): @@ -2147,6 +2173,7 @@ class SeatDemoService: _direct_mode_profile_id, _speech_understanding, assistant_plan=_assistant_plan, + mode_profile_draft_context=_mode_profile_draft_context, ) def _process_once( @@ -2171,6 +2198,7 @@ class SeatDemoService: assistant_plan: dict[str, Any] | None = None, allow_fuzzy_mode_profile_match: bool = True, allow_scene_composition: bool = False, + mode_profile_draft_context: dict[str, Any] | None = None, ) -> dict[str, Any]: control_transport = self._validate_control_transport(control_transport) utterance = utterance.strip() @@ -2203,11 +2231,24 @@ class SeatDemoService: mode_save_name = self._parse_mode_profile_save_name(utterance) mode_create_name = self._parse_mode_profile_create_name(utterance) mode_restore_name = self._parse_mode_profile_restore_name(utterance) - mode_update = self._parse_mode_profile_update(utterance) + draft_reference_name = ( + str(mode_profile_draft_context.get("name") or "").strip() + if isinstance(mode_profile_draft_context, dict) + else "" + ) + mode_update = parse_mode_profile_update( + utterance, + expected_mode_name=draft_reference_name or None, + ) + mode_profile_query = is_mode_profile_query( + utterance, + expected_mode_name=draft_reference_name or None, + ) control_plane_request = ( mode_save_name is not None or mode_restore_name is not None or mode_update is not None + or mode_profile_query ) hardware_request = execution_mode == "hardware" if hardware_request and _has_mixed_driver_passenger_seat_scope(utterance): @@ -2242,7 +2283,12 @@ class SeatDemoService: assistant_plan=assistant_plan, ) - request_id = f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" + # A request id is also the opaque capability used to reference a + # server-owned draft artifact. Keep the full 128 bits of randomness; + # the previous six-hex suffix was guessable under multi-user traffic. + request_id = ( + f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex}" + ) context = self._context_for_vehicle( self.conversations.snapshot(conversation_id), vehicle_profile, @@ -2319,6 +2365,24 @@ class SeatDemoService: } started = time.perf_counter() + if mode_profile_query: + result["status"] = "needs_confirmation" + result["execution_mode"] = "dry_run" + result["hardware_execution_allowed"] = False + result["orchestration_route"] = "mode_profile_query" + result["validation"] = [ + { + "severity": "warning", + "code": "MODE_PROFILE_QUERY_NOT_CONTROL", + "path": "utterance", + "message": "该语句是在询问功能模式,不是模式修改或车辆执行指令。", + } + ] + result["decision_summary"] = [ + "识别为功能模式问答;未生成或执行任何车辆控制动作。" + ] + self._persist(result) + return result if mode_save_name is not None: return self._process_mode_profile_save( utterance, @@ -2342,6 +2406,28 @@ class SeatDemoService: ) if mode_update is not None: mode_name, change_text = mode_update + if ( + draft_reference_name + and normalize_mode_profile_name(draft_reference_name) + == normalize_mode_profile_name(mode_name) + ): + return self._process_mode_profile_draft_update( + utterance, + request_id, + provider, + conversation_id, + context, + mode_name, + change_text, + vehicle_profile, + result, + traces, + mode_profile_draft_context, + approval_token=approval_token, + simulation_fault=simulation_fault, + control_transport=control_transport, + assistant_plan=assistant_plan, + ) return self._process_mode_profile_update( utterance, request_id, @@ -3550,18 +3636,7 @@ class SeatDemoService: @staticmethod def _parse_mode_profile_update(utterance: str) -> tuple[str, str] | None: - explicit = UPDATE_MODE_PROFILE_PREFIX.search(utterance) - if explicit is not None: - change_text = str(explicit.group("change") or "").strip() - return explicit.group("name").strip(), change_text - - inline = UPDATE_MODE_PROFILE_BA.search(utterance) - if inline is None: - return None - change_text = inline.group("change").strip("的").strip() - if not any(word in change_text for word in ("改", "调", "设", "更新", "变")): - return None - return inline.group("name").strip(), change_text + return parse_mode_profile_update(utterance) @staticmethod def _parse_mode_profile_restore_name(utterance: str) -> str | None: @@ -3953,6 +4028,414 @@ class SeatDemoService: domains.append(domain) return domains or ["seat"] + @staticmethod + def _preview_mode_operations( + source_result: dict[str, Any], + ) -> list[dict[str, Any]]: + """Recover every reusable operation from server-compiled preview data.""" + + plans = source_result.get("command_plans") + configs = source_result.get("function_configs") + if not isinstance(plans, dict) or not isinstance(configs, dict): + return [] + operations: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + for key, plan in plans.items(): + if ( + not isinstance(plan, dict) + or not isinstance(plan.get("spec"), dict) + or not isinstance(plan["spec"].get("commands"), list) + or not plan["spec"]["commands"] + ): + continue + config = configs.get(key) + spec = config.get("spec") if isinstance(config, dict) else None + if not isinstance(spec, dict): + continue + domain = str(spec.get("domain") or "").strip() + raw_operations = spec.get("operations") + if not domain or not isinstance(raw_operations, list): + continue + for operation in raw_operations: + if not isinstance(operation, dict): + continue + capability = str(operation.get("capability") or "").strip() + raw_target = operation.get("target") + target = ( + str( + raw_target.get("instance") + or raw_target.get("zone") + or raw_target.get("scope") + or "" + ).strip() + if isinstance(raw_target, dict) + else "" + ) + parameters = operation.get("parameters") + signature = (domain, capability, target) + if ( + not capability + or not target + or not isinstance(parameters, dict) + or signature in seen + ): + continue + reusable_parameters = deepcopy(parameters) + if ( + capability.startswith("seat.set_") + and reusable_parameters.get("position_pct") is not None + and reusable_parameters.get("angle_deg") is not None + ): + # Seat compilation adds the calibrated angle alongside the + # requested percentage. The scene contract intentionally + # permits one absolute representation only; retain the + # portable percentage and let the new compile derive angle. + reusable_parameters.pop("angle_deg", None) + seen.add(signature) + operations.append( + { + "domain": domain, + "capability": capability, + "target_scope": target, + "operation": "set", + "parameters": reusable_parameters, + "reason": "由服务端已校验功能模式草稿保留。", + } + ) + return operations + + def _load_mode_profile_draft_reference( + self, + reference: dict[str, Any], + *, + conversation_id: str, + mode_name: str, + vehicle_id: str, + ) -> tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]]]: + """Resolve an opaque client reference back to one trusted local artifact.""" + + if not isinstance(reference, dict) or set(reference) != { + "name", + "source_request_id", + "vehicle_id", + }: + raise ValueError("待保存功能模式草稿引用格式无效。") + clean_name = str(reference.get("name") or "").strip() + clean_request_id = str(reference.get("source_request_id") or "").strip() + clean_vehicle_id = str(reference.get("vehicle_id") or "").strip() + if not MODE_EDITOR_REQUEST_ID_PATTERN.fullmatch(clean_request_id): + raise ValueError("待保存功能模式草稿来源请求编号无效。") + if ( + normalize_mode_profile_name(clean_name) + != normalize_mode_profile_name(mode_name) + ): + raise ValueError("修改请求与待保存功能模式草稿名称不匹配。") + if clean_vehicle_id != vehicle_id: + raise ValueError("待保存功能模式草稿不属于当前车型。") + + result_path = self.artifact_root / clean_request_id / "result.json" + if not result_path.is_file(): + raise ValueError("未找到待保存功能模式草稿,请重新构建。") + try: + source_result = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError("待保存功能模式草稿无法读取,请重新构建。") from exc + if not isinstance(source_result, dict): + raise ValueError("待保存功能模式草稿内容无效,请重新构建。") + source_vehicle = source_result.get("vehicle") + source_vehicle_id = ( + str(source_vehicle.get("id") or "").strip() + if isinstance(source_vehicle, dict) + else "" + ) + if ( + source_result.get("request_id") != clean_request_id + or source_result.get("conversation_id") != conversation_id + or source_vehicle_id != vehicle_id + or source_result.get("status") != "succeeded" + or source_result.get("execution_mode") != "dry_run" + or source_result.get("hardware_execution_allowed") is not False + or source_result.get("orchestration_route") + != "mode_profile_create_preview" + ): + raise ValueError("待保存功能模式草稿与当前会话不匹配,请重新构建。") + draft = source_result.get("mode_profile_draft") + if not isinstance(draft, dict): + raise ValueError("来源请求不是可修改的功能模式草稿。") + if ( + str(draft.get("name") or "").strip() != clean_name + or str(draft.get("source_request_id") or "").strip() + != clean_request_id + or str(draft.get("vehicle_id") or "").strip() != vehicle_id + ): + raise ValueError("待保存功能模式草稿引用与来源内容不匹配。") + validation = source_result.get("validation") + if isinstance(validation, list) and any( + isinstance(issue, dict) and issue.get("severity") == "error" + for issue in validation + ): + raise ValueError("待保存功能模式草稿没有通过完整安全校验。") + operations = self._preview_mode_operations(source_result) + operation_count = draft.get("operation_count") + if ( + not isinstance(operation_count, int) + or isinstance(operation_count, bool) + or operation_count <= 0 + or len(operations) != operation_count + ): + raise ValueError("待保存功能模式草稿动作证据不完整,请重新构建。") + return source_result, draft, operations + + @staticmethod + def _absolute_mode_operations( + operations: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + """Convert bounded motion commands into replayable absolute mode targets.""" + + normalized: list[dict[str, Any]] = [] + window_targets = { + "all": ("front_left", "front_right", "rear_left", "rear_right"), + "front": ("front_left", "front_right"), + "rear": ("rear_left", "rear_right"), + } + for operation in operations: + item = deepcopy(operation) + capability = str(item.get("capability") or "") + parameters = dict(item.get("parameters") or {}) + if capability == "roof.set_shade_motion": + positions = {"close": 0, "half": 50, "open": 100} + motion = str(parameters.get("motion") or "") + if motion not in positions: + raise ValueError("遮阳帘停止动作没有可保存的最终位置,请改为明确开度。") + item["capability"] = "roof.set_shade_position" + item["parameters"] = {"position_pct": positions[motion]} + normalized.append(item) + continue + if capability == "window.set_motion": + positions = {"close": 0, "open": 100} + motion = str(parameters.get("motion") or "") + if motion not in positions: + raise ValueError("车窗停止动作没有可保存的最终位置,请改为明确开度。") + target = str(item.get("target_scope") or "") + targets = window_targets.get(target, (target,)) + for resolved_target in targets: + absolute = deepcopy(item) + absolute["capability"] = "window.set_position" + absolute["target_scope"] = resolved_target + absolute["parameters"] = {"position_pct": positions[motion]} + normalized.append(absolute) + continue + normalized.append(item) + return normalized + + @staticmethod + def _mode_profile_draft_plan( + operations: list[dict[str, Any]], + ) -> dict[str, Any]: + return { + "detected_domains": list( + dict.fromkeys(str(item["domain"]) for item in operations) + ), + "requests": [ + { + "domain": str(item["domain"]), + "capability": str(item["capability"]), + "target_scope": str(item["target_scope"]), + "operation": "set", + "duration_ms": None, + "parameters": [ + {"name": str(name), "value": deepcopy(value)} + for name, value in dict(item.get("parameters") or {}).items() + ], + "reason": "按用户要求修改待保存功能模式草稿。", + } + for item in operations + ], + "ambiguities": [], + "decision_summary": ["已在服务端合并并重新校验待保存模式动作。"], + "confidence": 1.0, + } + + @staticmethod + def _mode_update_target_is_explicit(change_text: str, target: str) -> bool: + patterns = { + "driver": r"主驾|驾驶位|驾驶员|左前", + "front_left": r"主驾|驾驶位|驾驶员|左前", + "passenger": r"副驾|乘客位|右前", + "front_right": r"副驾|乘客位|右前", + "front_row": r"前排|前舱", + "rear": r"后排|后座|后舱|所有后排", + "rear_row": r"后排|后座|后舱|所有后排", + "rear_left": r"左后|后排左|左侧后排", + "rear_right": r"右后|后排右|右侧后排", + "vehicle": r"全车|整车|所有|全部", + "all": r"全车|整车|所有|全部", + } + pattern = patterns.get(str(target)) + return bool(pattern and re.search(pattern, change_text)) + + def _process_mode_profile_draft_update( + self, + utterance: str, + request_id: str, + provider: str, + conversation_id: str, + context: dict[str, Any], + mode_name: str, + change_text: str, + vehicle_profile: VehicleProfile, + result: dict[str, Any], + traces: list[dict[str, Any]], + draft_reference: dict[str, Any], + *, + approval_token: str | None, + simulation_fault: str, + control_transport: str, + assistant_plan: dict[str, Any] | None, + ) -> dict[str, Any]: + """Patch an unsaved preview and produce a new atomic dry-run artifact.""" + + del request_id, traces + try: + _source_result, source_draft, source_operations = ( + self._load_mode_profile_draft_reference( + draft_reference, + conversation_id=conversation_id, + mode_name=mode_name, + vehicle_id=vehicle_profile.id, + ) + ) + base_operations = self._absolute_mode_operations(source_operations) + if not change_text: + raise ValueError("请说明要调整的设备和明确目标值。") + parseable_change_text = re.sub( + r"改为|改成|变更为|换成|变为", + "调整为", + change_text, + ) + unresolved_change_clauses = [ + clause + for clause, _parsed, status in self._local_clause_results( + parseable_change_text, + context, + ) + if status != "complete" + ] + if unresolved_change_clauses and assistant_plan is None: + raise ValueError( + "调整说明包含未完整识别的内容:" + + ";".join(unresolved_change_clauses) + ) + change_result = self._process_once( + parseable_change_text, + provider, + conversation_id, + "dry_run", + approval_token, + vehicle_profile.id, + simulation_fault, + control_transport=control_transport, + assistant_plan=assistant_plan, + allow_fuzzy_mode_profile_match=False, + ) + change_operations = self._absolute_mode_operations( + self._preview_mode_operations(change_result) + ) + if ( + change_result.get("status") != "succeeded" + or not change_operations + or change_result.get("supplement_required") is True + or bool(change_result.get("unresolved_clauses")) + or bool(change_result.get("deferred_requests")) + ): + raise ValueError("没有从调整说明中得到完整、可复用的设备目标。") + validation = change_result.get("validation") + if isinstance(validation, list) and any( + isinstance(issue, dict) and issue.get("severity") == "error" + for issue in validation + ): + raise ValueError("调整动作没有通过车辆能力或安全校验。") + + normalized_changes: list[dict[str, Any]] = [] + for change in change_operations: + same_capability = [ + item + for item in base_operations + if item["domain"] == change["domain"] + and item["capability"] == change["capability"] + ] + normalized = deepcopy(change) + if len(same_capability) == 1 and not self._mode_update_target_is_explicit( + change_text, + str(change["target_scope"]), + ): + normalized["target_scope"] = same_capability[0]["target_scope"] + elif len(same_capability) > 1 and not self._mode_update_target_is_explicit( + change_text, + str(change["target_scope"]), + ): + raise ValueError( + f"“{mode_name}”中该设备能力包含多个目标,请明确主驾、副驾或具体区域。" + ) + normalized_changes.append(normalized) + + change_by_key = { + ( + str(item["domain"]), + str(item["capability"]), + str(item["target_scope"]), + ): item + for item in normalized_changes + } + merged: list[dict[str, Any]] = [] + for operation in base_operations: + key = ( + str(operation["domain"]), + str(operation["capability"]), + str(operation["target_scope"]), + ) + merged.append(deepcopy(change_by_key.pop(key, operation))) + merged.extend(deepcopy(list(change_by_key.values()))) + + return self._process_mode_profile_create( + utterance, + "local", + conversation_id, + "dry_run", + approval_token, + vehicle_profile, + simulation_fault, + mode_name, + control_transport, + assistant_plan=self._mode_profile_draft_plan(merged), + source_draft=source_draft, + ) + except ValueError as exc: + result["status"] = "needs_confirmation" + result["execution_mode"] = "dry_run" + result["hardware_execution_allowed"] = False + result["orchestration_route"] = "mode_profile_create_preview" + result["validation"] = [ + { + "severity": "error", + "code": "MODE_PROFILE_DRAFT_UPDATE_REJECTED", + "path": "mode_profile_draft_context", + "message": str(exc), + } + ] + result["mode_creation_failure"] = { + "code": "MODE_PROFILE_DRAFT_UPDATE_REJECTED", + "reason": str(exc), + "next_step": "请保留当前模式卡片,并明确要修改的设备、区域和目标值后重试", + "unresolved_clauses": [change_text] if change_text else [], + "issues": [], + } + result["decision_summary"] = [ + f"未修改待保存的“{mode_name}”草稿;原草稿保持不变。" + ] + self._persist(result) + return result + def _process_mode_profile_update( self, utterance: str, @@ -4303,10 +4786,11 @@ class SeatDemoService: traces: list[dict[str, Any]], request_id: str, ) -> ModeProfile | None: - matches = self.mode_profiles.match_text( + exact = self.mode_profiles.resolve_exact_name( mode_name, vehicle_id=vehicle_profile.id, ) + matches = [exact] if exact is not None else [] result["matched_modes"] = [profile.public_dict() for profile in matches] if len(matches) == 1: return matches[0] @@ -4614,6 +5098,7 @@ class SeatDemoService: mode_name: str, control_transport: str, assistant_plan: dict[str, Any] | None = None, + source_draft: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build and verify a named-mode draft for explicit user confirmation. @@ -4629,14 +5114,42 @@ class SeatDemoService: if create_match is not None else "" ) - existing_profile = self.mode_profiles.resolve_exact_name( - mode_name, - vehicle_id=vehicle_profile.id, + source_existing_mode_id = ( + str(source_draft.get("existing_mode_id") or "").strip() + if isinstance(source_draft, dict) + else "" + ) + if source_draft is not None and source_existing_mode_id: + try: + existing_profile = self.mode_profiles.get_profile( + source_existing_mode_id, + vehicle_profile.id, + ) + except KeyError: + existing_profile = None + elif source_draft is not None: + existing_profile = None + else: + existing_profile = self.mode_profiles.resolve_exact_name( + mode_name, + vehicle_id=vehicle_profile.id, + ) + source_profile = ( + existing_profile + if not requested_instruction and source_draft is None + else None + ) + draft_mode_name = ( + str(source_draft.get("name") or mode_name).strip() + if isinstance(source_draft, dict) + else existing_profile.name + if existing_profile is not None + else mode_name ) - source_profile = existing_profile if not requested_instruction else None - draft_mode_name = existing_profile.name if existing_profile is not None else mode_name generation_source = ( - "registered_mode_profile" + "draft_revision" + if source_draft is not None + else "registered_mode_profile" if source_profile is not None else "codex_scene_composition" if assistant_plan is not None and not requested_instruction @@ -4647,31 +5160,99 @@ class SeatDemoService: # of appending a model prompt to the user's utterance. The latter was # 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"进入{mode_name}" - source_result = self._process_once( - source_utterance, - provider, - conversation_id, - "dry_run", - approval_token, - vehicle_profile.id, - simulation_fault, - control_transport=control_transport, - direct_mode_profile_id=( - source_profile.id if source_profile is not None else None - ), - assistant_plan=assistant_plan, - # ``source_utterance`` is synthesized for a name-only, unknown - # scene. It must not fall back to substring matching (for example - # ``我的小憩模式`` -> ``小憩模式``), otherwise the exact resolver's - # ambiguity and collision checks can be bypassed. - allow_fuzzy_mode_profile_match=bool(requested_instruction), - allow_scene_composition=( - assistant_plan is not None - and source_profile is None - and not requested_instruction - ), - ) + source_utterance = requested_instruction or f"进入{draft_mode_name}" + try: + source_result = self._process_once( + source_utterance, + provider, + conversation_id, + "dry_run", + approval_token, + vehicle_profile.id, + simulation_fault, + control_transport=control_transport, + direct_mode_profile_id=( + source_profile.id if source_profile is not None else None + ), + assistant_plan=assistant_plan, + # ``source_utterance`` is synthesized for a name-only, unknown + # scene. It must not fall back to substring matching (for example + # ``我的小憩模式`` -> ``小憩模式``), otherwise the exact resolver's + # ambiguity and collision checks can be bypassed. + allow_fuzzy_mode_profile_match=bool(requested_instruction), + allow_scene_composition=( + assistant_plan is not None + and source_profile is None + and not requested_instruction + ), + ) + except NativeCanCompileError: + # Repository modes and model-composed scenes share the same native + # compiler. A stale mode parameter must become a bounded failed + # preview, never an uncaught HTTP 500 or a partially usable card. + failure_context = self._context_for_vehicle( + self.conversations.snapshot(conversation_id), + vehicle_profile, + ) + source_result = { + "request_id": ( + f"req-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-" + f"{uuid.uuid4().hex}" + ), + "utterance": source_utterance, + "conversation_id": conversation_id, + "conversation_turn": failure_context["turn_count"], + "status": "validation_failed", + "provider_requested": provider, + "provider_used": "mode_profile_native_can_guard", + "execution_mode": "dry_run", + "control_transport": control_transport, + "hardware_execution_allowed": False, + "vehicle": vehicle_profile.public_dict(), + "matched_modes": ( + [source_profile.public_dict()] + if source_profile is not None + else [] + ), + "mode_profile": None, + "decision_summary": [ + f"“{draft_mode_name}”包含当前车型协议无法编译的动作,未生成草稿。" + ], + "deferred_requests": [], + "model_requests": [], + "unresolved_clauses": [], + "intent_ir": None, + "function_config": None, + "function_configs": {}, + "scene_config": None, + "preset_config": None, + "validation": [ + { + "severity": "error", + "code": "MODE_PROFILE_NATIVE_COMPILE_FAILED", + "path": "mode_profile_draft", + "message": "模式动作无法编译为当前车型支持的控制指令。", + } + ], + "command_plan": None, + "command_plans": {}, + "execution_report": None, + "actuator_state": failure_context["devices"], + "domain_state": failure_context["domain_devices"], + "saved_presets": [], + "trace_events": [], + "can_commands": [], + "orchestration_route": "mode_profile_create_preview", + "mode_creation_failure": { + "code": "MODE_PROFILE_NATIVE_COMPILE_FAILED", + "reason": "模式动作未通过当前车型 CAN 协议编译校验", + "next_step": "请检查模式中的设备参数是否符合当前车型能力范围", + "unresolved_clauses": [], + "issues": [], + }, + } + self._persist(source_result) + return source_result draft_context = self._context_for_vehicle( self.conversations.snapshot(conversation_id), vehicle_profile, @@ -4736,7 +5317,7 @@ class SeatDemoService: ] if isinstance(source_actions, list) else [] if ( source_result.get("status") == "succeeded" - and generation_source == "codex_scene_composition" + and generation_source in {"codex_scene_composition", "draft_revision"} and assistant_plan is not None and ( self._scene_plan_signatures(assistant_plan) @@ -4762,7 +5343,7 @@ class SeatDemoService: ] if source_result.get("status") != "succeeded": - if generation_source == "codex_scene_composition": + if generation_source in {"codex_scene_composition", "draft_revision"}: # Final fail-closed barrier for errors discovered after the # plan guard (adapter, schema, compiler, or safety validation). # A failed reusable scene must not expose a trial-able subset. @@ -4825,6 +5406,40 @@ class SeatDemoService: ) ) source_request_id = str(source_result.get("request_id") or "") + source_existing_revision = ( + str(source_draft.get("existing_mode_revision") or "").strip() + if isinstance(source_draft, dict) + else "" + ) + existing_binding = ( + { + "existing_mode_id": source_existing_mode_id, + "existing_mode_revision": source_existing_revision, + } + if source_draft is not None and source_existing_mode_id + else { + "existing_mode_id": existing_profile.id, + "existing_mode_revision": ( + self.mode_profiles.profile_revision(existing_profile) + ), + } + if existing_profile is not None + else {} + ) + raw_source_revision = ( + source_draft.get("draft_revision") + if isinstance(source_draft, dict) + else None + ) + source_revision = ( + raw_source_revision + if isinstance(raw_source_revision, int) + and not isinstance(raw_source_revision, bool) + and raw_source_revision >= 1 + else 1 + if source_draft is not None + else 0 + ) source_result["mode_profile_draft"] = { "name": draft_mode_name, "instruction": source_utterance, @@ -4832,17 +5447,18 @@ class SeatDemoService: "vehicle_id": vehicle_profile.id, "domains": domains, "operation_count": len(reusable_actions), + **existing_binding, + "generation_source": generation_source, + "draft_revision": source_revision + 1, **( { - "existing_mode_id": existing_profile.id, - "existing_mode_revision": ( - self.mode_profiles.profile_revision(existing_profile) - ), + "revises_source_request_id": str( + source_draft.get("source_request_id") or "" + ).strip() } - if existing_profile is not None + if isinstance(source_draft, dict) else {} ), - "generation_source": generation_source, } source_result["mode_creation"] = { "requested_utterance": utterance, @@ -4851,17 +5467,9 @@ class SeatDemoService: "source_status": source_result.get("status"), "source_provider": source_result.get("provider_used"), "operation_count": len(reusable_actions), - **( - { - "existing_mode_id": existing_profile.id, - "existing_mode_revision": ( - self.mode_profiles.profile_revision(existing_profile) - ), - } - if existing_profile is not None - else {} - ), + **existing_binding, "generation_source": generation_source, + "draft_revision": source_revision + 1, } source_result["orchestration_route"] = "mode_profile_create_preview" source_result["decision_summary"] = [ @@ -4871,6 +5479,8 @@ class SeatDemoService: if source_profile is not None else f"Codex 已根据场景语义组合并验证“{draft_mode_name}”的工程能力。" if generation_source == "codex_scene_composition" + else f"已调整并重新校验待保存的“{draft_mode_name}”模式草稿。" + if generation_source == "draft_revision" else f"已构建并验证“{mode_name}”场景。" ), *list(source_result.get("decision_summary", [])), diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index caf311e..b1df0ce 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -43,6 +43,10 @@ from cognitive_agent.application.codex_assistant import ( # noqa: E402 CodexVehicleToolError, ) from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402 +from cognitive_agent.application.mode_profile_intent import ( # noqa: E402 + normalize_mode_profile_name, + parse_mode_profile_update, +) from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402 from cognitive_agent.application.self_extension import ( # noqa: E402 ExtensionIntegrationError, @@ -1657,6 +1661,25 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if payload.get("reasoning_effort") is not None else "low" ) + mode_profile_draft_context = payload.get("mode_profile_draft_context") + parsed_mode_update = parse_mode_profile_update( + message, + expected_mode_name=( + mode_profile_draft_context.get("name") + if isinstance(mode_profile_draft_context, Mapping) + else None + ), + ) + matching_mode_profile_draft_context = ( + dict(mode_profile_draft_context) + if isinstance(mode_profile_draft_context, Mapping) + and parsed_mode_update is not None + and normalize_mode_profile_name(parsed_mode_update[0]) + == normalize_mode_profile_name( + mode_profile_draft_context.get("name") + ) + else None + ) common_arguments: dict[str, object] = { "utterance": message, # Codex has already converted an implicit request into one explicit @@ -1672,6 +1695,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler): "hardware_access_bypass": self._hardware_access_bypass_allowed(), "reasoning_effort": reasoning_effort, "control_transport": str(payload.get("control_transport") or "wired_can"), + **( + { + "mode_profile_draft_context": ( + matching_mode_profile_draft_context + ) + } + if matching_mode_profile_draft_context is not None + else {} + ), } capability_catalog = self._assistant_capability_catalog() capability_snapshot = capability_catalog.snapshot() @@ -1783,6 +1815,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler): control_terms=capability_catalog.control_terms(capability_snapshot), query_capabilities=capability_catalog.query, authoritative_answer=fast_answer.answer if fast_answer else None, + mode_profile_draft_context=mode_profile_draft_context, ) except HardwareAccessRequiredError as exc: self.vehicle_display_sync.update_task( diff --git a/tests/e2e/test_assistant_web_api.py b/tests/e2e/test_assistant_web_api.py index c6baf7f..8886ea4 100644 --- a/tests/e2e/test_assistant_web_api.py +++ b/tests/e2e/test_assistant_web_api.py @@ -186,7 +186,10 @@ class FakeVehicleService: if self.fail: raise RuntimeError("private adapter failure") utterance = str(kwargs["utterance"]) - mode_preview = utterance.startswith(("请帮我创建一个", "构建一个")) + draft_reference = kwargs.get("mode_profile_draft_context") + mode_preview = utterance.startswith(("请帮我创建一个", "构建一个")) or isinstance( + draft_reference, dict + ) callback = kwargs["on_commands_validated"] assert callable(callback) callback( @@ -199,7 +202,13 @@ class FakeVehicleService: ) if mode_preview: movie_mode = "观影模式" in utterance - mode_name = "观影模式" if movie_mode else "宝宝睡眠模式" + mode_name = ( + str(draft_reference.get("name")) + if isinstance(draft_reference, dict) + else "观影模式" + if movie_mode + else "宝宝睡眠模式" + ) instruction = ( "遮阳帘关闭,车窗关闭,副驾座椅调整为零重力模式,灯光全部关闭" if movie_mode @@ -297,6 +306,17 @@ class FakeVehicleService: "vehicle_id": "denza_n7", "domains": domains, "operation_count": len(actions), + **( + { + "generation_source": "draft_revision", + "draft_revision": 2, + "revises_source_request_id": draft_reference[ + "source_request_id" + ], + } + if isinstance(draft_reference, dict) + else {} + ), }, "intent_ir": {"spec": {"actions": actions}}, } @@ -690,6 +710,105 @@ class TestCodexAssistantWebApi: } ] + def test_assistant_forwards_only_the_opaque_reference_for_pending_draft_update( + self, + ) -> None: + reference = { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + } + events = self.post_assistant( + { + "message": "把小憩模式的空调温度调整到25度", + "conversation_id": "conv-api-update-pending-mode", + "execution_mode": "hardware", + "mode_profile_draft_context": reference, + } + ) + + assert not FAKE_CODEX.turn_calls + call = AssistantApiHandler.service.calls[-1] + assert call["utterance"] == "把小憩模式的空调温度调整到25度" + assert call["mode_profile_draft_context"] == reference + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["payload"]["execution_mode"] == "dry_run" + assert action["payload"]["hardware_execution_allowed"] is False + assert action["payload"]["mode_profile_draft"]["draft_revision"] == 2 + assert ( + action["payload"]["mode_profile_draft"]["revises_source_request_id"] + == "req-mode-preview" + ) + assert events[0]["engine"] == "n7web-mode-builder" + assert "调整待保存" in events[-1]["answer"] + + def test_spaced_device_action_update_keeps_the_draft_route_end_to_end( + self, + ) -> None: + reference = { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + } + events = self.post_assistant( + { + "message": "请帮我 把 小憩模式 里的 遮阳帘关闭", + "conversation_id": "conv-api-spaced-mode-update", + "execution_mode": "hardware", + "mode_profile_draft_context": reference, + } + ) + + assert not FAKE_CODEX.turn_calls + call = AssistantApiHandler.service.calls[-1] + assert call["mode_profile_draft_context"] == reference + assert call["execution_mode"] == "hardware" + action = next(event for event in events if event["type"] == "action.completed") + assert action["success"] is True + assert action["payload"]["execution_mode"] == "dry_run" + assert action["payload"]["hardware_execution_allowed"] is False + assert events[0]["engine"] == "n7web-mode-builder" + + def test_active_draft_reference_is_not_forwarded_for_an_unrelated_question( + self, + ) -> None: + events = self.post_assistant( + { + "message": "解释一下空调内循环和外循环的区别", + "conversation_id": "conv-api-question-with-active-draft", + "mode_profile_draft_context": { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + } + ) + + assert events[0]["route"] == "agent" + assert events[-1]["answer"] == "这是 Codex 的测试回答。" + assert not AssistantApiHandler.service.calls + + def test_mode_question_with_control_words_never_reaches_vehicle_service( + self, + ) -> None: + events = self.post_assistant( + { + "message": "修改小憩模式的空调设置会怎样?", + "conversation_id": "conv-api-mode-question-no-control", + "execution_mode": "hardware", + "mode_profile_draft_context": { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + } + ) + + assert events[0]["route"] == "agent" + assert events[-1]["answer"] == "这是 Codex 的测试回答。" + assert not AssistantApiHandler.service.calls + def test_exact_movie_mode_request_streams_a_nine_action_preview(self) -> None: events = self.post_assistant( { diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 676e350..88087dd 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -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-22", timeout=3 + f"{self.base_url}/assistant-chat.js?v=codex-assistant-23", timeout=3 ) as response: assistant_script = response.read().decode("utf-8") with self.opener.open( @@ -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-22"', html) - self.assertIn('src="/assistant-chat.js?v=codex-assistant-22"', html) + self.assertIn('href="/assistant-chat.css?v=codex-assistant-23"', html) + self.assertIn('src="/assistant-chat.js?v=codex-assistant-23"', 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,11 +992,11 @@ 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-v271"', service_worker) + self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v272"', service_worker) self.assertIn('/console-v2.css?v=delivery-state-65', service_worker) self.assertIn('/console-v2.js?v=delivery-state-62', service_worker) - self.assertIn('/assistant-chat.css?v=codex-assistant-22', service_worker) - self.assertIn('/assistant-chat.js?v=codex-assistant-22', service_worker) + self.assertIn('/assistant-chat.css?v=codex-assistant-23', service_worker) + self.assertIn('/assistant-chat.js?v=codex-assistant-23', 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-127"', service_worker) diff --git a/tests/integration/test_multidomain_service.py b/tests/integration/test_multidomain_service.py index ea15289..0943d04 100755 --- a/tests/integration/test_multidomain_service.py +++ b/tests/integration/test_multidomain_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import tempfile import unittest +from shutil import copytree from pathlib import Path from unittest.mock import MagicMock, patch @@ -96,6 +97,9 @@ class MultiDomainServiceTests(unittest.TestCase): self.temporary_directory = tempfile.TemporaryDirectory() self.addCleanup(self.temporary_directory.cleanup) self.service.artifact_root = Path(self.temporary_directory.name) + isolated_modes = Path(self.temporary_directory.name) / "modes" + copytree(REPOSITORY_ROOT / "config" / "modes", isolated_modes) + self.service.mode_profiles = ModeProfileRegistry(isolated_modes) def test_driver_massage_mode_and_strength_continuations_compile_locally( self, diff --git a/tests/integration/test_named_mode_inference_regression.py b/tests/integration/test_named_mode_inference_regression.py index 1ed46b4..c02cc32 100644 --- a/tests/integration/test_named_mode_inference_regression.py +++ b/tests/integration/test_named_mode_inference_regression.py @@ -9,8 +9,13 @@ from unittest.mock import MagicMock, patch import pytest from cognitive_agent.application.codex_assistant import CodexAssistantService +from cognitive_agent.application.mode_profile_intent import ( + is_mode_profile_query, + parse_mode_profile_update, +) from cognitive_agent.application.assistant_capabilities import AssistantCapabilityCatalog from cognitive_agent.application.mode_profile import ModeProfileRegistry +from cognitive_agent.application.native_can_runtime import NativeCanCompileError from cognitive_agent.application.seat_service import ( REPOSITORY_ROOT, SeatDemoService, @@ -130,6 +135,68 @@ def _intent_actions(result: dict[str, object]) -> list[dict[str, object]]: return actions +def _draft_reference(result: dict[str, object]) -> dict[str, str]: + draft = result["mode_profile_draft"] + assert isinstance(draft, dict) + return { + "name": str(draft["name"]), + "source_request_id": str(draft["source_request_id"]), + "vehicle_id": str(draft["vehicle_id"]), + } + + +def test_every_repository_mode_can_build_a_name_only_draft_without_exception( + vehicle_service: SeatDemoService, +) -> None: + modes = vehicle_service.mode_profiles.public_modes("denza_n7") + assert modes + + for index, mode in enumerate(modes): + labels = [str(mode["name"]), *map(str, mode.get("aliases", []))] + label = next((item for item in labels if item.endswith("模式")), None) + assert label is not None, mode["id"] + result = vehicle_service.preview_native_can( + f"构建一个{label}", + provider="local", + conversation_id=f"conv-name-only-mode-{index:02d}", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + + assert result["status"] == "succeeded", mode["id"] + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["mode_profile_draft"]["operation_count"] > 0 + + +def test_name_only_mode_native_compile_exception_returns_scrubbed_failure( + vehicle_service: SeatDemoService, +) -> None: + with patch.object( + vehicle_service.native_can_runtime, + "compile", + side_effect=NativeCanCompileError("synthetic compiler rejection"), + ): + result = vehicle_service.preview_native_can( + "构建一个办公模式", + provider="local", + conversation_id="conv-name-only-native-compile-failure", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + + assert result["status"] == "validation_failed" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["orchestration_route"] == "mode_profile_create_preview" + assert result.get("mode_profile_draft") is None + assert result["function_configs"] == {} + assert result["command_plans"] == {} + assert result["mode_creation_failure"]["code"] == ( + "MODE_PROFILE_NATIVE_COMPILE_FAILED" + ) + + def test_name_only_nap_request_reuses_the_verified_repository_profile( vehicle_service: SeatDemoService, tmp_path: Path, @@ -202,6 +269,537 @@ def test_inferred_nap_actions_are_bounded_by_the_runtime_capability_catalog( assert all("media" not in str(action["capability"]) for action in actions) +def test_pending_nap_draft_temperature_can_be_revised_without_saving_or_execution( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-nap-pending-draft-revision" + original_profile = vehicle_service.mode_profiles.get_profile("nap", "denza_n7") + original_revision = vehicle_service.mode_profiles.profile_revision(original_profile) + built = _build_nap_mode(vehicle_service, conversation_id) + built_draft = built["mode_profile_draft"] + assert isinstance(built_draft, dict) + + revised = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + draft = revised["mode_profile_draft"] + assert revised["status"] == "succeeded" + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + assert revised["orchestration_route"] == "mode_profile_create_preview" + assert draft["name"] == "小憩模式" + assert draft["operation_count"] == built_draft["operation_count"] == 9 + assert draft["generation_source"] == "draft_revision" + assert draft["draft_revision"] == 2 + assert draft["revises_source_request_id"] == built_draft["source_request_id"] + assert draft["source_request_id"] != built_draft["source_request_id"] + assert draft["existing_mode_id"] == "nap" + assert draft["existing_mode_revision"] == original_revision + revised_actions = _intent_actions(revised) + temperatures = [ + action + for action in revised_actions + if action["capability"] == "climate.set_temperature" + ] + assert len(temperatures) == 1 + assert temperatures[0]["parameters"]["temperature_c"] == 25 + assert ( + temperatures[0]["target"].get("zone") + or temperatures[0]["target"].get("instance") + ) == "vehicle" + def sibling_signature(action: dict[str, object]) -> str: + parameters = dict(action.get("parameters") or {}) + if parameters.get("position_pct") is not None: + parameters.pop("angle_deg", None) + return json.dumps( + { + "domain": action.get("domain"), + "capability": action.get("capability"), + "target": action.get("target"), + "parameters": parameters, + }, + ensure_ascii=False, + sort_keys=True, + ) + + assert { + sibling_signature(action) + for action in revised_actions + if action["capability"] != "climate.set_temperature" + } == { + sibling_signature(action) + for action in _intent_actions(built) + if action["capability"] != "climate.set_temperature" + } + # Generating and revising a card is configuration-only. The registered + # profile remains unchanged until the card's explicit save/update action. + unchanged = vehicle_service.mode_profiles.get_profile("nap", "denza_n7") + assert vehicle_service.mode_profiles.profile_revision(unchanged) == original_revision + assert next( + operation.parameters["temperature_c"] + for operation in unchanged.operations + if operation.capability == "climate.set_temperature" + ) != 25 + + +@pytest.mark.parametrize( + ("message", "capability", "parameter", "expected"), + ( + ( + "请帮我 把 小憩模式 的 空调温度调整到25度", + "climate.set_temperature", + "temperature_c", + 25, + ), + ( + "把小憩模式中的空调温度调整到25度", + "climate.set_temperature", + "temperature_c", + 25, + ), + ( + "把小憩模式里空调温度设置为25度", + "climate.set_temperature", + "temperature_c", + 25, + ), + ( + "小憩模式,空调温度调到25度。", + "climate.set_temperature", + "temperature_c", + 25, + ), + ( + "把小憩模式的遮阳帘关闭", + "roof.set_shade_position", + "position_pct", + 0, + ), + ( + "把小憩模式里的氛围灯打开", + "ambient_light.set_power", + "state", + "on", + ), + ), +) +def test_pending_draft_update_variants_never_enter_physical_execution( + vehicle_service: SeatDemoService, + message: str, + capability: str, + parameter: str, + expected: object, +) -> None: + conversation_id = "conv-nap-draft-variant-" + str(abs(hash(message)))[:12] + built = _build_nap_mode(vehicle_service, conversation_id) + + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("pending draft update reached hardware lease"), + ): + revised = vehicle_service.preview_native_can( + message, + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + assert revised["status"] == "succeeded" + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + assert revised["orchestration_route"] == "mode_profile_create_preview" + assert next( + action["parameters"][parameter] + for action in _intent_actions(revised) + if action["capability"] == capability + ) == expected + + +def test_pending_draft_can_be_revised_repeatedly_from_latest_server_artifact( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-nap-pending-draft-chain" + built = _build_nap_mode(vehicle_service, conversation_id) + second = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + third = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到26度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(second), + ) + + second_draft = second["mode_profile_draft"] + third_draft = third["mode_profile_draft"] + assert third_draft["draft_revision"] == 3 + assert third_draft["revises_source_request_id"] == second_draft["source_request_id"] + assert next( + action["parameters"]["temperature_c"] + for action in _intent_actions(third) + if action["capability"] == "climate.set_temperature" + ) == 26 + + +def test_pending_draft_reference_is_bound_to_conversation_name_and_vehicle( + vehicle_service: SeatDemoService, +) -> None: + built = _build_nap_mode(vehicle_service, "conv-nap-reference-owner") + reference = _draft_reference(built) + + wrong_conversation = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度", + provider="local", + conversation_id="conv-nap-reference-attacker", + vehicle_id="denza_n7", + mode_profile_draft_context=reference, + ) + assert wrong_conversation["status"] == "needs_confirmation" + assert wrong_conversation.get("mode_profile_draft") is None + assert any( + issue.get("code") == "MODE_PROFILE_DRAFT_UPDATE_REJECTED" + for issue in wrong_conversation["validation"] + ) + + for invalid_reference in ( + {**reference, "vehicle_id": "another_vehicle"}, + {**reference, "source_request_id": "req-forged-preview"}, + ): + rejected = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度", + provider="local", + conversation_id="conv-nap-reference-owner", + vehicle_id="denza_n7", + mode_profile_draft_context=invalid_reference, + ) + assert rejected["status"] == "needs_confirmation" + assert rejected.get("mode_profile_draft") is None + assert rejected["hardware_execution_allowed"] is False + + +@pytest.mark.parametrize( + "message", + ( + "把小憩模式的空调介绍一下", + "小憩模式的空调有什么作用", + "小憩模式的空调和普通空调有什么区别", + "小憩模式的空调设置有哪些?", + "小憩模式的空调该调几度", + "把小憩模式的空调调到25度合适吗", + "调整小憩模式的空调参数,列出来", + ), +) +def test_air_conditioner_name_alone_is_not_parsed_as_mode_profile_update( + message: str, +) -> None: + assert SeatDemoService._parse_mode_profile_update(message) is None + + +@pytest.mark.parametrize( + "message", + ( + "空调出风模式设置为吹面和吹脚", + "把空调出风模式设置为吹面和吹脚", + "调整空调出风模式为吹面和吹脚", + "主驾座椅按摩模式设置为放松", + "副驾座椅零重力模式设为开启", + "进入动态摇篮模式,空调24度,打开遮阳帘", + "开启女王副驾模式,天幕透光率调到20%", + ), +) +def test_device_property_mode_is_not_a_named_mode_profile_update( + message: str, +) -> None: + assert SeatDemoService._parse_mode_profile_update(message) is None + + +@pytest.mark.parametrize( + ("message", "expected"), + ( + ("把小憩模式的空调温度调整到25度", ("小憩模式", "空调温度调整到25度")), + ("午休模式里的遮阳帘关闭", ("午休模式", "遮阳帘关闭")), + ("修改露营模式,天幕透光率改到50%", ("露营模式", "天幕透光率改到50%")), + ), +) +def test_named_mode_profile_update_requires_an_explicit_member_boundary( + message: str, + expected: tuple[str, str], +) -> None: + assert SeatDemoService._parse_mode_profile_update(message) == expected + + +def test_name_first_comma_update_requires_the_active_draft_name() -> None: + message = "小憩模式,空调温度调到25度。" + + assert SeatDemoService._parse_mode_profile_update(message) is None + assert parse_mode_profile_update( + message, + expected_mode_name="小憩模式", + ) == ("小憩模式", "空调温度调到25度") + assert parse_mode_profile_update( + message, + expected_mode_name="露营模式", + ) is None + + +def test_name_first_comma_question_is_not_a_draft_update() -> None: + message = "小憩模式,空调温度调到25度合适吗?" + + assert parse_mode_profile_update( + message, + expected_mode_name="小憩模式", + ) is None + assert is_mode_profile_query( + message, + expected_mode_name="小憩模式", + ) + + +@pytest.mark.parametrize( + "message", + ( + "修改小憩模式的空调设置是什么", + "小憩模式的空调该调几度", + "把小憩模式的空调调到25度合适吗", + "调整小憩模式的空调参数,列出来", + "小憩模式的空调调到25度会怎样", + ), +) +def test_mode_profile_query_is_fail_closed_even_on_direct_hardware_service_call( + vehicle_service: SeatDemoService, + message: str, +) -> None: + with patch.object( + vehicle_service.hardware_execution, + "reserve", + side_effect=AssertionError("mode-profile query reached hardware lease"), + ): + result = vehicle_service.preview_native_can( + message, + provider="local", + conversation_id="conv-mode-profile-query-guard", + execution_mode="hardware", + vehicle_id="denza_n7", + ) + + assert result["status"] == "needs_confirmation" + assert result["execution_mode"] == "dry_run" + assert result["hardware_execution_allowed"] is False + assert result["orchestration_route"] == "mode_profile_query" + assert result["function_configs"] == {} + assert result["command_plans"] == {} + + +def test_pending_draft_rejects_unsafe_change_without_returning_a_partial_card( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-nap-pending-invalid-change" + built = _build_nap_mode(vehicle_service, conversation_id) + rejected = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到99度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + assert rejected["status"] == "needs_confirmation" + assert rejected["execution_mode"] == "dry_run" + assert rejected["hardware_execution_allowed"] is False + assert rejected.get("mode_profile_draft") is None + assert rejected["function_configs"] == {} + assert rejected["command_plans"] == {} + + +def test_pending_draft_revision_fails_closed_if_full_plan_loses_any_action( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-nap-pending-incomplete-revision" + built = _build_nap_mode(vehicle_service, conversation_id) + + with patch.object( + vehicle_service, + "_scene_action_signatures", + return_value={}, + ): + rejected = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + assert rejected["status"] != "succeeded" + assert rejected.get("mode_profile_draft") is None + assert not _intent_actions(rejected) + assert rejected["function_configs"] == {} + assert rejected["command_plans"] == {} + assert any( + issue.get("code") == "MODE_PROFILE_SCENE_PLAN_INCOMPLETE" + for issue in rejected["validation"] + ) + + +def test_pending_draft_update_rejects_partially_understood_change_atomically( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-nap-pending-partial-change" + built = _build_nap_mode(vehicle_service, conversation_id) + rejected = vehicle_service.preview_native_can( + "把小憩模式的空调温度调整到25度,同时启动曲速引擎", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + assert rejected["status"] == "needs_confirmation" + assert rejected.get("mode_profile_draft") is None + assert rejected["function_configs"] == {} + assert rejected["command_plans"] == {} + assert "未完整识别" in rejected["mode_creation_failure"]["reason"] + + +def test_revised_unknown_mode_draft_can_be_trialed_and_saved_with_all_actions( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-custom-pending-save-after-revision" + built = vehicle_service.preview_native_can( + "构建一个午休模式", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + assistant_plan=LUNCH_BREAK_SCENE_PLAN, + ) + revised = vehicle_service.preview_native_can( + "午休模式的空调温度改为25度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + draft = revised["mode_profile_draft"] + + trial = vehicle_service.trial_mode_profile_draft( + str(draft["name"]), + str(draft["instruction"]), + str(draft["source_request_id"]), + str(draft["vehicle_id"]), + ) + saved = vehicle_service.create_mode_profile_card( + str(draft["name"]), + str(draft["instruction"]), + str(draft["source_request_id"]), + str(draft["vehicle_id"]), + ) + + assert trial["status"] == "succeeded" + assert trial["mode_profile_draft"]["draft_revision"] == 2 + assert len(_intent_actions(trial)) == draft["operation_count"] == 5 + assert saved["mode_profile"]["operation_count"] == 5 + profile = vehicle_service.mode_profiles.get_profile( + str(saved["mode_profile"]["id"]), + "denza_n7", + ) + assert next( + operation.parameters["temperature_c"] + for operation in profile.operations + if operation.capability == "climate.set_temperature" + ) == 25 + + +def test_explicit_mode_with_close_motions_can_still_receive_a_temperature_revision( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-explicit-motion-draft-revision" + built = vehicle_service.preview_native_can( + "创建一个躺平测试模式,副驾座椅靠背向后移动到最大位置," + "遮阳帘关闭,车窗关闭,灯光全部关闭,空调调整到24度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + ) + assert built["status"] == "succeeded" + assert built["mode_profile_draft"]["operation_count"] == 7 + + revised = vehicle_service.preview_native_can( + "把躺平测试模式的空调温度调整到25度", + provider="local", + conversation_id=conversation_id, + execution_mode="hardware", + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + + actions = _intent_actions(revised) + assert revised["status"] == "succeeded" + assert revised["execution_mode"] == "dry_run" + assert revised["hardware_execution_allowed"] is False + assert revised["mode_profile_draft"]["operation_count"] == len(actions) == 7 + assert next( + action["parameters"]["temperature_c"] + for action in actions + if action["capability"] == "climate.set_temperature" + ) == 25 + capabilities = {str(action["capability"]) for action in actions} + assert "roof.set_shade_position" in capabilities + assert "window.set_position" in capabilities + assert "roof.set_shade_motion" not in capabilities + assert "window.set_motion" not in capabilities + + +def test_unscoped_change_rejects_a_draft_with_multiple_targets_for_same_capability( + vehicle_service: SeatDemoService, +) -> None: + conversation_id = "conv-multi-zone-pending-draft" + multi_zone_plan = { + **LUNCH_BREAK_SCENE_PLAN, + "detected_domains": ["climate"], + "requests": [ + _scene_request( + "climate", "climate.set_temperature", "driver", {"temperature_c": 23} + ), + _scene_request( + "climate", "climate.set_temperature", "passenger", {"temperature_c": 24} + ), + ], + } + built = vehicle_service.preview_native_can( + "构建一个双区舒适模式", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + assistant_plan=multi_zone_plan, + ) + assert built["status"] == "succeeded" + + rejected = vehicle_service.preview_native_can( + "把双区舒适模式的空调温度调整到25度", + provider="local", + conversation_id=conversation_id, + vehicle_id="denza_n7", + mode_profile_draft_context=_draft_reference(built), + ) + assert rejected["status"] == "needs_confirmation" + assert rejected.get("mode_profile_draft") is None + assert "多个目标" in rejected["mode_creation_failure"]["reason"] + + def test_codex_composes_an_unregistered_name_only_scene_from_runtime_capabilities( vehicle_service: SeatDemoService, tmp_path: Path, diff --git a/tests/unit/assistant_stream_runtime.test.cjs b/tests/unit/assistant_stream_runtime.test.cjs index 68718e2..a4e88fc 100644 --- a/tests/unit/assistant_stream_runtime.test.cjs +++ b/tests/unit/assistant_stream_runtime.test.cjs @@ -72,6 +72,11 @@ class MockElement extends MockEventTarget { this.value = ""; } + get className() { return [...this.classList.values].join(" "); } + set className(value) { + this.classList.values = new Set(String(value).split(/\s+/).filter(Boolean)); + } + append(...nodes) { nodes.forEach((node) => { this.children.push(node); @@ -79,8 +84,31 @@ class MockElement extends MockEventTarget { }); } + closest(selector) { + if (selector.startsWith("#") && this.id === selector.slice(1)) return this; + return this.parentElement?.closest?.(selector) || null; + } focus() {} - querySelector() { return null; } + matches(selector) { + if (selector === "button") return this.tagName === "BUTTON"; + if (selector.startsWith(".")) return this.classList.contains(selector.slice(1)); + const dataSelector = selector.match(/^\[data-([a-z-]+)(?:="([^"]*)")?\]$/); + if (!dataSelector) return false; + const key = dataSelector[1].replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + if (!(key in this.dataset)) return false; + return dataSelector[2] === undefined || this.dataset[key] === dataSelector[2]; + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } + querySelectorAll(selector) { + const matches = []; + const visit = (node) => { + if (!(node instanceof MockElement)) return; + if (node.matches(selector)) matches.push(node); + node.children.forEach(visit); + }; + this.children.forEach(visit); + return matches; + } replaceChildren(...nodes) { this.children = []; this.append(...nodes); @@ -133,7 +161,9 @@ function createHarness(events) { document.createTextNode = (text) => ({ nodeType: 3, textContent: String(text) }); document.querySelector = (selector) => selectors.get(selector) || null; document.querySelectorAll = (selector) => ( - selector === "#assistantMessages [data-message-role]" ? [...messages.children] : [] + selector === "#assistantMessages [data-message-role]" + ? [...messages.children] + : messages.querySelectorAll(selector) ); const storage = new Map(); @@ -181,6 +211,15 @@ function createHarness(events) { sandbox.CognitiveAgentNetwork = { async request(url, options) { requestCalls.push({ url, options }); + if (url === "/api/mode-profiles" || url.startsWith("/api/mode-profiles/")) { + return { + ok: true, + status: 200, + async json() { + return { mode_profile: { id: "mode-saved", name: "午休模式" } }; + }, + }; + } return ndjsonResponse(events); }, }; @@ -234,3 +273,177 @@ test("a completed NDJSON answer remains the authoritative visible response", asy assert.equal(harness.completed.length, 1); assert.equal(harness.completed[0].error, null); }); + +function modeDraftEvents(sourceRequestId, temperatureC) { + return [ + { type: "message.accepted", route: "vehicle_control", engine: "n7web-mode-builder" }, + { + type: "action.completed", + success: true, + payload: { + status: "succeeded", + execution_mode: "dry_run", + orchestration_route: "mode_profile_create_preview", + mode_profile_draft: { + name: "午休模式", + instruction: `空调调整到${temperatureC}度`, + operation_count: 1, + source_request_id: sourceRequestId, + vehicle_id: "demo-vehicle", + }, + actions: [{ + domain: "climate", + capability: "climate.set_temperature", + target: { zone: "all" }, + parameters: { temperature_c: temperatureC }, + }], + }, + }, + { type: "answer.completed", route: "vehicle_control", answer: "模式草稿已生成" }, + ]; +} + +test("a follow-up turn carries only the active authoritative draft reference", async () => { + const events = modeDraftEvents("req-draft-one", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "已收到调整请求" }, + ); + harness.input.value = "把午休模式的空调温度调整到25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const request = JSON.parse(harness.requestCalls[1].options.body); + assert.equal(request.mode_profile_draft_context.name, "午休模式"); + assert.equal(request.mode_profile_draft_context.source_request_id, "req-draft-one"); + assert.equal(request.mode_profile_draft_context.vehicle_id, "demo-vehicle"); + assert.equal(Object.hasOwn(request.mode_profile_draft_context, "actions"), false); +}); + +test("ordinary questions and updates for another mode do not carry the draft reference", async () => { + const events = modeDraftEvents("req-draft-scoped", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "普通回答" }, + ); + harness.input.value = "空调制冷原理是什么"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + harness.input.value = "把露营模式的空调温度调整到25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const ordinary = JSON.parse(harness.requestCalls[1].options.body); + const anotherMode = JSON.parse(harness.requestCalls[2].options.body); + assert.equal(Object.hasOwn(ordinary, "mode_profile_draft_context"), false); + assert.equal(Object.hasOwn(anotherMode, "mode_profile_draft_context"), false); +}); + +test("device action predicates carry context while mode questions stay ordinary", async () => { + const events = modeDraftEvents("req-draft-actions", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "已处理" }, + ); + harness.input.value = "请帮我把午休模式里的遮阳帘关闭"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + harness.input.value = "把午休模式的车窗全关"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + harness.input.value = "午休模式的空调设置有哪些?"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const deviceAction = JSON.parse(harness.requestCalls[1].options.body); + const secondDeviceAction = JSON.parse(harness.requestCalls[2].options.body); + const ordinaryQuestion = JSON.parse(harness.requestCalls[3].options.body); + assert.equal(deviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions"); + assert.equal(secondDeviceAction.mode_profile_draft_context.source_request_id, "req-draft-actions"); + assert.equal(Object.hasOwn(ordinaryQuestion, "mode_profile_draft_context"), false); +}); + +test("a replacement draft disables the stale same-name card", async () => { + const events = modeDraftEvents("req-draft-old", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + events.splice(0, events.length, ...modeDraftEvents("req-draft-new", 25)); + harness.input.value = "把午休模式的空调温度调整到25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const oldCard = harness.messages.querySelector( + '[data-assistant-mode-draft="req-draft-old"]', + ); + const newCard = harness.messages.querySelector( + '[data-assistant-mode-draft="req-draft-new"]', + ); + assert.ok(oldCard); + assert.ok(newCard); + assert.equal(oldCard.classList.contains("is-superseded"), true); + oldCard.querySelectorAll("button").forEach((button) => assert.equal(button.disabled, true)); + assert.equal( + oldCard.children.at(-1).textContent, + "已由下方最新草稿替换", + ); +}); + +test("saving the replacement card uses its latest request and clears follow-up context", async () => { + const events = modeDraftEvents("req-draft-before-save", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + events.splice(0, events.length, ...modeDraftEvents("req-draft-after-edit", 25)); + harness.input.value = "把午休模式的空调温度调整到25度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const latestCard = harness.messages.querySelector( + '[data-assistant-mode-draft="req-draft-after-edit"]', + ); + latestCard.querySelectorAll("button")[0].dispatchEvent({ type: "click" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const saveCall = harness.requestCalls.find((call) => call.url === "/api/mode-profiles"); + assert.ok(saveCall); + assert.equal(JSON.parse(saveCall.options.body).source_request_id, "req-draft-after-edit"); + + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "已保存后的回答" }, + ); + harness.input.value = "把午休模式的空调温度调整到26度"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + const finalStreamCall = harness.requestCalls.filter( + (call) => call.url === "/api/assistant/stream", + ).at(-1); + assert.equal( + Object.hasOwn(JSON.parse(finalStreamCall.options.body), "mode_profile_draft_context"), + false, + ); +}); + +test("starting a new conversation clears the active draft reference", async () => { + const events = modeDraftEvents("req-draft-reset", 24); + const harness = createHarness(events); + harness.input.value = "构建一个午休模式"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const reset = new MockElement("button"); + reset.id = "newConversation"; + harness.sandbox.document.dispatchEvent({ type: "click", target: reset }); + events.splice(0, events.length, + { type: "message.accepted", route: "agent", engine: "codex-app-server" }, + { type: "answer.completed", route: "agent", answer: "新的对话" }, + ); + harness.input.value = "普通问题"; + await harness.sandbox.CognitiveAgentAssistant.submit(); + + const request = JSON.parse(harness.requestCalls[1].options.body); + assert.equal(Object.hasOwn(request, "mode_profile_draft_context"), false); +}); diff --git a/tests/unit/test_assistant_frontend_contract.py b/tests/unit/test_assistant_frontend_contract.py index 6a7c5f3..d5eb664 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-22"' in html - assert 'src="/assistant-chat.js?v=codex-assistant-22"' in html + assert 'href="/assistant-chat.css?v=codex-assistant-23"' in html + assert 'src="/assistant-chat.js?v=codex-assistant-23"' 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-v271"' in service_worker - assert '"/assistant-chat.css?v=codex-assistant-22"' in service_worker - assert '"/assistant-chat.js?v=codex-assistant-22"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker + assert '"/assistant-chat.css?v=codex-assistant-23"' in service_worker + assert '"/assistant-chat.js?v=codex-assistant-23"' in service_worker def test_codex_frontend_uses_one_streaming_endpoint_and_agent_work_events(): @@ -157,6 +157,55 @@ def test_assistant_mode_creation_previews_then_saves_to_the_compact_mode_strip() assert ".assistant-mode-created" in css +def test_assistant_carries_only_the_active_mode_draft_reference_to_follow_up_turns(): + assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8") + request_body = assistant.split("function requestBody(message)", 1)[1].split( + "async function readErrorResponse", 1 + )[0] + context_flow = assistant.split("function sanitizeModeDraftContext", 1)[1].split( + "function loadStoredMessages", 1 + )[0] + + assert ( + 'const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft"' + in assistant + ) + assert "mode_profile_draft_context: modeProfileDraftContext" in request_body + assert "requestModeDraftContext(message)" in request_body + assert "name: context.name" in context_flow + assert "source_request_id: context.source_request_id" in context_flow + assert "vehicle_id: context.vehicle_id" in context_flow + assert "actions" not in context_flow + assert "rememberModeDraftContext(draft)" in assistant + assert "activeModeDraftContext = loadActiveModeDraftContext()" in assistant + assert "function isModeDraftUpdateRequest(message, context)" in assistant + assert "normalizeModeName(matched[1]) === normalizeModeName(context.name)" in assistant + + +def test_latest_same_name_mode_draft_retires_stale_card_and_lifecycle_clears_context(): + assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8") + css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8") + replace_flow = assistant.split("function supersedeModeDraftCards", 1)[1].split( + "function appendModeDraftState", 1 + )[0] + save_flow = assistant.split("async function saveModeProfileDraft", 1)[1].split( + "async function tryModeProfileDraft", 1 + )[0] + new_conversation_flow = assistant.split( + 'if (!target?.closest("#newConversation")) return;', 1 + )[1].split("}, true);", 1)[0] + + assert "card.dataset.assistantModeName !== nextName" in replace_flow + assert "card.dataset.assistantModeDraft === nextRequestId" in replace_flow + assert 'card.classList.add("is-superseded")' in replace_flow + assert "button.disabled = true" in replace_flow + assert "已由下方最新草稿替换" in replace_flow + assert "clearActiveModeDraftContext(draft.source_request_id)" in save_flow + assert "clearActiveModeDraftContext()" in new_conversation_flow + assert ".assistant-mode-draft.is-superseded" in css + assert ".assistant-mode-draft-status.superseded" in css + + def test_assistant_composer_grows_to_a_cap_then_scrolls_its_own_content(): assistant = (ROOT / "apps/web-console/assistant-chat.js").read_text(encoding="utf-8") css = (ROOT / "apps/web-console/assistant-chat.css").read_text(encoding="utf-8") diff --git a/tests/unit/test_codex_assistant_service.py b/tests/unit/test_codex_assistant_service.py index 50528c1..843f4b5 100644 --- a/tests/unit/test_codex_assistant_service.py +++ b/tests/unit/test_codex_assistant_service.py @@ -341,6 +341,7 @@ def _stream( execute_control: Callable[..., dict[str, object]] | None = None, capability_snapshot: dict[str, object] | None = None, capability_context: dict[str, object] | None = None, + mode_profile_draft_context: dict[str, object] | None = None, ) -> tuple[dict[str, object], list[dict[str, object]]]: events: list[dict[str, object]] = [] result = assistant.stream( @@ -354,6 +355,7 @@ def _stream( control_terms=CONTROL_TERMS, capability_snapshot=capability_snapshot, capability_context=capability_context, + mode_profile_draft_context=mode_profile_draft_context, ) return result, events @@ -426,6 +428,181 @@ def test_registered_name_only_mode_keeps_the_fast_local_route_without_codex() -> assert events[0]["engine"] == "n7web-mode-builder" +def test_pending_mode_draft_update_uses_fast_configuration_route_without_codex() -> None: + client = FakeCodexClient(available=False) + assistant = CodexAssistantService(client) + calls: list[str] = [] + result_payload = _sample_mode_preview_result( + "小憩模式", + generation_source="draft_revision", + existing_mode_id="nap", + ) + draft = result_payload["mode_profile_draft"] + assert isinstance(draft, dict) + draft.update( + { + "source_request_id": "req-mode-revision", + "draft_revision": 2, + "revises_source_request_id": "req-mode-preview", + } + ) + + result, events = _stream( + assistant, + "把小憩模式的空调温度调整到25度", + execute_control=lambda instruction, _progress: ( + calls.append(instruction) or result_payload + ), + mode_profile_draft_context={ + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + ) + + assert result["route"] == "vehicle_control" + assert result["success"] is True + assert calls == ["把小憩模式的空调温度调整到25度"] + assert client.turn_calls == [] + assert events[0]["engine"] == "n7web-mode-builder" + assert "调整待保存" in result["answer"] + + +def test_active_draft_context_does_not_hijack_an_ordinary_question() -> None: + client = FakeCodexClient() + assistant = CodexAssistantService(client) + + result, events = _stream( + assistant, + "解释一下空调内循环和外循环的区别", + mode_profile_draft_context={ + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + ) + + assert result == {"route": "agent", "answer": "这是 Codex 的流式回答。"} + assert client.turn_calls[0]["text"] == "解释一下空调内循环和外循环的区别" + assert events[0]["engine"] == "codex-app-server" + + +def test_malformed_stale_draft_context_is_ignored_for_an_ordinary_question() -> None: + client = FakeCodexClient() + assistant = CodexAssistantService(client) + events: list[dict[str, object]] = [] + + result = assistant.stream( + "解释一下空调内循环和外循环的区别", + conversation_id="conv-stale-draft-context", + principal="alice", + history=[], + emit=lambda event: events.append(event), + execute_control=lambda _instruction, _progress: pytest.fail( + "vehicle control was unexpected" + ), + control_terms=CONTROL_TERMS, + mode_profile_draft_context={"untrusted": "stale"}, + ) + + assert result["route"] == "agent" + assert events[0]["engine"] == "codex-app-server" + + +def test_malformed_matching_draft_reference_is_rejected_before_control() -> None: + assistant = CodexAssistantService(FakeCodexClient(available=False)) + + with pytest.raises(ValueError, match="fields are invalid"): + assistant.stream( + "把小憩模式的空调温度调整到25度", + conversation_id="conv-invalid-draft-context", + principal="alice", + history=[], + emit=lambda _event: True, + execute_control=lambda _instruction, _progress: pytest.fail( + "vehicle control was unexpected" + ), + control_terms=CONTROL_TERMS, + mode_profile_draft_context={"name": "小憩模式"}, + ) + + +def test_nonmatching_draft_context_does_not_claim_another_mode_update() -> None: + authorization = _authorize_vehicle_request( + "把观影模式的空调温度调整到25度", + CONTROL_TERMS, + { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + ) + + # The stale card reference has been ignored. The sentence is still an + # explicit registered control under the legacy saved-mode update route. + assert authorization.reason == "explicit_registered_control" + + +@pytest.mark.parametrize( + "message", + ( + "把小憩模式的空调介绍一下", + "小憩模式的空调有什么作用", + "小憩模式的空调和普通空调有什么区别", + "小憩模式的空调设置有哪些?", + "小憩模式的空调该调几度", + "把小憩模式的空调调到25度合适吗", + "调整小憩模式的空调参数,列出来", + ), +) +def test_device_word_air_conditioner_does_not_fake_a_draft_update_verb( + message: str, +) -> None: + authorization = _authorize_vehicle_request( + message, + CONTROL_TERMS, + { + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + ) + + assert authorization.reason != "explicit_mode_profile_draft_update" + + +@pytest.mark.parametrize( + "message", + ( + "修改小憩模式的空调设置是什么", + "小憩模式的空调该调几度", + "把小憩模式的空调调到25度合适吗", + "调整小憩模式的空调参数,列出来", + "小憩模式的空调调到25度会怎样", + ), +) +def test_mode_profile_question_with_control_words_never_calls_vehicle_executor( + message: str, +) -> None: + assistant = CodexAssistantService(FakeCodexClient()) + + result, events = _stream( + assistant, + message, + execute_control=lambda _instruction, _progress: pytest.fail( + "mode-profile question reached vehicle executor" + ), + mode_profile_draft_context={ + "name": "小憩模式", + "source_request_id": "req-mode-preview", + "vehicle_id": "denza_n7", + }, + ) + + assert result["route"] == "agent" + assert events[0]["engine"] == "codex-app-server" + + def test_unknown_name_only_mode_uses_codex_and_overrides_model_execution_claim() -> None: client = FakeCodexClient() assistant = CodexAssistantService(client) diff --git a/tests/unit/test_energy_monitor_frontend_contract.py b/tests/unit/test_energy_monitor_frontend_contract.py index 2cdce26..65ceb11 100644 --- a/tests/unit/test_energy_monitor_frontend_contract.py +++ b/tests/unit/test_energy_monitor_frontend_contract.py @@ -36,10 +36,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-22") < html.index( + assert html.index("/assistant-chat.js?v=codex-assistant-23") < html.index( "/energy-monitor.js?v=energy-monitor-4" ) - assert 'const CACHE_VERSION = "cognitive-agent-pad-v271"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' 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 ed431c0..c85e46f 100644 --- a/tests/unit/test_frontend_theme_contract.py +++ b/tests/unit/test_frontend_theme_contract.py @@ -48,7 +48,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-v271"' in service_worker + assert 'const CACHE_VERSION = "cognitive-agent-pad-v272"' in service_worker def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None: